@happyvertical/smrt-core 0.40.70 → 0.42.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 (193) hide show
  1. package/AGENTS.md +3 -3
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +561 -32
  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 +54 -13
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/change-signals.d.ts.map +1 -1
  14. package/dist/change-signals.js +8 -1
  15. package/dist/change-signals.js.map +1 -1
  16. package/dist/class.d.ts +36 -3
  17. package/dist/class.d.ts.map +1 -1
  18. package/dist/class.js +69 -14
  19. package/dist/class.js.map +1 -1
  20. package/dist/collection-cache.js +0 -0
  21. package/dist/collection-cache.js.map +1 -1
  22. package/dist/collection.d.ts +29 -0
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +68 -16
  25. package/dist/collection.js.map +1 -1
  26. package/dist/config.d.ts +10 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/decorators/index.d.ts +63 -5
  30. package/dist/decorators/index.d.ts.map +1 -1
  31. package/dist/decorators/index.js +37 -5
  32. package/dist/decorators/index.js.map +1 -1
  33. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  34. package/dist/dispatch/collections/Dispatches.js +21 -6
  35. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  36. package/dist/dispatch/models/Dispatch.d.ts +1 -1
  37. package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
  38. package/dist/dispatch/models/Dispatch.js +3 -2
  39. package/dist/dispatch/models/Dispatch.js.map +1 -1
  40. package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
  41. package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
  42. package/dist/dispatch/models/DispatchSubscription.js +2 -1
  43. package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
  44. package/dist/dispatch/types.d.ts +5 -0
  45. package/dist/dispatch/types.d.ts.map +1 -1
  46. package/dist/embedded-write-queue.d.ts +46 -0
  47. package/dist/embedded-write-queue.d.ts.map +1 -0
  48. package/dist/embedded-write-queue.js +66 -0
  49. package/dist/embedded-write-queue.js.map +1 -0
  50. package/dist/embeddings/storage.d.ts +7 -0
  51. package/dist/embeddings/storage.d.ts.map +1 -1
  52. package/dist/embeddings/storage.js +31 -13
  53. package/dist/embeddings/storage.js.map +1 -1
  54. package/dist/errors.d.ts +1 -1
  55. package/dist/hierarchical.js +1 -1
  56. package/dist/index.d.ts +2 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -6
  59. package/dist/interceptors.d.ts +21 -0
  60. package/dist/interceptors.d.ts.map +1 -1
  61. package/dist/interceptors.js +27 -1
  62. package/dist/interceptors.js.map +1 -1
  63. package/dist/learning/memory.d.ts.map +1 -1
  64. package/dist/learning/memory.js +5 -4
  65. package/dist/learning/memory.js.map +1 -1
  66. package/dist/manifest/generator.d.ts.map +1 -1
  67. package/dist/manifest/generator.js +4 -7
  68. package/dist/manifest/generator.js.map +1 -1
  69. package/dist/manifest/static-manifest.js +10 -10
  70. package/dist/manifest/static-manifest.js.map +1 -1
  71. package/dist/manifest/store.js +1 -1
  72. package/dist/manifest/store.js.map +1 -1
  73. package/dist/manifest.json +19 -19
  74. package/dist/migrations/differ.d.ts +9 -1
  75. package/dist/migrations/differ.d.ts.map +1 -1
  76. package/dist/migrations/differ.js +12 -7
  77. package/dist/migrations/differ.js.map +1 -1
  78. package/dist/migrations/index.d.ts +2 -0
  79. package/dist/migrations/index.d.ts.map +1 -1
  80. package/dist/migrations/index.js +3 -1
  81. package/dist/migrations/integer-width.d.ts +99 -0
  82. package/dist/migrations/integer-width.d.ts.map +1 -0
  83. package/dist/migrations/integer-width.js +237 -0
  84. package/dist/migrations/integer-width.js.map +1 -0
  85. package/dist/migrations/minor-units.d.ts +162 -0
  86. package/dist/migrations/minor-units.d.ts.map +1 -0
  87. package/dist/migrations/minor-units.js +383 -0
  88. package/dist/migrations/minor-units.js.map +1 -0
  89. package/dist/migrations/tracker.d.ts.map +1 -1
  90. package/dist/migrations/tracker.js +28 -20
  91. package/dist/migrations/tracker.js.map +1 -1
  92. package/dist/migrations.js +3 -1
  93. package/dist/object.d.ts +98 -5
  94. package/dist/object.d.ts.map +1 -1
  95. package/dist/object.js +138 -30
  96. package/dist/object.js.map +1 -1
  97. package/dist/query-bounds.d.ts +5 -1
  98. package/dist/query-bounds.d.ts.map +1 -1
  99. package/dist/query-bounds.js +5 -1
  100. package/dist/query-bounds.js.map +1 -1
  101. package/dist/registry/class-registration.d.ts.map +1 -1
  102. package/dist/registry/class-registration.js +3 -1
  103. package/dist/registry/class-registration.js.map +1 -1
  104. package/dist/registry/manifest-field-merge.d.ts +12 -0
  105. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  106. package/dist/registry/manifest-field-merge.js +14 -2
  107. package/dist/registry/manifest-field-merge.js.map +1 -1
  108. package/dist/registry/schema-builder.d.ts +22 -1
  109. package/dist/registry/schema-builder.d.ts.map +1 -1
  110. package/dist/registry/schema-builder.js +205 -165
  111. package/dist/registry/schema-builder.js.map +1 -1
  112. package/dist/registry/types.d.ts +3 -2
  113. package/dist/registry/types.d.ts.map +1 -1
  114. package/dist/registry.d.ts +41 -46
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +61 -83
  117. package/dist/registry.js.map +1 -1
  118. package/dist/scanner/manifest-generator.d.ts +45 -0
  119. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  120. package/dist/scanner/manifest-generator.js +92 -28
  121. package/dist/scanner/manifest-generator.js.map +1 -1
  122. package/dist/schema/conflict-target.d.ts +104 -0
  123. package/dist/schema/conflict-target.d.ts.map +1 -0
  124. package/dist/schema/conflict-target.js +129 -0
  125. package/dist/schema/conflict-target.js.map +1 -0
  126. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  127. package/dist/schema/ddl/duckdb-strategy.js +3 -1
  128. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  129. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  130. package/dist/schema/ddl/postgres-strategy.js +14 -1
  131. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  132. package/dist/schema/generator.d.ts +245 -41
  133. package/dist/schema/generator.d.ts.map +1 -1
  134. package/dist/schema/generator.js +397 -241
  135. package/dist/schema/generator.js.map +1 -1
  136. package/dist/schema/index-utils.d.ts +120 -0
  137. package/dist/schema/index-utils.d.ts.map +1 -1
  138. package/dist/schema/index-utils.js +242 -1
  139. package/dist/schema/index-utils.js.map +1 -1
  140. package/dist/schema/index.d.ts +2 -3
  141. package/dist/schema/index.d.ts.map +1 -1
  142. package/dist/schema/index.js +2 -3
  143. package/dist/schema/live-parity.d.ts +3 -1
  144. package/dist/schema/live-parity.d.ts.map +1 -1
  145. package/dist/schema/live-parity.js +23 -2
  146. package/dist/schema/live-parity.js.map +1 -1
  147. package/dist/schema/system-table-shapes.d.ts +13 -0
  148. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  149. package/dist/schema/system-table-shapes.js +14 -1
  150. package/dist/schema/system-table-shapes.js.map +1 -1
  151. package/dist/schema/types.d.ts +17 -10
  152. package/dist/schema/types.d.ts.map +1 -1
  153. package/dist/schema/utils.d.ts +1 -1
  154. package/dist/schema/utils.d.ts.map +1 -1
  155. package/dist/schema/utils.js +3 -3
  156. package/dist/schema/utils.js.map +1 -1
  157. package/dist/schema.js +2 -3
  158. package/dist/smrt-knowledge.json +9 -9
  159. package/dist/system/compatibility.d.ts +42 -0
  160. package/dist/system/compatibility.d.ts.map +1 -1
  161. package/dist/system/compatibility.js +182 -9
  162. package/dist/system/compatibility.js.map +1 -1
  163. package/dist/system/index.d.ts +1 -0
  164. package/dist/system/index.d.ts.map +1 -1
  165. package/dist/system/index.js +3 -2
  166. package/dist/system/retention.d.ts +237 -0
  167. package/dist/system/retention.d.ts.map +1 -0
  168. package/dist/system/retention.js +497 -0
  169. package/dist/system/retention.js.map +1 -0
  170. package/dist/system/schema.d.ts +102 -16
  171. package/dist/system/schema.d.ts.map +1 -1
  172. package/dist/system/schema.js +85 -47
  173. package/dist/system/schema.js.map +1 -1
  174. package/dist/system/types.d.ts +0 -2
  175. package/dist/system/types.d.ts.map +1 -1
  176. package/dist/testing/database.d.ts.map +1 -1
  177. package/dist/testing/database.js.map +1 -1
  178. package/dist/utils/safe-integer.d.ts +19 -0
  179. package/dist/utils/safe-integer.d.ts.map +1 -0
  180. package/dist/utils/safe-integer.js +31 -0
  181. package/dist/utils/safe-integer.js.map +1 -0
  182. package/dist/utils.d.ts +2 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +6 -3
  185. package/dist/utils.js.map +1 -1
  186. package/dist/vite-plugin/index.d.ts.map +1 -1
  187. package/dist/vite-plugin/index.js +4 -62
  188. package/dist/vite-plugin/index.js.map +1 -1
  189. package/package.json +4 -4
  190. package/dist/schema/override-system.d.ts +0 -43
  191. package/dist/schema/override-system.d.ts.map +0 -1
  192. package/dist/schema/override-system.js +0 -205
  193. package/dist/schema/override-system.js.map +0 -1
