@rebasepro/server-postgres 0.12.1-canary.gcfa333c → 0.12.1-canary.gddbd489

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 (81) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +6 -1
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/{backup-service-DH9kPg-E.js → backup-service-CD8o_1Sl.js} +144 -11
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/{connection-B5Wndbr1.js → connection-BuZ97wsr.js} +57 -3
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/{ensure-collection-policies-Vl3Cv1Q9.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  13. package/dist/{ensure-collection-policies-Vl3Cv1Q9.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  14. package/dist/{ensure-collection-tables-DsDsNl6o.js → ensure-collection-tables-CBQdOETu.js} +72 -12
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +990 -677
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/ensure-collection-tables.d.ts +21 -1
  21. package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -0
  22. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  23. package/dist/schema/introspect-db-logic.d.ts +94 -5
  24. package/dist/schema/introspect-db-queries.d.ts +119 -0
  25. package/dist/schema/introspect-db-structure.d.ts +263 -0
  26. package/dist/schema/introspect-db-types.d.ts +11 -0
  27. package/dist/services/FetchService.d.ts +4 -1
  28. package/dist/services/RelationService.d.ts +24 -1
  29. package/dist/services/channel-bus/index.d.ts +1 -7
  30. package/dist/services/collection-helpers.d.ts +36 -2
  31. package/dist/services/dataService.d.ts +3 -1
  32. package/dist/services/row-pipeline.d.ts +1 -1
  33. package/dist/{src-DihrDFuP.js → src-DlPBctw_.js} +217 -101
  34. package/dist/src-DlPBctw_.js.map +1 -0
  35. package/dist/utils/connection-string.d.ts +29 -0
  36. package/dist/utils/drizzle-conditions.d.ts +5 -4
  37. package/dist/utils/pg-error-utils.d.ts +16 -0
  38. package/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
  39. package/dist/websocket-B2LsrINK.js.map +1 -0
  40. package/package.json +6 -6
  41. package/src/PostgresAdapter.ts +7 -2
  42. package/src/PostgresBackendDriver.ts +4 -0
  43. package/src/PostgresBootstrapper.ts +83 -22
  44. package/src/auth/services.ts +6 -3
  45. package/src/backup/backup-cli.ts +41 -2
  46. package/src/backup/backup-service.ts +38 -5
  47. package/src/backup/pg-tools.ts +96 -3
  48. package/src/cli-helpers.ts +70 -0
  49. package/src/cli.ts +44 -26
  50. package/src/collections/validate-relations.ts +15 -0
  51. package/src/connection.ts +73 -0
  52. package/src/data-transformer.ts +9 -3
  53. package/src/databasePoolManager.ts +5 -2
  54. package/src/schema/ensure-collection-tables.ts +81 -10
  55. package/src/schema/generate-drizzle-schema-logic.ts +35 -3
  56. package/src/schema/generate-postgres-ddl-logic.ts +50 -6
  57. package/src/schema/introspect-db-constraints.ts +385 -0
  58. package/src/schema/introspect-db-inference.ts +18 -8
  59. package/src/schema/introspect-db-logic.ts +385 -71
  60. package/src/schema/introspect-db-queries.ts +326 -0
  61. package/src/schema/introspect-db-structure.ts +670 -0
  62. package/src/schema/introspect-db-types.ts +56 -0
  63. package/src/schema/introspect-db.ts +37 -80
  64. package/src/security/policy-drift.test.ts +11 -3
  65. package/src/services/FetchService.ts +90 -14
  66. package/src/services/PersistService.ts +20 -6
  67. package/src/services/RelationService.ts +211 -45
  68. package/src/services/channel-bus/index.ts +0 -9
  69. package/src/services/collection-helpers.ts +69 -3
  70. package/src/services/dataService.ts +3 -1
  71. package/src/services/realtimeService.ts +3 -3
  72. package/src/services/row-pipeline.ts +1 -1
  73. package/src/utils/connection-string.ts +58 -0
  74. package/src/utils/drizzle-conditions.ts +31 -6
  75. package/src/utils/pg-error-utils.ts +19 -0
  76. package/src/websocket.ts +18 -9
  77. package/dist/backup-service-DH9kPg-E.js.map +0 -1
  78. package/dist/connection-B5Wndbr1.js.map +0 -1
  79. package/dist/ensure-collection-tables-DsDsNl6o.js.map +0 -1
  80. package/dist/src-DihrDFuP.js.map +0 -1
  81. package/dist/websocket-BKcGvILX.js.map +0 -1
@@ -27,12 +27,14 @@
27
27
  */
28
28
  import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
29
29
  import { getTableName } from "@rebasepro/common";
30
+ import { logger } from "@rebasepro/server";
30
31
  import {
31
32
  getSqlColumnType,
32
33
  resolveColumnName,
33
34
  isIdProperty,
34
35
  planRelationalColumns,
35
- planJunctionTables
36
+ planJunctionTables,
37
+ quoteSqlLiteral
36
38
  } from "./generate-postgres-ddl-logic";
37
39
 
38
40
  /**
@@ -75,7 +77,7 @@ export interface ExistingSchema {
75
77
  }
76
78
 
77
79
  export interface EnsureAction {
78
- kind: "create-enum" | "create-table" | "add-column" | "add-constraint";
80
+ kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column";
79
81
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
80
82
  target: string;
81
83
  sql: string;
@@ -85,6 +87,27 @@ export interface EnsurePlan {
85
87
  actions: EnsureAction[];
86
88
  /** Every statement, in dependency order. Empty when the schema is current. */
87
89
  statements: string[];
90
+ /**
91
+ * Relation columns this plan is about to create where the table already
92
+ * carries the same column under its pre-singularization name.
93
+ *
94
+ * The reason this is reported rather than silently handled: the ensure is
95
+ * additive, so it would add `category_id` beside a populated
96
+ * `categorie_id` and the relation would then read the new, empty one. No
97
+ * statement fails, no table is missing, and the only symptom is relations
98
+ * resolving to nothing — which is indistinguishable from having no data.
99
+ */
100
+ legacyForeignKeys: LegacyForeignKey[];
101
+ }
102
+
103
+ /** A relation column whose old and new spellings both plausibly apply. */
104
+ export interface LegacyForeignKey {
105
+ /** `schema.table`. */
106
+ table: string;
107
+ /** The name the current rule derives, and what this plan would create. */
108
+ expected: string;
109
+ /** The name the old rule derived, which the table already has. */
110
+ legacy: string;
88
111
  }
89
112
 
90
113
  export interface EnsureOutcome extends EnsurePlan {
@@ -136,11 +159,6 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
136
159
  return out;
137
160
  }
138
161
 
139
- /** Single-quote escaping for an enum label. */
140
- function quoteLiteral(value: string): string {
141
- return `'${value.replace(/'/g, "''")}'`;
142
- }
143
-
144
162
  /**
145
163
  * Decide what to add. Pure — the caller supplies what exists and runs the result.
146
164
  *
@@ -165,7 +183,7 @@ export function planCollectionSchemaEnsure(
165
183
  actions.push({
166
184
  kind: "create-enum",
167
185
  target: name,
168
- sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteLiteral).join(", ")});`
186
+ sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});`
169
187
  });
170
188
  }
171
189
  }
@@ -218,6 +236,41 @@ export function planCollectionSchemaEnsure(
218
236
  }
219
237
 
220
238
  // 3. Missing columns, on both brand-new and pre-existing tables.
239
+ const legacyForeignKeys: LegacyForeignKey[] = [];
240
+
241
+ /**
242
+ * Move a relation column that is only missing because it was renamed.
243
+ *
244
+ * Returns true when it handled the column, so the caller skips the ordinary
245
+ * ADD. Adding here would be the wrong move and a quiet one: the data is in
246
+ * the old column, `ADD COLUMN` creates the new one empty beside it, every
247
+ * statement succeeds, and the relation reads the empty one. A rename is
248
+ * metadata-only in Postgres, keeps the values, and carries the column's
249
+ * indexes and constraints with it.
250
+ *
251
+ * Only ever reached when the new name is absent and the old name is
252
+ * present, so there is nothing to overwrite and nothing to choose between.
253
+ */
254
+ const renameLegacyColumn = (
255
+ key: string,
256
+ schema: string,
257
+ table: string,
258
+ column: string,
259
+ legacyName: string | undefined
260
+ ): boolean => {
261
+ const present = existing.tables.get(key);
262
+ if (!legacyName || !present) return false;
263
+ if (present.has(column) || !present.has(legacyName)) return false;
264
+
265
+ legacyForeignKeys.push({ table: key, expected: column, legacy: legacyName });
266
+ actions.push({
267
+ kind: "rename-column",
268
+ target: `${key}.${column}`,
269
+ sql: `ALTER TABLE "${schema}"."${table}" RENAME COLUMN "${legacyName}" TO "${column}";`
270
+ });
271
+ return true;
272
+ };
273
+
221
274
  const addColumn = (
222
275
  key: string,
223
276
  schema: string,
@@ -261,14 +314,17 @@ export function planCollectionSchemaEnsure(
261
314
  const key = `${junction.schema}.${junction.table}`;
262
315
  if (created.has(key)) continue;
263
316
  for (const column of junction.columns) {
317
+ if (renameLegacyColumn(key, junction.schema, junction.table, column.name, column.legacyName)) continue;
264
318
  addColumn(key, junction.schema, junction.table, column.name, column.type);
265
319
  }
266
320
  }
267
321
 
268
322
  // 3c. The columns relation and reference properties own.
269
323
  for (const relational of planRelationalColumns(collections)) {
324
+ const relKey = `${relational.schema}.${relational.table}`;
325
+ if (renameLegacyColumn(relKey, relational.schema, relational.table, relational.column, relational.legacyColumn)) continue;
270
326
  addColumn(
271
- `${relational.schema}.${relational.table}`,
327
+ relKey,
272
328
  relational.schema,
273
329
  relational.table,
274
330
  relational.column,
@@ -297,7 +353,7 @@ export function planCollectionSchemaEnsure(
297
353
  });
298
354
  }
299
355
 
300
- return { actions, statements: actions.map(a => a.sql) };
356
+ return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
301
357
  }
302
358
 
303
359
  /** Read what the database has, for the schemas the collections live in. */
@@ -386,6 +442,21 @@ export async function ensureCollectionTables(
386
442
  const plan = planCollectionSchemaEnsure(collections, existing);
387
443
  const failures: { target: string; error: string }[] = [];
388
444
 
445
+ // Reported, not warned: this is a rename the ensure is about to perform, and
446
+ // the operator should be able to see in the log why a column changed name.
447
+ // The interesting case is the one that no longer happens — before this,
448
+ // ensure added the new column empty beside the populated old one, every
449
+ // statement succeeded, and the relation read the empty one.
450
+ for (const legacy of plan.legacyForeignKeys) {
451
+ const message =
452
+ `Renaming "${legacy.table}"."${legacy.legacy}" to "${legacy.expected}". The old name is ` +
453
+ "the one Rebase derived for this relation before it singularized properly; the column " +
454
+ "keeps its data, indexes and constraints. To keep the old name instead, set " +
455
+ `\`localKey: "${legacy.legacy}"\` on the relation and this will stop.`;
456
+ logger.info(`[schema] ${message}`);
457
+ log?.(message);
458
+ }
459
+
389
460
  if (plan.actions.length === 0) {
390
461
  log?.("Schema is up to date; nothing to create.");
391
462
  return { ...plan, failures };
@@ -137,6 +137,25 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
137
137
  let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
138
138
  if (numProp.columnType) {
139
139
  if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
140
+ // `bigint` and `bigserial` are the only pg-core builders that
141
+ // *require* a config argument: without `mode`, drizzle cannot
142
+ // know whether to hand back a `number` or a `bigint`, and the
143
+ // emitted call does not typecheck.
144
+ //
145
+ // This is why `schema.generated.ts` drifted. Regenerating it
146
+ // produced a file that would not compile, so the bigint lines
147
+ // were hand-patched — and every regeneration after that looked
148
+ // like a large, alarming diff nobody wanted to ship. The file
149
+ // then sat stale for long enough that a security fix to two RLS
150
+ // policies never reached production.
151
+ //
152
+ // `number` rather than `bigint`: these are counters and byte
153
+ // totals that every caller already treats as numbers, and
154
+ // switching the runtime type would be a breaking change to
155
+ // every consumer of the generated schema.
156
+ else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") {
157
+ baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
158
+ }
140
159
  else baseType = `${numProp.columnType}("${colName}")`;
141
160
  }
142
161
 
@@ -551,8 +570,15 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
551
570
  const tableVarName = getTableVarName(tableName);
552
571
  if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
553
572
  const targetCollection = relation.target();
554
- const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : undefined) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : undefined);
555
- const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
573
+ // Junctions live in `public`, full stop — `resolveJunctionSpecs`
574
+ // hardcodes that, so it is where `planJunctionTables` CREATEs them
575
+ // and where the derived RLS policies are applied. Inheriting the
576
+ // endpoint's schema here (as this used to) put the junction of any
577
+ // m2m onto `users` in `rebase`, while its policies were still
578
+ // created against `public.<junction>` — RLS enabled on one table,
579
+ // rows written to another. The three generators have to agree, and
580
+ // the other two already did.
581
+ const tableCreator = "pgTable";
556
582
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
557
583
  const {
558
584
  sourceColumn,
@@ -778,8 +804,14 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
778
804
  // name (e.g. "client_id") may differ from the property key
779
805
  // (e.g. "clientId") when `columnName` is set.
780
806
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
807
+ // The column the far side points at: its
808
+ // primary key, unless the link names another
809
+ // one with `sourceKey`.
810
+ const referencedKey = otherRel.sourceKey
811
+ ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey)
812
+ : getPrimaryKeyName(otherCollection);
781
813
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
782
- tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
814
+ tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
783
815
  emittedRelationNames.add(deduplicationKey);
