@happyvertical/smrt-core 0.40.70 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +487 -29
  4. package/dist/browser.js +5 -4
  5. package/dist/cascade.d.ts +120 -0
  6. package/dist/cascade.d.ts.map +1 -0
  7. package/dist/cascade.js +430 -0
  8. package/dist/cascade.js.map +1 -0
  9. package/dist/change-feed.d.ts +34 -2
  10. package/dist/change-feed.d.ts.map +1 -1
  11. package/dist/change-feed.js +52 -11
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/class.d.ts +36 -3
  14. package/dist/class.d.ts.map +1 -1
  15. package/dist/class.js +59 -5
  16. package/dist/class.js.map +1 -1
  17. package/dist/collection-cache.js +0 -0
  18. package/dist/collection-cache.js.map +1 -1
  19. package/dist/collection.d.ts +29 -0
  20. package/dist/collection.d.ts.map +1 -1
  21. package/dist/collection.js +66 -14
  22. package/dist/collection.js.map +1 -1
  23. package/dist/config.d.ts +10 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/decorators/index.d.ts +63 -5
  27. package/dist/decorators/index.d.ts.map +1 -1
  28. package/dist/decorators/index.js +37 -5
  29. package/dist/decorators/index.js.map +1 -1
  30. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  31. package/dist/dispatch/collections/Dispatches.js +19 -4
  32. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  33. package/dist/dispatch/types.d.ts +5 -0
  34. package/dist/dispatch/types.d.ts.map +1 -1
  35. package/dist/embedded-write-queue.d.ts +46 -0
  36. package/dist/embedded-write-queue.d.ts.map +1 -0
  37. package/dist/embedded-write-queue.js +66 -0
  38. package/dist/embedded-write-queue.js.map +1 -0
  39. package/dist/embeddings/storage.d.ts +7 -0
  40. package/dist/embeddings/storage.d.ts.map +1 -1
  41. package/dist/embeddings/storage.js +29 -12
  42. package/dist/embeddings/storage.js.map +1 -1
  43. package/dist/errors.d.ts +1 -1
  44. package/dist/hierarchical.js +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +8 -6
  48. package/dist/interceptors.d.ts +21 -0
  49. package/dist/interceptors.d.ts.map +1 -1
  50. package/dist/interceptors.js +27 -1
  51. package/dist/interceptors.js.map +1 -1
  52. package/dist/manifest/generator.d.ts.map +1 -1
  53. package/dist/manifest/generator.js +4 -7
  54. package/dist/manifest/generator.js.map +1 -1
  55. package/dist/manifest/static-manifest.js +10 -10
  56. package/dist/manifest/static-manifest.js.map +1 -1
  57. package/dist/manifest/store.js +1 -1
  58. package/dist/manifest/store.js.map +1 -1
  59. package/dist/manifest.json +19 -19
  60. package/dist/migrations/differ.d.ts +9 -1
  61. package/dist/migrations/differ.d.ts.map +1 -1
  62. package/dist/migrations/differ.js +10 -5
  63. package/dist/migrations/differ.js.map +1 -1
  64. package/dist/migrations/index.d.ts +1 -0
  65. package/dist/migrations/index.d.ts.map +1 -1
  66. package/dist/migrations/index.js +2 -1
  67. package/dist/migrations/minor-units.d.ts +162 -0
  68. package/dist/migrations/minor-units.d.ts.map +1 -0
  69. package/dist/migrations/minor-units.js +381 -0
  70. package/dist/migrations/minor-units.js.map +1 -0
  71. package/dist/migrations.js +2 -1
  72. package/dist/object.d.ts +98 -5
  73. package/dist/object.d.ts.map +1 -1
  74. package/dist/object.js +138 -30
  75. package/dist/object.js.map +1 -1
  76. package/dist/query-bounds.d.ts +5 -1
  77. package/dist/query-bounds.d.ts.map +1 -1
  78. package/dist/query-bounds.js +5 -1
  79. package/dist/query-bounds.js.map +1 -1
  80. package/dist/registry/class-registration.d.ts.map +1 -1
  81. package/dist/registry/class-registration.js +3 -1
  82. package/dist/registry/class-registration.js.map +1 -1
  83. package/dist/registry/manifest-field-merge.d.ts +12 -0
  84. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  85. package/dist/registry/manifest-field-merge.js +14 -2
  86. package/dist/registry/manifest-field-merge.js.map +1 -1
  87. package/dist/registry/schema-builder.d.ts +22 -1
  88. package/dist/registry/schema-builder.d.ts.map +1 -1
  89. package/dist/registry/schema-builder.js +205 -165
  90. package/dist/registry/schema-builder.js.map +1 -1
  91. package/dist/registry/types.d.ts +3 -2
  92. package/dist/registry/types.d.ts.map +1 -1
  93. package/dist/registry.d.ts +41 -46
  94. package/dist/registry.d.ts.map +1 -1
  95. package/dist/registry.js +61 -83
  96. package/dist/registry.js.map +1 -1
  97. package/dist/scanner/manifest-generator.d.ts +45 -0
  98. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  99. package/dist/scanner/manifest-generator.js +92 -28
  100. package/dist/scanner/manifest-generator.js.map +1 -1
  101. package/dist/schema/conflict-target.d.ts +104 -0
  102. package/dist/schema/conflict-target.d.ts.map +1 -0
  103. package/dist/schema/conflict-target.js +129 -0
  104. package/dist/schema/conflict-target.js.map +1 -0
  105. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  106. package/dist/schema/ddl/duckdb-strategy.js +2 -1
  107. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  108. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  109. package/dist/schema/ddl/postgres-strategy.js +12 -1
  110. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  111. package/dist/schema/generator.d.ts +252 -16
  112. package/dist/schema/generator.d.ts.map +1 -1
  113. package/dist/schema/generator.js +411 -106
  114. package/dist/schema/generator.js.map +1 -1
  115. package/dist/schema/index-utils.d.ts +120 -0
  116. package/dist/schema/index-utils.d.ts.map +1 -1
  117. package/dist/schema/index-utils.js +242 -1
  118. package/dist/schema/index-utils.js.map +1 -1
  119. package/dist/schema/index.d.ts +1 -1
  120. package/dist/schema/index.d.ts.map +1 -1
  121. package/dist/schema/index.js +2 -2
  122. package/dist/schema/live-parity.d.ts.map +1 -1
  123. package/dist/schema/live-parity.js +4 -2
  124. package/dist/schema/live-parity.js.map +1 -1
  125. package/dist/schema/system-table-shapes.d.ts +13 -0
  126. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  127. package/dist/schema/system-table-shapes.js +14 -1
  128. package/dist/schema/system-table-shapes.js.map +1 -1
  129. package/dist/schema/utils.d.ts +1 -1
  130. package/dist/schema/utils.d.ts.map +1 -1
  131. package/dist/schema/utils.js +3 -3
  132. package/dist/schema/utils.js.map +1 -1
  133. package/dist/schema.js +2 -2
  134. package/dist/smrt-knowledge.json +9 -9
  135. package/dist/system/compatibility.d.ts +42 -0
  136. package/dist/system/compatibility.d.ts.map +1 -1
  137. package/dist/system/compatibility.js +182 -9
  138. package/dist/system/compatibility.js.map +1 -1
  139. package/dist/system/index.d.ts +1 -0
  140. package/dist/system/index.d.ts.map +1 -1
  141. package/dist/system/index.js +3 -2
  142. package/dist/system/retention.d.ts +237 -0
  143. package/dist/system/retention.d.ts.map +1 -0
  144. package/dist/system/retention.js +497 -0
  145. package/dist/system/retention.js.map +1 -0
  146. package/dist/system/schema.d.ts +100 -15
  147. package/dist/system/schema.d.ts.map +1 -1
  148. package/dist/system/schema.js +81 -45
  149. package/dist/system/schema.js.map +1 -1
  150. package/dist/system/types.d.ts +0 -2
  151. package/dist/system/types.d.ts.map +1 -1
  152. package/dist/testing/database.d.ts.map +1 -1
  153. package/dist/testing/database.js.map +1 -1
  154. package/dist/vite-plugin/index.d.ts.map +1 -1
  155. package/dist/vite-plugin/index.js +4 -9
  156. package/dist/vite-plugin/index.js.map +1 -1
  157. package/package.json +4 -4
