@rebasepro/server-postgres 0.16.1-canary.g2d1aec8 → 0.16.1-canary.g37d7b4f

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 (60) hide show
  1. package/dist/PostgresBackendDriver.d.ts +59 -5
  2. package/dist/{backup-service-BZoixhVl.js → backup-service-BtgHxfFm.js} +5 -4
  3. package/dist/{backup-service-BZoixhVl.js.map → backup-service-BtgHxfFm.js.map} +1 -1
  4. package/dist/cli-helpers.d.ts +41 -0
  5. package/dist/{ensure-collection-tables-jYsvOLZF.js → collection-index-DxJBvVTH.js} +466 -1915
  6. package/dist/collection-index-DxJBvVTH.js.map +1 -0
  7. package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
  8. package/dist/connection-GOKU3Hu5.js.map +1 -0
  9. package/dist/connection.d.ts +16 -0
  10. package/dist/{ensure-collection-policies-CVfVHS6o.js → ensure-collection-policies-DFpOl8SM.js} +4 -4
  11. package/dist/{ensure-collection-policies-CVfVHS6o.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
  12. package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
  13. package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
  14. package/dist/index.es.js +20 -7495
  15. package/dist/index.es.js.map +1 -1
  16. package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DNzaWd4C.js} +3 -3
  17. package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DNzaWd4C.js.map} +1 -1
  18. package/dist/{rls-enforcement-CsD7nZDn.js → rls-enforcement-CInuYj1-.js} +14 -4
  19. package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
  20. package/dist/schema/collection-index.d.ts +182 -0
  21. package/dist/schema/generated-schema-staleness.d.ts +19 -0
  22. package/dist/schema/introspect-db-inference.d.ts +1 -1
  23. package/dist/schema/introspect-db-logic.d.ts +4 -4
  24. package/dist/schema/introspect-db-project.d.ts +2 -2
  25. package/dist/{src-BBFsDaeA.js → src-DiDgtX8P.js} +97 -1
  26. package/dist/src-DiDgtX8P.js.map +1 -0
  27. package/dist/websocket-CUnpSe8v.js +8188 -0
  28. package/dist/websocket-CUnpSe8v.js.map +1 -0
  29. package/dist/websocket.d.ts +27 -0
  30. package/package.json +7 -7
  31. package/src/PostgresBackendDriver.ts +150 -59
  32. package/src/backup/backup-service.ts +1 -1
  33. package/src/cli-helpers.ts +117 -2
  34. package/src/cli.ts +22 -0
  35. package/src/connection.ts +37 -3
  36. package/src/databasePoolManager.ts +5 -2
  37. package/src/schema/collection-index.ts +427 -0
  38. package/src/schema/ensure-collection-tables.ts +21 -0
  39. package/src/schema/generate-drizzle-schema-logic.ts +23 -11
  40. package/src/schema/generate-drizzle-schema.ts +13 -2
  41. package/src/schema/generate-postgres-ddl-logic.ts +18 -6
  42. package/src/schema/generate-postgres-ddl.ts +13 -2
  43. package/src/schema/generated-schema-staleness.ts +114 -1
  44. package/src/schema/introspect-db-inference.ts +1 -1
  45. package/src/schema/introspect-db-logic.ts +4 -4
  46. package/src/schema/introspect-db-project.ts +2 -2
  47. package/src/schema/introspect-db.ts +2 -2
  48. package/src/services/collection-helpers.ts +3 -2
  49. package/src/services/realtimeService.ts +17 -4
  50. package/src/websocket.ts +47 -3
  51. package/dist/connection-BuZ97wsr.js.map +0 -1
  52. package/dist/data_driver-ULAyJEi9.js +0 -193
  53. package/dist/data_driver-ULAyJEi9.js.map +0 -1
  54. package/dist/ensure-collection-tables-jYsvOLZF.js.map +0 -1
  55. package/dist/rls-enforcement-CsD7nZDn.js.map +0 -1
  56. package/dist/src-BBFsDaeA.js.map +0 -1
  57. package/dist/utils/table-classification.d.ts +0 -8
  58. package/dist/websocket-BVgDVO-V.js +0 -614
  59. package/dist/websocket-BVgDVO-V.js.map +0 -1
  60. package/src/utils/table-classification.ts +0 -16
