@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.14e53ae
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 +7 -3
- package/dist/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/ensure-collection-tables-CNlIONzj.js +304 -0
- package/dist/ensure-collection-tables-CNlIONzj.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1472 -4640
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +114 -6
- package/dist/src-B0v4IKaI.js +329 -0
- package/dist/src-B0v4IKaI.js.map +1 -0
- package/dist/src-DmsRg8MR.js +4056 -0
- package/dist/src-DmsRg8MR.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +72 -3
- package/src/auth/ensure-tables.ts +91 -3
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/index.ts +4 -0
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-postgres-ddl-logic.ts +3 -3
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +383 -14
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import type { CollectionConfig } from "@rebasepro/types";
|
|
3
|
+
import {
|
|
4
|
+
planCollectionSchemaEnsure,
|
|
5
|
+
ensureCollectionTables,
|
|
6
|
+
type ExistingSchema,
|
|
7
|
+
type Queryable
|
|
8
|
+
} from "./ensure-collection-tables";
|
|
9
|
+
|
|
10
|
+
const posts = {
|
|
11
|
+
name: "Posts",
|
|
12
|
+
slug: "posts",
|
|
13
|
+
properties: {
|
|
14
|
+
id: { name: "ID", type: "string", isId: "uuid" },
|
|
15
|
+
title: { name: "Title", type: "string" },
|
|
16
|
+
views: { name: "Views", type: "number" },
|
|
17
|
+
status: {
|
|
18
|
+
name: "Status",
|
|
19
|
+
type: "string",
|
|
20
|
+
enum: [
|
|
21
|
+
{ id: "draft", label: "Draft" },
|
|
22
|
+
{ id: "published", label: "Published" }
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} as unknown as CollectionConfig;
|
|
27
|
+
|
|
28
|
+
const empty = (): ExistingSchema => ({ tables: new Map(), enums: new Set() });
|
|
29
|
+
|
|
30
|
+
const withTable = (key: string, columns: string[]): ExistingSchema => ({
|
|
31
|
+
tables: new Map([[key, new Set(columns)]]),
|
|
32
|
+
enums: new Set()
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("planning an additive schema ensure", () => {
|
|
36
|
+
it("creates a missing table, its enum type, and its columns in that order", () => {
|
|
37
|
+
const plan = planCollectionSchemaEnsure([posts], empty());
|
|
38
|
+
const kinds = plan.actions.map(a => a.kind);
|
|
39
|
+
|
|
40
|
+
// The enum must exist before the column that references it, and the
|
|
41
|
+
// table before its own columns.
|
|
42
|
+
expect(kinds.indexOf("create-enum")).toBeLessThan(kinds.indexOf("create-table"));
|
|
43
|
+
expect(kinds.indexOf("create-table")).toBeLessThan(kinds.indexOf("add-column"));
|
|
44
|
+
expect(plan.statements.join("\n")).toMatch(/CREATE TABLE IF NOT EXISTS "public"\."posts"/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("adds only the columns an existing table is missing", () => {
|
|
48
|
+
const plan = planCollectionSchemaEnsure([posts], withTable("public.posts", ["id", "title"]));
|
|
49
|
+
const added = plan.actions.filter(a => a.kind === "add-column").map(a => a.target);
|
|
50
|
+
|
|
51
|
+
expect(added).toContain("public.posts.views");
|
|
52
|
+
expect(added).toContain("public.posts.status");
|
|
53
|
+
expect(added).not.toContain("public.posts.title");
|
|
54
|
+
expect(plan.actions.some(a => a.kind === "create-table")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("is a no-op against a database that is already current", () => {
|
|
58
|
+
const existing: ExistingSchema = {
|
|
59
|
+
tables: new Map([["public.posts", new Set(["id", "title", "views", "status"])]]),
|
|
60
|
+
enums: new Set(["public.posts_status"])
|
|
61
|
+
};
|
|
62
|
+
expect(planCollectionSchemaEnsure([posts], existing).actions).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("skips an enum type that already exists, since CREATE TYPE has no IF NOT EXISTS", () => {
|
|
66
|
+
const existing: ExistingSchema = { tables: new Map(), enums: new Set(["public.posts_status"]) };
|
|
67
|
+
const plan = planCollectionSchemaEnsure([posts], existing);
|
|
68
|
+
expect(plan.actions.some(a => a.kind === "create-enum")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("NEVER emits a destructive statement, whatever the database contains", () => {
|
|
72
|
+
// The core safety property. This runs unattended against customer data
|
|
73
|
+
// with nobody reading a diff, so a column the collections no longer
|
|
74
|
+
// mention must be left alone, not dropped.
|
|
75
|
+
const existing = withTable("public.posts", ["id", "title", "legacy_column", "another_old_one"]);
|
|
76
|
+
const sql = planCollectionSchemaEnsure([posts], existing).statements.join("\n");
|
|
77
|
+
|
|
78
|
+
expect(sql).not.toMatch(/\bDROP\b/i);
|
|
79
|
+
expect(sql).not.toMatch(/\bTRUNCATE\b/i);
|
|
80
|
+
expect(sql).not.toMatch(/ALTER COLUMN/i);
|
|
81
|
+
expect(sql).not.toMatch(/legacy_column/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("never adds a NOT NULL column, which an existing table with rows could not take", () => {
|
|
85
|
+
const sql = planCollectionSchemaEnsure([posts], withTable("public.posts", ["id"]))
|
|
86
|
+
.statements.join("\n");
|
|
87
|
+
expect(sql).not.toMatch(/NOT NULL/i);
|
|
88
|
+
});
|
|
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 }
|
|
96
|
+
}
|
|
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);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("applying the plan", () => {
|
|
104
|
+
function fakeClient(): { client: Queryable; executed: string[] } {
|
|
105
|
+
const executed: string[] = [];
|
|
106
|
+
const client: Queryable = {
|
|
107
|
+
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
108
|
+
executed.push(sql);
|
|
109
|
+
return { rows: [] as T[] };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return { client, executed };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it("creates the schema, reads what exists, then applies", async () => {
|
|
116
|
+
const { client, executed } = fakeClient();
|
|
117
|
+
const plan = await ensureCollectionTables(client, [posts]);
|
|
118
|
+
|
|
119
|
+
expect(plan.actions.length).toBeGreaterThan(0);
|
|
120
|
+
expect(executed.some(s => s.includes("information_schema.columns"))).toBe(true);
|
|
121
|
+
expect(executed.some(s => s.includes("CREATE TABLE IF NOT EXISTS"))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("surfaces which statement failed rather than a bare driver error", async () => {
|
|
125
|
+
const client: Queryable = {
|
|
126
|
+
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
127
|
+
if (sql.startsWith("CREATE TABLE")) throw new Error("permission denied");
|
|
128
|
+
return { rows: [] as T[] };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/permission denied/);
|
|
132
|
+
await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does nothing when the database is already current", async () => {
|
|
136
|
+
const client: Queryable = {
|
|
137
|
+
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
138
|
+
if (sql.includes("information_schema.columns")) {
|
|
139
|
+
return {
|
|
140
|
+
rows: ["id", "title", "views", "status"].map(c => ({
|
|
141
|
+
table_schema: "public",
|
|
142
|
+
table_name: "posts",
|
|
143
|
+
column_name: c
|
|
144
|
+
})) as unknown as T[]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (sql.includes("pg_type")) {
|
|
148
|
+
return { rows: [{ schema: "public", name: "posts_status" }] as unknown as T[] };
|
|
149
|
+
}
|
|
150
|
+
return { rows: [] as T[] };
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const plan = await ensureCollectionTables(client, [posts]);
|
|
154
|
+
expect(plan.actions).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bringing a database up to date with a bundle's collections, additively.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A managed runtime boots someone else's compiled project against a database it
|
|
7
|
+
* has never seen. Auth tables are ensured at boot already, but collection tables
|
|
8
|
+
* were not created by anything: the platform ran the app and every `/api/data/*`
|
|
9
|
+
* request answered 500 on a missing relation. `rebase db push` cannot help — it
|
|
10
|
+
* is an Atlas-driven CLI command, and the runtime image ships no CLI.
|
|
11
|
+
*
|
|
12
|
+
* ## Why additive-only, forever
|
|
13
|
+
*
|
|
14
|
+
* This runs unattended, against a database with customers' data in it, with no
|
|
15
|
+
* human reading a diff. So it may only ever do things that cannot lose data:
|
|
16
|
+
* create a missing table, add a missing column, create a missing enum type.
|
|
17
|
+
*
|
|
18
|
+
* It will **never** drop a table or a column, narrow a type, or alter a
|
|
19
|
+
* constraint. A removed field leaves its column behind; a renamed field looks
|
|
20
|
+
* like an addition and the old column stays. That is the correct trade for an
|
|
21
|
+
* automated path — the alternative is an unattended process that can silently
|
|
22
|
+
* destroy a column, which is precisely the failure `db push` was hardened
|
|
23
|
+
* against. Destructive changes stay a deliberate, human-reviewed migration.
|
|
24
|
+
*
|
|
25
|
+
* Because of that, this is safe to run on every boot, and re-running it is a
|
|
26
|
+
* no-op.
|
|
27
|
+
*/
|
|
28
|
+
import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
29
|
+
import { getTableName } from "@rebasepro/common";
|
|
30
|
+
import {
|
|
31
|
+
getSqlColumnType,
|
|
32
|
+
resolveColumnName,
|
|
33
|
+
isIdProperty
|
|
34
|
+
} from "./generate-postgres-ddl-logic";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The subset of a database handle this needs: run a statement, get rows back.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately parameterless. Everything here is DDL or catalogue reads keyed by
|
|
40
|
+
* schema name, and schema names are identifiers — they cannot be bound as
|
|
41
|
+
* parameters anyway. They are validated against {@link SAFE_IDENTIFIER} before
|
|
42
|
+
* they reach a statement, so a config that somehow carried a quote is refused
|
|
43
|
+
* rather than concatenated.
|
|
44
|
+
*/
|
|
45
|
+
export interface Queryable {
|
|
46
|
+
query<T = unknown>(sql: string): Promise<{ rows: T[] }>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Postgres identifiers this module is willing to interpolate. */
|
|
50
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
51
|
+
|
|
52
|
+
function assertSafeIdentifier(value: string, what: string): string {
|
|
53
|
+
if (!SAFE_IDENTIFIER.test(value)) {
|
|
54
|
+
throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the database currently has, as the planner needs it. */
|
|
60
|
+
export interface ExistingSchema {
|
|
61
|
+
/** `schema.table` → set of column names. */
|
|
62
|
+
tables: Map<string, Set<string>>;
|
|
63
|
+
/** `schema.typename` of every enum type that already exists. */
|
|
64
|
+
enums: Set<string>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EnsureAction {
|
|
68
|
+
kind: "create-enum" | "create-table" | "add-column";
|
|
69
|
+
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
70
|
+
target: string;
|
|
71
|
+
sql: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EnsurePlan {
|
|
75
|
+
actions: EnsureAction[];
|
|
76
|
+
/** Every statement, in dependency order. Empty when the schema is current. */
|
|
77
|
+
statements: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function schemaOf(collection: CollectionConfig): string {
|
|
81
|
+
return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function qualified(collection: CollectionConfig): string {
|
|
85
|
+
return `${schemaOf(collection)}.${getTableName(collection)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Enum types a collection's properties require, as `schema.typename`.
|
|
90
|
+
*
|
|
91
|
+
* Named exactly as the DDL generator names them (`<table>_<column>`), because
|
|
92
|
+
* a column added here has to reference the same type the generator would have
|
|
93
|
+
* created — a second, differently-named type for the same field would be a
|
|
94
|
+
* silent schema fork.
|
|
95
|
+
*/
|
|
96
|
+
function requiredEnums(collection: CollectionConfig): { name: string; values: string[] }[] {
|
|
97
|
+
const table = getTableName(collection);
|
|
98
|
+
const schema = schemaOf(collection);
|
|
99
|
+
const out: { name: string; values: string[] }[] = [];
|
|
100
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
101
|
+
const p = prop as Property;
|
|
102
|
+
if (!("enum" in p) || !p.enum) continue;
|
|
103
|
+
if (p.type !== "string" && p.type !== "number") continue;
|
|
104
|
+
const values = (p.enum as unknown[])
|
|
105
|
+
.map(entry =>
|
|
106
|
+
entry && typeof entry === "object" && "id" in (entry as Record<string, unknown>)
|
|
107
|
+
? String((entry as Record<string, unknown>).id)
|
|
108
|
+
: String(entry)
|
|
109
|
+
)
|
|
110
|
+
.filter(v => v.length > 0);
|
|
111
|
+
if (values.length === 0) continue;
|
|
112
|
+
out.push({ name: `${schema}.${table}_${resolveColumnName(propName, p)}`, values });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Single-quote escaping for an enum label. */
|
|
118
|
+
function quoteLiteral(value: string): string {
|
|
119
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decide what to add. Pure — the caller supplies what exists and runs the result.
|
|
124
|
+
*
|
|
125
|
+
* Ordering matters and is deliberate: enum types before the tables and columns
|
|
126
|
+
* that reference them, tables before the columns added to other tables (a new
|
|
127
|
+
* table may be the target of a relation), and nothing is emitted twice.
|
|
128
|
+
*/
|
|
129
|
+
export function planCollectionSchemaEnsure(
|
|
130
|
+
collections: CollectionConfig[],
|
|
131
|
+
existing: ExistingSchema
|
|
132
|
+
): EnsurePlan {
|
|
133
|
+
const actions: EnsureAction[] = [];
|
|
134
|
+
const plannedEnums = new Set<string>();
|
|
135
|
+
|
|
136
|
+
// 1. Enum types. `CREATE TYPE` has no IF NOT EXISTS, so an existing type is
|
|
137
|
+
// skipped by name rather than guarded in SQL.
|
|
138
|
+
for (const collection of collections) {
|
|
139
|
+
for (const { name, values } of requiredEnums(collection)) {
|
|
140
|
+
if (existing.enums.has(name) || plannedEnums.has(name)) continue;
|
|
141
|
+
plannedEnums.add(name);
|
|
142
|
+
const [schema, typeName] = name.split(".");
|
|
143
|
+
actions.push({
|
|
144
|
+
kind: "create-enum",
|
|
145
|
+
target: name,
|
|
146
|
+
sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteLiteral).join(", ")});`
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Missing tables. Only the identity column is created here; every other
|
|
152
|
+
// column is added by step 3, so a new table and an existing table that
|
|
153
|
+
// gained a field travel the exact same code path. One way to build a
|
|
154
|
+
// column means one way for it to be wrong.
|
|
155
|
+
const created = new Set<string>();
|
|
156
|
+
for (const collection of collections) {
|
|
157
|
+
const key = qualified(collection);
|
|
158
|
+
if (existing.tables.has(key) || created.has(key)) continue;
|
|
159
|
+
created.add(key);
|
|
160
|
+
const schema = schemaOf(collection);
|
|
161
|
+
const table = getTableName(collection);
|
|
162
|
+
const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) =>
|
|
163
|
+
isIdProperty(n, p as Property, collection)
|
|
164
|
+
);
|
|
165
|
+
const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
|
|
166
|
+
const idProp = idEntry?.[1] as Property | undefined;
|
|
167
|
+
let idDef: string;
|
|
168
|
+
if (idProp?.type === "number") {
|
|
169
|
+
idDef = `"${idName}" BIGSERIAL PRIMARY KEY`;
|
|
170
|
+
} else if (
|
|
171
|
+
idProp &&
|
|
172
|
+
idProp.type === "string" &&
|
|
173
|
+
(idProp as { isId?: unknown }).isId === "uuid"
|
|
174
|
+
) {
|
|
175
|
+
idDef = `"${idName}" UUID PRIMARY KEY DEFAULT gen_random_uuid()`;
|
|
176
|
+
} else {
|
|
177
|
+
idDef = `"${idName}" TEXT PRIMARY KEY`;
|
|
178
|
+
}
|
|
179
|
+
actions.push({
|
|
180
|
+
kind: "create-table",
|
|
181
|
+
target: key,
|
|
182
|
+
sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. Missing columns, on both brand-new and pre-existing tables.
|
|
187
|
+
for (const collection of collections) {
|
|
188
|
+
const key = qualified(collection);
|
|
189
|
+
const schema = schemaOf(collection);
|
|
190
|
+
const table = getTableName(collection);
|
|
191
|
+
const present = existing.tables.get(key) ?? new Set<string>();
|
|
192
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
193
|
+
const p = prop as Property;
|
|
194
|
+
if (isIdProperty(propName, p, collection)) continue;
|
|
195
|
+
// A relation's own column is emitted by the DDL generator with a
|
|
196
|
+
// foreign key; adding a bare column here would create the column
|
|
197
|
+
// without the constraint and make the generator's later output
|
|
198
|
+
// disagree with the database. Left to a real migration.
|
|
199
|
+
if (p.type === "reference" || p.type === "relation") continue;
|
|
200
|
+
const column = resolveColumnName(propName, p);
|
|
201
|
+
if (present.has(column)) continue;
|
|
202
|
+
const type = getSqlColumnType(propName, p, collection, collections);
|
|
203
|
+
actions.push({
|
|
204
|
+
kind: "add-column",
|
|
205
|
+
target: `${key}.${column}`,
|
|
206
|
+
// Never NOT NULL: an existing table with rows cannot take a
|
|
207
|
+
// non-null column without a default, and inventing one would be
|
|
208
|
+
// guessing at the customer's data.
|
|
209
|
+
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { actions, statements: actions.map(a => a.sql) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Read what the database has, for the schemas the collections live in. */
|
|
218
|
+
export async function readExistingSchema(
|
|
219
|
+
client: Queryable,
|
|
220
|
+
schemas: string[]
|
|
221
|
+
): Promise<ExistingSchema> {
|
|
222
|
+
const tables = new Map<string, Set<string>>();
|
|
223
|
+
const enums = new Set<string>();
|
|
224
|
+
if (schemas.length === 0) return { tables, enums };
|
|
225
|
+
|
|
226
|
+
const inList = schemas
|
|
227
|
+
.map(schema => `'${assertSafeIdentifier(schema, "schema name")}'`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
|
|
230
|
+
const { rows: columns } = await client.query<{
|
|
231
|
+
table_schema: string;
|
|
232
|
+
table_name: string;
|
|
233
|
+
column_name: string;
|
|
234
|
+
}>(
|
|
235
|
+
`SELECT table_schema, table_name, column_name
|
|
236
|
+
FROM information_schema.columns
|
|
237
|
+
WHERE table_schema IN (${inList})`
|
|
238
|
+
);
|
|
239
|
+
for (const row of columns) {
|
|
240
|
+
const key = `${row.table_schema}.${row.table_name}`;
|
|
241
|
+
if (!tables.has(key)) tables.set(key, new Set());
|
|
242
|
+
tables.get(key)!.add(row.column_name);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { rows: enumRows } = await client.query<{ schema: string; name: string }>(
|
|
246
|
+
`SELECT n.nspname AS schema, t.typname AS name
|
|
247
|
+
FROM pg_type t
|
|
248
|
+
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
249
|
+
WHERE t.typtype = 'e' AND n.nspname IN (${inList})`
|
|
250
|
+
);
|
|
251
|
+
for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
|
|
252
|
+
|
|
253
|
+
return { tables, enums };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Bring the database up to date. Returns what it did.
|
|
258
|
+
*
|
|
259
|
+
* Each statement runs on its own rather than in one transaction: they are all
|
|
260
|
+
* independently safe and idempotent, and a single failure (an enum label that
|
|
261
|
+
* cannot be added, say) should not roll back the tables that were created fine.
|
|
262
|
+
* The error is surfaced with the statement that caused it.
|
|
263
|
+
*/
|
|
264
|
+
export async function ensureCollectionTables(
|
|
265
|
+
client: Queryable,
|
|
266
|
+
collections: CollectionConfig[],
|
|
267
|
+
log?: (message: string) => void
|
|
268
|
+
): Promise<EnsurePlan> {
|
|
269
|
+
const schemas = Array.from(new Set(collections.map(schemaOf)));
|
|
270
|
+
for (const schema of schemas) {
|
|
271
|
+
assertSafeIdentifier(schema, "schema name");
|
|
272
|
+
if (schema !== "public") {
|
|
273
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const existing = await readExistingSchema(client, schemas);
|
|
278
|
+
const plan = planCollectionSchemaEnsure(collections, existing);
|
|
279
|
+
|
|
280
|
+
if (plan.actions.length === 0) {
|
|
281
|
+
log?.("Schema is up to date; nothing to create.");
|
|
282
|
+
return plan;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const action of plan.actions) {
|
|
286
|
+
try {
|
|
287
|
+
await client.query(action.sql);
|
|
288
|
+
log?.(`${action.kind}: ${action.target}`);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Failed to ${action.kind} ${action.target}: ` +
|
|
292
|
+
`${err instanceof Error ? err.message : String(err)}\n ${action.sql}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return plan;
|
|
297
|
+
}
|
|
@@ -4,7 +4,7 @@ import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
|
4
4
|
|
|
5
5
|
// --- Helper Functions ---
|
|
6
6
|
|
|
7
|
-
const resolveColumnName = (propName: string, prop?: Property | null): string => {
|
|
7
|
+
export const resolveColumnName = (propName: string, prop?: Property | null): string => {
|
|
8
8
|
if (prop && "columnName" in prop && typeof prop.columnName === "string") {
|
|
9
9
|
return prop.columnName;
|
|
10
10
|
}
|
|
@@ -36,7 +36,7 @@ const getPrimaryKeyName = (collection: CollectionConfig): string => {
|
|
|
36
36
|
return getPrimaryKeyProp(collection).name;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
|
|
39
|
+
export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
|
|
40
40
|
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
41
41
|
const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
|
|
42
42
|
return !hasExplicitId && propName === "id";
|
|
@@ -90,7 +90,7 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
|
|
|
90
90
|
return `${ddl};\n`;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
-
const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
93
|
+
export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
94
94
|
switch (prop.type) {
|
|
95
95
|
case "string": {
|
|
96
96
|
const stringProp = prop as StringProperty;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Client as PgClient } from "pg";
|
|
2
1
|
import { logger } from "@rebasepro/server";
|
|
3
2
|
import { CDC_CHANNEL } from "./trigger-cdc";
|
|
3
|
+
import { PgNotifyListener } from "../pg-notify-listener";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* A single database change captured by the CDC triggers and delivered over the
|
|
@@ -54,22 +54,30 @@ export function parseCdcPayload(payload: string): CdcChangeEvent | null {
|
|
|
54
54
|
/**
|
|
55
55
|
* Dedicated Postgres LISTEN client for database-level CDC.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
57
|
+
* A {@link PgNotifyListener} — a connection outside the Drizzle pool that stays
|
|
58
|
+
* open and repairs itself — plus the parsing that turns a `rebase_cdc` payload
|
|
59
|
+
* into a change event. Each backend instance runs one, so every instance
|
|
60
|
+
* observes every committed change regardless of which instance (or external
|
|
61
|
+
* process) made the write.
|
|
62
62
|
*/
|
|
63
63
|
export class CdcListener {
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
private readonly listener: PgNotifyListener;
|
|
65
|
+
|
|
66
|
+
constructor(connectionString: string, onEvent: (event: CdcChangeEvent) => void | Promise<void>) {
|
|
67
|
+
this.listener = new PgNotifyListener({
|
|
68
|
+
connectionString,
|
|
69
|
+
channel: CDC_CHANNEL,
|
|
70
|
+
logLabel: "[CDC]",
|
|
71
|
+
onPayload: (payload) => {
|
|
72
|
+
const event = parseCdcPayload(payload);
|
|
73
|
+
if (!event) {
|
|
74
|
+
logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
return onEvent(event);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
73
81
|
|
|
74
82
|
/**
|
|
75
83
|
* Connect and begin listening. Idempotent.
|
|
@@ -78,90 +86,18 @@ export class CdcListener {
|
|
|
78
86
|
* established (or `LISTEN` is refused), this rejects so callers — notably
|
|
79
87
|
* `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
|
|
80
88
|
* app-level realtime. Once the initial connection succeeds, later drops
|
|
81
|
-
* self-heal
|
|
89
|
+
* self-heal in the background.
|
|
82
90
|
*/
|
|
83
91
|
async start(): Promise<void> {
|
|
84
|
-
if (this.
|
|
92
|
+
if (this.listener.active) {
|
|
85
93
|
logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
88
|
-
this.
|
|
89
|
-
try {
|
|
90
|
-
await this.connect({ initial: true });
|
|
91
|
-
} catch (err) {
|
|
92
|
-
this.running = false;
|
|
93
|
-
throw err;
|
|
94
|
-
}
|
|
96
|
+
await this.listener.start();
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/** Stop listening and release the connection. */
|
|
98
100
|
async stop(): Promise<void> {
|
|
99
|
-
this.
|
|
100
|
-
if (this.reconnectTimer) {
|
|
101
|
-
clearTimeout(this.reconnectTimer);
|
|
102
|
-
this.reconnectTimer = undefined;
|
|
103
|
-
}
|
|
104
|
-
if (this.client) {
|
|
105
|
-
try {
|
|
106
|
-
await this.client.end();
|
|
107
|
-
} catch { /* ignore close errors */ }
|
|
108
|
-
this.client = undefined;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
|
|
113
|
-
try {
|
|
114
|
-
const client = new PgClient({ connectionString: this.connectionString });
|
|
115
|
-
|
|
116
|
-
client.on("error", (err) => {
|
|
117
|
-
logger.error("❌ [CDC] LISTEN client error", { detail: err.message });
|
|
118
|
-
this.scheduleReconnect();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
client.on("end", () => {
|
|
122
|
-
if (this.running) {
|
|
123
|
-
logger.warn("⚠️ [CDC] LISTEN client disconnected unexpectedly.");
|
|
124
|
-
this.scheduleReconnect();
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
client.on("notification", (msg) => {
|
|
129
|
-
if (!msg.payload) return;
|
|
130
|
-
const event = parseCdcPayload(msg.payload);
|
|
131
|
-
if (!event) {
|
|
132
|
-
logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
// Never let a handler rejection escape into the pg client.
|
|
136
|
-
Promise.resolve(this.onEvent(event)).catch((err) =>
|
|
137
|
-
logger.error("❌ [CDC] Error handling change event", { error: err })
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
await client.connect();
|
|
142
|
-
await client.query(`LISTEN ${CDC_CHANNEL}`);
|
|
143
|
-
this.client = client;
|
|
144
|
-
logger.info(`📡 [CDC] Listening for database changes on channel "${CDC_CHANNEL}".`);
|
|
145
|
-
} catch (err) {
|
|
146
|
-
// Surface the initial failure so callers can choose to fall back;
|
|
147
|
-
// for reconnects, keep retrying quietly in the background.
|
|
148
|
-
if (initial) throw err;
|
|
149
|
-
logger.error("❌ [CDC] Failed to connect LISTEN client", { error: err });
|
|
150
|
-
this.scheduleReconnect();
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private scheduleReconnect(): void {
|
|
155
|
-
if (!this.running || this.reconnectTimer) return;
|
|
156
|
-
|
|
157
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
158
|
-
this.reconnectTimer = undefined;
|
|
159
|
-
if (!this.running) return;
|
|
160
|
-
if (this.client) {
|
|
161
|
-
try { await this.client.end(); } catch { /* ignore */ }
|
|
162
|
-
this.client = undefined;
|
|
163
|
-
}
|
|
164
|
-
await this.connect();
|
|
165
|
-
}, CdcListener.RECONNECT_DELAY_MS);
|
|
101
|
+
await this.listener.stop();
|
|
166
102
|
}
|
|
167
103
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime pieces of the channel bus that are not the contract itself.
|
|
3
|
+
*
|
|
4
|
+
* The interface, the frame shape and the implementer's contract live in
|
|
5
|
+
* `@rebasepro/types` (`types/channel_bus.ts`), so a transport shipped as its own
|
|
6
|
+
* package — a Redis one, say — depends on the contract and not on this database
|
|
7
|
+
* adapter. They are re-exported here for convenience: code already importing
|
|
8
|
+
* from the adapter should not have to know where the types are declared.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChannelBusFrame } from "@rebasepro/types";
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
ChannelBus,
|
|
15
|
+
ChannelBusFrame,
|
|
16
|
+
ChannelBusHandler,
|
|
17
|
+
ChannelBusConfig,
|
|
18
|
+
ChannelBusSetting
|
|
19
|
+
} from "@rebasepro/types";
|
|
20
|
+
export { isChannelBusInstance } from "@rebasepro/types";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The default: no cross-instance delivery at all.
|
|
24
|
+
*
|
|
25
|
+
* This is what every deployment ran before the bus existed, and what a
|
|
26
|
+
* single-instance deployment should keep running — `publish` resolves without
|
|
27
|
+
* touching the network, so the broadcast path is the same handful of `ws.send`
|
|
28
|
+
* calls it always was.
|
|
29
|
+
*/
|
|
30
|
+
export class MemoryChannelBus {
|
|
31
|
+
readonly kind = "memory" as const;
|
|
32
|
+
readonly maxFrameBytes = Infinity;
|
|
33
|
+
|
|
34
|
+
async start(): Promise<void> { /* nothing to connect */ }
|
|
35
|
+
|
|
36
|
+
async publish(): Promise<void> { /* nowhere to publish to */ }
|
|
37
|
+
|
|
38
|
+
async stop(): Promise<void> { /* nothing to release */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Encoded size of a frame, for a transport's size check. */
|
|
42
|
+
export function frameByteLength(frame: ChannelBusFrame): number {
|
|
43
|
+
return Buffer.byteLength(JSON.stringify(frame), "utf8");
|
|
44
|
+
}
|