@rebasepro/server-postgres 0.22.0 → 0.24.0

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 (123) hide show
  1. package/dist/{BranchService-CucnFcSE.js → BranchService-BRt78gfa.js} +35 -18
  2. package/dist/BranchService-BRt78gfa.js.map +1 -0
  3. package/dist/PostgresBackendDriver.d.ts +116 -4
  4. package/dist/auth/services.d.ts +99 -22
  5. package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
  6. package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
  7. package/dist/backup/backup-cli.d.ts +22 -0
  8. package/dist/{backup-cli-DqBakiMO.js → backup-cli-DW5p9_zv.js} +17 -14
  9. package/dist/backup-cli-DW5p9_zv.js.map +1 -0
  10. package/dist/{backup-service-CaMOS76G.js → backup-service-EwcDVG-8.js} +7 -9
  11. package/dist/{backup-service-CaMOS76G.js.map → backup-service-EwcDVG-8.js.map} +1 -1
  12. package/dist/{cli-errors-Dka89exj.js → cli-errors-DsA-K9uP.js} +101 -1
  13. package/dist/cli-errors-DsA-K9uP.js.map +1 -0
  14. package/dist/cli-errors.d.ts +42 -0
  15. package/dist/cli-flags-BglvpjHv.js +138 -0
  16. package/dist/cli-flags-BglvpjHv.js.map +1 -0
  17. package/dist/cli-flags.d.ts +28 -0
  18. package/dist/cli-helpers.d.ts +18 -12
  19. package/dist/cli-scratch-database.d.ts +34 -0
  20. package/dist/cli.js +177 -287
  21. package/dist/cli.js.map +1 -1
  22. package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-1-LQD0yI.js} +44 -38
  23. package/dist/column-plan-helpers-1-LQD0yI.js.map +1 -0
  24. package/dist/data-transformer.d.ts +0 -8
  25. package/dist/{doctor-CU9IogdL.js → doctor-C-sYWbmt.js} +228 -46
  26. package/dist/doctor-C-sYWbmt.js.map +1 -0
  27. package/dist/{ensure-collection-policies-Dzd-2S81.js → ensure-collection-policies-B1ureSIV.js} +73 -18
  28. package/dist/ensure-collection-policies-B1ureSIV.js.map +1 -0
  29. package/dist/{ensure-collection-tables-CpAgy51F.js → ensure-collection-tables-kkkHk8oo.js} +124 -37
  30. package/dist/ensure-collection-tables-kkkHk8oo.js.map +1 -0
  31. package/dist/{ensure-tables-Cr5B4UmH.js → ensure-tables-BmI_tRxc.js} +10 -3
  32. package/dist/ensure-tables-BmI_tRxc.js.map +1 -0
  33. package/dist/{generate-drizzle-schema-B537GIvz.js → generate-drizzle-schema-93M0lUxK.js} +2 -2
  34. package/dist/{generate-drizzle-schema-B537GIvz.js.map → generate-drizzle-schema-93M0lUxK.js.map} +1 -1
  35. package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js → generate-drizzle-schema-logic-sSFDp6LR.js} +26 -8
  36. package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +1 -0
  37. package/dist/generate-postgres-ddl-logic-BJsLaVNX.js +152 -0
  38. package/dist/generate-postgres-ddl-logic-BJsLaVNX.js.map +1 -0
  39. package/dist/generated-sql.d.ts +28 -0
  40. package/dist/index.es.js +4768 -1117
  41. package/dist/index.es.js.map +1 -1
  42. package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-kCETE8TY.js} +532 -39
  43. package/dist/introspect-db-logic-kCETE8TY.js.map +1 -0
  44. package/dist/introspect-db-queries-C_Q5VgQw.js +317 -0
  45. package/dist/introspect-db-queries-C_Q5VgQw.js.map +1 -0
  46. package/dist/{plan-schema-CboAIwLN.js → plan-schema-DU9exq6C.js} +291 -529
  47. package/dist/plan-schema-DU9exq6C.js.map +1 -0
  48. package/dist/{policy-drift-xJfy9xG7.js → policy-drift-B-J2hhm0.js} +3 -3
  49. package/dist/policy-drift-B-J2hhm0.js.map +1 -0
  50. package/dist/{generate-postgres-ddl-logic-D7imhYV8.js → render-ddl-Ds2t_d9V.js} +11 -149
  51. package/dist/render-ddl-Ds2t_d9V.js.map +1 -0
  52. package/dist/{rls-bootstrap-sql-H3rCFi3F.js → rls-bootstrap-sql-_KNnjanK.js} +562 -35
  53. package/dist/rls-bootstrap-sql-_KNnjanK.js.map +1 -0
  54. package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-CfXOJJaW.js} +10 -2
  55. package/dist/rls-enforcement-CfXOJJaW.js.map +1 -0
  56. package/dist/schema/atlas-argv.d.ts +15 -0
  57. package/dist/schema/auth-schema.d.ts +170 -0
  58. package/dist/schema/classify-change.d.ts +29 -1
  59. package/dist/schema/column-plan-helpers.d.ts +51 -20
  60. package/dist/schema/destructive-sql.d.ts +71 -1
  61. package/dist/schema/doctor-cli.js +4 -4
  62. package/dist/schema/doctor.d.ts +34 -1
  63. package/dist/schema/ensure-collection-policies.d.ts +22 -0
  64. package/dist/schema/generate-drizzle-schema.js +1 -1
  65. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  66. package/dist/schema/generate-postgres-ddl.js +1 -1
  67. package/dist/schema/generate-schema-commit.d.ts +12 -0
  68. package/dist/schema/introspect-db-logic.d.ts +31 -0
  69. package/dist/schema/introspect-db-queries.d.ts +1 -1
  70. package/dist/schema/introspect-db-search.d.ts +22 -0
  71. package/dist/schema/introspect-db-storage.d.ts +83 -0
  72. package/dist/schema/introspect-db.js +15 -314
  73. package/dist/schema/introspect-db.js.map +1 -1
  74. package/dist/schema/plan/diff-plan.d.ts +4 -3
  75. package/dist/schema/plan/plan-schema.d.ts +26 -11
  76. package/dist/schema/plan/render-ddl.d.ts +6 -0
  77. package/dist/schema/plan/types.d.ts +43 -12
  78. package/dist/search-column-BM-GV6vH.js +442 -0
  79. package/dist/search-column-BM-GV6vH.js.map +1 -0
  80. package/dist/security/policy-drift.d.ts +1 -1
  81. package/dist/security/rls-enforcement.d.ts +7 -0
  82. package/dist/services/BranchService.d.ts +22 -1
  83. package/dist/services/FetchService.d.ts +102 -32
  84. package/dist/services/PersistService.d.ts +41 -5
  85. package/dist/services/RelationService.d.ts +29 -0
  86. package/dist/services/RelationWriteService.d.ts +6 -0
  87. package/dist/services/cdc/CdcListener.d.ts +15 -5
  88. package/dist/services/cdc/identity-columns.d.ts +19 -0
  89. package/dist/services/cdc/trigger-cdc.d.ts +45 -7
  90. package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
  91. package/dist/services/channel-history.d.ts +17 -1
  92. package/dist/services/collection-helpers.d.ts +21 -0
  93. package/dist/services/dataService.d.ts +3 -20
  94. package/dist/services/field-op-sql.d.ts +71 -0
  95. package/dist/services/junction-writes.d.ts +19 -3
  96. package/dist/services/pg-notify-listener.d.ts +95 -6
  97. package/dist/services/read-field-access.d.ts +19 -0
  98. package/dist/services/realtimeService.d.ts +232 -44
  99. package/dist/services/row-pipeline.d.ts +5 -0
  100. package/dist/services/socket-liveness.d.ts +45 -0
  101. package/dist/services/soft-delete.d.ts +12 -2
  102. package/dist/services/sql-script.d.ts +71 -0
  103. package/dist/services/write-depth.d.ts +16 -0
  104. package/dist/services/write-transaction-scope.d.ts +42 -0
  105. package/dist/utils/drizzle-conditions.d.ts +49 -4
  106. package/dist/utils/sql-redaction.d.ts +21 -0
  107. package/dist/websocket.d.ts +57 -18
  108. package/package.json +9 -9
  109. package/dist/BranchService-CucnFcSE.js.map +0 -1
  110. package/dist/backup-cli-DqBakiMO.js.map +0 -1
  111. package/dist/cli-errors-Dka89exj.js.map +0 -1
  112. package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
  113. package/dist/doctor-CU9IogdL.js.map +0 -1
  114. package/dist/ensure-collection-policies-Dzd-2S81.js.map +0 -1
  115. package/dist/ensure-collection-tables-CpAgy51F.js.map +0 -1
  116. package/dist/ensure-tables-Cr5B4UmH.js.map +0 -1
  117. package/dist/generate-drizzle-schema-logic-BcMl7VSy.js.map +0 -1
  118. package/dist/generate-postgres-ddl-logic-D7imhYV8.js.map +0 -1
  119. package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
  120. package/dist/plan-schema-CboAIwLN.js.map +0 -1
  121. package/dist/policy-drift-xJfy9xG7.js.map +0 -1
  122. package/dist/rls-bootstrap-sql-H3rCFi3F.js.map +0 -1
  123. package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