@@ -0,0 +1,427 @@
1
+ /**
2
+ * The one place a collection's `indexes:` block becomes `CREATE INDEX`.
3
+ *
4
+ * Like `search-column.ts` and `vector-index.ts`, this module exists so the DDL
5
+ * generator and the boot-time ensure render the *same* specification rather
6
+ * than describing the same index twice, differently.
7
+ *
8
+ * ## Why every form here is core Postgres
9
+ *
10
+ * `rebase db push` runs `atlas schema apply`, which materialises the desired
11
+ * state in a bare scratch database to plan against. `--exclude` does not
12
+ * suppress that replay, and `CREATE EXTENSION` cannot go in the file. So an
13
+ * index wanting `gin_trgm_ops` or `vector_cosine_ops` would parse, plan, and
14
+ * then fail against a database the author has never heard of. Those are
15
+ * refused here instead, and redirected to the feature that owns them: trigram
16
+ * search is `search:`, ANN is a `vector` property.
17
+ *
18
+ * Verified against atlas v1.2.3 and Postgres 18 before this was written: plain,
19
+ * composite with `DESC NULLS LAST`, partial, unique, covering `INCLUDE`, `GIN`
20
+ * and expression indexes all parse, apply, and re-plan clean. The Atlas
21
+ * limitation that forced the search carve-out is that it will not parse a file
22
+ * containing a function *definition* — a function *call* inside an index is
23
+ * fine. That is why this module needs no carve-out and search did.
24
+ *
25
+ * ## Why the name carries a hash
26
+ *
27
+ * `CREATE INDEX IF NOT EXISTS` is a **name** check, not a definition check. A
28
+ * readable name means a changed declaration keeps the old index and reports
29
+ * success, forever. Hashing the index's *semantics* into its name makes a
30
+ * redefinition a different object: the new one is built before the old one is
31
+ * dropped, there is never a window with no index, and drift detection reduces
32
+ * to a set difference over names.
33
+ */
34
+ import type { CollectionConfig, CollectionIndex, IndexPredicate, Property, ResolvedRelation } from "@rebasepro/types";
35
+ import { isPostgresCollectionConfig } from "@rebasepro/types";
36
+ import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
37
+ import { sha1Hex, truncateToBytes } from "@rebasepro/utils";
38
+
39
+ /** Resolve a property key to its column name. Injected to avoid an import cycle. */
40
+ export type ResolveColumnName = (propName: string, prop?: Property | null) => string;
41
+
42
+ export type IndexMethod = "btree" | "gin" | "brin";
43
+
44
+ /** A key column, with the ordering Postgres will actually apply. */
45
+ export interface ResolvedIndexKey {
46
+ column: string;
47
+ /** Always concrete. `btree` defaults ascending; unordered methods report `asc`. */
48
+ direction: "asc" | "desc";
49
+ /** Postgres's own default: `last` under `asc`, `first` under `desc`. */
50
+ nulls: "first" | "last";
51
+ }
52
+
53
+ /** A predicate resolved onto column names, ready to render and to hash. */
54
+ export type ResolvedPredicate =
55
+ | { column: string; op: "=" | "!=" | "<" | "<=" | ">" | ">="; value: string | number | boolean }
56
+ | { column: string; op: "is null" | "is not null" }
57
+ | { column: string; op: "in"; value: readonly (string | number)[] }
58
+ | { and: readonly ResolvedPredicate[] };
59
+
60
+ /** One index, fully resolved. The only shape the renderers accept. */
61
+ export interface CollectionIndexSpec {
62
+ schema: string;
63
+ table: string;
64
+ method: IndexMethod;
65
+ unique: boolean;
66
+ keys: ResolvedIndexKey[];
67
+ include: string[];
68
+ predicate: ResolvedPredicate | null;
69
+ /** The author's one-line justification. Never enters the name. */
70
+ reason: string;
71
+ /** Derived by {@link deriveIndexName}. Frozen — see the module comment. */
72
+ indexName: string;
73
+ }
74
+
75
+ /**
76
+ * A declaration that cannot become an index.
77
+ *
78
+ * Thrown at build time, naming the collection and the array position, because
79
+ * the alternative is a `CREATE INDEX` that fails during a push with a Postgres
80
+ * error mentioning a column the author never wrote.
81
+ */
82
+ export class CollectionIndexConfigError extends Error {
83
+ readonly collectionSlug: string;
84
+ readonly position: number;
85
+
86
+ constructor(collectionSlug: string, position: number, message: string) {
87
+ super(`${collectionSlug}.indexes[${position}]: ${message}`);
88
+ this.name = "CollectionIndexConfigError";
89
+ this.collectionSlug = collectionSlug;
90
+ this.position = position;
91
+ }
92
+ }
93
+
94
+ /** Postgres allows 32 key columns. See the doc comment on `on`. */
95
+ export const MAX_INDEX_KEYS = 5;
96
+
97
+ /**
98
+ * `_ix`/`_ux` plus `_` plus 7 hex — the part of the name that must always
99
+ * survive truncation, and therefore is never inside the truncated portion.
100
+ */
101
+ const NAME_SUFFIX_BYTES = 11;
102
+
103
+ /**
104
+ * Every Rebase-managed index name, and nothing else.
105
+ *
106
+ * The terminal `_ix_`/`_ux_` plus exactly seven lowercase hex characters is
107
+ * what separates this scheme from every other producer in the codebase —
108
+ * `_fkey`, `_gin`, `_trgm`, `_pkey`, `_key`, the vector distances, and the
109
+ * `idx_` prefix auth uses. `_idx` was rejected as a tail because it is already
110
+ * taken for real: `users_email_verification_token_idx` is byte-for-byte what a
111
+ * naive `<table>_<column>_idx` derives on an auth-enabled `users` collection.
112
+ *
113
+ * Load-bearing for safety, not just tidiness. An index that does NOT match this
114
+ * belongs to somebody else — a hand-written one, or one an introspected
115
+ * database arrived with — and is excluded from the Atlas diff so the push
116
+ * cannot drop it.
117
+ */
118
+ export const isRebaseIndexName = (name: string): boolean => /_(?:ix|ux)_[0-9a-f]{7}$/.test(name);
119
+
120
+ const isOrderedMethod = (method: IndexMethod): boolean => method === "btree";
121
+
122
+ /**
123
+ * The parts of an index that decide what it *is*.
124
+ *
125
+ * A semantic projection, not the rendered statement — the same arrangement as
126
+ * `getPolicyNameHash`, and for the same reason. A change to how this file
127
+ * formats SQL (eliding a default `USING btree`, quoting differently, emitting
128
+ * `NULLS LAST` explicitly) must not silently rename every index in every
129
+ * deployed database. Hashing generator output would make every cosmetic edit a
130
+ * fleet-wide DROP + CREATE.
131
+ *
132
+ * `reason` is deliberately absent: rewording a comment must not rebuild an
133
+ * index. `nulls` is the *effective* placement, so writing Postgres's own
134
+ * default down is a no-op rather than a redefinition.
135
+ *
136
+ * `v` is the only escape hatch, and it is expensive on purpose: bumping it
137
+ * renames every index in the field.
138
+ */
139
+ export const indexFingerprint = (spec: Omit<CollectionIndexSpec, "indexName">): string => sha1Hex(JSON.stringify({
140
+ v: 1,
141
+ s: spec.schema,
142
+ t: spec.table,
143
+ m: spec.method,
144
+ u: spec.unique,
145
+ k: spec.keys.map(k => [k.column, k.direction, k.nulls]),
146
+ i: spec.include,
147
+ w: spec.predicate
148
+ })).substring(0, 7);
149
+
150
+ /**
151
+ * `<table>_<columns>_ix_<hash>`, or `_ux_` when unique.
152
+ *
153
+ * Truncation eats the readable head and never the hash. `toPostgresIdentifier`
154
+ * truncates the whole string at 63 bytes, which on a hashed name would cut off
155
+ * the one part that makes it unique — the failure already frozen into
156
+ * `contracts/derived-names.txt`, where a foreign key is recorded with its
157
+ * `_fkey` suffix truncated away, so a second foreign key on that table would
158
+ * derive a byte-identical name.
159
+ */
160
+ export const deriveIndexName = (spec: Omit<CollectionIndexSpec, "indexName">): string => {
161
+ // Built suffix-first, so the two parts that carry meaning — the `_ix`/`_ux`
162
+ // tag that {@link isRebaseIndexName} matches on, and the fingerprint — are
163
+ // never in the string being truncated. Composing the whole name and then
164
+ // trimming it to 63 loses both, silently: an 80-byte table name yields
165
+ // `xxxx…xxx_610bb9e` with the tag gone, so the index stops being
166
+ // recognisable as Rebase's and `db push` treats it as foreign forever.
167
+ const tag = spec.unique ? "ux" : "ix";
168
+ const suffix = `_${tag}_${indexFingerprint(spec)}`;
169
+ const readable = `${spec.table}_${spec.keys.map(k => k.column).join("_")}`;
170
+ return `${truncateToBytes(readable, 63 - NAME_SUFFIX_BYTES)}${suffix}`;
171
+ };
172
+
173
+ const quoteLiteral = (value: string | number | boolean): string => {
174
+ if (typeof value === "number") return String(value);
175
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
176
+ return `'${value.replace(/'/g, "''")}'`;
177
+ };
178
+
179
+ /** Render a resolved predicate as the body of a `WHERE` clause. */
180
+ export const renderPredicate = (predicate: ResolvedPredicate): string => {
181
+ if ("and" in predicate) {
182
+ // Always parenthesised. Postgres would apply the same precedence
183
+ // without it, but the rendered SQL is read by people diffing a plan.
184
+ return predicate.and.map(renderPredicate).join(" AND ");
185
+ }
186
+ switch (predicate.op) {
187
+ case "is null":
188
+ case "is not null":
189
+ return `"${predicate.column}" ${predicate.op.toUpperCase()}`;
190
+ case "in":
191
+ return `"${predicate.column}" IN (${predicate.value.map(quoteLiteral).join(", ")})`;
192
+ default:
193
+ return `"${predicate.column}" ${predicate.op} ${quoteLiteral(predicate.value)}`;
194
+ }
195
+ };
196
+
197
+ /**
198
+ * The `CREATE INDEX` for one spec.
199
+ *
200
+ * `concurrently` is a parameter rather than a string replacement on the way
201
+ * out. `search-column.ts` and `vector-index.ts` both reach for
202
+ * `.replace("CREATE INDEX IF NOT EXISTS", …)` instead, which silently does
203
+ * nothing for a UNIQUE index — the rendered text is `CREATE UNIQUE INDEX …`
204
+ * and the pattern never matches.
205
+ */
206
+ export const collectionIndexStatement = (
207
+ spec: CollectionIndexSpec,
208
+ options: { concurrently?: boolean; ifNotExists?: boolean } = {}
209
+ ): string => {
210
+ const unique = spec.unique ? "UNIQUE " : "";
211
+ const concurrently = options.concurrently ? "CONCURRENTLY " : "";
212
+ const ifNotExists = options.ifNotExists ? "IF NOT EXISTS " : "";
213
+ const using = spec.method === "btree" ? "" : ` USING ${spec.method}`;
214
+
215
+ const keys = spec.keys.map(k => {
216
+ if (!isOrderedMethod(spec.method)) return `"${k.column}"`;
217
+ const direction = k.direction === "desc" ? " DESC" : "";
218
+ // Emitted only when it is not what the direction already implies, so
219
+ // the rendered SQL matches what `pg_get_indexdef` reads back and a
220
+ // re-plan finds no difference.
221
+ const impliedNulls = k.direction === "desc" ? "first" : "last";
222
+ const nulls = k.nulls === impliedNulls ? "" : ` NULLS ${k.nulls.toUpperCase()}`;
223
+ return `"${k.column}"${direction}${nulls}`;
224
+ }).join(", ");
225
+
226
+ const include = spec.include.length > 0
227
+ ? ` INCLUDE (${spec.include.map(c => `"${c}"`).join(", ")})`
228
+ : "";
229
+ const where = spec.predicate ? ` WHERE ${renderPredicate(spec.predicate)}` : "";
230
+
231
+ return `CREATE ${unique}INDEX ${concurrently}${ifNotExists}"${spec.indexName}" ` +
232
+ `ON "${spec.schema}"."${spec.table}"${using} (${keys})${include}${where};`;
233
+ };
234
+
235
+ export const collectionIndexStatements = (
236
+ specs: readonly CollectionIndexSpec[],
237
+ options: { concurrently?: boolean; ifNotExists?: boolean } = {}
238
+ ): string[] => specs.map(spec => collectionIndexStatement(spec, options));
239
+
240
+ const relationOf = (
241
+ collection: CollectionConfig,
242
+ propKey: string
243
+ ): ResolvedRelation | undefined => resolveCollectionRelations(collection)[propKey];
244
+
245
+ /**
246
+ * The column a property key indexes.
247
+ *
248
+ * A `belongsTo` resolves to its `localKey` — `primaryCategory` becomes
249
+ * `primary_category_id` — which is the case an index is most often wanted for
250
+ * and the case where the property key and the column differ. Everything else
251
+ * goes through `resolveColumnName`.
252
+ *
253
+ * The other relation kinds have no local column at all: the foreign key lives
254
+ * on the target's table, or in a junction. Indexing them here is refused
255
+ * rather than resolved to a column that does not exist.
256
+ */
257
+ export const resolveIndexableColumn = (
258
+ collection: CollectionConfig,
259
+ propKey: string,
260
+ resolveColumnName: ResolveColumnName,
261
+ fail: (message: string) => never
262
+ ): string => {
263
+ const relation = relationOf(collection, propKey);
264
+ if (relation) {
265
+ if (relation.kind === "belongsTo") return relation.localKey;
266
+ fail(
267
+ `"${propKey}" is a ${relation.kind} relation, which has no column on this table — ` +
268
+ `the foreign key lives on "${relation.targetSlug}". Declare the index there.`
269
+ );
270
+ }
271
+
272
+ const property = collection.properties?.[propKey] as Property | undefined;
273
+ if (!property) {
274
+ fail(`"${propKey}" is not a property of this collection.`);
275
+ }
276
+ return resolveColumnName(propKey, property);
277
+ };
278
+
279
+ const resolvePredicate = (
280
+ collection: CollectionConfig,
281
+ predicate: IndexPredicate,
282
+ resolveColumnName: ResolveColumnName,
283
+ fail: (message: string) => never
284
+ ): ResolvedPredicate => {
285
+ if ("and" in predicate) {
286
+ return { and: predicate.and.map(p => resolvePredicate(collection, p, resolveColumnName, fail)) };
287
+ }
288
+ const column = resolveIndexableColumn(collection, predicate.prop, resolveColumnName, fail);
289
+ switch (predicate.op) {
290
+ case "is null":
291
+ case "is not null":
292
+ return { column, op: predicate.op };
293
+ case "in": {
294
+ const seen = new Set(predicate.value);
295
+ if (seen.size !== predicate.value.length) {
296
+ fail(`the \`in\` list for "${predicate.prop}" repeats a value, which changes nothing.`);
297
+ }
298
+ return { column, op: "in", value: [...predicate.value] };
299
+ }
300
+ default:
301
+ return { column, op: predicate.op, value: predicate.value };
302
+ }
303
+ };
304
+
305
+ /** The primary key columns of a collection, for the "you already have this" refusal. */
306
+ const primaryKeyColumns = (collection: CollectionConfig, resolveColumnName: ResolveColumnName): string[] =>
307
+ Object.entries(collection.properties ?? {})
308
+ .filter(([, prop]) => prop && typeof prop === "object" && "isId" in prop && Boolean((prop as { isId?: unknown }).isId))
309
+ .map(([key, prop]) => resolveColumnName(key, prop as Property));
310
+
311
+ /**
312
+ * Every index one collection declares, resolved and named.
313
+ *
314
+ * Throws {@link CollectionIndexConfigError} rather than dropping a bad entry:
315
+ * an index that silently does not exist is the failure mode this whole feature
316
+ * is here to remove.
317
+ */
318
+ export const buildCollectionIndexSpecs = (
319
+ collection: CollectionConfig,
320
+ resolveColumnName: ResolveColumnName
321
+ ): CollectionIndexSpec[] => {
322
+ if (!isPostgresCollectionConfig(collection)) return [];
323
+ const declared = collection.indexes;
324
+ if (!declared || declared.length === 0) return [];
325
+
326
+ const slug = collection.slug ?? getTableName(collection);
327
+ const schema = collection.schema ?? "public";
328
+ const table = getTableName(collection);
329
+ const pk = primaryKeyColumns(collection, resolveColumnName).sort().join(",");
330
+
331
+ const specs: CollectionIndexSpec[] = [];
332
+ const byName = new Map<string, number>();
333
+
334
+ declared.forEach((index: CollectionIndex, position: number) => {
335
+ const fail = (message: string): never => {
336
+ throw new CollectionIndexConfigError(slug, position, message);
337
+ };
338
+
339
+ if (typeof index.reason !== "string" || index.reason.trim() === "") {
340
+ fail("`reason` is required — see the doc comment. An index nobody can justify is one nobody can delete.");
341
+ }
342
+ if (!Array.isArray(index.on) || index.on.length === 0) {
343
+ fail("`on` must name at least one property.");
344
+ }
345
+ if (index.on.length > MAX_INDEX_KEYS) {
346
+ fail(`\`on\` has ${index.on.length} keys; the limit is ${MAX_INDEX_KEYS}. Payload columns belong in \`include\`.`);
347
+ }
348
+
349
+ const method: IndexMethod = index.using ?? "btree";
350
+ const unique = method === "btree" && Boolean((index as { unique?: boolean }).unique);
351
+ if (!isOrderedMethod(method)) {
352
+ for (const key of index.on) {
353
+ if (typeof key !== "string" && ("direction" in key || "nulls" in key)) {
354
+ fail(`access method "${method}" does not support ASC/DESC or NULLS options.`);
355
+ }
356
+ }
357
+ }
358
+
359
+ const keys: ResolvedIndexKey[] = index.on.map(key => {
360
+ const propKey = typeof key === "string" ? key : key.prop;
361
+ const column = resolveIndexableColumn(collection, propKey, resolveColumnName, fail);
362
+ const direction = (typeof key === "string" ? undefined : (key as { direction?: "asc" | "desc" }).direction) ?? "asc";
363
+ const nulls = (typeof key === "string" ? undefined : (key as { nulls?: "first" | "last" }).nulls)
364
+ ?? (direction === "desc" ? "first" : "last");
365
+ return { column, direction, nulls };
366
+ });
367
+
368
+ const duplicateKey = keys.map(k => k.column).find((c, i, all) => all.indexOf(c) !== i);
369
+ if (duplicateKey) fail(`"${duplicateKey}" appears twice in \`on\`.`);
370
+
371
+ if (keys.map(k => k.column).sort().join(",") === pk && pk !== "") {
372
+ fail(`this is the primary key — "${table}_pkey" already indexes exactly these columns.`);
373
+ }
374
+
375
+ const include = ((index as { include?: readonly string[] }).include ?? [])
376
+ .map(propKey => resolveIndexableColumn(collection, propKey, resolveColumnName, fail));
377
+ const overlap = include.find(c => keys.some(k => k.column === c));
378
+ if (overlap) fail(`"${overlap}" is in both \`on\` and \`include\`; Postgres rejects the overlap.`);
379
+
380
+ if (unique && keys.length === 1) {
381
+ const propKey = typeof index.on[0] === "string" ? index.on[0] as string : (index.on[0] as { prop: string }).prop;
382
+ const property = collection.properties?.[propKey] as { validation?: { unique?: boolean } } | undefined;
383
+ if (property?.validation?.unique) {
384
+ fail(
385
+ `"${propKey}" already declares \`validation.unique\`, which compiles to an inline UNIQUE. ` +
386
+ `Two declarations of one guarantee — remove one.`
387
+ );
388
+ }
389
+ }
390
+
391
+ const predicate = index.where
392
+ ? resolvePredicate(collection, index.where, resolveColumnName, fail)
393
+ : null;
394
+
395
+ const withoutName = { schema, table, method, unique, keys, include, predicate, reason: index.reason };
396
+ const indexName = deriveIndexName(withoutName);
397
+
398
+ const clash = byName.get(indexName);
399
+ if (clash !== undefined) {
400
+ fail(`derives the same name as indexes[${clash}] — they are the same index declared twice.`);
401
+ }
402
+ byName.set(indexName, position);
403
+
404
+ specs.push({ ...withoutName, indexName });
405
+ });
406
+
407
+ return specs;
408
+ };
409
+
410
+ /**
411
+ * Every declared index across a set of collections, in a stable order.
412
+ *
413
+ * Sorted because the result reaches `schema.sql`, which `doctor` string-
414
+ * compares against a regenerated copy — `generatePostgresDdl` does not sort its
415
+ * collections, so leaving this in declaration order would make the artifact
416
+ * depend on the order files happened to load in.
417
+ */
418
+ export const buildCollectionIndexPlan = (
419
+ collections: readonly CollectionConfig[],
420
+ resolveColumnName: ResolveColumnName
421
+ ): CollectionIndexSpec[] =>
422
+ collections
423
+ .flatMap(collection => buildCollectionIndexSpecs(collection, resolveColumnName))
424
+ .sort((a, b) =>
425
+ a.schema.localeCompare(b.schema) ||
426
+ a.table.localeCompare(b.table) ||
427
+ a.indexName.localeCompare(b.indexName));
@@ -49,6 +49,7 @@ import {
49
49
  quoteSqlLiteral
50
50
  } from "./generate-postgres-ddl-logic";
