@rebasepro/server-postgres 0.10.1-canary.6f89f77 → 0.10.1-canary.7801eed

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 (64) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/schema-version.d.ts +106 -0
  3. package/dist/chunk-DSJWtz9O.js +40 -0
  4. package/dist/collections/validate-relations.d.ts +53 -0
  5. package/dist/data-transformer.d.ts +3 -3
  6. package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
  7. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.es.js +1853 -4900
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  12. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  13. package/dist/services/FetchService.d.ts +21 -8
  14. package/dist/services/PersistService.d.ts +12 -0
  15. package/dist/services/RelationService.d.ts +39 -8
  16. package/dist/services/cdc/CdcListener.d.ts +7 -14
  17. package/dist/services/cdc/junction-tables.d.ts +38 -0
  18. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  19. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  20. package/dist/services/channel-bus/index.d.ts +55 -0
  21. package/dist/services/channel-history.d.ts +11 -0
  22. package/dist/services/channel-presence.d.ts +66 -0
  23. package/dist/services/nested-path.d.ts +59 -0
  24. package/dist/services/pg-notify-listener.d.ts +47 -0
  25. package/dist/services/realtimeService.d.ts +133 -6
  26. package/dist/services/row-pipeline.d.ts +2 -2
  27. package/dist/src-3VmUJ8Xn.js +3994 -0
  28. package/dist/src-3VmUJ8Xn.js.map +1 -0
  29. package/dist/src-D5xBTl32.js +346 -0
  30. package/dist/src-D5xBTl32.js.map +1 -0
  31. package/dist/utils/drizzle-conditions.d.ts +71 -18
  32. package/package.json +8 -9
  33. package/src/PostgresBootstrapper.ts +87 -5
  34. package/src/auth/ensure-tables.ts +23 -0
  35. package/src/auth/schema-version.ts +260 -0
  36. package/src/cli-errors.ts +1 -1
  37. package/src/cli-helpers.ts +4 -3
  38. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  39. package/src/collections/buildRegistry.ts +7 -0
  40. package/src/collections/validate-relations.ts +280 -0
  41. package/src/data-transformer.ts +28 -38
  42. package/src/index.ts +4 -0
  43. package/src/schema/doctor.ts +14 -14
  44. package/src/schema/ensure-collection-tables.test.ts +156 -0
  45. package/src/schema/ensure-collection-tables.ts +297 -0
  46. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  47. package/src/schema/generate-postgres-ddl-logic.ts +31 -24
  48. package/src/schema/introspect-db-inference.ts +13 -13
  49. package/src/schema/introspect-db-logic.ts +25 -29
  50. package/src/services/FetchService.ts +116 -126
  51. package/src/services/PersistService.ts +126 -88
  52. package/src/services/RelationService.ts +157 -86
  53. package/src/services/cdc/CdcListener.ts +27 -91
  54. package/src/services/cdc/junction-tables.ts +91 -0
  55. package/src/services/channel-bus/ChannelBus.ts +44 -0
  56. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  57. package/src/services/channel-bus/index.ts +123 -0
  58. package/src/services/channel-history.ts +35 -0
  59. package/src/services/channel-presence.ts +148 -0
  60. package/src/services/nested-path.ts +145 -0
  61. package/src/services/pg-notify-listener.ts +137 -0
  62. package/src/services/realtimeService.ts +430 -11
  63. package/src/services/row-pipeline.ts +5 -6
  64. package/src/utils/drizzle-conditions.ts +268 -330
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The shared presence roster.
3
+ *
4
+ * Broadcast only ever needed *fan-out* to work across instances — a frame goes
5
+ * out, whoever is connected receives it. Presence needs more than that, because
6
+ * `presence_state` is a question ("who is in this document?") and a per-process
7
+ * `Map` can only answer for the clients that happen to share a replica with the
8
+ * asker. Two people editing the same scene through different pods would each
9
+ * see an empty room while broadcasting cursors at each other perfectly.
10
+ *
11
+ * So presence gets one row per tracked client, in Postgres, readable by every
12
+ * instance. Three consequences worth stating:
13
+ *
14
+ * - **The table is the roster; the in-process map is a cache of our own
15
+ * clients.** Reads answer from the table when this store is active, so the
16
+ * answer is the same whichever instance is asked.
17
+ *
18
+ * - **`last_seen` is the liveness signal, and it is already there.** The client
19
+ * heartbeats presence every ~20 s against a 30 s window; the sweep that has
20
+ * always reaped local stale entries now also reaps rows belonging to
21
+ * instances that stopped writing — which is exactly what a crashed pod looks
22
+ * like. Crash recovery is a property of the TTL, not a separate mechanism.
23
+ *
24
+ * - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete
25
+ * is the one that announces the departures, so a stale client produces one
26
+ * `presence_diff` for the cluster rather than one per replica.
27
+ */
28
+
29
+ import { sql } from "drizzle-orm";
30
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
31
+
32
+ /** A tracked client, as any instance sees it. */
33
+ export interface PresenceRow {
34
+ channel: string;
35
+ clientId: string;
36
+ state: Record<string, unknown>;
37
+ }
38
+
39
+ export class ChannelPresenceStore {
40
+ private tablesReady = false;
41
+
42
+ constructor(
43
+ private readonly db: NodePgDatabase<Record<string, unknown>>,
44
+ private readonly instanceId: string
45
+ ) {}
46
+
47
+ /** Create the roster table. Idempotent. */
48
+ async ensureTables(): Promise<void> {
49
+ if (this.tablesReady) return;
50
+
51
+ await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
52
+
53
+ // Keyed by (channel, client_id): a client id is globally unique, so the
54
+ // instance is a column rather than part of the identity — a client that
55
+ // reconnects onto another replica replaces its own row instead of
56
+ // appearing twice in the roster.
57
+ await this.db.execute(sql`
58
+ CREATE TABLE IF NOT EXISTS rebase.channel_presence (
59
+ channel TEXT NOT NULL,
60
+ client_id TEXT NOT NULL,
61
+ instance_id TEXT NOT NULL,
62
+ state JSONB NOT NULL DEFAULT '{}'::jsonb,
63
+ last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
64
+ PRIMARY KEY (channel, client_id)
65
+ )
66
+ `);
67
+
68
+ // The sweep's access path; the roster read rides the primary key.
69
+ await this.db.execute(sql`
70
+ CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
71
+ ON rebase.channel_presence (last_seen)
72
+ `);
73
+
74
+ this.tablesReady = true;
75
+ }
76
+
77
+ /** Record (or refresh) a client's presence. */
78
+ async track(channel: string, clientId: string, state: Record<string, unknown>): Promise<void> {
79
+ await this.db.execute(sql`
80
+ INSERT INTO rebase.channel_presence (channel, client_id, instance_id, state, last_seen)
81
+ VALUES (${channel}, ${clientId}, ${this.instanceId}, ${JSON.stringify(state ?? {})}::jsonb, NOW())
82
+ ON CONFLICT (channel, client_id) DO UPDATE
83
+ SET state = EXCLUDED.state,
84
+ instance_id = EXCLUDED.instance_id,
85
+ last_seen = NOW()
86
+ `);
87
+ }
88
+
89
+ /** Drop one client's presence in one channel. */
90
+ async remove(channel: string, clientId: string): Promise<void> {
91
+ await this.db.execute(sql`
92
+ DELETE FROM rebase.channel_presence
93
+ WHERE channel = ${channel} AND client_id = ${clientId}
94
+ `);
95
+ }
96
+
97
+ /** Drop a client from every channel — used when its socket closes. */
98
+ async removeClient(clientId: string): Promise<void> {
99
+ await this.db.execute(sql`
100
+ DELETE FROM rebase.channel_presence WHERE client_id = ${clientId}
101
+ `);
102
+ }
103
+
104
+ /** The global roster for a channel. */
105
+ async roster(channel: string): Promise<Record<string, Record<string, unknown>>> {
106
+ const result = await this.db.execute(sql`
107
+ SELECT client_id, state FROM rebase.channel_presence WHERE channel = ${channel}
108
+ `);
109
+
110
+ const presences: Record<string, Record<string, unknown>> = {};
111
+ for (const row of result.rows as Array<{ client_id: string; state: Record<string, unknown> | null }>) {
112
+ presences[row.client_id] = row.state ?? {};
113
+ }
114
+ return presences;
115
+ }
116
+
117
+ /**
118
+ * Reap rows this instance is not responsible for and that have gone quiet.
119
+ *
120
+ * Own rows are excluded because the in-process sweep already handles them —
121
+ * and handles them better, since it can tell "the socket is gone" from "the
122
+ * heartbeat is late". What is left is precisely the interesting case: rows
123
+ * written by an instance that is no longer writing.
124
+ *
125
+ * Returns what was removed, so the caller can announce it.
126
+ */
127
+ async sweepStale(ttlMs: number): Promise<PresenceRow[]> {
128
+ const result = await this.db.execute(sql`
129
+ DELETE FROM rebase.channel_presence
130
+ WHERE instance_id <> ${this.instanceId}
131
+ AND last_seen < NOW() - MAKE_INTERVAL(secs => ${ttlMs / 1000})
132
+ RETURNING channel, client_id, state
133
+ `);
134
+
135
+ return (result.rows as Array<{ channel: string; client_id: string; state: Record<string, unknown> | null }>)
136
+ .map(row => ({ channel: row.channel, clientId: row.client_id, state: row.state ?? {} }));
137
+ }
138
+
139
+ /**
140
+ * Remove every row this instance owns. Called on graceful shutdown so a
141
+ * rolling deploy does not leave a TTL window of ghosts in every roster.
142
+ */
143
+ async removeInstance(): Promise<void> {
144
+ await this.db.execute(sql`
145
+ DELETE FROM rebase.channel_presence WHERE instance_id = ${this.instanceId}
146
+ `);
147
+ }
148
+ }
@@ -0,0 +1,145 @@
1
+ import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
2
+ import { findRelation, resolveCollectionRelations } from "@rebasepro/common";
3
+ import { ApiError } from "@rebasepro/server";
4
+
5
+ import { getCollectionByPath } from "./collection-helpers";
6
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
+
8
+ /**
9
+ * The last hop of a nested collection path, e.g. `authors/1/posts`.
10
+ *
11
+ * The walk that produces this was written out four separate times — in
12
+ * `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
13
+ * `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
14
+ * drifted, so the read path and the write path did not agree on which relation
15
+ * a path named. It lives here once now.
16
+ */
17
+ export interface NestedPathHop {
18
+ /** The collection the final relation is declared on (e.g. `authors`). */
19
+ parentCollection: CollectionConfig;
20
+ /** The parent's id as it appeared in the path, unparsed. */
21
+ parentId: string;
22
+ /** The path segment that named the relation (e.g. `posts`). */
23
+ relationKey: string;
24
+ relation: ResolvedRelation;
25
+ /** `relation.target()`, resolved once. */
26
+ targetCollection: CollectionConfig;
27
+ }
28
+
29
+ /**
30
+ * True when `path` addresses rows through a relation rather than a root
31
+ * collection.
32
+ *
33
+ * Any separator at all counts — a root collection slug never contains one — so
34
+ * a malformed path like `collection/id` is a *broken* nested path and gets
35
+ * reported as one by {@link resolveNestedPath}, rather than being looked up as
36
+ * a root collection whose slug happens to contain a slash.
37
+ */
38
+ export function isNestedPath(path: string): boolean {
39
+ return path.includes("/");
40
+ }
41
+
42
+ export function splitPathSegments(path: string): string[] {
43
+ return path.split("/").filter(s => s && s !== "undefined");
44
+ }
45
+
46
+ /**
47
+ * Walk a nested collection path down to the relation it ends in.
48
+ *
49
+ * Returns `undefined` for a plain root-collection path so callers can keep the
50
+ * root case on its existing code path. Throws when the path is malformed, or
51
+ * when a segment names a relation that does not exist — the same errors the
52
+ * individual walks used to raise, with the available names attached.
53
+ */
54
+ export function resolveNestedPath(
55
+ path: string,
56
+ registry: PostgresCollectionRegistry
57
+ ): NestedPathHop | undefined {
58
+ if (!isNestedPath(path)) return undefined;
59
+
60
+ const segments = splitPathSegments(path);
61
+ if (segments.length < 3 || segments.length % 2 === 0) {
62
+ throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
63
+ }
64
+
65
+ let parentCollection = getCollectionByPath(segments[0], registry);
66
+ let parentId = segments[1];
67
+
68
+ for (let i = 2; i < segments.length; i += 2) {
69
+ const relationKey = segments[i];
70
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
71
+ const relation = findRelation(resolvedRelations, relationKey);
72
+
73
+ if (!relation) {
74
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
75
+ throw new Error(
76
+ `Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`
77
+ );
78
+ }
79
+
80
+ const targetCollection = relation.target();
81
+
82
+ if (i === segments.length - 1) {
83
+ return {
84
+ parentCollection,
85
+ parentId,
86
+ relationKey,
87
+ relation,
88
+ targetCollection
89
+ };
90
+ }
91
+
92
+ parentCollection = targetCollection;
93
+ parentId = segments[i + 1];
94
+ }
95
+
96
+ // Unreachable: the loop returns on the final segment, and the odd-length
97
+ // check above guarantees there is one.
98
+ throw new Error(`Unable to resolve path: ${path}`);
99
+ }
100
+
101
+ /**
102
+ * A relation reached through a junction table — many-to-many, or a multi-hop
103
+ * `joinPath`. The target row is shared with other parents, so writing "through"
104
+ * such a path addresses the *link*, not the row.
105
+ */
106
+ export function isJunctionBackedRelation(relation: ResolvedRelation): boolean {
107
+ return relation.shared;
108
+ }
109
+
110
+ /**
111
+ * Reject a nested write whose final segment is a to-one relation.
112
+ *
113
+ * There is no column on the target row that records a to-one parent — the
114
+ * foreign key lives on the *parent* table. The write path used to fall through
115
+ * to `relation.localKey` here and stamp the parent's own FK column onto the
116
+ * target row, which either raised an opaque "column does not exist" or, when a
117
+ * column of that name happened to exist on the target, silently wrote the wrong
118
+ * one.
119
+ */
120
+ export function assertWritableThrough(hop: NestedPathHop, path: string): void {
121
+ const { relation } = hop;
122
+
123
+ // Read the flag rather than re-deriving the rule. This guard used to test
124
+ // `cardinality !== "many"`, which let a to-many `via` through even though
125
+ // its type declares `writable: false` — a second expression of one rule,
126
+ // free to disagree with the first, which is the shape of defect this union
127
+ // exists to remove.
128
+ if (relation.writable && relation.cardinality === "many") return;
129
+
130
+ if (!relation.writable) {
131
+ throw ApiError.badRequest(
132
+ `"${path}" ends in '${hop.relationKey}', a \`via\` relation. Rebase will not infer how to write ` +
133
+ `through an arbitrary join chain — write the row at "${hop.targetCollection.slug}" directly.`,
134
+ "RELATION_NOT_WRITABLE"
135
+ );
136
+ }
137
+
138
+ throw ApiError.badRequest(
139
+ `"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: ` +
140
+ `the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on ` +
141
+ `'${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set ` +
142
+ `'${hop.relationKey}' on the parent instead.`,
143
+ "RELATION_NOT_WRITABLE"
144
+ );
145
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * A dedicated, self-healing Postgres `LISTEN` connection.
3
+ *
4
+ * Every cross-instance feature in the backend needs the same thing: one
5
+ * connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
6
+ * comes back on its own after the database or the network drops it. CDC needed
7
+ * it first; the channel bus needs it too. This is that connection, with the one
8
+ * behaviour that matters to callers preserved: the **first** connect is
9
+ * validated and rethrown, so a caller can fall back to a different strategy,
10
+ * while every later drop is repaired quietly in the background.
11
+ *
12
+ * `LISTEN` is session state, so this connection must not go through a
13
+ * transaction-mode pooler (PgBouncer): give it the direct database URL.
14
+ */
15
+
16
+ import { Client as PgClient } from "pg";
17
+ import { logger } from "@rebasepro/server";
18
+
19
+ export interface PgNotifyListenerOptions {
20
+ /** Direct Postgres connection string (must bypass a transaction-mode pooler). */
21
+ connectionString: string;
22
+ /** NOTIFY channel to LISTEN on. Must be a plain identifier — it is interpolated. */
23
+ channel: string;
24
+ /** Called for every notification payload received. */
25
+ onPayload: (payload: string) => void | Promise<void>;
26
+ /** Prefix for log lines, e.g. `"[CDC]"`. */
27
+ logLabel: string;
28
+ /** Delay before a reconnect attempt. */
29
+ reconnectDelayMs?: number;
30
+ }
31
+
32
+ const DEFAULT_RECONNECT_DELAY_MS = 3000;
33
+ /** Guards the identifier interpolated into `LISTEN`. */
34
+ const SAFE_CHANNEL = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
+
36
+ export class PgNotifyListener {
37
+ private client?: PgClient;
38
+ private running = false;
39
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
40
+
41
+ constructor(private readonly options: PgNotifyListenerOptions) {
42
+ if (!SAFE_CHANNEL.test(options.channel)) {
43
+ throw new Error(`Unsafe NOTIFY channel name "${options.channel}" — expected a plain SQL identifier.`);
44
+ }
45
+ }
46
+
47
+ /** Whether the listener is meant to be connected right now. */
48
+ get active(): boolean {
49
+ return this.running;
50
+ }
51
+
52
+ /**
53
+ * Connect and begin listening. Idempotent.
54
+ *
55
+ * Rejects if the *initial* connection or `LISTEN` fails, leaving the
56
+ * listener stopped — callers use that to degrade deliberately instead of
57
+ * running blind against a channel nothing is delivering.
58
+ */
59
+ async start(): Promise<void> {
60
+ if (this.running) return;
61
+ this.running = true;
62
+ try {
63
+ await this.connect({ initial: true });
64
+ } catch (err) {
65
+ this.running = false;
66
+ throw err;
67
+ }
68
+ }
69
+
70
+ /** Stop listening and release the connection. Idempotent. */
71
+ async stop(): Promise<void> {
72
+ this.running = false;
73
+ if (this.reconnectTimer) {
74
+ clearTimeout(this.reconnectTimer);
75
+ this.reconnectTimer = undefined;
76
+ }
77
+ if (this.client) {
78
+ try {
79
+ await this.client.end();
80
+ } catch { /* ignore close errors */ }
81
+ this.client = undefined;
82
+ }
83
+ }
84
+
85
+ private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
86
+ const { connectionString, channel, onPayload, logLabel } = this.options;
87
+ try {
88
+ const client = new PgClient({ connectionString });
89
+
90
+ client.on("error", (err) => {
91
+ logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
92
+ this.scheduleReconnect();
93
+ });
94
+
95
+ client.on("end", () => {
96
+ if (this.running) {
97
+ logger.warn(`⚠️ ${logLabel} LISTEN client disconnected unexpectedly.`);
98
+ this.scheduleReconnect();
99
+ }
100
+ });
101
+
102
+ client.on("notification", (msg) => {
103
+ if (!msg.payload) return;
104
+ // A handler rejection must never surface as an unhandled
105
+ // rejection inside the pg client's event emitter.
106
+ Promise.resolve(onPayload(msg.payload)).catch((err) =>
107
+ logger.error(`❌ ${logLabel} Error handling notification`, { error: err })
108
+ );
109
+ });
110
+
111
+ await client.connect();
112
+ await client.query(`LISTEN ${channel}`);
113
+ this.client = client;
114
+ logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
115
+ } catch (err) {
116
+ // Surface the initial failure so callers can choose to fall back;
117
+ // for reconnects, keep retrying quietly in the background.
118
+ if (initial) throw err;
119
+ logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
120
+ this.scheduleReconnect();
121
+ }
122
+ }
123
+
124
+ private scheduleReconnect(): void {
125
+ if (!this.running || this.reconnectTimer) return;
126
+
127
+ this.reconnectTimer = setTimeout(async () => {
128
+ this.reconnectTimer = undefined;
129
+ if (!this.running) return;
130
+ if (this.client) {
131
+ try { await this.client.end(); } catch { /* ignore */ }
132
+ this.client = undefined;
133
+ }
134
+ await this.connect();
135
+ }, this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
136
+ }
137
+ }