@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g06f263c

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 (53) hide show
  1. package/dist/PostgresBootstrapper.d.ts +25 -1
  2. package/dist/auth/services.d.ts +16 -0
  3. package/dist/backup-service-vAKWJkYL.js +8867 -0
  4. package/dist/backup-service-vAKWJkYL.js.map +1 -0
  5. package/dist/cli-helpers.d.ts +24 -0
  6. package/dist/connection-BuZ97wsr.js +250 -0
  7. package/dist/connection-BuZ97wsr.js.map +1 -0
  8. package/dist/connection.d.ts +42 -0
  9. package/dist/ensure-collection-policies-BjSwj0FM.js +57 -0
  10. package/dist/ensure-collection-policies-BjSwj0FM.js.map +1 -0
  11. package/dist/ensure-collection-tables-D6-XhqnQ.js +590 -0
  12. package/dist/ensure-collection-tables-D6-XhqnQ.js.map +1 -0
  13. package/dist/index.es.js +545 -9629
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/policy-CeA1JcxP.js +105 -0
  16. package/dist/policy-CeA1JcxP.js.map +1 -0
  17. package/dist/schema/auth-schema.d.ts +83 -144
  18. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  19. package/dist/schema/ensure-collection-tables.d.ts +24 -2
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +116 -1
  21. package/dist/{src-BbFOPJ1S.js → src-C_NHNVW2.js} +94 -153
  22. package/dist/src-C_NHNVW2.js.map +1 -0
  23. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  24. package/dist/src-DoU9yPqq.js.map +1 -0
  25. package/dist/utils/pg-error-utils.d.ts +19 -0
  26. package/dist/websocket-DB7TbFPT.js +529 -0
  27. package/dist/websocket-DB7TbFPT.js.map +1 -0
  28. package/package.json +14 -14
  29. package/src/PostgresAdapter.ts +14 -0
  30. package/src/PostgresBootstrapper.ts +192 -33
  31. package/src/auth/ensure-tables.ts +164 -9
  32. package/src/auth/services.ts +21 -2
  33. package/src/cli-helpers.ts +44 -0
  34. package/src/cli.ts +31 -3
  35. package/src/connection.ts +73 -0
  36. package/src/databasePoolManager.ts +5 -2
  37. package/src/schema/auth-schema.ts +30 -19
  38. package/src/schema/ensure-collection-policies.ts +105 -0
  39. package/src/schema/ensure-collection-tables.test.ts +105 -9
  40. package/src/schema/ensure-collection-tables.ts +142 -25
  41. package/src/schema/generate-drizzle-schema-logic.ts +16 -5
  42. package/src/schema/generate-postgres-ddl-logic.ts +335 -16
  43. package/src/schema/introspect-runtime.test.ts +56 -8
  44. package/src/schema/introspect-runtime.ts +31 -9
  45. package/src/services/RelationService.ts +38 -3
  46. package/src/services/realtimeService.ts +3 -3
  47. package/src/utils/pg-error-utils.ts +46 -0
  48. package/src/websocket.ts +3 -3
  49. package/dist/chunk-DSJWtz9O.js +0 -40
  50. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  51. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  52. package/dist/src-BbFOPJ1S.js.map +0 -1
  53. package/dist/src-Zqwaw3P5.js.map +0 -1
@@ -1,5 +1,5 @@
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
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
3
3
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
4
4
 
5
5
  // --- Helper Functions ---
@@ -11,7 +11,7 @@ export const resolveColumnName = (propName: string, prop?: Property | null): str
11
11
  return toSnakeCase(propName);
12
12
  };
13
13
 