@@ -6,13 +6,15 @@
6
6
  * properties of `NOTIFY` shape everything below:
7
7
  *
8
8
  * - **8000 bytes per payload.** Presence and cursors fit with room to spare; a
9
- * scene snapshot does not. Rather than truncate or drop, an oversized frame
10
- * on a *retained* channel is published as a pointer — the body is already in
11
- * `rebase.channel_messages` with a sequence number, so the receiver reads it
12
- * back. That is the same trick the entity path uses (notify an address,
13
- * refetch the row), applied to a different table. On an ephemeral channel
14
- * there is nothing to point at, so the publish is refused loudly instead of
15
- * reaching some instances and not others.
9
+ * scene snapshot does not. A message on a *retained* channel never travels
10
+ * here at all, whatever its size: the statement that numbers it notifies a
11
+ * pointer — the body is already in `rebase.channel_messages` with a sequence
12
+ * number, so the receiver reads it back (`ChannelHistoryStore.append`). That
13
+ * is the same trick the entity path uses (notify an address, refetch the
14
+ * row), applied to a different table, and for the same reason: any database
15
+ * login can `LISTEN`. On an ephemeral channel there is nothing to point at,
16
+ * so the frame carries the message, and an oversized one is refused loudly
17
+ * instead of reaching some instances and not others.
16
18
  *
