@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 0.10.1-canary.18115ba

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.
@@ -21,4 +21,4 @@
21
21
  * `DROP SCHEMA "rebase" CASCADE` on the next diff. The `auth` schema is safe
22
22
  * because the generated schema.sql declares it.
23
23
  */
24
- export declare const AUTH_BOOTSTRAP_SQL = "-- Auth schema + RLS helper functions (required by the policies below)\nCREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$\n SELECT NULLIF(current_setting('app.user_id', true), '');\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.jwt', true), ''),\n '{}'\n )::jsonb;\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$\n SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');\n$$ LANGUAGE sql STABLE;\n";
24
+ export declare const AUTH_BOOTSTRAP_SQL = "-- Auth schema + RLS helper functions (required by the policies below)\nCREATE SCHEMA IF NOT EXISTS auth;\n\n-- Falls back to the pre-rename `app.user_id` so a database that has taken the\n-- new schema but is still served by an older backend keeps resolving the\n-- principal. Drop the COALESCE once no such deployment remains.\nCREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.uid', true), ''),\n NULLIF(current_setting('app.user_id', true), '')\n );\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.jwt', true), ''),\n '{}'\n )::jsonb;\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$\n SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');\n$$ LANGUAGE sql STABLE;\n";
@@ -232,6 +232,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
232
232
  }, {}, {
233
233
  $type: Record<string, unknown>;
234
234
  }>;
235
+ tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
236
+ name: "tokens_valid_after";
237
+ tableName: "users";
238
+ dataType: "date";
239
+ columnType: "PgTimestamp";
240
+ data: Date;
241
+ driverParam: string;
242
+ notNull: false;
243
+ hasDefault: false;
244
+ isPrimaryKey: false;
245
+ isAutoincrement: false;
246
+ hasRuntimeDefault: false;
247
+ enumValues: undefined;
248
+ baseColumn: never;
249
+ identity: undefined;
250
+ generated: undefined;
251
+ }, {}, {}>;
235
252
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
236
253
  name: "created_at";
237
254
  tableName: "users";
@@ -290,8 +307,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
290
307
  identity: undefined;
291
308
  generated: undefined;
292
309
  }, {}, {}>;
293
- userId: import("drizzle-orm/pg-core").PgColumn<{
294
- name: "user_id";
310
+ uid: import("drizzle-orm/pg-core").PgColumn<{
311
+ name: "uid";
295
312
  tableName: "refresh_tokens";
296
313
  dataType: "string";
297
314
  columnType: "PgUUID";
@@ -307,6 +324,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
307
324
  identity: undefined;
308
325
  generated: undefined;
309
326
  }, {}, {}>;
327
+ sessionId: import("drizzle-orm/pg-core").PgColumn<{
328
+ name: "session_id";
329
+ tableName: "refresh_tokens";
330
+ dataType: "string";
331
+ columnType: "PgUUID";
332
+ data: string;
333
+ driverParam: string;
334
+ notNull: true;
335
+ hasDefault: true;
336
+ isPrimaryKey: false;
337
+ isAutoincrement: false;
338
+ hasRuntimeDefault: false;
339
+ enumValues: undefined;
340
+ baseColumn: never;
341
+ identity: undefined;
342
+ generated: undefined;
343
+ }, {}, {}>;
310
344
  tokenHash: import("drizzle-orm/pg-core").PgColumn<{
311
345
  name: "token_hash";
312
346
  tableName: "refresh_tokens";
@@ -343,6 +377,57 @@ export declare function createAuthSchema(usersSchemaName?: string): {
343
377
  identity: undefined;
344
378
  generated: undefined;
345
379
  }, {}, {}>;
380
+ revoked: import("drizzle-orm/pg-core").PgColumn<{
381
+ name: "revoked";
382
+ tableName: "refresh_tokens";
383
+ dataType: "boolean";
384
+ columnType: "PgBoolean";
385
+ data: boolean;
386
+ driverParam: boolean;
387
+ notNull: true;
388
+ hasDefault: true;
389
+ isPrimaryKey: false;
390
+ isAutoincrement: false;
391
+ hasRuntimeDefault: false;
392
+ enumValues: undefined;
393
+ baseColumn: never;
394
+ identity: undefined;
395
+ generated: undefined;
396
+ }, {}, {}>;
397
+ rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
398
+ name: "rotated_at";
399
+ tableName: "refresh_tokens";
400
+ dataType: "date";
401
+ columnType: "PgTimestamp";
402
+ data: Date;
403
+ driverParam: string;
404
+ notNull: false;
405
+ hasDefault: false;
406
+ isPrimaryKey: false;
407
+ isAutoincrement: false;
408
+ hasRuntimeDefault: false;
409
+ enumValues: undefined;
410
+ baseColumn: never;
411
+ identity: undefined;
412
+ generated: undefined;
413
+ }, {}, {}>;
414
+ sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
415
+ name: "session_started_at";
416
+ tableName: "refresh_tokens";
417
+ dataType: "date";
418
+ columnType: "PgTimestamp";
419
+ data: Date;
420
+ driverParam: string;
421
+ notNull: true;
422
+ hasDefault: true;
423
+ isPrimaryKey: false;
424
+ isAutoincrement: false;
425
+ hasRuntimeDefault: false;
426
+ enumValues: undefined;
427
+ baseColumn: never;
428
+ identity: undefined;
429
+ generated: undefined;
430
+ }, {}, {}>;
346
431
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
347
432
  name: "user_agent";
