@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133

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 (117) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +57 -1
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1815 -1023
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  31. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  32. package/dist/schema/introspect-db-inference.d.ts +8 -1
  33. package/dist/schema/introspect-db-logic.d.ts +49 -0
  34. package/dist/schema/introspect-db-project.d.ts +21 -0
  35. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  36. package/dist/schema/search-column.d.ts +248 -0
  37. package/dist/security/policy-drift.d.ts +34 -0
  38. package/dist/security/rls-enforcement.d.ts +61 -5
  39. package/dist/services/FetchService.d.ts +34 -7
  40. package/dist/services/PersistService.d.ts +21 -17
  41. package/dist/services/RelationService.d.ts +9 -57
  42. package/dist/services/RelationWriteService.d.ts +82 -0
  43. package/dist/services/collection-helpers.d.ts +42 -0
  44. package/dist/services/dataService.d.ts +5 -0
  45. package/dist/services/junction-writes.d.ts +82 -0
  46. package/dist/services/realtimeService.d.ts +164 -23
  47. package/dist/services/write-denial.d.ts +36 -0
  48. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  49. package/dist/src-DCdn3Val.js.map +1 -0
  50. package/dist/utils/drizzle-conditions.d.ts +124 -2
  51. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  52. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  53. package/package.json +9 -8
  54. package/src/PostgresBackendDriver.ts +172 -6
  55. package/src/PostgresBootstrapper.ts +136 -11
  56. package/src/auth/ensure-tables.ts +212 -91
  57. package/src/auth/services.ts +82 -5
  58. package/src/backup/backup-cli.ts +59 -57
  59. package/src/cli-errors.ts +6 -6
  60. package/src/cli-helpers.ts +132 -13
  61. package/src/cli-output.ts +43 -0
  62. package/src/cli.ts +371 -161
  63. package/src/collections/buildRegistry.ts +3 -1
  64. package/src/collections/validate-relations.ts +124 -17
  65. package/src/data-transformer.ts +142 -28
  66. package/src/history/ensure-history-table.ts +9 -2
  67. package/src/schema/auth-schema.ts +17 -1
  68. package/src/schema/auth-users-columns.ts +131 -0
  69. package/src/schema/doctor-cli.ts +14 -65
  70. package/src/schema/doctor-policy-checks.ts +105 -0
  71. package/src/schema/doctor.ts +156 -77
  72. package/src/schema/ensure-collection-policies.ts +99 -6
  73. package/src/schema/ensure-collection-tables.ts +374 -32
  74. package/src/schema/generate-drizzle-schema-logic.ts +152 -66
  75. package/src/schema/generate-drizzle-schema.ts +11 -10
  76. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  77. package/src/schema/generate-postgres-ddl.ts +38 -14
  78. package/src/schema/generated-schema-staleness.ts +171 -0
  79. package/src/schema/introspect-db-inference.ts +9 -2
  80. package/src/schema/introspect-db-logic.ts +251 -75
  81. package/src/schema/introspect-db-project.ts +78 -0
  82. package/src/schema/introspect-db.ts +42 -25
  83. package/src/schema/introspect-runtime.ts +14 -2
  84. package/src/schema/non-sql-collections.test.ts +131 -0
  85. package/src/schema/rls-bootstrap-sql.ts +288 -0
  86. package/src/schema/search-column.ts +643 -0
  87. package/src/security/anonymous-grants.test.ts +4 -2
  88. package/src/security/policy-drift.test.ts +104 -3
  89. package/src/security/policy-drift.ts +129 -7
  90. package/src/security/rls-enforcement.ts +150 -7
  91. package/src/services/BranchService.ts +5 -0
  92. package/src/services/FetchService.ts +253 -115
  93. package/src/services/PersistService.ts +82 -43
  94. package/src/services/RelationService.ts +37 -696
  95. package/src/services/RelationWriteService.ts +653 -0
  96. package/src/services/cdc/trigger-cdc.ts +5 -1
  97. package/src/services/channel-history.ts +14 -0
  98. package/src/services/channel-presence.ts +13 -0
  99. package/src/services/collection-helpers.ts +89 -4
  100. package/src/services/dataService.ts +5 -0
  101. package/src/services/junction-writes.ts +295 -0
  102. package/src/services/pg-notify-listener.ts +1 -1
  103. package/src/services/realtimeService.ts +382 -118
  104. package/src/services/write-denial.ts +55 -0
  105. package/src/utils/drizzle-conditions.ts +433 -35
  106. package/src/utils/pg-error-utils.ts +8 -3
  107. package/src/websocket.ts +113 -16
  108. package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
  109. package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
  110. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  111. package/dist/policy-CeA1JcxP.js +0 -105
  112. package/dist/policy-CeA1JcxP.js.map +0 -1
  113. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  114. package/dist/src-DlPBctw_.js.map +0 -1
  115. package/dist/src-DoU9yPqq.js.map +0 -1
  116. package/dist/websocket-B2LsrINK.js.map +0 -1
  117. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -1,6 +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
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
3
+ import { buildSearchColumnSpec } from "./search-column";
4
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, fieldKeyForColumn, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections, sortCollectionsBySlug } from "@rebasepro/common";
4
5
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
5
6
  import { logger } from "@rebasepro/server";
