@rebasepro/server-postgres 0.14.0 → 0.14.1

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 (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +19 -0
  3. package/dist/auth/services.d.ts +10 -0
  4. package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
  5. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  6. package/dist/data_driver-ULAyJEi9.js.map +1 -1
  7. package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-DoHwhVf8.js} +3 -3
  8. package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
  10. package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
  11. package/dist/index.es.js +543 -105
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
  14. package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
  15. package/dist/schema/drizzle-ddl.d.ts +9 -0
  16. package/dist/services/FetchService.d.ts +81 -5
  17. package/dist/services/RelationService.d.ts +3 -3
  18. package/dist/services/channel-presence.d.ts +16 -1
  19. package/dist/services/dataService.d.ts +6 -4
  20. package/dist/services/realtimeService.d.ts +54 -10
  21. package/dist/src-DCdn3Val.js.map +1 -1
  22. package/dist/utils/drizzle-conditions.d.ts +25 -0
  23. package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
  24. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  25. package/package.json +6 -6
  26. package/src/PostgresBackendDriver.ts +7 -3
  27. package/src/PostgresBootstrapper.ts +105 -41
  28. package/src/auth/services.ts +26 -5
  29. package/src/schema/drizzle-ddl.ts +33 -0
  30. package/src/schema/ensure-collection-tables.test.ts +99 -0
  31. package/src/schema/ensure-collection-tables.ts +65 -3
  32. package/src/schema/generate-drizzle-schema-logic.ts +19 -1
  33. package/src/services/FetchService.ts +310 -63
  34. package/src/services/RelationService.ts +3 -3
  35. package/src/services/channel-history.ts +38 -6
  36. package/src/services/channel-presence.ts +31 -7
  37. package/src/services/dataService.ts +6 -4
  38. package/src/services/pg-notify-listener.ts +14 -0
  39. package/src/services/realtimeService.ts +161 -41
  40. package/src/utils/drizzle-conditions.ts +155 -5
  41. package/src/websocket.ts +44 -1
  42. package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
  43. package/dist/websocket-C8ZqVBiV.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.14.1",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/codegen": "0.14.0",
51
- "@rebasepro/server": "0.14.0",
52
- "@rebasepro/common": "0.14.0",
53
- "@rebasepro/types": "0.14.0",
54
- "@rebasepro/utils": "0.14.0"
50
+ "@rebasepro/codegen": "0.14.1",
51
+ "@rebasepro/common": "0.14.1",
52
+ "@rebasepro/server": "0.14.1",
53
+ "@rebasepro/utils": "0.14.1",
54
+ "@rebasepro/types": "0.14.1"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -1188,16 +1188,20 @@ export class PostgresBackendDriver implements DataDriver {
1188
1188
  collection,
1189
1189
  filter,
1190
1190
  logical,
1191
- searchString
1191
+ searchString,
1192
+ vectorSearch
1192
1193
  }: FetchCollectionProps<M>): Promise<number> {
1193
1194
  return this.dataService.count(
1194
1195
  path,
1195
1196
  {
1196
1197
  filter,
1197
1198
  // Counted as well as filtered, or `meta.total` describes a
1198
- // different set of rows from the `data` beside it.
1199
+ // different set of rows from the `data` beside it. The same
1200
+ // held for a `vectorSearch` carrying a `threshold`: it narrows
1201
+ // the fetch, so it has to narrow the count.
1199
1202
  logical,
1200
- searchString
1203
+ searchString,
1204
+ vectorSearch
1201
1205
  }
1202
1206
  );
1203
1207
  }
@@ -137,6 +137,52 @@ export function resolveDriftCheckName(
137
137
  ?? col.slug;
138
138
  }
139
139
 