@@ -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 } 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
  /**
@@ -8,30 +10,6 @@ import { createHash } from "node:crypto";
8
10
  * Converts AST field definitions to database schema definitions
9
11
  */
10
12
  var SchemaGenerator = class {
11
- /**
12
- * Generate schema definition from SMRT object definition
13
- */
14
- generateSchema(objectDef) {
15
- const tableName = this.getTableName(objectDef);
16
- const columns = this.generateColumns(objectDef.fields, objectDef.decoratorConfig);
17
- const indexes = this.generateIndexes(objectDef, columns);
18
- const triggers = this.generateTriggers(objectDef, tableName);
19
- const foreignKeys = this.extractForeignKeys(columns);
20
- const dependencies = this.extractDependencies(objectDef, foreignKeys);
21
- const version = this.generateVersion(objectDef);
22
- this.ensureTenantIdIndex(indexes, columns, tableName);
23
- return {
24
- tableName,
25
- columns,
26
- indexes,
27
- triggers,
28
- foreignKeys,
29
- dependencies,
30
- version,
31
- packageName: this.extractPackageName(objectDef.filePath),
32
- baseClass: objectDef.extends
33
- };
34
- }
35
13
  /**
36
14
  * Convert field type to SQL data type
37
15
  */
@@ -90,55 +68,144 @@ var SchemaGenerator = class {
90
68
  }
91
69
  }
92
70
  /**
93
- * Generate column definitions
71
+ * Whether an existing index already leads with `leadingColumns`, in order.
72
+ *
73
+ * Only an UNQUALIFIED index (no partial `WHERE`, no JSON-path expression)
74
+ * counts. A partial index such as `... WHERE _meta_type = 'Article'` cannot
75
+ * serve a base-class polymorphic query, which carries no subtype predicate,
76
+ * so it must not suppress the standalone index (#2359, review of #2384).
77
+ *
78
+ * A B-tree serves any prefix of its column list, so an index over
79
+ * `(tenant_id, created_at, status)` covers both `(tenant_id)` equality
80
+ * lookups and `(tenant_id, created_at)` ordering — hence the prefix test
81
+ * rather than an exact match (#2363).
94
82
  */
