@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.1-canary.g009ed95

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 (98) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +33 -1
  3. package/dist/auth/services.d.ts +21 -0
  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-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/connection-BuZ97wsr.js +250 -0
  11. package/dist/connection-BuZ97wsr.js.map +1 -0
  12. package/dist/connection.d.ts +42 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
  14. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  16. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  17. package/dist/history/HistoryService.d.ts +9 -29
  18. package/dist/index.es.js +1234 -9753
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CeA1JcxP.js +105 -0
  21. package/dist/policy-CeA1JcxP.js.map +1 -0
  22. package/dist/schema/auth-schema.d.ts +83 -144
  23. package/dist/schema/dynamic-tables.d.ts +1 -1
  24. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  25. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  27. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  28. package/dist/schema/introspect-db-logic.d.ts +94 -5
  29. package/dist/schema/introspect-db-queries.d.ts +119 -0
  30. package/dist/schema/introspect-db-structure.d.ts +263 -0
  31. package/dist/schema/introspect-db-types.d.ts +11 -0
  32. package/dist/schema/introspect-runtime.d.ts +1 -1
  33. package/dist/services/FetchService.d.ts +40 -2
  34. package/dist/services/RelationService.d.ts +24 -1
  35. package/dist/services/channel-bus/index.d.ts +1 -7
  36. package/dist/services/collection-helpers.d.ts +24 -1
  37. package/dist/services/dataService.d.ts +3 -1
  38. package/dist/services/row-pipeline.d.ts +4 -2
  39. package/dist/{src-3VmUJ8Xn.js → src-CzbghKwf.js} +464 -187
  40. package/dist/src-CzbghKwf.js.map +1 -0
  41. package/dist/{src-D5xBTl32.js → src-DoU9yPqq.js} +79 -189
  42. package/dist/src-DoU9yPqq.js.map +1 -0
  43. package/dist/utils/connection-string.d.ts +29 -0
  44. package/dist/utils/drizzle-conditions.d.ts +162 -7
  45. package/dist/utils/pg-error-utils.d.ts +25 -3
  46. package/dist/websocket-B2LsrINK.js +530 -0
  47. package/dist/websocket-B2LsrINK.js.map +1 -0
  48. package/package.json +14 -14
  49. package/src/PostgresAdapter.ts +21 -2
  50. package/src/PostgresBackendDriver.ts +4 -0
  51. package/src/PostgresBootstrapper.ts +212 -36
  52. package/src/auth/ensure-tables.ts +164 -9
  53. package/src/auth/services.ts +24 -2
  54. package/src/backup/backup-cli.ts +41 -2
  55. package/src/backup/backup-service.ts +38 -5
  56. package/src/backup/pg-tools.ts +96 -3
  57. package/src/cli-helpers.ts +70 -0
  58. package/src/cli.ts +44 -26
  59. package/src/collections/buildRegistry.ts +1 -1
  60. package/src/collections/validate-relations.ts +15 -0
  61. package/src/connection.ts +73 -0
  62. package/src/data-transformer.ts +9 -3
  63. package/src/databasePoolManager.ts +5 -2
  64. package/src/history/HistoryService.ts +13 -31
  65. package/src/schema/auth-schema.ts +30 -19
  66. package/src/schema/dynamic-tables.ts +1 -1
  67. package/src/schema/ensure-collection-policies.ts +105 -0
  68. package/src/schema/ensure-collection-tables.test.ts +105 -9
  69. package/src/schema/ensure-collection-tables.ts +220 -32
  70. package/src/schema/generate-drizzle-schema-logic.ts +33 -8
  71. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  72. package/src/schema/introspect-db-constraints.ts +385 -0
  73. package/src/schema/introspect-db-inference.ts +18 -8
  74. package/src/schema/introspect-db-logic.ts +385 -71
  75. package/src/schema/introspect-db-queries.ts +326 -0
  76. package/src/schema/introspect-db-structure.ts +670 -0
  77. package/src/schema/introspect-db-types.ts +56 -0
  78. package/src/schema/introspect-db.ts +37 -80
  79. package/src/schema/introspect-runtime.test.ts +56 -8
  80. package/src/schema/introspect-runtime.ts +32 -10
  81. package/src/security/policy-drift.test.ts +11 -3
  82. package/src/services/FetchService.ts +148 -18
  83. package/src/services/PersistService.ts +20 -6
  84. package/src/services/RelationService.ts +249 -48
  85. package/src/services/channel-bus/index.ts +0 -9
  86. package/src/services/collection-helpers.ts +40 -1
  87. package/src/services/dataService.ts +3 -1
  88. package/src/services/realtimeService.ts +3 -3
  89. package/src/services/row-pipeline.ts +4 -2
  90. package/src/utils/connection-string.ts +58 -0
  91. package/src/utils/drizzle-conditions.ts +539 -50
  92. package/src/utils/pg-error-utils.ts +98 -3
  93. package/src/websocket.ts +18 -9
  94. package/dist/chunk-DSJWtz9O.js +0 -40
  95. package/dist/ensure-collection-tables-DGMYK0fr.js +0 -304
  96. package/dist/ensure-collection-tables-DGMYK0fr.js.map +0 -1
  97. package/dist/src-3VmUJ8Xn.js.map +0 -1
  98. package/dist/src-D5xBTl32.js.map +0 -1
