@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.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 (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
3
  import { buildSearchColumnSpec } from "./search-column";
4
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
4
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, fieldKeyForColumn, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections, sortCollectionsBySlug } from "@rebasepro/common";
5
5
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
6
6
  import { logger } from "@rebasepro/server";
7
7
  // --- Helper Functions ---
@@ -11,6 +11,31 @@ import { logger } from "@rebasepro/server";
11
11
  * Uses the explicit `columnName` when set (e.g. from introspection),
12
12
  * falling back to `toSnakeCase(propName)` for manually-authored collections.
13
13
  */
14
+ const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
15
+
16
+ /**
17
+ * A string literal for the generated schema file.
18
+ *
19
+ * Column names, table names and enum values are all written into this file as
20
+ * literals, and none of them is constrained to be quote-free: a Postgres
21
+ * identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
22
+ * Interpolating them raw ended the literal early — for enum values, inside
23
+ * single quotes, where an apostrophe is not an edge case.
24
+ */
25
+ const quote = (value: string): string => JSON.stringify(value);
26
+
27
+ /** An object key: verbatim when it is an identifier, quoted otherwise. */
28
+ const propKey = (name: string): string => (JS_IDENTIFIER.test(name) ? name : quote(name));
29
+
30
+ /**
31
+ * A property access on a generated table variable.
32
+ *
33
+ * `users.full name` is not an expression; `users["full name"]` is, and Drizzle
34
+ * treats the two identically.
35
+ */
36
+ const member = (object: string, key: string): string =>
37
+ (JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote(key)}]`);
38
+
14
39
  const resolveColumnName = (propName: string, prop?: Property | null): string => {
15
40
  if (prop && "columnName" in prop && typeof prop.columnName === "string") {
16
41
  return prop.columnName;
@@ -43,29 +68,17 @@ isUuid: isUuid ?? false };
43
68
  };
44
69
 
45
70
  /**
46
- * Given a raw DB column name (e.g. "client_id"), find the Drizzle property key
47
- * on the collection that maps to that column. A property matches if:
48
- * (a) it has an explicit `columnName` equal to the given column, OR
49
- * (b) its snake_case form equals the given column.
71
+ * Given a raw DB column name (e.g. "client_id"), the Drizzle property key that
72
+ * maps to it.
50
73
  *
51
- * Returns the property key (the Drizzle object key) if found, or the original
52
- * column name as a fallback.
74
+ * One line, because the rule is shared: the Drizzle object key is the wire
75
+ * name, and {@link fieldKeyForColumn} is the one definition of what a column is
76
+ * named on the wire. This used to be a private copy that fell back to the
77
+ * column verbatim, which is how a derived foreign key ended up served as
78
+ * `author_id` beside a hand-authored `displayName`.
53
79
  */
54
- const resolvePropertyKeyForColumn = (collection: CollectionConfig, column: string): string => {
55
- if (!collection.properties) return column;
56
- for (const [propKey, prop] of Object.entries(collection.properties)) {
57
- const p = prop as Property;
58
- // Explicit columnName match
59
- if ("columnName" in p && typeof (p as unknown as Record<string, unknown>).columnName === "string") {
60
- if ((p as unknown as Record<string, unknown>).columnName === column) return propKey;
61
- }
62
- // Convention match: snake_case(propKey) === column
63
- if (toSnakeCase(propKey) === column) return propKey;
64
- // Exact match (propKey is already the column name)
65
- if (propKey === column) return propKey;
66
- }
67
- return column;
68
- };
80
+ const resolvePropertyKeyForColumn = (collection: CollectionConfig, column: string): string =>
81
+ fieldKeyForColumn(collection, column);
69
82
 
70
83
  const isNumericId = (collection: CollectionConfig): boolean => {
71
84
  return getPrimaryKeyProp(collection).type === "number";
@@ -83,7 +96,14 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
83
96
  return !hasExplicitId && propName === "id";
84
97
  };
85
98
 
86
- const getDrizzleColumn = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string | null => {
99
+ /**
100
+ * The Drizzle column declaration a property compiles to, or `null` when the
101
+ * property puts no column on *this* table (an inverse relation, whose column
102
+ * lives on the target). Exported so it can be checked against its DDL twin
103
+ * `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
104
+ * with a database column and no Drizzle key.
105
+ */
106
+ export const getDrizzleColumn = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string | null => {
87
107
 
88
108
  const colName = resolveColumnName(propName, prop);
89
109
  let columnDefinition: string;
@@ -93,23 +113,23 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
93
113
  const stringProp = prop as unknown as StringProperty;
94
114
  if (stringProp.enum) {
95
115
  const enumName = getEnumVarName(getTableName(collection), propName);
96
- columnDefinition = `${enumName}("${colName}")`;
116
+ columnDefinition = `${enumName}(${quote(colName)})`;
97
117
  } else if ("isId" in stringProp && stringProp.isId === "uuid") {
98
- columnDefinition = `uuid("${colName}")`;
118
+ columnDefinition = `uuid(${quote(colName)})`;
99
119
  } else if (stringProp.columnType === "uuid") {
100
- columnDefinition = `uuid("${colName}")`;
120
+ columnDefinition = `uuid(${quote(colName)})`;
101
121
  } else if (stringProp.columnType === "char") {
102
- columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
122
+ columnDefinition = `char(${quote(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
103
123
  } else if (stringProp.columnType === "varchar") {
104
124
  // The length is not optional decoration: `varchar("col")` with
105
125
  // no length is an UNBOUNDED varchar in Postgres, which is what
106
126
  // this emitted while the DDL generator emitted VARCHAR(255) for
107
127
  // the very same property.
108
- columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
128
+ columnDefinition = `varchar(${quote(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
109
129
  } else {
110
130
  // `text` is the default, and the only length-unbounded choice.
111
131
  // Ask for `varchar` explicitly if you want the length constraint.
112
- columnDefinition = `text("${colName}")`;
132
+ columnDefinition = `text(${quote(colName)})`;
113
133
  }
114
134
  if (isIdProperty(propName, prop, collection)) {
115
135
  columnDefinition += ".primaryKey()";
@@ -135,9 +155,9 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
135
155
  const numProp = prop as unknown as NumberProperty;
136
156
  const isId = isIdProperty(propName, prop, collection);
137
157
 
138
- let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
158
+ let baseType = (numProp.validation?.integer || isId) ? `integer(${quote(colName)})` : `numeric(${quote(colName)})`;
139
159
  if (numProp.columnType) {
140
- if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
160
+ if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote(colName)})`;
141
161
  // `bigint` and `bigserial` are the only pg-core builders that
142
162
  // *require* a config argument: without `mode`, drizzle cannot
143
163
  // know whether to hand back a `number` or a `bigint`, and the
@@ -155,9 +175,9 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
155
175
  // switching the runtime type would be a breaking change to
156
176
  // every consumer of the generated schema.
157
177
  else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") {
158
- baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
178
+ baseType = `${numProp.columnType}(${quote(colName)}, { mode: "number" })`;
159
179
  }
160
- else baseType = `${numProp.columnType}("${colName}")`;
180
+ else baseType = `${numProp.columnType}(${quote(colName)})`;
161
181
  }
162
182
 
163
183
  if ("isId" in numProp && numProp.isId === "increment") {
@@ -181,16 +201,16 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
181
201
  break;
182
202
  }
183
203
  case "boolean":
184
- columnDefinition = `boolean("${colName}")`;
204
+ columnDefinition = `boolean(${quote(colName)})`;
185
205
  break;
186
206
  case "date": {
187
207
  const dateProp = prop as DateProperty;
188
208
  if (dateProp.columnType === "date") {
189
- columnDefinition = `date("${colName}", { mode: 'string' })`;
209
+ columnDefinition = `date(${quote(colName)}, { mode: 'string' })`;
190
210
  } else if (dateProp.columnType === "time") {
191
- columnDefinition = `time("${colName}")`;
211
+ columnDefinition = `time(${quote(colName)})`;
192
212
  } else {
193
- columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
213
+ columnDefinition = `timestamp(${quote(colName)}, { withTimezone: true, mode: 'string' })`;
194
214
  }
195
215
  // autoValue: database-level default for initial value on INSERT
196
216
  if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
@@ -201,12 +221,22 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
201
221
  case "map": {
202
222
  const mapProp = prop as MapProperty;
203
223
  if (mapProp.columnType === "json") {
204
- columnDefinition = `json("${colName}")`;
224
+ columnDefinition = `json(${quote(colName)})`;
205
225
  } else {
206
- columnDefinition = `jsonb("${colName}")`;
226
+ columnDefinition = `jsonb(${quote(colName)})`;
207
227
  }
208
228
  break;
209
229
  }
230
+ case "geopoint": {
231
+ // `{ latitude, longitude }`, which is what the OpenAPI schema, the
232
+ // generated TS type and the admin's field binding all describe.
233
+ // This arm did not exist: `geopoint` fell to `default: return null`
234
+ // and the caller dropped the column, so the DDL generator created a
235
+ // column the Drizzle table had no key for — and every write to it
236
+ // was silently discarded with a 201.
237
+ columnDefinition = `jsonb(${quote(colName)})`;
238
+ break;
239
+ }
210
240
  case "array": {
211
241
  const arrayProp = prop as ArrayProperty;
212
242
  let colType = arrayProp.columnType;
@@ -222,27 +252,27 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
222
252
  }
223
253
 
224
254
  if (colType === "json") {
225
- columnDefinition = `json("${colName}")`;
255
+ columnDefinition = `json(${quote(colName)})`;
226
256
  } else if (colType === "text[]") {
227
- columnDefinition = `text("${colName}").array()`;
257
+ columnDefinition = `text(${quote(colName)}).array()`;
228
258
  } else if (colType === "integer[]") {
229
- columnDefinition = `integer("${colName}").array()`;
259
+ columnDefinition = `integer(${quote(colName)}).array()`;
230
260
  } else if (colType === "boolean[]") {
231
- columnDefinition = `boolean("${colName}").array()`;
261
+ columnDefinition = `boolean(${quote(colName)}).array()`;
232
262
  } else if (colType === "numeric[]") {
233
- columnDefinition = `numeric("${colName}").array()`;
263
+ columnDefinition = `numeric(${quote(colName)}).array()`;
234
264
  } else {
235
- columnDefinition = `jsonb("${colName}")`;
265
+ columnDefinition = `jsonb(${quote(colName)})`;
236
266
  }
237
267
  break;
238
268
  }
239
269
  case "vector": {
240
270
  const vp = prop as VectorProperty;
241
- columnDefinition = `vector("${colName}", { dimensions: ${vp.dimensions} })`;
271
+ columnDefinition = `vector(${quote(colName)}, { dimensions: ${vp.dimensions} })`;
242
272
  break;
243
273
  }
244
274
  case "binary": {
245
- columnDefinition = `customType({ dataType() { return 'bytea'; } })("${colName}")`;
275
+ columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote(colName)})`;
246
276
  break;
247
277
  }
248
278
  case "relation": {
@@ -256,9 +286,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
256
286
  return null;
257
287
  }
258
288
 
259
- // If the localKey property is defined elsewhere in the properties, it will be handled there.
260
- // This logic is for when the relation property itself defines the FK.
261
- if (collection.properties[relation.localKey] && propName !== relation.localKey) {
289
+ // If a property of its own already declares this foreign key, that
290
+ // property emits the column and this relation must not emit a second
291
+ // key for it. Asked of the *field key*, not the column: a property
292
+ // declared `authorId` with `columnName: "author_id"` did not answer
293
+ // to `properties["author_id"]`, so both sides emitted and the table
294
+ // carried two Drizzle keys pointing at one column.
295
+ const fkFieldKey = fieldKeyForColumn(collection, relation.localKey);
296
+ if (collection.properties[fkFieldKey] && propName !== fkFieldKey) {
262
297
  return null;
263
298
  }
264
299
 
@@ -283,47 +318,63 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
283
318
  const refOptionsParts = [onUpdate, onDelete].filter(Boolean);
284
319
  const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
285
320
 
286
- let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
321
+ let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
287
322
 
288
323
  if (required) {
289
324
  columnDef += ".notNull()";
290
325
  }
291
326
 
292
- return ` ${relation.localKey}: ${columnDef}`;
327
+ // Key by the wire name, column by `localKey`. They are two different
328
+ // names for one thing and the generated line is where they meet:
329
+ // `authorId: integer("author_id")`.
330
+ return ` ${propKey(fkFieldKey)}: ${columnDef}`;
293
331
  }
294
332
  case "reference": {
295
333
  const refProp = prop as ReferenceProperty;
296
334
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
297
335
  if (!targetCollection) {
298
- columnDefinition = `text("${colName}")`;
336
+ columnDefinition = `text(${quote(colName)})`;
299
337
  break;
300
338
  }
301
339
 
302
340
  const pkProp = getPrimaryKeyProp(targetCollection);
303
341
  const targetTableVar = getTableVarName(getTableName(targetCollection));
304
342
  const targetIdField = pkProp.name;
305
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`);
343
+ const baseColumn = pkProp.type === "number" ? `integer(${quote(colName)})` : (pkProp.isUuid ? `uuid(${quote(colName)})` : `text(${quote(colName)})`);
306
344
 
307
345
  const required = prop.validation?.required;
308
346
  const onDelete = required ? "cascade" : "set null";
309
347
  const refOptions = `{ onDelete: "${onDelete}" }`;
310
348
 
311
- columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${refOptions})`;
349
+ columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
312
350
  if (required) {
313
351
  columnDefinition += ".notNull()";
314
352
  }
315
353
  // Skip the standard notNull() handling below because we did it here with references
316
- return ` ${propName}: ${columnDefinition}`;
354
+ return ` ${propKey(propName)}: ${columnDefinition}`;
317
355
  }
318
356
  default:
319
- return null;
357
+ // Not `return null`. A `null` here means "this property puts no
358
+ // column on this table", which is true of an inverse relation and
359
+ // false of everything else — and the caller cannot tell the two
360
+ // apart, so a type this switch simply forgot produced a table
361
+ // missing a column while the DDL generator happily created one.
362
+ // That is how `geopoint` stayed unpersistable: writes to it were
363
+ // dropped before the SQL was built, forever, with a 201. A property
364
+ // type nobody mapped is a generator bug, and it says so here rather
365
+ // than at some caller's next INSERT.
366
+ throw new Error(
367
+ `No Postgres column mapping for property '${propName}' of type ` +
368
+ `'${(prop as Property).type}' in collection '${collection.slug}'. ` +
369
+ "Add a case to `getDrizzleColumn` (and to `getSqlColumnType`, which must agree)."
370
+ );
320
371
  }
321
372
 
322
373
  if (prop.validation?.required) {
323
374
  columnDefinition += ".notNull()";
324
375
  }
325
376
 
326
- return ` ${propName}: ${columnDefinition}`;
377
+ return ` ${propKey(propName)}: ${columnDefinition}`;
327
378
  };
328
379
 
329
380
  /**
@@ -400,7 +451,7 @@ const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRu
400
451
  if (usingClause) parts.push(`using: ${usingClause}`);
401
452
  if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
402
453
 
403
- return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
454
+ return ` pgPolicy(${quote(policyName)}, { ${parts.join(", ")} }),\n`;
404
455
  };
405
456
 
406
457
  /**
@@ -458,7 +509,12 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
458
509
  // `rebase doctor` would then report the store the collection actually reads
459
510
  // from as drift. Non-SQL collections leave the toolchain here, once, rather
460
511
  // than being filtered again in each stage below.
461
- const collections = relationalCollections(allCollections);
512
+ //
513
+ // Sorted here rather than in the writer: the output is order-dependent and
514
+ // `rebase doctor` regenerates it in memory to diff against the file on
515
+ // disk. With the sort in the writer only, a project whose file order
516
+ // differed from its slug order was reported stale forever.
517
+ const collections = sortCollectionsBySlug(relationalCollections(allCollections));
462
518
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
463
519
 
464
520
 
@@ -532,7 +588,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
532
588
  )
533
589
  : Object.keys(prop.enum);
534
590
  if (values.length > 0) {
535
- schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
591
+ schemaContent += `export const ${enumVarName} = pgEnum(${quote(enumDbName)}, [${values.map(v => quote(v)).join(", ")}]);\n`;
536
592
  if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
537
593
  }
538
594
  }
@@ -728,7 +784,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
728
784
  }
729
785
 
730
786
  // Source side one(): pairs with owning table's many(junctionTable, { relationName })
731
- tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
787
+ tableRelations.push(` ${quote(relation.through.sourceColumn)}: one(${sourceTableVar}, {\n fields: [${member(tableVarName, relation.through.sourceColumn)}],\n references: [${member(sourceTableVar, sourceId)}],\n relationName: ${quote(owningRelationName)}\n })`);
732
788
 
733
789
  // Target side one(): pairs with inverse table's many(junctionTable, { relationName })
734
790
  // Always emit a relationName to avoid collisions with the source-side's owningRelationName.
@@ -736,7 +792,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
736
792
  const targetRelationName = inverseRelationName
737
793
  ? inverseRelationName
738
794
  : `${tableName}_${relation.through.targetColumn}`;
739
- tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: "${targetRelationName}"\n })`);
795
+ tableRelations.push(` ${quote(relation.through.targetColumn)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, relation.through.targetColumn)}],\n references: [${member(targetTableVar, targetId)}],\n relationName: ${quote(targetRelationName)}\n })`);
740
796
  }
741
797
  } else {
742
798
  const resolvedRelations = resolveCollectionRelations(collection);
@@ -776,7 +832,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
776
832
  // schema that does not compile. The three other
777
833
  // emission sites normalise; this one did not.
778
834
  const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
779
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${localFieldKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
835
+ tableRelations.push(` ${quote(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote(drizzleRelationName)}\n })`);
780
836
  break;
781
837
  }
782
838
 
@@ -841,7 +897,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
841
897
  ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey)
842
898
  : getPrimaryKeyName(otherCollection);
843
899
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
844
- tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
900
+ tableRelations.push(` ${quote(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote(drizzleRelationName)}\n })`);
845
901
  emittedRelationNames.add(deduplicationKey);
846
902
  }
847
903
  }
@@ -5,7 +5,8 @@ import { pathToFileURL } from "url";
5
5
  import chokidar from "chokidar";
6
6
  import { generateSchema } from "./generate-drizzle-schema-logic";
7
7
  import { CollectionConfig } from "@rebasepro/types";
8
- import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
8
+ import { loadCollectionsFromDirectory } from "@rebasepro/server";
9
+ import { out, outError } from "../cli-output";
9
10
 
10
11
 
11
12
  // --- Helper Functions ---
@@ -49,7 +50,7 @@ const formatTerminalText = (text: string, options: {
49
50
  const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
50
51
  try {
51
52
  if (!collectionsFilePath) {
52
- logger.error("Error: No collections file path provided. Skipping schema generation.");
53
+ outError("Error: No collections file path provided. Skipping schema generation.");
53
54
  return;
54
55
  }
55
56
 
@@ -75,20 +76,20 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
75
76
  const outputDir = path.dirname(outputPath);
76
77
  await fsPromises.mkdir(outputDir, { recursive: true });
77
78
  await fsPromises.writeFile(outputPath, schemaContent);
78
- logger.info(`✅ Drizzle schema generated successfully at ${outputPath}`);
79
+ out(`✅ Drizzle schema generated successfully at ${outputPath}`);
79
80
  } else {
80
- logger.info("✅ Drizzle schema generated successfully.");
81
- logger.info(String(schemaContent));
81
+ out("✅ Drizzle schema generated successfully.");
82
+ out(String(schemaContent));
82
83
  }
83
84
 
84
- logger.info(`You can now run ${formatTerminalText("rebase db generate", {
85
+ out(`You can now run ${formatTerminalText("rebase db generate", {
85
86
  bold: true,
86
87
  backgroundColor: "blue",
87
88
  textColor: "black"
88
89
  })} to generate the SQL migration files.`);
89
90
 
90
91
  } catch (error) {
91
- logger.error("Error generating schema", { error: error });
92
+ outError(`Error generating schema: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
92
93
  }
93
94
  };
94
95
 
@@ -102,7 +103,7 @@ const main = () => {
102
103
  const watch = process.argv.includes("--watch");
103
104
 
104
105
  if (!collectionsFilePath) {
105
- logger.info("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
106
+ out("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
106
107
  return;
107
108
  }
108
109
 
@@ -110,14 +111,14 @@ const main = () => {
110
111
  const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
111
112
 
112
113
  if (watch) {
113
- logger.info(`Watching for changes in ${resolvedPath}...`);
114
+ out(`Watching for changes in ${resolvedPath}...`);
114
115
  const watcher = chokidar.watch(resolvedPath, {
115
116
  persistent: true,
116
117
  ignoreInitial: false
117
118
  });
118
119
 
119
120
  watcher.on("all", (event, filePath) => {
120
- logger.info(`[${event}] ${filePath}. Regenerating schema...`);
121
+ out(`[${event}] ${filePath}. Regenerating schema...`);
121
122
  runGeneration(resolvedPath, resolvedOutputPath);
122
123
  });
123
124
  } else {
@@ -10,6 +10,8 @@ import {
10
10
  searchHelperFunctions,
11
11
  searchExtensionStatements,
12
12
  searchColumnNames,
13
+ searchColumnStamps,
14
+ searchStampGuards,
13
15
  searchIndexNames,
14
16
  type SearchColumnSpec
15
17
  } from "./search-column";
@@ -193,6 +195,11 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
193
195
  const mapProp = prop as MapProperty;
194
196
  return mapProp.columnType === "json" ? "JSON" : "JSONB";
195
197
  }
198
+ // `{ latitude, longitude }` — a document, like `map`. It used to fall to
199
+ // the `TEXT` default below while the Drizzle generator emitted no column
200
+ // at all, which is the divergence that made the type unpersistable.
201
+ case "geopoint":
202
+ return "JSONB";
196
203
  case "array": {
197
204
  const arrayProp = prop as ArrayProperty;
198
205
  let colType = arrayProp.columnType;
@@ -244,7 +251,17 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
244
251
  return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
245
252
  }
246
253
  default:
247
- return "TEXT";
254
+ // A silent `TEXT` here is how the two generators drifted apart: the
255
+ // database got a column for `geopoint` and the Drizzle table did
256
+ // not, so the type looked supported and persisted nothing. Every
257
+ // member of `DataType` is handled above; anything else is a
258
+ // generator that has not been taught the type yet, and it should
259
+ // say so rather than invent a column.
260
+ throw new Error(
261
+ `No Postgres column type for property '${propName}' of type ` +
262
+ `'${(prop as Property).type}' in collection '${collection.slug}'. ` +
263
+ "Add a case to `getSqlColumnType` (and to `getDrizzleColumn`, which must agree)."
264
+ );
248
265
  }
249
266
  };
250
267
 
@@ -318,9 +335,19 @@ export const generatePostgresSearchDdl = (allCollections: CollectionConfig[]): s
318
335
  // ADD COLUMN IF NOT EXISTS rather than the inline definition the
319
336
  // CREATE TABLE would use: by the time this runs the table exists,
320
337
  // whether Atlas just created it or it has been live for a year.
338
+ // Refuse before altering anything if the column in the database was
339
+ // generated from a different `search` block: `ADD COLUMN IF NOT EXISTS`
340
+ // is a no-op against an existing column, so without this the file would
341
+ // report success and leave the old expression in place — and then stamp
342
+ // it as current.
343
+ searchStampGuards(spec).forEach(s => { ddl += `${s}\n`; });
321
344
  ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
322
345
  const fuzzyDef = fuzzyColumnDefinition(spec);
323
346
  if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
347
+ // The fingerprint of the expression each column was built from — the
348
+ // only record of *which* block a generated column came from, and what
349
+ // the guard above and the boot-time ensure both compare against.
350
+ searchColumnStamps(spec).forEach(s => { ddl += `${s.sql}\n`; });
324
351
  searchIndexStatements(spec).forEach(s => { ddl += `${s}\n`; });
325
352
  ddl += "\n";
326
353
  }
@@ -9,13 +9,14 @@ import {
9
9
  generatePostgresSearchDdl
10
10
  } from "./generate-postgres-ddl-logic";
11
11
  import { CollectionConfig } from "@rebasepro/types";
12
- import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
12
+ import { loadCollectionsFromDirectory } from "@rebasepro/server";
13
+ import { out, outError } from "../cli-output";
13
14
 
14
15
 
15
16
  const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
16
17
  try {
17
18
  if (!collectionsFilePath) {
18
- logger.error("Error: No collections file path provided. Skipping schema generation.");
19
+ outError("Error: No collections file path provided. Skipping schema generation.");
19
20
  return;
20
21
  }
21
22
 
@@ -48,11 +49,11 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
48
49
  const outputDir = path.dirname(outputPath);
49
50
  await fsPromises.mkdir(outputDir, { recursive: true });
50
51
  await fsPromises.writeFile(outputPath, ddlContent);
51
- logger.info(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
52
+ out(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
52
53
 
53
54
  const policiesPath = path.join(outputDir, "policies.sql");
54
55
  await fsPromises.writeFile(policiesPath, policiesContent);
55
- logger.info(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
56
+ out(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
56
57
 
57
58
  // Removed when the last `search` block goes: the CLI applies this
58
59
  // file whenever it exists, and a stale one would keep re-creating
@@ -60,19 +61,19 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
60
61
  const searchPath = path.join(outputDir, "search.sql");
61
62
  if (searchContent) {
62
63
  await fsPromises.writeFile(searchPath, searchContent);
63
- logger.info(`✅ PostgreSQL search DDL generated successfully at ${searchPath}`);
64
+ out(`✅ PostgreSQL search DDL generated successfully at ${searchPath}`);
64
65
  } else if (fs.existsSync(searchPath)) {
65
66
  await fsPromises.rm(searchPath);
66
67
  }
67
68
  } else {
68
- logger.info("✅ PostgreSQL DDL generated successfully.");
69
- logger.info(String(ddlContent));
70
- logger.info("\n✅ PostgreSQL Policies DDL generated successfully.");
71
- logger.info(String(policiesContent));
69
+ out("✅ PostgreSQL DDL generated successfully.");
70
+ out(String(ddlContent));
71
+ out("\n✅ PostgreSQL Policies DDL generated successfully.");
72
+ out(String(policiesContent));
72
73
  }
73
74
 
74
75
  } catch (error) {
75
- logger.error("Error generating DDL schema", { error: error });
76
+ outError(`Error generating DDL schema: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
76
77
  }
77
78
  };
78
79
 
@@ -86,7 +87,7 @@ const main = () => {
86
87
  const watch = process.argv.includes("--watch");
87
88
 
88
89
  if (!collectionsFilePath) {
89
- logger.info("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
90
+ out("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
90
91
  return;
91
92
  }
92
93
 
@@ -94,14 +95,14 @@ const main = () => {
94
95
  const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
95
96
 
96
97
  if (watch) {
97
- logger.info(`Watching for changes in ${resolvedPath}...`);
98
+ out(`Watching for changes in ${resolvedPath}...`);
98
99
  const watcher = chokidar.watch(resolvedPath, {
99
100
  persistent: true,
100
101
  ignoreInitial: false
101
102
  });
102
103
 
103
104
  watcher.on("all", (event, filePath) => {
104
- logger.info(`[${event}] ${filePath}. Regenerating DDL schema...`);
105
+ out(`[${event}] ${filePath}. Regenerating DDL schema...`);
105
106
  runGeneration(resolvedPath, resolvedOutputPath);
106
107
  });
107
108
  } else {
@@ -60,11 +60,13 @@ function tableBlock(source: string, table: string): string {
60
60
  * clear the finding and the check would be permanently red.
61
61
  */
62
62
  function declaresColumn(block: string, column: string): boolean {
63
- // `categorie_id: integer("categorie_id")` — the Drizzle column shape. The key
64
- // and the string argument agree in generated output, so requiring both is
65
- // both precise and cheap.
66
- const key = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
- return new RegExp(`(^|[\\s,{])${key}\\s*:\\s*\\w+\\(\\s*["']${key}["']`, "m").test(block);
63
+ // `categorieId: integer("categorie_id")` — the Drizzle column shape. Only
64
+ // the string argument is the column; the key beside it is the *wire* name
65
+ // and no longer agrees with it (`authorId: integer("author_id")`). Matching
66
+ // both, as this did, made every camelCased key look like a column the
67
+ // generated file did not declare.
68
+ const literal = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ return new RegExp(`(^|[\\s,{])[\\w$"']+\\s*:\\s*\\w+\\(\\s*["']${literal}["']`, "m").test(block);
68
70
  }
69
71
 
70
72
  /**