51
51
  import { buildVectorIndexPlan, vectorIndexStatement, type SkippedVectorIndex } from "./vector-index";
52
+ import { buildCollectionIndexPlan, collectionIndexStatement } from "./collection-index";
52
53
  import {
53
54
  AUTH_USERS_COLUMNS,
54
55
  authUsersColumnDefinition,
@@ -812,6 +813,26 @@ export function planCollectionSchemaEnsure(
812
813
  vectorIndexSkipped.push(...plan.skipped);
813
814
  }
814
815
 
816
+ // Declared indexes, on exactly the same terms as the ANN ones above.
817
+ //
818
+ // Boot has to emit these, not just `db push`: the managed runtime
819
+ // provisions at boot and never runs a push, so a push-only index would
820
+ // simply not exist there — and nothing would say so.
821
+ // `contracts/derived-names.txt` states the rule ("Both, or it is a
822
+ // bug") and the gate enforces it, which is what caught this.
823
+ //
824
+ // `concurrently` is a parameter here rather than a string replacement
825
+ // on the rendered SQL. The `.replace("CREATE INDEX IF NOT EXISTS", …)`
826
+ // just above silently does nothing for a UNIQUE index, whose text is
827
+ // `CREATE UNIQUE INDEX …` and never matches the pattern.
828
+ for (const spec of buildCollectionIndexPlan(collections, resolveColumnName)) {
829
+ actions.push({
830
+ kind: "create-index",
831
+ target: `${spec.schema}.${spec.table}`,
832
+ sql: collectionIndexStatement(spec, { concurrently: true, ifNotExists: true })
833
+ });
834
+ }
835
+
815
836
  return {
816
837
  actions,
817
838
  statements: actions.map(a => a.sql),
@@ -308,7 +308,12 @@ export const getDrizzleColumn = (propName: string, prop: Property, collection: C
308
308
  const targetTableVar = getTableVarName(getTableName(targetCollection));
309
309
  const pkProp = getPrimaryKeyProp(targetCollection);
310
310
  const targetIdField = pkProp.name;
311
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`);
311
+ // `quote`, like every other column literal in this file: a column
312
+ // name only has to be quotable in Postgres, and this one is derived
313
+ // from a relation the author wrote.
314
+ const baseColumn = pkProp.type === "number"
315
+ ? `integer(${quote(fkColumnName)})`
316
+ : (pkProp.isUuid ? `uuid(${quote(fkColumnName)})` : `text(${quote(fkColumnName)})`);
312
317
 
313
318
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
314
319
  const required = prop.validation?.required;
@@ -681,10 +686,13 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
681
686
  const targetId = getPrimaryKeyName(targetCollection);
682
687
 
683
688
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
684
- schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
685
- schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
689
+ // The junction block was the one place these three helpers were not
690
+ // applied, so a junction column containing a space or a hyphen —
691
+ // both legal in Postgres — produced a file that does not parse.
692
+ schemaContent += ` ${propKey(sourceColumn)}: ${sourceColType}(${quote(sourceColumn)}).notNull().references(() => ${member(getTableVarName(getTableName(sourceCollection)), sourceId)}, ${refOptions}),\n`;
693
+ schemaContent += ` ${propKey(targetColumn)}: ${targetColType}(${quote(targetColumn)}).notNull().references(() => ${member(getTableVarName(getTableName(targetCollection)), targetId)}, ${refOptions}),\n`;
686
694
  schemaContent += "}, (table) => ([\n";
687
- schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
695
+ schemaContent += ` primaryKey({ columns: [${member("table", sourceColumn)}, ${member("table", targetColumn)}] }),\n`;
688
696
 
689
697
  // Junctions are generated tables like any other: locked by default,
690
698
  // with derived policies (reads follow the endpoints, writes follow
@@ -719,19 +727,20 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
719
727
  const searchSpec = buildSearchColumnSpec(collection);
720
728
  if (searchSpec) {
721
729
  columns.add(
722
- ` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}")` +
730
+ ` ${propKey(searchSpec.column)}: customType({ dataType() { return 'tsvector'; } })(${quote(searchSpec.column)})` +
723
731
  `.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
724
732
  );
725
733
  if (searchSpec.fuzzy) {
726
734
  columns.add(
727
- ` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}")` +
735
+ ` ${propKey(searchSpec.fuzzy.column)}: text(${quote(searchSpec.fuzzy.column)})` +
728
736
  `.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
729
737
  );
730
738
  }
731
739
  }
732
740
 
733
- // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
734
- // We should generate a basic id column if one was completely omitted.
741
+ // A collection that declares no primary key gets an implicit one:
742
+ // `id TEXT PRIMARY KEY`. The DDL generator emits the same column,
743
+ // and `derivePrimaryKeys` reads it back.
735
744
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
736
745
  if (!hasIdColumn) {
737
746
  columns.add(" id: text(\"id\").primaryKey()");
@@ -860,11 +869,14 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
860
869
  // `fields`/`references` here is invalid and crashes
861
870
  // `normalizeRelation` with "Cannot read properties of
862
871
  // undefined (reading 'referencedTable')".
863
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
872
+ // A `relationName` is authored, and a `"` in one
873
+ // closed this string literal early — in a file that
874
+ // is compiled and imported by the server.
875
+ tableRelations.push(` ${quote(relationKey)}: one(${targetTableVar}, {\n relationName: ${quote(drizzleRelationName)}\n })`);
864
876
  break;
865
877
 
866
878
  case "hasMany":
867
- tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
879
+ tableRelations.push(` ${quote(relationKey)}: many(${targetTableVar}, { relationName: ${quote(drizzleRelationName)} })`);
868
880
  break;
869
881
 
870
882
  case "manyToMany": {
@@ -874,7 +886,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
874
886
  // junction table — unnecessary now that each side names
875
887
  // its own.
876
888
  const junctionTableVar = getTableVarName(rel.through.table);
877
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
889
+ tableRelations.push(` ${quote(relationKey)}: many(${junctionTableVar}, { relationName: ${quote(drizzleRelationName)} })`);
878
890
  break;
879
891
  }
880
892
 
@@ -2,7 +2,6 @@ import { promises as fsPromises } from "fs";
2
2
  import * as fs from "fs";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
- import chokidar from "chokidar";
6
5
  import { generateSchema } from "./generate-drizzle-schema-logic";
7
6
  import { CollectionConfig } from "@rebasepro/types";
8
7
  import { loadCollectionsFromDirectory } from "@rebasepro/server";
@@ -93,7 +92,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
93
92
  }
