MARKER INDEXES — constraints DynamoDB doesn't have

View .md

A Kanject marker index is not a GSI — it's a set of reserved rows in the base table itself that the Kanject Core library reads to enforce and resolve a constraint DynamoDB has no native concept of: uniqueness, membership buckets, value-sorted ranges. Creating one backfills those rows from existing data; the matching annotation ([Unique], [Collection], …) is what enforces the constraint on every future write.

Syntax

sql
CREATE UNIQUE     INDEX ["name"] ON "table" (col[, col…]) [WHERE …]CREATE COLLECTION INDEX ON "table" (col)CREATE RANGE      INDEX ON "table" (col)ALTER  <kind> INDEX ON "table" (col) REFRESH        -- reconcile to current dataALTER  <kind> INDEX ON "table" (col) SET WHERE …    -- re-scope a partial markerDROP   <kind> INDEX ON "table" (col)                -- delete the reserved rows

Runs from the PartiQL console and Browse's PartiQL mode for bounded tables (the in-app path is scan-capped); larger tables route to an AWS Glue job. Guide: Tables, indexes & replicas.

Use cases

Enforce unique emails Kanject

One reserved row per distinct value. The backfill scans, groups by value — and if a value repeats it refuses and lists the collisions: it never picks a winner; you resolve them and re-run.

sql
CREATE UNIQUE INDEX ON "stage.Users" (email)
Executes asBackfill: scan → group by email → one reserved row per distinct value (refuses on collisions)
  • Pair it with the [Unique] annotation in Kanject Core — the backfill alone is point-in-time.
Unique within a tenant (composite) Kanject

The same email may exist across tenants but not within one — a multi-column unique, named to match the entity's [CompositeUnique("uxTenantEmail")] group.

sql
CREATE UNIQUE INDEX "uxTenantEmail" ON "stage.Users" (tenantId, email)
Executes asBackfill: one reserved row per distinct (tenantId, email) pair
Scope a rule to active rows Kanject

Active users need unique emails; deactivated accounts may share one. A trailing WHERE makes the marker partial — it only conflicts within that scope.

sql
CREATE UNIQUE INDEX ON "stage.Users" (email)WHERE status = 'active'
Executes asBackfill applies the predicate per scanned item · an unparseable predicate refuses rather than index everything
Bucket items by a value Kanject

A membership marker — one reserved row per item, bucketed by status, no uniqueness check. Needs the base table to have a sort key.

sql
CREATE COLLECTION INDEX ON "stage.Orders" (status)
Executes asBackfill: one reserved row per item, bucketed by status
Reconcile a marker to current data Kanject

Data changed outside the annotations? REFRESH recomputes the reserved rows in one scan and diffs: adds the missing, removes the stale, reports the diff — and a UNIQUE refresh can surface new conflicts and refuse, just like create.

sql
ALTER UNIQUE INDEX ON "stage.Users" (email) REFRESH
Executes asScan → recompute reserved rows → diff against stored (add missing · remove stale · report)
  • DROP … INDEX deletes the reserved rows — but if the entity is still annotated, the SDK re-creates them on the next write, so drop the annotation too.

Try it

Try it · UNIQUE markerSample
Executes asMarker backfill · stage.Users (email, partial)
Scan (bounded) → group by email within status = 'active' → write one reserved row per distinct value
Refuses with the colliding items listed if any active email repeats — you resolve, then re-run.
Reserved rows live in the base table; this is not a GSI.
Backfill complete · 4,918 reserved rows written · 0 collisions · add [Unique] to the entity to enforce future writes

Related: Tables, indexes & replicas · CREATE GSI for read paths (markers are for constraints) · Kanject.Core.NoSqlDatabase for the annotations.

Was this page helpful?