@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.
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- 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"),
|
|
47
|
-
*
|
|
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
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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}(
|
|
116
|
+
columnDefinition = `${enumName}(${quote(colName)})`;
|
|
97
117
|
} else if ("isId" in stringProp && stringProp.isId === "uuid") {
|
|
98
|
-
columnDefinition = `uuid(
|
|
118
|
+
columnDefinition = `uuid(${quote(colName)})`;
|
|
99
119
|
} else if (stringProp.columnType === "uuid") {
|
|
100
|
-
columnDefinition = `uuid(
|
|
120
|
+
columnDefinition = `uuid(${quote(colName)})`;
|
|
101
121
|
} else if (stringProp.columnType === "char") {
|
|
102
|
-
columnDefinition = `char(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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}(
|
|
178
|
+
baseType = `${numProp.columnType}(${quote(colName)}, { mode: "number" })`;
|
|
159
179
|
}
|
|
160
|
-
else baseType = `${numProp.columnType}(
|
|
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(
|
|
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(
|
|
209
|
+
columnDefinition = `date(${quote(colName)}, { mode: 'string' })`;
|
|
190
210
|
} else if (dateProp.columnType === "time") {
|
|
191
|
-
columnDefinition = `time(
|
|
211
|
+
columnDefinition = `time(${quote(colName)})`;
|
|
192
212
|
} else {
|
|
193
|
-
columnDefinition = `timestamp(
|
|
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(
|
|
224
|
+
columnDefinition = `json(${quote(colName)})`;
|
|
205
225
|
} else {
|
|
206
|
-
columnDefinition = `jsonb(
|
|
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(
|
|
255
|
+
columnDefinition = `json(${quote(colName)})`;
|
|
226
256
|
} else if (colType === "text[]") {
|
|
227
|
-
columnDefinition = `text(
|
|
257
|
+
columnDefinition = `text(${quote(colName)}).array()`;
|
|
228
258
|
} else if (colType === "integer[]") {
|
|
229
|
-
columnDefinition = `integer(
|
|
259
|
+
columnDefinition = `integer(${quote(colName)}).array()`;
|
|
230
260
|
} else if (colType === "boolean[]") {
|
|
231
|
-
columnDefinition = `boolean(
|
|
261
|
+
columnDefinition = `boolean(${quote(colName)}).array()`;
|
|
232
262
|
} else if (colType === "numeric[]") {
|
|
233
|
-
columnDefinition = `numeric(
|
|
263
|
+
columnDefinition = `numeric(${quote(colName)}).array()`;
|
|
234
264
|
} else {
|
|
235
|
-
columnDefinition = `jsonb(
|
|
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(
|
|
271
|
+
columnDefinition = `vector(${quote(colName)}, { dimensions: ${vp.dimensions} })`;
|
|
242
272
|
break;
|
|
243
273
|
}
|
|
244
274
|
case "binary": {
|
|
245
|
-
columnDefinition = `customType({ dataType() { return 'bytea'; } })(
|
|
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
|
|
260
|
-
//
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
out(`✅ Drizzle schema generated successfully at ${outputPath}`);
|
|
79
80
|
} else {
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
out("✅ Drizzle schema generated successfully.");
|
|
82
|
+
out(String(schemaContent));
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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
|
/**
|