Skip to content

feat!: schema-wide invariants — datetime, padded ids, and Event-relation provenance (redesign 7/10) - #354

Open
prashantasdeveloper wants to merge 13 commits into
redesign/06-identity-keysfrom
redesign/07-schema-invariants
Open

prashantasdeveloper wants to merge 13 commits into
redesign/06-identity-keysfrom
redesign/07-schema-invariants

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

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 its
message says which.

10 commits (db79816, from oldest):

commit
3364e17 docs: datetime fields are UTC without a zone marker (A16 / D8 → documentation only)
9f27337 feat!: zero-pad chain-assigned numeric ids (A14 / D12)
20da7b1 docs: fold the entity-provenance rework into Phase 7 (plan 13, D13, A17/A18/A19)
15fd13c feat: add Extrinsic.events and a synthetic seed Event (D13 / A17 genesis half)
a78d36a fix: coerce venue_id to a string before padding it (7.2 regression)
91ccd2b docs: revise the D13 plan after review (nullable-first dropped, no timestamptz, PolyxEntry.blockId)
2867ab3 feat!: record provenance as an Event relation, not a Block+index copy (D13, the atomic swap)
addc94d feat!: remove the remaining Block-and-index copies (D13)
15f3511 feat: index tuning for the provenance rework (A18, compat.sql)
db79816 docs: mark the D13 provenance rework implemented

What changes

Datetime serialization (A16 / D8) — docs only

SubQuery types a GraphQL Date as Postgres timestamp without time zone; PostGraphile serialises
it with no zone marker, so a consumer parsing "2021-11-05T13:56:36" reads local time. D8
originally called for converting every column to timestamptz; that is an unconditional breaking
change 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 every Date
field) and the entitlement-critical fields (tradeDate, valueDate, the expiry fields,
filedAt, Sto.start/end). Non-breaking.

Padded numeric ids (A14 / D12) — breaking

Instruction.id is the chain's settlement sequence stored as a String, so orderBy: [ID_DESC]
put "9999" above "14712". A shared padNumericId helper is applied at construction and every
lookup for Instruction, Venue, Proposal, Authorization ids and the FK columns that
reference them. Composite ids (Sto, Distribution, MultiSigProposal) are out of scope — their
leading segment is already a padded/fixed-width key.

Breaking: those four connections now have 10-digit zero-padded string ids. ID_* ordering is
correct; a consumer filtering by a literal unpadded id (id: { equalTo: "42" }) breaks.

Entity provenance (D13) — breaking, the bulk of the diff

A domain entity records provenance as a relation to the Event that caused it, and carries no
field derivable from that relation.

  • createdEvent / updatedEvent replace createdBlock / updatedBlock on ~60 entities.
    Read the block as createdEvent { block }. createdBlock / updatedBlock survive on
    EvmAccountMapping alone (no event on any path).
  • Standalone datetime comes off 14 entities (it was always createdEvent.block.datetime).
    Read it as createdEvent { block { datetime } }.
  • Identity.eventId is dropped (it was constant — every write was DidCreated).
    Account.eventId is kept.
  • EvmTransaction becomes extrinsic-only: block / datetime / createdBlock /
    updatedBlock removed; reach them through extrinsic.
  • Nft.mintedBlock → createdEvent, Nft.burnedBlock → burnedEvent (nullable). The "in
    circulation" filter is now burnedEventId: { isNull: true }.
  • Extrinsic.events added — the reverse of Event.extrinsic.
  • Genesis- and storage-seeded rows point at one synthetic seed Event
    (seeding / Seeded, id 0000000000/0000000000), written by genesisHandler. This fixes
    A17
    : genesisHandler has always written createdEventId: '0000000000/0000000000' for an
    Event row 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. One created_event_id btree added on
    multi_sig_proposals — the one portal-consumed connection whose id can't carry chronological
    order.

updateLegs (A19): the leg rewrite is now inside the if (address) guard and skips the bulk
write when nothing changed, so the scheduled/unsigned execution paths stop rewriting every leg.

Consumer impact (coordinate with the SDK and portal releases)

Removed selectable field Read it as
createdBlock / updatedBlock on ~60 types createdEvent { block } / updatedEvent { block }
datetime on 14 types createdEvent { block { datetime } }
Identity.eventId — (was constant)
EvmTransaction.block / datetime extrinsic { block { datetime } }
Nft.burnedBlockId filter burnedEventId: { isNull: true }

Confirmed compile break: the portal hardcodes orderBy: CREATED_BLOCK_ID_DESC on
portfolioMovements; PortfolioMovement.createdBlock is removed. Migration is ID_DESC —
strictly better, and it also fixes the intra-block pagination bug CREATED_BLOCK_ID_DESC has.
The other two hardcoded portal orderings (assetTransactions / distributionPayments
CREATED_EVENT_ID_DESC) should also move to ID_DESC.

Split out to follow-ups (from 7.5)

  • The standalone eventIdx / extrinsicIdx removal (~22 entities) — several are written and
    some queried; each needs a per-entity consumer check. A redundant eventIdx: Int is harmless.
  • Threading the real createdEventId through the asset-holder resolution chain —
    getOrCreateAccount / ledgerAccount currently fall back to the block's first event for an
    account 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.createdEvent points at a system-module event,
and the removal of datetime from live queries. db/compat.sql's btree changes are exercised
only at a resync.

Merge

Base = redesign/06-identity-keys. Linear chain 04→05→06→07 — needs a rebase onto whatever lands
before it. Do not squash — semantic-release reads the individual commits and the three
BREAKING CHANGE: footers.

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.
@sonarqubecloud

Copy link
Copy Markdown

@prashantasdeveloper
prashantasdeveloper marked this pull request as ready for review September 11, 2026 12:30
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 11, 2026 12:30
@prashantasdeveloper
prashantasdeveloper added this pull request to stack #358 September 15, 2026 08:23
Comment thread schema.graphql
"Same ID as the `Extrinsic` that carried this transaction"
id: ID!
extrinsic: Extrinsic!
block: Block! @index(unique: false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread schema.graphql
failureReason: ErrorJson
createdBlock: Block!
updatedBlock: Block!
updatedEvent: Event!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread schema.graphql
@@ -2185,9 +2186,7 @@ type TickerExternalAgentAction @entity {
caller: Identity @index(unique: false)
palletName: String! @index(unique: false)
eventId: EventIdEnum!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 handlers

So the real sequence is:

  1. Block N's handleBlock runs → reconcileBlock(N). pendingBlock is still N-1 from the previous block's events, so blockNumber(block) !== pendingBlock → return.
  2. Block N's event handlers run → reconcileAccount sets pendingBlock = N and fills pending.
  3. Block N+1's handleBlock runs → reconcileBlock(N+1). pendingBlock is 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.

Comment thread project.ts
// 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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parameter is blockEventId and goes straight into createdEventId / updatedEventId, but both call sites pass a bare blockId:

  • mapPolyxLedger.ts:168 — the ledgerBalance miss path.
  • seed/accountBalance.ts:52 — genesis-seeded balances get a bare block id instead of SEED_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 (
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants