@rebasepro/server-postgres 0.12.1-canary.g5f403cf → 0.12.1-canary.g7f1150f

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.
@@ -0,0 +1,57 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ import "process";
3
+ __createRequire(import.meta.url);
4
+ import { readExistingSchema, t as planCollectionPolicies } from "./ensure-collection-tables-DmOzh_G6.js";
5
+ //#region src/schema/ensure-collection-policies.ts
6
+ var isCreatePolicy = (statement) => /^\s*CREATE POLICY/i.test(statement);
7
+ /**
8
+ * Bring the declared collections' RLS policies up to date. Returns what it did.
9
+ *
10
+ * Only tables that already exist are touched: the boot-time table creator runs
11
+ * first, so anything still missing is a table this additive path is not allowed
12
+ * to create (a junction, or a relation left to a migration). Enabling RLS on a
13
+ * non-existent table would error, so those are recorded as skipped, not failed.
14
+ */
15
+ async function ensureCollectionPolicies(client, collections, log) {
16
+ const result = {
17
+ policiesApplied: 0,
18
+ tablesSecured: 0,
19
+ skipped: [],
20
+ failures: []
21
+ };
22
+ const plans = planCollectionPolicies(collections);
23
+ if (plans.length === 0) return result;
24
+ const existing = await readExistingSchema(client, Array.from(new Set(plans.map((p) => p.schema))));
25
+ for (const plan of plans) {
26
+ if (!existing.tables.has(plan.qualified)) {
27
+ result.skipped.push({
28
+ table: plan.qualified,
29
+ reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
30
+ });
31
+ continue;
32
+ }
33
+ try {
34
+ await client.query(plan.enableRls);
35
+ result.tablesSecured++;
36
+ let created = 0;
37
+ for (const statement of plan.policyStatements) {
38
+ await client.query(statement);
39
+ if (isCreatePolicy(statement)) {
40
+ result.policiesApplied++;
41
+ created++;
42
+ }
43
+ }
44
+ log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
45
+ } catch (err) {
46
+ result.failures.push({
47
+ table: plan.qualified,
48
+ error: err instanceof Error ? err.message : String(err)
49
+ });
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ //#endregion
55
+ export { ensureCollectionPolicies };
56
+
57
+ //# sourceMappingURL=ensure-collection-policies-t875gSJF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensure-collection-policies-t875gSJF.js","names":[],"sources":["../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies } from \"./generate-postgres-ddl-logic\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /** Tables whose RLS could not be fully applied (fail closed). */\n failures: { table: string; error: string }[];\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n try {\n // Enable first: if a later policy statement fails, the table is left\n // locked (deny-all for the user role) rather than open.\n await client.query(plan.enableRls);\n result.tablesSecured++;\n\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;AAgDA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;AAU1F,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;CAAE;CAErG,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAEA,IAAI;GAGA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;GAEP,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GACA,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,qBAAqB;EAC1E,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
@@ -0,0 +1,589 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ import "process";
3
+ __createRequire(import.meta.url);
4
+ import { O as toSnakeCase, T as getPolicyNamesForRule, a as getJunctionSecurityRules, c as policyToPostgres, d as findRelation, g as resolveCollectionRelations, i as getJunctionCollectionConfig, l as securityRuleToConditions, m as getTableName, o as resolveJunctionSpecs, r as resolveStringColumnLength, s as getEffectiveSecurityRules } from "./src-DihrDFuP.js";
5
+ import { n as isPostgresCollectionConfig } from "./src-DoU9yPqq.js";
6
+ //#region src/schema/generate-postgres-ddl-logic.ts
7
+ var resolveColumnName = (propName, prop) => {
8
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
9
+ return toSnakeCase(propName);
10
+ };
11
+ var getPrimaryKeyProp = (collection) => {
12
+ if (collection.properties) {
13
+ const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
14
+ if (idPropEntry) {
15
+ const prop = idPropEntry[1];
16
+ const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
17
+ return {
18
+ name: idPropEntry[0],
19
+ type: prop.type === "number" ? "number" : "string",
20
+ isUuid
21
+ };
22
+ }
23
+ }
24
+ const idProp = collection.properties?.["id"];
25
+ if (idProp?.type === "number") return {
26
+ name: "id",
27
+ type: "number",
28
+ isUuid: false
29
+ };
30
+ return {
31
+ name: "id",
32
+ type: "string",
33
+ isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
34
+ };
35
+ };
36
+ var isNumericId = (collection) => {
37
+ return getPrimaryKeyProp(collection).type === "number";
38
+ };
39
+ var getPrimaryKeyName = (collection) => {
40
+ return getPrimaryKeyProp(collection).name;
41
+ };
42
+ /** The column type a junction holds for one endpoint's primary key. */
43
+ var junctionKeyType = (collection) => isNumericId(collection) ? "INTEGER" : getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT";
44
+ var isIdProperty = (propName, prop, collection) => {
45
+ if ("isId" in prop && Boolean(prop.isId)) return true;
46
+ return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
47
+ };
48
+ /**
49
+ * The individual SQL statements a single security rule compiles to: a
50
+ * `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
51
+ * statement (terminated by `;`, no trailing newline).
52
+ *
53
+ * This is the primitive the boot-time RLS applier runs one statement at a time
54
+ * (the runtime's DB handle speaks the extended query protocol, which forbids
55
+ * multiple commands in one execute), while `db push` writes the joined string.
56
+ */
57
+ var generatePolicyStatements = (collection, rule, resolveCollection) => {
58
+ const tableName = getTableName(collection);
59
+ const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
60
+ const policyNames = getPolicyNamesForRule(rule, tableName);
61
+ return ops.flatMap((op, opIdx) => {
62
+ return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
63
+ });
64
+ };
65
+ var generateSinglePolicyStatements = (collection, rule, operation, policyName, resolveCollection) => {
66
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
67
+ const tableName = getTableName(collection);
68
+ const mode = (rule.mode ?? "permissive").toUpperCase();
69
+ const operationUpper = operation.toUpperCase();
70
+ const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
71
+ const needsUsing = operation !== "insert";
72
+ const needsWithCheck = operation !== "select" && operation !== "delete";
73
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
74
+ let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
75
+ let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
76
+ if (!usingClause && needsUsing) usingClause = "false";
77
+ if (!withCheckClause && needsWithCheck) withCheckClause = "false";
78
+ const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
79
+ let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map((r) => `"${r}"`).join(", ")}`;
80
+ if (usingClause) create += ` USING (${usingClause})`;
81
+ if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
82
+ create += ";";
83
+ return [drop, create];
84
+ };
85
+ var getSqlColumnType = (propName, prop, collection, collections) => {
86
+ switch (prop.type) {
87
+ case "string": {
88
+ const stringProp = prop;
89
+ if (stringProp.enum) {
90
+ const tableName = getTableName(collection);
91
+ const colName = resolveColumnName(propName, prop);
92
+ return `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${tableName}_${colName}"`;
93
+ }
94
+ if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") return "UUID";
95
+ if (stringProp.columnType === "char") return `CHAR(${resolveStringColumnLength(stringProp)})`;
96
+ if (stringProp.columnType === "varchar") return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
97
+ return "TEXT";
98
+ }
99
+ case "number": {
100
+ const numProp = prop;
101
+ const isId = isIdProperty(propName, prop, collection);
102
+ if ("isId" in numProp && numProp.isId === "increment") return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
103
+ if (numProp.columnType) {
104
+ if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
105
+ return numProp.columnType.toUpperCase();
106
+ }
107
+ return numProp.validation?.integer || isId ? "INTEGER" : "NUMERIC";
108
+ }
109
+ case "boolean": return "BOOLEAN";
110
+ case "date": {
111
+ const dateProp = prop;
112
+ if (dateProp.columnType === "date") return "DATE";
113
+ if (dateProp.columnType === "time") return "TIME";
114
+ return "TIMESTAMP WITH TIME ZONE";
115
+ }
116
+ case "map": return prop.columnType === "json" ? "JSON" : "JSONB";
117
+ case "array": {
118
+ const arrayProp = prop;
119
+ let colType = arrayProp.columnType;
120
+ if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
121
+ const ofProp = arrayProp.of;
122
+ if (ofProp.type === "string") colType = "text[]";
123
+ else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
124
+ else if (ofProp.type === "boolean") colType = "boolean[]";
125
+ }
126
+ if (colType === "json") return "JSON";
127
+ if (colType === "text[]") return "TEXT[]";
128
+ if (colType === "integer[]") return "INTEGER[]";
129
+ if (colType === "boolean[]") return "BOOLEAN[]";
130
+ if (colType === "numeric[]") return "NUMERIC[]";
131
+ return "JSONB";
132
+ }
133
+ case "vector": return `VECTOR(${prop.dimensions})`;
134
+ case "binary": return "BYTEA";
135
+ case "relation": {
136
+ const refProp = prop;
137
+ const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
138
+ if (relation?.kind !== "belongsTo") throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
139
+ let targetCollection;
140
+ try {
141
+ targetCollection = relation.target();
142
+ } catch {
143
+ return "TEXT";
144
+ }
145
+ const pkProp = getPrimaryKeyProp(targetCollection);
146
+ return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
147
+ }
148
+ case "reference": {
149
+ const refProp = prop;
150
+ const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
151
+ if (!targetCollection) return "TEXT";
152
+ const pkProp = getPrimaryKeyProp(targetCollection);
153
+ return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
154
+ }
155
+ default: return "TEXT";
156
+ }
157
+ };
158
+ var schemaOfCollection = (collection) => isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
159
+ var bareTableName = (name) => name.includes(".") ? name.split(".").pop() : name;
160
+ var foreignKeyPlan = (args) => {
161
+ const constraintName = `${args.table}_${args.column}_fkey`;
162
+ const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
163
+ return {
164
+ constraintName,
165
+ schema: args.schema,
166
+ table: args.table,
167
+ column: args.column,
168
+ targetSchema: args.targetSchema,
169
+ targetTable: args.targetTable,
170
+ targetColumn: args.targetColumn,
171
+ sql: `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
172
+ };
173
+ };
174
+ /**
175
+ * The FK columns the declared collections own — one entry per `relation`
176
+ * (`belongsTo` side) or `reference` property.
177
+ *
178
+ * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
179
+ * create the same columns with the same names, types and constraints. Before
180
+ * this it skipped them outright, which was survivable only because `db push`
181
+ * always followed; on a managed tenant nothing follows, so a table arrived
182
+ * without the column its own collection reads and wrote 400 on every insert.
183
+ *
184
+ * A relation whose target is not in the bundle yields no column at all (the
185
+ * generator returns early on an unresolvable target); a `reference` whose target
186
+ * is unknown yields the column without a constraint. Both mirror the generator
187
+ * exactly — a divergence here is a schema fork between boot and `db push`.
188
+ */
189
+ var planRelationalColumns = (collections) => {
190
+ const plans = [];
191
+ for (const collection of collections) {
192
+ const tableName = getTableName(collection);
193
+ if (!tableName) continue;
194
+ const schema = schemaOfCollection(collection);
195
+ const table = bareTableName(tableName);
196
+ for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
197
+ const prop = rawProp;
198
+ if (prop.type === "relation") {
199
+ const refProp = prop;
200
+ const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
201
+ if (relInfo?.kind !== "belongsTo") continue;
202
+ if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
203
+ let targetCollection;
204
+ try {
205
+ targetCollection = relInfo.target();
206
+ } catch {
207
+ continue;
208
+ }
209
+ if (!targetCollection) continue;
210
+ const required = prop.validation?.required;
211
+ plans.push({
212
+ schema,
213
+ table,
214
+ column: relInfo.localKey,
215
+ type: getSqlColumnType(propName, prop, collection, collections),
216
+ foreignKey: foreignKeyPlan({
217
+ schema,
218
+ table,
219
+ column: relInfo.localKey,
220
+ targetSchema: schemaOfCollection(targetCollection),
221
+ targetTable: bareTableName(getTableName(targetCollection)),
222
+ targetColumn: getPrimaryKeyName(targetCollection),
223
+ onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
224
+ onUpdate: relInfo.onUpdate
225
+ })
226
+ });
227
+ } else if (prop.type === "reference") {
228
+ const refProp = prop;
229
+ const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
230
+ const column = resolveColumnName(propName, prop);
231
+ const type = getSqlColumnType(propName, prop, collection, collections);
232
+ const required = prop.validation?.required;
233
+ plans.push({
234
+ schema,
235
+ table,
236
+ column,
237
+ type,
238
+ foreignKey: targetCollection ? foreignKeyPlan({
239
+ schema,
240
+ table,
241
+ column,
242
+ targetSchema: schemaOfCollection(targetCollection),
243
+ targetTable: bareTableName(getTableName(targetCollection)),
244
+ targetColumn: getPrimaryKeyName(targetCollection),
245
+ onDelete: required ? "CASCADE" : "SET NULL"
246
+ }) : void 0
247
+ });
248
+ }
249
+ }
250
+ }
251
+ return plans;
252
+ };
253
+ /**
254
+ * The junction tables a bundle's many-to-many relations imply.
255
+ *
256
+ * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
257
+ * comes from, so a table created here always has policies planned for it — a
258
+ * junction with row-level security left off is readable and writable by every
259
+ * signed-in user, which is why the two must ship together.
260
+ */
261
+ var planJunctionTables = (collections) => {
262
+ const plans = [];
263
+ for (const spec of resolveJunctionSpecs(collections).values()) {
264
+ const [source, target] = spec.endpoints;
265
+ const columns = [{
266
+ name: source.junctionColumn,
267
+ type: junctionKeyType(source.collection)
268
+ }, {
269
+ name: target.junctionColumn,
270
+ type: junctionKeyType(target.collection)
271
+ }];
272
+ const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
273
+ plans.push({
274
+ schema: spec.schema,
275
+ table: spec.table,
276
+ columns,
277
+ createTable: `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` + columns.map((c) => `"${c.name}" ${c.type} NOT NULL`).join(", ") + `, PRIMARY KEY (${columns.map((c) => `"${c.name}"`).join(", ")}));`,
278
+ foreignKeys: [source, target].map((endpoint, i) => foreignKeyPlan({
279
+ schema: spec.schema,
280
+ table: spec.table,
281
+ column: columns[i].name,
282
+ targetSchema: schemaOfCollection(endpoint.collection),
283
+ targetTable: bareTableName(getTableName(endpoint.collection)),
284
+ targetColumn: getPrimaryKeyName(endpoint.collection),
285
+ onDelete
286
+ }))
287
+ });
288
+ }
289
+ return plans;
290
+ };
291
+ /**
292
+ * The per-table RLS plan for the *declared* collections, as executable
293
+ * statements — what the managed runtime applies at boot so a freshly
294
+ * provisioned tenant database serves data instead of 401ing every read.
295
+ *
296
+ * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
297
+ * `generatePolicyStatements`, same enable-RLS, same effective rules, same
298
+ * derived junction rules), so boot and `db push` produce identical policies from
299
+ * identical collections.
300
+ *
301
+ * Junction tables are included, and have to be: boot creates them now
302
+ * ({@link planJunctionTables}), and a junction with RLS left off is readable and
303
+ * writable by every signed-in user. A junction whose table is still absent is
304
+ * skipped by the applier, not planned away here.
305
+ */
306
+ var planCollectionPolicies = (collections) => {
307
+ const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
308
+ const plans = [];
309
+ const seen = /* @__PURE__ */ new Set();
310
+ for (const collection of collections) {
311
+ const tableName = getTableName(collection);
312
+ if (!tableName) continue;
313
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
314
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
315
+ const qualified = `${schema}.${baseTableName}`;
316
+ if (seen.has(qualified)) continue;
317
+ seen.add(qualified);
318
+ const policyStatements = [];
319
+ for (const rule of getEffectiveSecurityRules(collection)) policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
320
+ plans.push({
321
+ schema,
322
+ table: baseTableName,
323
+ qualified,
324
+ enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
325
+ policyStatements
326
+ });
327
+ }
328
+ for (const spec of resolveJunctionSpecs(collections).values()) {
329
+ const qualified = `${spec.schema}.${spec.table}`;
330
+ if (seen.has(qualified)) continue;
331
+ seen.add(qualified);
332
+ const junctionCollection = getJunctionCollectionConfig(spec);
333
+ const policyStatements = [];
334
+ for (const rule of getJunctionSecurityRules(spec)) policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
335
+ plans.push({
336
+ schema: spec.schema,
337
+ table: spec.table,
338
+ qualified,
339
+ enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
340
+ policyStatements
341
+ });
342
+ }
343
+ return plans;
344
+ };
345
+ //#endregion
346
+ //#region src/schema/ensure-collection-tables.ts
347
+ /**
348
+ * Bringing a database up to date with a bundle's collections, additively.
349
+ *
350
+ * ## Why this exists
351
+ *
352
+ * A managed runtime boots someone else's compiled project against a database it
353
+ * has never seen. Auth tables are ensured at boot already, but collection tables
354
+ * were not created by anything: the platform ran the app and every `/api/data/*`
355
+ * request answered 500 on a missing relation. `rebase db push` cannot help — it
356
+ * is an Atlas-driven CLI command, and the runtime image ships no CLI.
357
+ *
358
+ * ## Why additive-only, forever
359
+ *
360
+ * This runs unattended, against a database with customers' data in it, with no
361
+ * human reading a diff. So it may only ever do things that cannot lose data:
362
+ * create a missing table, add a missing column, create a missing enum type.
363
+ *
364
+ * It will **never** drop a table or a column, narrow a type, or alter a
365
+ * constraint. A removed field leaves its column behind; a renamed field looks
366
+ * like an addition and the old column stays. That is the correct trade for an
367
+ * automated path — the alternative is an unattended process that can silently
368
+ * destroy a column, which is precisely the failure `db push` was hardened
369
+ * against. Destructive changes stay a deliberate, human-reviewed migration.
370
+ *
371
+ * Because of that, this is safe to run on every boot, and re-running it is a
372
+ * no-op.
373
+ */
374
+ /** Postgres identifiers this module is willing to interpolate. */
375
+ var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
376
+ function assertSafeIdentifier(value, what) {
377
+ if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
378
+ return value;
379
+ }
380
+ function schemaOf(collection) {
381
+ return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
382
+ }
383
+ function qualified(collection) {
384
+ return `${schemaOf(collection)}.${getTableName(collection)}`;
385
+ }
386
+ /**
387
+ * Enum types a collection's properties require, as `schema.typename`.
388
+ *
389
+ * Named exactly as the DDL generator names them (`<table>_<column>`), because
390
+ * a column added here has to reference the same type the generator would have
391
+ * created — a second, differently-named type for the same field would be a
392
+ * silent schema fork.
393
+ */
394
+ function requiredEnums(collection) {
395
+ const table = getTableName(collection);
396
+ const schema = schemaOf(collection);
397
+ const out = [];
398
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
399
+ const p = prop;
400
+ if (!("enum" in p) || !p.enum) continue;
401
+ if (p.type !== "string" && p.type !== "number") continue;
402
+ const values = p.enum.map((entry) => entry && typeof entry === "object" && "id" in entry ? String(entry.id) : String(entry)).filter((v) => v.length > 0);
403
+ if (values.length === 0) continue;
404
+ out.push({
405
+ name: `${schema}.${table}_${resolveColumnName(propName, p)}`,
406
+ values
407
+ });
408
+ }
409
+ return out;
410
+ }
411
+ /** Single-quote escaping for an enum label. */
412
+ function quoteLiteral(value) {
413
+ return `'${value.replace(/'/g, "''")}'`;
414
+ }
415
+ /**
416
+ * Decide what to add. Pure — the caller supplies what exists and runs the result.
417
+ *
418
+ * Ordering matters and is deliberate: enum types before the tables and columns
419
+ * that reference them, tables before the columns added to other tables (a new
420
+ * table may be the target of a relation), and nothing is emitted twice.
421
+ */
422
+ function planCollectionSchemaEnsure(collections, existing) {
423
+ const actions = [];
424
+ const plannedEnums = /* @__PURE__ */ new Set();
425
+ for (const collection of collections) for (const { name, values } of requiredEnums(collection)) {
426
+ if (existing.enums.has(name) || plannedEnums.has(name)) continue;
427
+ plannedEnums.add(name);
428
+ const [schema, typeName] = name.split(".");
429
+ actions.push({
430
+ kind: "create-enum",
431
+ target: name,
432
+ sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteLiteral).join(", ")});`
433
+ });
434
+ }
435
+ const created = /* @__PURE__ */ new Set();
436
+ for (const collection of collections) {
437
+ const key = qualified(collection);
438
+ if (existing.tables.has(key) || created.has(key)) continue;
439
+ created.add(key);
440
+ const schema = schemaOf(collection);
441
+ const table = getTableName(collection);
442
+ const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) => isIdProperty(n, p, collection));
443
+ const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1]) : "id";
444
+ const idProp = idEntry?.[1];
445
+ let idDef;
446
+ if (idProp?.type === "number") idDef = `"${idName}" BIGSERIAL PRIMARY KEY`;
447
+ else if (idProp && idProp.type === "string" && idProp.isId === "uuid") idDef = `"${idName}" UUID PRIMARY KEY DEFAULT gen_random_uuid()`;
448
+ else idDef = `"${idName}" TEXT PRIMARY KEY`;
449
+ actions.push({
450
+ kind: "create-table",
451
+ target: key,
452
+ sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
453
+ });
454
+ }
455
+ const junctions = planJunctionTables(collections);
456
+ for (const junction of junctions) {
457
+ const key = `${junction.schema}.${junction.table}`;
458
+ if (existing.tables.has(key) || created.has(key)) continue;
459
+ created.add(key);
460
+ actions.push({
461
+ kind: "create-table",
462
+ target: key,
463
+ sql: junction.createTable
464
+ });
465
+ }
466
+ const addColumn = (key, schema, table, column, type) => {
467
+ if (existing.tables.get(key)?.has(column)) return;
468
+ actions.push({
469
+ kind: "add-column",
470
+ target: `${key}.${column}`,
471
+ sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
472
+ });
473
+ };
474
+ for (const collection of collections) {
475
+ const key = qualified(collection);
476
+ const schema = schemaOf(collection);
477
+ const table = getTableName(collection);
478
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
479
+ const p = prop;
480
+ if (isIdProperty(propName, p, collection)) continue;
481
+ if (p.type === "reference" || p.type === "relation") continue;
482
+ addColumn(key, schema, table, resolveColumnName(propName, p), getSqlColumnType(propName, p, collection, collections));
483
+ }
484
+ }
485
+ for (const junction of junctions) {
486
+ const key = `${junction.schema}.${junction.table}`;
487
+ for (const column of junction.columns) addColumn(key, junction.schema, junction.table, column.name, column.type);
488
+ }
489
+ for (const relational of planRelationalColumns(collections)) addColumn(`${relational.schema}.${relational.table}`, relational.schema, relational.table, relational.column, relational.type);
490
+ const knownConstraints = existing.constraints ?? /* @__PURE__ */ new Set();
491
+ const plannedConstraints = /* @__PURE__ */ new Set();
492
+ const foreignKeys = [...planRelationalColumns(collections).map((r) => r.foreignKey), ...junctions.flatMap((j) => j.foreignKeys)];
493
+ for (const fk of foreignKeys) {
494
+ if (!fk) continue;
495
+ const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
496
+ if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
497
+ plannedConstraints.add(name);
498
+ actions.push({
499
+ kind: "add-constraint",
500
+ target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
501
+ sql: fk.sql
502
+ });
503
+ }
504
+ return {
505
+ actions,
506
+ statements: actions.map((a) => a.sql)
507
+ };
508
+ }
509
+ /** Read what the database has, for the schemas the collections live in. */
510
+ async function readExistingSchema(client, schemas) {
511
+ const tables = /* @__PURE__ */ new Map();
512
+ const enums = /* @__PURE__ */ new Set();
513
+ if (schemas.length === 0) return {
514
+ tables,
515
+ enums
516
+ };
517
+ const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
518
+ const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name
519
+ FROM information_schema.columns
520
+ WHERE table_schema IN (${inList})`);
521
+ for (const row of columns) {
522
+ const key = `${row.table_schema}.${row.table_name}`;
523
+ if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
524
+ tables.get(key).add(row.column_name);
525
+ }
526
+ const { rows: enumRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name
527
+ FROM pg_type t
528
+ JOIN pg_namespace n ON t.typnamespace = n.oid
529
+ WHERE t.typtype = 'e' AND n.nspname IN (${inList})`);
530
+ for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
531
+ const constraints = /* @__PURE__ */ new Set();
532
+ const { rows: constraintRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
533
+ FROM pg_constraint con
534
+ JOIN pg_class c ON con.conrelid = c.oid
535
+ JOIN pg_namespace n ON c.relnamespace = n.oid
536
+ WHERE n.nspname IN (${inList})`);
537
+ for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
538
+ return {
539
+ tables,
540
+ enums,
541
+ constraints
542
+ };
543
+ }
544
+ /**
545
+ * Bring the database up to date. Returns what it did.
546
+ *
547
+ * Each statement runs on its own rather than in one transaction: they are all
548
+ * independently safe and idempotent, and a single failure (an enum label that
549
+ * cannot be added, say) should not roll back the tables that were created fine.
550
+ * The error is surfaced with the statement that caused it.
551
+ */
552
+ async function ensureCollectionTables(client, collections, log) {
553
+ const schemas = Array.from(/* @__PURE__ */ new Set([...collections.map(schemaOf), ...planJunctionTables(collections).map((j) => j.schema)]));
554
+ for (const schema of schemas) {
555
+ assertSafeIdentifier(schema, "schema name");
556
+ if (schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
557
+ }
558
+ const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas));
559
+ const failures = [];
560
+ if (plan.actions.length === 0) {
561
+ log?.("Schema is up to date; nothing to create.");
562
+ return {
563
+ ...plan,
564
+ failures
565
+ };
566
+ }
567
+ for (const action of plan.actions) try {
568
+ await client.query(action.sql);
569
+ log?.(`${action.kind}: ${action.target}`);
570
+ } catch (err) {
571
+ const message = err instanceof Error ? err.message : String(err);
572
+ if (action.kind === "add-constraint") {
573
+ failures.push({
574
+ target: action.target,
575
+ error: message
576
+ });
577
+ continue;
578
+ }
579
+ throw new Error(`Failed to ${action.kind} ${action.target}: ${message}\n ${action.sql}`);
580
+ }
581
+ return {
582
+ ...plan,
583
+ failures
584
+ };
585
+ }
586
+ //#endregion
587
+ export { ensureCollectionTables, readExistingSchema, planCollectionPolicies as t };
588
+
589
+ //# sourceMappingURL=ensure-collection-tables-DmOzh_G6.js.map