@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.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 (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { RelationService } from "./RelationService";
2
+ import { RelationWriteService } from "./RelationWriteService";
2
3
  import { FetchService } from "./FetchService";
3
4
  import { DrizzleClient } from "../interfaces";
4
5
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
@@ -9,25 +10,15 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
9
10
  export declare class PersistService {
10
11
  private db;
11
12
  private registry;
13
+ /** Reads: whether a row is under a parent, the key a link joins on. */
12
14
  private relationService;
15
+ /** Writes: junction membership, foreign-key stamping, links. */
16
+ private relationWrites;
13
17
  private fetchService;
14
18
  constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
15
19
  /**
16
- * Explain a write that matched no rows.
17
- *
18
- * Row-level security filters UPDATE and DELETE through the policy's USING
19
- * clause instead of raising: a denied write is reported by Postgres exactly
20
- * like a successful one that happened to match nothing. Left unchecked, a
21
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
22
- * the row is untouched.
23
- *
24
- * Re-reading the target over the *same* RLS-scoped handle separates the two
25
- * cases. A visible row means the policy rejected the write (403); an
26
- * invisible one means there is nothing there to write for this caller (404,
27
- * matching what a GET would say). The re-read is bound by the caller's own
28
- * policies, so it discloses nothing a plain read wouldn't.
29
- *
30
- * Only reached when zero rows matched, so the happy path pays nothing.
20
+ * Explain a row write that matched nothing — see {@link explainZeroRowWrite}
21
+ * for why a zero-row write cannot be reported as success.
31
22
  */
32
23
  private explainZeroRowWrite;
33
24
  /**
@@ -39,8 +30,14 @@ export declare class PersistService {
39
30
  */
40
31
  deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
41
32
  /**
42
- * The column on the *target* table that records the parent, for a create
43
- * under a nested one-to-many path.
33
+ * The field on the *target* row that records the parent, for a create under
34
+ * a nested one-to-many path.
35
+ *
36
+ * A **field**, not the column: the value is stamped into the caller's
37
+ * payload, which is keyed by wire names — `authorId`, never the `author_id`
38
+ * the relation names its link by. Stamping the column instead put a key on
39
+ * the payload that no property answers to, and `strictWrites` rejected the
40
+ * request the framework had just written to.
44
41
  *
45
42
  * Returns `undefined` when the link is not a column at all (a multi-hop
46
43
  * `joinPath`), so the caller writes the row without stamping anything.
@@ -66,6 +63,13 @@ export declare class PersistService {
66
63
  * Get the RelationService instance for external use
67
64
  */
68
65
  getRelationService(): RelationService;
66
+ /**
67
+ * The write half, for external use. Separate from
68
+ * {@link getRelationService} because they are separate objects now: reads
69
+ * answer questions, writes change rows, and the callers of one are not the
70
+ * callers of the other.
71
+ */
72
+ getRelationWriteService(): RelationWriteService;
69
73
  /**
70
74
  * Get the FetchService instance for external use
71
75
  */
@@ -1,6 +1,5 @@
1
1
  import { DrizzleClient } from "../interfaces";
2
- import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
- import { type ResolvedVia } from "@rebasepro/types";
2
+ import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
4
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
5
4
  import type { NestedPathHop } from "./nested-path";
6
5
  /**
@@ -78,7 +77,14 @@ export declare class RelationService {
78
77
  * nested path and would otherwise write the id and lose the row.
79
78
  */
80
79
  parentKeyValue(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentId: string | number, db?: DrizzleClient): Promise<string | number | undefined>;
81
- private resolveSourceKeys;
80
+ /**
81
+ * Shared with {@link RelationWriteService}: a write needs the same source
82
+ * key a read does, and resolving it twice is how the two would disagree.
83
+ */
84
+ resolveSourceKeys(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentIds: (string | number)[], db?: DrizzleClient): Promise<{
85
+ keyByParentId: Map<string, string | number>;
86
+ parentIdByKey: Map<string, string | number>;
87
+ }>;
82
88
  /**
83
89
  * Fetch rows related to a parent row through a specific relation
84
90
  */
@@ -129,16 +135,6 @@ export declare class RelationService {
129
135
  * belonged to. Reads, updates and deletes now all gate on this.
130
136
  */
131
137
  isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean>;
132
- /**
133
- * Remove the junction row linking a parent to `targetId`, leaving the target
134
- * row itself alone.
135
- *
136
- * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
137
- * target is shared, so deleting the row would remove the tag from every other
138
- * post that uses it. It used to do exactly that — resolve the path to the
139
- * `tags` table and delete by primary key.
140
- */
141
- unlinkRelatedEntity(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number): Promise<void>;
142
138
  /**
143
139
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
144
140
  */
@@ -149,48 +145,4 @@ export declare class RelationService {
149
145
  * Uses a single SQL query with IN clause to avoid N+1.
150
146
  */
151
147
  batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>>;
152
- /**
153
- * Update many-to-many and junction relations
154
- */
155
- updateRelationsUsingJoins<M extends Record<string, unknown>>(tx: DrizzleClient, collection: CollectionConfig, id: string | number, relationValues: Partial<M>): Promise<void>;
156
- /**
157
- * Update inverse relations (where FK is on the target table)
158
- */
159
- updateInverseRelations(tx: DrizzleClient, sourceCollection: CollectionConfig, sourceEntityId: string | number, inverseRelationUpdates: Array<{
160
- relationKey: string;
161
- relation: ResolvedRelation;
162
- newValue: unknown;
163
- }>): Promise<void>;
164
- /**
165
- * Handle inverse relations with joinPath
166
- */
167
- private updateInverseJoinPathRelation;
168
- /**
169
- * Handle many-to-many inverse relation updates using junction tables
170
- */
171
- private updateManyToManyInverseRelation;
172
- /**
173
- * Update one-to-one relations that use joinPath
174
- */
175
- updateJoinPathOneToOneRelations(tx: DrizzleClient, parentCollection: CollectionConfig, parentId: string | number, updates: Array<{
176
- relationKey: string;
177
- relation: ResolvedVia;
178
- newTargetId: string | number | null;
179
- }>): Promise<void>;
180
- /**
181
- * Resolve joinPath write mapping for one-to-one relations
182
- */
183
- resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation: ResolvedVia): {
184
- targetFKColName: string;
185
- parentSourceColName: string;
186
- };
187
- /**
188
- * Handle junction table creation for many-to-many path-based saves
189
- */
190
- handleJunctionTableCreation(tx: DrizzleClient, newEntityId: string | number, junctionTableInfo: {
191
- parentCollection: CollectionConfig;
192
- parentId: string | number;
193
- relation: ResolvedManyToMany;
194
- relationKey: string;
195
- }): Promise<void>;
196
148
  }
@@ -0,0 +1,82 @@
1
+ import { CollectionConfig, ResolvedManyToMany, ResolvedRelation, ResolvedVia } from "@rebasepro/types";
2
+ import { DrizzleClient } from "../interfaces";
3
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
+ import type { NestedPathHop } from "./nested-path";
5
+ /**
6
+ * Writing relations: junction membership, foreign-key stamping, and the links a
7
+ * nested path creates or removes.
8
+ *
9
+ * Split from {@link RelationService}, which now only reads. The two had grown
10
+ * into one 1700-line class doing two unrelated jobs, and sharing one habit —
11
+ * warning about a relation it could not resolve and carrying on, which surfaces
12
+ * as an empty list on the read side and as a successful save on the write side.
13
+ * Separating them is what made that visible as one class of defect rather than
14
+ * eighteen scattered log lines.
15
+ *
16
+ * The reads it still needs — the source key a link joins on — it asks
17
+ * {@link RelationService} for rather than reimplementing.
18
+ */
19
+ export declare class RelationWriteService {
20
+ private db;
21
+ private registry;
22
+ private reads;
23
+ constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
24
+ /**
25
+ * Remove the junction row linking a parent to `targetId`, leaving the target
26
+ * row itself alone.
27
+ *
28
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
29
+ * target is shared, so deleting the row would remove the tag from every other
30
+ * post that uses it. It used to do exactly that — resolve the path to the
31
+ * `tags` table and delete by primary key.
32
+ */
33
+ unlinkRelatedEntity(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number): Promise<void>;
34
+ /** A collection's id, parsed to the type its primary key column holds. */
35
+ private parsedId;
36
+ /** The same, for the membership list a to-many write names. */
37
+ private parsedIds;
38
+ /**
39
+ * Update many-to-many and junction relations
40
+ */
41
+ updateRelationsUsingJoins<M extends Record<string, unknown>>(tx: DrizzleClient, collection: CollectionConfig, id: string | number, relationValues: Partial<M>): Promise<void>;
42
+ /**
43
+ * Update inverse relations (where FK is on the target table)
44
+ */
45
+ updateInverseRelations(tx: DrizzleClient, sourceCollection: CollectionConfig, sourceEntityId: string | number, inverseRelationUpdates: Array<{
46
+ relationKey: string;
47
+ relation: ResolvedRelation;
48
+ newValue: unknown;
49
+ }>): Promise<void>;
50
+ /**
51
+ * Handle inverse relations with joinPath
52
+ */
53
+ private updateInverseJoinPathRelation;
54
+ /**
55
+ * Handle many-to-many inverse relation updates using junction tables
56
+ */
57
+ private updateManyToManyInverseRelation;
58
+ /**
59
+ * Update one-to-one relations that use joinPath
60
+ */
61
+ updateJoinPathOneToOneRelations(tx: DrizzleClient, parentCollection: CollectionConfig, parentId: string | number, updates: Array<{
62
+ relationKey: string;
63
+ relation: ResolvedVia;
64
+ newTargetId: string | number | null;
65
+ }>): Promise<void>;
66
+ /**
67
+ * Resolve joinPath write mapping for one-to-one relations
68
+ */
69
+ resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation: ResolvedVia): {
70
+ targetFKColName: string;
71
+ parentSourceColName: string;
72
+ };
73
+ /**
74
+ * Handle junction table creation for many-to-many path-based saves
75
+ */
76
+ handleJunctionTableCreation(tx: DrizzleClient, newEntityId: string | number, junctionTableInfo: {
77
+ parentCollection: CollectionConfig;
78
+ parentId: string | number;
79
+ relation: ResolvedManyToMany;
80
+ relationKey: string;
81
+ }): Promise<void>;
82
+ }
@@ -1,6 +1,7 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
2
  import { CollectionConfig, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
+ import { ApiError } from "@rebasepro/server";
4
5
  export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
5
6
  export type { PrimaryKeyInfo } from "@rebasepro/common";
6
7
  import type { PrimaryKeyInfo } from "@rebasepro/common";
@@ -34,6 +35,47 @@ export declare function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta;
34
35
  */
35
36
  export declare function idCanAddressTable(id: string | number, table: PgTable, idInfoArray: PrimaryKeyInfo[]): boolean;
36
37
  export declare function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig;
38
+ /**
39
+ * Reject a write naming something that is not a column of the table.
40
+ *
41
+ * Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
42
+ * from `Object.keys(tableColumns)`, so a key the table does not carry is not
43
+ * rejected by anything — it is *left out of the statement*. The insert answers
44
+ * 201 having stored nothing under that name; the update, if the key was the
45
+ * only one, builds `update "posts" set where …` and Postgres raises a syntax
46
+ * error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
47
+ * 500 for what is a caller's typo.
48
+ *
49
+ * That makes this the last honest place to check, and the only one every write
50
+ * passes through. `assertKnownWriteFields` in the REST layer checks the same
51
+ * thing against the *config* and is skipped on four paths — `strictWrites:
52
+ * false`, a collection declaring no properties, an auth adapter that owns the
53
+ * body's shape, and a nested route whose target cannot be walked — and it never
54
+ * sees an in-process `rebase.data` write at all.
55
+ *
56
+ * It also gives `strictWrites: false` a truthful implementation. The flag is
57
+ * documented for "a column that really does exist which the config never
58
+ * declared", and skipping the config check alone could not deliver that: the
59
+ * value was dropped a layer later regardless. Skipping the config check and
60
+ * keeping this one does exactly what the flag says — the column must exist,
61
+ * the property need not.
62
+ */
63
+ export declare function assertWritableColumns(values: Record<string, unknown>, table: PgTable, collectionPath: string): void;
64
+ /**
65
+ * A relation whose names do not resolve against the registered schema.
66
+ *
67
+ * Every one of these used to be a `logger.warn` followed by `continue`, so a
68
+ * save reported success for a relation it had not written and a read answered
69
+ * `[]` for one it could not resolve. `assertRelationsResolve` (validate-relations)
70
+ * fails boot on the same defects, which is where they belong — a server that
71
+ * refuses to start is recoverable in a minute. This is the second line, for the
72
+ * paths that assemble a registry by hand, and it exists so that "cannot resolve"
73
+ * is never again reported as "done".
74
+ *
75
+ * @param label `<collection>.<relation>`
76
+ * @param detail what does not resolve, in terms of the schema
77
+ */
78
+ export declare function relationMisconfigured(label: string, detail: string): ApiError;
37
79
  export declare function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any>;
38
80
  /**
39
81
  * The key columns a collection's rows are addressed by.
@@ -52,6 +52,8 @@ export declare class DataService implements DataRepository {
52
52
  */
53
53
  searchRows<M extends Record<string, unknown>>(collectionPath: string, searchString: string, options?: {
54
54
  filter?: FilterValues<Extract<keyof M, string>>;
55
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
56
+ logical?: LogicalCondition;
55
57
  orderBy?: string;
56
58
  order?: "desc" | "asc";
57
59
  limit?: number;
@@ -0,0 +1,82 @@
1
+ import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
2
+ import type { ResolvedVia } from "@rebasepro/types";
3
+ import { DrizzleClient } from "../interfaces";
4
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
5
+ /**
6
+ * Writing a many-to-many means writing rows in a junction table, and doing that
7
+ * needs three things: the table, the column naming the row being written from,
8
+ * and the column naming the far side.
9
+ *
10
+ * Those three used to be re-derived at each of the four call sites — twice for
11
+ * `through`, twice for `joinPath`, in the owning and the inverse direction —
12
+ * and the two `joinPath` derivations did not agree. The inverse one keyed off
13
+ * `step.table`; the owning one asked `getTableNamesFromColumns` which table a
14
+ * step's columns belonged to, and that answers `""` for an unqualified column
15
+ * name. So for `{ table: "posts_tags", on: { from: "id", to: "tag_id" } }` —
16
+ * the form the docs and every fixture use — no branch matched, both columns
17
+ * stayed null, and the write was skipped with a warning nobody reads. Writing
18
+ * a to-many `via` relation did nothing at all.
19
+ *
20
+ * Hence one binder, keyed on `step.table` (always present) and accepting the
21
+ * qualified `table.column` form where it is used.
22
+ */
23
+ export interface JunctionBinding {
24
+ table: PgTable;
25
+ /** The junction column holding the id of the row being written from. */
26
+ parentColumn: AnyPgColumn;
27
+ /** The junction column holding the id of the row on the far side. */
28
+ targetColumn: AnyPgColumn;
29
+ /** `<collection>.<relation>`, for error messages. */
30
+ label: string;
31
+ }
32
+ /** The junction a `manyToMany` names outright. */
33
+ export declare function bindThroughJunction(registry: PostgresCollectionRegistry, through: {
34
+ table: string;
35
+ sourceColumn: string;
36
+ targetColumn: string;
37
+ }, label: string): JunctionBinding;
38
+ /**
39
+ * The junction a `via` relation reaches through, from either end.
40
+ *
41
+ * A step is `{ table: T, on: { from, to } }` where `from` names a column on
42
+ * whatever the walk was standing on and `to` names one on `T`. That positional
43
+ * meaning is what makes the unqualified form work at all, so the walk carries
44
+ * the previous table rather than asking the column names where they live.
45
+ */
46
+ export declare function bindJoinPathJunction(registry: PostgresCollectionRegistry, joinPath: ResolvedVia["joinPath"], parentTableName: string, targetTableName: string, label: string): JunctionBinding;
47
+ /**
48
+ * Remove one link, leaving the row on the far side alone.
49
+ *
50
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
51
+ * target is shared, so deleting the row would remove the tag from every other
52
+ * post that uses it.
53
+ */
54
+ export declare function removeJunctionLink(tx: DrizzleClient, binding: JunctionBinding, parentId: unknown, targetId: unknown, subject: {
55
+ parent: string;
56
+ relation: string;
57
+ }): Promise<void>;
58
+ /**
59
+ * Make the junction say that `parentId` is linked to exactly `targetIds`, by
60
+ * diffing against what is linked now rather than replacing the set.
61
+ *
62
+ * A save of the parent used to delete every junction row for it and re-insert
63
+ * the ids the browser sent — a list the browser assembled from a read it did
64
+ * earlier. Three things followed, all data loss rather than display:
65
+ *
66
+ * - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
67
+ * saves any field from a form that predates it, and X is gone with nothing
68
+ * reported to either of them.
69
+ * - **A partially-read set is a partially-deleted set.** The read that fills
70
+ * the form runs under RLS, so a user who may edit the parent but cannot see
71
+ * some of the linked rows gets a shorter list — and writing it back deleted
72
+ * the links they were never shown. The select that drives the diff runs in
73
+ * this same transaction under the same policies, so a link the caller cannot
74
+ * read is in neither list and survives the save.
75
+ * - **Junction payload columns.** A junction carrying its own columns
76
+ * (`position`, `role`, `created_at`) lost them on every save, because every
77
+ * row was re-inserted with only the two keys. Untouched links are left alone.
78
+ *
79
+ * The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
80
+ * concurrently is a no-op rather than a unique violation.
81
+ */
82
+ export declare function applyJunctionMembership(tx: DrizzleClient, binding: JunctionBinding, parentId: unknown, targetIds: unknown[]): Promise<void>;
@@ -14,6 +14,34 @@ export interface SubscriptionAuthContext {
14
14
  uid: string;
15
15
  roles: string[];
16
16
  }
17
+ /** What a channel frame is asking to do. */
18
+ export type ChannelAction = "join" | "broadcast" | "presence" | "history";
19
+ /** Everything an authorizer is told about the frame it is asked to allow. */
20
+ export interface ChannelAuthorizationRequest {
21
+ /** The channel the frame names, exactly as the client wrote it. */
22
+ channel: string;
23
+ action: ChannelAction;
24
+ /** The socket, not the principal — one user may hold several. */
25
+ clientId: string;
26
+ /** The socket's authenticated principal, or the anonymous one. */
27
+ user?: SubscriptionAuthContext;
28
+ }
29
+ /**
30
+ * The extension point for channel access rules.
31
+ *
32
+ * **This is deliberately not a product API yet.** The rule *language* — a
33
+ * config key, a per-pattern DSL, how it composes with `securityRules` — is an
34
+ * open design question (see `docs/channel-authorization.md`), and
35
+ * inventing one here would be inventing the answer. What exists is the single
36
+ * place every channel frame passes through, so that whatever shape the rules
37
+ * eventually take has exactly one seam to plug into and no arm of the switch
38
+ * can be forgotten.
39
+ *
40
+ * Returning `false` — or throwing — refuses the frame. It is consulted *after*
41
+ * the membership floor below, so an authorizer can only ever narrow access,
42
+ * never widen it.
43
+ */
44
+ export type ChannelAuthorizer = (request: ChannelAuthorizationRequest) => boolean | Promise<boolean>;
17
45
  /**
18
46
  * The narrowing a collection subscription was created with, kept so that every
19
47
  * refetch answers the same query the initial fetch did.
@@ -46,6 +74,12 @@ type StoredCollectionRequest = {
46
74
  export declare class RealtimeService extends EventEmitter implements RealtimeProvider {
47
75
  private db;
48
76
  private registry;
77
+ /**
78
+ * Declares to the multi-engine router that channel frames can be handled
79
+ * here. Read by `createRoutedRealtimeService`, which otherwise would have to
80
+ * guess — and guessed "the default provider", whichever engine that is.
81
+ */
82
+ readonly supportsChannels = true;
49
83
  private clients;
50
84
  private channels;
51
85
  private presence;
@@ -92,6 +126,23 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
92
126
  * so a hot channel logs the problem once rather than once per message.
93
127
  */
94
128
  private oversizedBroadcastWarned;
129
+ /**
130
+ * Optional narrowing on top of the membership floor — see
131
+ * {@link ChannelAuthorizer}. Unset by default, which leaves membership as
132
+ * the whole of the rule.
133
+ */
134
+ private channelAuthorizer?;
135
+ /**
136
+ * Whether a notification from another instance has ever arrived.
137
+ *
138
+ * The entity LISTEN handler sees a foreign `sid` on every cross-instance
139
+ * change, which is proof that this deployment runs more than one pod — the
140
+ * one fact needed to tell "the memory bus is fine here" from "broadcast and
141
+ * presence silently reach a fraction of your users".
142
+ */
143
+ private foreignInstanceSeen;
144
+ /** So the multi-pod memory-bus warning is emitted once, not once per join. */
145
+ private memoryBusWarned;
95
146
  private presenceInterval?;
96
147
  private static readonly PRESENCE_TIMEOUT_MS;
97
148
  /** How often stale roster rows from other instances are reaped. */
@@ -196,7 +247,39 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
196
247
  */
197
248
  notifyUpdate(path: string, id: string, row: Record<string, unknown> | null, databaseId?: string, broadcast?: boolean, origin?: "app" | "cdc"): Promise<void>;
198
249
  /**
199
- * Notify subscriptions for a specific path
250
+ * Notify subscriptions for a specific path.
251
+ *
252
+ * **A subscriber only ever receives rows re-read under its own scope.**
253
+ * `row` is used to decide *that* something changed, never to say *what* —
254
+ * every delivery below goes through a refetch that binds the subscription's
255
+ * own auth context.
256
+ *
257
+ * It used to be conditional. The CDC path already did the right thing: it
258
+ * discards the captured tuple and emits `{_rebase_invalidated: true}`, and
259
+ * that marker selected the refetch branch. But the marker is produced in
260
+ * exactly two places, and the *other* side of each branch here shipped the
261
+ * row it was handed straight to the socket. Two of the three entry paths
262
+ * took that side — every API mutation (`PostgresBackendDriver.save` passes
263
+ * the row it just wrote, read under the **writer's** scope) and the legacy
264
+ * cross-instance LISTEN handler (which re-reads on the owner connection,
265
+ * bypassing RLS altogether). Path matching was the only filter applied: the
266
+ * subscription's own `filter`/`logical` was never evaluated, and any
267
+ * `afterRead` redaction was the writer's rather than the reader's.
268
+ *
269
+ * A single-row subscription was the sharpest case. `subscribe_one` on a row
270
+ * RLS denies is accepted and answered `null`; the next update then pushed
271
+ * the full row with no later correction. The collection variant was merely
272
+ * papered over ~300 ms later by the debounced refetch — after the bytes had
273
+ * already reached the browser.
274
+ *
275
+ * The same defect was found and fixed on the Mongo driver in `065e2b615`
276
+ * (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
277
+ * the Postgres half, stated as one rule rather than three patched branches.
278
+ *
279
+ * The cost is the instant row-level patch that used to precede the refetch:
280
+ * cross-tab feedback now waits for the debounce. That is the price of not
281
+ * being able to know, without asking the database as this subscriber,
282
+ * whether this subscriber may see the row at all.
200
283
  */
201
284
  private notifyPathUpdate;
202
285
  /**
@@ -237,7 +320,6 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
237
320
  * columns and no address. The SDK holds no collection config to derive one
238
321
  * from, so this is the only place the mapping can come from.
239
322
  */
240
- private sendCollectionPatch;
241
323
  /** The key columns of the collection at `path`, if they can be resolved. */
242
324
  private primaryKeysForPath;
243
325
  private sendError;
@@ -247,8 +329,61 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
247
329
  * Returns ["posts", "posts/70"] for the example above
248
330
  */
249
331
  private getParentPaths;
332
+ /**
333
+ * Install a channel authorizer — see {@link ChannelAuthorizer}.
334
+ *
335
+ * Nothing in the framework calls this yet: it is the seam a rules API will
336
+ * be built on, kept deliberately separate from the membership floor so the
337
+ * floor holds whether or not anyone uses it.
338
+ */
339
+ setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void;
340
+ /** Which action each channel frame is asking to perform. */
341
+ private static readonly CHANNEL_ACTIONS;
342
+ /**
343
+ * The one door every channel frame comes through.
344
+ *
345
+ * Returns synchronously — and so dispatches synchronously — unless an
346
+ * authorizer is installed. That matters: a client sends `join_channel`,
347
+ * `presence_state` and `channel_history` back to back on connect, and the
348
+ * socket's message handler processes each frame up to its first `await`,
349
+ * so a gate that always yielded would let the reads overtake the join that
350
+ * is about to authorize them.
351
+ */
352
+ private handleChannelMessage;
353
+ /** Perform an already-authorized channel frame. */
354
+ private dispatchChannelMessage;
355
+ /**
356
+ * Decide whether a client may perform an action on a channel.
357
+ *
358
+ * **Membership is the floor.** Reading a channel's presence roster, replaying
359
+ * its retained history and broadcasting into it all require that this client
360
+ * has joined it. That is a low bar — joining is open to anyone who can name
361
+ * the channel — but it is not the bar that was there before, which was none
362
+ * at all: `channel_history` and `presence_state` answered any socket about
363
+ * any channel, and a broadcast fanned out to members the sender had never
364
+ * joined. Two internal tables (`rebase.channel_presence`,
365
+ * `rebase.channel_messages`) are held outside RLS on the strength of this
366
+ * check, so it fails closed: an authorizer that throws refuses the frame.
367
+ *
368
+ * Anything richer than membership belongs in a {@link ChannelAuthorizer};
369
+ * this method is where it is consulted, and the only place.
370
+ */
371
+ private authorizeChannelAction;
372
+ /** Tell the client why its channel frame went nowhere, and say so in the log. */
373
+ private denyChannelAction;
250
374
  /** Join a broadcast channel */
251
375
  joinChannel(clientId: string, channel: string): void;
376
+ /**
377
+ * Say something the first time channels are used on a deployment that is
378
+ * demonstrably multi-pod while the bus is still the in-memory default.
379
+ *
380
+ * Every other warning in this subsystem covers a *configured* bus failing —
381
+ * the case where the operator already knew a bus mattered. The common
382
+ * misconfiguration is the opposite one: scaled to two replicas, never
383
+ * touched `realtime.bus`, and broadcast and presence quietly serve a
384
+ * fraction of the room. The evidence is already in the process, so use it.
385
+ */
386
+ private warnIfMemoryBusOnMultiplePods;
252
387
  /** Leave a broadcast channel */
253
388
  leaveChannel(clientId: string, channel: string): void;
254
389
  /**
@@ -0,0 +1,36 @@
1
+ import { SQL } from "drizzle-orm";
2
+ import { PgTable } from "drizzle-orm/pg-core";
3
+ import { ApiError } from "@rebasepro/server";
4
+ import { DrizzleClient } from "../interfaces";
5
+ /**
6
+ * Explain a write that matched no rows.
7
+ *
8
+ * Row-level security filters UPDATE and DELETE through the policy's USING
9
+ * clause instead of raising: a denied write is reported by Postgres exactly
10
+ * like a successful one that happened to match nothing. Left unchecked, a
11
+ * caller cannot tell "denied" from "done" — the write returns 200/204 and the
12
+ * row is untouched. An agent handed a key with `orders:delete` and no delete
13
+ * policy is the case that makes it concrete: it deletes nothing, forever, and
14
+ * is told it worked every time.
15
+ *
16
+ * Re-reading the target over the *same* RLS-scoped handle separates the two
17
+ * cases. A visible row means the policy rejected the write (403); an invisible
18
+ * one means there is nothing there to write for this caller (404, matching what
19
+ * a GET would say). The re-read is bound by the caller's own policies, so it
20
+ * discloses nothing a plain read wouldn't.
21
+ *
22
+ * Only reached when zero rows matched, so the happy path pays nothing.
23
+ *
24
+ * It lives here, rather than beside its first caller, because every zero-row
25
+ * write has to answer the same question and answer it identically: the rule
26
+ * that a readable-but-unwritable target is a 403 is the contract, and a second
27
+ * copy of it is a second chance to get it wrong.
28
+ *
29
+ * @param handle The RLS-scoped connection the write ran on — not a fresh
30
+ * one, or the re-read would answer for a different caller.
31
+ * @param table The table the write targeted (the junction, for a link).
32
+ * @param conditions The write's own WHERE terms, reused verbatim.
33
+ * @param denied Message for the 403: the target is there and was refused.
34
+ * @param missing Message for the 404: there is nothing there for this caller.
35
+ */
36
+ export declare function explainZeroRowWrite(handle: DrizzleClient, table: PgTable, conditions: SQL[], denied: string, missing: string): Promise<ApiError>;