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 emailsKanject
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 rowsKanject
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 valueKanject
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 dataKanject
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.