17
19
  * - **A notify is a query on the primary database.** Not a slow one, but it
18
20
  * competes with the application's real queries, and that — not throughput —
@@ -26,6 +28,7 @@
26
28
  * than correctness. That is what makes coalescing safe.
27
29
  */
28
30
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
31
+ import { type PgNotifyListenerStatus } from "../pg-notify-listener.js";
29
32
  import { ChannelBus, ChannelBusFrame, ChannelBusHandler } from "./ChannelBus.js";
30
33
  /** NOTIFY channel carrying channel-bus frames. */
31
34
  export declare const CHANNEL_BUS_NOTIFY_CHANNEL = "rebase_channel_bus";
@@ -79,6 +82,8 @@ export declare class PostgresChannelBus implements ChannelBus {
79
82
  * still holds.
80
83
  */
81
84
  publish(frame: ChannelBusFrame): Promise<void>;
85
+ /** The LISTEN connection's state, for `/health` — see {@link PgNotifyListener}. */
86
+ status(): PgNotifyListenerStatus | undefined;
82
87
  stop(): Promise<void>;
83
88
  private openWindow;
84
89
  /** Send everything queued and settle the promises waiting on it. */
@@ -53,6 +53,22 @@ export interface ResolvedRetention {
53
53
  limit?: number;
54
54
  ttlMs?: number;
55
55
  }
56
+ /**
57
+ * A bus frame to send in the statement that numbers a retained message — see
58
+ * {@link ChannelHistoryStore.append}.
59
+ *
60
+ * It is always a pointer, `{ kind: "broadcast_ref", sid, channel, seq }`, and
61
+ * never the message. Postgres puts no privilege on `LISTEN`, so a NOTIFY payload
62
+ * reaches every role that can connect to the database; the body stays in
63
+ * `rebase.channel_messages`, behind the table grants, and each receiving
64
+ * instance reads it back by `(channel, seq)` — the sender's id with it.
65
+ */
66
+ export interface RetainedAnnouncement {
67
+ /** The NOTIFY channel the bus listens on. */
68
+ notifyChannel: string;
69
+ /** The sending instance. */
70
+ sid: string;
71
+ }
56
72
  /**
57
73
  * Persistence and replay for retained channels.
58
74
  *
@@ -91,7 +107,7 @@ export declare class ChannelHistoryStore {
91
107
  * broadcasts to one channel line up in a single order — and what keeps
92
108
  * different channels from contending with each other at all.
93
109
  */
94
- append(channel: string, event: string, payload: unknown, senderId?: string): Promise<{
110
+ append(channel: string, event: string, payload: unknown, senderId?: string, announce?: RetainedAnnouncement): Promise<{
95
111
  seq: number;
96
112
  at: string;
97
113
  }>;
@@ -1,4 +1,5 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
+ import { type SQL } from "drizzle-orm";
2
3
  import { CollectionConfig, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
4
5
  import { ApiError } from "@rebasepro/server";
@@ -34,6 +35,16 @@ export declare function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta;
34
35
  * statement into an unrelated-looking `25P02`.
35
36
  */
36
37
  export declare function idCanAddressTable(id: string | number, table: PgTable, idInfoArray: PrimaryKeyInfo[]): boolean;
38
+ /**
39
+ * The condition that names one row: every key column equal to its part of the
40
+ * address.
41
+ *
42
+ * Every key column, not the first. A composite-key read that matched only the
43
+ * leading column answered `p1:::bob` with whichever `p1` row Postgres reached
44
+ * first, and a delete then judged that row's `beforeDelete` while deleting bob —
45
+ * the writes have always matched the whole key, so the reads must too.
46
+ */
47
+ export declare function rowIdentityCondition(table: PgTable, idInfoArray: PrimaryKeyInfo[], id: string | number, collectionPath: string): SQL;
37
48
  export declare function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig;
38
49
  /**
39
50
  * Reject a write naming something that is not a column of the table.
@@ -122,6 +133,16 @@ export declare function getPrimaryKeys(collection: CollectionConfig, registry: P
122
133
  * wrong and which collection it is wrong about.
123
134
  */
124
135
  export declare function requirePrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[];
136
+ /**
137
+ * The table's key columns, in key order.
138
+ *
139
+ * Throws when there are none, or when the table has no column for one of them:
140
+ * a WHERE built from fewer columns than the key matches every row that shares
141
+ * the columns it did find, so there is no partial answer worth returning.
142
+ *
143
+ * @param collectionPath named in the error.
144
+ */
145
+ export declare function primaryKeyColumns(table: PgTable, primaryKeys: PrimaryKeyInfo[], collectionPath: string): AnyPgColumn[];
125
146
  /**
126
147
  * The column on the *source* table that a `hasOne`/`hasMany` link points at.
127
148
  *
@@ -3,7 +3,7 @@ import type { VectorSearchParams } from "@rebasepro/types";
3
3
  import { FetchService } from "./FetchService.js";
4
4
  import type { ReadCallContextProvider } from "./read-scope.js";
5
5
  import type { WithDeleted } from "./soft-delete.js";
6
- import { PersistService } from "./PersistService.js";
6
+ import { PersistService, type PersistSaveOptions } from "./PersistService.js";
7
7
  import { RelationService } from "./RelationService.js";
8
8
  import { DataRepository, DrizzleClient } from "../interfaces.js";
9
9
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
@@ -52,21 +52,7 @@ export declare class DataService implements DataRepository {
52
52
  /**
53
53
  * Fetch a collection of rows with optional filtering, ordering, and pagination
54
54
  */
55
- fetchCollection<M extends Record<string, unknown>>(collectionPath: string, options?: {
56
- filter?: FilterValues<Extract<keyof M, string>>;
57
- /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
58
- logical?: LogicalCondition;
59
- orderBy?: string | OrderByTuple[];
60
- order?: "desc" | "asc";
61
- limit?: number;
62
- offset?: number;
63
- startAfter?: Record<string, unknown>;
64
- searchString?: string;
65
- databaseId?: string;
66
- vectorSearch?: VectorSearchParams;
67
- /** See `FetchCollectionProps.withDeleted`. */
68
- withDeleted?: WithDeleted;
69
- }): Promise<Record<string, unknown>[]>;
55
+ fetchCollection<M extends Record<string, unknown>>(collectionPath: string, options?: Parameters<FetchService["fetchCollection"]>[1]): Promise<Record<string, unknown>[]>;
70
56
  /**
71
57
  * The REST read pipeline: flat rows, with exactly the relations `include`
72
58
  * names — see {@link FetchService.fetchCollectionForRest}.
@@ -132,10 +118,7 @@ export declare class DataService implements DataRepository {
132
118
  /**
133
119
  * Save an row (create or update)
134
120
  */
135
- save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: {
136
- upsert?: boolean;
137
- onConflict?: readonly string[];
138
- }): Promise<Record<string, unknown>>;
121
+ save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: PersistSaveOptions): Promise<Record<string, unknown>>;
139
122
  /**
140
123
  * Delete an row by ID
141
124
  */
@@ -1,6 +1,9 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
+ import { ApiError } from "@rebasepro/server";
3
4
  import type { ParsedFieldOp } from "@rebasepro/server";
5
+ import type { Properties } from "@rebasepro/types";
6
+ import type { DrizzleClient } from "../interfaces.js";
4
7
  /**
5
8
  * The expression one operation compiles to, or a 400 saying why it cannot.
6
9
  *
@@ -14,3 +17,71 @@ export declare function compileFieldOp(table: PgTable, field: string, op: Parsed
14
17
  export declare function compileFieldOps(table: PgTable, fieldOps: Record<string, ParsedFieldOp>, context: {
15
18
  collectionPath: string;
16
19
  }): Record<string, SQL>;
20
+ /**
21
+ * One declared bound an operation's result has to stay inside.
22
+ *
23
+ * `code` and `message` are what a plain value breaking the same rule reports
24
+ * (see `write-validation.ts`), so `{ stock: { $inc: -1000 } }` and
25
+ * `{ stock: -5 }` are refused with one answer.
26
+ */
27
+ export interface FieldOpBound {
28
+ /** The property key. */
29
+ field: string;
30
+ code: "min" | "max" | "more_than" | "less_than" | "positive" | "negative" | "min_items" | "max_items";
31
+ /**
32
+ * TRUE when the value the operation writes keeps to the rule, evaluated
33
+ * against the row the statement is changing. Never NULL: an unset column
34
+ * reads as `0` or empty wherever the operation itself reads it so, and an
35
+ * array the operation leaves unset has no size for a bound to judge.
36
+ */
37
+ holds: SQL;
38
+ message: string;
39
+ }
40
+ /**
41
+ * The declared bounds each operation's *result* must satisfy, as conditions
42
+ * for the UPDATE that applies it.
43
+ *
44
+ * `{ stock: -5 }` on `stock: { validation: { min: 0 } }` is refused before the
45
+ * statement is built, because the value is in the request. What
46
+ * `{ stock: { $inc: -1000 } }` produces depends on the stored row, which the
47
+ * request never sees, so it can only be judged where the row is: in the WHERE
48
+ * of the UPDATE that does the arithmetic, over the row that statement holds
49
+ * locked. Reading the row first and judging the sum would reopen the race the
50
+ * operation exists to close — two decrements of a stock of 1 would each read
51
+ * `1`, each find room for one more, and both go through. A condition on the
52
+ * UPDATE cannot be raced that way: under READ COMMITTED an UPDATE blocked on a
53
+ * concurrent writer re-evaluates its WHERE against the row that writer
54
+ * committed, so the second decrement sees `0 - 1` and matches nothing.
55
+ *
56
+ * The bound is the whole declared range, applied to the result the way it
57
+ * applies to a value: a number's `min`, `max`, `moreThan`, `lessThan`,
58
+ * `positive` and `negative`, an array's `min` and `max` items. `integer` is not
59
+ * here: a whole increment of a whole number stays whole, and the request
60
+ * already refuses a fractional one. `$merge` has no bounds to hold.
61
+ */
62
+ export declare function compileFieldOpBounds(table: PgTable, fieldOps: Record<string, ParsedFieldOp>, properties: Properties, context: {
63
+ collectionPath: string;
64
+ }): FieldOpBound[];
65
+ /**
66
+ * Which bounds the row, as it now stands, would break — for an UPDATE that
67
+ * carried bounds and matched nothing.
68
+ *
69
+ * A guarded UPDATE matching zero rows means one of three things: the row is not
70
+ * there for this caller (404), a row-level security policy refused the write
71
+ * (403), or the operation's result broke a bound (400). The first two are
72
+ * `explainZeroRowWrite`'s; this answers the third, by evaluating each bound
73
+ * against the row over the same handle and the same key conditions. An empty
74
+ * answer — the row is not visible, or every bound holds — leaves the decision to
75
+ * `explainZeroRowWrite`.
76
+ *
77
+ * It is a second statement, so the row can change in between. A row deleted in
78
+ * between is a 404 here as it would have been anyway. A row changed so that the
79
+ * operation now fits reads as "every bound holds" and falls through to the
80
+ * row-level-security answer; the write was refused either way, and the
81
+ * refusal is never turned into a write.
82
+ *
83
+ * Only booleans come back — whether each bound holds — never the stored value.
84
+ */
85
+ export declare function brokenFieldOpBounds(handle: DrizzleClient, table: PgTable, conditions: SQL[], bounds: FieldOpBound[]): Promise<FieldOpBound[]>;
86
+ /** The 400 a write gets when an operation's result breaks a bound. */
87
+ export declare function fieldOpBoundsError(collectionSlug: string, broken: FieldOpBound[]): ApiError;
@@ -1,3 +1,4 @@
1
+ import { type SQL } from "drizzle-orm";
1
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
2
3
  import type { Properties, ResolvedVia } from "@rebasepro/types";
3
4
  import { DrizzleClient } from "../interfaces.js";
@@ -49,6 +50,19 @@ export interface JunctionLinkWrite {
49
50
  /** Payload values keyed by property key, already serialized for the driver. */
50
51
  pivot?: Record<string, unknown>;
51
52
  }
53
+ /**
54
+ * The target rows a membership write can see, when that is not all of them —
55
+ * the target's `beforeQuery` scope and its soft delete.
56
+ *
57
+ * The diff reads "what is linked now" through it, so a link to a row the caller
58
+ * was never shown is in neither list and is left alone rather than unlinked.
59
+ */
60
+ export interface VisibleTargets {
61
+ table: PgTable;
62
+ /** The target column the junction's `targetColumn` holds values of. */
63
+ idColumn: AnyPgColumn;
64
+ condition: SQL;
65
+ }
52
66
  /** The junction a `manyToMany` names outright. */
53
67
  export declare function bindThroughJunction(registry: PostgresCollectionRegistry, through: {
54
68
  table: string;
@@ -91,8 +105,10 @@ export declare function removeJunctionLink(tx: DrizzleClient, binding: JunctionB
91
105
  * the form runs under RLS, so a user who may edit the parent but cannot see
92
106
  * some of the linked rows gets a shorter list — and writing it back deleted
93
107
  * the links they were never shown. The select that drives the diff runs in
94
- * this same transaction under the same policies, so a link the caller cannot
95
- * read is in neither list and survives the save.
108
+ * this same transaction under the same policies, and through
109
+ * `visibleTargets` — the target's `beforeQuery` scope and soft delete, which
110
+ * RLS knows nothing of — so a link the caller cannot read is in neither list
111
+ * and survives the save.
96
112
  * - **Junction payload columns.** A junction carrying its own columns
97
113
  * (`position`, `role`, `created_at`) lost them on every save, because every
98
114
  * row was re-inserted with only the two keys. Untouched links are left alone.
@@ -115,7 +131,7 @@ export declare function removeJunctionLink(tx: DrizzleClient, binding: JunctionB
115
131
  * that is what keeps a plain `[1, 2, 3]` membership write from wiping the
116
132
  * `role`s off the links it did not mention.
117
133
  */
118
- export declare function applyJunctionMembership(tx: DrizzleClient, binding: JunctionBinding, parentId: unknown, links: JunctionLinkWrite[]): Promise<void>;
134
+ export declare function applyJunctionMembership(tx: DrizzleClient, binding: JunctionBinding, parentId: unknown, links: JunctionLinkWrite[], visibleTargets?: VisibleTargets): Promise<void>;
119
135
  /**
120
136
  * Set the columns of ONE existing link, without touching the membership.
121
137
  *
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Every cross-instance feature in the backend needs the same thing: one
5
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.
6
+ * comes back on its own after the database or the network drops it. CDC, the
7
+ * channel bus and the cross-instance broadcast all use this one. By default the
8
+ * **first** connect is validated and rethrown, so a caller can fall back to a
9
+ * different strategy, while every later drop is repaired quietly in the
10
+ * background; `retryInitialConnect` repairs the first one the same way.
11
11
  *
12
12
  * `LISTEN` is session state, so this connection must not go through a
13
13
  * transaction-mode pooler (PgBouncer): give it the direct database URL.
@@ -23,25 +23,114 @@ export interface PgNotifyListenerOptions {
23
23
  logLabel: string;
24
24
  /** Delay before a reconnect attempt. */
25
25
  reconnectDelayMs?: number;
26
+ /**
27
+ * Called once the connection is listening again after a drop — never after
28
+ * the first connect. Postgres keeps no NOTIFY for a session that is not
29
+ * listening, so whatever was published while the connection was down is
30
+ * gone for good; a caller that mirrors state from the channel resyncs here.
31
+ */
32
+ onReconnect?: () => void;
33
+ /**
34
+ * How often the connection proves it is alive with a round trip. `0`
35
+ * turns the heartbeat off. See {@link PgNotifyListener} for why it exists.
36
+ */
37
+ heartbeatIntervalMs?: number;
38
+ /** How long a heartbeat may take before the connection is declared dead. */
39
+ heartbeatTimeoutMs?: number;
40
+ /**
41
+ * Treat a failed first connect like any later drop: `start()` resolves,
42
+ * the listener reports itself down, and it keeps dialling in the
43
+ * background. For a caller with nothing to fall back to, for whom a
44
+ * database that is unreachable at boot is an outage to wait out rather
45
+ * than a reason to run without the channel. Off by default: `start()`
46
+ * rejects, so a caller with a fallback can take it.
47
+ */
48
+ retryInitialConnect?: boolean;
49
+ }
50
+ /**
51
+ * What a listener can say about itself — read by `/health`.
52
+ *
53
+ * `connected` is "listening, and the last heartbeat answered", not "a socket
54
+ * object exists": a half-open connection has a perfectly good socket object.
55
+ */
56
+ export interface PgNotifyListenerStatus {
57
+ channel: string;
58
+ connected: boolean;
59
+ /** Since when (epoch ms) it has been down, while it is down. */
60
+ downSince?: number;
61
+ /** When (epoch ms) it last answered a heartbeat. */
62
+ lastHeartbeatAt?: number;
26
63
  }
64
+ /**
65
+ * Below every common idle-flow timeout: Azure's load balancer drops an idle
66
+ * flow at 4 minutes, AWS's NLB at 350 s, and NAT and conntrack tables and
67
+ * pooler-side reapers are often shorter. A LISTEN connection is idle by
68
+ * nature, so it is exactly the connection those timeouts kill.
69
+ */
70
+ export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
71
+ export declare const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10000;
72
+ /**
73
+ * A half-open connection — one whose packets stop arriving, with no FIN and no
74
+ * RST, which is what an idle-flow timeout on a load balancer, a NAT, a dead
75
+ * middlebox or a failed-over network looks like — fires no `error` and no
76
+ * `end`. The connection object stays, `LISTEN` stays registered on a backend
77
+ * that may no longer exist, and every notification from then on is lost: CDC,
78
+ * the cross-instance broadcast and the channel bus all went quiet with no
79
+ * error, no reconnect and a green `/health`, while writes made through this
80
+ * instance still looked live because they are delivered locally.
81
+ *
82
+ * So the connection proves itself: TCP keepalive is on, and every
83
+ * {@link DEFAULT_HEARTBEAT_INTERVAL_MS} a `SELECT 1` must come back within
84
+ * {@link DEFAULT_HEARTBEAT_TIMEOUT_MS}. One that does not is torn down and
85
+ * replaced, and `onReconnect` resyncs whatever was missed.
86
+ */
27
87
  export declare class PgNotifyListener {
28
88
  private readonly options;
29
89
  private client?;
30
90
  private running;
31
91
  private reconnectTimer?;
92
+ private heartbeatTimer?;
93
+ private heartbeatInFlight;
94
+ private downSince?;
95
+ private lastHeartbeatAt?;
32
96
  constructor(options: PgNotifyListenerOptions);
33
97
  /** Whether the listener is meant to be connected right now. */
34
98
  get active(): boolean;
99
+ /** Whether it is listening on a connection that answered its last heartbeat. */
100
+ get connected(): boolean;
101
+ status(): PgNotifyListenerStatus;
35
102
  /**
36
103
  * Connect and begin listening. Idempotent.
37
104
  *
38
105
  * Rejects if the *initial* connection or `LISTEN` fails, leaving the
39
106
  * listener stopped — callers use that to degrade deliberately instead of
40
- * running blind against a channel nothing is delivering.
107
+ * running blind against a channel nothing is delivering. With
108
+ * `retryInitialConnect` it resolves instead, reports itself down, and
109
+ * keeps dialling.
41
110
  */
42
111
  start(): Promise<void>;
43
112
  /** Stop listening and release the connection. Idempotent. */
44
113
  stop(): Promise<void>;
114
+ /**
115
+ * @param first The connect `start()` makes: never a reconnect, so it owes
116
+ * no resync, and — unless `retryInitialConnect` — a failure is
117
+ * thrown to the caller rather than retried.
118
+ */
45
119
  private connect;
120
+ /**
121
+ * Give up on the current connection: it is no longer the listener's, so
122
+ * its late events are ignored and `connected` says so at once; it is
123
+ * ended without waiting on it; and a replacement is scheduled.
124
+ */
125
+ private abandon;
126
+ private startHeartbeat;
127
+ private stopHeartbeat;
128
+ /**
129
+ * One round trip on the LISTEN connection. Not answered in time, it is
130
+ * treated as dead: the client is ended — which, with the query still
131
+ * outstanding, destroys the socket rather than waiting on a goodbye that
132
+ * cannot arrive — and replaced.
133
+ */
134
+ private heartbeat;
46
135
  private scheduleReconnect;
47
136
  }
@@ -0,0 +1,19 @@
1
+ import type { CollectionConfig, FetchCollectionProps } from "@rebasepro/types";
2
+ import type { FieldViewer } from "@rebasepro/common";
3
+ /**
4
+ * Refuse a read that names a field its caller may not read, for the doors that
5
+ * take a driver-shaped request rather than a query string: the socket's
6
+ * `FETCH_COLLECTION`, `COUNT` and `CHECK_UNIQUE_FIELD`, and `subscribe_collection`.
7
+ * Callers hand it the request itself, not a re-listed copy of some of its keys.
8
+ *
9
+ * The row strip keeps a withheld value off the wire; this is the other half, and
10
+ * without it the value is still readable one predicate at a time — a `COUNT`
11
+ * filtered on `passwordHash like '$2b$10$A%'` answers 1 or 0. `GET /api/data`
12
+ * refuses the same request with `FIELD_NOT_READABLE`, and this is that rule, not
13
+ * a copy of it: only the request's shape is translated here.
14
+ *
15
+ * The shape arrives as whatever JSON the client sent, so it is read defensively.
16
+ * A malformed `orderBy` or `fields` is not this check's to reject; the entries
17
+ * that name a field are checked and the rest are left for the driver to refuse.
18
+ */
19
+ export declare function assertReadRequestReadable(request: Pick<FetchCollectionProps, "filter" | "logical" | "orderBy" | "fields" | "include" | "vectorSearch">, collection: CollectionConfig, viewer: FieldViewer): void;