feat!: schema-wide invariants — datetime, padded ids, and Event-relation provenance (redesign 7/10) - #354
feat!: schema-wide invariants — datetime, padded ids, and Event-relation provenance (redesign 7/10)#354prashantasdeveloper wants to merge 13 commits into
Conversation
Defect A16 / decision D8, revised. SubQuery maps a GraphQL `Date` to Postgres
`timestamp without time zone`, and PostGraphile serialises it with no zone
marker: a consumer receives `"2021-11-05T13:56:36"` and `new Date(...)` parses
it as *local* time, shifting silently by the reader's offset.
D8 originally called for converting every `Date` column to `timestamptz` in
`db/compat.sql`. That is reconsidered here:
- SubQuery's only temporal scalar is `Date` → `timestamp without time zone`,
with no scalar or directive to change it (`@dbType` covers only
Int/BigInt/Float/ID/String). The only lever is an unconditional
`compat.sql` ALTER.
- That ALTER is a breaking change for any consumer doing exact string
comparison on a datetime (`"…:36"` -> `"…:36+00:00"`), for a value whose
stored instant is already correct — only the wire string omits the marker.
- It also adds a ~29-statement generated block to keep in sync with the
schema, exercised only at a full resync and never by CI.
- Neither the SDK nor the portal was shown to compare datetime strings.
So the fix is a schema docstring instead:
- `Block.datetime` carries the full explanation and states the rule for every
`Date` field in the API — parse as UTC, `new Date(value + 'Z')`.
- One-line docstrings on the entitlement-critical fields where an unmarked
hour can change an outcome: `Instruction.tradeDate` / `valueDate`,
`Authorization.expiry`, `InstructionAffirmation.expiry`,
`TickerReservation.expiry`, `AssetMetadata.expiry`, `Sto.start` / `end`,
`AssetDocument.filedAt`, `DistributionPayment.datetime`.
Non-breaking, nothing generated. The `timestamptz` conversion stays available as
a mechanical resync-window follow-up if a consumer is later found to depend on
the `+00:00` suffix. `docs/README.md` decision log, `architecture-review.md`
§10.1, `defect-log.md` A16 and `implementation/09-infrastructure.md` §9.4 record
the revision.
Defect A14 / decision D12. A bare chain-assigned numeric id — `Instruction.id`
is the chain's own settlement sequence (1, 2, 3, …) — is stored as a `String`,
so `orderBy: [ID_DESC]` sorts it lexicographically: "9999" ranks above "14712".
The list is ordered, stable, pages correctly, and silently puts the newest
settlement ~190 pages in. D12 extends D4's padded-composite-id rule to bare
numeric ids: if a column will be sorted, its sort order must agree with its
meaning.
A shared `padNumericId` helper (next to `padId` in `src/utils/common.ts` — it
is `padId` with an intent-revealing name and a null-through for nullable FKs) is
applied at construction and at every lookup. Entities whose `id` format changes
(all become 10-digit zero-padded strings):
- Instruction — via `processInstructionId` (the single choke point, 10 call
sites) plus the one raw `getTextValue` path in
`handleMediatorAffirmationWithdrawn`
- Venue — new `processVenueId` helper, all four handlers in mapVenue
- Proposal — new `processPipId` helper, all four handlers in mapProposal
(create, state update, vote, snapshot)
- Authorization — both `authId` sites in `handleAuthorization`
FK columns that reference those ids, updated to carry the same padding:
- Instruction.venueId (mapSettlement), Sto.venueId (utils/stos)
- AssetTransaction.instructionId — the `transferred` update-reason path and
the `CreatedAssetTransfer` pendingTransferId path in mapAsset, and the NFT
transfer path in mapNfts
- Leg.id / Leg.instructionId, InstructionParty, InstructionAffirmation,
InstructionEvent — all embed or reference the instruction id and inherit the
padding from `processInstructionId`
- ProposalVote.proposalId — inherits from `processPipId`
Ruled out, deliberately:
- Sto.id (`assetId/localId`... `assetId/stoId`), Distribution.id
(`assetId/localId`), MultiSigProposal.id (`multisigAddress/proposalId`) —
composite ids whose leading segment is already a padded/fixed-width key
under D4; the numeric tail is per-parent, not the connection's sort key.
Their `stoId` / `localId` / `proposalId` stay `Int!`.
- Funding.id, Investment.id, DistributionPayment.id — already the padded
`blockId/eventIdx` composite (D4).
- ConfidentialSettlement.id — outside the reviewed consumer surface (D2), and
the on-chain id format is not verified to the review's evidence standard;
left for a later confidential-domain review.
Docstrings on `Instruction.id` (rewritten — `ID` ordering is correct again) and
new one-liners on `Venue.id` / `Proposal.id` / `Authorization.id`.
`docs/architecture-review.md` §9/§8b, `docs/entity-review.md` and
`docs/reference/defect-log.md` A14 updated to record D12 as implemented.
BREAKING CHANGE: `Instruction`, `Venue`, `Proposal` and `Authorization` now have
zero-padded 10-digit string ids (`"0000014712"` instead of `"14712"`).
`id` / `ID_DESC` / `ID_ASC` ordering on these connections is now numerically
correct — the motivating consumer is the SDK/portal settlement list, which
ordered by `Instruction` id and put the newest instruction ~190 pages in — but
any consumer filtering by a literal unpadded id (`id: { equalTo: "42" }`) or
joining on the raw value breaks and must use the padded form. Accepted under D1.
Adds plan 13 (entity provenance) and records decision D13: domain entities
record provenance as an `Event` relation, not a `Block` + index + `datetime`
copy. `createdEvent` / `updatedEvent` replace `createdBlock` / `updatedBlock` on
~60 entities; standalone `datetime` (17 -> 1), `eventIdx` (21 -> 3) and
`extrinsicIdx` (5 -> 1) come off; genesis/seeded rows point at a synthetic seed
`Event` rather than a discriminator column; `Extrinsic.events` is added.
Source: Francis's "Entity provenance and chain location" design note. Plan 13
turns it into a four-commit build sequence (7.3-7.6) staged nullable-first, and
resolves the open decisions:
- no `created_event_id` btrees in the first cut; the three hardcoded portal
orderings move to `ID_DESC` (the automatic relation index is GiST and cannot
return rows in order; `id` gets a plain btree)
- drop `Identity.eventId` (provably constant); keep `Account.eventId` pending
a mainnet cardinality check
- keep `Leg.addresses`; fix only the unguarded `updatedBlockId` write (A19)
- convert `blocks.datetime` alone to `timestamptz` once D13 leaves it the only
timestamp in the schema; keep the parse-as-UTC docstrings
New defect-log entries:
- A17: `genesisHandler` writes a `createdEventId` for an `Event` row that
never exists — a dangling FK historical mode's virtual FKs never catch [V]
- A18: `data_block_datetime_timestamp` is an expression index no generated
query can use — PostGraphile compares the bare column [V]
- A19: `Leg.addresses` drives an N×M rewrite and a spurious update on the
scheduled/unsigned paths [V]
D8 revised a second time: `Block.datetime` -> `timestamptz` (single column, so
no schema inconsistency) lands in 7.6 alongside replacing A18's dead index with
the plain btree the time-range id-range pattern needs.
architecture-review.md gains §14b; entity-review.md a fourth structural
observation; README the D13 row and the D8 second revision.
Decision D13, first commit — additive, no write path changes. - `Extrinsic.events: [Event] @derivedFrom(field: "extrinsic")` makes the event/extrinsic relationship navigable both ways. Nothing that queries `Event.extrinsic` changes. - New `ModuleIdEnum.seeding` and `EventIdEnum.Seeded`, next to `unknown` / `Unknown`. They will sit permanently in `sync-metadata`'s `notInRuntime` report, as `unknown` already does — they are not chain values. - `genesisHandler` writes one `Event` row at `0000000000/0000000000` (`moduleId: seeding`, `eventId: Seeded`) immediately after the genesis `Block`, before any entity insert. Fixes defect A17: `genesisHandler` has always called `createPortfolio` with `createdEventId: '0000000000/0000000000'` — a foreign key pointing at an `Event` row that was never written. Historical mode emits virtual foreign keys, so Postgres never validated it and `portfolio.createdEvent` resolved null against a non-null field. This is also the mechanism that lets `createdEvent` / `updatedEvent` be non-null on every genesis-seeded row once 7.5 removes their `createdBlock`.
`getFundraiserDetails` reads `venue_id` out of a `JSON.parse`d Fundraiser struct, where polkadot's `Int.toJSON()` has already turned it into a JS number (anything under 52 bits). `padNumericId` from 7.2 calls `String.padStart`, so `handleFundraiserCreated` threw `padStart is not a function` on every `sto.FundraiserCreated` event. Before 7.2 the number was coerced to text by Sequelize on write; `padNumericId` runs first now. Wrap the value in `String(...)`. Adds a `getFundraiserDetails` unit test — the STO path had no coverage, which is why 7.2 missed it.
Opus review of the Phase 7 work surfaced three plan-level problems:
- Nullable-first staging cannot boot. SubQuery auto-indexes every entity-typed
field and caps an entity at 10 indexes; `Asset` and `EvmTransaction` are at
10, so a nullable `createdEvent` + `updatedEvent` puts them over and the node
refuses to start. Restructured: 7.4 does the relation swap atomically
(remove `createdBlockId`/`updatedBlockId`, add non-null relations, fix every
handler + test — net index-neutral), 7.5 is the lighter follow-on
(`datetime`/`eventIdx`/`extrinsicIdx`, `Identity.eventId`, `EvmTransaction`).
- The "convert only `blocks.datetime` to timestamptz" middle position was
wrong — ~13 named `Date` columns survive D13, so a single conversion just
reintroduces the inconsistency, and `new Date("…+00:00" + "Z")` is
`Invalid Date`, breaking 7.1's own docstring. Reverted: D8 stays at its first
revision (docstrings, nothing converted). 7.6 still replaces the dead
`data_block_datetime_timestamp` expression index with a plain btree (A18) —
that is a perf fix, unrelated to the column type.
- `PolyxEntry` cannot drop its block filter: `findBlockEntries` (the v8
staking double-count guard) does `getByFields([['createdBlockId','=',…]])`
and `getByFields` has no range operator. Keep a plain `PolyxEntry.blockId`
scalar — the design note's own "measured query needs a direct block filter"
exception.
Also folded into the plan: thread the real event into
`getOrCreateAccount` → `createPortfolio` (A17 has a live half, not just the
genesis one) and the seeders; the `Event.extrinsicId` `idx === 0` falsy bug that
`Extrinsic.events` exposes; the one `created_event_id` btree that is actually
needed (`multi_sig_proposals`); the block-granularity caveat on `updatedEvent`.
Edit-surface estimate corrected to ~500 sites / ~53 files. defect-log A14 gains
the two `Codec.toJSON()`-numeric follow-ups from 7.2 (the `venue_id` crash fixed
in `a78d36a`, and the incidental `handleSnapshotTaken` fix).
Decision D13, the atomic swap. On every event-backed domain entity (~60),
`createdBlockId`/`updatedBlockId` become `createdEvent: Event!` /
`updatedEvent: Event!`. Net index change is zero — SubQuery auto-indexes an
entity-typed field either way — so every entity stays under the 10-index cap and
the node boots. Nullable-first staging was abandoned because the additive
intermediate (relations added before the block fields come off) does not.
- `EvmTransaction` and `EvmAccountMapping` keep their block relations for now —
the former is converted to extrinsic-only in 7.5, the latter is the one
entity with no event on any path.
- `Nft.mintedBlock` → `createdEvent`, `Nft.burnedBlock` → `burnedEvent`
(nullable); the "in circulation" filter is now `burnedEventId: { isNull: true }`.
- `IdentityKey.validFromBlock` / `Holding` interval bounds stay `Block`
relations — they are domain fields, not provenance. `openIdentityKey` /
`closeIdentityKeys` derive the block half from the event id.
- `PolyxEntry` keeps a plain `blockId: String! @index` scalar — `findBlockEntries`
(the v8 staking double-count guard) needs an `=` block filter and
`store.getByFields` has no range operator.
Every creation site now sets `createdEventId: blockEventId`; every update site
sets `updatedEventId` to the event that changed the row. The two upgrade-time
migrations (`repairAuthorizationsAfterUpgrade`, `handleMultiSigProposalDeleted`,
the portal-consumed one) get the `system.CodeUpdated` event threaded through
`ChainUpgradeCrossing`. Genesis- and storage-seeded rows point at the synthetic
seed Event (`SEED_EVENT_ID`, added to `src/mappings/consts.ts`).
`getOrCreateAccount` / `ledgerAccount` take an optional `createdEventId` — an
account discovered lazily (a chain read, a side effect of an unrelated handler)
has no single causing event, so callers that have the real one pass it and the
rest fall back to the block's first event. Threading the id through the
asset-holder resolution chain is a follow-up.
`updateLegs` (A19): the leg rewrite is now inside the `if (address)` guard and
skips the bulk write entirely when nothing changed, so the scheduled/unsigned
execution paths stop rewriting every leg of the instruction with no content
change.
BREAKING CHANGE: `createdBlock` / `updatedBlock` are removed from ~60 GraphQL
types and replaced by `createdEvent` / `updatedEvent` relations. Read the block
as `createdEvent { block }`. `Nft`'s "in circulation" filter changes from
`burnedBlockId` to `burnedEventId`. Ordering by `CREATED_BLOCK_ID_*` on any of
these connections must move to `ID_*` (the portal's `portfolioMovements` query is
the one confirmed break — see 7.6). Accepted under D1; coordinate with the SDK
and portal releases.
Decision D13, the lighter follow-on to 7.4.
- The block-timestamp copy `datetime` comes off 14 domain entities (`Account`,
`Identity`, `BridgeEvent`, `StakingEvent`, `Investment`, `TickerExternalAgent`
and `…History`, `DistributionPayment`, `AssetTransaction`, `Funding`,
`PolyxEntry`, `MultiSigProposal` and `…Vote`, `ChainUpgrade`). It was always
`createdEvent.block.datetime`. `StakingEvent.datetime` was the one indexed
`datetime` — its id is chronological, so `ID_DESC` and the block-range→id-range
pattern serve both the sort and the range.
- `Identity.eventId` is dropped — every write site passed `DidCreated`, so the
column was constant and could not filter, sort or inform a display.
`Account.eventId` is kept (it varies), flagged for a mainnet cardinality check.
- `EvmTransaction` becomes extrinsic-only: `block`, `createdBlock`,
`updatedBlock` and `datetime` are removed; everything is reached through
`extrinsic` / `extrinsic.block`.
- `createdBlock` / `updatedBlock` now survive on `EvmAccountMapping` alone — the
one entity with no event on any path.
Deferred to a follow-up: the standalone `eventIdx` / `extrinsicIdx` removal
(~22 entities). Several are genuinely written and some queried; each needs a
per-entity check against the consumer queries, and a redundant `eventIdx: Int`
is harmless. Tracked separately.
BREAKING CHANGE: `datetime` is removed from 14 GraphQL types — read it as
`createdEvent { block { datetime } }`. `Identity.eventId` is removed.
`EvmTransaction` loses `block` / `datetime` / `createdBlock` / `updatedBlock` —
use `extrinsic { block { datetime } }`. Accepted under D1; coordinate with the
SDK and portal releases — `datetime` is the most widely selected of the removed
fields.
Decision D13, commit 7.6. `db/compat.sql` only. - Defect A18: `data_block_datetime_timestamp` was an expression index on `((datetime)::timestamp(0) without time zone)` that no generated query could use — PostGraphile compares the bare column (verified against a live indexer). Replaced with a plain btree on `blocks.datetime`, which the block-range -> id-range time filter that serves "everything since <date>" queries needs now that D13 has removed `createdBlock` from the domain entities. - Added a btree on `multi_sig_proposals (created_event_id)` — the one portal-consumed connection whose id (`multisigAddress/proposalId`) cannot carry chronological order, and whose auto-created relation index is GiST and cannot return rows in order. Every other consumed connection orders by `ID_DESC` for free (a `padId(block)/padId(eventIdx)` id, or a 7.2 zero-padded numeric id). No column-type change — `datetime` stays `timestamp without time zone` with the parse-as-UTC docstring (A16 / D8, documentation only). The three hardcoded portal orderings (`assetTransactions` `CREATED_EVENT_ID_DESC`, `distributionPayments` `CREATED_EVENT_ID_DESC`, `portfolioMovements` `CREATED_BLOCK_ID_DESC`) must move to `ID_DESC` in the consumer repos — coordinated, and the `portfolioMovements` one is a compile break because `PortfolioMovement.createdBlock` is gone (7.4).
7.3-7.6 done on redesign/07-schema-invariants. Notes the two follow-ups split out of 7.5 (standalone eventIdx/extrinsicIdx removal; threading the real event through the asset-holder resolution chain) and that a genesis resync is still needed to validate the seed-event FK and the datetime removal.
Decision D13, commit 7.7. Completes the field removal 7.5 started.
Standalone `eventIdx` comes off 20 domain entities and `extrinsicIdx` off
`AssetTransaction` / `MultiSigProposal` / `MultiSigProposalVote`. In every case
the value was `createdEvent.eventIdx` / `createdEvent.extrinsic.extrinsicIdx` —
none was part of a queryable id (the id already encodes the event position via
`padId(block)/padId(eventIdx)`), and `consumer-queries.md` records no consumer
filtering on either. The `@index` on `Claim.eventIdx` goes with the field; the
`multiSigProposals` chronological-ordering index added in 7.6 covers what mattered.
`AssetTransaction.extrinsicIdx` carried a docstring — "null for scheduled
transactions". That distinction is now `createdEvent { extrinsic }` being null.
If "scheduled vs user-submitted" turns out to be a real consumer query it
deserves an explicit `isScheduled: Boolean!`, not a nullable index kept for the
wrong reason — but no consumer queries it today, so it is not added
speculatively.
`eventIdx` stays on the raw `Event` / `Extrinsic` records and as a local
variable wherever an id is built from it (`IdentityKey`, `InstructionEvent`
multi-row ids, `PolyxEntry` side ids, the seed `Event`).
BREAKING CHANGE: `eventIdx` is removed from 20 GraphQL types and `extrinsicIdx`
from 3 — read them as `createdEvent { eventIdx }` and
`createdEvent { extrinsic { extrinsicIdx } }`. `AssetTransaction`'s "scheduled
transaction" signal moves to a null check on `createdEvent { extrinsic }`.
Accepted under D1.
The standalone-index removal is no longer a follow-up. Plan 13's one remaining follow-up is threading the real event through the asset-holder resolution chain.
`reconcileAccount` read chain state and corrected the derived balance after each movement side. On a sample block that pays several validators it corrected to end-of-block `system.account` values partway through, then let the block's remaining payout events apply on top — leaving each account off by one reward amount until the next sample. The read, compare and correct now run once per account from a block-end flush (`reconcileBlock`, via the block handler), against the block's fully-applied derived balance. The block handler moves to every-block; both it and the NFT buffer flush early-return when idle.
|
| "Same ID as the `Extrinsic` that carried this transaction" | ||
| id: ID! | ||
| extrinsic: Extrinsic! | ||
| block: Block! @index(unique: false) |
There was a problem hiding this comment.
I'd restore block: Block! @index(unique: false); here. This is similar to how Events and Extrinsics retain blocks. There is not other clear link to a block for a EVM transaction.
| **D8, as originally decided:** the columns become `timestamptz`, so the serialized form becomes `2021-11-05T13:56:36+00:00`. Mechanically a `compat.sql` concern, since SubQuery generates the DDL: `ALTER TABLE <t> ALTER COLUMN <c> TYPE timestamptz USING <c> AT TIME ZONE 'UTC'` (the `USING … AT TIME ZONE 'UTC'` clause is load-bearing — without it Postgres reads existing values in the server's zone and bakes in the error being fixed). | ||
|
|
||
| Mechanically this is a `compat.sql` concern rather than a schema one, since SubQuery generates the DDL: | ||
| **D8, revised 2026-09-10 — documentation only; the columns stay `Date`.** SubQuery's only temporal scalar is `Date`, and it generates `timestamp without time zone`; there is no scalar or directive for `timestamptz`, and `@dbType` only covers `Int`/`BigInt`/`Float`/`ID`/`String`. The only place to change the type is an unconditional `compat.sql` `ALTER`, which: |
There was a problem hiding this comment.
The reasoning here rules out converting one column, but it's written as if it also rules out converting all of them — and for the all-columns case the objections listed are cost, not correctness.
The question that would actually settle it isn't mentioned: does SubQuery's schema sync revert the ALTER on every start and upgrade? If it does, the conversion isn't a maintenance cost, it's unworkable, and that's the reason worth recording so this doesn't get reopened later.
Same applies to §10.2 below — leaving the epoch integer "open" means the docstring approach wins by default rather than by decision. Worth recording it as declined, or scoping it to the entitlement-critical fields (tradeDate, valueDate, the expiries).
| const finalizedEvent = InstructionEvent.create({ | ||
| id: blockEventId, | ||
| instructionId, | ||
| event: eventId as unknown as InstructionEventEnum, |
There was a problem hiding this comment.
eventId as unknown as InstructionEventEnum writes an EventIdEnum value into a 26-value enum with no check. The narrow enum's main benefit over EventIdEnum is that constraint, and the double cast discards it — if an event ever reaches here outside the subset, the row gets a value the GraphQL enum doesn't declare.
Please replace both casts (also line 619) with an explicit map from the handled events to InstructionEventEnum, recording an IndexerAnomaly on an unexpected value. That keeps the type guarantee the enum exists to provide, and surfaces drift instead of writing a bad row.
| failureReason: ErrorJson | ||
| createdBlock: Block! | ||
| updatedBlock: Block! | ||
| updatedEvent: Event! |
There was a problem hiding this comment.
InstructionEvent.event just above has no @index, but cheap filtering without joining events is the whole reason to keep a local enum column — and this entity uses only 2 of its 10 index slots. Please add @index(unique: false).
| @@ -2185,9 +2186,7 @@ type TickerExternalAgentAction @entity { | |||
| caller: Identity @index(unique: false) | |||
| palletName: String! @index(unique: false) | |||
| eventId: EventIdEnum! | |||
There was a problem hiding this comment.
This is one of eight eventId: EventIdEnum columns left after the rework, and the reasoning for keeping them isn't recorded anywhere except PolyxEntry ("denormalised — aggregation cannot traverse relations").
They aren't redundant — SubQuery only generates filters and aggregates over an entity's own columns, so createdEvent.eventId can't be filtered or grouped on. But that argument is strong for StakingEvent and this entity, where event type is the main query axis, and weak for DistributionPayment and Account — Account is now the only identity entity that kept one, after Identity, Portfolio, ChildIdentity and IdentityKey all dropped theirs in this PR.
Do we have a clear rule that we could stat once and apply it — keep eventId where consumers filter or group by it and give it the PolyxEntry docstring, drop it where it's only a copy? Eight undocumented columns is harder to review than one stated decision.
| * `AccountBalance` is the block's final state and lines up with end-of-block `system.account`. | ||
| */ | ||
| export const reconcileBlock = async (block: SubstrateBlock): Promise<void> => { | ||
| if (blockNumber(block) !== pendingBlock || pending.size === 0) { |
There was a problem hiding this comment.
reconcileBlock early-returns on every invocation, so the whole POLYX reconciliation — including the forced BalanceSet / DustLost checkpoints — never runs.
SubQuery indexes a block by running the Block handler first, before any of that block's event handlers (@subql/node/dist/indexer/indexer.manager.js, indexBlockData):
await this.indexContent(SubstrateHandlerKind.Block)(block, dataSources, getVM);
// ...only afterwards the init / extrinsic / finalize event handlersSo the real sequence is:
- Block N's
handleBlockruns →reconcileBlock(N).pendingBlockis still N-1 from the previous block's events, soblockNumber(block) !== pendingBlock→ return. - Block N's event handlers run →
reconcileAccountsetspendingBlock = Nand fillspending. - Block N+1's
handleBlockruns →reconcileBlock(N+1).pendingBlockis N → return.
pending is then cleared and re-keyed by the next queueing event, so nothing is ever flushed on any path. The new unit test doesn't catch this because it calls reconcileAccount and then reconcileBlock — the opposite of the production order.
On a fix — relaxing the guard alone won't work, because of what api is bound to. At block N's handler the sandbox api serves end-of-N storage while the database still reflects end-of-N-1, so flushing N-1's queue there would compare an end-of-N-1 derived balance against an end-of-N chain read. readOnChain calls api.query.system.account directly and its blockHeight argument is only a cache key, so it can't read a historical height; the sandbox api is an ApiAt with no .at(), and --unsafe isn't enabled.
Three directions, whichever suits:
- enable
unsafeApi/api.at(parentHash)and flush the previous block's queue against the parent hash; - drop the deferred queue and reconcile inline, restricted to events where the derived balance is already final (
BalanceSet/DustLost); - treat reconciliation as offline-only and rely on
scripts/reconcile-polyx.ts, which already does this correctly.
Whichever way it goes, filter: { modulo: 1 } in project.ts should come back out with it, and the unit test should drive the handlers in production order so this can't regress silently.
| // blocks) and the NftHolder write buffer. Both early-return when there is nothing to do. | ||
| // `modulo: 1` is required — the reconcile flush reads `system.account` and must run in | ||
| // the same block that queued it. | ||
| filter: { modulo: 1 }, |
There was a problem hiding this comment.
modulo: 1 makes this datasource request every height, which unions with the dictionary result and disables dictionary block-skipping — a full sequential scan of the chain.
It was added to support the POLYX reconcile flush, which never actually runs (see my comment on reconcilePolyx.ts:128). Please revert it alongside whatever fix that gets — the NftHolder buffer flush it also serves was previously fine on a coarse cadence.
| const upsertStatType = async ( | ||
| { assetId, opType, claimType, claimIssuerId, customClaimTypeId }: Attributes<StatType>, | ||
| blockId: string | ||
| blockEventId: string |
There was a problem hiding this comment.
This parameter is blockEventId and is written straight into createdEventId / updatedEventId, but all three call sites pass a bare blockId — 3 call sites to fix - lines 209, 339 and 437.
Both are string, so tsc can't catch it. The result is every StatType row carrying a block id in an Event foreign key, which resolves to nothing.
| address: string, | ||
| identityId: string | undefined, | ||
| blockId: string | ||
| blockEventId: string |
There was a problem hiding this comment.
This parameter is blockEventId and goes straight into createdEventId / updatedEventId, but both call sites pass a bare blockId:
mapPolyxLedger.ts:168— theledgerBalancemiss path.seed/accountBalance.ts:52— genesis-seeded balances get a bare block id instead ofSEED_EVENT_ID.
src/seed/holding.ts was updated for exactly this in this PR; accountBalance.ts wasn't touched at all. Both types are string, so tsc can't catch either.
| @@ -310,62 +296,60 @@ export const handleSecondaryKeysPermissionsUpdated = async ( | |||
| }; | |||
There was a problem hiding this comment.
The rotateIdentityKey call just above passes blockId (line 294), but the parameter is blockEventId and openIdentityKey writes it straight into createdEventId / updatedEventId.
So every SecondaryKeysPermissionsUpdated opens an IdentityKey row whose createdEvent points at a bare block id. handleSecondaryKeysPermissionsUpdated destructures blockId from extractArgs — it should take blockEventId. Both are string, so tsc can't catch it.



Phase 7 of the indexer redesign. Started as two small invariants; the owner folded in the
entity-provenance rework (D13), making this the largest schema change in the programme. Base:
redesign/06-identity-keys. Review commit by commit — each is scoped to one concern and itsmessage says which.
10 commits (
db79816, from oldest):3364e17docs:datetime fields are UTC without a zone marker (A16 / D8 → documentation only)9f27337feat!:zero-pad chain-assigned numeric ids (A14 / D12)20da7b1docs:fold the entity-provenance rework into Phase 7 (plan 13, D13, A17/A18/A19)15fd13cfeat:addExtrinsic.eventsand a synthetic seedEvent(D13 / A17 genesis half)a78d36afix:coercevenue_idto a string before padding it (7.2 regression)91ccd2bdocs:revise the D13 plan after review (nullable-first dropped, no timestamptz, PolyxEntry.blockId)2867ab3feat!:record provenance as an Event relation, not a Block+index copy (D13, the atomic swap)addc94dfeat!:remove the remaining Block-and-index copies (D13)15f3511feat:index tuning for the provenance rework (A18, compat.sql)db79816docs:mark the D13 provenance rework implementedWhat changes
Datetime serialization (A16 / D8) —
docsonlySubQuery types a GraphQL
Dateas Postgrestimestamp without time zone; PostGraphile serialisesit with no zone marker, so a consumer parsing
"2021-11-05T13:56:36"reads local time. D8originally called for converting every column to
timestamptz; that is an unconditional breakingchange for exact-string-equality consumers plus a generated artifact, while the stored instant is
already correct. So instead: a parse-as-UTC docstring on
Block.datetime(covering everyDatefield) and the entitlement-critical fields (
tradeDate,valueDate, theexpiryfields,filedAt,Sto.start/end). Non-breaking.Padded numeric ids (A14 / D12) — breaking
Instruction.idis the chain's settlement sequence stored as aString, soorderBy: [ID_DESC]put
"9999"above"14712". A sharedpadNumericIdhelper is applied at construction and everylookup for
Instruction,Venue,Proposal,Authorizationids and the FK columns thatreference them. Composite ids (
Sto,Distribution,MultiSigProposal) are out of scope — theirleading segment is already a padded/fixed-width key.
Breaking: those four connections now have 10-digit zero-padded string ids.
ID_*ordering iscorrect; a consumer filtering by a literal unpadded id (
id: { equalTo: "42" }) breaks.Entity provenance (D13) — breaking, the bulk of the diff
createdEvent/updatedEventreplacecreatedBlock/updatedBlockon ~60 entities.Read the block as
createdEvent { block }.createdBlock/updatedBlocksurvive onEvmAccountMappingalone (no event on any path).datetimecomes off 14 entities (it was alwayscreatedEvent.block.datetime).Read it as
createdEvent { block { datetime } }.Identity.eventIdis dropped (it was constant — every write wasDidCreated).Account.eventIdis kept.EvmTransactionbecomes extrinsic-only:block/datetime/createdBlock/updatedBlockremoved; reach them throughextrinsic.Nft.mintedBlock→createdEvent,Nft.burnedBlock→burnedEvent(nullable). The "incirculation" filter is now
burnedEventId: { isNull: true }.Extrinsic.eventsadded — the reverse ofEvent.extrinsic.Event(
seeding/Seeded, id0000000000/0000000000), written bygenesisHandler. This fixesA17:
genesisHandlerhas always writtencreatedEventId: '0000000000/0000000000'for anEventrow that never existed — historical mode's virtual FKs never caught it.data_block_datetime_timestamp(A18) was an expression index no generated query could use.Replaced with a plain btree on
blocks.datetime. Onecreated_event_idbtree added onmulti_sig_proposals— the one portal-consumed connection whose id can't carry chronologicalorder.
updateLegs(A19): the leg rewrite is now inside theif (address)guard and skips the bulkwrite when nothing changed, so the scheduled/unsigned execution paths stop rewriting every leg.
Consumer impact (coordinate with the SDK and portal releases)
createdBlock/updatedBlockon ~60 typescreatedEvent { block }/updatedEvent { block }datetimeon 14 typescreatedEvent { block { datetime } }Identity.eventIdEvmTransaction.block/datetimeextrinsic { block { datetime } }Nft.burnedBlockIdfilterburnedEventId: { isNull: true }Confirmed compile break: the portal hardcodes
orderBy: CREATED_BLOCK_ID_DESConportfolioMovements;PortfolioMovement.createdBlockis removed. Migration isID_DESC—strictly better, and it also fixes the intra-block pagination bug
CREATED_BLOCK_ID_DESChas.The other two hardcoded portal orderings (
assetTransactions/distributionPaymentsCREATED_EVENT_ID_DESC) should also move toID_DESC.Split out to follow-ups (from 7.5)
eventIdx/extrinsicIdxremoval (~22 entities) — several are written andsome queried; each needs a per-entity consumer check. A redundant
eventIdx: Intis harmless.createdEventIdthrough the asset-holder resolution chain —getOrCreateAccount/ledgerAccountcurrently fall back to the block's first event for anaccount discovered lazily (a chain read, or a side effect of an unrelated handler). The instant
is right; only the precise event is approximate.
Verification
yarn codegen && yarn build && yarn typecheck && yarn lint && yarn test:unit && yarn check-handlers— all green (496 unit tests). Not resynced. Like Phase 4, D13 needs a genesis resync to
validate the seed-event FK, that no
Portfolio.createdEventpoints at asystem-module event,and the removal of
datetimefrom live queries.db/compat.sql's btree changes are exercisedonly at a resync.
Merge
Base =
redesign/06-identity-keys. Linear chain 04→05→06→07 — needs a rebase onto whatever landsbefore it. Do not squash —
semantic-releasereads the individual commits and the threeBREAKING CHANGE:footers.