6
7
  // --- Helper Functions ---
@@ -10,6 +11,31 @@ import { logger } from "@rebasepro/server";
10
11
  * Uses the explicit `columnName` when set (e.g. from introspection),
11
12
  * falling back to `toSnakeCase(propName)` for manually-authored collections.
12
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
+
13
39
  const resolveColumnName = (propName: string, prop?: Property | null): string => {
14
40
  if (prop && "columnName" in prop && typeof prop.columnName === "string") {
15
41
  return prop.columnName;
@@ -42,29 +68,17 @@ isUuid: isUuid ?? false };
42
68
  };
43
69
 
44
70
  /**
45
- * Given a raw DB column name (e.g. "client_id"), find the Drizzle property key
46
- * on the collection that maps to that column. A property matches if:
47
- * (a) it has an explicit `columnName` equal to the given column, OR
48
- * (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.
49
73
  *
50
- * Returns the property key (the Drizzle object key) if found, or the original
51
- * 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`.
52
79
  */
53
- const resolvePropertyKeyForColumn = (collection: CollectionConfig, column: string): string => {
54
- if (!collection.properties) return column;
55
- for (const [propKey, prop] of Object.entries(collection.properties)) {
56
- const p = prop as Property;
57
- // Explicit columnName match
58
- if ("columnName" in p && typeof (p as unknown as Record<string, unknown>).columnName === "string") {
59
- if ((p as unknown as Record<string, unknown>).columnName === column) return propKey;
60
- }
61
- // Convention match: snake_case(propKey) === column
62
- if (toSnakeCase(propKey) === column) return propKey;
63
- // Exact match (propKey is already the column name)
64
- if (propKey === column) return propKey;
65
- }
66
- return column;
67
- };
80
+ const resolvePropertyKeyForColumn = (collection: CollectionConfig, column: string): string =>
81
+ fieldKeyForColumn(collection, column);
68
82
 
69
83
  const isNumericId = (collection: CollectionConfig): boolean => {
70
84
  return getPrimaryKeyProp(collection).type === "number";
@@ -82,7 +96,14 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
82
96
  return !hasExplicitId && propName === "id";
83
97
  };
84
98
 