95
- generateColumns(fields, config) {
96
- const columns = {};
97
- columns.id = {
98
- type: this.getIdColumnType(config),
99
- primaryKey: true,
100
- referenceKind: "id",
101
- notNull: true,
102
- description: "Primary identifier"
103
- };
104
- columns.created_at = {
105
- type: "TIMESTAMP",
106
- notNull: true,
107
- defaultValue: "current_timestamp",
108
- description: "Creation timestamp"
109
- };
110
- columns.updated_at = {
111
- type: "TIMESTAMP",
112
- notNull: true,
113
- defaultValue: "current_timestamp",
114
- description: "Last update timestamp"
115
- };
116
- for (const [fieldName, fieldDef] of Object.entries(fields)) {
117
- if (fieldName === "id" || fieldName === "created_at" || fieldName === "updated_at") continue;
118
- if (fieldDef.transient || fieldDef._meta?.transient) continue;
119
- if (fieldDef.type === "oneToMany" || fieldDef.type === "manyToMany") continue;
120
- if (fieldDef.type === "meta") continue;
121
- const column = {
122
- type: this.getRelationshipColumnType(fieldDef),
123
- referenceKind: this.getReferenceKind(fieldDef),
124
- notNull: fieldDef._meta?.nullable ? false : fieldDef.required || false,
125
- unique: fieldDef._meta?.unique || false,
126
- description: fieldDef.description
127
- };
128
- if (fieldDef.default !== void 0 && this.shouldEmitDefault(fieldDef, fieldDef.default)) column.defaultValue = fieldDef.default;
129
- if (fieldDef.type === "foreignKey" && fieldDef.related) {
130
- const [table, columnName = "id"] = fieldDef.related.split(".");
131
- column.foreignKey = {
132
- table,
133
- column: columnName,
134
- onDelete: "CASCADE",
135
- onUpdate: "CASCADE"
136
- };
137
- }
138
- if (fieldName === "slug" || fieldName === "email") column.unique = true;
139
- columns[fieldName] = column;
140
- }
141
- return columns;
83
+ hasUnqualifiedLeadingColumns(indexes, leadingColumns) {
84
+ return indexes.some((index) => !index.where && !index.jsonPath && (index.columns?.length ?? 0) >= leadingColumns.length && leadingColumns.every((column, i) => index.columns[i] === column));
85
+ }
86
+ /**
87
+ * Whether an existing index already serves equality lookups on `column`.
88
+ *
89
+ * Single-column shorthand for {@link hasUnqualifiedLeadingColumns}.
90
+ */
91
+ hasUnqualifiedLeadingIndex(indexes, column) {
92
+ return this.hasUnqualifiedLeadingColumns(indexes, [column]);
93
+ }
94
+ /**
95
+ * The column list of the default list-ordering index for a table.
96
+ *
97
+ * Every generated list surface — REST, MCP, and the SvelteKit list route —
98
+ * pages with `ORDER BY created_at DESC, <pk> ASC` (`DEFAULT_LIST_ORDER_BY`,
99
+ * #2367). On a tenant-scoped table that page is always preceded by a
100
+ * `tenant_id = ?` equality filter from the tenancy interceptor, so the
101
+ * serving index leads with the tenant column and orders inside it.
102
+ *
103
+ * `created_at` is generator-owned on every path — a declared `createdAt`
104
+ * field is rewritten to the same `TIMESTAMP NOT NULL DEFAULT
105
+ * current_timestamp` column so it is never a primary key and never
106
+ * inline-UNIQUE. That is why only `indexes` needs checking for existing
107
+ * coverage: no constraint-backed index can already order this column.
108
+ *
109
+ * Returns `null` when the table has no `created_at` column (no path emits
110
+ * such a table today, but the helper stays total).
111
+ */
112
+ getDefaultListOrderingColumns(columns) {
113
+ if (!columns.created_at) return null;
114
+ const tenantColumn = Object.entries(columns).find(([, columnDef]) => columnDef?.referenceKind === "tenantId")?.[0];
115
+ return tenantColumn ? [tenantColumn, "created_at"] : ["created_at"];
116
+ }
117
+ /**
118
+ * Ensure the table can serve its own default list page from an index.
119
+ *
120
+ * Generated REST/MCP/SvelteKit list routes all page with
121
+ * `ORDER BY created_at DESC, <pk> ASC LIMIT n` and no `created_at` index
122
+ * existed on any schema path (the dead AST path indexed `updated_at`
123
+ * instead), so every default list page was a sequential scan plus a top-N
124
+ * sort of the whole table — 21 ms against 0.1 ms with the right composite on
125
+ * the workload the assessment measured (#2363, finding A2).
126
+ *
127
+ * Emitted shape:
128
+ *
129
+ * - tenant-scoped table → `(<tenant column>, created_at)`. The tenancy
130
+ * interceptor adds `tenant_id = ?` to every list, so the tenant column
131
+ * leads and `created_at` orders within it.
132
+ * - otherwise → `(created_at)`.
133
+ *
134
+ * No `DESC` declaration: PostgreSQL scans a B-tree backwards just as
135
+ * cheaply, and `IndexDefinition` carries no per-column direction. The
136
+ * trailing primary-key tiebreak is left out deliberately — its direction is
137
+ * opposite to `created_at`'s, so no single-direction index can satisfy the
138
+ * whole key anyway; the leading columns turn a full sort into an index scan
139
+ * with an incremental sort over rows that share a timestamp.
140
+ *
141
+ * Call this AFTER {@link appendDeclaredIndexes} and BEFORE
142
+ * {@link ensureReferenceColumnIndexes}: a declared composite that already
143
+ * leads with the same columns suppresses this one, and this composite in
144
+ * turn suppresses the standalone tenant index that would otherwise be a
145
+ * redundant prefix of it.
146
+ */
147
+ ensureDefaultListOrderingIndex(indexes, columns, tableName) {
148
+ const orderingColumns = this.getDefaultListOrderingColumns(columns);
149
+ if (!orderingColumns) return;
150
+ if (this.hasUnqualifiedLeadingColumns(indexes, orderingColumns)) return;
151
+ const name = `${tableName}_${orderingColumns.join("_")}_idx`;
152
+ if (indexes.some((index) => index.name === name)) return;
153
+ indexes.push({
154
+ name,
155
+ columns: orderingColumns
156
+ });
157
+ }
158
+ /**
159
+ * The `(meta_type, meta_id)` owner-lookup columns for a polymorphic
160
+ * association table, or `null` when the table isn't one.
161
+ *
162
+ * `SmrtPolymorphicAssociation` contributes `metaType`/`metaId`/`role` (and
163
+ * `sortOrder`) to any concrete subclass's manifest — the scanner's
164
+ * `FRAMEWORK_ABSTRACT_BASE_NAMES` wiring merges the abstract base's fields
165
+ * directly into the subclass, because the base carries no `@smrt()`
166
+ * decorator of its own to declare an index on. Detected structurally, by
167
+ * the same three-column test `cascade.ts`'s `isPolymorphicAssociationClass`
168
+ * uses on registry field maps: requiring all three keeps an unrelated class
169
+ * that merely happens to carry a `metaType` column from being treated as an
170
+ * association table.
171
+ */
172
+ getPolymorphicAssociationOwnerColumns(columns) {
173
+ if (!Object.hasOwn(columns, "meta_type") || !Object.hasOwn(columns, "meta_id") || !Object.hasOwn(columns, "role")) return null;
174
+ return ["meta_type", "meta_id"];
175
+ }
176
+ /**
177
+ * Ensure a polymorphic association table indexes its owner lookup
178
+ * `(meta_type, meta_id)` (#2364, epic #2382 finding A3).
179
+ *
180
+ * A concrete association's `conflictColumns` lead with its own FK (e.g.
181
+ * `AssetAssociation`'s `asset_id, meta_type, meta_id, role`), so
182
+ * `meta_type`/`meta_id` sit in the *middle* of that unique index — a
183
+ * lookup like `AssetAssociationCollection.byLeft(metaType, metaId)`
184
+ * ("what points at this target") filters columns 2-3 of a 4-column index
185
+ * and cannot use it as a leading prefix. `metaId` is deliberately a bare
186
+ * string, never a typed `@foreignKey`/`@crossPackageRef` (there is no
187
+ * single target table), so {@link ensureReferenceColumnIndexes} never
188
+ * covers it either.
189
+ *
190
+ * Call this alongside {@link ensureDefaultListOrderingIndex}, before
191
+ * {@link ensureReferenceColumnIndexes}.
192
+ *
193
+ * Blast radius: this applies retroactively to every
194
+ * `SmrtPolymorphicAssociation` subclass across every package on the next
195
+ * `db:migrate`, not only the ones a given change touches — roll the wave
196
+ * out with `smrt db:migrate --postgres-safe` (#2362), same as any other
197
+ * bulk index addition.
198
+ */
199
+ ensurePolymorphicAssociationIndex(indexes, columns, tableName) {
200
+ const ownerColumns = this.getPolymorphicAssociationOwnerColumns(columns);
201
+ if (!ownerColumns) return;
202
+ if (this.hasUnqualifiedLeadingColumns(indexes, ownerColumns)) return;
203
+ const name = `${tableName}_${ownerColumns.join("_")}_idx`;
204
+ if (indexes.some((index) => index.name === name)) return;
205
+ indexes.push({
206
+ name,
207
+ columns: ownerColumns
208
+ });
142
209
  }
143
210
  /**
144
211
  * Append `@smrt({ indexes: [...] })` declarations to a generated index set
@@ -146,10 +213,13 @@ var SchemaGenerator = class {
146
213
  *
147
214
  * Shared by every schema path — build-time AST, runtime registry CTI/STI, and
148
215
  * 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).
216
+ * was derived. Call it *first*, ahead of
217
+ * {@link ensureDefaultListOrderingIndex} (#2363) and
218
+ * {@link ensureReferenceColumnIndexes}: a declared composite leading with a
219
+ * reference column (e.g. `tenant_id`), or with the default ordering columns,
220
+ * is exactly what those helpers' leads-with check is meant to defer to, and
221
+ * appending afterwards would leave the table with a redundant standalone
222
+ * index (#2384, #2359).
153
223
  *
154
224
  * Nothing is dropped quietly. A column that resolves to no column on the
155
225
  * table, a malformed entry, and a name collision with a different index are
@@ -195,6 +265,7 @@ var SchemaGenerator = class {
195
265
  };
196
266
  if (typeof spec !== "object" || spec === null || Array.isArray(spec)) throw new Error(`Declared index on "${tableName}" must be an object: got ${describe()}.`);
197
267
  if (typeof spec.name !== "string" || spec.name.length === 0) throw new Error(`Declared index on "${tableName}" needs a non-empty "name": got ${describe()}.`);
268
+ assertIdentifierFits(spec.name, "Declared index", `table "${tableName}"`);
198
269
  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
270
  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
271
  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,64 +289,189 @@ var SchemaGenerator = class {
218
289
  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
290
  }
220
291
  /**
221
- * Ensure a tenancy-injected `tenant_id` column has an index leading with it.
222
- *
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).
229
- *
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.
292
+ * Ensure every reference column `@foreignKey`, `@crossPackageRef`, and the
293
+ * tenancy-injected `tenant_id` — has an index leading with it.
294
+ *
295
+ * These are the columns every relationship load, `include:` batch, reverse
296
+ * ownership lookup and tenant-scoped read filters on. Before #2359 only the
297
+ * registry (test) paths indexed foreign keys, the manifest (production)
298
+ * paths indexed none of them, and `tenant_id` was indexed nowhere (#2356):
299
+ * 196/231 `@foreignKey` and 91/92 `@crossPackageRef` columns shipped with no
300
+ * serving index. Every schema path now calls this helper so the four paths
301
+ * stay in step (see `schema-path-parity.test.ts`).
302
+ *
303
+ * "Leading with it" rather than "always add": a table that already has an
304
+ * unqualified index starting on the column (commonly the `conflictColumns`
305
+ * unique index, or an `indexed: true` opt-in) is already served, and a
306
+ * standalone duplicate would only cost writes. A *partial* index does not
307
+ * count — see {@link hasUnqualifiedLeadingIndex}.
308
+ *
309
+ * Reference columns are therefore always indexed; `indexed: true` on one of
310
+ * them is redundant and harmless.
311
+ *
312
+ * Call this LAST in every path, after every other index (conflict, opt-in,
313
+ * unique, the declared composites, the default list-ordering composite, and
314
+ * any future addition) has been appended, so the leads-with suppression sees
315
+ * the full set. In particular {@link ensureDefaultListOrderingIndex} runs
316
+ * first on a tenant-scoped table: its `(tenant_id, created_at)` composite
317
+ * serves the tenant equality filter too, so no standalone tenant index is
318
+ * added (#2363).
233
319
  */
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;
320
+ ensureReferenceColumnIndexes(indexes, columns, tableName) {
321
+ for (const [columnName, columnDef] of Object.entries(columns)) {
322
+ const kind = columnDef?.referenceKind;
323
+ if (kind !== "foreignKey" && kind !== "crossPackageRef" && kind !== "tenantId") continue;
324
+ if (columnDef?.primaryKey || columnDef?.unique) continue;
325
+ if (this.hasUnqualifiedLeadingIndex(indexes, columnName)) continue;
326
+ const name = `${tableName}_${columnName}_idx`;
327
+ if (indexes.some((index) => index.name === name)) continue;
328
+ indexes.push({
329
+ name,
330
+ columns: [columnName]
331
+ });
332
+ }
333
+ }
334
+ /**
335
+ * Whether `conflictColumns` names exactly the primary key column(s).
336
+ *
337
+ * `ON CONFLICT (id)` binds to the primary-key constraint on every engine, so
338
+ * a separate unique index over the same column set is a second B-tree over
339
+ * the same random UUIDs with nothing to add (#2359, finding A5).
340
+ */
341
+ conflictColumnsArePrimaryKey(conflictColumns, columns) {
342
+ const primaryKeyColumns = Object.entries(columns).filter(([, columnDef]) => columnDef?.primaryKey === true).map(([name]) => name);
343
+ return primaryKeyColumns.length > 0 && primaryKeyColumns.length === conflictColumns.length && primaryKeyColumns.every((column) => conflictColumns.includes(column));
344
+ }
345
+ /**
346
+ * The table's tenant column as the generated columns describe it: the
347
+ * column carrying `referenceKind: 'tenantId'` (set from the field's
348
+ * `__tenancy.isTenantIdField` marker on every path).
349
+ */
350
+ findTenantColumn(columns) {
351
+ for (const [columnName, columnDef] of Object.entries(columns)) if (columnDef?.referenceKind === "tenantId") return columnName;
352
+ }
353
+ /**
354
+ * Resolve the conflict target the unique conflict index must cover
355
+ * (#2360). `config.conflictColumns` wins when the caller resolved it —
356
+ * registry callers pass `ObjectRegistry.getConflictColumns()`, the manifest
357
+ * pipeline passes the normalized `decoratorConfig` — otherwise the strategy
358
+ * default, led by the tenant column when `config.tenantScoped` names a
359
+ * tenant field that exists as a column. Both roads apply the same rule as
360
+ * `ObjectRegistry.getConflictColumns()`, so schema and upsert agree by
361
+ * construction (`schema-path-parity.test.ts`).
362
+ */
363
+ resolveConflictTarget(strategy, columns, config) {
364
+ const tenantColumn = this.findTenantColumn(columns);
365
+ if (config?.conflictColumns && config.conflictColumns.length > 0) return {
366
+ conflictColumns: [...config.conflictColumns],
367
+ tenantColumn
368
+ };
369
+ const tenantScoped = config?.tenantScoped;
370
+ return {
371
+ conflictColumns: resolveConflictColumns({
372
+ strategy,
373
+ tenantColumn: tenantScoped ? resolveTenantColumn(typeof tenantScoped === "object" ? tenantScoped.field ?? "tenantId" : "tenantId", (fieldName) => Boolean(columns[this.toSnakeCase(fieldName)]), (fieldName) => this.toSnakeCase(fieldName)) : void 0
374
+ }),
375
+ tenantColumn
376
+ };
377
+ }
378
+ /**
379
+ * Keep `(slug, context)` lookups served when `conflictColumns` are custom.
380
+ *
381
+ * The default conflict index is `(slug, context)`, and `loadFromSlug()`,
382
+ * `getId()` and `getSavedId()` all filter on `slug`/`context` regardless of
383
+ * the configured conflict key. A class that declares custom conflict
384
+ * columns replaces that unique index and, before #2359, left those lookups
385
+ * with no index at all — 120 tables (finding A7).
386
+ *
387
+ * Emitting a plain `(slug, context)` index is the safer of the two fixes:
388
+ * routing the lookups through the conflict key would change which row a
389
+ * slug resolves to on every such class, whereas an extra non-unique index
390
+ * is purely additive. It is skipped when an unqualified index already
391
+ * serves the lookup — one leading with `slug` (e.g. custom conflict columns
392
+ * that still start with it), or the tenant-led default key
393
+ * `(tenant_id, slug, context)` of a tenant-scoped table (#2360): every slug
394
+ * lookup on such a table carries the tenant predicate (#2365) and is served
395
+ * by that prefix, so a second index would only cost writes.
396
+ */
397
+ ensureSlugLookupIndex(indexes, columns, tableName) {
398
+ if (!columns.slug || !columns.context) return;
399
+ const tenantColumn = this.findTenantColumn(columns);
400
+ if (indexes.some((index) => !index.where && !index.jsonPath && servesSlugLookup(index.columns ?? [], tenantColumn))) return;
401
+ const name = `${tableName}_slug_context_idx`;
402
+ if (indexes.some((index) => index.name === name)) return;
238
403
  indexes.push({
239
- name: `${tableName}_${tenantColumn}_idx`,
240
- columns: [tenantColumn]
404
+ name,
405
+ columns: ["slug", "context"]
241
406
  });
242
407
  }
243
408
  /**
244
- * Generate index definitions
409
+ * Emit the unique conflict index of an STI table from the resolved
410
+ * conflict target (#2360): the STI default `(slug, context, _meta_type)`,
411
+ * a custom `@smrt({ conflictColumns })` declared on the STI root, or the
412
+ * default led by the tenant column when the root is tenant-scoped. Shared
413
+ * by the registry and manifest STI paths so they cannot drift.
414
+ *
415
+ * No index is emitted when the target is the primary key itself
416
+ * (`ON CONFLICT (id)` binds to the PK constraint), matching the CTI paths.
245
417
  */
246
- generateIndexes(objectDef, columns) {
247
- const indexes = [];
248
- const tableName = this.getTableName(objectDef);
249
- for (const [columnName, columnDef] of Object.entries(columns)) if (columnDef.foreignKey) indexes.push({
250
- name: `idx_${tableName}_${columnName}`,
251
- columns: [columnName],
252
- description: `Index for foreign key ${columnName}`
253
- });
418
+ emitStiConflictIndex(indexes, columns, tableName, config) {
419
+ const { conflictColumns, tenantColumn } = this.resolveConflictTarget("sti", columns, config);
420
+ if (this.conflictColumnsArePrimaryKey(conflictColumns, columns)) return;
254
421
  indexes.push({
255
- name: `idx_${tableName}_updated_at`,
256
- columns: ["updated_at"],
257
- description: "Index for timestamp queries"
258
- });
259
- for (const [columnName, columnDef] of Object.entries(columns)) if (columnDef.unique && !columnDef.primaryKey) indexes.push({
260
- name: `idx_${tableName}_${columnName}_unique`,
261
- columns: [columnName],
262
- unique: true,
263
- description: `Unique index for ${columnName}`
422
+ name: conflictIndexName(tableName, conflictColumns, tenantColumn),
423
+ columns: conflictColumns,
424
+ unique: true
264
425
  });
265
- this.appendDeclaredIndexes(indexes, objectDef.decoratorConfig?.indexes, columns, tableName);
266
- return indexes;
267
426
  }
268
427
  /**
269
- * Generate trigger definitions for automatic timestamp updates
428
+ * Emit unique indexes for `@field({ unique: true })` columns on an STI table.
429
+ *
430
+ * STI columns are the union of every class in the hierarchy, all nullable,
431
+ * so a column-level `UNIQUE` (what CTI renders inline) is not enough on its
432
+ * own: the migration differ cannot add a column constraint to an existing
433
+ * table, but it can add an index. Two shapes (#2359, finding A4):
434
+ *
435
+ * - declared on the STI **base** (and therefore inherited by every class in
436
+ * the table): one full unique index, table-wide like CTI;
437
+ * - declared only on a **descendant**: one partial unique index per class
438
+ * that carries the flag (the declaring class and, through inherited field
439
+ * metadata, its own descendants), `WHERE _meta_type = '<qualified>'`, so
440
+ * siblings that merely share the column name are not constrained.
441
+ * Uniqueness is then enforced per concrete class, not across the subtree
442
+ * — a documented limitation of the discriminator-partial shape. Engines
443
+ * without partial indexes (DuckDB, JSON) skip this shape entirely rather
444
+ * than widen it to a table-wide UNIQUE (see DuckDBStrategy.generateIndexes
445
+ * and SchemaComparer.compareIndexes).
446
+ *
447
+ * @param declarers - column name → classes whose field metadata carries
448
+ * `unique: true` for that column, in hierarchy order (base first).
270
449
  */
271
- generateTriggers(_objectDef, tableName) {
272
- return [{
273
- name: `trg_${tableName}_updated_at`,
274
- when: "BEFORE",
275
- event: "UPDATE",
276
- body: `UPDATE ${quoteIdentifier(tableName)} SET "updated_at" = current_timestamp WHERE "id" = NEW."id";`,
277
- description: "Automatically update updated_at timestamp"
278
- }];
450
+ emitStiUniqueIndexes(indexes, declarers, baseClassName, tableName) {
451
+ for (const [columnName, classNames] of declarers.entries()) {
452
+ if (classNames.has(baseClassName)) {
453
+ indexes.push({
454
+ name: `${tableName}_${columnName}_unique_idx`,
455
+ columns: [columnName],
456
+ unique: true
457
+ });
458
+ continue;
459
+ }
460
+ for (const className of classNames) {
461
+ const simpleName = className.includes(":") ? className.slice(className.lastIndexOf(":") + 1) : className;
462
+ let name = `${tableName}_${columnName}_${this.toSnakeCase(simpleName)}_unique_idx`;
463
+ if (indexes.some((index) => index.name === name)) {
464
+ const digest = createHash("sha256").update(className).digest("hex").slice(0, 6);
465
+ name = `${tableName}_${columnName}_${this.toSnakeCase(simpleName)}_${digest}_unique_idx`;
466
+ }
467
+ indexes.push({
468
+ name,
469
+ columns: [columnName],
470
+ unique: true,
471
+ where: `_meta_type = ${quoteStringLiteral(className)}`
472
+ });
473
+ }
474
+ }
279
475
  }
280
476
  /**
281
477
  * Extract foreign key definitions
@@ -292,45 +488,12 @@ var SchemaGenerator = class {
292
488
  return foreignKeys;
293
489
  }
294
490
  /**
295
- * Extract schema dependencies from foreign keys and inheritance
296
- */
297
- extractDependencies(objectDef, foreignKeys) {
298
- const dependencies = /* @__PURE__ */ new Set();
299
- for (const fk of foreignKeys) dependencies.add(fk.referencesTable);
300
- if (objectDef.extends && objectDef.extends !== "SmrtObject" && objectDef.extends !== "SmrtCollection") dependencies.add(this.classNameToTableName(objectDef.extends));
301
- return Array.from(dependencies);
302
- }
303
- /**
304
- * Generate version hash for schema
305
- */
306
- generateVersion(objectDef) {
307
- const content = JSON.stringify({
308
- className: objectDef.className,
309
- fields: objectDef.fields,
310
- extends: objectDef.extends
311
- });
312
- return createHash("sha256").update(content).digest("hex").substring(0, 8);
313
- }
314
- /**
315
- * Get table name from object definition
316
- */
317
- getTableName(objectDef) {
318
- return this.classNameToTableName(objectDef.className);
319
- }
320
- /**
321
491
  * Convert class name to table name (camelCase to snake_case, pluralized)
322
492
  */
323
493
  classNameToTableName(className) {
324
494
  return classnameToTablename(className);
325
495
  }
326
496
  /**
327
- * Extract package name from file path
328
- */
329
- extractPackageName(filePath) {
330
- const match = filePath.match(/packages\/([^/]+)/);
331
- return match ? match[1] : "unknown";
332
- }
333
- /**
334
497
  * Generate schema definition from ObjectRegistry fields (runtime)
335
498
  *
336
499
  * This method builds a SchemaDefinition from ObjectRegistry cached fields,
@@ -435,39 +598,25 @@ var SchemaGenerator = class {
435
598
  this.reconcileRegistryForeignKeyColumnTypes(columns, fields, config?.registry);
436
599
  const indexes = [];
437
600
  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,
601
+ const { conflictColumns, tenantColumn } = this.resolveConflictTarget("cti", columns, config);
602
+ if (!this.conflictColumnsArePrimaryKey(conflictColumns, columns)) indexes.push({
603
+ name: conflictIndexName(tableName, conflictColumns, tenantColumn),
447
604
  columns: conflictColumns,
448
605
  unique: true,
449
606
  description: `Unique conflict index for ${className}`
450
607
  });
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
608
  }
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
- });
609
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
464
610
  for (const colName of indexedColumns) indexes.push({
465
611
  name: `${tableName}_${colName}_idx`,
466
612
  columns: [colName],
467
613
  description: `Index for ${colName}`
468
614
  });
469
615
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
470
- this.ensureTenantIdIndex(indexes, columns, tableName);
616
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
617
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
618
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
619
+ enforceIdentifierLimits(tableName, indexes);
471
620
  return {
472
621
  tableName,
473
622
  columns,
@@ -487,7 +636,14 @@ var SchemaGenerator = class {
487
636
  * - _meta_type: Discriminator column to identify class type
488
637
  * - _meta_data: JSON column for flexible field storage
489
638
  * - Union of all FK columns from descendants (all nullable)
490
- * - Partial indexes for FK columns (filtered by _meta_type)
639
+ * - One plain index per reference column (FK / cross-package ref /
640
+ * tenant_id). Plain rather than partial-by-class: base-class polymorphic
641
+ * queries carry no `_meta_type` predicate, so a partial index could not
642
+ * serve them, while a plain index serves both those and the child
643
+ * collections' `_meta_type = X AND fk = ?` filters — one index per column
644
+ * instead of one per (column, class) (#2359).
645
+ * - Unique indexes for `@field({ unique: true })` columns — see
646
+ * {@link emitStiUniqueIndexes}.
491
647
  *
492
648
  * @param baseClassName - Base class name for the STI hierarchy
493
649
  * @param tableName - Shared table name (from base class)
@@ -547,12 +703,11 @@ var SchemaGenerator = class {
547
703
  let hasUpdatedAt = false;
548
704
  const descendants = ObjectRegistry.getDescendants(baseClassName);
549
705
  const allClassNames = [baseClassName, ...descendants];
550
- const fkColumnsByClass = /* @__PURE__ */ new Map();
551
706
  const indexedMetaFields = /* @__PURE__ */ new Set();
552
707
  const indexedStiColumns = /* @__PURE__ */ new Set();
708
+ const uniqueColumnDeclarers = /* @__PURE__ */ new Map();
553
709
  for (const className of allClassNames) {
554
710
  const classFields = await ObjectRegistry.getAllFields(className);
555
- fkColumnsByClass.set(className, /* @__PURE__ */ new Set());
556
711
  for (const [fieldName, field] of classFields.entries()) {
557
712
  if (field.transient || field._meta?.transient) continue;
558
713
  if (field.type === "meta" && (field.indexed === true || field._meta?.indexed === true)) indexedMetaFields.add(fieldName);
@@ -582,6 +737,14 @@ var SchemaGenerator = class {
582
737
  if (field.type === "oneToMany" || field.type === "manyToMany") continue;
583
738
  if (field.type === "meta") continue;
584
739
  const columnName = this.toSnakeCase(fieldName);
740
+ if (field._meta?.unique === true) {
741
+ let declarers = uniqueColumnDeclarers.get(columnName);
742
+ if (!declarers) {
743
+ declarers = /* @__PURE__ */ new Set();
744
+ uniqueColumnDeclarers.set(columnName, declarers);
745
+ }
746
+ declarers.add(className);
747
+ }
585
748
  if (columns[columnName]) continue;
586
749
  const columnDef = {
587
750
  type: this.getRelationshipColumnType(field),
@@ -595,15 +758,12 @@ var SchemaGenerator = class {
595
758
  if (field.type === "foreignKey") {
596
759
  const relatedName = field.related;
597
760
  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
- }
761
+ if (relatedName) columnDef.foreignKey = {
762
+ table: this.classNameToTableName(relatedName),
763
+ column: "id",
764
+ onDelete: onDeleteAction || "CASCADE",
765
+ onUpdate: "CASCADE"
766
+ };
607
767
  }
608
768
  if ((field._meta?.indexed === true || field.indexed === true) && !columnDef.foreignKey) indexedStiColumns.add(columnName);
609
769
  columns[columnName] = columnDef;
@@ -626,32 +786,14 @@ var SchemaGenerator = class {
626
786
  this.reconcileRegistryForeignKeyColumnTypes(columns, classFields, ObjectRegistry);
627
787
  }
628
788
  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
- });
789
+ this.emitStiConflictIndex(indexes, columns, tableName, config);
644
790
  indexes.push({
645
791
  name: `${tableName}_meta_type_idx`,
646
792
  columns: ["_meta_type"],
647
793
  description: "Index for type discriminator queries"
648
794
  });
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
- });
795
+ this.emitStiUniqueIndexes(indexes, uniqueColumnDeclarers, baseClassName, tableName);
796
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
655
797
  for (const fieldName of indexedMetaFields) indexes.push({
656
798
  name: `${tableName}_meta_${this.toSnakeCase(fieldName)}_idx`,
657
799
  columns: [],
@@ -667,7 +809,10 @@ var SchemaGenerator = class {
667
809
  description: `Index for ${colName}`
668
810
  });
669
811
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
670
- this.ensureTenantIdIndex(indexes, columns, tableName);
812
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
813
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
814
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
815
+ enforceIdentifierLimits(tableName, indexes);
671
816
  return {
672
817
  tableName,
673
818
  columns,
@@ -732,15 +877,15 @@ var SchemaGenerator = class {
732
877
  notNull: true,
733
878
  default: "current_timestamp"
734
879
  };
735
- const descendants = this.findDescendantsInManifest(baseClassName, manifest);
736
- const allClassNames = [baseClassName, ...descendants];
737
- const fkColumnsByClass = /* @__PURE__ */ new Map();
880
+ const baseKey = this.resolveManifestClassKey(baseClassName, manifest);
881
+ const descendants = this.findDescendantsInManifest(baseKey, manifest);
882
+ const allClassNames = [baseKey, ...descendants];
738
883
  const indexedMetaFields = /* @__PURE__ */ new Set();
739
884
  const indexedStiColumns = /* @__PURE__ */ new Set();
885
+ const uniqueColumnDeclarers = /* @__PURE__ */ new Map();
740
886
  for (const className of allClassNames) {
741
887
  const objDef = manifest.objects[className];
742
888
  if (!objDef) continue;
743
- fkColumnsByClass.set(className, /* @__PURE__ */ new Set());
744
889
  for (const [fieldName, field] of Object.entries(objDef.fields)) {
745
890
  if (field.transient || field._meta?.transient) continue;
746
891
  const indexedField = field;
@@ -749,6 +894,14 @@ var SchemaGenerator = class {
749
894
  if (field.type === "oneToMany" || field.type === "manyToMany") continue;
750
895
  if (field.type === "meta") continue;
751
896
  const columnName = this.toSnakeCase(fieldName);
897
+ if (field._meta?.unique === true) {
898
+ let declarers = uniqueColumnDeclarers.get(columnName);
899
+ if (!declarers) {
900
+ declarers = /* @__PURE__ */ new Set();
901
+ uniqueColumnDeclarers.set(columnName, declarers);
902
+ }
903
+ declarers.add(className);
904
+ }
752
905
  if (columns[columnName]) continue;
753
906
  const columnDef = {
754
907
  type: this.getRelationshipColumnType(field),
@@ -756,29 +909,18 @@ var SchemaGenerator = class {
756
909
  notNull: false
757
910
  };
758
911
  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
912
  if ((indexedField.indexed === true || field._meta?.indexed === true) && field.type !== "foreignKey") indexedStiColumns.add(columnName);
761
913
  columns[columnName] = columnDef;
762
914
  }
763
915
  }
764
916
  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
- });
917
+ this.emitStiConflictIndex(indexes, columns, tableName, config);
778
918
  indexes.push({
779
919
  name: `${tableName}_meta_type_idx`,
780
920
  columns: ["_meta_type"]
781
921
  });
922
+ this.emitStiUniqueIndexes(indexes, uniqueColumnDeclarers, baseKey, tableName);
923
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
782
924
  for (const fieldName of indexedMetaFields) indexes.push({
783
925
  name: `${tableName}_meta_${this.toSnakeCase(fieldName)}_idx`,
784
926
  columns: [],
@@ -792,7 +934,10 @@ var SchemaGenerator = class {
792
934
  columns: [colName]
793
935
  });
794
936
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
795
- this.ensureTenantIdIndex(indexes, columns, tableName);
937
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
938
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
939
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
940
+ enforceIdentifierLimits(tableName, indexes);
796
941
  const schemaDefinition = {
797
942
  tableName,
798
943
  columns: this.convertManifestColumnsToSchemaColumns(columns),
@@ -874,23 +1019,22 @@ var SchemaGenerator = class {
874
1019
  columns[columnName] = columnDef;
875
1020
  }
876
1021
  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,
1022
+ const { conflictColumns, tenantColumn } = this.resolveConflictTarget("cti", columns, config);
1023
+ if (!this.conflictColumnsArePrimaryKey(conflictColumns, columns)) indexes.push({
1024
+ name: conflictIndexName(tableName, conflictColumns, tenantColumn),
885
1025
  columns: conflictColumns,
886
1026
  unique: true
887
1027
  });
1028
+ this.ensureSlugLookupIndex(indexes, columns, tableName);
888
1029
  for (const colName of indexedColumns) indexes.push({
889
1030
  name: `${tableName}_${colName}_idx`,
890
1031
  columns: [colName]
891
1032
  });
892
1033
  this.appendDeclaredIndexes(indexes, config?.indexes, columns, tableName);
893
- this.ensureTenantIdIndex(indexes, columns, tableName);
1034
+ this.ensureDefaultListOrderingIndex(indexes, columns, tableName);
1035
+ this.ensurePolymorphicAssociationIndex(indexes, columns, tableName);
1036
+ this.ensureReferenceColumnIndexes(indexes, columns, tableName);
1037
+ enforceIdentifierLimits(tableName, indexes);
894
1038
  const schemaDefinition = {
895
1039
  tableName,
896
1040
  columns: this.convertManifestColumnsToSchemaColumns(columns),
@@ -919,6 +1063,18 @@ var SchemaGenerator = class {
919
1063
  };
920
1064
  }
921
1065
  /**
1066
+ * Resolve a class name (simple or qualified) to the key it is stored under
1067
+ * in `manifest.objects`. Falls back to the input when the manifest does not
1068
+ * carry the class (e.g. an external STI base absent from the aggregated
1069
+ * manifest), so callers can keep using it as an opaque label.
1070
+ */
1071
+ resolveManifestClassKey(className, manifest) {
1072
+ if (manifest.objects[className]) return className;
1073
+ const simpleName = className.includes(":") ? className.slice(className.lastIndexOf(":") + 1) : className;
1074
+ for (const [key, obj] of Object.entries(manifest.objects)) if (obj.qualifiedName === className || obj.className === simpleName) return key;
1075
+ return className;
1076
+ }
1077
+ /**
922
1078
  * Find all descendants of a class in the manifest
923
1079
  *
924
1080
  * Note: Manifest keys are now qualified names (@pkg:ClassName) but `extends` field