94
93
  };
95
94
 
96
- const main = () => {
95
+ const main = async () => {
97
96
  const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
98
97
  const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
99
98
 
@@ -112,6 +111,18 @@ const main = () => {
112
111
 
113
112
  if (watch) {
114
113
  out(`Watching for changes in ${resolvedPath}...`);
114
+ // Imported here rather than at module scope, and this is not a style
115
+ // choice: chokidar is needed only by `--watch`, which is a
116
+ // schema-authoring path that never runs inside the runtime image. A
117
+ // top-level import puts it on the boot path of the published driver
118
+ // bundle, and the image installs a hand-listed set of runtime
119
+ // dependencies that does not include it — so the whole driver failed to
120
+ // load with "Cannot find package 'chokidar'", and every self-hosted
121
+ // container answered 500 with a stack trace about a file watcher.
122
+ //
123
+ // Same reasoning the image already applies to @ariga/atlas: an
124
+ // authoring-only dependency does not belong on a boot path.
125
+ const { default: chokidar } = await import("chokidar");
115
126
  const watcher = chokidar.watch(resolvedPath, {
116
127
  persistent: true,
117
128
  ignoreInitial: false
@@ -20,6 +20,7 @@ import {
20
20
  vectorIndexStatements,
21
21
  type VectorIndexPlan
22
22
  } from "./vector-index";
23
+ import { buildCollectionIndexSpecs, collectionIndexStatements } from "./collection-index";
23
24
  import { REBASE_SCHEMA } from "@rebasepro/types";
24
25
 
25
26
  // --- Helper Functions ---
@@ -734,7 +735,14 @@ export const generatePostgresDdl = async (
734
735
  indexStatements.push(`-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}`);
735
736
  }
736
737
 
737
- // Backwards compatibility: add default id primary key if missing
738
+ // The collection's own `indexes:` block. Last of the three index
739
+ // producers, and the only one the developer wrote deliberately
740
+ // rather than getting as a side effect of another feature.
741
+ indexStatements.push(...collectionIndexStatements(
742
+ buildCollectionIndexSpecs(collection, resolveColumnName)
743
+ ));
744
+
745
+ // The implicit primary key, for a collection that declares none.
738
746
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
739
747
  if (!hasPk) {
740
748
  columns.unshift(' "id" TEXT PRIMARY KEY');
@@ -762,16 +770,20 @@ export const generatePostgresDdl = async (
762
770
  }
763
771
  }
764
772
 
765
- if (fkStatements.length > 0) {
766
- ddl += "-- Foreign Key Constraints\n";
767
- ddl += fkStatements.join("\n") + "\n\n";
768
- }
769
-
773
+ // Indexes before foreign keys. Today every FK targets a primary key created
774
+ // inline, so the order is safe by accident; the moment a declared
775
+ // `unique: true` index can back an FK target, a constraint emitted first
776
+ // would reference an index that does not exist yet.
770
777
  if (indexStatements.length > 0) {
771
778
  ddl += "-- Indexes\n";
772
779
  ddl += indexStatements.join("\n") + "\n\n";
773
780
  }
774
781
 
782
+ if (fkStatements.length > 0) {
783
+ ddl += "-- Foreign Key Constraints\n";
784
+ ddl += fkStatements.join("\n") + "\n\n";
785
+ }
786
+
775
787
  if (policyStatements.length > 0) {
776
788
  ddl += "-- Row Level Security Policies\n";
777
789
  ddl += policyStatements.join("");
@@ -2,7 +2,6 @@ import { promises as fsPromises } from "fs";
2
2
  import * as fs from "fs";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
- import chokidar from "chokidar";
6
5
  import {
7
6
  generatePostgresDdl,
8
7
  generatePostgresPoliciesDdl,
@@ -77,7 +76,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
77
76
  }
78
77
  };
79
78
 
80
- const main = () => {
79
+ const main = async () => {
81
80
  const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
82
81
  const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
83
82
 
@@ -96,6 +95,18 @@ const main = () => {
96
95
 
97
96
  if (watch) {
98
97
  out(`Watching for changes in ${resolvedPath}...`);
98
+ // Imported here rather than at module scope, and this is not a style
99
+ // choice: chokidar is needed only by `--watch`, which is a
100
+ // schema-authoring path that never runs inside the runtime image. A
101
+ // top-level import puts it on the boot path of the published driver
102
+ // bundle, and the image installs a hand-listed set of runtime
103
+ // dependencies that does not include it — so the whole driver failed to
104
+ // load with "Cannot find package 'chokidar'", and every self-hosted
105
+ // container answered 500 with a stack trace about a file watcher.
106
+ //
107
+ // Same reasoning the image already applies to @ariga/atlas: an
108
+ // authoring-only dependency does not belong on a boot path.
109
+ const { default: chokidar } = await import("chokidar");
99
110
  const watcher = chokidar.watch(resolvedPath, {
100
111
  persistent: true,
101
112
  ignoreInitial: false