85
- 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 => {
86
107
 
87
108
  const colName = resolveColumnName(propName, prop);
88
109
  let columnDefinition: string;
@@ -92,23 +113,23 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
92
113
  const stringProp = prop as unknown as StringProperty;
93
114
  if (stringProp.enum) {
94
115
  const enumName = getEnumVarName(getTableName(collection), propName);
95
- columnDefinition = `${enumName}("${colName}")`;
116
+ columnDefinition = `${enumName}(${quote(colName)})`;
96
117
  } else if ("isId" in stringProp && stringProp.isId === "uuid") {
97
- columnDefinition = `uuid("${colName}")`;
118
+ columnDefinition = `uuid(${quote(colName)})`;
98
119
  } else if (stringProp.columnType === "uuid") {
99
- columnDefinition = `uuid("${colName}")`;
120
+ columnDefinition = `uuid(${quote(colName)})`;
100
121
  } else if (stringProp.columnType === "char") {
101
- columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
122
+ columnDefinition = `char(${quote(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
102
123
  } else if (stringProp.columnType === "varchar") {
103
124
  // The length is not optional decoration: `varchar("col")` with
104
125
  // no length is an UNBOUNDED varchar in Postgres, which is what
105
126
  // this emitted while the DDL generator emitted VARCHAR(255) for
106
127
  // the very same property.
107
- columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
128
+ columnDefinition = `varchar(${quote(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
108
129
  } else {
109
130
  // `text` is the default, and the only length-unbounded choice.
110
131
  // Ask for `varchar` explicitly if you want the length constraint.
111
- columnDefinition = `text("${colName}")`;
132
+ columnDefinition = `text(${quote(colName)})`;
112
133
  }
113
134
  if (isIdProperty(propName, prop, collection)) {
114
135
  columnDefinition += ".primaryKey()";
@@ -134,9 +155,9 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
134
155
  const numProp = prop as unknown as NumberProperty;
135
156
  const isId = isIdProperty(propName, prop, collection);
136
157
 
137
- let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
158
+ let baseType = (numProp.validation?.integer || isId) ? `integer(${quote(colName)})` : `numeric(${quote(colName)})`;
138
159
  if (numProp.columnType) {
139
- if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
160
+ if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote(colName)})`;
140
161
  // `bigint` and `bigserial` are the only pg-core builders that
141
162
  // *require* a config argument: without `mode`, drizzle cannot
142
163
  // know whether to hand back a `number` or a `bigint`, and the
@@ -154,9 +175,9 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
154
175
  // switching the runtime type would be a breaking change to
155
176
  // every consumer of the generated schema.
156
177
  else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") {
157
- baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
178
+ baseType = `${numProp.columnType}(${quote(colName)}, { mode: "number" })`;
158
179
  }
159
- else baseType = `${numProp.columnType}("${colName}")`;
180
+ else baseType = `${numProp.columnType}(${quote(colName)})`;
160
181
  }
161
182
 
162
183
  if ("isId" in numProp && numProp.isId === "increment") {
@@ -180,16 +201,16 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
180
201
  break;
181
202
  }
182
203
  case "boolean":
183
- columnDefinition = `boolean("${colName}")`;
204
+ columnDefinition = `boolean(${quote(colName)})`;
184
205
  break;
185
206
  case "date": {
186
207
  const dateProp = prop as DateProperty;
187
208
  if (dateProp.columnType === "date") {
188
- columnDefinition = `date("${colName}", { mode: 'string' })`;
209
+ columnDefinition = `date(${quote(colName)}, { mode: 'string' })`;
189
210
  } else if (dateProp.columnType === "time") {
190
- columnDefinition = `time("${colName}")`;
211
+ columnDefinition = `time(${quote(colName)})`;
191
212
  } else {
192
- columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
213
+ columnDefinition = `timestamp(${quote(colName)}, { withTimezone: true, mode: 'string' })`;
193
214
  }
194
215
  // autoValue: database-level default for initial value on INSERT
195
216
  if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
@@ -200,12 +221,22 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
200
221
  case "map": {
201
222
  const mapProp = prop as MapProperty;
202
223
  if (mapProp.columnType === "json") {
203
- columnDefinition = `json("${colName}")`;
224
+ columnDefinition = `json(${quote(colName)})`;
204
225
  } else {
205
- columnDefinition = `jsonb("${colName}")`;
226
+ columnDefinition = `jsonb(${quote(colName)})`;
206
227
  }
207
228
  break;
208
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
+ }
209
240
  case "array": {
210
241
  const arrayProp = prop as ArrayProperty;
211
242
  let colType = arrayProp.columnType;
@@ -221,27 +252,27 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
221
252
  }
222
253
 
223
254
  if (colType === "json") {
224
- columnDefinition = `json("${colName}")`;
255
+ columnDefinition = `json(${quote(colName)})`;
225
256
  } else if (colType === "text[]") {
226
- columnDefinition = `text("${colName}").array()`;
257
+ columnDefinition = `text(${quote(colName)}).array()`;
227
258
  } else if (colType === "integer[]") {
228
- columnDefinition = `integer("${colName}").array()`;
259
+ columnDefinition = `integer(${quote(colName)}).array()`;
229
260
  } else if (colType === "boolean[]") {
230
- columnDefinition = `boolean("${colName}").array()`;
261
+ columnDefinition = `boolean(${quote(colName)}).array()`;
231
262
  } else if (colType === "numeric[]") {
232
- columnDefinition = `numeric("${colName}").array()`;
263
+ columnDefinition = `numeric(${quote(colName)}).array()`;
233
264
  } else {
234
- columnDefinition = `jsonb("${colName}")`;
265
+ columnDefinition = `jsonb(${quote(colName)})`;
235
266
  }
236
267
  break;
237
268
  }
238
269
  case "vector": {
239
270
  const vp = prop as VectorProperty;
240
- columnDefinition = `vector("${colName}", { dimensions: ${vp.dimensions} })`;
271
+ columnDefinition = `vector(${quote(colName)}, { dimensions: ${vp.dimensions} })`;
241
272
  break;
242
273
  }
243
274
  case "binary": {
244
- columnDefinition = `customType({ dataType() { return 'bytea'; } })("${colName}")`;
275
+ columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote(colName)})`;
245
276
  break;
246
277
  }
247
278
  case "relation": {
@@ -255,9 +286,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
255
286
  return null;
256
287
  }
257
288
 
258
- // If the localKey property is defined elsewhere in the properties, it will be handled there.
259
- // This logic is for when the relation property itself defines the FK.
260
- 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) {
261
297
  return null;
262
298
  }
263
299
 
@@ -282,47 +318,63 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
282
318
  const refOptionsParts = [onUpdate, onDelete].filter(Boolean);
283
319
  const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
284
320
 
285
- let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
321
+ let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
286
322
 
287
323
  if (required) {
288
324
  columnDef += ".notNull()";
289
325
  }
290
326
 
291
- 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}`;
292
331
  }
293
332
  case "reference": {
294
333
  const refProp = prop as ReferenceProperty;
295
334
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
296
335
  if (!targetCollection) {
297
- columnDefinition = `text("${colName}")`;
336
+ columnDefinition = `text(${quote(colName)})`;
298
337
  break;
299
338
  }
300
339
 
301
340
  const pkProp = getPrimaryKeyProp(targetCollection);
302
341
  const targetTableVar = getTableVarName(getTableName(targetCollection));
303
342
  const targetIdField = pkProp.name;
304
- 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)})`);
305
344
 