@@ -1,6 +1,8 @@
1
+ import { classnameToTablename } from "../utils/naming.js";
1
2
  import { formatDefaultValue, quoteIdentifier, quoteStringLiteral } from "./sql-identifiers.js";
3
+ import { assertIdentifierFits, enforceIdentifierLimits, shortenIdentifier } from "./index-utils.js";
2
4
  import { getDDLStrategy } from "./ddl/index.js";
3
- import { classnameToTablename } from "../utils/naming.js";
5
+ import { conflictIndexName, resolveConflictColumns, resolveTenantColumn, servesSlugLookup } from "./conflict-target.js";
4
6
  import { createHash } from "node:crypto";
5
7
  //#region src/schema/generator.ts
6
8
  /**
@@ -19,7 +21,11 @@ var SchemaGenerator = class {
19
21
  const foreignKeys = this.extractForeignKeys(columns);
20
22
  const dependencies = this.extractDependencies(objectDef, foreignKeys);
21
23
  const version = this.generateVersion(objectDef);
22
- this.ensureTenantIdIndex(indexes, columns, tableName);
24
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
25
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
26
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
27
+ enforceIdentifierLimits(tableName, indexes);
28
+ for (const trigger of triggers) trigger.name = shortenIdentifier(trigger.name);
23
29
  return {
24
30
  tableName,
25
31
  columns,
@@ -141,15 +147,158 @@ var SchemaGenerator = class {
141
147
  return columns;
142
148
  }
143
149
  /**
150
+ * Whether an existing index already leads with `leadingColumns`, in order.
151
+ *
152
+ * Only an UNQUALIFIED index (no partial `WHERE`, no JSON-path expression)
153
+ * counts. A partial index such as `... WHERE _meta_type = 'Article'` cannot
154
+ * serve a base-class polymorphic query, which carries no subtype predicate,
155
+ * so it must not suppress the standalone index (#2359, review of #2384).
156
+ *
157
+ * A B-tree serves any prefix of its column list, so an index over
158
+ * `(tenant_id, created_at, status)` covers both `(tenant_id)` equality
159
+ * lookups and `(tenant_id, created_at)` ordering — hence the prefix test
160
+ * rather than an exact match (#2363).
161
+ */
162
+ hasUnqualifiedLeadingColumns(indexes, leadingColumns) {
163
+ return indexes.some((index) => !index.where && !index.jsonPath && (index.columns?.length ?? 0) >= leadingColumns.length && leadingColumns.every((column, i) => index.columns[i] === column));
164
+ }
165
+ /**
166
+ * Whether an existing index already serves equality lookups on `column`.
167
+ *
168
+ * Single-column shorthand for {@link hasUnqualifiedLeadingColumns}.
169
+ */
170
+ hasUnqualifiedLeadingIndex(indexes, column) {
171
+ return this.hasUnqualifiedLeadingColumns(indexes, [column]);
172
+ }
173
+ /**
174
+ * The column list of the default list-ordering index for a table.
175
+ *
176
+ * Every generated list surface — REST, MCP, and the SvelteKit list route —
177
+ * pages with `ORDER BY created_at DESC, <pk> ASC` (`DEFAULT_LIST_ORDER_BY`,
178
+ * #2367). On a tenant-scoped table that page is always preceded by a
179
+ * `tenant_id = ?` equality filter from the tenancy interceptor, so the
180
+ * serving index leads with the tenant column and orders inside it.
181
+ *
182
+ * `created_at` is generator-owned on every path — a declared `createdAt`
183
+ * field is rewritten to the same `TIMESTAMP NOT NULL DEFAULT
184
+ * current_timestamp` column — so it is never a primary key and never
185
+ * inline-UNIQUE. That is why only `indexes` needs checking for existing
186
+ * coverage: no constraint-backed index can already order this column.
187
+ *
188
+ * Returns `null` when the table has no `created_at` column (no path emits
189
+ * such a table today, but the helper stays total).
190
+ */
191
+ getDefaultListOrderingColumns(columns) {
192
+ if (!columns.created_at) return null;
193
+ const tenantColumn = Object.entries(columns).find(([, columnDef]) => columnDef?.referenceKind === "tenantId")?.[0];
194
+ return tenantColumn ? [tenantColumn, "created_at"] : ["created_at"];
195
+ }
196
+ /**
197
+ * Ensure the table can serve its own default list page from an index.
198
+ *
199
+ * Generated REST/MCP/SvelteKit list routes all page with
200
+ * `ORDER BY created_at DESC, <pk> ASC LIMIT n` and no `created_at` index
201
+ * existed on any schema path (the dead AST path indexed `updated_at`
202
+ * instead), so every default list page was a sequential scan plus a top-N
203
+ * sort of the whole table — 21 ms against 0.1 ms with the right composite on
204
+ * the workload the assessment measured (#2363, finding A2).
205
+ *
206
+ * Emitted shape:
207
+ *
208
+ * - tenant-scoped table → `(<tenant column>, created_at)`. The tenancy
209
+ * interceptor adds `tenant_id = ?` to every list, so the tenant column
210
+ * leads and `created_at` orders within it.
211
+ * - otherwise → `(created_at)`.
212
+ *
213
+ * No `DESC` declaration: PostgreSQL scans a B-tree backwards just as
214
+ * cheaply, and `IndexDefinition` carries no per-column direction. The
215
+ * trailing primary-key tiebreak is left out deliberately — its direction is
216
+ * opposite to `created_at`'s, so no single-direction index can satisfy the
217
+ * whole key anyway; the leading columns turn a full sort into an index scan
218
+ * with an incremental sort over rows that share a timestamp.
219
+ *
220
+ * Call this AFTER {@link appendDeclaredIndexes} and BEFORE
221
+ * {@link ensureReferenceColumnIndexes}: a declared composite that already
222
+ * leads with the same columns suppresses this one, and this composite in
223
+ * turn suppresses the standalone tenant index that would otherwise be a
224
+ * redundant prefix of it.
225
+ */
226
+ ensureDefaultListOrderingIndex(indexes, columns, tableName) {
227
+ const orderingColumns = this.getDefaultListOrderingColumns(columns);
228
+ if (!orderingColumns) return;
229
+ if (this.hasUnqualifiedLeadingColumns(indexes, orderingColumns)) return;
230
+ const name = `${tableName}_${orderingColumns.join("_")}_idx`;
231
+ if (indexes.some((index) => index.name === name)) return;
232
+ indexes.push({
233
+ name,
234
+ columns: orderingColumns
235
+ });
236
+ }
237
+ /**
238
+ * The `(meta_type, meta_id)` owner-lookup columns for a polymorphic
239
+ * association table, or `null` when the table isn't one.
240
+ *
241
+ * `SmrtPolymorphicAssociation` contributes `metaType`/`metaId`/`role` (and
242
+ * `sortOrder`) to any concrete subclass's manifest — the scanner's
243
+ * `FRAMEWORK_ABSTRACT_BASE_NAMES` wiring merges the abstract base's fields
244
+ * directly into the subclass, because the base carries no `@smrt()`
245
+ * decorator of its own to declare an index on. Detected structurally, by
246
+ * the same three-column test `cascade.ts`'s `isPolymorphicAssociationClass`
247
+ * uses on registry field maps: requiring all three keeps an unrelated class
248
+ * that merely happens to carry a `metaType` column from being treated as an
249
+ * association table.
250
+ */
251
+ getPolymorphicAssociationOwnerColumns(columns) {
252
+ if (!Object.hasOwn(columns, "meta_type") || !Object.hasOwn(columns, "meta_id") || !Object.hasOwn(columns, "role")) return null;
253
+ return ["meta_type", "meta_id"];
254
+ }
255
+ /**
256
+ * Ensure a polymorphic association table indexes its owner lookup
257
+ * `(meta_type, meta_id)` (#2364, epic #2382 finding A3).
258
+ *
259
+ * A concrete association's `conflictColumns` lead with its own FK (e.g.
260
+ * `AssetAssociation`'s `asset_id, meta_type, meta_id, role`), so
261
+ * `meta_type`/`meta_id` sit in the *middle* of that unique index — a
262
+ * lookup like `AssetAssociationCollection.byLeft(metaType, metaId)`
263
+ * ("what points at this target") filters columns 2-3 of a 4-column index
264
+ * and cannot use it as a leading prefix. `metaId` is deliberately a bare
265
+ * string, never a typed `@foreignKey`/`@crossPackageRef` (there is no
266
+ * single target table), so {@link ensureReferenceColumnIndexes} never
267
+ * covers it either.
268
+ *
269
+ * Call this alongside {@link ensureDefaultListOrderingIndex}, before
270
+ * {@link ensureReferenceColumnIndexes}.
271
+ *
272
+ * Blast radius: this applies retroactively to every
273
+ * `SmrtPolymorphicAssociation` subclass across every package on the next
274
+ * `db:migrate`, not only the ones a given change touches — roll the wave
275
+ * out with `smrt db:migrate --postgres-safe` (#2362), same as any other
276
+ * bulk index addition.
277
+ */
278
+ ensurePolymorphicAssociationIndex(indexes, columns, tableName) {
279
+ const ownerColumns = this.getPolymorphicAssociationOwnerColumns(columns);
280
+ if (!ownerColumns) return;
281
+ if (this.hasUnqualifiedLeadingColumns(indexes, ownerColumns)) return;
282
+ const name = `${tableName}_${ownerColumns.join("_")}_idx`;
283
+ if (indexes.some((index) => index.name === name)) return;
284
+ indexes.push({
285
+ name,
286
+ columns: ownerColumns
287
+ });
288
+ }
289
+ /**
144
290
  * Append `@smrt({ indexes: [...] })` declarations to a generated index set
145
291
  * (#2357).
146
292
  *
147
293
  * Shared by every schema path — build-time AST, runtime registry CTI/STI, and
148
294
  * manifest CTI/STI — so one declaration behaves identically however the schema
149
- * was derived. Call it *before* `ensureTenantIdIndex`: a declared composite
150
- * leading with `tenant_id` is exactly what that helper's "already leading"
151
- * check is meant to defer to, and appending afterwards would leave the table
152
- * with a redundant standalone tenant index (#2384).
295
+ * was derived. Call it *first*, ahead of
296
+ * {@link ensureDefaultListOrderingIndex} (#2363) and
297
+ * {@link ensureReferenceColumnIndexes}: a declared composite leading with a
298
+ * reference column (e.g. `tenant_id`), or with the default ordering columns,
299
+ * is exactly what those helpers' leads-with check is meant to defer to, and
300
+ * appending afterwards would leave the table with a redundant standalone
301
+ * index (#2384, #2359).
153
302
  *
154
303
  * Nothing is dropped quietly. A column that resolves to no column on the
155
304
  * table, a malformed entry, and a name collision with a different index are
@@ -195,6 +344,7 @@ var SchemaGenerator = class {
195
344
  };
196
345
  if (typeof spec !== "object" || spec === null || Array.isArray(spec)) throw new Error(`Declared index on "${tableName}" must be an object: got ${describe()}.`);
197
346
  if (typeof spec.name !== "string" || spec.name.length === 0) throw new Error(`Declared index on "${tableName}" needs a non-empty "name": got ${describe()}.`);
347
+ assertIdentifierFits(spec.name, "Declared index", `table "${tableName}"`);
198
348
  if (!Array.isArray(spec.columns) || spec.columns.length === 0 || !spec.columns.every((column) => typeof column === "string" && column.length > 0)) throw new Error(`Declared index "${spec.name}" on "${tableName}" needs a non-empty "columns" array of field or column names.`);
199
349
  if (spec.unique !== void 0 && typeof spec.unique !== "boolean") throw new Error(`Declared index "${spec.name}" on "${tableName}" has a non-boolean "unique": got ${describe()}.`);
200
350
  if (spec.where !== void 0 && (typeof spec.where !== "string" || spec.where.trim().length === 0)) throw new Error(`Declared index "${spec.name}" on "${tableName}" has an empty or non-string "where" predicate.`);
@@ -218,29 +368,191 @@ var SchemaGenerator = class {
218
368
  return a.columns.length === b.columns.length && a.columns.every((column, i) => column === b.columns[i]) && (a.unique ?? false) === (b.unique ?? false) && (a.where ?? null) === (b.where ?? null);
219
369
  }
220
370
  /**
221
- * Ensure a tenancy-injected `tenant_id` column has an index leading with it.
371
+ * Ensure every reference column `@foreignKey`, `@crossPackageRef`, and the
372
+ * tenancy-injected `tenant_id` — has an index leading with it.
373
+ *
374
+ * These are the columns every relationship load, `include:` batch, reverse
375
+ * ownership lookup and tenant-scoped read filters on. Before #2359 only the
376
+ * registry (test) paths indexed foreign keys, the manifest (production)
377
+ * paths indexed none of them, and `tenant_id` was indexed nowhere (#2356):
378
+ * 196/231 `@foreignKey` and 91/92 `@crossPackageRef` columns shipped with no
379
+ * serving index. Every schema path now calls this helper so the four paths
380
+ * stay in step (see `schema-path-parity.test.ts`).
222
381
  *
223
- * Tenancy injects the column but nothing indexed it: the generated set covers
224
- * foreign keys, unique columns, `updated_at` and the STI discriminator, and
225
- * `tenant_id` is in none of those. Every tenant-scoped read filters on it, so
226
- * without an index each one scans the whole multi-tenant table — measured in
227
- * one production database, 164 of 212 tenant-scoped tables had no such index
228
- * (#2356).
382
+ * "Leading with it" rather than "always add": a table that already has an
383
+ * unqualified index starting on the column (commonly the `conflictColumns`
384
+ * unique index, or an `indexed: true` opt-in) is already served, and a
385
+ * standalone duplicate would only cost writes. A *partial* index does not
386
+ * count see {@link hasUnqualifiedLeadingIndex}.
229
387
  *
230
- * "Leading with it" rather than "always add": a table that already has a
231
- * composite index starting on `tenant_id` (commonly from `conflictColumns`)
232
- * is already served, and a standalone duplicate would only cost writes.
388
+ * Reference columns are therefore always indexed; `indexed: true` on one of
389
+ * them is redundant and harmless.
390
+ *
391
+ * Call this LAST in every path, after every other index (conflict, opt-in,
392
+ * unique, the declared composites, the default list-ordering composite, and
393
+ * any future addition) has been appended, so the leads-with suppression sees
394
+ * the full set. In particular {@link ensureDefaultListOrderingIndex} runs
395
+ * first on a tenant-scoped table: its `(tenant_id, created_at)` composite
396
+ * serves the tenant equality filter too, so no standalone tenant index is
397
+ * added (#2363).
233
398
  */