140
+ /**
141
+ * Why the tables this backend serves are not in the database — the part of the
142
+ * drift warning that has to be true rather than merely plausible.
143
+ *
144
+ * The three answers need three different actions, and only the caller knows
145
+ * which one applies. This warning used to assert the first ("this runtime
146
+ * applies the collection schema at boot unless REBASE_MIGRATE_ON_BOOT=none")
147
+ * and then point at that variable and at driver-version skew. For an app whose
148
+ * boot path contained no provisioning step at all, every word of that was a
149
+ * dead end: nothing read the variable, and the driver was current. The advice
150
+ * cost an investigation, which is a strictly worse outcome than saying less.
151
+ *
152
+ * Exported for its own test: the surrounding check needs a live pool and a real
153
+ * database, and this is the part that was wrong.
154
+ */
155
+ export function describeSchemaDriftCause(
156
+ provisioning: { attempted: boolean; reason?: string } | undefined
157
+ ): string[] {
158
+ // A caller too old to send the signal gets no claim either way — just where
159
+ // to look. Guessing is what got this wrong the first time.
160
+ if (provisioning === undefined) {
161
+ return [
162
+ " This runtime could not determine whether a schema-creation step ran",
163
+ " before this check (the caller predates that signal).",
164
+ " • Look for a \"Collection schema:\" line above. No such line at all",
165
+ " means nothing tried to create these tables in this process."
166
+ ];
167
+ }
168
+ if (provisioning.attempted) {
169
+ return [
170
+ " A schema-creation step DID run this boot and these tables are still",
171
+ " missing, so it did not create them — check the \"schema:\" lines above",
172
+ " for what it did instead, and for DDL errors.",
173
+ " • A collection routed to another engine or data source is not",
174
+ " created here; that is reported separately at boot.",
175
+ " • Otherwise this is a bug worth reporting, with those lines."
176
+ ];
177
+ }
178
+ return [
179
+ " No schema-creation step ran this boot:",
180
+ ` ${provisioning.reason ?? "no reason was given."}`,
181
+ " Resolve that reason — the drift is its consequence, not a separate",
182
+ " problem, and re-running a migration tool will not change it."
183
+ ];
184
+ }
185
+
140
186
  /**
141
187
  * Is this the local database `rebase init` scaffolds — i.e. the one case where
142
188
  * "you are connected as a superuser" is not news?
@@ -200,17 +246,53 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
200
246
  configureUnknownFilterFields(pgConfig.unknownFilterFields);
201
247
  }
202
248
 
249
+ /**
250
+ * The handle the schema/policy hooks issue their DDL through.
251
+ *
252
+ * Both hooks run BEFORE `initializeDriver`, so `driverResult` is a stand-in
253
+ * the caller may not have: the bundle path can synthesize one from the
254
+ * connection its coordinator opened, but an application that built this
255
+ * adapter itself never handed the framework a connection — it handed it to
256
+ * *us*, as `pgConfig.connection`. Falling back to that is what lets a
257
+ * self-built adapter provision at all; requiring the argument is what left
258
+ * those apps with no tables and a 500 on every data route.
259
+ *
260
+ * Either handle is equivalent here. The driver's `schemaAwareDb` differs
261
+ * only by the drizzle schema object registered on it — relevant to the query
262
+ * builder, not to `execute(sql.raw(...))` — and every statement these hooks
263
+ * emit is schema-qualified DDL, so neither depends on `search_path`.
264
+ */
265
+ const provisioningQueryable = (driverResult?: InitializedDriver) => {
266
+ const internals = driverResult?.internals as PostgresDriverInternals | undefined;
267
+ const db = internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
268
+ if (!db) {
269
+ throw new Error(
270
+ "Cannot provision the collection schema: this Postgres adapter was created without a " +
271
+ "`connection`, and no initialized driver was supplied to fall back on. Pass `connection` " +
272
+ "to `createPostgresAdapter` (see `createPostgresDatabaseConnection`)."
273
+ );
274
+ }
275
+ return {
276
+ async query<T>(text: string): Promise<{ rows: T[] }> {
277
+ const result = await db.execute(sql.raw(text));
278
+ const rows = (result as unknown as { rows?: T[] }).rows;
279
+ return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
280
+ }
281
+ };
282
+ };
283
+
203
284
  return {
204
285
  type: "postgres",
205
286
 
206
287
  async initializeDriver(config: unknown): Promise<InitializedDriver> {
207
288
  // config is passed from coordinator, we merge it with our internal pgConfig if needed
208
289
  // Currently config from init.ts is `{ collections, collectionRegistry, mode }`
209
- const { collections, collectionRegistry, introspectCollections, baas } = config as {
290
+ const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config as {
210
291
  collections?: CollectionConfig[];
211
292
  collectionRegistry?: unknown;
212
293
  introspectCollections?: boolean;
213
294
  baas?: { unprotectedTables?: "exclude" | "serve" };
295
+ schemaProvisioning?: { attempted: boolean; reason?: string };
214
296
  };
215
297
  // Secure by default: a table with no RLS is not served.
216
298
  const unprotectedTables = baas?.unprotectedTables ?? "exclude";
@@ -695,30 +777,29 @@ foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
695
777
  " (`?options=-c%20search_path%3Dpublic`).",
696
778
  ""
697
779
  ];
698
- // This runtime creates collection tables (and their RLS)
699
- // at boot unless REBASE_MIGRATE_ON_BOOT=none, so a drift
700
- // this late means that step was disabled, could not run,
701
- // or failed the guidance names a path for both a managed
702
- // tenant (whose in-cluster database `pnpm db:push` cannot
703
- // reach) and a self-host. Naming only the pnpm scripts, as
704
- // this once did, told a managed operator to run a command
705
- // that structurally cannot touch their database.
780
+ // What to tell the operator depends entirely on whether a
781
+ // create step ran in this process, and the caller is the
782
+ // only thing that knows. This warning used to assert that
783
+ // it had ("this runtime applies the collection schema at
784
+ // boot unless REBASE_MIGRATE_ON_BOOT=none") and send
785
+ // people to that variable and to driver-version skew. For
786
+ // an app whose boot path contained no provisioning step,
787
+ // both were dead ends: nothing read that variable, and the
788
+ // driver was current. Say which case this is instead of
789
+ // guessing, and say nothing when the caller is too old to
790
+ // tell us.
791
+ const cause = describeSchemaDriftCause(schemaProvisioning);
706
792
  logger.warn([
707
793
  "",
708
794
  "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
709
795
  ...lines,
710
796
  "",
711
797
  ...misplacedHelp,
712
- " This runtime applies the collection schema at boot unless",
713
- " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
714
- " log lines above — this drift means that step was off, skipped, or failed.",
715
- " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
716
- " \"ensure\"; the runtime applies the schema to the tenant DB.",
717
- " If the log above says the driver does not implement collection-table",
718
- " creation, THIS driver is too old to do it. A driver is installed from",
719
- " your bundle's dependencies, not supplied by the platform image, so a",
720
- " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
721
- " in your project's package.json and redeploy.",
798
+ ...cause,
799
+ "",
800
+ " To apply this project's schema:",
801
+ " • Managed cloud: the runtime creates tables and RLS at boot. `rebase db",
802
+ " push` cannot reach a tenant's in-cluster database redeploy instead.",
722
803
  " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
723
804
  " against DATABASE_URL.",
724
805
  ""
@@ -862,23 +943,16 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
862
943
  */
863
944
  async ensureCollectionSchema(
864
945
  collections: unknown[],
865
- driverResult: InitializedDriver,
946
+ driverResult?: InitializedDriver,
866
947
  log?: (message: string) => void
867
948
  ): Promise<{ applied: number }> {
868
- const internals = driverResult.internals as PostgresDriverInternals;
869
949
  const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
870
950
  // Runs through the drizzle handle the driver already bootstrapped
871
951
  // with, so it uses exactly the connection and privileges that were
872
952
  // proven to work. Every statement is DDL or a catalogue read with no
873
953
  // bindable values (schema names are identifiers), and the module
874
954
  // validates them before they reach a string.
875
- const queryable = {
876
- async query<T>(text: string): Promise<{ rows: T[] }> {
877
- const result = await internals.db.execute(sql.raw(text));
878
- const rows = (result as unknown as { rows?: T[] }).rows;
879
- return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
880
- }
881
- };
955
+ const queryable = provisioningQueryable(driverResult);
882
956
  const plan = await ensureCollectionTables(
883
957
  queryable,
884
958
  collections as Parameters<typeof ensureCollectionTables>[1],
@@ -917,18 +991,11 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
917
991
  */
918
992
  async ensureCollectionPolicies(
919
993
  collections: unknown[],
920
- driverResult: InitializedDriver,
994
+ driverResult?: InitializedDriver,
921
995
  log?: (message: string) => void
922
996
  ): Promise<{ applied: number }> {
923
- const internals = driverResult.internals as PostgresDriverInternals;
924
997
  const { ensureCollectionPolicies } = await import("./schema/ensure-collection-policies");
925
- const queryable = {
926
- async query<T>(text: string): Promise<{ rows: T[] }> {
927
- const result = await internals.db.execute(sql.raw(text));
928
- const rows = (result as unknown as { rows?: T[] }).rows;
929
- return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
930
- }
931
- };
998
+ const queryable = provisioningQueryable(driverResult);
932
999
  const outcome = await ensureCollectionPolicies(
933
1000
  queryable,
934
1001
  collections as CollectionConfig[],
@@ -982,10 +1049,7 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
982
1049
  try {
983
1050
  const { dropLegacyAuthSchema } = await import("./schema/rls-bootstrap-sql");
984
1051
  await dropLegacyAuthSchema(
985
- async (text) => {
986
- const res = await internals.db.execute(sql.raw(text));
987
- return (res.rows ?? []) as Record<string, unknown>[];
988
- },
1052
+ async (text) => (await queryable.query<Record<string, unknown>>(text)).rows,
989
1053
  { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
990
1054
  );
991
1055
  } catch (err) {
@@ -22,10 +22,12 @@ import {
22
22
  PaginatedUsersResult,
23
23
  MfaFactor,
24
24
  MfaChallengeInfo,
25
- RoleData as Role
25
+ RoleData as Role,
26
+ ApiError
26
27
  } from "@rebasepro/server";
27
28
  import { toSnakeCase, camelCase } from "@rebasepro/utils";
28
29
  import { escapeLikePattern } from "../utils/drizzle-conditions";
30
+ import { extractPgError } from "../utils/pg-error-utils";
29
31
 
30
32
  export type { Role };
31
33
 
@@ -249,12 +251,31 @@ export class UserService implements UserRepository {
249
251
  return payload;
250
252
  }
251
253
 
254
+ /**
255
+ * @see UserRepository.createUser — an email already in use is a 409.
256
+ *
257
+ * The route checks first and answers 409; this is the same answer for the
258
+ * requests that get past the check, which two clicks on a signup button
259
+ * are enough to produce. `PersistService` has mapped `23505` to a conflict
260
+ * for collection writes since the layer that holds the SQLSTATE was made
261
+ * responsible for saying whose fault a failure is; the auth writes never
262
+ * got the same treatment and reached the client as "Internal Server Error".
263
+ */
252
264
  async createUser(data: CreateUserData): Promise<UserData> {
253
265
  const payload = this.mapPayload(data);
254
- const [row] = await this.withServerContext(async (db) =>
255
- (await db.insert(this.usersTable).values(payload).returning()) as Record<string, unknown>[]
256
- );
257
- return this.mapRowToUser(row);
266
+ try {
267
+ const [row] = await this.withServerContext(async (db) =>
268
+ (await db.insert(this.usersTable).values(payload).returning()) as Record<string, unknown>[]
269
+ );
270
+ return this.mapRowToUser(row);
271
+ } catch (error) {
272
+ // Drizzle wraps the pg error, so the SQLSTATE is down the `cause`
273
+ // chain rather than on the error itself.
274
+ if (extractPgError(error)?.code === "23505") {
275
+ throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
276
+ }
277
+ throw error;
278
+ }
258
279
  }
259
280
 
260
281
  async getUserById(id: string): Promise<UserData | null> {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The server's DDL bootstrapper, over a Drizzle handle.
3
+ *
4
+ * `createDdlBootstrapper` in `@rebasepro/server` wants a plain
5
+ * `(sql: string) => Promise<rows>`; the driver's internal stores hold a Drizzle
6
+ * database. This is the adapter between them, and it exists so the retry policy
7
+ * has exactly one definition. A second copy of the SQLSTATE list living in the
8
+ * driver is how the two drift apart, and the drift is invisible: both versions
9
+ * work perfectly on every single-instance deployment.
10
+ */
11
+ import { sql } from "drizzle-orm";
12
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
13
+ import { createDdlBootstrapper, type DdlBootstrapper } from "@rebasepro/server";
14
+
15
+ /**
16
+ * A {@link DdlBootstrapper} that runs its statements through `db.execute`.
17
+ *
18
+ * @param db the Drizzle handle the calling store already holds
19
+ * @param scope log prefix identifying the caller, e.g. `"channel-presence"`
20
+ */
21
+ export function drizzleDdlBootstrapper(
22
+ db: NodePgDatabase<Record<string, unknown>>,
23
+ scope: string
24
+ ): DdlBootstrapper {
25
+ return createDdlBootstrapper(async (statement: string) => {
26
+ // `sql.raw`, because everything reaching this path is DDL assembled from
27
+ // identifiers that were validated before they got here — there is no
28
+ // parameter to bind, and Drizzle's tagged template would treat the whole
29
+ // statement as one.
30
+ const result = await db.execute(sql.raw(statement));
31
+ return (result as unknown as { rows?: Record<string, unknown>[] }).rows ?? [];
32
+ }, scope);
33
+ }
@@ -250,3 +250,102 @@ describe("applying the plan", () => {
250
250
  expect(plan.actions).toEqual([]);
251
251
  });
252
252
  });
253
+
254
+ /**
255
+ * Two instances booting into the same fresh database.
256
+ *
257
+ * These inject the error at the statement level rather than driving two real
258
+ * boots, and that is deliberate: the end state after a real race is usually
259
+ * correct anyway — one instance always finishes — so a test that only asserts
260
+ * the end state passes against the broken code as well as the fixed code. The
261
+ * defect was never "the table is missing"; it was that the throw abandoned
262
+ * every remaining action, so the loser skipped work it had not attempted yet.
263
+ */
264
+ describe("a simultaneous boot", () => {
265
+ /** A driver error shaped the way node-postgres reports one, through drizzle. */
266
+ function pgError(code: string, extra: Record<string, unknown> = {}): Error {
267
+ const inner = Object.assign(new Error(`pg error ${code}`), { code, ...extra });
268
+ // Drizzle wraps the driver error; nothing useful is ever on the top level.
269
+ return Object.assign(new Error("Failed query"), { cause: inner });
270
+ }
271
+
272
+ /** Records every statement, and fails the first match of `failOn` once. */
273
+ function racingClient(
274
+ failOn: RegExp,
275
+ error: Error,
276
+ options: { forever?: boolean } = {}
277
+ ): { client: Queryable; executed: string[] } {
278
+ const executed: string[] = [];
279
+ let thrown = false;
280
+ return {
281
+ executed,
282
+ client: {
283
+ async query<T>(sql: string): Promise<{ rows: T[] }> {
284
+ executed.push(sql);
285
+ if (failOn.test(sql) && (options.forever || !thrown)) {
286
+ thrown = true;
287
+ throw error;
288
+ }
289
+ return { rows: [] as T[] };
290
+ }
291
+ }
292
+ };
293
+ }
294
+
295
+ it("carries on to the remaining actions when it loses a CREATE TABLE race", async () => {
296
+ // 42P07 duplicate_table: a peer created it between our catalog read and
297
+ // our write. The table exists; everything after it still has to run.
298
+ const { client, executed } = racingClient(/^CREATE TABLE/, pgError("42P07"), { forever: true });
299
+
300
+ const plan = await ensureCollectionTables(client, [posts]);
301
+
302
+ expect(plan.actions.length).toBeGreaterThan(0);
303
+ // The proof: statements that come *after* the losing one were attempted.
304
+ expect(executed.some(s => s.startsWith("ALTER TABLE"))).toBe(true);
305
+ });
306
+
307
+ it("treats a catalog unique violation as the object already existing", async () => {
308
+ // The one measured in practice: `CREATE TYPE` has no IF NOT EXISTS, so
309
+ // the loser gets 23505 on pg_type's own index.
310
+ const { client, executed } = racingClient(
311
+ /^CREATE TYPE/,
312
+ pgError("23505", { constraint: "pg_type_typname_nsp_index" }),
313
+ { forever: true }
314
+ );
315
+
316
+ await ensureCollectionTables(client, [posts]);
317
+
318
+ expect(executed.some(s => s.startsWith("CREATE TABLE"))).toBe(true);
319
+ });
320
+
321
+ it("retries a deadlock and then succeeds", async () => {
322
+ // 40P01: two boots taking catalog locks in step. Unlike a duplicate, the
323
+ // statement did nothing at all, so it must actually be run again.
324
+ const { client, executed } = racingClient(/^CREATE TABLE/, pgError("40P01"));
325
+
326
+ await ensureCollectionTables(client, [posts]);
327
+
328
+ expect(executed.filter(s => s.startsWith("CREATE TABLE")).length).toBeGreaterThan(1);
329
+ });
330
+
331
+ it("still fails loudly on a unique violation from the customer's own data", async () => {
332
+ // A named constraint, not a `pg_` catalog index — this is a real problem
333
+ // with real rows and must not be swallowed as "someone beat me to it".
334
+ // It is still retried first, because 23505 is in the retryable set and
335
+ // this shape cannot be told from a race until the constraint name is
336
+ // read; what matters is that it ends in a throw rather than a shrug.
337
+ const { client } = racingClient(
338
+ /^CREATE TABLE/,
339
+ pgError("23505", { constraint: "posts_slug_key" }),
340
+ { forever: true }
341
+ );
342
+
343
+ await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
344
+ });
345
+
346
+ it("still fails loudly on a permission error", async () => {
347
+ const { client } = racingClient(/^CREATE TABLE/, pgError("42501"), { forever: true });
348
+
349
+ await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
350
+ });
351
+ });
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
29
29
  import { getTableName, relationalCollections } from "@rebasepro/common";
30
- import { logger } from "@rebasepro/server";
30
+ import { logger, isConcurrentDdlRace, isDuplicateObjectRace } from "@rebasepro/server";
31
31
  import {
32
32
  assertSearchIsPostgresOnly,
33
33
  buildSearchColumnSpec,
@@ -800,8 +800,11 @@ export async function ensureCollectionTables(
800
800
 
801
801
  for (const action of plan.actions) {
802
802
  try {
803
- await client.query(action.sql);
804
- log?.(`${action.kind}: ${action.target}`);
803
+ if (await applyAction(client, action)) {
804
+ log?.(`${action.kind}: ${action.target}`);
805
+ } else {
806
+ log?.(`${action.kind}: ${action.target} (already created by a peer)`);
807
+ }
805
808
  } catch (err) {
806
809
  const message = err instanceof Error ? err.message : String(err);
807
810
  // A foreign key is the only action that can fail on the customer's
@@ -825,3 +828,62 @@ export async function ensureCollectionTables(
825
828
  }
826
829
  return { ...plan, failures };
827
830
  }
831
+
832
+ /** Attempts per action, including the first. Matches the server's bootstraps. */
833
+ const DDL_ATTEMPTS = 4;
834
+
835
+ /**
836
+ * Run one planned statement, surviving a simultaneous boot.
837
+ *
838
+ * Every statement in a plan is written to be idempotent, and that is not the
839
+ * same as being safe to run concurrently: `CREATE … IF NOT EXISTS` reads the
840
+ * catalog and then writes to it as two steps, so peers starting together both
841
+ * see "absent" and the loser gets a duplicate key on a *catalog* index. Measured
842
+ * against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is
843
+ * worse, because Postgres has no `IF NOT EXISTS` for it at all.
844
+ *
845
+ * What made that fatal here rather than merely noisy is the loop this sits in.
846
+ * A losing statement threw, and the throw abandoned **every remaining action in
847
+ * the plan** — so a replica that lost one race came up missing tables it never
848
+ * attempted, and the boot log blamed the one statement that failed.
849
+ *
850
+ * @returns `true` if this process applied the statement, `false` if a peer had
851
+ * already created the object. The distinction is only for the log; both mean
852
+ * the object is now there.
853
+ * @throws the original error for anything that is not a race — a syntax error, a
854
+ * permission failure, a unique constraint the customer's own rows violate.
855
+ */
856
+ async function applyAction(
857
+ client: Queryable,
858
+ action: EnsureAction
859
+ ): Promise<boolean> {
860
+ for (let attempt = 1; ; attempt++) {
861
+ try {
862
+ await client.query(action.sql);
863
+ return true;
864
+ } catch (err) {
865
+ // Already there. Not "retry" — the end state this statement wanted
866
+ // is the end state the database is in, so carry on to the next
867
+ // action rather than spending three more attempts proving it.
868
+ if (isDuplicateObjectRace(err)) {
869
+ logger.debug(
870
+ `[schema] ${action.kind} ${action.target}: already created by another instance`
871
+ );
872
+ return false;
873
+ }
874
+ // Retryable but not yet satisfied — a deadlock between two boots
875
+ // taking catalog locks in step. The statement did nothing; run it
876
+ // again after a jittered pause so peers that collided once do not
877
+ // collide again in lockstep.
878
+ if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {
879
+ logger.debug(
880
+ `[schema] ${action.kind} ${action.target}: lost a race with another instance ` +
881
+ `(attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`
882
+ );
883
+ await new Promise(resolve => setTimeout(resolve, 40 * attempt * (1 + Math.random())));
884
+ continue;
885
+ }
886
+ throw err;
887
+ }
888
+ }
889
+ }
@@ -379,8 +379,26 @@ export const getDrizzleColumn = (propName: string, prop: Property, collection: C
379
379
 
380
380
  /**
381
381
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
382
+ *
383
+ * The clause is SQL being written into a TypeScript file, so it has to survive
384
+ * being read back as a template literal. Three characters do not:
385
+ *
386
+ * - `` ` `` closes the template early, and the rest of the clause becomes code.
387
+ * - `${` opens an interpolation — the file stops compiling, or worse, compiles
388
+ * against whatever identifier happens to be in scope.
389
+ * - `\` is an escape, and Drizzle's `sql` tag reads the *cooked* strings, not
390
+ * `.raw`. So a policy written as `email ~ '^admin\.user@corp\.com$'` reaches
391
+ * the database as `^admin.user@corp.com$`, where every `\.` now matches any
392
+ * character. A `USING` clause is a security boundary and that one silently
393
+ * widened it — the SQL file emitted by the DDL generator kept the backslashes
394
+ * while this path dropped them, so the two disagreed about who could read the
395
+ * table.
396
+ *
397
+ * Escaping here rather than in the compiler: the clause is correct SQL, and it
398
+ * is only this destination that has an opinion about backslashes.
382
399
  */
383
- const wrapSql = (clause: string): string => `sql\`${clause}\``;
400
+ const wrapSql = (clause: string): string =>
401
+ `sql\`${clause.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``;
384
402
 
385
403
  /**
386
404
  * Generates a deterministic hash based on the rule configuration.