306
345
  const required = prop.validation?.required;
307
346
  const onDelete = required ? "cascade" : "set null";
308
347
  const refOptions = `{ onDelete: "${onDelete}" }`;
309
348
 
310
- columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${refOptions})`;
349
+ columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
311
350
  if (required) {
312
351
  columnDefinition += ".notNull()";
313
352
  }
314
353
  // Skip the standard notNull() handling below because we did it here with references
315
- return ` ${propName}: ${columnDefinition}`;
354
+ return ` ${propKey(propName)}: ${columnDefinition}`;
316
355
  }
317
356
  default:
318
- 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
+ );
319
371
  }
320
372
 
321
373
  if (prop.validation?.required) {
322
374
  columnDefinition += ".notNull()";
323
375
  }
324
376
 
325
- return ` ${propName}: ${columnDefinition}`;
377
+ return ` ${propKey(propName)}: ${columnDefinition}`;
326
378
  };
327
379
 
328
380
  /**
@@ -399,7 +451,7 @@ const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRu
399
451
  if (usingClause) parts.push(`using: ${usingClause}`);
400
452
  if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
401
453
 
402
- return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
454
+ return ` pgPolicy(${quote(policyName)}, { ${parts.join(", ")} }),\n`;
403
455
  };
404
456
 
405
457
  /**
@@ -451,7 +503,18 @@ const computeSharedRelationName = (
451
503
  };
452
504
 
453
505
  // --- Main Schema Generation Logic ---
454
- export const generateSchema = async (collections: CollectionConfig[], stripPolicies = false): Promise<string> => {
506
+ export const generateSchema = async (allCollections: CollectionConfig[], stripPolicies = false): Promise<string> => {
507
+ // A Firestore or MongoDB collection has no table to generate, and generating
508
+ // one for it is not merely wasted output: `db push` would create it, and
509
+ // `rebase doctor` would then report the store the collection actually reads
510
+ // from as drift. Non-SQL collections leave the toolchain here, once, rather
511
+ // than being filtered again in each stage below.
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));
455
518
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
456
519
 
457
520
 
@@ -473,11 +536,15 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
473
536
  )
474
537
  );
475
538
 
539
+ // drizzle ships no `tsvector` builder, so an opted-in search column reaches
540
+ // the schema the same way `bytea` does — through `customType`.
541
+ const hasSearch = collections.some(c => buildSearchColumnSpec(c) !== undefined);
542
+
476
543
  // Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
477
544
  const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
478
545
  if (hasUuid) pgCoreImports.push("uuid");
479
546
  if (hasVector) pgCoreImports.push("vector");
480
- if (hasBinary) pgCoreImports.push("customType");
547
+ if (hasBinary || hasSearch) pgCoreImports.push("customType");
481
548
 
482
549
  const uniqueSchemas = Array.from(new Set(
483
550
  collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
@@ -521,7 +588,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
521
588
  )
522
589
  : Object.keys(prop.enum);
523
590
  if (values.length > 0) {
524
- 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`;
525
592
  if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
