indexPolicy — Per-Half GSI Membership Rules
1. The problem this solves
Section titled “1. The problem this solves”A DynamoDB item is one row. In real systems, multiple writers may touch a single item for different reasons — a telemetry pipeline writing per-event clock and alert state, an enrichment job writing the owning account, a stamp writer marking a side-channel published timestamp. Each writer touches a different subset of attributes, and each one issues a partial Entity.update payload.
GSI keys are derived from those attributes — a partition key like gsi1pk is composed from one or more composite attributes on the model. A writer that doesn’t supply some composites shouldn’t have to either read the item first or silently break the index. indexPolicy is how you tell the library what to do, per half, when an update doesn’t have everything needed to recompose a GSI key.
2. Mental model — three units, three contracts
Section titled “2. Mental model — three units, three contracts”Three units to keep distinct:
- Composite attribute — a model field declared as part of a GSI’s key (e.g.
accountId,alertState). - GSI key attribute — the physical DynamoDB attribute the library writes (
gsi1pk,gsi1sk). Each is composed from one or more composite attributes. - GSI — the queryable index. An item appears in GSI query results if and only if both
gsiNpkandgsiNskare present (DDB projection rule — invisible items are still in the table, just not in the GSI).
Three contracts, all per-half:
preserveis a contract with other writers (“don’t disturb my key when you fire”);sparseis a contract with yourself as the half’s owner (“drop my key if I touch this half but can’t compose it”);Entity.remove([attr])is the explicit signal that a composite is gone — the library REMOVEs the half(s) containing the cleared attribute.
That framing — declaration / evaluation / outcome / cascade are all per-half — is what makes the multi-writer story work. v1.7.0 had three connected bugs in how outcomes were rolled up; v1.7.1 makes the model uniformly per-half across all four pillars.
3. The two settings, in plain language
Section titled “3. The two settings, in plain language”| Setting | What it means | When to use |
|---|---|---|
'preserve' (default) | “If the update touches my half but I can’t compose it, leave the stored GSI key alone.” Stored value may go stale until the next write that can compose, or an Entity.remove cascade fires. | When the half’s composites are owned by another writer — the other writer’s key value is the source of truth, your update should pass through. |
'sparse' | ”If the update touches my half but I can’t compose it, REMOVE the stored GSI key — the item drops out of this half of the GSI.” | When this writer is the half’s sole owner and absence means “shouldn’t be indexed here.” |
Both rules fire only when the half is touched — see the per-half evaluation gate below.
4. Per-key declaration
Section titled “4. Per-key declaration”Declare an indexPolicy per GSI:
const Devices = Entity.make({ model: Device, entityType: "Device", primaryKey: { pk: { field: "pk", composite: ["channel", "deviceId"] }, sk: { field: "sk", composite: [] }, }, indexes: { // byCurrentAlert — the canonical hybrid GSI: pk is enrichment-owned // (preserve so telemetry doesn't disturb it); sk is telemetry-owned // (sparse so an event without an alert drops the half). byCurrentAlert: { name: "gsi1", pk: { field: "gsi1pk", composite: ["accountId"] }, sk: { field: "gsi1sk", composite: ["alertState", "timestamp"] }, indexPolicy: { pk: "preserve", sk: "sparse" }, }, }, timestamps: true,})Halves omitted from indexPolicy default to 'preserve'. If you don’t declare indexPolicy at all, both halves are 'preserve'.
5. The structural rule (longest valid leading prefix)
Section titled “5. The structural rule (longest valid leading prefix)”For each touched half (PK and SK independently), the library walks the composite list left-to-right and builds the longest valid leading prefix of values present in the merged record ({ ...storedKeyAttrs, ...payload }).
| Composite state for the half | Outcome |
|---|---|
| All composites present | SET the half from the full composition |
Trailing composites absent ([A, B, _, _]) | SET the half truncated to the leading prefix [A, B] |
Empty leading prefix OR hole pattern ([_, C] or [A, _, C]) | Compose failed — see “Per-half outcome” below |
Truncation on PK is the same hierarchical demotion as truncation on SK. An item with pk.composite = ['accountId', 'fleetId'] and fleetId absent composes a partition key of account#A and stays queryable at the account scope.
Per-half evaluation gate (reframed as a skip-predicate in v1.7.3)
Section titled “Per-half evaluation gate (reframed as a skip-predicate in v1.7.3)”Before the structural rule even runs, each half is asked one question: can this half be safely skipped? The gate exists for one reason — multi-writer protection: prevent writer A from clobbering keys for halves it doesn’t own when writer B does own them. The skip-predicate states that purpose directly:
A half is skipped iff multi-writer protection actually applies — composites exist (otherwise the half value is a constant prefix and there’s nothing to multi-writer-clobber), no composite was explicitly removed via
Entity.remove([...]), AND every composite is absent from BOTH the update payload AND thekeyRecord(the entity primary-key attributes carried alongside the payload). If the half cannot be safely skipped, it is evaluated — SET / REMOVE / noop per the structural rule below.
This is what makes the multi-writer story work end-to-end:
| Writer | Payload | byCurrentAlert eval |
|---|---|---|
| Stamp | { published: "2026-04-30" } | Both halves skipped (composites not in payload, not in keyRecord, not in Entity.remove, both halves have composites) → noop, both halves preserved |
| Enrichment | { accountId: "newAcct" } | pk evaluated (SET); sk skipped (noop) |
| Telemetry | { alertState: "active", timestamp: T } | pk skipped (noop); sk evaluated (SET) |
The skip-predicate has three structural conditions, each closing a different class of regression:
composites.length > 0— empty-composite halves (e.g.sk: { composite: [] }) are always evaluated. The half value is a constant prefix; multi-writer protection doesn’t apply, and skipping would leave the key permanently missing. Closes #46 — see the empty-composite half pitfall below.!hasRemoved— halves with any composite inEntity.remove([...])are always evaluated. Removal intent must run the structural rule (which may then SET-truncate, REMOVE under sparse, or REMOVE under preserve via the cascade override).every(composite NOT in updatePayload AND NOT in keyRecord)— only writers genuinely not claiming ownership of the half (composites absent from both input sources) get the skip. Closes #41 (multi-writer leak — composites in payload mean ownership) and #43 (PK-composites-only — entity-PK composites arrive viakeyRecord, never via payload, so they correctly fail this clause and are always evaluated). The resulting SET for PK-composites-only halves is idempotent because PK composites are immutable.
The skip-predicate’s negation is observably equivalent to the touched-predicate ||-chain that v1.7.0 → v1.7.2 progressively built up — same SET/REMOVE outcomes for every input. v1.7.3 reframes the gate to make its purpose (multi-writer protection) the only thing the predicate checks, so future degenerate-case shapes fall out automatically instead of requiring another || arm.
6. Per-key outcome rolls up to per-key storage
Section titled “6. Per-key outcome rolls up to per-key storage”The roll-up is per-key — each half’s outcome applies to its own key attribute only. PK dropping doesn’t drop SK; SK dropping doesn’t drop PK. The item may be invisible in the GSI during a single-half-dropped period (DDB projection rule needs both keys for query visibility), but the surviving half’s value persists. When the missing half is later composed again, the item rejoins the GSI without the other writer needing to re-fire.
| Outcome for this half’s key attribute | Conditions |
|---|---|
| noop (leave key alone) | Half is skipped by the gate (multi-writer protection applies — has composites, no removal, every composite absent from payload AND keyRecord) |
| SET full | Half evaluated, all composites composable |
| SET truncated to leading prefix | Half evaluated, leading prefix non-empty, no hole |
| REMOVE this half’s key | Half evaluated, can’t compose (empty leading prefix OR hole pattern), AND policy is 'sparse' OR a composite is in Entity.remove([...]) (cascade override under preserve) |
| noop (stored key may go stale) | Half evaluated, can’t compose, policy is 'preserve', AND no composite is in Entity.remove |
The “noop with stale risk” row is the one place sparse vs preserve actually differ at runtime. Under preserve, if you set({ X: undefined }) for an optional composite without supplying surrounding context, the GSI key isn’t updated — it retains whatever was stored last. To force a clean cleanup under preserve, use Entity.remove(["X"]): the cascade override fires and REMOVEs the half.
7. Worked examples
Section titled “7. Worked examples”7.1 Single-writer sparse — alert lifecycle
Section titled “7.1 Single-writer sparse — alert lifecycle”// Lifecycle: alertState is the only sk composite, and it's owned by a// single ingest writer. Declaring sk: sparse means "if my event doesn't// have an alertState, drop my sk key — the item shouldn't be in this GSI."yield* db.entities.Devices.put({ channel: "c-1", deviceId: "d-1", accountId: "acme", alertState: "active", timestamp: "2026-04-30T10:00:00Z",})
// "No alert this event" — alertState explicitly undefined. sk touched// (alertState is in payload), can't compose (empty leading prefix) →// sparse → REMOVE sk. pk untouched → preserved.yield* db.entities.Devices.update({ channel: "c-1", deviceId: "d-1" }).set({ alertState: undefined, timestamp: "2026-04-30T11:00:00Z",})// Item invisible in byCurrentAlert (DDB needs both keys), but gsi1pk// preserved. Re-adding alertState recomposes sk → item rejoins WITHOUT// the enrichment writer needing to re-fire.7.2 Multi-writer preserve + sparse — full lifecycle
Section titled “7.2 Multi-writer preserve + sparse — full lifecycle”// Item exists with all composites populated.yield* db.entities.Devices.put({ channel: "c-2", deviceId: "d-2", accountId: "acme", alertState: "active", timestamp: "2026-04-30T10:00:00Z",})
// Stamp writer — touches a non-composite attribute. Both halves untouched// → both preserved. (v1.7.0 would have REMOVE'd sk because sparse fired// on the untouched sk half.)yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({ published: "2026-04-30",})// Item still visible in GSI under acme + active.
// Enrichment writer rotates the account. pk touched → SET pk full// (account#newAcct). sk untouched → noop. Item re-indexes under newAcct;// stored gsi1sk unchanged.yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({ accountId: "newAcct",})
// Telemetry writer fires fresh. sk touched → SET sk full// (alert#active#ts#T2). pk untouched → noop.yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({ alertState: "active", timestamp: "2026-04-30T11:00:00Z",})
// Telemetry "no alert" event — sk touched, can't compose, sparse →// REMOVE sk. pk preserved. Item invisible in GSI but gsi1pk persists.yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({ alertState: undefined, timestamp: "2026-04-30T12:00:00Z",})
// Telemetry rejoins. sk touched, composes fine → SET sk full. pk// untouched, value still acme from way back. Item re-visible under// newAcct + new sk — WITHOUT the enrichment writer re-firing. This is// the critical multi-writer property the v1.7.1 per-key roll-up enables.yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({ alertState: "active", timestamp: "2026-04-30T13:00:00Z",})7.3 Hierarchical truncation via set+remove
Section titled “7.3 Hierarchical truncation via set+remove”// Initial state — full hierarchy populated.yield* db.entities.Assets.put({ assetId: "rack-42", region: "americas", country: "us", city: "sf", site: "datacenter-1",})// gsi1sk = "$indexpolicy-demo#v1#asset#country_us#city_sf#site_datacenter-1"
// Asset leaves the datacenter — *demote*, don't evict. Supply surviving// composites via set; invalidate the leaf via remove. Per the set/remove// asymmetry, the structural rule succeeds with the leading prefix// [country, city] → SET sk truncated. The cascade override only fires// when the rule CAN'T compose — here it composes fine.yield* db.entities.Assets.update({ assetId: "rack-42" }) .set({ country: "us", city: "sf" }) .remove(["site"])// gsi1sk = "$indexpolicy-demo#v1#asset#country_us#city_sf"// Asset still queryable at city level via begins_with.8. The two drop triggers (recap)
Section titled “8. The two drop triggers (recap)”| Trigger | Granularity | Where it lives | Intent |
|---|---|---|---|
'sparse' policy + touched-half + can’t-compose | per-half | per-GSI declaration, implicit | ”I touched this half but can’t compose it — drop me” |
Entity.remove([attr]) cascade | per-half (per attribute, fires on the half(s) containing it) | per-call, explicit | ”this composite is gone — drop the half(s) it belongs to” |
Cascade and sparse interact:
- Cascade always makes its half touched.
- For touched halves where the structural rule CAN compose (e.g.
set({ country, city }).remove(["site"])— leading prefix[country, city]is non-empty), the structural rule wins → SET truncated. - For touched halves where the structural rule CAN’T compose, the per-half outcome rule fires:
'sparse'→ REMOVE this half’s key.'preserve'+removedSet→ REMOVE this half’s key (cascade override under preserve).'preserve'+ noremovedSet→ noop (stored key retained).
9. Common pitfalls
Section titled “9. Common pitfalls”set({ X: null }) doesn’t compile for composites
Section titled “set({ X: null }) doesn’t compile for composites”Composites can never be null (EDD-9025 — see §10 below). The v1.7.0 update-payload type widening revert means the model’s declaration is the source of truth: a Schema.optional(Schema.String) composite accepts T | undefined, not T | null. set({ X: null }) on a composite is a TypeScript error.
// ❌ TypeScript error if X is a composite (model can't widen to include null,// payload doesn't widen beyond model)yield* db.entities.X.update(key).set({ X: null })
// ✅ Use undefined for an optional composite, or Entity.remove for requiredyield* db.entities.X.update(key).set({ X: undefined }) // X must be Schema.optionalyield* db.entities.X.update(key).remove(["X"]) // works for required + optionalPreserve + set({ X: undefined }) leaves stale risk
Section titled “Preserve + set({ X: undefined }) leaves stale risk”Under preserve, an set({ X: undefined }) on an optional composite touches the half but can’t compose without X. Per the per-half outcome rule, preserve + can’t-compose + no removedSet → noop. The stored gsi1sk is not updated — it retains whatever was last composed, possibly a stale value derived from the old X.
To force the cleanup, use Entity.remove(["X"]) instead. The composite is added to removedSet, the cascade override fires, and the half’s key is REMOVE’d cleanly:
// Stale risk — stored gsi1sk may reflect old Xyield* db.entities.X.update(key).set({ X: undefined })
// Force cleanup — REMOVE the half's key via cascade overrideyield* db.entities.X.update(key).remove(["X"])Entity.remove(["X"]) doesn’t truncate without supplied parents (set/remove asymmetry)
Section titled “Entity.remove(["X"]) doesn’t truncate without supplied parents (set/remove asymmetry)”
setprovides values to compose with.removeinvalidates a composite without providing a replacement. The library has no read-before-write — soremovewithout surroundingsetcontext can’t truncate (it doesn’t know what to truncate to) and instead REMOVEs the half’s key entirely.
// Truncation works — surviving composites supplied via set, leaf invalidated via removeyield* db.entities.Assets.update(key) .set({ region, country, city }) .remove(["site"])// → SET gsi1sk truncated to "region#R#country#C#city#S"
// REMOVE the whole half — surviving composites not suppliedyield* db.entities.Assets.update(key).remove(["site"])// → REMOVE gsi1sk entirely (no surviving values to compose with)If you want to demote (truncate) hierarchically, you must set the surviving composites in the same call.
Multi-writer leak (v1.7.0 footgun, fixed in v1.7.1)
Section titled “Multi-writer leak (v1.7.0 footgun, fixed in v1.7.1)”In v1.7.0, a stamp writer that touched only a non-composite attribute (e.g. set({ published: "2026-04-30" })) would still trigger sparse-policy evaluation on every GSI half declared sparse — and REMOVE the half’s key even though the stamp writer wasn’t claiming ownership of the half. This made the multi-writer pattern impractical.
v1.7.1’s per-half evaluation gate fixes this: untouched halves are skipped entirely. The stamp writer above leaves both halves alone.
byChannel GSI returns 0 items in v1.7.0 / v1.7.1 (PK-composites-only regression, fixed in v1.7.2)
Section titled “byChannel GSI returns 0 items in v1.7.0 / v1.7.1 (PK-composites-only regression, fixed in v1.7.2)”In v1.7.0 and v1.7.1, GSIs whose composites are entirely entity primary-key composites — e.g. byChannel: { pk: [channel], sk: [deviceId] } on a Telemetry entity with primaryKey: [channel, deviceId] — were silently skipped on every write. The per-half evaluation gate consulted only the update payload, but PK composites never appear there: the writer addresses the row by key, never restates them in .set({...}), and .append() separates structural fields from the SET clause. The composer ran but the gate classified both halves as untouched → gsi*pk / gsi*sk were never written → channel-scoped GSI queries returned nothing.
v1.7.2 fixes this in two places:
- The per-half evaluation gate now also counts
keyRecordmembership, so PK-composite-only halves are touched on every write that has akeyRecord. Entity.append()no longer filters PK composites out of the payload it passes to the composer (the filter never solved a real problem and combined with #1 to silently break this pattern).
Affected items: items written to PK-composites-only GSIs under v1.7.0 or v1.7.1 will repair themselves on the next Entity.update() against them. The gate now fires correctly, the structural rule composes the immutable PK values, and the missing GSI keys are SET. No data migration is needed; reads via the GSI start returning these items as their next update lands. If you have items that aren’t naturally updated, a one-shot bulk set({ otherField: ... }) or Entity.update(key).set({}) (touching no fields, just bumping updatedAt + version) is enough to repair them.
This pattern is common — tenant-scoped queries (byTenant: { pk: [tenantId] }), entity-key-projected GSIs, channel-scoped time-series. Test coverage for the policy-aware composer must always include the PK-composites-only shape; missing it (as the v1.7.1 fixture matrix did) is what produced this regression.
Empty-composite-half GSI returns 0 items in v1.7.0–v1.7.2 (fixed in v1.7.3)
Section titled “Empty-composite-half GSI returns 0 items in v1.7.0–v1.7.2 (fixed in v1.7.3)”In v1.7.0, v1.7.1, and v1.7.2, GSIs with at least one half declared composite: [] (the standard “bare entity prefix” pattern, common in single-table-design lookup GSIs) had that half silently skipped on every Entity.update(). The per-half evaluation gate used a “touched” predicate built from .some(...) clauses — and .some(...) over an empty array trivially returns false, so the gate classified empty halves as untouched and never composed them. Entity.put() worked correctly (it uses the separate composeAllKeys path), but any subsequent .update() that touched the OTHER half left the empty-composite half missing — and worse, an .update() that bound a previously-sparse GSI for the first time (e.g. setting deviceBinding on a vehicle that was created without one) wrote only the PK half, leaving the SK missing → invisible to the GSI.
// Reproducer (#46) — Vehicle entity with byDeviceBinding lookup GSI.const Vehicles = Entity.make({ model: Vehicle, entityType: "Vehicle", primaryKey: { pk: { field: "pk", composite: ["id"] }, sk: { field: "sk", composite: [] }, }, indexes: { byDeviceBinding: { name: "gsi3", pk: { field: "gsi3pk", composite: ["deviceBinding"] }, sk: { field: "gsi3sk", composite: [] }, // ← empty SK composite }, },})
// Pre-v1.7.3:yield* db.entities.Vehicles.put({ id: "veh-1" }) // sparse, no gsi3 keysyield* db.entities.Vehicles.update({ id: "veh-1" }).set({ deviceBinding: "cloud#dev-1" })// → gsi3pk SET, gsi3sk NEVER written → query returns 0 items.
// v1.7.3:// → gsi3pk SET, gsi3sk SET to constant prefix → query returns the vehicle. ✓v1.7.3 reframes the per-half gate from a touched-predicate to a skip-predicate keyed on the gate’s actual purpose (multi-writer protection). The leading composites.length > 0 short-circuit keeps empty-composite halves “always evaluated” — the value is a constant prefix, so there’s nothing for another writer to clobber, and skipping would just leave a permanently missing key.
Affected items: vehicles (or other entities with this shape) that were .update()’d under v1.7.0–v1.7.2 with a missing empty-half key will repair themselves on the next .update() against them under v1.7.3. The next write composes the missing half from the constant prefix and the item rejoins the GSI. No data migration is needed; reads via the GSI start returning these items as their next update lands. If you have items that aren’t naturally updated, a one-shot bulk set({ otherField: ... }) (touching no GSI composites, just bumping updatedAt + version) is enough to repair them.
This shape is common in single-table designs — any GSI used as a flat lookup (byOwner, byEmail, byExternalId) where the SK is just the entity prefix. Test coverage for the policy-aware composer must always include empty-composite halves; missing it (as the v1.7.2 fixture matrix did) is what produced this regression.
10. Make-time guarantees
Section titled “10. Make-time guarantees”EDD-9025 — composite attributes can’t be nullable
Section titled “EDD-9025 — composite attributes can’t be nullable”At Entity.make() time, the library walks every composite across primaryKey, every entry in indexes, and every entry in unique constraints. For each composite’s Schema, it inspects the AST and throws CompositeNullableError (EDD-9025) if null is reachable anywhere in the type union (Schema.NullOr, Schema.NullishOr, Schema.Union with a Schema.Null branch, etc.).
The semantic justification: composites participate in string composition (acc#X#alert#Y); null is not a meaningful slot value, only present-with-value or absent. Allowing Schema.NullOr on a composite would let set({ composite: null }) typecheck and then either blow up at runtime or silently produce a key with the literal string "null" as a slot.
// ❌ Throws EDD-9025 at Entity.make() time:class BadModel extends Schema.Class<BadModel>("BadModel")({ id: Schema.String, tenantId: Schema.NullOr(Schema.String), // ← composite nullable}) {}Entity.make({ model: BadModel, entityType: "BadModel", primaryKey: { pk: { field: "pk", composite: ["id"] }, sk: { field: "sk", composite: [] } }, indexes: { byTenant: { name: "gsi1", pk: { field: "gsi1pk", composite: ["tenantId"] }, sk: { field: "gsi1sk", composite: [] }, }, },})// → CompositeNullableError [EDD-9025]:// composite attribute "tenantId" on index "byTenant" resolves to a Schema// that includes `null` ... Replace the nullable wrapper with Schema.optional(...)
// ✅ Use Schema.optional(...) — produces T | undefined, no null:class OkModel extends Schema.Class<OkModel>("OkModel")({ id: Schema.String, tenantId: Schema.optional(Schema.String), // ← T | undefined; sparse pattern works}) {}Combined with the v1.7.x update-payload type widening revert, set({ composite: null }) is a TypeScript error two ways — the model can’t widen the composite to include null, and the update payload type isn’t widened beyond the model. The stale-GSI-via-set-null footgun is closed at the type level.
EDD-9024 (CompositeKeyHoleError) — deprecated in v1.7.1
Section titled “EDD-9024 (CompositeKeyHoleError) — deprecated in v1.7.1”The error class is still exported for back-compat with consumers who type-imported it for Effect.catchTag handlers, but it is no longer thrown at runtime under v1.7.1. Hole patterns now collapse into the unified per-half can’t-compose rule (drop under sparse, noop-or-cascade-override under preserve). Remove any Effect.catchTag("CompositeKeyHoleError", ...) handlers — the throw site is gone and the catch is dead code.
When is a GSI evaluated?
Section titled “When is a GSI evaluated?”Per the per-half evaluation gate, each half is skipped iff multi-writer protection actually applies — and evaluated otherwise. The skip condition is:
- the half has composites (
composites.length > 0) — empty-composite halves are always evaluated (#46), AND - no composite is in
Entity.remove([...])(cascade intent always evaluates), AND - every composite is absent from BOTH the update payload AND the
keyRecord(the entity primary-key attributes used to address the row).
Equivalently, a half is evaluated when ANY of these holds:
- the half has no composites (constant prefix — always SET), OR
- a composite is in
Entity.remove([attr]), OR - a composite is in the update payload (the writer is asserting ownership), OR
- a composite is in
keyRecord(entity-PK composites — added to the gate in v1.7.2, #43).
The presence of an indexPolicy declaration does not force evaluation of every update under v1.7.1+ (this is a behavior change from v1.7.0). The per-half gate is the same regardless of whether indexPolicy is declared.
Interaction with put()
Section titled “Interaction with put()”put() does NOT consult indexPolicy. It writes a complete item from scratch — any missing composite means “this item is not in that GSI.” Unchanged from prior versions.
Interaction with time-series .append()
Section titled “Interaction with time-series .append()”v1.7.x unifies .append() with .update() — both call the same composer with the same per-half evaluation gate. Composites outside appendInput are absent under the structural rule, AND the half is untouched per the gate (composite name is not in payload, not in keyRecord, not in removedSet). Halves whose composites are entirely outside appendInput and entirely outside the entity primary key are skipped.
For GSIs whose composites are entirely entity-PK composites (e.g. byChannel: { pk: [channel], sk: [deviceId] } on primaryKey: [channel, deviceId]), the half is always touched via keyRecord membership and the structural rule composes from the immutable PK values on every append. The resulting SET is idempotent. (v1.7.0 / v1.7.1 silently skipped these GSIs — see the byChannel pitfall above; closes #43.)
A multi-writer entity should declare its GSI shape so each half is owned by a single writer’s domain — that’s the design rule and the natural fit with per-half policy.
Multi-writer entity design rule
Section titled “Multi-writer entity design rule”Each GSI half should be entirely owned by a single writer’s domain. The library does not paper over cross-writer composite ownership — that’s a consumer-side modeling discipline.
With per-half policy, the per-half evaluation gate, and the structural composition rule, a writer that doesn’t own a GSI’s composites simply doesn’t supply them, and the half is skipped entirely. There is no per-composite leakage across writers like in v1.6, and no GSI-wide blast radius like in v1.7.0.
Concretely: if you have a hybrid GSI with pk = [accountId, alertState] where accountId is enrichment-owned and alertState is ingest-owned, restructure to put each writer’s composites on its own half — pk = [accountId], sk = [alertState, ...] — so each half has a single owner. The per-half policy then expresses each writer’s intent cleanly, and the per-half gate ensures one writer never disturbs the other’s key.
Migrating from 1.7.1 (or 1.7.0)
Section titled “Migrating from 1.7.1 (or 1.7.0)”v1.7.2 is a patch release fixing the PK-composites-only GSI regression introduced by v1.7.1’s per-half evaluation gate (closes #43). No API changes — same indexPolicy: { pk, sk } declaration, same Entity.remove([...]) API, same EDD-9025 invariants.
Behavior change (strictly more correct than v1.7.1):
- Per-half evaluation gate now also counts
keyRecordmembership. GSI halves whose composites are entirely entity primary-key composites are now correctly classified as touched on every write, and the structural rule composes their keys from the immutable PK values. The resulting SET is idempotent. Items written under v1.7.0 / v1.7.1 against such GSIs (where the gate silently skipped them) repair themselves on the nextEntity.update()against them — no data migration needed; reads via the GSI start returning these items as their next update lands.
If you used the byChannel-style pattern (GSI composites = subset of entity PK) under v1.7.0 or v1.7.1 and noticed channel-scoped queries returning no items, this is the fix. Re-run any item that should be in the GSI through any update touching any field, and the GSI key composition will land.
Migrating from 1.7.0
Section titled “Migrating from 1.7.0”v1.7.1 was a patch release fixing three connected bugs in the v1.7.0 roll-up. No API changes — same indexPolicy: { pk, sk } declaration, same Entity.remove([...]) API, same EDD-9025 invariants.
Behavior changes (all bug fixes — strictly more correct than v1.7.0):
- GSI-wide cascade on can’t-compose → per-key REMOVE. v1.7.0 REMOVE’d both
gsiNpkANDgsiNskwhenever either half couldn’t compose; v1.7.1 REMOVEs only the half that couldn’t compose. The other half’s stored value persists (preserved across writers). - Per-half evaluation gate (NEW). v1.7.0 fired the policy on every update of every GSI declaring
indexPolicy, regardless of whether the writer touched the half. v1.7.1 skips untouched halves entirely. Entity.remove([attr])is per-half (not GSI-wide). v1.7.0 REMOVE’d both halves of every GSI containing the cleared attribute; v1.7.1 REMOVEs only the half(s) whose composite list contains it.CompositeKeyHoleError(EDD-9024) deprecated — no longer thrown. Hole patterns collapse into the unified per-half can’t-compose rule.
Most consumers won’t see any difference because v1.7.0 just shipped. The behavior changes mainly affect the multi-writer pattern that v1.7.0 was designed to enable — and didn’t actually enable correctly.
Migrating from 1.6.0
Section titled “Migrating from 1.6.0”1.7.x simplifies the v1.6 per-attribute callback model to per-half declaration. This is a breaking change in 1.7.0 (shipped as a minor bump because in-the-wild consumer count is ~1).
Per-attribute callback → per-half object. Replace the callback API with the per-half object literal. Take the most-restrictive per-attribute policy on each half:
// 1.6.0indexPolicy: () => ({ region: "preserve", country: "preserve", alertState: "sparse",})
// 1.7.x — pick the most-restrictive per-half. If alertState is on the SK// half and is the membership key, declare the SK half sparse.indexPolicy: { pk: "preserve", sk: "sparse" }set({ attr: null }) → Entity.remove([attr]). The v1.6 “set null = drop” pattern no longer cascades GSI keys (the structural composer treats null as absent — the same as omitted). For the explicit-drop intent, use Entity.remove([attr]):
// 1.6.0 — implicitly cascaded to drop the GSIyield* db.entities.Devices.update(key).set({ alertState: null })
// 1.7.x — explicit per-attribute drop + per-half cascadeyield* db.entities.Devices.update(key).remove(["alertState"])Mixed sparse/preserve attrs in same half → not expressible. Pick one per half. The half is a single concatenated string; per-attribute mixing within a half had no coherent runtime semantic anyway under the v1.6 model.
Update-payload type widening reverted. v1.6 wrapped each update field in Schema.NullishOr so set({ attr: null }) would always typecheck. v1.7.x reverts this; set({ attr: null }) only compiles when the model declares the attr as nullable. Combined with EDD-9025, this closes the stale-GSI footgun at the type level.
EDD-9025 — composite attribute schemas must not include null. Convert any nullable composites to Schema.optional(...) (T | undefined). This catches a footgun that v1.6 silently allowed.
Hierarchical PK truncation is now supported (additive). The v1.6 PK-clear-degrades-to-sparse asymmetry is gone — pk.composite = ['accountId', 'fleetId'] with fleetId absent now truncates the PK to account#A instead of dropping the GSI. If you relied on the old PK-drop behavior, declare the PK half as 'sparse' to keep that semantic.
Runnable example
Section titled “Runnable example”Full working program (requires DynamoDB Local):
docker run -p 8000:8000 amazon/dynamodb-localnpx tsx examples/guide-index-policy.tsSee examples/guide-index-policy.ts for the full source.
What’s Next?
Section titled “What’s Next?”- Sparse Maps — the storage primitive (different “sparse”)
- Indexes & Collections — How GSI composites and collections are defined
- Time Series —
.appenddetails and enrichment preservation - Queries — Query by index, filter by SK composites