348
433
  tableName: "refresh_tokens";
@@ -422,8 +507,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
422
507
  identity: undefined;
423
508
  generated: undefined;
424
509
  }, {}, {}>;
425
- userId: import("drizzle-orm/pg-core").PgColumn<{
426
- name: "user_id";
510
+ uid: import("drizzle-orm/pg-core").PgColumn<{
511
+ name: "uid";
427
512
  tableName: "password_reset_tokens";
428
513
  dataType: "string";
429
514
  columnType: "PgUUID";
@@ -593,8 +678,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
593
678
  identity: undefined;
594
679
  generated: undefined;
595
680
  }, {}, {}>;
596
- userId: import("drizzle-orm/pg-core").PgColumn<{
597
- name: "user_id";
681
+ uid: import("drizzle-orm/pg-core").PgColumn<{
682
+ name: "uid";
598
683
  tableName: "user_identities";
599
684
  dataType: "string";
600
685
  columnType: "PgUUID";
@@ -723,8 +808,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
723
808
  identity: undefined;
724
809
  generated: undefined;
725
810
  }, {}, {}>;
726
- userId: import("drizzle-orm/pg-core").PgColumn<{
727
- name: "user_id";
811
+ uid: import("drizzle-orm/pg-core").PgColumn<{
812
+ name: "uid";
728
813
  tableName: "mfa_factors";
729
814
  dataType: "string";
730
815
  columnType: "PgUUID";
@@ -983,8 +1068,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
983
1068
  identity: undefined;
984
1069
  generated: undefined;
985
1070
  }, {}, {}>;
986
- userId: import("drizzle-orm/pg-core").PgColumn<{
987
- name: "user_id";
1071
+ uid: import("drizzle-orm/pg-core").PgColumn<{
1072
+ name: "uid";
988
1073
  tableName: "recovery_codes";
989
1074
  dataType: "string";
990
1075
  columnType: "PgUUID";
@@ -1077,8 +1162,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
1077
1162
  identity: undefined;
1078
1163
  generated: undefined;
1079
1164
  }, {}, {}>;
1080
- userId: import("drizzle-orm/pg-core").PgColumn<{
1081
- name: "user_id";
1165
+ uid: import("drizzle-orm/pg-core").PgColumn<{
1166
+ name: "uid";
1082
1167
  tableName: "magic_link_tokens";
1083
1168
  dataType: "string";
1084
1169
  columnType: "PgUUID";
@@ -1398,6 +1483,23 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
1398
1483
  }, {}, {
1399
1484
  $type: Record<string, unknown>;
1400
1485
  }>;
1486
+ tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
1487
+ name: "tokens_valid_after";
1488
+ tableName: "users";
1489
+ dataType: "date";
1490
+ columnType: "PgTimestamp";
1491
+ data: Date;
1492
+ driverParam: string;
1493
+ notNull: false;
1494
+ hasDefault: false;
1495
+ isPrimaryKey: false;
1496
+ isAutoincrement: false;
1497
+ hasRuntimeDefault: false;
1498
+ enumValues: undefined;
1499
+ baseColumn: never;
1500
+ identity: undefined;
1501
+ generated: undefined;
1502
+ }, {}, {}>;
1401
1503
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
1402
1504
  name: "created_at";
1403
1505
  tableName: "users";
@@ -1456,8 +1558,8 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1456
1558
  identity: undefined;
1457
1559
  generated: undefined;
1458
1560
  }, {}, {}>;
1459
- userId: import("drizzle-orm/pg-core").PgColumn<{
1460
- name: "user_id";
1561
+ uid: import("drizzle-orm/pg-core").PgColumn<{
1562
+ name: "uid";
1461
1563
  tableName: "refresh_tokens";
1462
1564
  dataType: "string";
1463
1565
  columnType: "PgUUID";
@@ -1473,6 +1575,23 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1473
1575
  identity: undefined;
1474
1576
  generated: undefined;
1475
1577
  }, {}, {}>;
1578
+ sessionId: import("drizzle-orm/pg-core").PgColumn<{
1579
+ name: "session_id";
1580
+ tableName: "refresh_tokens";
1581
+ dataType: "string";
1582
+ columnType: "PgUUID";
1583
+ data: string;
1584
+ driverParam: string;
1585
+ notNull: true;
1586
+ hasDefault: true;
1587
+ isPrimaryKey: false;
1588
+ isAutoincrement: false;
1589
+ hasRuntimeDefault: false;
1590
+ enumValues: undefined;
1591
+ baseColumn: never;
1592
+ identity: undefined;
1593
+ generated: undefined;
1594
+ }, {}, {}>;
1476
1595
  tokenHash: import("drizzle-orm/pg-core").PgColumn<{
1477
1596
  name: "token_hash";
1478
1597
  tableName: "refresh_tokens";
@@ -1509,6 +1628,57 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
1509
1628
  identity: undefined;
1510
1629
  generated: undefined;
1511
1630
  }, {}, {}>;
1631
+ revoked: import("drizzle-orm/pg-core").PgColumn<{
1632
+ name: "revoked";
1633
+ tableName: "refresh_tokens";
1634
+ dataType: "boolean";
1635
+ columnType: "PgBoolean";
1636
+ data: boolean;
1637
+ driverParam: boolean;
1638
+ notNull: true;
1639
+ hasDefault: true;
1640
+ isPrimaryKey: false;
1641
+ isAutoincrement: false;
1642
+ hasRuntimeDefault: false;
1643
+ enumValues: undefined;
1644
+ baseColumn: never;
1645
+ identity: undefined;
1646
+ generated: undefined;
1647
+ }, {}, {}>;
1648
+ rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
1649
+ name: "rotated_at";
1650
+ tableName: "refresh_tokens";
1651
+ dataType: "date";
1652
+ columnType: "PgTimestamp";
1653
+ data: Date;
1654
+ driverParam: string;
1655
+ notNull: false;
1656
+ hasDefault: false;
1657
+ isPrimaryKey: false;
1658
+ isAutoincrement: false;
1659
+ hasRuntimeDefault: false;
1660
+ enumValues: undefined;
1661
+ baseColumn: never;
1662
+ identity: undefined;
1663
+ generated: undefined;
1664
+ }, {}, {}>;
1665
+ sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
1666
+ name: "session_started_at";
1667
+ tableName: "refresh_tokens";
1668
+ dataType: "date";
1669
+ columnType: "PgTimestamp";
1670
+ data: Date;
1671
+ driverParam: string;
1672
+ notNull: true;
1673
+ hasDefault: true;
1674
+ isPrimaryKey: false;
1675
+ isAutoincrement: false;
1676
+ hasRuntimeDefault: false;
1677
+ enumValues: undefined;
1678
+ baseColumn: never;
1679
+ identity: undefined;
1680
+ generated: undefined;
1681
+ }, {}, {}>;
1512
1682
  userAgent: import("drizzle-orm/pg-core").PgColumn<{
1513
1683
  name: "user_agent";
1514
1684
  tableName: "refresh_tokens";
@@ -1588,8 +1758,8 @@ export declare const passwordResetTokens: import("drizzle-orm/pg-core").PgTableW
1588
1758
  identity: undefined;
1589
1759
  generated: undefined;
1590
1760
  }, {}, {}>;
1591
- userId: import("drizzle-orm/pg-core").PgColumn<{
1592
- name: "user_id";
1761
+ uid: import("drizzle-orm/pg-core").PgColumn<{
1762
+ name: "uid";
1593
1763
  tableName: "password_reset_tokens";
1594
1764
  dataType: "string";
1595
1765
  columnType: "PgUUID";
@@ -1759,8 +1929,8 @@ export declare const userIdentities: import("drizzle-orm/pg-core").PgTableWithCo
1759
1929
  identity: undefined;
1760
1930
  generated: undefined;
1761
1931
  }, {}, {}>;
1762
- userId: import("drizzle-orm/pg-core").PgColumn<{
1763
- name: "user_id";
1932
+ uid: import("drizzle-orm/pg-core").PgColumn<{
1933
+ name: "uid";
1764
1934
  tableName: "user_identities";
1765
1935
  dataType: "string";
1766
1936
  columnType: "PgUUID";
@@ -1889,8 +2059,8 @@ export declare const mfaFactors: import("drizzle-orm/pg-core").PgTableWithColumn
1889
2059
  identity: undefined;
1890
2060
  generated: undefined;
1891
2061
  }, {}, {}>;
1892
- userId: import("drizzle-orm/pg-core").PgColumn<{
1893
- name: "user_id";
2062
+ uid: import("drizzle-orm/pg-core").PgColumn<{
2063
+ name: "uid";
1894
2064
  tableName: "mfa_factors";
1895
2065
  dataType: "string";
1896
2066
  columnType: "PgUUID";
@@ -2149,8 +2319,8 @@ export declare const recoveryCodes: import("drizzle-orm/pg-core").PgTableWithCol
2149
2319
  identity: undefined;
2150
2320
  generated: undefined;
2151
2321
  }, {}, {}>;
2152
- userId: import("drizzle-orm/pg-core").PgColumn<{
2153
- name: "user_id";
2322
+ uid: import("drizzle-orm/pg-core").PgColumn<{
2323
+ name: "uid";
2154
2324
  tableName: "recovery_codes";
2155
2325
  dataType: "string";
2156
2326
  columnType: "PgUUID";
@@ -2243,8 +2413,8 @@ export declare const magicLinkTokens: import("drizzle-orm/pg-core").PgTableWithC
2243
2413
  identity: undefined;
2244
2414
  generated: undefined;
2245
2415
  }, {}, {}>;
2246
- userId: import("drizzle-orm/pg-core").PgColumn<{
2247
- name: "user_id";
2416
+ uid: import("drizzle-orm/pg-core").PgColumn<{
2417
+ name: "uid";
2248
2418
  tableName: "magic_link_tokens";
2249
2419
  dataType: "string";
2250
2420
  columnType: "PgUUID";
@@ -32,11 +32,6 @@ export interface TableMeta {
32
32
  fks: ForeignKeyRow[];
33
33
  }
34
34
  export declare function singularize(word: string): string;
35
- /**
36
- * Convert a snake_case name to a human-readable Title Case label.
37
- * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
38
- */
39
- export declare function humanize(snakeName: string): string;
40
35
  /**
41
36
  * Convert a snake_case table name to a camelCase + "Collection" variable name.
42
37
  * e.g. "company_token" -> "companyTokenCollection"
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Naming helpers shared by the introspection modules. These live apart from
3
+ * `introspect-db-logic.ts` because the inference pass needs them too, and
4
+ * importing them from there would close a cycle back through this module.
5
+ */
6
+ /**
7
+ * Convert a snake_case name to a human-readable Title Case label.
8
+ * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
9
+ */
10
+ export declare function humanize(snakeName: string): string;
@@ -26,6 +26,14 @@ export interface PolicyRef {
26
26
  hasUsing: boolean;
27
27
  /** Whether a WITH CHECK clause is present at all (not what it says). */
28
28
  hasWithCheck: boolean;
29
+ /**
30
+ * The live clause text, when read from `pg_policies`. Present only for live
31
+ * policies (the expected side is parsed from DDL and does not carry it).
32
+ * Used solely for the insecure-tautology scan, not for divergence — Postgres
33
+ * rewrites this text, so it is not safe to diff against expected.
34
+ */
35
+ qual?: string | null;
36
+ withCheck?: string | null;
29
37
  }
30
38
  export interface PolicyDrift {
31
39
  /** Described by the collections, absent from the database. */
@@ -38,6 +46,22 @@ export interface PolicyDrift {
38
46
  actual: PolicyRef;
39
47
  differences: string[];
40
48
  }[];
49
+ /**
50
+ * A live policy whose expression is the known-permissive tautology
51
+ * `auth.uid() IS NOT NULL` — true for anonymous visitors too, because the
52
+ * user path coerces a blank id to the `'anonymous'` sentinel. This is what
53
+ * `policy.authenticated()` used to compile to, so a database pushed before
54
+ * that fix carries it, and neither the name, roles, command nor clause
55
+ * *presence* differs from the corrected policy — the only thing that changed
56
+ * is the expression text, which this checker otherwise (correctly) ignores.
57
+ * So it is the one drift that hides from every other check here.
58
+ *
59
+ * @see reason a sentence naming the clause and what to do.
60
+ */
61
+ insecure: {
62
+ policy: PolicyRef;
63
+ reason: string;
64
+ }[];
41
65
  }
42
66
  export interface Queryable {
43
67
  query<R>(text: string, values?: unknown[]): Promise<{
@@ -51,7 +51,7 @@ export interface ConnectionPosture {
51
51
  privileged: boolean;
52
52
  }
53
53
  export interface AuthContext {
54
- userId: string;
54
+ uid: string;
55
55
  /** Raw roles as carried on the user (strings or `{ id }` objects). */
56
56
  roles: unknown[];
57
57
  }
@@ -93,7 +93,7 @@ export declare function ensureAppRole(run: RawSqlRunner, schemas: string[]): Pro
93
93
  * SECURITY: this function is only ever called on the **user** path (the server
94
94
  * context uses the base/owner driver and never calls it). The default policies
95
95
  * treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
96
- * is `NULLIF(current_setting('app.user_id'), '')` — so an EMPTY user id would
96
+ * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
97
97
  * be read as NULL and silently escalate a user request to server privileges.
98
98
  * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
99
99
  * rather than trusting every caller (e.g. realtime subscription auth) to do it.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Ordered, replayable per-channel message history.
3
+ *
4
+ * Broadcast on its own is fire-and-forget to whoever is connected at the
5
+ * instant it is sent: fine for presence and for "someone saved" notifications,
6
+ * not enough for op-based collaborative editing, where a client that blinks
7
+ * out for two seconds has to resync a whole document rather than catch up on
8
+ * the four operations it missed. This adds the missing half — every retained
9
+ * broadcast gets a per-channel sequence number, and a client can ask for
10
+ * everything after the last one it saw.
11
+ *
12
+ * Three decisions worth stating, because each rules out a simpler-looking one:
13
+ *
14
+ * - **Retention is server-side and opt-in.** A channel is created by whoever
15
+ * names it, so a client-supplied history depth would let any visitor commit
16
+ * the backend to unbounded storage. And presence channels — the common case
17
+ * — must not pay for this: with no rules configured nothing is written, no
18
+ * table is created, and `broadcast` runs exactly the code it ran before.
19
+ *
20
+ * - **Sequence numbers come from the database, not from a counter in this
21
+ * process.** They have to survive a restart and be shared across instances;
22
+ * an in-memory counter would restart at 1 after a deploy and hand a
23
+ * reconnecting client a replay from the wrong era, silently.
24
+ *
25
+ * - **The cursor row outlives the messages it numbered.** Pruning is what
26
+ * makes retention affordable, but pruning the cursor along with the messages
27
+ * would restart the sequence and make `sinceSeq` mean something different
28
+ * before and after — the worst kind of bug, because replay would still
29
+ * return rows and they would look plausible. Cursors are tiny and are kept
30
+ * forever; see {@link prune}, which touches only `channel_messages`.
31
+ */
32
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
33
+ import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
34
+ /**
35
+ * Parse a retention TTL into milliseconds.
36
+ *
37
+ * Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
38
+ * `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
39
+ * caller treats as "no TTL" — a misspelt duration must not silently become an
40
+ * aggressive one.
41
+ */
42
+ export declare function parseTtlMs(ttl: number | string | undefined): number | undefined;
43
+ /**
44
+ * Whether `channel` is covered by `rule`.
45
+ *
46
+ * Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
47
+ * decides what reaches disk, and a pattern language whose reach is not obvious
48
+ * at a glance is the wrong tool for that job.
49
+ */
50
+ export declare function channelMatchesRule(channel: string, rule: ChannelRetentionRule): boolean;
51
+ /** A rule with its TTL already resolved to milliseconds. */
52
+ export interface ResolvedRetention {
53
+ limit?: number;
54
+ ttlMs?: number;
55
+ }
56
+ /**
57
+ * Persistence and replay for retained channels.
58
+ *
59
+ * Inert unless constructed with at least one rule: {@link enabled} is false,
60
+ * {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
61
+ * for every channel, so the realtime service never reaches the SQL below.
62
+ */
63
+ export declare class ChannelHistoryStore {
64
+ private db;
65
+ private rules;
66
+ /** Resolved rule per channel name, so the match runs once per channel. */
67
+ private resolved;
68
+ /** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
69
+ private lastPruned;
70
+ private tablesReady;
71
+ constructor(db: NodePgDatabase<Record<string, unknown>>, rules?: ChannelRetentionRule[]);
72
+ /** Whether any channel retains anything at all. */
73
+ get enabled(): boolean;
74
+ /**
75
+ * The retention that applies to `channel`, or undefined when none does.
76
+ *
77
+ * First matching rule wins, so callers order them most-specific first.
78
+ */
79
+ retentionFor(channel: string): ResolvedRetention | undefined;
80
+ /**
81
+ * Create the history tables. Idempotent, and a no-op when no rule is set —
82
+ * a deployment that never retains anything gets no schema for it.
83
+ */
84
+ ensureTables(): Promise<void>;
85
+ /**
86
+ * Append a broadcast and return the sequence number it was given.
87
+ *
88
+ * The sequence is allocated by the same statement that stores the message,
89
+ * so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
90
+ * takes a row lock on the channel's cursor, which is what makes concurrent
91
+ * broadcasts to one channel line up in a single order — and what keeps
92
+ * different channels from contending with each other at all.
93
+ */
94
+ append(channel: string, event: string, payload: unknown, senderId?: string): Promise<{
95
+ seq: number;
96
+ at: string;
97
+ }>;
98
+ /**
99
+ * Everything retained for `channel` after `sinceSeq`, oldest first.
100
+ *
101
+ * `latestSeq` is reported whether or not the messages were capped, so a
102
+ * client that is further behind than one page can tell.
103
+ */
104
+ replay(channel: string, sinceSeq?: number, limit?: number): Promise<{
105
+ messages: ChannelHistoryEntry[];
106
+ latestSeq: number;
107
+ }>;
108
+ /**
109
+ * Enforce a channel's retention bounds.
110
+ *
111
+ * Throttled per channel, so a burst of operations prunes once rather than
112
+ * once per message — the cost then tracks elapsed time instead of write
113
+ * volume, which is what makes retention affordable on a hot channel.
114
+ */
115
+ prune(channel: string, retention: ResolvedRetention): Promise<number>;
116
+ /** Forget throttle and match caches. Called on shutdown. */
117
+ clear(): void;
118
+ }
@@ -4,12 +4,13 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
4
4
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
6
6
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
+ import type { ChannelRetentionRule } from "@rebasepro/types";
7
8
  /**
8
9
  * Auth context stored per-subscription so real-time refetches respect RLS.
9
10
  * Mirrors the session variables set by PostgresBackendDriver.withAuth().
10
11
  */
11
12
  export interface SubscriptionAuthContext {
12
- userId: string;
13
+ uid: string;
13
14
  roles: string[];
14
15
  }
15
16
  /**
@@ -24,6 +25,26 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
24
25
  private clients;
25
26
  private channels;
26
27
  private presence;
28
+ /**
29
+ * Ordered, replayable history for channels that opt into it.
30
+ *
31
+ * Undefined until {@link configureChannelHistory} is called, and inert even
32
+ * then unless retention rules were supplied — so presence and ephemeral
33
+ * notification channels never touch it. See `channel-history.ts`.
34
+ */
35
+ private channelHistory?;
36
+ /**
37
+ * One promise chain per retained channel, so that assigning a sequence
38
+ * number and fanning the message out happen in the same order for every
39
+ * message on that channel.
40
+ *
41
+ * Without it, two concurrent broadcasts can be numbered 4 and 5 by the
42
+ * database and still reach subscribers as 5 then 4 — live order and replay
43
+ * order would disagree, which is exactly the divergence sequence numbers
44
+ * are supposed to rule out. Keyed by channel, so unrelated channels never
45
+ * wait on each other.
46
+ */
47
+ private channelSendQueues;
27
48
  private presenceInterval?;
28
49
  private static readonly PRESENCE_TIMEOUT_MS;
29
50
  private dataService;
@@ -197,8 +218,54 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
197
218
  joinChannel(clientId: string, channel: string): void;
198
219
  /** Leave a broadcast channel */
199
220
  leaveChannel(clientId: string, channel: string): void;
200
- /** Broadcast a message to all clients in a channel except sender */
221
+ /**
222
+ * Broadcast a message to all clients in a channel except the sender.
223
+ *
224
+ * On a channel with no retention rule this is what it always was: a
225
+ * synchronous fan-out to whoever is connected, with no sequence number, no
226
+ * SQL and no await — the body below runs to completion before returning.
227
+ *
228
+ * On a retained channel the message is durably numbered first and only then
229
+ * delivered, through a per-channel queue so that delivery order matches
230
+ * sequence order. That ordering is the whole point: a client that catches up
231
+ * with `sinceSeq` has to arrive at the same state as one that never
232
+ * disconnected.
233
+ */
201
234
  broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void;
235
+ /**
236
+ * Number a broadcast, store it, then deliver it.
237
+ *
238
+ * A message that cannot be stored is **not** delivered. Delivering it would
239
+ * put it in front of live subscribers while leaving it absent from every
240
+ * future replay — the two views of the channel would disagree permanently,
241
+ * and no later message could repair the gap. Failing loudly to the sender
242
+ * instead lets it retry, which for an operation stream is the only outcome
243
+ * that keeps clients convergent.
244
+ */
245
+ private persistAndFanOut;
246
+ /** Deliver a broadcast frame to every member of a channel but the sender. */
247
+ private fanOutBroadcast;
248
+ /**
249
+ * Install retention rules and create the tables they need.
250
+ *
251
+ * Safe to call with no rules (and safe not to call at all): the store stays
252
+ * inert, no schema is created, and broadcast keeps its original
253
+ * fire-and-forget path.
254
+ */
255
+ configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void>;
256
+ /** Whether any channel is configured to retain messages. */
257
+ isChannelHistoryEnabled(): boolean;
258
+ /**
259
+ * Answer a client's catch-up request.
260
+ *
261
+ * A channel with no retention rule is answered with `retained: false`
262
+ * rather than an empty list, so the client can tell "you missed nothing"
263
+ * apart from "this channel never keeps anything" — the second means its
264
+ * reconnect strategy has to be a full resync, and silence would leave it
265
+ * guessing.
266
+ */
267
+ private handleChannelHistoryRequest;
268
+ private sendChannelHistory;
202
269
  /** Track presence in a channel */
203
270
  trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
204
271
  /** Remove presence from a channel */