@rebasepro/server-postgres 0.17.3 → 0.18.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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,427 +0,0 @@
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));
@@ -1,103 +0,0 @@
1
- /**
2
- * The collections schema version, stamped into the database.
3
- *
4
- * Deliberately the same table shape, the same key/value convention and the same
5
- * "unstamped is null, and null is not an error" rule as `auth/schema-version.ts`.
6
- * They answer the same kind of question — "was this database provisioned by
7
- * something that agrees with me" — and inventing a second convention for the
8
- * second one would be a second thing to find, back up and reason about.
9
- *
10
- * It does *not* follow the auth stamp's schema. `resolveAuthSchema` tracks the
11
- * users collection, so a project that puts its users in a custom schema moves
12
- * the auth stamp with them — correct there, because that stamp describes those
13
- * tables. This one describes the collections as a whole and belongs with the
14
- * runtime's own internal state, which is always `rebase`. Usually they land in
15
- * the same table anyway; when they do not, that is the reason.
16
- *
17
- * The value is opaque here on purpose. It is a hash produced by
18
- * `computeSchemaVersion` in `@rebasepro/types`, and this module's only job is to
19
- * put a string in and take the same string out. Comparing them, and deciding
20
- * what a difference means, is `boot/schema-stamp.ts` in the runtime — where it
21
- * can be tested without a database.
22
- *
23
- * Unlike the auth version, this is **not** ordered. The auth stamp is an integer
24
- * whose comparison direction carries meaning (a database newer than the runtime
25
- * is unrecoverable, older is the ordinary upgrade path). A schema hash has no
26
- * order at all: it can say the two disagree and never which is ahead. That is a
27
- * deliberate limit, and the reason the runtime's response to a mismatch is to
28
- * describe it rather than to decide who is wrong.
29
- */
30
- import { sql } from "drizzle-orm";
31
- import type { NodePgDatabase } from "drizzle-orm/node-postgres";
32
-
33
- /** Key under which the version is stored in the meta table. */
34
- const VERSION_KEY = "collections_schema_version";
35
-
36
- /**
37
- * Anything that can run SQL for us.
38
- *
39
- * Narrower than `NodePgDatabase` so the provisioning handle — which is what the
40
- * boot path actually has in hand — satisfies it without a cast at every call
41
- * site.
42
- */
43
- export interface SchemaMetaQueryable {
44
- execute(query: unknown): Promise<{ rows: Record<string, unknown>[] }>;
45
- }
46
-
47
- /**
48
- * Read the stamped version, or `null` when this database has never been stamped.
49
- *
50
- * `to_regclass` rather than a plain `SELECT` so a missing schema or table is a
51
- * `null` instead of a thrown 42P01 — the overwhelmingly common case on a fresh
52
- * database is that neither exists yet, and that is not news.
53
- */
54
- export async function readCollectionsSchemaVersion(
55
- db: SchemaMetaQueryable | NodePgDatabase,
56
- metaSchema: string
57
- ): Promise<string | null> {
58
- const qualified = `"${metaSchema}"."schema_meta"`;
59
- const exists = await (db as SchemaMetaQueryable).execute(
60
- sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`
61
- );
62
- if (!(exists.rows[0] as { present: boolean } | undefined)?.present) return null;
63
-
64
- const result = await (db as SchemaMetaQueryable).execute(sql`
65
- SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
66
- `);
67
- const raw = (result.rows[0] as { value: string } | undefined)?.value;
68
-
69
- // An empty string is not a version anybody computed, and treating it as one
70
- // would make every process disagree with the database forever. Unstamped.
71
- return raw === undefined || raw.trim() === "" ? null : raw;
72
- }
73
-
74
- /**
75
- * Record the version this runtime just applied.
76
- *
77
- * Creates the meta table if the auth stamp has not already — the two are
78
- * independent halves of one boot and either may run first, so neither can assume
79
- * the table is there. `CREATE TABLE IF NOT EXISTS` is not atomic against a
80
- * concurrent identical statement, which is exactly the race a split deployment
81
- * arranges; it is tolerated here for the same reason it is elsewhere in the boot
82
- * path, and the caller treats any failure as a warning rather than a fatal.
83
- */
84
- export async function stampCollectionsSchemaVersion(
85
- db: SchemaMetaQueryable | NodePgDatabase,
86
- metaSchema: string,
87
- version: string
88
- ): Promise<void> {
89
- const qualified = `"${metaSchema}"."schema_meta"`;
90
- await (db as SchemaMetaQueryable).execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.identifier(metaSchema)}`);
91
- await (db as SchemaMetaQueryable).execute(sql`
92
- CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
93
- key TEXT PRIMARY KEY,
94
- value TEXT NOT NULL,
95
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
96
- )
97
- `);
98
- await (db as SchemaMetaQueryable).execute(sql`
99
- INSERT INTO ${sql.raw(qualified)} (key, value)
100
- VALUES (${VERSION_KEY}, ${version})
101
- ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
102
- `);
103
- }
@@ -1,94 +0,0 @@
1
- /**
2
- * Pure helpers that classify an Atlas declarative-apply plan as destructive
3
- * or not, and decide what `rebase db push` should do about it.
4
- *
5
- * `db push` runs `atlas schema apply` to make the live database match the
6
- * generated `schema.sql`. Removing a collection field compiles to
7
- * `DROP COLUMN`; renaming compiles to drop-then-add — either destroys data.
8
- * We first run the apply with `--dry-run` to obtain the planned SQL, scan it
9
- * here, and refuse to auto-approve anything destructive without an explicit
10
- * opt-in.
11
- *
12
- * Everything in this file is side-effect free so it can be unit-tested
13
- * without Atlas or a database.
14
- */
15
-
16
- /**
17
- * SQL fragments that destroy data or data-bearing objects. Matched
18
- * case-insensitively against each statement of the plan. `IF EXISTS` /
19
- * whitespace variations are tolerated by the regexes below.
20
- */
21
- const DESTRUCTIVE_PATTERNS: { label: string; re: RegExp }[] = [
22
- { label: "DROP TABLE", re: /\bDROP\s+TABLE\b/i },
23
- { label: "DROP COLUMN", re: /\bDROP\s+COLUMN\b/i },
24
- { label: "DROP SCHEMA", re: /\bDROP\s+SCHEMA\b/i },
25
- { label: "DROP VIEW", re: /\bDROP\s+(MATERIALIZED\s+)?VIEW\b/i },
26
- { label: "DROP TYPE", re: /\bDROP\s+TYPE\b/i },
27
- { label: "TRUNCATE", re: /\bTRUNCATE\b/i }
28
- ];
29
-
30
- /**
31
- * Split a SQL script into individual statements, dropping blank lines and
32
- * `--` comment lines. Deliberately simple: Atlas emits one plain statement
33
- * per `;`, without string literals that contain semicolons in a schema DDL
34
- * plan, so a naive split is safe and keeps this dependency-free.
35
- */
36
- export function splitSqlStatements(sql: string): string[] {
37
- // Strip full-line SQL comments so a commented-out DROP never trips the
38
- // detector, then split on semicolons.
39
- const withoutComments = sql
40
- .split("\n")
41
- .filter((line) => !line.trim().startsWith("--"))
42
- .join("\n");
43
- return withoutComments
44
- .split(";")
45
- .map((s) => s.trim())
46
- .filter((s) => s.length > 0);
47
- }
48
-
49
- export interface DestructiveStatement {
50
- /** The offending statement (trimmed, without the trailing `;`). */
51
- statement: string;
52
- /** Which destructive operation it was flagged for, e.g. "DROP COLUMN". */
53
- kind: string;
54
- }
55
-
56
- /**
57
- * Scan an Atlas plan (the SQL printed by `schema apply --dry-run`) and return
58
- * the statements that would destroy data. An empty array means the plan is
59
- * safe to auto-approve.
60
- */
61
- export function detectDestructiveStatements(planSql: string): DestructiveStatement[] {
62
- const found: DestructiveStatement[] = [];
63
- for (const statement of splitSqlStatements(planSql)) {
64
- for (const { label, re } of DESTRUCTIVE_PATTERNS) {
65
- if (re.test(statement)) {
66
- found.push({ statement, kind: label });
67
- break; // one label per statement is enough to flag it
68
- }
69
- }
70
- }
71
- return found;
72
- }
73
-
74
- export type PushDecision = "apply" | "confirm" | "refuse";
75
-
76
- /**
77
- * Decide how `db push` should proceed given the plan's destructiveness and
78
- * the invocation context.
79
- *
80
- * - No destructive statements → `apply` (safe to auto-approve).
81
- * - Destructive + `--allow-destructive` → `apply` (operator opted in).
82
- * - Destructive + interactive TTY → `confirm` (prompt before applying).
83
- * - Destructive + non-interactive → `refuse` (never silently drop data in
84
- * CI / scripts / agents).
85
- */
86
- export function decidePushSafety(opts: {
87
- destructiveCount: number;
88
- allowDestructive: boolean;
89
- interactive: boolean;
90
- }): PushDecision {
91
- if (opts.destructiveCount === 0) return "apply";
92
- if (opts.allowDestructive) return "apply";
93
- return opts.interactive ? "confirm" : "refuse";
94
- }