@@ -2,7 +2,7 @@ import { Pool } from "pg";
2
2
  import { drizzle } from "drizzle-orm/node-postgres";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { logger } from "@rebasepro/server";
5
- import { guardPoolAgainstDirtyRelease } from "./connection";
5
+ import { guardPoolAgainstDirtyRelease, pinSearchPath } from "./connection";
6
6
 
7
7
  export class DatabasePoolManager {
8
8
  private pools: Map<string, Pool> = new Map();
@@ -41,7 +41,10 @@ export class DatabasePoolManager {
41
41
  url.pathname = `/${databaseName}`;
42
42
 
43
43
  const pool = new Pool({
44
- connectionString: url.toString(),
44
+ // Same pin as the primary pool: these are branch/multi-database
45
+ // connections to the *same* server, so they inherit the same
46
+ // `"$user"` hazard. See `pinSearchPath`.
47
+ connectionString: pinSearchPath(url.toString()),
45
48
  max: 10, // Default sensible limit, can be tuned later
46
49
  idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
47
50
  allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
@@ -1,39 +1,21 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
+ import type { EntityHistoryEntry } from "@rebasepro/types";
4
5
 
5
- export interface HistoryEntry {
6
- id: string;
7
- table_name: string;
8
- entity_id: string;
9
- action: "create" | "update" | "delete";
10
- changed_fields: string[] | null;
11
- values: Record<string, unknown> | null;
12
- previous_values: Record<string, unknown> | null;
13
- updated_by: string | null;
14
- updated_at: string;
15
- }
16
-
17
- export interface RecordHistoryParams {
18
- tableName: string;
19
- id: string;
20
- action: "create" | "update" | "delete";
21
- values?: Record<string, unknown> | null;
22
- previousValues?: Record<string, unknown> | null;
23
- updatedBy?: string | null;
24
- }
25
-
26
- export interface FetchHistoryOptions {
27
- limit?: number;
28
- offset?: number;
29
- }
6
+ export type {
7
+ RecordHistoryParams,
8
+ FetchHistoryOptions,
9
+ HistoryRetentionConfig
10
+ } from "@rebasepro/types";
11
+ import type { RecordHistoryParams, FetchHistoryOptions, HistoryRetentionConfig } from "@rebasepro/types";
30
12
 
31
- export interface HistoryRetentionConfig {
32
- /** Max entries per row. Oldest pruned first. Default 200. */
33
- maxEntries: number;
34
- /** Entries older than this many days are pruned. Default 90. */
35
- ttlDays: number;
36
- }
13
+ /**
14
+ * A Postgres history row is already the wire shape — `updated_at` comes back
15
+ * from the driver as a string. Kept as an alias because the name is used
16
+ * throughout this package and in `PostgresBackendDriver`.
17
+ */
18
+ export type HistoryEntry = EntityHistoryEntry;
37
19
 
38
20
  const DEFAULT_RETENTION: HistoryRetentionConfig = {
39
21
  maxEntries: 200,
@@ -1,8 +1,19 @@
1
- import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
5
5
  * Factory function to dynamically create the auth tables bound to the specified schema names.
6
+ *
7
+ * This module builds queries; it does not create tables. `ensureAuthTablesExist`
8
+ * owns the DDL, which makes everything here a *claim* about a database it cannot
9
+ * enforce — and the claims drifted. Every column below was declared
10
+ * `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
11
+ * `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
12
+ * `token_hash` as varchar(255). None of it was true of any database this
13
+ * framework ever provisioned. Harmless at runtime — drizzle does not enforce a
14
+ * length client-side, so the widths only ever misled the next reader — but a
15
+ * schema module that describes columns that do not exist is worse than no
16
+ * schema module. They are `text` here now because they are TEXT there.
6
17
  */
7
18
  export function createAuthSchema(usersSchemaName = "rebase") {
8
19
  const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
15
26
  */
16
27
  const users = usersTableCreator("users", {
17
28
  id: uuid("id").defaultRandom().primaryKey(),
18
- email: varchar("email", { length: 255 }).notNull().unique(),
19
- passwordHash: varchar("password_hash", { length: 255 }), // NULL for OAuth-only users
20
- displayName: varchar("display_name", { length: 255 }),
21
- photoUrl: varchar("photo_url", { length: 500 }),
29
+ email: text("email").notNull().unique(),
30
+ passwordHash: text("password_hash"), // NULL for OAuth-only users
31
+ displayName: text("display_name"),
32
+ photoUrl: text("photo_url"),
22
33
  emailVerified: boolean("email_verified").default(false).notNull(),
23
- emailVerificationToken: varchar("email_verification_token", { length: 255 }),
34
+ emailVerificationToken: text("email_verification_token"),
24
35
  emailVerificationSentAt: timestamp("email_verification_sent_at"),
25
36
  isAnonymous: boolean("is_anonymous").default(false).notNull(),
26
37
  roles: text("roles").array().default([]).notNull(),
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
65
76
  id: uuid("id").defaultRandom().primaryKey(),
66
77
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
67
78
  sessionId: uuid("session_id").defaultRandom().notNull(),
68
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
79
+ tokenHash: text("token_hash").notNull().unique(),
69
80
  expiresAt: timestamp("expires_at").notNull(),
70
81
  revoked: boolean("revoked").default(false).notNull(),
71
82
  rotatedAt: timestamp("rotated_at"),
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
76
87
  * that rotates immediately after it.
77
88
  */
78
89
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
79
- userAgent: varchar("user_agent", { length: 500 }),
80
- ipAddress: varchar("ip_address", { length: 45 }),
90
+ userAgent: text("user_agent"),
91
+ ipAddress: text("ip_address"),
81
92
  createdAt: timestamp("created_at").defaultNow().notNull()
82
93
  }, (table) => ({
83
94
  sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
89
100
  const passwordResetTokens = tableCreator("password_reset_tokens", {
90
101
  id: uuid("id").defaultRandom().primaryKey(),
91
102
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
92
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
103
+ tokenHash: text("token_hash").notNull().unique(),
93
104
  expiresAt: timestamp("expires_at").notNull(),
94
105
  usedAt: timestamp("used_at"),
95
106
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
99
110
  * App config - key/value store for custom settings
100
111
  */
101
112
  const appConfig = tableCreator("app_config", {
102
- key: varchar("key", { length: 100 }).primaryKey(),
113
+ key: text("key").primaryKey(),
103
114
  value: jsonb("value").notNull(),
104
115
  updatedAt: timestamp("updated_at").defaultNow().notNull()
105
116
  });
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
110
121
  const userIdentities = tableCreator("user_identities", {
111
122
  id: uuid("id").defaultRandom().primaryKey(),
112
123
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
113
- provider: varchar("provider", { length: 50 }).notNull(), // e.g. 'google', 'linkedin'
114
- providerId: varchar("provider_id", { length: 255 }).notNull(),
124
+ provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
125
+ providerId: text("provider_id").notNull(),
115
126
  profileData: jsonb("profile_data"),
116
127
  createdAt: timestamp("created_at").defaultNow().notNull(),
117
128
  updatedAt: timestamp("updated_at").defaultNow().notNull()
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
125
136
  const mfaFactors = tableCreator("mfa_factors", {
126
137
  id: uuid("id").defaultRandom().primaryKey(),
127
138
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
128
- factorType: varchar("factor_type", { length: 20 }).notNull(), // 'totp'
129
- secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
130
- friendlyName: varchar("friendly_name", { length: 255 }),
139
+ factorType: text("factor_type").notNull(), // 'totp'
140
+ secretEncrypted: text("secret_encrypted").notNull(),
141
+ friendlyName: text("friendly_name"),
131
142
  verified: boolean("verified").default(false).notNull(),
132
143
  createdAt: timestamp("created_at").defaultNow().notNull(),
133
144
  updatedAt: timestamp("updated_at").defaultNow().notNull()
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
141
152
  factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
142
153
  createdAt: timestamp("created_at").defaultNow().notNull(),
143
154
  verifiedAt: timestamp("verified_at"),
144
- ipAddress: varchar("ip_address", { length: 45 }),
155
+ ipAddress: text("ip_address"),
145
156
  expiresAt: timestamp("expires_at").notNull()
146
157
  });
147
158
 
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
151
162
  const recoveryCodes = tableCreator("recovery_codes", {
152
163
  id: uuid("id").defaultRandom().primaryKey(),
153
164
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
154
- codeHash: varchar("code_hash", { length: 255 }).notNull(),
165
+ codeHash: text("code_hash").notNull(),
155
166
  usedAt: timestamp("used_at"),
156
167
  createdAt: timestamp("created_at").defaultNow().notNull()
157
168
  });
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
162
173
  const magicLinkTokens = tableCreator("magic_link_tokens", {
163
174
  id: uuid("id").defaultRandom().primaryKey(),
164
175
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
165
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
176
+ tokenHash: text("token_hash").notNull().unique(),
166
177
  expiresAt: timestamp("expires_at").notNull(),
167
178
  usedAt: timestamp("used_at"),
168
179
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Build drizzle tables at runtime from an introspected schema.
3
3
  *
4
- * CMS mode gets its drizzle tables from a generated `schema.generated.ts` that
4
+ * A project with declared collections gets its drizzle tables from a generated `schema.generated.ts` that
5
5
  * the developer commits. BaaS mode has no such file — it points at a database
6
6
  * and serves it — so the equivalent table objects are constructed here from
7
7
  * `information_schema` metadata.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Applying a bundle's RLS policies to a database at boot, idempotently.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * {@link ensureCollectionTables} creates the collection *tables* a managed
7
+ * runtime boots against, but a table with row-level security disabled and no
8
+ * policies is not servable: authenticated requests run as the restricted
9
+ * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
10
+ * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
11
+ * is denied. The policies live in the collections' `securityRules`; nothing at
12
+ * boot applied them. `rebase db push` does — but it drives Atlas against a
13
+ * local `DATABASE_URL`, and a managed tenant's database is reachable only from
14
+ * inside the cluster, by the runtime that is already connected to it. So the
15
+ * runtime is the only thing that *can* apply them, and this is where it does.
16
+ *
17
+ * ## Why this is safe to run on every boot
18
+ *
19
+ * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
20
+ * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
21
+ * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
22
+ * and replaces; it never drops data. (It does not *reconcile* — a policy a
23
+ * previous push left behind under an old name is not removed here; that stays a
24
+ * `db push` / `db migrate` concern, alongside destructive schema changes.)
25
+ *
26
+ * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
27
+ * table whose policies could not be applied fails **closed** (denies) rather
28
+ * than leaking rows. One collection's policy failing (e.g. a rule that
29
+ * references a table a real migration has not created yet) must not crash-loop
30
+ * the whole deployment and take the other collections' working routes down with
31
+ * it. Failures are reported loudly and per-table so the operator can see
32
+ * exactly which collection is not yet servable and why.
33
+ */
34
+ import { type CollectionConfig } from "@rebasepro/types";
35
+ import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
36
+ import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
37
+
38
+ export interface PolicyEnsureResult {
39
+ /** `CREATE POLICY` statements that ran successfully. */
40
+ policiesApplied: number;
41
+ /** Tables that had RLS enabled. */
42
+ tablesSecured: number;
43
+ /** Declared tables absent from the database — left to a real migration. */
44
+ skipped: { table: string; reason: string }[];
45
+ /** Tables whose RLS could not be fully applied (fail closed). */
46
+ failures: { table: string; error: string }[];
47
+ }
48
+
49
+ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
50
+
51
+ /**
52
+ * Bring the declared collections' RLS policies up to date. Returns what it did.
53
+ *
54
+ * Only tables that already exist are touched: the boot-time table creator runs
55
+ * first, so anything still missing is a table this additive path is not allowed
56
+ * to create (a junction, or a relation left to a migration). Enabling RLS on a
57
+ * non-existent table would error, so those are recorded as skipped, not failed.
58
+ */
59
+ export async function ensureCollectionPolicies(
60
+ client: Queryable,
61
+ collections: CollectionConfig[],
62
+ log?: (message: string) => void
63
+ ): Promise<PolicyEnsureResult> {
64
+ const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
65
+
66
+ const plans = planCollectionPolicies(collections);
67
+ if (plans.length === 0) return result;
68
+
69
+ const schemas = Array.from(new Set(plans.map(p => p.schema)));
70
+ const existing = await readExistingSchema(client, schemas);
71
+
72
+ for (const plan of plans) {
73
+ if (!existing.tables.has(plan.qualified)) {
74
+ result.skipped.push({
75
+ table: plan.qualified,
76
+ reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
77
+ });
78
+ continue;
79
+ }
80
+
81
+ try {
82
+ // Enable first: if a later policy statement fails, the table is left
83
+ // locked (deny-all for the user role) rather than open.
84
+ await client.query(plan.enableRls);
85
+ result.tablesSecured++;
86
+
87
+ let created = 0;
88
+ for (const statement of plan.policyStatements) {
89
+ await client.query(statement);
90
+ if (isCreatePolicy(statement)) {
91
+ result.policiesApplied++;
92
+ created++;
93
+ }
94
+ }
95
+ log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
96
+ } catch (err) {
97
+ result.failures.push({
98
+ table: plan.qualified,
99
+ error: err instanceof Error ? err.message : String(err)
100
+ });
101
+ }
102
+ }
103
+
104
+ return result;
105
+ }
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
87
87
  expect(sql).not.toMatch(/NOT NULL/i);
88
88
  });
89
89
 
90
- it("leaves relation columns to a real migration rather than adding them without their key", () => {
91
- const withRelation = {
92
- ...posts,
93
- properties: {
94
- ...(posts as unknown as { properties: Record<string, unknown> }).properties,
95
- author: { name: "Author", type: "reference", target: () => posts }
90
+ // ── Relations ────────────────────────────────────────────────────────────
91
+ //
92
+ // These columns were once skipped outright, on the reasoning that a bare
93
+ // column without its foreign key would disagree with what `db push` later
94
+ // generated. On a managed tenant nothing pushes afterwards, so the table
95
+ // arrived without the column its own collection reads: every insert 400ed
96
+ // with `column "author_id" does not exist`. The answer is to emit the key
97
+ // too, from the same planner `db push` uses.
98
+
99
+ const authors = {
100
+ name: "Authors",
101
+ slug: "authors",
102
+ properties: {
103
+ id: { name: "ID", type: "string", isId: "uuid" },
104
+ name: { name: "Name", type: "string" }
105
+ }
106
+ } as unknown as CollectionConfig;
107
+
108
+ const postsWithAuthor = {
109
+ ...posts,
110
+ properties: {
111
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
112
+ author: { name: "Author", type: "reference", path: "authors" }
113
+ }
114
+ } as unknown as CollectionConfig;
115
+
116
+ const tags = {
117
+ name: "Tags",
118
+ slug: "tags",
119
+ properties: {
120
+ id: { name: "ID", type: "number", isId: "increment" },
121
+ name: { name: "Name", type: "string" }
122
+ }
123
+ } as unknown as CollectionConfig;
124
+
125
+ const postsWithTags = {
126
+ ...posts,
127
+ properties: {
128
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
129
+ tags: {
130
+ name: "Tags",
131
+ type: "relation",
132
+ relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
96
133
  }
97
- } as unknown as CollectionConfig;
98
- const plan = planCollectionSchemaEnsure([withRelation], withTable("public.posts", ["id"]));
99
- expect(plan.actions.some(a => a.target.endsWith(".author"))).toBe(false);
134
+ }
135
+ } as unknown as CollectionConfig;
136
+
137
+ it("adds a reference column, and the foreign key that makes it one", () => {
138
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
139
+
140
+ expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
141
+ const fk = plan.actions.find(a => a.kind === "add-constraint");
142
+ expect(fk?.target).toBe("public.posts.posts_author_fkey");
143
+ expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
144
+ });
145
+
146
+ it("orders every constraint after the columns and tables it depends on", () => {
147
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
148
+ const kinds = plan.actions.map(a => a.kind);
149
+ expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
150
+ expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
151
+ });
152
+
153
+ it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
154
+ const existing: ExistingSchema = {
155
+ tables: new Map([
156
+ ["public.posts", new Set(["id", "author"])],
157
+ ["public.authors", new Set(["id", "name"])]
158
+ ]),
159
+ enums: new Set(["public.posts_status"]),
160
+ constraints: new Set(["public.posts.posts_author_fkey"])
161
+ };
162
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
163
+ expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
164
+ });
165
+
166
+ it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
167
+ const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
168
+ const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
169
+
170
+ expect(junction).toBeDefined();
171
+ // The endpoint key types have to match the primary keys they reference:
172
+ // posts is a uuid, tags an auto-increment integer.
173
+ expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
174
+ expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
175
+ expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
176
+
177
+ const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
178
+ expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
179
+ expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
180
+ });
181
+
182
+ it("leaves an existing junction table alone", () => {
183
+ const existing: ExistingSchema = {
184
+ tables: new Map([
185
+ ["public.posts", new Set(["id", "title", "views", "status"])],
186
+ ["public.tags", new Set(["id", "name"])],
187
+ ["public.posts_tags", new Set(["post_id", "tag_id"])]
188
+ ]),
189
+ enums: new Set(["public.posts_status"]),
190
+ constraints: new Set([
191
+ "public.posts_tags.posts_tags_post_id_fkey",
192
+ "public.posts_tags.posts_tags_tag_id_fkey"
193
+ ])
194
+ };
195
+ expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
100
196
  });
101
197
  });
102
198