@rebasepro/server-postgres 0.14.1-canary.g7e666eb → 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.
- package/dist/PostgresBootstrapper.d.ts +19 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -1
- package/dist/{ensure-collection-policies-BedO2aNX.js → ensure-collection-policies-DoHwhVf8.js} +2 -2
- package/dist/{ensure-collection-policies-BedO2aNX.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
- package/dist/{ensure-collection-tables-B1qKdXA4.js → ensure-collection-tables-DT2eq859.js} +44 -4
- package/dist/{ensure-collection-tables-B1qKdXA4.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
- package/dist/index.es.js +325 -42
- package/dist/index.es.js.map +1 -1
- package/dist/schema/drizzle-ddl.d.ts +9 -0
- package/dist/services/FetchService.d.ts +27 -0
- package/dist/services/channel-presence.d.ts +16 -1
- package/dist/utils/drizzle-conditions.d.ts +25 -0
- package/dist/websocket-D2jXv0Ds.js.map +1 -1
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +105 -41
- package/src/schema/drizzle-ddl.ts +33 -0
- package/src/schema/ensure-collection-tables.test.ts +99 -0
- package/src/schema/ensure-collection-tables.ts +65 -3
- package/src/services/FetchService.ts +126 -1
- package/src/services/channel-history.ts +38 -6
- package/src/services/channel-presence.ts +31 -7
- package/src/services/realtimeService.ts +1 -1
- package/src/utils/drizzle-conditions.ts +155 -5
|
@@ -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
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
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
|
-
|
|
713
|
-
"
|
|
714
|
-
"
|
|
715
|
-
" • Managed cloud:
|
|
716
|
-
"
|
|
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
|
|
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
|
|
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) {
|
|
@@ -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
|
|
804
|
-
|
|
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, isNotNull, isNull, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
1
|
+
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, isNotNull, isNull, lt, or, sql, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
|
|
4
4
|
import type { VectorSearchParams } from "@rebasepro/types";
|
|
@@ -1326,6 +1326,131 @@ relatedTo: hop });
|
|
|
1326
1326
|
return Number(result[0]?.count || 0);
|
|
1327
1327
|
}
|
|
1328
1328
|
|
|
1329
|
+
/**
|
|
1330
|
+
* `count`/`sum`/`avg`/`min`/`max`, optionally grouped.
|
|
1331
|
+
*
|
|
1332
|
+
* The gap this fills is narrow and constant: every dashboard wants "revenue
|
|
1333
|
+
* by status" and "orders per day", and without it the options were a custom
|
|
1334
|
+
* function holding hand-written SQL, or fetching every row and reducing in
|
|
1335
|
+
* JavaScript — which is wrong at any size that matters, and silently wrong
|
|
1336
|
+
* under a `limit`.
|
|
1337
|
+
*
|
|
1338
|
+
* It runs through the same request-scoped handle as every other read, so
|
|
1339
|
+
* **RLS applies to the rows being aggregated**. That is the property worth
|
|
1340
|
+
* protecting here: an aggregate is an effective way to read data you cannot
|
|
1341
|
+
* select, and `count(*)` over a table whose policies would return nothing
|
|
1342
|
+
* has to be zero.
|
|
1343
|
+
*/
|
|
1344
|
+
async aggregate<M extends Record<string, unknown>>(
|
|
1345
|
+
collectionPath: string,
|
|
1346
|
+
options: {
|
|
1347
|
+
aggregates: { fn: "count" | "sum" | "avg" | "min" | "max"; field?: string; alias: string }[];
|
|
1348
|
+
groupBy?: string[];
|
|
1349
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1350
|
+
logical?: LogicalCondition;
|
|
1351
|
+
searchString?: string;
|
|
1352
|
+
limit?: number;
|
|
1353
|
+
}
|
|
1354
|
+
): Promise<Record<string, unknown>[]> {
|
|
1355
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1356
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1357
|
+
const columns = getTableColumns(table);
|
|
1358
|
+
|
|
1359
|
+
const columnFor = (field: string, forWhat: string): AnyPgColumn => {
|
|
1360
|
+
const column = columns[field as keyof typeof columns] as AnyPgColumn | undefined;
|
|
1361
|
+
if (!column) {
|
|
1362
|
+
throw ApiError.badRequest(
|
|
1363
|
+
`Unknown field '${field}' in ${forWhat}. Valid fields: ${Object.keys(columns).sort().join(", ")}`,
|
|
1364
|
+
"UNKNOWN_AGGREGATE_FIELD"
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
return column;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
const selection: Record<string, SQL> = {};
|
|
1371
|
+
|
|
1372
|
+
for (const aggregate of options.aggregates) {
|
|
1373
|
+
if (aggregate.fn === "count" && !aggregate.field) {
|
|
1374
|
+
selection[aggregate.alias] = sql`count(*)`;
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
const column = columnFor(aggregate.field as string, `${aggregate.fn}()`);
|
|
1378
|
+
switch (aggregate.fn) {
|
|
1379
|
+
case "count": selection[aggregate.alias] = sql`count(${column})`; break;
|
|
1380
|
+
// Cast through numeric so what comes back is a string this
|
|
1381
|
+
// method parses, rather than a float whose precision depends on
|
|
1382
|
+
// the column type — `avg` over an integer column is otherwise
|
|
1383
|
+
// one shape here and another there.
|
|
1384
|
+
case "sum": selection[aggregate.alias] = sql`sum(${column})::numeric`; break;
|
|
1385
|
+
case "avg": selection[aggregate.alias] = sql`avg(${column})::numeric`; break;
|
|
1386
|
+
case "min": selection[aggregate.alias] = sql`min(${column})`; break;
|
|
1387
|
+
case "max": selection[aggregate.alias] = sql`max(${column})`; break;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const groupColumns = (options.groupBy ?? []).map(field => ({
|
|
1392
|
+
field,
|
|
1393
|
+
column: columnFor(field, "groupBy")
|
|
1394
|
+
}));
|
|
1395
|
+
for (const group of groupColumns) {
|
|
1396
|
+
selection[group.field] = sql`${group.column}`;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
let query = this.db.select(selection).from(table).$dynamic();
|
|
1400
|
+
|
|
1401
|
+
const conditions: SQL[] = [];
|
|
1402
|
+
if (options.searchString) {
|
|
1403
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1404
|
+
options.searchString, collection.properties, table, collection
|
|
1405
|
+
);
|
|
1406
|
+
// No searchable field means no row matches — the same impossible
|
|
1407
|
+
// WHERE the listing uses, rather than an unfiltered aggregate.
|
|
1408
|
+
if (searchConditions.length === 0) return [];
|
|
1409
|
+
conditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions) as SQL);
|
|
1410
|
+
}
|
|
1411
|
+
if (options.filter) {
|
|
1412
|
+
conditions.push(...this.buildFilterConditions(options.filter, table, collectionPath));
|
|
1413
|
+
}
|
|
1414
|
+
if (options.logical) {
|
|
1415
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
|
|
1416
|
+
options.logical, table, collectionPath, this.filterContext(collectionPath, table)
|
|
1417
|
+
);
|
|
1418
|
+
if (logicalCondition) conditions.push(logicalCondition);
|
|
1419
|
+
}
|
|
1420
|
+
if (conditions.length > 0) {
|
|
1421
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
|
|
1422
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (groupColumns.length > 0) {
|
|
1426
|
+
query = query.groupBy(...groupColumns.map(g => g.column));
|
|
1427
|
+
// Bounded for the same reason a listing is: grouping by a
|
|
1428
|
+
// high-cardinality column is a whole table's worth of rows in one
|
|
1429
|
+
// response.
|
|
1430
|
+
if (options.limit) query = query.limit(options.limit);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const rows = await query as Record<string, unknown>[];
|
|
1434
|
+
|
|
1435
|
+
// `count`, `sum` and `avg` arrive as strings: Postgres returns bigint
|
|
1436
|
+
// and numeric that way because they do not fit a JS number in general.
|
|
1437
|
+
// They do fit for every aggregate anyone puts on a dashboard, and a
|
|
1438
|
+
// caller handed `"12"` where they expected `12` has to find that out
|
|
1439
|
+
// for themselves. Parsed once, here.
|
|
1440
|
+
const numericAliases = new Set(
|
|
1441
|
+
options.aggregates.filter(a => a.fn === "count" || a.fn === "sum" || a.fn === "avg").map(a => a.alias)
|
|
1442
|
+
);
|
|
1443
|
+
return rows.map(row => {
|
|
1444
|
+
const out: Record<string, unknown> = { ...row };
|
|
1445
|
+
for (const alias of numericAliases) {
|
|
1446
|
+
if (out[alias] === null || out[alias] === undefined) continue;
|
|
1447
|
+
const parsed = Number(out[alias]);
|
|
1448
|
+
if (!Number.isNaN(parsed)) out[alias] = parsed;
|
|
1449
|
+
}
|
|
1450
|
+
return out;
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1329
1454
|
/**
|
|
1330
1455
|
* Check if a field value is unique
|
|
1331
1456
|
*/
|
|
@@ -35,6 +35,7 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
|
35
35
|
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
36
36
|
import { logger } from "@rebasepro/server";
|
|
37
37
|
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
38
|
+
import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
|
|
38
39
|
|
|
39
40
|
/** How many messages a replay returns when the caller does not say. */
|
|
40
41
|
const DEFAULT_REPLAY_LIMIT = 200;
|
|
@@ -167,12 +168,19 @@ export class ChannelHistoryStore {
|
|
|
167
168
|
async ensureTables(): Promise<void> {
|
|
168
169
|
if (!this.enabled || this.tablesReady) return;
|
|
169
170
|
|
|
170
|
-
|
|
171
|
+
// Contained, retrying steps rather than one straight sequence — see the
|
|
172
|
+
// note on `ChannelPresenceStore.ensureTables`. The failure mode here is
|
|
173
|
+
// the same and the stakes are the same: the two `REVOKE`s at the end are
|
|
174
|
+
// what keep retained broadcasts off the end-user role, and a lost create
|
|
175
|
+
// race used to skip them.
|
|
176
|
+
const ddl = drizzleDdlBootstrapper(this.db, "channel-history");
|
|
177
|
+
|
|
178
|
+
await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
171
179
|
|
|
172
180
|
// The primary key is exactly the replay query's access path
|
|
173
181
|
// (`channel = $1 AND seq > $2 ORDER BY seq`), so it needs no further
|
|
174
182
|
// index of its own.
|
|
175
|
-
await
|
|
183
|
+
await ddl.ensureObject("channel_messages table", `
|
|
176
184
|
CREATE TABLE IF NOT EXISTS rebase.channel_messages (
|
|
177
185
|
channel TEXT NOT NULL,
|
|
178
186
|
seq BIGINT NOT NULL,
|
|
@@ -185,14 +193,14 @@ export class ChannelHistoryStore {
|
|
|
185
193
|
`);
|
|
186
194
|
|
|
187
195
|
// Only for the TTL arm of pruning; the limit arm rides the primary key.
|
|
188
|
-
await
|
|
196
|
+
await ddl.ensureObject("channel_messages created_at index", `
|
|
189
197
|
CREATE INDEX IF NOT EXISTS idx_channel_messages_created
|
|
190
198
|
ON rebase.channel_messages (created_at)
|
|
191
199
|
`);
|
|
192
200
|
|
|
193
201
|
// Never pruned — see the note at the top of this file. One row per
|
|
194
202
|
// channel that has ever retained a message.
|
|
195
|
-
await
|
|
203
|
+
await ddl.ensureObject("channel_cursors table", `
|
|
196
204
|
CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
|
|
197
205
|
channel TEXT PRIMARY KEY,
|
|
198
206
|
last_seq BIGINT NOT NULL
|
|
@@ -209,8 +217,32 @@ export class ChannelHistoryStore {
|
|
|
209
217
|
// `docs/channel-authorization.md`. The driver's schema-wide
|
|
210
218
|
// grant reaches these (created here, after it ran), so take the
|
|
211
219
|
// privilege back.
|
|
212
|
-
|
|
213
|
-
|
|
220
|
+
//
|
|
221
|
+
// Driven off a probe of what exists rather than off who won each create.
|
|
222
|
+
const [messagesReady, cursorsReady] = await Promise.all([
|
|
223
|
+
ddl.isReadable("rebase.channel_messages"),
|
|
224
|
+
ddl.isReadable("rebase.channel_cursors")
|
|
225
|
+
]);
|
|
226
|
+
if (messagesReady) {
|
|
227
|
+
await ddl.step("channel_messages revoke", () =>
|
|
228
|
+
this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")))
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (cursorsReady) {
|
|
232
|
+
await ddl.step("channel_cursors revoke", () =>
|
|
233
|
+
this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")))
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!messagesReady || !cursorsReady) {
|
|
238
|
+
// Left un-ready on purpose so the next call retries. Announcing
|
|
239
|
+
// "ready" here is what would turn a half-created schema into replays
|
|
240
|
+
// that answer empty forever.
|
|
241
|
+
logger.warn(
|
|
242
|
+
"[ChannelHistory] Retained-channel tables are not both present; history is not ready yet."
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
214
246
|
|
|
215
247
|
this.tablesReady = true;
|
|
216
248
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|