784
816
  }
785
817
  }
@@ -1,6 +1,6 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
2
2
  import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
3
- import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
3
+ import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
4
4
 
5
5
  // --- Helper Functions ---
6
6
 
@@ -116,6 +116,16 @@ const generateSinglePolicyStatements = (collection: CollectionConfig, rule: Secu
116
116
  return [drop, create];
117
117
  };
118
118
 
119
+ /**
120
+ * Single-quote escaping for a SQL string literal (PostgreSQL doubles the
121
+ * quote). Enum labels come straight from user-authored collection config, so a
122
+ * label like `it's` closes the literal early and the whole generated file stops
123
+ * parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
124
+ * because ensure-collection-tables already imports from this module — the
125
+ * reverse would be a cycle.
126
+ */
127
+ export const quoteSqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
128
+
119
129
  export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
120
130
  switch (prop.type) {
121
131
  case "string": {
@@ -242,6 +252,13 @@ export const generatePostgresDdl = async (
242
252
  if (uniqueSchemas.length > 0) ddl += "\n";
243
253
 
244
254
  // 2. Generate Enums
255
+ //
256
+ // The enum type name is derived from table + column, so two collections
257
+ // mapped onto the same table — or two properties whose `columnName`
258
+ // resolves to the same column — land on the same name. `CREATE TYPE` has no
259
+ // IF NOT EXISTS, so emitting it twice aborts the whole file on the second
260
+ // statement. Dedupe by name, matching planCollectionSchemaEnsure.
261
+ const emittedEnums = new Set<string>();
245
262
  collections.forEach(collection => {
246
263
  const collectionTable = getTableName(collection);
247
264
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
@@ -253,8 +270,9 @@ export const generatePostgresDdl = async (
253
270
  String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
254
271
  )
255
272
  : Object.keys(prop.enum);
256
- if (values.length > 0) {
257
- ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
273
+ if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
274
+ emittedEnums.add(`${schema}.${enumDbName}`);
275
+ ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
258
276
  }
259
277
  }
260
278
  });
@@ -530,6 +548,15 @@ export interface RelationalColumnPlan {
530
548
  type: string;
531
549
  /** Absent when the target collection is not part of this bundle. */
532
550
  foreignKey?: ForeignKeyPlan;
551
+ /**
552
+ * What this column would have been called before `generateForeignKeyName`
553
+ * learned to singularize — set only when the two differ and the column name
554
+ * is the derived default rather than one the author wrote.
555
+ *
556
+ * Carried so the boot-time ensure can notice a database provisioned under
557
+ * the old rule. It is never used to name anything.
558
+ */
559
+ legacyColumn?: string;
533
560
  }
534
561
 
535
562
  /** The table behind a many-to-many `through` relation. */
@@ -537,7 +564,7 @@ export interface JunctionTablePlan {
537
564
  schema: string;
538
565
  /** Bare table name, no schema prefix. */
539
566
  table: string;
540
- columns: { name: string; type: string }[];
567
+ columns: { name: string; type: string; legacyName?: string }[];
541
568
  /** Both endpoint columns plus the composite primary key. */
542
569
  createTable: string;
543
570
  foreignKeys: ForeignKeyPlan[];
@@ -613,10 +640,16 @@ export const planRelationalColumns = (collections: CollectionConfig[]): Relation
613
640
  if (!targetCollection) continue;
614
641
 
615
642
  const required = prop.validation?.required;
643
+ // Only a *derived* column name can be affected by the change to
644
+ // `generateForeignKeyName`; one the author wrote is theirs.
645
+ const relationName = refProp.relation?.relationName ?? propName;
646
+ const legacyKey = legacyForeignKeyName(relationName);
647
+ const derived = relInfo.localKey === generateForeignKeyName(relationName);
616
648
  plans.push({
617
649
  schema,
618
650
  table,
619
651
  column: relInfo.localKey,
652
+ legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : undefined,
620
653
  type: getSqlColumnType(propName, prop, collection, collections),
621
654
  foreignKey: foreignKeyPlan({
622
655
  schema,
@@ -675,9 +708,20 @@ export const planJunctionTables = (collections: CollectionConfig[]): JunctionTab
675
708
 
676
709
  for (const spec of resolveJunctionSpecs(collections).values()) {
677
710
  const [source, target] = spec.endpoints;
711
+ // A junction column's default name is derived from the endpoint
712
+ // collection's slug — which is normally plural, so this is where the
713
+ // singularization change actually lands: a `categories` endpoint used
714
+ // to give `categorie_id` and now gives `category_id`. Recorded, not
715
+ // used, so boot-ensure can recognise a table built under the old rule.
716
+ const legacyFor = (endpoint: typeof source): string | undefined => {
717
+ const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
718
+ const legacy = legacyForeignKeyName(slug);
719
+ const derived = endpoint.junctionColumn === generateForeignKeyName(slug);
720
+ return derived && legacy !== endpoint.junctionColumn ? legacy : undefined;
721
+ };
678
722
  const columns = [
679
- { name: source.junctionColumn, type: junctionKeyType(source.collection) },
680
- { name: target.junctionColumn, type: junctionKeyType(target.collection) }
723
+ { name: source.junctionColumn, type: junctionKeyType(source.collection), legacyName: legacyFor(source) },
724
+ { name: target.junctionColumn, type: junctionKeyType(target.collection), legacyName: legacyFor(target) }
681
725
  ];
682
726
  // Every declaring side agrees on the edge's lifetime; the first one wins,
683
727
  // as it does in the generator's walk.