14
- const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
14
+ export const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
15
15
  if (collection.properties) {
16
16
  const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
17
17
  if (idPropEntry) {
@@ -28,14 +28,18 @@ const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type:
28
28
  return { name: "id", type: "string", isUuid: isUuid ?? false };
29
29
  };
30
30
 
31
- const isNumericId = (collection: CollectionConfig): boolean => {
31
+ export const isNumericId = (collection: CollectionConfig): boolean => {
32
32
  return getPrimaryKeyProp(collection).type === "number";
33
33
  };
34
34
 
35
- const getPrimaryKeyName = (collection: CollectionConfig): string => {
35
+ export const getPrimaryKeyName = (collection: CollectionConfig): string => {
36
36
  return getPrimaryKeyProp(collection).name;
37
37
  };
38
38
 
39
+ /** The column type a junction holds for one endpoint's primary key. */
40
+ const junctionKeyType = (collection: CollectionConfig): string =>
41
+ isNumericId(collection) ? "INTEGER" : (getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT");
42
+
39
43
  export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
40
44
  if ("isId" in prop && Boolean(prop.isId)) return true;
41
45
  const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
@@ -45,7 +49,28 @@ export const isIdProperty = (propName: string, prop: Property, collection: Colle
45
49
 
46
50
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
47
51
 
48
- const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string => {
52
+ /**
53
+ * Render statements produced by {@link generatePolicyStatements} back into the
54
+ * exact string the DDL/policies files have always carried: each statement on
55
+ * its own line, terminated by a newline. Keeping the string form derived from
56
+ * the statement array means the two can never drift — the boot-time applier and
57
+ * the generated `policies.sql` emit the same SQL, from the same source.
58
+ */
59
+ const statementsToDdl = (statements: string[]): string => statements.map(s => `${s}\n`).join("");
60
+
61
+ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string =>
62
+ statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
63
+
64
+ /**
65
+ * The individual SQL statements a single security rule compiles to: a
66
+ * `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
67
+ * statement (terminated by `;`, no trailing newline).
68
+ *
69
+ * This is the primitive the boot-time RLS applier runs one statement at a time
70
+ * (the runtime's DB handle speaks the extended query protocol, which forbids
71
+ * multiple commands in one execute), while `db push` writes the joined string.
72
+ */
73
+ export const generatePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string[] => {
49
74
  const tableName = getTableName(collection);
50
75
  const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
51
76
  ? rule.operations
@@ -53,12 +78,12 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
53
78
 
54
79
  const policyNames = getPolicyNamesForRule(rule, tableName);
55
80
 
56
- return ops.map((op, opIdx) => {
57
- return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
58
- }).join("");
81
+ return ops.flatMap((op, opIdx) => {
82
+ return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
83
+ });
59
84
  };
60
85
 
61
- const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
86
+ const generateSinglePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string[] => {
62
87
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
63
88
  const tableName = getTableName(collection);
64
89
  const mode = (rule.mode ?? "permissive").toUpperCase();
@@ -83,11 +108,12 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
83
108
  withCheckClause = "false";
84
109
  }
85
110
 
86
- let ddl = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";\n`;
87
- ddl += `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
88
- if (usingClause) ddl += ` USING (${usingClause})`;
89
- if (withCheckClause) ddl += ` WITH CHECK (${withCheckClause})`;
90
- return `${ddl};\n`;
111
+ const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
112
+ let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
113
+ if (usingClause) create += ` USING (${usingClause})`;
114
+ if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
115
+ create += ";";
116
+ return [drop, create];
91
117
  };
92
118
 
93
119
  export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
@@ -103,11 +129,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
103
129
  if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
104
130
  return "UUID";
105
131
  }
132
+ // Width comes from `validation.max` when the property states one.
133
+ // It used to be a hardcoded 255 here and *absent* on the Drizzle
134
+ // path, so the same property produced a bounded column down one
135
+ // generator and an unbounded one down the other.
106
136
  if (stringProp.columnType === "char") {
107
- return "CHAR(255)";
137
+ return `CHAR(${resolveStringColumnLength(stringProp)})`;
108
138
  }
109
139
  if (stringProp.columnType === "varchar") {
110
- return "VARCHAR(255)";
140
+ return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
111
141
  }
112
142
  // `text` is the default. The two generators disagreed here before:
113
143
  // this one emitted VARCHAR(255) while the drizzle path emitted a bare
@@ -470,6 +500,295 @@ export const generatePostgresDdl = async (
470
500
  return ddl;
471
501
  };
472
502
 
503
+ /** The RLS statements one declared collection's table needs, ready to run. */
504
+ /**
505
+ * A foreign key, as both its parts and the statement that creates it.
506
+ *
507
+ * `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
508
+ * these has to skip by name — hence the name is a field and not only a substring
509
+ * of the SQL.
510
+ */
511
+ export interface ForeignKeyPlan {
512
+ constraintName: string;
513
+ schema: string;
514
+ /** Bare table name, no schema prefix. */
515
+ table: string;
516
+ column: string;
517
+ targetSchema: string;
518
+ targetTable: string;
519
+ targetColumn: string;
520
+ sql: string;
521
+ }
522
+
523
+ /** A column a `relation` or `reference` property owns on its own table. */
524
+ export interface RelationalColumnPlan {
525
+ schema: string;
526
+ /** Bare table name, no schema prefix. */
527
+ table: string;
528
+ column: string;
529
+ /** Postgres type, exactly as the DDL generator declares it. */
530
+ type: string;
531
+ /** Absent when the target collection is not part of this bundle. */
532
+ foreignKey?: ForeignKeyPlan;
533
+ }
534
+
535
+ /** The table behind a many-to-many `through` relation. */
536
+ export interface JunctionTablePlan {
537
+ schema: string;
538
+ /** Bare table name, no schema prefix. */
539
+ table: string;
540
+ columns: { name: string; type: string }[];
541
+ /** Both endpoint columns plus the composite primary key. */
542
+ createTable: string;
543
+ foreignKeys: ForeignKeyPlan[];
544
+ }
545
+
546
+ const schemaOfCollection = (collection: CollectionConfig): string =>
547
+ isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
548
+
549
+ const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
550
+
551
+ const foreignKeyPlan = (
552
+ args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
553
+ ): ForeignKeyPlan => {
554
+ const constraintName = `${args.table}_${args.column}_fkey`;
555
+ const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
556
+ return {
557
+ constraintName,
558
+ schema: args.schema,
559
+ table: args.table,
560
+ column: args.column,
561
+ targetSchema: args.targetSchema,
562
+ targetTable: args.targetTable,
563
+ targetColumn: args.targetColumn,
564
+ sql:
565
+ `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
566
+ `FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
567
+ `("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
568
+ };
569
+ };
570
+
571
+ /**
572
+ * The FK columns the declared collections own — one entry per `relation`
573
+ * (`belongsTo` side) or `reference` property.
574
+ *
575
+ * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
576
+ * create the same columns with the same names, types and constraints. Before
577
+ * this it skipped them outright, which was survivable only because `db push`
578
+ * always followed; on a managed tenant nothing follows, so a table arrived
579
+ * without the column its own collection reads and wrote 400 on every insert.
580
+ *
581
+ * A relation whose target is not in the bundle yields no column at all (the
582
+ * generator returns early on an unresolvable target); a `reference` whose target
583
+ * is unknown yields the column without a constraint. Both mirror the generator
584
+ * exactly — a divergence here is a schema fork between boot and `db push`.
585
+ */
586
+ export const planRelationalColumns = (collections: CollectionConfig[]): RelationalColumnPlan[] => {
587
+ const plans: RelationalColumnPlan[] = [];
588
+
589
+ for (const collection of collections) {
590
+ const tableName = getTableName(collection);
591
+ if (!tableName) continue;
592
+ const schema = schemaOfCollection(collection);
593
+ const table = bareTableName(tableName);
594
+
595
+ for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
596
+ const prop = rawProp as Property;
597
+
598
+ if (prop.type === "relation") {
599
+ const refProp = prop as RelationProperty;
600
+ const resolvedRelations = resolveCollectionRelations(collection);
601
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
602
+ if (relInfo?.kind !== "belongsTo") continue;
603
+ // The relation and an explicit FK property can both be declared;
604
+ // the explicit one owns the column.
605
+ if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
606
+
607
+ let targetCollection: CollectionConfig;
608
+ try {
609
+ targetCollection = relInfo.target();
610
+ } catch {
611
+ continue;
612
+ }
613
+ if (!targetCollection) continue;
614
+
615
+ const required = prop.validation?.required;
616
+ plans.push({
617
+ schema,
618
+ table,
619
+ column: relInfo.localKey,
620
+ type: getSqlColumnType(propName, prop, collection, collections),
621
+ foreignKey: foreignKeyPlan({
622
+ schema,
623
+ table,
624
+ column: relInfo.localKey,
625
+ targetSchema: schemaOfCollection(targetCollection),
626
+ targetTable: bareTableName(getTableName(targetCollection)),
627
+ targetColumn: getPrimaryKeyName(targetCollection),
628
+ onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
629
+ onUpdate: relInfo.onUpdate
630
+ })
631
+ });
632
+ } else if (prop.type === "reference") {
633
+ const refProp = prop as ReferenceProperty;
634
+ const targetCollection = collections.find(
635
+ c => c.slug === refProp.path || getTableName(c) === refProp.path
636
+ );
637
+ const column = resolveColumnName(propName, prop);
638
+ const type = getSqlColumnType(propName, prop, collection, collections);
639
+ const required = prop.validation?.required;
640
+
641
+ plans.push({
642
+ schema,
643
+ table,
644
+ column,
645
+ type,
646
+ foreignKey: targetCollection
647
+ ? foreignKeyPlan({
648
+ schema,
649
+ table,
650
+ column,
651
+ targetSchema: schemaOfCollection(targetCollection),
652
+ targetTable: bareTableName(getTableName(targetCollection)),
653
+ targetColumn: getPrimaryKeyName(targetCollection),
654
+ onDelete: required ? "CASCADE" : "SET NULL"
655
+ })
656
+ : undefined
657
+ });
658
+ }
659
+ }
660
+ }
661
+
662
+ return plans;
663
+ };
664
+
665
+ /**
666
+ * The junction tables a bundle's many-to-many relations imply.
667
+ *
668
+ * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
669
+ * comes from, so a table created here always has policies planned for it — a
670
+ * junction with row-level security left off is readable and writable by every
671
+ * signed-in user, which is why the two must ship together.
672
+ */
673
+ export const planJunctionTables = (collections: CollectionConfig[]): JunctionTablePlan[] => {
674
+ const plans: JunctionTablePlan[] = [];
675
+
676
+ for (const spec of resolveJunctionSpecs(collections).values()) {
677
+ const [source, target] = spec.endpoints;
678
+ const columns = [
679
+ { name: source.junctionColumn, type: junctionKeyType(source.collection) },
680
+ { name: target.junctionColumn, type: junctionKeyType(target.collection) }
681
+ ];
682
+ // Every declaring side agrees on the edge's lifetime; the first one wins,
683
+ // as it does in the generator's walk.
684
+ const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
685
+
686
+ plans.push({
687
+ schema: spec.schema,
688
+ table: spec.table,
689
+ columns,
690
+ createTable:
691
+ `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
692
+ columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
693
+ `, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
694
+ foreignKeys: [source, target].map((endpoint, i) =>
695
+ foreignKeyPlan({
696
+ schema: spec.schema,
697
+ table: spec.table,
698
+ column: columns[i].name,
699
+ targetSchema: schemaOfCollection(endpoint.collection),
700
+ targetTable: bareTableName(getTableName(endpoint.collection)),
701
+ targetColumn: getPrimaryKeyName(endpoint.collection),
702
+ onDelete
703
+ })
704
+ )
705
+ });
706
+ }
707
+
708
+ return plans;
709
+ };
710
+
711
+ export interface CollectionPolicyPlan {
712
+ /** The table's schema (e.g. `public`, `rebase`). */
713
+ schema: string;
714
+ /** The bare table name, no schema prefix. */
715
+ table: string;
716
+ /** `schema.table` — matches the keys `readExistingSchema` returns. */
717
+ qualified: string;
718
+ /** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
719
+ enableRls: string;
720
+ /** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
721
+ policyStatements: string[];
722
+ }
723
+
724
+ /**
725
+ * The per-table RLS plan for the *declared* collections, as executable
726
+ * statements — what the managed runtime applies at boot so a freshly
727
+ * provisioned tenant database serves data instead of 401ing every read.
728
+ *
729
+ * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
730
+ * `generatePolicyStatements`, same enable-RLS, same effective rules, same
731
+ * derived junction rules), so boot and `db push` produce identical policies from
732
+ * identical collections.
733
+ *
734
+ * Junction tables are included, and have to be: boot creates them now
735
+ * ({@link planJunctionTables}), and a junction with RLS left off is readable and
736
+ * writable by every signed-in user. A junction whose table is still absent is
737
+ * skipped by the applier, not planned away here.
738
+ */
739
+ export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
740
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
741
+ const plans: CollectionPolicyPlan[] = [];
742
+ const seen = new Set<string>();
743
+
744
+ for (const collection of collections) {
745
+ const tableName = getTableName(collection);
746
+ if (!tableName) continue;
747
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
748
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
749
+ const qualified = `${schema}.${baseTableName}`;
750
+ if (seen.has(qualified)) continue;
751
+ seen.add(qualified);
752
+
753
+ const policyStatements: string[] = [];
754
+ for (const rule of getEffectiveSecurityRules(collection)) {
755
+ policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
756
+ }
757
+
758
+ plans.push({
759
+ schema,
760
+ table: baseTableName,
761
+ qualified,
762
+ enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
763
+ policyStatements
764
+ });
765
+ }
766
+
767
+ // Junctions are derived from `through` relations rather than declared, so
768
+ // the walk above never sees them.
769
+ for (const spec of resolveJunctionSpecs(collections).values()) {
770
+ const qualified = `${spec.schema}.${spec.table}`;
771
+ if (seen.has(qualified)) continue;
772
+ seen.add(qualified);
773
+
774
+ const junctionCollection = getJunctionCollectionConfig(spec);
775
+ const policyStatements: string[] = [];
776
+ for (const rule of getJunctionSecurityRules(spec)) {
777
+ policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
778
+ }
779
+
780
+ plans.push({
781
+ schema: spec.schema,
782
+ table: spec.table,
783
+ qualified,
784
+ enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
785
+ policyStatements
786
+ });
787
+ }
788
+
789
+ return plans;
790
+ };
791
+
473
792
  export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
474
793
  let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
475
794
 
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "@jest/globals";
2
+ import { resolveCollectionRelations } from "@rebasepro/common";
3
+ import type { CollectionConfig } from "@rebasepro/types";
2
4
 
3
5
  import { buildCollectionsFromSchema, introspectSchema, readRlsStatus, IntrospectedSchema, Queryable } from "./introspect-runtime";
4
6
  import { buildTablesMap, identifyJoinTables, TableColumn, ForeignKeyRow, PrimaryKeyRow } from "./introspect-db-logic";
@@ -121,14 +123,60 @@ describe("buildCollectionsFromSchema", () => {
121
123
  const props = posts.properties as any;
122
124
 
123
125
  expect(props.author_id).toBeUndefined();
124
- expect(props.author).toEqual({
125
- name: "Author",
126
- type: "relation",
127
- target: "authors",
128
- cardinality: "one",
129
- direction: "owning",
130
- localKey: "author_id"
131
- });
126
+ expect(props.author.name).toBe("Author");
127
+ expect(props.author.type).toBe("relation");
128
+ // The descriptor is nested under `relation`, carries `kind`, and its
129
+ // `target` is a thunk — see the resolvability test below for why none of
130
+ // those three are stylistic.
131
+ expect(props.author.relation).toMatchObject({ kind: "belongsTo", localKey: "author_id" });
132
+ expect(typeof props.author.relation.target).toBe("function");
133
+ expect(props.author.relation.target().slug).toBe("authors");
134
+ });
135
+
136
+ /**
137
+ * The shape has to be one the resolver actually reads.
138
+ *
139
+ * This path emitted `cardinality` / `direction` / a bare-string `target`
140
+ * flat on the property — the shape `Relation` stopped accepting, and the
141
+ * same drift `introspect-emits-valid-relations.test.ts` caught in the
142
+ * generated-file path and this one was missed by. Nothing threw:
143
+ * `resolveCollectionRelations` reads `property.relation`, found none, and
144
+ * reported a collection with no relations at all.
145
+ *
146
+ * The visible cost was writes. `assertKnownWriteFields` learns an owning
147
+ * relation's FK column from the *resolved* relation's `localKey`, and the FK
148
+ * column is deliberately absent from `properties` ("surfaces as a
149
+ * relation"), so with nothing resolving, `POST /api/data/orders` with
150
+ * `product_id` came back 400 `has no field 'product_id'` — the column was
151
+ * simultaneously the only way to set the relation and not a known field.
152
+ *
153
+ * Asserting the literal object is what let it drift, so this asserts
154
+ * through the resolver instead: a future reshape has to keep it resolvable,
155
+ * not merely keep the keys someone once wrote down.
156
+ */
157
+ it("emits a relation the resolver can read, so the fk column is writable", () => {
158
+ const schema = schemaOf(
159
+ [
160
+ column({ table_name: "products", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
161
+ column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
162
+ column({ table_name: "orders", column_name: "product_id", data_type: "integer", udt_name: "int4" })
163
+ ],
164
+ [
165
+ { table_name: "products", column_name: "id" },
166
+ { table_name: "orders", column_name: "id" }
167
+ ],
168
+ [{ table_name: "orders", column_name: "product_id", foreign_table_name: "products", foreign_column_name: "id" }]
169
+ );
170
+
171
+ const orders = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "orders")!;
172
+ const resolved = resolveCollectionRelations(orders as unknown as CollectionConfig);
173
+
174
+ expect(Object.keys(resolved)).toEqual(["product"]);
175
+ expect(resolved.product.kind).toBe("belongsTo");
176
+ // `localKey` is the field `assertKnownWriteFields` adds to the known set,
177
+ // which is what makes `product_id` writable. The write itself is covered
178
+ // end-to-end by `scripts/smoke-baas.ts` against a real database.
179
+ expect((resolved.product as { localKey: string }).localKey).toBe("product_id");
132
180
  });
133
181
 
134
182
  it("skips join tables — they are an edge between collections, not a collection", () => {
@@ -207,13 +207,30 @@ function buildProperties(
207
207
  }
208
208
 
209
209
  /**
210
- * Owning relations, derived from this table's foreign keys. Mirrors the shape
211
- * `generateCollectionFile` emits, except `target` uses the slug string form
212
- * rather than a thunk to a module import.
210
+ * Owning relations, derived from this table's foreign keys the same shape
211
+ * `generateCollectionFile` writes into a collection file: a `relation` property
212
+ * whose nested descriptor carries `kind`, a `target` thunk and the `localKey`.
213
+ *
214
+ * The shape is load-bearing, not cosmetic. `resolveCollectionRelations` reads
215
+ * relations from `property.relation` and `resolveRelation` requires `target` to
216
+ * be a thunk; this used to emit `target`/`cardinality`/`localKey` flat on the
217
+ * property with the slug as a bare string, which satisfies neither. Nothing
218
+ * threw — the resolver simply skipped every such property and reported that the
219
+ * collection had no relations. So an introspected BaaS collection had its FK
220
+ * columns removed from `properties` (they "surface as relations") and then no
221
+ * resolvable relation to surface as, which is why writing the FK column
222
+ * directly came back as `has no field 'product_id'`: `assertKnownWriteFields`
223
+ * learns that column from the resolved relation's `localKey`.
224
+ *
225
+ * The thunk closes over the collections being built in this same pass rather
226
+ * than importing a module, which is what a runtime introspection has instead of
227
+ * generated files. It is called lazily, after the map is fully populated, so a
228
+ * table may reference one introspected later.
213
229
  */
214
230
  function buildRelations(
215
231
  meta: TableMeta,
216
- slugByTable: Map<string, string>
232
+ slugByTable: Map<string, string>,
233
+ collectionBySlug: Map<string, PostgresCollectionConfig>
217
234
  ): Record<string, Record<string, unknown>> {
218
235
  const relations: Record<string, Record<string, unknown>> = {};
219
236
 
@@ -233,10 +250,11 @@ function buildRelations(
233
250
  relations[key] = {
234
251
  name: humanize(key),
235
252
  type: "relation",
236
- target: targetSlug,
237
- cardinality: "one",
238
- direction: "owning",
239
- localKey: fk.column_name
253
+ relation: {
254
+ kind: "belongsTo",
255
+ target: () => collectionBySlug.get(targetSlug),
256
+ localKey: fk.column_name
257
+ }
240
258
  };
241
259
  }
242
260
 
@@ -259,6 +277,9 @@ export function buildCollectionsFromSchema(
259
277
  }
260
278
 
261
279
  const collections: PostgresCollectionConfig[] = [];
280
+ // Filled as we go; the relation thunks read it lazily, so a table may point
281
+ // at one that has not been built yet at the moment its relation is created.
282
+ const collectionBySlug = new Map<string, PostgresCollectionConfig>();
262
283
 
263
284
  for (const [tableName, meta] of tablesMap) {
264
285
  if (joinTables.has(tableName)) continue;
@@ -273,11 +294,12 @@ export function buildCollectionsFromSchema(
273
294
  icon: getIconForTable(tableName),
274
295
  properties: {
275
296
  ...buildProperties(meta, enumMap),
276
- ...buildRelations(meta, slugByTable)
297
+ ...buildRelations(meta, slugByTable, collectionBySlug)
277
298
  }
278
299
  } as unknown as PostgresCollectionConfig;
279
300
 
280
301
  collections.push(collection);
302
+ collectionBySlug.set(tableName, collection);
281
303
  }
282
304
 
283
305
  return collections;
@@ -18,6 +18,39 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
18
18
  import { logger } from "@rebasepro/server";
19
19
  import type { NestedPathHop } from "./nested-path";
20
20
 
21
+ /**
22
+ * The ids in a to-many relation write, whatever shape the caller sent.
23
+ *
24
+ * A membership list is written as either the related rows (`[{ id: 1 }]`, what
25
+ * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
26
+ * what anyone writing the API by hand sends). Only the first was read, via a
27
+ * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
28
+ * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
29
+ * and on a string-keyed one it did not surface at all — `String(undefined)`
30
+ * wrote a junction row pointing at the literal `"undefined"`, which no read
31
+ * would ever match. Both shapes are accepted here, in one place, because both
32
+ * call sites had the same assumption.
33
+ *
34
+ * An element that carries no key is refused rather than skipped: dropping it
35
+ * would silently write a shorter membership list than the caller asked for.
36
+ */
37
+ function relationTargetIds(value: unknown, relationName: string, collectionSlug: string): (string | number)[] {
38
+ if (!Array.isArray(value)) return [];
39
+
40
+ return value.map((element, index) => {
41
+ if (typeof element === "string" || typeof element === "number") return element;
42
+ if (element && typeof element === "object") {
43
+ const id = (element as { id?: unknown }).id;
44
+ if (typeof id === "string" || typeof id === "number") return id;
45
+ }
46
+ throw new Error(
47
+ `Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. ` +
48
+ "Pass either the related rows (`[{ id: … }]`) or their keys (`[1, 2]`), not " +
49
+ `${element === null ? "null" : typeof element}.`
50
+ );
51
+ });
52
+ }
53
+
21
54
  /**
22
55
  * Typed wrapper for Drizzle dynamic query innerJoin.
23
56
  * Drizzle's `$dynamic()` queries lose the `innerJoin` method from
@@ -840,7 +873,7 @@ export class RelationService {
840
873
  const relation = findRelation(resolvedRelations, key);
841
874
  if (!relation || relation.cardinality !== "many") continue;
842
875
 
843
- const targetEntityIds = (value && Array.isArray(value)) ? value.map((rel: { id: string | number }) => rel.id) : [];
876
+ const targetEntityIds = relationTargetIds(value, key, collection.slug);
844
877
  const targetCollection = relation.target();
845
878
 
846
879
  // Use joinPath if available
@@ -1166,7 +1199,9 @@ export class RelationService {
1166
1199
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1167
1200
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1168
1201
  const targetIdInfo = targetPks[0];
1169
- const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
1202
+ // This path already read both shapes; the other two did not.
1203
+ // Same helper now, so the three cannot drift again.
1204
+ const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
1170
1205
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1171
1206
 
1172
1207
  const newLinks = parsedTargetIds.map(targetId => ({
@@ -1238,7 +1273,7 @@ export class RelationService {
1238
1273
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1239
1274
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1240
1275
  const targetIdInfo = targetPks[0];
1241
- const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
1276
+ const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
1242
1277
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1243
1278
 
1244
1279
  const newLinks = parsedTargetIds.map(targetId => ({
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
4
4
  import { randomUUID } from "crypto";
5
5
  import { DataService } from "./dataService";
6
6
 
7
- import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
7
+ import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { sql as drizzleSql } from "drizzle-orm";
10
10
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
@@ -751,7 +751,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
751
751
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
752
752
  // Refetches are reads: apply the same GUCs + reader-role downgrade as the
753
753
  // driver's read path, so realtime cannot leak rows the initial fetch hid.
754
- const activeAuth = authContext || { uid: "anon",
754
+ const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
755
755
  roles: ["anon"] };
756
756
  return await this.db.transaction(async (tx) => {
757
757
  await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
@@ -931,7 +931,7 @@ roles: activeAuth.roles },
931
931
 
932
932
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
933
933
  // Same read isolation as collection refetches: GUCs + reader-role downgrade.
934
- const activeAuth = authContext || { uid: "anon",
934
+ const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
935
935
  roles: ["anon"] };
936
936
  return await this.db.transaction(async (tx) => {
937
937
  await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);