@rebasepro/server-postgres 0.0.1-canary.4829d6e
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/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +640 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Client as PgClient } from "pg";
|
|
2
|
+
import { logger } from "@rebasepro/server";
|
|
3
|
+
import { CDC_CHANNEL } from "./trigger-cdc";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single database change captured by the CDC triggers and delivered over the
|
|
7
|
+
* `rebase_cdc` NOTIFY channel.
|
|
8
|
+
*/
|
|
9
|
+
export interface CdcChangeEvent {
|
|
10
|
+
schema: string;
|
|
11
|
+
table: string;
|
|
12
|
+
op: "INSERT" | "UPDATE" | "DELETE";
|
|
13
|
+
/**
|
|
14
|
+
* The changed tuple (NEW for insert/update, OLD for delete). May be a
|
|
15
|
+
* partial identity-only object when the full row overflowed the pg_notify
|
|
16
|
+
* size cap — see {@link truncated}.
|
|
17
|
+
*/
|
|
18
|
+
row: Record<string, unknown>;
|
|
19
|
+
/** True when the row was reduced to its identity because it was too large to notify. */
|
|
20
|
+
truncated?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a `rebase_cdc` NOTIFY payload. Returns `null` for anything malformed so
|
|
25
|
+
* a single bad message can never crash the listener.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCdcPayload(payload: string): CdcChangeEvent | null {
|
|
28
|
+
let parsed: unknown;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(payload);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
35
|
+
|
|
36
|
+
const obj = parsed as Record<string, unknown>;
|
|
37
|
+
const schema = typeof obj.schema === "string" ? obj.schema : undefined;
|
|
38
|
+
const table = typeof obj.table === "string" ? obj.table : undefined;
|
|
39
|
+
const op = obj.op;
|
|
40
|
+
if (!schema || !table) return null;
|
|
41
|
+
if (op !== "INSERT" && op !== "UPDATE" && op !== "DELETE") return null;
|
|
42
|
+
|
|
43
|
+
const row = obj.row && typeof obj.row === "object" ? (obj.row as Record<string, unknown>) : {};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
schema,
|
|
47
|
+
table,
|
|
48
|
+
op,
|
|
49
|
+
row,
|
|
50
|
+
truncated: obj.truncated === true
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dedicated Postgres LISTEN client for database-level CDC.
|
|
56
|
+
*
|
|
57
|
+
* Mirrors the resilience of the RealtimeService cross-instance LISTEN client: a
|
|
58
|
+
* standalone `pg.Client` (outside the Drizzle pool) that stays connected, and
|
|
59
|
+
* transparently reconnects on error/disconnect. Each backend instance runs one,
|
|
60
|
+
* so every instance observes every committed change regardless of which
|
|
61
|
+
* instance (or external process) made the write.
|
|
62
|
+
*/
|
|
63
|
+
export class CdcListener {
|
|
64
|
+
private client?: PgClient;
|
|
65
|
+
private running = false;
|
|
66
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
67
|
+
private static readonly RECONNECT_DELAY_MS = 3000;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
private readonly connectionString: string,
|
|
71
|
+
private readonly onEvent: (event: CdcChangeEvent) => void | Promise<void>
|
|
72
|
+
) {}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Connect and begin listening. Idempotent.
|
|
76
|
+
*
|
|
77
|
+
* The **initial** connection is validated synchronously: if it cannot be
|
|
78
|
+
* established (or `LISTEN` is refused), this rejects so callers — notably
|
|
79
|
+
* `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
|
|
80
|
+
* app-level realtime. Once the initial connection succeeds, later drops
|
|
81
|
+
* self-heal via {@link scheduleReconnect}.
|
|
82
|
+
*/
|
|
83
|
+
async start(): Promise<void> {
|
|
84
|
+
if (this.running) {
|
|
85
|
+
logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.running = true;
|
|
89
|
+
try {
|
|
90
|
+
await this.connect({ initial: true });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
this.running = false;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Stop listening and release the connection. */
|
|
98
|
+
async stop(): Promise<void> {
|
|
99
|
+
this.running = false;
|
|
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);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { logger } from "@rebasepro/server";
|
|
2
|
+
import type { RawSqlRunner } from "../../security/rls-enforcement";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Trigger-based Change Data Capture (CDC).
|
|
6
|
+
*
|
|
7
|
+
* The preferred CDC source is the write-ahead log (logical replication), which
|
|
8
|
+
* — like Supabase Realtime — sees *every* commit regardless of how it was made.
|
|
9
|
+
* When logical replication is unavailable (managed Postgres without
|
|
10
|
+
* `wal_level=logical`, no replication privilege, no `REPLICA IDENTITY`), this
|
|
11
|
+
* trigger-based fallback provides the same guarantee at the row level:
|
|
12
|
+
*
|
|
13
|
+
* AFTER INSERT/UPDATE/DELETE trigger → pg_notify('rebase_cdc', payload)
|
|
14
|
+
*
|
|
15
|
+
* A single dedicated LISTEN client per backend instance consumes the channel
|
|
16
|
+
* (see {@link CdcListener}) and feeds the change into the existing
|
|
17
|
+
* `RealtimeService.notifyUpdate` pipeline, so subscribers see the change no
|
|
18
|
+
* matter what wrote it — psql, a cron in another service, raw Drizzle/SQL, or
|
|
19
|
+
* the Studio SQL editor.
|
|
20
|
+
*
|
|
21
|
+
* Provisioning runs from the framework's own bootstrap as the owner (server)
|
|
22
|
+
* context, alongside the RLS role provisioning. It is idempotent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Postgres NOTIFY channel carrying database-level change events. */
|
|
26
|
+
export const CDC_CHANNEL = "rebase_cdc";
|
|
27
|
+
|
|
28
|
+
/** Schema-qualified name of the generic trigger function. */
|
|
29
|
+
export const CDC_TRIGGER_FUNCTION = "rebase.rebase_cdc_notify";
|
|
30
|
+
|
|
31
|
+
/** Name of the per-table trigger (unqualified — triggers are namespaced by table). */
|
|
32
|
+
export const CDC_TRIGGER_NAME = "rebase_cdc_trigger";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* pg_notify hard-caps payloads at 8000 bytes and *aborts the triggering
|
|
36
|
+
* statement* if the limit is exceeded. We stay comfortably under it and, for
|
|
37
|
+
* wide rows, fall back to an identity-only payload so CDC can never break a
|
|
38
|
+
* write. 7900 leaves headroom for the JSON envelope keys.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_NOTIFY_BYTES = 7900;
|
|
41
|
+
|
|
42
|
+
const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
|
|
43
|
+
const quoteLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* SQL that (re)creates the generic CDC trigger function. Safe to run repeatedly:
|
|
47
|
+
* `CREATE OR REPLACE` updates in place without dropping dependent triggers.
|
|
48
|
+
*
|
|
49
|
+
* The function emits `{ schema, table, op, row }`. The `row` is the full changed
|
|
50
|
+
* tuple (NEW for insert/update, OLD for delete) so the consumer can route it to
|
|
51
|
+
* a collection and extract the primary key. It is *not* trusted for delivery:
|
|
52
|
+
* the consumer marks the row invalidated and each subscriber re-reads it under
|
|
53
|
+
* its own RLS context, so a subscriber never receives a row it cannot read.
|
|
54
|
+
*/
|
|
55
|
+
export function buildCdcFunctionSql(): string {
|
|
56
|
+
return `
|
|
57
|
+
CREATE SCHEMA IF NOT EXISTS rebase;
|
|
58
|
+
|
|
59
|
+
CREATE OR REPLACE FUNCTION ${CDC_TRIGGER_FUNCTION}() RETURNS trigger
|
|
60
|
+
LANGUAGE plpgsql AS $rebase_cdc$
|
|
61
|
+
DECLARE
|
|
62
|
+
rec jsonb;
|
|
63
|
+
payload text;
|
|
64
|
+
BEGIN
|
|
65
|
+
IF (TG_OP = 'DELETE') THEN
|
|
66
|
+
rec := to_jsonb(OLD);
|
|
67
|
+
ELSE
|
|
68
|
+
rec := to_jsonb(NEW);
|
|
69
|
+
END IF;
|
|
70
|
+
|
|
71
|
+
payload := json_build_object(
|
|
72
|
+
'schema', TG_TABLE_SCHEMA,
|
|
73
|
+
'table', TG_TABLE_NAME,
|
|
74
|
+
'op', TG_OP,
|
|
75
|
+
'row', rec
|
|
76
|
+
)::text;
|
|
77
|
+
|
|
78
|
+
-- Never let CDC abort the write: if the full row overflows the pg_notify
|
|
79
|
+
-- 8000-byte cap, emit an identity-only payload the consumer can still route
|
|
80
|
+
-- (and refetch the authoritative row from).
|
|
81
|
+
IF (octet_length(payload) > ${MAX_NOTIFY_BYTES}) THEN
|
|
82
|
+
payload := json_build_object(
|
|
83
|
+
'schema', TG_TABLE_SCHEMA,
|
|
84
|
+
'table', TG_TABLE_NAME,
|
|
85
|
+
'op', TG_OP,
|
|
86
|
+
'row', CASE WHEN rec ? 'id' THEN jsonb_build_object('id', rec->'id') ELSE '{}'::jsonb END,
|
|
87
|
+
'truncated', true
|
|
88
|
+
)::text;
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
PERFORM pg_notify(${quoteLiteral(CDC_CHANNEL)}, payload);
|
|
92
|
+
RETURN NULL;
|
|
93
|
+
END;
|
|
94
|
+
$rebase_cdc$;
|
|
95
|
+
`.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* SQL that (re)attaches the CDC trigger to a single table. `DROP ... IF EXISTS`
|
|
100
|
+
* before `CREATE` keeps it idempotent and picks up any function signature change.
|
|
101
|
+
*/
|
|
102
|
+
export function buildCdcTriggerSql(schema: string, table: string): string {
|
|
103
|
+
const qualified = `${quoteIdent(schema)}.${quoteIdent(table)}`;
|
|
104
|
+
return (
|
|
105
|
+
`DROP TRIGGER IF EXISTS ${quoteIdent(CDC_TRIGGER_NAME)} ON ${qualified};\n` +
|
|
106
|
+
`CREATE TRIGGER ${quoteIdent(CDC_TRIGGER_NAME)} ` +
|
|
107
|
+
`AFTER INSERT OR UPDATE OR DELETE ON ${qualified} ` +
|
|
108
|
+
`FOR EACH ROW EXECUTE FUNCTION ${CDC_TRIGGER_FUNCTION}();`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CdcTableRef {
|
|
113
|
+
schema: string;
|
|
114
|
+
table: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ProvisionResult {
|
|
118
|
+
/** Tables the trigger was successfully attached to. */
|
|
119
|
+
installed: CdcTableRef[];
|
|
120
|
+
/** Tables that could not be provisioned (e.g. not yet migrated), with the error. */
|
|
121
|
+
skipped: Array<CdcTableRef & { reason: string }>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Idempotently install the CDC trigger function and per-table triggers.
|
|
126
|
+
*
|
|
127
|
+
* Runs as the owner (server) connection at bootstrap. A table that does not yet
|
|
128
|
+
* exist in the database (schema drift) is skipped with a warning rather than
|
|
129
|
+
* aborting the whole install, so one un-migrated collection cannot disable CDC
|
|
130
|
+
* for the rest.
|
|
131
|
+
*/
|
|
132
|
+
export async function provisionTriggerCdc(
|
|
133
|
+
run: RawSqlRunner,
|
|
134
|
+
tables: CdcTableRef[]
|
|
135
|
+
): Promise<ProvisionResult> {
|
|
136
|
+
// 1. The shared trigger function (once).
|
|
137
|
+
await run(buildCdcFunctionSql());
|
|
138
|
+
|
|
139
|
+
// 2. One trigger per managed table. De-duplicate identical refs.
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const installed: CdcTableRef[] = [];
|
|
142
|
+
const skipped: ProvisionResult["skipped"] = [];
|
|
143
|
+
|
|
144
|
+
for (const ref of tables) {
|
|
145
|
+
const key = `${ref.schema}.${ref.table}`;
|
|
146
|
+
if (seen.has(key)) continue;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await run(buildCdcTriggerSql(ref.schema, ref.table));
|
|
151
|
+
installed.push(ref);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
154
|
+
skipped.push({ ...ref, reason });
|
|
155
|
+
logger.warn(
|
|
156
|
+
`⚠️ [CDC] Could not attach change-capture trigger to "${key}" — ` +
|
|
157
|
+
`is the table migrated? Writes to it won't emit database-level events.`,
|
|
158
|
+
{ detail: reason }
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
logger.info(
|
|
164
|
+
`📡 [CDC] Trigger-based change capture provisioned on ${installed.length} table(s)` +
|
|
165
|
+
(skipped.length ? ` (${skipped.length} skipped)` : "") + "."
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return { installed, skipped };
|
|
169
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
|
+
import { CollectionConfig, Property } from "@rebasepro/types";
|
|
3
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
|
+
import { getTableName } from "@rebasepro/common";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared helper functions for row operations.
|
|
8
|
+
* These are used by FetchService, PersistService, and RelationService.
|
|
9
|
+
*
|
|
10
|
+
* All functions that need collection/table lookups require an explicit
|
|
11
|
+
* `PostgresCollectionRegistry` instance — there is no global singleton.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Interface for Drizzle column metadata introspection.
|
|
16
|
+
* Replaces unsafe `as Record<string, unknown>` double-cast chains.
|
|
17
|
+
*/
|
|
18
|
+
export interface DrizzleColumnMeta {
|
|
19
|
+
columnType?: string;
|
|
20
|
+
dataType?: string;
|
|
21
|
+
primary?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Safely extract Drizzle column metadata from a column object. */
|
|
25
|
+
export function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta {
|
|
26
|
+
const raw = col as unknown as Record<string | symbol, unknown>;
|
|
27
|
+
return {
|
|
28
|
+
columnType: typeof raw.columnType === "string" ? raw.columnType : undefined,
|
|
29
|
+
dataType: typeof raw.dataType === "string" ? raw.dataType : undefined,
|
|
30
|
+
primary: typeof raw.primary === "boolean" ? raw.primary : undefined
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig {
|
|
35
|
+
const collection = registry.getCollectionByPath(collectionPath);
|
|
36
|
+
if (!collection) {
|
|
37
|
+
const registered = registry.getCollections().map(c => c.slug).join(", ");
|
|
38
|
+
throw new Error(`Collection not found: ${collectionPath}. Registered collections: [${registered}]`);
|
|
39
|
+
}
|
|
40
|
+
return collection;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any> {
|
|
44
|
+
const tableName = getTableName(collection);
|
|
45
|
+
const table = registry.getTable(tableName);
|
|
46
|
+
if (!table) {
|
|
47
|
+
throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
|
|
48
|
+
}
|
|
49
|
+
return table;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] {
|
|
53
|
+
const table = getTableForCollection(collection, registry);
|
|
54
|
+
|
|
55
|
+
// Fallback to explicitly defined isId properties
|
|
56
|
+
if (collection.properties) {
|
|
57
|
+
const idProps = Object.entries(collection.properties)
|
|
58
|
+
.filter(([_, prop]) => "isId" in (prop as object) && Boolean((prop as { isId?: unknown }).isId))
|
|
59
|
+
.map(([key, prop]) => ({
|
|
60
|
+
fieldName: key,
|
|
61
|
+
type: prop.type === "number" ? "number" as const : "string" as const,
|
|
62
|
+
isUUID: (prop as { isId?: unknown }).isId === "uuid"
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
if (idProps.length > 0) {
|
|
66
|
+
return idProps;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Otherwise infer from Drizzle schema
|
|
71
|
+
const keys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] = [];
|
|
72
|
+
for (const [key, colRaw] of Object.entries(table)) {
|
|
73
|
+
const col = colRaw as AnyPgColumn;
|
|
74
|
+
if (col && typeof col === "object" && "primary" in col && col.primary) {
|
|
75
|
+
const meta = getColumnMeta(col);
|
|
76
|
+
const type = col.dataType === "number" || meta.columnType === "PgSerial" || meta.columnType === "PgInteger" ? "number" : "string";
|
|
77
|
+
const isUUID = meta.columnType === "PgUUID";
|
|
78
|
+
keys.push({ fieldName: key,
|
|
79
|
+
type,
|
|
80
|
+
isUUID });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Default to 'id' if no primary keys are found and it exists in the schema
|
|
85
|
+
// This maintains backwards compatibility
|
|
86
|
+
if (keys.length === 0 && "id" in table) {
|
|
87
|
+
const idCol = table["id" as keyof typeof table] as AnyPgColumn;
|
|
88
|
+
const idMeta = getColumnMeta(idCol);
|
|
89
|
+
const type = idCol.dataType === "number" || idMeta.columnType === "PgSerial" || idMeta.columnType === "PgInteger" ? "number" : "string";
|
|
90
|
+
const isUUID = idMeta.columnType === "PgUUID";
|
|
91
|
+
keys.push({ fieldName: "id",
|
|
92
|
+
type,
|
|
93
|
+
isUUID });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return keys;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function parseIdValues(idValue: string | number, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): Record<string, string | number> {
|
|
100
|
+
const result: Record<string, string | number> = {};
|
|
101
|
+
|
|
102
|
+
if (primaryKeys.length === 0) {
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (primaryKeys.length === 1) {
|
|
107
|
+
const pk = primaryKeys[0];
|
|
108
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
109
|
+
const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
|
|
110
|
+
if (isNaN(parsed)) {
|
|
111
|
+
throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
112
|
+
}
|
|
113
|
+
result[pk.fieldName] = parsed;
|
|
114
|
+
} else {
|
|
115
|
+
result[pk.fieldName] = String(idValue);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Composite key - split by :::
|
|
121
|
+
const parts = String(idValue).split(":::");
|
|
122
|
+
if (parts.length !== primaryKeys.length) {
|
|
123
|
+
throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < primaryKeys.length; i++) {
|
|
127
|
+
const pk = primaryKeys[i];
|
|
128
|
+
const val = parts[i];
|
|
129
|
+
if (pk.type === "number" && !pk.isUUID) {
|
|
130
|
+
const parsed = parseInt(val, 10);
|
|
131
|
+
if (isNaN(parsed)) {
|
|
132
|
+
throw new Error(`Invalid numeric ID component: ${val}`);
|
|
133
|
+
}
|
|
134
|
+
result[pk.fieldName] = parsed;
|
|
135
|
+
} else {
|
|
136
|
+
result[pk.fieldName] = val;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildCompositeId(values: Record<string, unknown>, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): string {
|
|
144
|
+
if (primaryKeys.length === 0) {
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
if (primaryKeys.length === 1) {
|
|
148
|
+
return String(values[primaryKeys[0].fieldName] ?? "");
|
|
149
|
+
}
|
|
150
|
+
return primaryKeys.map(pk => String(values[pk.fieldName] ?? "")).join(":::");
|
|
151
|
+
}
|