234
- ensureTenantIdIndex(indexes, columns, tableName) {
235
- const tenantColumn = Object.entries(columns).find(([, columnDef]) => columnDef?.referenceKind === "tenantId")?.[0];
236
- if (!tenantColumn) return;
237
- if (indexes.some((index) => index.columns?.[0] === tenantColumn)) return;
399
+ ensureReferenceColumnIndexes(indexes, columns, tableName) {
400
+ for (const [columnName, columnDef] of Object.entries(columns)) {
401
+ const kind = columnDef?.referenceKind;
402
+ if (kind !== "foreignKey" && kind !== "crossPackageRef" && kind !== "tenantId") continue;
403
+ if (columnDef?.primaryKey || columnDef?.unique) continue;
404
+ if (this.hasUnqualifiedLeadingIndex(indexes, columnName)) continue;
405
+ const name = `${tableName}_${columnName}_idx`;
406
+ if (indexes.some((index) => index.name === name)) continue;
407
+ indexes.push({
408
+ name,
409
+ columns: [columnName]
410
+ });
411
+ }
412
+ }
413
+ /**
414
+ * Whether `conflictColumns` names exactly the primary key column(s).
415
+ *
416
+ * `ON CONFLICT (id)` binds to the primary-key constraint on every engine, so
417
+ * a separate unique index over the same column set is a second B-tree over
418
+ * the same random UUIDs with nothing to add (#2359, finding A5).
419
+ */
420
+ conflictColumnsArePrimaryKey(conflictColumns, columns) {
421
+ const primaryKeyColumns = Object.entries(columns).filter(([, columnDef]) => columnDef?.primaryKey === true).map(([name]) => name);
422
+ return primaryKeyColumns.length > 0 && primaryKeyColumns.length === conflictColumns.length && primaryKeyColumns.every((column) => conflictColumns.includes(column));
423
+ }
424
+ /**
425
+ * The table's tenant column as the generated columns describe it: the
426
+ * column carrying `referenceKind: 'tenantId'` (set from the field's
427
+ * `__tenancy.isTenantIdField` marker on every path).
428
+ */
429
+ findTenantColumn(columns) {
430
+ for (const [columnName, columnDef] of Object.entries(columns)) if (columnDef?.referenceKind === "tenantId") return columnName;
431
+ }
432
+ /**
433
+ * Resolve the conflict target the unique conflict index must cover
434
+ * (#2360). `config.conflictColumns` wins when the caller resolved it —
435
+ * registry callers pass `ObjectRegistry.getConflictColumns()`, the manifest
436
+ * pipeline passes the normalized `decoratorConfig` — otherwise the strategy
437
+ * default, led by the tenant column when `config.tenantScoped` names a
438
+ * tenant field that exists as a column. Both roads apply the same rule as
439
+ * `ObjectRegistry.getConflictColumns()`, so schema and upsert agree by
440
+ * construction (`schema-path-parity.test.ts`).
441
+ */
442
+ resolveConflictTarget(strategy, columns, config) {
443
+ const tenantColumn = this.findTenantColumn(columns);
444
+ if (config?.conflictColumns && config.conflictColumns.length > 0) return {
445
+ conflictColumns: [...config.conflictColumns],
446
+ tenantColumn
447
+ };
448
+ const tenantScoped = config?.tenantScoped;
449
+ return {
450
+ conflictColumns: resolveConflictColumns({
451
+ strategy,
452
+ tenantColumn: tenantScoped ? resolveTenantColumn(typeof tenantScoped === "object" ? tenantScoped.field ?? "tenantId" : "tenantId", (fieldName) => Boolean(columns[this.toSnakeCase(fieldName)]), (fieldName) => this.toSnakeCase(fieldName)) : void 0
453
+ }),
454
+ tenantColumn
455
+ };
456
+ }
457
+ /**
458
+ * Keep `(slug, context)` lookups served when `conflictColumns` are custom.
459
+ *
460
+ * The default conflict index is `(slug, context)`, and `loadFromSlug()`,
461
+ * `getId()` and `getSavedId()` all filter on `slug`/`context` regardless of
462
+ * the configured conflict key. A class that declares custom conflict
463
+ * columns replaces that unique index and, before #2359, left those lookups
464
+ * with no index at all — 120 tables (finding A7).
465
+ *
466
+ * Emitting a plain `(slug, context)` index is the safer of the two fixes:
467
+ * routing the lookups through the conflict key would change which row a
468
+ * slug resolves to on every such class, whereas an extra non-unique index
469
+ * is purely additive. It is skipped when an unqualified index already
470
+ * serves the lookup — one leading with `slug` (e.g. custom conflict columns
471
+ * that still start with it), or the tenant-led default key
472
+ * `(tenant_id, slug, context)` of a tenant-scoped table (#2360): every slug
473
+ * lookup on such a table carries the tenant predicate (#2365) and is served
474
+ * by that prefix, so a second index would only cost writes.
475
+ */
476
+ ensureSlugLookupIndex(indexes, columns, tableName) {
477
+ if (!columns.slug || !columns.context) return;
478
+ const tenantColumn = this.findTenantColumn(columns);
479
+ if (indexes.some((index) => !index.where && !index.jsonPath && servesSlugLookup(index.columns ?? [], tenantColumn))) return;
480
+ const name = `${tableName}_slug_context_idx`;
481
+ if (indexes.some((index) => index.name === name)) return;
238
482
  indexes.push({
239
- name: `${tableName}_${tenantColumn}_idx`,
240
- columns: [tenantColumn]
483
+ name,
484
+ columns: ["slug", "context"]
485
+ });
486
+ }
487
+ /**
488
+ * Emit the unique conflict index of an STI table from the resolved
489
+ * conflict target (#2360): the STI default `(slug, context, _meta_type)`,
490
+ * a custom `@smrt({ conflictColumns })` declared on the STI root, or the
491
+ * default led by the tenant column when the root is tenant-scoped. Shared
492
+ * by the registry and manifest STI paths so they cannot drift.
493
+ *
494
+ * No index is emitted when the target is the primary key itself
495
+ * (`ON CONFLICT (id)` binds to the PK constraint), matching the CTI paths.
496
+ */
497
+ emitStiConflictIndex(indexes, columns, tableName, config) {
498
+ const { conflictColumns, tenantColumn } = this.resolveConflictTarget("sti", columns, config);
499
+ if (this.conflictColumnsArePrimaryKey(conflictColumns, columns)) return;
500
+ indexes.push({
501
+ name: conflictIndexName(tableName, conflictColumns, tenantColumn),
502
+ columns: conflictColumns,
503
+ unique: true
241
504
  });
242
505
  }
243
506
  /**
507
+ * Emit unique indexes for `@field({ unique: true })` columns on an STI table.
508
+ *
509
+ * STI columns are the union of every class in the hierarchy, all nullable,
510
+ * so a column-level `UNIQUE` (what CTI renders inline) is not enough on its
511
+ * own: the migration differ cannot add a column constraint to an existing
512
+ * table, but it can add an index. Two shapes (#2359, finding A4):
513
+ *
514
+ * - declared on the STI **base** (and therefore inherited by every class in
515
+ * the table): one full unique index, table-wide like CTI;
516
+ * - declared only on a **descendant**: one partial unique index per class
517
+ * that carries the flag (the declaring class and, through inherited field
518
+ * metadata, its own descendants), `WHERE _meta_type = '<qualified>'`, so
519
+ * siblings that merely share the column name are not constrained.
520
+ * Uniqueness is then enforced per concrete class, not across the subtree
521
+ * — a documented limitation of the discriminator-partial shape. Engines
522
+ * without partial indexes (DuckDB, JSON) skip this shape entirely rather
523
+ * than widen it to a table-wide UNIQUE (see DuckDBStrategy.generateIndexes
524
+ * and SchemaComparer.compareIndexes).
525
+ *
526
+ * @param declarers - column name → classes whose field metadata carries
527
+ * `unique: true` for that column, in hierarchy order (base first).
528
+ */
529
+ emitStiUniqueIndexes(indexes, declarers, baseClassName, tableName) {
530
+ for (const [columnName, classNames] of declarers.entries()) {
531
+ if (classNames.has(baseClassName)) {
532
+ indexes.push({
533
+ name: `${tableName}_${columnName}_unique_idx`,
534
+ columns: [columnName],
535
+ unique: true
536
+ });
537
+ continue;
538
+ }
539
+ for (const className of classNames) {
540
+ const simpleName = className.includes(":") ? className.slice(className.lastIndexOf(":") + 1) : className;
541
+ let name = `${tableName}_${columnName}_${this.toSnakeCase(simpleName)}_unique_idx`;
542
+ if (indexes.some((index) => index.name === name)) {
543
+ const digest = createHash("sha256").update(className).digest("hex").slice(0, 6);
544
+ name = `${tableName}_${columnName}_${this.toSnakeCase(simpleName)}_${digest}_unique_idx`;
545
+ }
546
+ indexes.push({
547
+ name,
548
+ columns: [columnName],
549
+ unique: true,
550
+ where: `_meta_type = ${quoteStringLiteral(className)}`
551
+ });
552
+ }
553
+ }
554
+ }
555
+ /**
244
556
  * Generate index definitions
245
557
  */
246
558
  generateIndexes(objectDef, columns) {
@@ -435,39 +747,25 @@ var SchemaGenerator = class {
435
747
  this.reconcileRegistryForeignKeyColumnTypes(columns, fields, config?.registry);
436
748
  const indexes = [];
437
749
  if (!hasCustomPK) {
438
- indexes.push({
439
- name: `${tableName}_id_idx`,
440
- columns: ["id"],
441
- description: "Primary key index"
442
- });
443
- const conflictColumns = config?.conflictColumns || ["slug", "context"];
444
- const conflictIndexName = conflictColumns.length > 2 ? `${tableName}_${conflictColumns.slice(0, 2).join("_")}_idx` : `${tableName}_${conflictColumns.join("_")}_idx`;
445
- indexes.push({
446
- name: conflictIndexName,
750
+ const { conflictColumns, tenantColumn } = this.resolveConflictTarget("cti", columns, config);
751
+ if (!this.conflictColumnsArePrimaryKey(conflictColumns, columns)) indexes.push({
752
+ name: conflictIndexName(tableName, conflictColumns, tenantColumn),
447
753
  columns: conflictColumns,
448
754
  unique: true,
449
755
  description: `Unique conflict index for ${className}`
450
756
  });
451
- } else for (const [colName, colDef] of Object.entries(columns)) if (colDef.primaryKey) {
452
- indexes.push({
453
- name: `${tableName}_${colName}_idx`,
454
- columns: [colName],
455
- description: `Primary key index`
456
- });
457
- break;
458
757
  }
459
- for (const [colName, colDef] of Object.entries(columns)) if (colDef.foreignKey) indexes.push({
460
- name: `idx_${tableName}_${colName}`,
461
- columns: [colName],
462
- description: `Foreign key index for ${colName}`
463
- });
758
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
464
759
  for (const colName of indexedColumns) indexes.push({
465
760
  name: `${tableName}_${colName}_idx`,
466
761
  columns: [colName],
467
762
  description: `Index for ${colName}`
468
763
  });
469
764
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
470
- this.ensureTenantIdIndex(indexes, columns, tableName);
765
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
766
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
767
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
768
+ enforceIdentifierLimits(tableName, indexes);
471
769
  return {
472
770
  tableName,
473
771
  columns,
@@ -487,7 +785,14 @@ var SchemaGenerator = class {
487
785
  * - _meta_type: Discriminator column to identify class type
488
786
  * - _meta_data: JSON column for flexible field storage
489
787
  * - Union of all FK columns from descendants (all nullable)
490
- * - Partial indexes for FK columns (filtered by _meta_type)
788
+ * - One plain index per reference column (FK / cross-package ref /
789
+ * tenant_id). Plain rather than partial-by-class: base-class polymorphic
790
+ * queries carry no `_meta_type` predicate, so a partial index could not
791
+ * serve them, while a plain index serves both those and the child
792
+ * collections' `_meta_type = X AND fk = ?` filters — one index per column
793
+ * instead of one per (column, class) (#2359).
794
+ * - Unique indexes for `@field({ unique: true })` columns — see
795
+ * {@link emitStiUniqueIndexes}.
491
796
  *
492
797
  * @param baseClassName - Base class name for the STI hierarchy
493
798
  * @param tableName - Shared table name (from base class)
@@ -547,12 +852,11 @@ var SchemaGenerator = class {
547
852
  let hasUpdatedAt = false;
548
853
  const descendants = ObjectRegistry.getDescendants(baseClassName);
549
854
  const allClassNames = [baseClassName, ...descendants];
550
- const fkColumnsByClass = /* @__PURE__ */ new Map();
551
855
  const indexedMetaFields = /* @__PURE__ */ new Set();
552
856
  const indexedStiColumns = /* @__PURE__ */ new Set();
857
+ const uniqueColumnDeclarers = /* @__PURE__ */ new Map();
553
858
  for (const className of allClassNames) {
554
859
  const classFields = await ObjectRegistry.getAllFields(className);
555
- fkColumnsByClass.set(className, /* @__PURE__ */ new Set());
556
860
  for (const [fieldName, field] of classFields.entries()) {
557
861
  if (field.transient || field._meta?.transient) continue;
558
862
  if (field.type === "meta" && (field.indexed === true || field._meta?.indexed === true)) indexedMetaFields.add(fieldName);
@@ -582,6 +886,14 @@ var SchemaGenerator = class {
582
886
  if (field.type === "oneToMany" || field.type === "manyToMany") continue;
583
887
  if (field.type === "meta") continue;
584
888
  const columnName = this.toSnakeCase(fieldName);
889
+ if (field._meta?.unique === true) {
890
+ let declarers = uniqueColumnDeclarers.get(columnName);
891
+ if (!declarers) {
892
+ declarers = /* @__PURE__ */ new Set();
893
+ uniqueColumnDeclarers.set(columnName, declarers);
894
+ }
895
+ declarers.add(className);
896
+ }
585
897
  if (columns[columnName]) continue;
586
898
  const columnDef = {
587
899
  type: this.getRelationshipColumnType(field),
@@ -595,15 +907,12 @@ var SchemaGenerator = class {
595
907
  if (field.type === "foreignKey") {
596
908
  const relatedName = field.related;
597
909
  const onDeleteAction = field._meta?.onDelete;
598
- if (relatedName) {
599
- columnDef.foreignKey = {
600
- table: this.classNameToTableName(relatedName),
601
- column: "id",
602
- onDelete: onDeleteAction || "CASCADE",
603
- onUpdate: "CASCADE"
604
- };
605
- fkColumnsByClass.get(className)?.add(columnName);
606
- }
910
+ if (relatedName) columnDef.foreignKey = {
911
+ table: this.classNameToTableName(relatedName),
912
+ column: "id",
913
+ onDelete: onDeleteAction || "CASCADE",
914
+ onUpdate: "CASCADE"
915
+ };
607
916
  }
608
917
  if ((field._meta?.indexed === true || field.indexed === true) && !columnDef.foreignKey) indexedStiColumns.add(columnName);
609
918
  columns[columnName] = columnDef;
@@ -626,32 +935,14 @@ var SchemaGenerator = class {
626
935
  this.reconcileRegistryForeignKeyColumnTypes(columns, classFields, ObjectRegistry);
627
936
  }
628
937
  const indexes = [];
629
- indexes.push({
630
- name: `${tableName}_id_idx`,
631
- columns: ["id"],
632
- description: "Primary key index"
633
- });
634
- indexes.push({
635
- name: `${tableName}_slug_context_meta_type_idx`,
636
- columns: [
637
- "slug",
638
- "context",
639
- "_meta_type"
640
- ],
641
- unique: true,
642
- description: "Unique index for slug, context, and type"
643
- });
938
+ this.emitStiConflictIndex(indexes, columns, tableName, config);
644
939
  indexes.push({
645
940
  name: `${tableName}_meta_type_idx`,
646
941
  columns: ["_meta_type"],
647
942
  description: "Index for type discriminator queries"
648
943
  });
649
- for (const [className, fkColumns] of fkColumnsByClass.entries()) for (const fkColumn of fkColumns) indexes.push({
650
- name: `idx_${tableName}_${fkColumn}_${className.toLowerCase()}`,
651
- columns: [fkColumn],
652
- where: `_meta_type = ${quoteStringLiteral(className)}`,
653
- description: `Partial index for ${fkColumn} in ${className} rows`
654
- });
944
+ this.emitStiUniqueIndexes(indexes, uniqueColumnDeclarers, baseClassName, tableName);
945
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
655
946
  for (const fieldName of indexedMetaFields) indexes.push({
656
947
  name: `${tableName}_meta_${this.toSnakeCase(fieldName)}_idx`,
657
948
  columns: [],
@@ -667,7 +958,10 @@ var SchemaGenerator = class {
667
958
  description: `Index for ${colName}`
668
959
  });
669
960
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
670
- this.ensureTenantIdIndex(indexes, columns, tableName);
961
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
962
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
963
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
964
+ enforceIdentifierLimits(tableName, indexes);
671
965
  return {
672
966
  tableName,
673
967
  columns,
@@ -732,15 +1026,15 @@ var SchemaGenerator = class {
732
1026
  notNull: true,
733
1027
  default: "current_timestamp"
734
1028
  };
735
- const descendants = this.findDescendantsInManifest(baseClassName, manifest);
736
- const allClassNames = [baseClassName, ...descendants];
737
- const fkColumnsByClass = /* @__PURE__ */ new Map();
1029
+ const baseKey = this.resolveManifestClassKey(baseClassName, manifest);
1030
+ const descendants = this.findDescendantsInManifest(baseKey, manifest);
1031
+ const allClassNames = [baseKey, ...descendants];
738
1032
  const indexedMetaFields = /* @__PURE__ */ new Set();
739
1033
  const indexedStiColumns = /* @__PURE__ */ new Set();
1034
+ const uniqueColumnDeclarers = /* @__PURE__ */ new Map();
740
1035
  for (const className of allClassNames) {
741
1036
  const objDef = manifest.objects[className];
742
1037
  if (!objDef) continue;
743
- fkColumnsByClass.set(className, /* @__PURE__ */ new Set());
744
1038
  for (const [fieldName, field] of Object.entries(objDef.fields)) {
745
1039
  if (field.transient || field._meta?.transient) continue;
746
1040
  const indexedField = field;
@@ -749,6 +1043,14 @@ var SchemaGenerator = class {
749
1043
  if (field.type === "oneToMany" || field.type === "manyToMany") continue;
750
1044
  if (field.type === "meta") continue;
751
1045
  const columnName = this.toSnakeCase(fieldName);
1046
+ if (field._meta?.unique === true) {
1047
+ let declarers = uniqueColumnDeclarers.get(columnName);
1048
+ if (!declarers) {
1049
+ declarers = /* @__PURE__ */ new Set();
1050
+ uniqueColumnDeclarers.set(columnName, declarers);
1051
+ }
1052
+ declarers.add(className);
1053
+ }
752
1054
  if (columns[columnName]) continue;
753
1055
  const columnDef = {
754
1056
  type: this.getRelationshipColumnType(field),
@@ -756,29 +1058,18 @@ var SchemaGenerator = class {
756
1058
  notNull: false
757
1059
  };
758
1060
  if (field.default !== void 0 && this.shouldEmitDefault(field, field.default)) columnDef.default = field.default;
759
- if (field.type === "foreignKey") fkColumnsByClass.get(className)?.add(columnName);
760
1061
  if ((indexedField.indexed === true || field._meta?.indexed === true) && field.type !== "foreignKey") indexedStiColumns.add(columnName);
761
1062
  columns[columnName] = columnDef;
762
1063
  }
763
1064
  }
764
1065
  const indexes = [];
765
- indexes.push({
766
- name: `${tableName}_id_idx`,
767
- columns: ["id"]
768
- });
769
- indexes.push({
770
- name: `${tableName}_slug_context_meta_type_idx`,
771
- columns: [
772
- "slug",
773
- "context",
774
- "_meta_type"
775
- ],
776
- unique: true
777
- });
1066
+ this.emitStiConflictIndex(indexes, columns, tableName, config);
778
1067
  indexes.push({
779
1068
  name: `${tableName}_meta_type_idx`,
780
1069
  columns: ["_meta_type"]
781
1070
  });
1071
+ this.emitStiUniqueIndexes(indexes, uniqueColumnDeclarers, baseKey, tableName);
1072
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
782
1073
  for (const fieldName of indexedMetaFields) indexes.push({
783
1074
  name: `${tableName}_meta_${this.toSnakeCase(fieldName)}_idx`,
784
1075
  columns: [],
@@ -792,7 +1083,10 @@ var SchemaGenerator = class {
792
1083
  columns: [colName]
793
1084
  });
794
1085
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
795
- this.ensureTenantIdIndex(indexes, columns, tableName);
1086
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
1087
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
1088
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
1089
+ enforceIdentifierLimits(tableName, indexes);
796
1090
  const schemaDefinition = {
797
1091
  tableName,
798
1092
  columns: this.convertManifestColumnsToSchemaColumns(columns),
@@ -874,23 +1168,22 @@ var SchemaGenerator = class {
874
1168
  columns[columnName] = columnDef;
875
1169
  }
876
1170
  const indexes = [];
877
- indexes.push({
878
- name: `${tableName}_id_idx`,
879
- columns: ["id"]
880
- });
881
- const conflictColumns = config?.conflictColumns || ["slug", "context"];
882
- const indexName = conflictColumns.length > 2 ? `${tableName}_${conflictColumns.slice(0, 2).join("_")}_idx` : `${tableName}_${conflictColumns.join("_")}_idx`;
883
- indexes.push({
884
- name: indexName,
1171
+ const { conflictColumns, tenantColumn } = this.resolveConflictTarget("cti", columns, config);
1172
+ if (!this.conflictColumnsArePrimaryKey(conflictColumns, columns)) indexes.push({
1173
+ name: conflictIndexName(tableName, conflictColumns, tenantColumn),
885
1174
  columns: conflictColumns,
886
1175
  unique: true
887
1176
  });
1177
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
888
1178
  for (const colName of indexedColumns) indexes.push({
889
1179
  name: `${tableName}_${colName}_idx`,
890
1180
  columns: [colName]
891
1181
  });
892
1182
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
893
- this.ensureTenantIdIndex(indexes, columns, tableName);
1183
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
1184
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
1185
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
1186
+ enforceIdentifierLimits(tableName, indexes);
894
1187
  const schemaDefinition = {
895
1188
  tableName,
896
1189
  columns: this.convertManifestColumnsToSchemaColumns(columns),
@@ -919,6 +1212,18 @@ var SchemaGenerator = class {
919
1212
  };
920
1213
  }
921
1214
  /**
1215
+ * Resolve a class name (simple or qualified) to the key it is stored under
1216
+ * in `manifest.objects`. Falls back to the input when the manifest does not
1217
+ * carry the class (e.g. an external STI base absent from the aggregated
1218
+ * manifest), so callers can keep using it as an opaque label.
1219
+ */
1220
+ resolveManifestClassKey(className, manifest) {
1221
+ if (manifest.objects[className]) return className;
1222
+ const simpleName = className.includes(":") ? className.slice(className.lastIndexOf(":") + 1) : className;
1223
+ for (const [key, obj] of Object.entries(manifest.objects)) if (obj.qualifiedName === className || obj.className === simpleName) return key;
1224
+ return className;
1225
+ }
1226
+ /**
922
1227
  * Find all descendants of a class in the manifest
923
1228
  *
924
1229
  * Note: Manifest keys are now qualified names (@pkg:ClassName) but `extends` field