526
593
  }
527
594
  }
@@ -626,6 +693,25 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
626
693
 
627
694
  });
628
695
 
696
+ // The opt-in search column. Declared with its real generation
697
+ // expression rather than as a bare custom type: `schema.generated.ts`
698
+ // is what a developer running drizzle-kit themselves diffs against,
699
+ // and a column declared without its expression reads to drizzle-kit
700
+ // as one it should alter.
701
+ const searchSpec = buildSearchColumnSpec(collection);
702
+ if (searchSpec) {
703
+ columns.add(
704
+ ` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}")` +
705
+ `.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
706
+ );
707
+ if (searchSpec.fuzzy) {
708
+ columns.add(
709
+ ` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}")` +
710
+ `.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
711
+ );
712
+ }
713
+ }
714
+
629
715
  // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
630
716
  // We should generate a basic id column if one was completely omitted.
631
717
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
@@ -698,7 +784,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
698
784
  }
699
785
 
700
786
  // Source side one(): pairs with owning table's many(junctionTable, { relationName })
701
- 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 })`);
702
788
 
703
789
  // Target side one(): pairs with inverse table's many(junctionTable, { relationName })
704
790
  // Always emit a relationName to avoid collisions with the source-side's owningRelationName.
@@ -706,7 +792,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
706
792
  const targetRelationName = inverseRelationName
707
793
  ? inverseRelationName
708
794
  : `${tableName}_${relation.through.targetColumn}`;
709
- 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 })`);
710
796
  }
711
797
  } else {
712
798
  const resolvedRelations = resolveCollectionRelations(collection);
@@ -746,7 +832,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
746
832
  // schema that does not compile. The three other
747
833
  // emission sites normalise; this one did not.
748
834
  const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
749
- 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 })`);
750
836
  break;
751
837
  }
752
838
 
@@ -811,7 +897,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
811
897
  ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey)
812
898
  : getPrimaryKeyName(otherCollection);
813
899
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
814
- 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 })`);
815
901
  emittedRelationNames.add(deduplicationKey);
816
902
  }
817
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 {