@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.
Files changed (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. 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
+ }