@rebasepro/server-postgres 0.10.1-canary.6f89f77 → 0.10.1-canary.7801eed
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1853 -4900
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +133 -6
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +87 -5
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/index.ts +4 -0
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +430 -11
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bringing a database up to date with a bundle's collections, additively.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A managed runtime boots someone else's compiled project against a database it
|
|
7
|
+
* has never seen. Auth tables are ensured at boot already, but collection tables
|
|
8
|
+
* were not created by anything: the platform ran the app and every `/api/data/*`
|
|
9
|
+
* request answered 500 on a missing relation. `rebase db push` cannot help — it
|
|
10
|
+
* is an Atlas-driven CLI command, and the runtime image ships no CLI.
|
|
11
|
+
*
|
|
12
|
+
* ## Why additive-only, forever
|
|
13
|
+
*
|
|
14
|
+
* This runs unattended, against a database with customers' data in it, with no
|
|
15
|
+
* human reading a diff. So it may only ever do things that cannot lose data:
|
|
16
|
+
* create a missing table, add a missing column, create a missing enum type.
|
|
17
|
+
*
|
|
18
|
+
* It will **never** drop a table or a column, narrow a type, or alter a
|
|
19
|
+
* constraint. A removed field leaves its column behind; a renamed field looks
|
|
20
|
+
* like an addition and the old column stays. That is the correct trade for an
|
|
21
|
+
* automated path — the alternative is an unattended process that can silently
|
|
22
|
+
* destroy a column, which is precisely the failure `db push` was hardened
|
|
23
|
+
* against. Destructive changes stay a deliberate, human-reviewed migration.
|
|
24
|
+
*
|
|
25
|
+
* Because of that, this is safe to run on every boot, and re-running it is a
|
|
26
|
+
* no-op.
|
|
27
|
+
*/
|
|
28
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
29
|
+
/**
|
|
30
|
+
* The subset of a database handle this needs: run a statement, get rows back.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately parameterless. Everything here is DDL or catalogue reads keyed by
|
|
33
|
+
* schema name, and schema names are identifiers — they cannot be bound as
|
|
34
|
+
* parameters anyway. They are validated against {@link SAFE_IDENTIFIER} before
|
|
35
|
+
* they reach a statement, so a config that somehow carried a quote is refused
|
|
36
|
+
* rather than concatenated.
|
|
37
|
+
*/
|
|
38
|
+
export interface Queryable {
|
|
39
|
+
query<T = unknown>(sql: string): Promise<{
|
|
40
|
+
rows: T[];
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
/** What the database currently has, as the planner needs it. */
|
|
44
|
+
export interface ExistingSchema {
|
|
45
|
+
/** `schema.table` → set of column names. */
|
|
46
|
+
tables: Map<string, Set<string>>;
|
|
47
|
+
/** `schema.typename` of every enum type that already exists. */
|
|
48
|
+
enums: Set<string>;
|
|
49
|
+
}
|
|
50
|
+
export interface EnsureAction {
|
|
51
|
+
kind: "create-enum" | "create-table" | "add-column";
|
|
52
|
+
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
53
|
+
target: string;
|
|
54
|
+
sql: string;
|
|
55
|
+
}
|
|
56
|
+
export interface EnsurePlan {
|
|
57
|
+
actions: EnsureAction[];
|
|
58
|
+
/** Every statement, in dependency order. Empty when the schema is current. */
|
|
59
|
+
statements: string[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Decide what to add. Pure — the caller supplies what exists and runs the result.
|
|
63
|
+
*
|
|
64
|
+
* Ordering matters and is deliberate: enum types before the tables and columns
|
|
65
|
+
* that reference them, tables before the columns added to other tables (a new
|
|
66
|
+
* table may be the target of a relation), and nothing is emitted twice.
|
|
67
|
+
*/
|
|
68
|
+
export declare function planCollectionSchemaEnsure(collections: CollectionConfig[], existing: ExistingSchema): EnsurePlan;
|
|
69
|
+
/** Read what the database has, for the schemas the collections live in. */
|
|
70
|
+
export declare function readExistingSchema(client: Queryable, schemas: string[]): Promise<ExistingSchema>;
|
|
71
|
+
/**
|
|
72
|
+
* Bring the database up to date. Returns what it did.
|
|
73
|
+
*
|
|
74
|
+
* Each statement runs on its own rather than in one transaction: they are all
|
|
75
|
+
* independently safe and idempotent, and a single failure (an enum label that
|
|
76
|
+
* cannot be added, say) should not roll back the tables that were created fine.
|
|
77
|
+
* The error is surfaced with the statement that caused it.
|
|
78
|
+
*/
|
|
79
|
+
export declare function ensureCollectionTables(client: Queryable, collections: CollectionConfig[], log?: (message: string) => void): Promise<EnsurePlan>;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { CollectionConfig } from "@rebasepro/types";
|
|
1
|
+
import { CollectionConfig, Property } from "@rebasepro/types";
|
|
2
|
+
export declare const resolveColumnName: (propName: string, prop?: Property | null) => string;
|
|
3
|
+
export declare const isIdProperty: (propName: string, prop: Property, collection: CollectionConfig) => boolean;
|
|
4
|
+
export declare const getSqlColumnType: (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]) => string;
|
|
2
5
|
export declare const generatePostgresDdl: (collections: CollectionConfig[], options?: {
|
|
3
6
|
includePolicies?: boolean;
|
|
4
7
|
}) => Promise<string>;
|
|
@@ -5,6 +5,7 @@ import type { VectorSearchParams } from "@rebasepro/types";
|
|
|
5
5
|
import { RelationService } from "./RelationService";
|
|
6
6
|
import { DrizzleClient } from "../interfaces";
|
|
7
7
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
8
|
+
import { type NestedPathHop } from "./nested-path";
|
|
8
9
|
/**
|
|
9
10
|
* Service for handling all row read operations.
|
|
10
11
|
* Handles fetching, searching, counting, and filtering rows.
|
|
@@ -65,6 +66,22 @@ export declare class FetchService {
|
|
|
65
66
|
* Extract cursor pagination conditions from startAfter options.
|
|
66
67
|
*/
|
|
67
68
|
private buildCursorConditions;
|
|
69
|
+
/**
|
|
70
|
+
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
71
|
+
* target table, so a nested listing can run as an ordinary collection query.
|
|
72
|
+
*/
|
|
73
|
+
private buildRelationScope;
|
|
74
|
+
/**
|
|
75
|
+
* Whether `id` is actually reachable at `collectionPath`.
|
|
76
|
+
*
|
|
77
|
+
* Trivially true for a root path. For a nested one it is a real question:
|
|
78
|
+
* the path resolves to the target collection, and matching on the primary
|
|
79
|
+
* key alone made the parent segment decorative — `authors/1/posts/43`
|
|
80
|
+
* returned post 43 whoever wrote it, and the REST layer's delete then
|
|
81
|
+
* deleted it. A row that is not under this parent is reported as absent,
|
|
82
|
+
* which is what a caller addressing it through the parent should see.
|
|
83
|
+
*/
|
|
84
|
+
private isAddressableUnder;
|
|
68
85
|
/**
|
|
69
86
|
* Fetch a single row by ID
|
|
70
87
|
*/
|
|
@@ -83,6 +100,8 @@ export declare class FetchService {
|
|
|
83
100
|
databaseId?: string;
|
|
84
101
|
vectorSearch?: VectorSearchParams;
|
|
85
102
|
logical?: LogicalCondition;
|
|
103
|
+
/** Narrow to the rows reachable from a parent through a relation. */
|
|
104
|
+
relatedTo?: NestedPathHop;
|
|
86
105
|
}): Promise<Record<string, unknown>[]>;
|
|
87
106
|
/**
|
|
88
107
|
* Fallback path used when db.query is unavailable.
|
|
@@ -118,10 +137,6 @@ export declare class FetchService {
|
|
|
118
137
|
limit?: number;
|
|
119
138
|
databaseId?: string;
|
|
120
139
|
}): Promise<Record<string, unknown>[]>;
|
|
121
|
-
/**
|
|
122
|
-
* Fetch collection from multi-segment path
|
|
123
|
-
*/
|
|
124
|
-
private fetchCollectionFromPath;
|
|
125
140
|
/**
|
|
126
141
|
* Count rows in a collection
|
|
127
142
|
*/
|
|
@@ -130,10 +145,6 @@ export declare class FetchService {
|
|
|
130
145
|
searchString?: string;
|
|
131
146
|
databaseId?: string;
|
|
132
147
|
}): Promise<number>;
|
|
133
|
-
/**
|
|
134
|
-
* Count rows from multi-segment path
|
|
135
|
-
*/
|
|
136
|
-
private countEntitiesFromPath;
|
|
137
148
|
/**
|
|
138
149
|
* Check if a field value is unique
|
|
139
150
|
*/
|
|
@@ -160,6 +171,8 @@ export declare class FetchService {
|
|
|
160
171
|
searchString?: string;
|
|
161
172
|
databaseId?: string;
|
|
162
173
|
vectorSearch?: VectorSearchParams;
|
|
174
|
+
/** Narrow to the rows reachable from a parent through a relation. */
|
|
175
|
+
relatedTo?: NestedPathHop;
|
|
163
176
|
}, include?: string[]): Promise<Record<string, unknown>[]>;
|
|
164
177
|
/**
|
|
165
178
|
* Fetch a single row with optional relation includes for REST API.
|
|
@@ -38,6 +38,18 @@ export declare class PersistService {
|
|
|
38
38
|
* Delete all rows from a collection
|
|
39
39
|
*/
|
|
40
40
|
deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* The column on the *target* table that records the parent, for a create
|
|
43
|
+
* under a nested one-to-many path.
|
|
44
|
+
*
|
|
45
|
+
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
46
|
+
* `joinPath`), so the caller writes the row without stamping anything.
|
|
47
|
+
*
|
|
48
|
+
* `relation.localKey` is deliberately not consulted: it names a column on
|
|
49
|
+
* the *source* table. Falling back to it here — which is what this used to
|
|
50
|
+
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
51
|
+
*/
|
|
52
|
+
private resolveParentForeignKeyColumn;
|
|
41
53
|
/**
|
|
42
54
|
* Save an row (create or update)
|
|
43
55
|
*
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DrizzleClient } from "../interfaces";
|
|
2
|
-
import { CollectionConfig, FilterValues,
|
|
2
|
+
import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
|
|
3
|
+
import { type ResolvedVia } from "@rebasepro/types";
|
|
3
4
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
5
|
+
import type { NestedPathHop } from "./nested-path";
|
|
4
6
|
/**
|
|
5
7
|
* Service for handling all relation-related operations.
|
|
6
8
|
* Handles fetching, updating, and managing row relations.
|
|
@@ -69,7 +71,7 @@ export declare class RelationService {
|
|
|
69
71
|
/**
|
|
70
72
|
* Fetch rows using join paths for complex relations
|
|
71
73
|
*/
|
|
72
|
-
fetchEntitiesUsingJoins<M extends Record<string, unknown>>(parentCollection: CollectionConfig, parentId: string | number, relation:
|
|
74
|
+
fetchEntitiesUsingJoins<M extends Record<string, unknown>>(parentCollection: CollectionConfig, parentId: string | number, relation: ResolvedRelation, options?: {
|
|
73
75
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
74
76
|
orderBy?: string;
|
|
75
77
|
order?: "desc" | "asc";
|
|
@@ -85,16 +87,45 @@ export declare class RelationService {
|
|
|
85
87
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
86
88
|
databaseId?: string;
|
|
87
89
|
}): Promise<number>;
|
|
90
|
+
/**
|
|
91
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
92
|
+
* `additionalFilters` (conditions on the target table).
|
|
93
|
+
*
|
|
94
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
95
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
96
|
+
* same join — a membership test that reconstructed the join separately would
|
|
97
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
98
|
+
*/
|
|
99
|
+
private countRelatedRows;
|
|
100
|
+
/**
|
|
101
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
102
|
+
*
|
|
103
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
104
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
105
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
106
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
107
|
+
*/
|
|
108
|
+
isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean>;
|
|
109
|
+
/**
|
|
110
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
111
|
+
* row itself alone.
|
|
112
|
+
*
|
|
113
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
114
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
115
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
116
|
+
* `tags` table and delete by primary key.
|
|
117
|
+
*/
|
|
118
|
+
unlinkRelatedEntity(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number): Promise<void>;
|
|
88
119
|
/**
|
|
89
120
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
90
121
|
*/
|
|
91
|
-
batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation:
|
|
122
|
+
batchFetchRelatedEntities(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>>>;
|
|
92
123
|
/**
|
|
93
124
|
* Batch fetch many-cardinality related rows for multiple parent rows.
|
|
94
125
|
* Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
|
|
95
126
|
* Uses a single SQL query with IN clause to avoid N+1.
|
|
96
127
|
*/
|
|
97
|
-
batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation:
|
|
128
|
+
batchFetchRelatedEntitiesMany(parentCollectionPath: string, parentIds: (string | number)[], _relationKey: string, relation: ResolvedRelation): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>>;
|
|
98
129
|
/**
|
|
99
130
|
* Update many-to-many and junction relations
|
|
100
131
|
*/
|
|
@@ -104,7 +135,7 @@ export declare class RelationService {
|
|
|
104
135
|
*/
|
|
105
136
|
updateInverseRelations(tx: DrizzleClient, sourceCollection: CollectionConfig, sourceEntityId: string | number, inverseRelationUpdates: Array<{
|
|
106
137
|
relationKey: string;
|
|
107
|
-
relation:
|
|
138
|
+
relation: ResolvedRelation;
|
|
108
139
|
newValue: unknown;
|
|
109
140
|
}>): Promise<void>;
|
|
110
141
|
/**
|
|
@@ -120,13 +151,13 @@ export declare class RelationService {
|
|
|
120
151
|
*/
|
|
121
152
|
updateJoinPathOneToOneRelations(tx: DrizzleClient, parentCollection: CollectionConfig, parentId: string | number, updates: Array<{
|
|
122
153
|
relationKey: string;
|
|
123
|
-
relation:
|
|
154
|
+
relation: ResolvedVia;
|
|
124
155
|
newTargetId: string | number | null;
|
|
125
156
|
}>): Promise<void>;
|
|
126
157
|
/**
|
|
127
158
|
* Resolve joinPath write mapping for one-to-one relations
|
|
128
159
|
*/
|
|
129
|
-
resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation:
|
|
160
|
+
resolveJoinPathWriteMapping(parentCollection: CollectionConfig, relation: ResolvedVia): {
|
|
130
161
|
targetFKColName: string;
|
|
131
162
|
parentSourceColName: string;
|
|
132
163
|
};
|
|
@@ -136,7 +167,7 @@ export declare class RelationService {
|
|
|
136
167
|
handleJunctionTableCreation(tx: DrizzleClient, newEntityId: string | number, junctionTableInfo: {
|
|
137
168
|
parentCollection: CollectionConfig;
|
|
138
169
|
parentId: string | number;
|
|
139
|
-
relation:
|
|
170
|
+
relation: ResolvedManyToMany;
|
|
140
171
|
relationKey: string;
|
|
141
172
|
}): Promise<void>;
|
|
142
173
|
}
|
|
@@ -23,19 +23,14 @@ export declare function parseCdcPayload(payload: string): CdcChangeEvent | null;
|
|
|
23
23
|
/**
|
|
24
24
|
* Dedicated Postgres LISTEN client for database-level CDC.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* A {@link PgNotifyListener} — a connection outside the Drizzle pool that stays
|
|
27
|
+
* open and repairs itself — plus the parsing that turns a `rebase_cdc` payload
|
|
28
|
+
* into a change event. Each backend instance runs one, so every instance
|
|
29
|
+
* observes every committed change regardless of which instance (or external
|
|
30
|
+
* process) made the write.
|
|
31
31
|
*/
|
|
32
32
|
export declare class CdcListener {
|
|
33
|
-
private readonly
|
|
34
|
-
private readonly onEvent;
|
|
35
|
-
private client?;
|
|
36
|
-
private running;
|
|
37
|
-
private reconnectTimer?;
|
|
38
|
-
private static readonly RECONNECT_DELAY_MS;
|
|
33
|
+
private readonly listener;
|
|
39
34
|
constructor(connectionString: string, onEvent: (event: CdcChangeEvent) => void | Promise<void>);
|
|
40
35
|
/**
|
|
41
36
|
* Connect and begin listening. Idempotent.
|
|
@@ -44,11 +39,9 @@ export declare class CdcListener {
|
|
|
44
39
|
* established (or `LISTEN` is refused), this rejects so callers — notably
|
|
45
40
|
* `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
|
|
46
41
|
* app-level realtime. Once the initial connection succeeds, later drops
|
|
47
|
-
* self-heal
|
|
42
|
+
* self-heal in the background.
|
|
48
43
|
*/
|
|
49
44
|
start(): Promise<void>;
|
|
50
45
|
/** Stop listening and release the connection. */
|
|
51
46
|
stop(): Promise<void>;
|
|
52
|
-
private connect;
|
|
53
|
-
private scheduleReconnect;
|
|
54
47
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import { PostgresCollectionRegistry } from "../../collections/PostgresCollectionRegistry";
|
|
3
|
+
/**
|
|
4
|
+
* One end of a many-to-many, as seen from the junction table.
|
|
5
|
+
*
|
|
6
|
+
* A junction table is not a collection, so nothing in the registry maps it to
|
|
7
|
+
* one — which is why a change to it was invisible to change capture. But its
|
|
8
|
+
* rows are exactly the contents of a parent's child list, so a write to it is a
|
|
9
|
+
* change to `<parentSlug>/<sourceId>/<relationKey>` and to nothing else.
|
|
10
|
+
*/
|
|
11
|
+
export interface JunctionLink {
|
|
12
|
+
schema: string;
|
|
13
|
+
/** The junction table itself, e.g. `posts_tags`. */
|
|
14
|
+
table: string;
|
|
15
|
+
/** The collection whose relation this is, e.g. `posts`. */
|
|
16
|
+
parentCollection: CollectionConfig;
|
|
17
|
+
/** The relation's key — the path segment a child list is addressed by. */
|
|
18
|
+
relationKey: string;
|
|
19
|
+
/** Junction column holding the parent's id. */
|
|
20
|
+
sourceColumn: string;
|
|
21
|
+
/** Junction column holding the target's id. */
|
|
22
|
+
targetColumn: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every junction table reachable from a registered collection, once per
|
|
26
|
+
* relation that uses it.
|
|
27
|
+
*
|
|
28
|
+
* A junction is listed once per *direction* when both sides declare it, because
|
|
29
|
+
* each direction addresses a different child list: `posts/1/tags` and
|
|
30
|
+
* `tags/t/posts` both change when one link is written.
|
|
31
|
+
*/
|
|
32
|
+
export declare function collectJunctionLinks(registry: PostgresCollectionRegistry): JunctionLink[];
|
|
33
|
+
/**
|
|
34
|
+
* Index {@link collectJunctionLinks} by table, under both the qualified and the
|
|
35
|
+
* bare name — a change event carries whatever the trigger reports, and a
|
|
36
|
+
* collection need not declare a schema.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildJunctionLinkMap(registry: PostgresCollectionRegistry): Map<string, JunctionLink[]>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime pieces of the channel bus that are not the contract itself.
|
|
3
|
+
*
|
|
4
|
+
* The interface, the frame shape and the implementer's contract live in
|
|
5
|
+
* `@rebasepro/types` (`types/channel_bus.ts`), so a transport shipped as its own
|
|
6
|
+
* package — a Redis one, say — depends on the contract and not on this database
|
|
7
|
+
* adapter. They are re-exported here for convenience: code already importing
|
|
8
|
+
* from the adapter should not have to know where the types are declared.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChannelBusFrame } from "@rebasepro/types";
|
|
11
|
+
export type { ChannelBus, ChannelBusFrame, ChannelBusHandler, ChannelBusConfig, ChannelBusSetting } from "@rebasepro/types";
|
|
12
|
+
export { isChannelBusInstance } from "@rebasepro/types";
|
|
13
|
+
/**
|
|
14
|
+
* The default: no cross-instance delivery at all.
|
|
15
|
+
*
|
|
16
|
+
* This is what every deployment ran before the bus existed, and what a
|
|
17
|
+
* single-instance deployment should keep running — `publish` resolves without
|
|
18
|
+
* touching the network, so the broadcast path is the same handful of `ws.send`
|
|
19
|
+
* calls it always was.
|
|
20
|
+
*/
|
|
21
|
+
export declare class MemoryChannelBus {
|
|
22
|
+
readonly kind: "memory";
|
|
23
|
+
readonly maxFrameBytes: number;
|
|
24
|
+
start(): Promise<void>;
|
|
25
|
+
publish(): Promise<void>;
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** Encoded size of a frame, for a transport's size check. */
|
|
29
|
+
export declare function frameByteLength(frame: ChannelBusFrame): number;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel bus over Postgres LISTEN/NOTIFY.
|
|
3
|
+
*
|
|
4
|
+
* Chosen because it needs nothing that a Rebase deployment does not already
|
|
5
|
+
* have — the same database, the same direct URL the CDC listener uses. Three
|
|
6
|
+
* properties of `NOTIFY` shape everything below:
|
|
7
|
+
*
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* - **A notify is a query on the primary database.** Not a slow one, but it
|
|
18
|
+
* competes with the application's real queries, and that — not throughput —
|
|
19
|
+
* is what actually limits this transport. Measured, it carried ~10k
|
|
20
|
+
* cross-instance messages/second and stayed flat out to eight instances; what
|
|
21
|
+
* it should not do is spend 10k queries/second of the database's budget on
|
|
22
|
+
* cursor movement. Hence the batching below.
|
|
23
|
+
*
|
|
24
|
+
* - **Delivery is best-effort.** Retained channels repair themselves through
|
|
25
|
+
* the client's history replay, so a lost frame costs a live update rather
|
|
26
|
+
* than correctness. That is what makes coalescing safe.
|
|
27
|
+
*/
|
|
28
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
29
|
+
import { ChannelBus, ChannelBusFrame, ChannelBusHandler } from "./ChannelBus";
|
|
30
|
+
/** NOTIFY channel carrying channel-bus frames. */
|
|
31
|
+
export declare const CHANNEL_BUS_NOTIFY_CHANNEL = "rebase_channel_bus";
|
|
32
|
+
/**
|
|
33
|
+
* Postgres refuses a NOTIFY payload of 8000 bytes or more. The margin below it
|
|
34
|
+
* is for nothing in particular — it is there so that a payload which passes this
|
|
35
|
+
* check cannot fail at the server for being a few bytes over.
|
|
36
|
+
*/
|
|
37
|
+
export declare const PG_NOTIFY_MAX_PAYLOAD_BYTES = 7500;
|
|
38
|
+
/**
|
|
39
|
+
* How long a batching window stays open.
|
|
40
|
+
*
|
|
41
|
+
* Ten milliseconds is below the threshold where a human notices a cursor lag,
|
|
42
|
+
* and it is the difference between one query per message and one query per
|
|
43
|
+
* window under load. Set to 0 to disable coalescing entirely.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_BATCH_WINDOW_MS = 10;
|
|
46
|
+
export declare class PostgresChannelBus implements ChannelBus {
|
|
47
|
+
private readonly db;
|
|
48
|
+
private readonly connectionString;
|
|
49
|
+
readonly kind: "postgres";
|
|
50
|
+
readonly maxFrameBytes = 7500;
|
|
51
|
+
private listener?;
|
|
52
|
+
private readonly batchWindowMs;
|
|
53
|
+
/**
|
|
54
|
+
* Frames waiting for the current window to close.
|
|
55
|
+
*
|
|
56
|
+
* The window is opened by a publish that found none open, and that publish
|
|
57
|
+
* is sent *immediately* rather than joining a batch — see {@link publish}.
|
|
58
|
+
*/
|
|
59
|
+
private pending;
|
|
60
|
+
private pendingBytes;
|
|
61
|
+
private windowTimer?;
|
|
62
|
+
private stopped;
|
|
63
|
+
constructor(db: NodePgDatabase<Record<string, unknown>>, connectionString: string, options?: {
|
|
64
|
+
batchWindowMs?: number;
|
|
65
|
+
});
|
|
66
|
+
start(handler: ChannelBusHandler): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Publish, coalescing under load.
|
|
69
|
+
*
|
|
70
|
+
* The window is *leading edge*: a publish arriving when no window is open is
|
|
71
|
+
* sent straight away and opens one, so an idle channel pays no added latency
|
|
72
|
+
* at all. Frames arriving while it is open are collected and leave together
|
|
73
|
+
* when it closes. The effect is that cost tracks elapsed time rather than
|
|
74
|
+
* message count — one query per window instead of one per message — which is
|
|
75
|
+
* the same shape as the retention pruning throttle, for the same reason.
|
|
76
|
+
*
|
|
77
|
+
* The returned promise settles when the frame has actually left, not when it
|
|
78
|
+
* was queued, so the contract ("reaches the other instances, or rejects")
|
|
79
|
+
* still holds.
|
|
80
|
+
*/
|
|
81
|
+
publish(frame: ChannelBusFrame): Promise<void>;
|
|
82
|
+
stop(): Promise<void>;
|
|
83
|
+
private openWindow;
|
|
84
|
+
/** Send everything queued and settle the promises waiting on it. */
|
|
85
|
+
private flush;
|
|
86
|
+
/**
|
|
87
|
+
* One NOTIFY.
|
|
88
|
+
*
|
|
89
|
+
* A single frame goes out in the plain, unwrapped shape. That is not just
|
|
90
|
+
* economy: during a rolling deploy an instance running the previous build
|
|
91
|
+
* understands only that shape, and low-rate traffic — presence, the tail of
|
|
92
|
+
* a session — is exactly what is flowing while pods restart. Batching only
|
|
93
|
+
* appears under load, which shrinks the mixed-version window to almost
|
|
94
|
+
* nothing.
|
|
95
|
+
*/
|
|
96
|
+
private send;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Parse a bus payload into the frames it carries.
|
|
100
|
+
*
|
|
101
|
+
* Accepts both wire shapes — a bare frame and a `{ batch: [...] }` envelope —
|
|
102
|
+
* so an instance on the new build understands one on the old. Returns an empty
|
|
103
|
+
* array for anything unrecognisable: a malformed or future-versioned message
|
|
104
|
+
* must never take the listener down.
|
|
105
|
+
*/
|
|
106
|
+
export declare function parseChannelBusPayload(payload: string): ChannelBusFrame[];
|
|
107
|
+
/**
|
|
108
|
+
* Parse a single bus frame, returning null for anything that is not a frame we
|
|
109
|
+
* understand.
|
|
110
|
+
*/
|
|
111
|
+
export declare function parseChannelBusFrame(payload: string): ChannelBusFrame | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution of the channel bus from config, environment, or a supplied instance.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in, like every other cross-cutting realtime switch here: with nothing
|
|
5
|
+
* configured a deployment gets the memory bus and behaves exactly as it did
|
|
6
|
+
* before this existed. Unlike `REALTIME_CDC=auto`, there is no "try it and see"
|
|
7
|
+
* default — a bus changes where messages go, and quietly turning on a Postgres
|
|
8
|
+
* NOTIFY per broadcast because a direct URL happened to be set is not a
|
|
9
|
+
* decision to make on the user's behalf.
|
|
10
|
+
*
|
|
11
|
+
* Two transports ship, and neither adds a service to a deployment. A third is
|
|
12
|
+
* not a code change here: `realtime.bus` also accepts an already-constructed
|
|
13
|
+
* {@link ChannelBus}, so a transport published as its own package plugs in
|
|
14
|
+
* without this file learning about it. See `@rebasepro/types` →
|
|
15
|
+
* `types/channel_bus.ts` for the contract such a package implements.
|
|
16
|
+
*/
|
|
17
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
18
|
+
import { type ChannelBus, type ChannelBusConfig, type ChannelBusSetting } from "@rebasepro/types";
|
|
19
|
+
export * from "./ChannelBus";
|
|
20
|
+
export { PostgresChannelBus, CHANNEL_BUS_NOTIFY_CHANNEL, PG_NOTIFY_MAX_PAYLOAD_BYTES, DEFAULT_BATCH_WINDOW_MS, parseChannelBusFrame, parseChannelBusPayload } from "./PostgresChannelBus";
|
|
21
|
+
export interface ChannelBusDeps {
|
|
22
|
+
db: NodePgDatabase<Record<string, unknown>>;
|
|
23
|
+
/**
|
|
24
|
+
* Direct (non-pooled) Postgres URL for the LISTEN client. `LISTEN` is
|
|
25
|
+
* session state, so behind PgBouncer in transaction mode this must be the
|
|
26
|
+
* database itself and not the pooler.
|
|
27
|
+
*/
|
|
28
|
+
directUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Merge `REALTIME_CHANNEL_BUS` into the configured bus.
|
|
32
|
+
*
|
|
33
|
+
* The environment wins over a *named* built-in, so the transport can be changed
|
|
34
|
+
* per deployment without a rebuild — the same reason `REALTIME_CDC` is an env
|
|
35
|
+
* var. It does **not** win over a supplied instance: the env var can only name
|
|
36
|
+
* transports this package knows how to construct, so honouring it there would
|
|
37
|
+
* mean silently discarding the object the application handed us.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveChannelBusSetting(configured?: ChannelBusSetting): ChannelBusSetting;
|
|
40
|
+
/**
|
|
41
|
+
* @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
|
|
42
|
+
* supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
|
|
43
|
+
* config-only callers keep their exact types.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig;
|
|
46
|
+
/**
|
|
47
|
+
* Produce the bus a setting asks for.
|
|
48
|
+
*
|
|
49
|
+
* An instance is handed straight back — constructing it was the application's
|
|
50
|
+
* job, and this function has nothing to add. A named built-in that turns out to
|
|
51
|
+
* be unusable degrades to the memory bus, with the reason logged, rather than
|
|
52
|
+
* throwing: a misconfigured bus should cost a deployment its cross-instance
|
|
53
|
+
* fan-out, not its ability to boot.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createChannelBus(setting: ChannelBusSetting, deps: ChannelBusDeps): ChannelBus;
|
|
@@ -105,6 +105,17 @@ export declare class ChannelHistoryStore {
|
|
|
105
105
|
messages: ChannelHistoryEntry[];
|
|
106
106
|
latestSeq: number;
|
|
107
107
|
}>;
|
|
108
|
+
/**
|
|
109
|
+
* One retained message by its address.
|
|
110
|
+
*
|
|
111
|
+
* This is what makes the cross-instance pointer path work: a broadcast too
|
|
112
|
+
* large to travel inside a `pg_notify` payload is already stored here, so
|
|
113
|
+
* the notification carries `(channel, seq)` and each receiving instance
|
|
114
|
+
* reads the body back. Returns null when the message has since been pruned
|
|
115
|
+
* — a receiver that is that far behind has nothing useful to deliver, and
|
|
116
|
+
* the client's own `channel_history` replay is the repair path.
|
|
117
|
+
*/
|
|
118
|
+
getBySeq(channel: string, seq: number): Promise<ChannelHistoryEntry | null>;
|
|
108
119
|
/**
|
|
109
120
|
* Enforce a channel's retention bounds.
|
|
110
121
|
*
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared presence roster.
|
|
3
|
+
*
|
|
4
|
+
* Broadcast only ever needed *fan-out* to work across instances — a frame goes
|
|
5
|
+
* out, whoever is connected receives it. Presence needs more than that, because
|
|
6
|
+
* `presence_state` is a question ("who is in this document?") and a per-process
|
|
7
|
+
* `Map` can only answer for the clients that happen to share a replica with the
|
|
8
|
+
* asker. Two people editing the same scene through different pods would each
|
|
9
|
+
* see an empty room while broadcasting cursors at each other perfectly.
|
|
10
|
+
*
|
|
11
|
+
* So presence gets one row per tracked client, in Postgres, readable by every
|
|
12
|
+
* instance. Three consequences worth stating:
|
|
13
|
+
*
|
|
14
|
+
* - **The table is the roster; the in-process map is a cache of our own
|
|
15
|
+
* clients.** Reads answer from the table when this store is active, so the
|
|
16
|
+
* answer is the same whichever instance is asked.
|
|
17
|
+
*
|
|
18
|
+
* - **`last_seen` is the liveness signal, and it is already there.** The client
|
|
19
|
+
* heartbeats presence every ~20 s against a 30 s window; the sweep that has
|
|
20
|
+
* always reaped local stale entries now also reaps rows belonging to
|
|
21
|
+
* instances that stopped writing — which is exactly what a crashed pod looks
|
|
22
|
+
* like. Crash recovery is a property of the TTL, not a separate mechanism.
|
|
23
|
+
*
|
|
24
|
+
* - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete
|
|
25
|
+
* is the one that announces the departures, so a stale client produces one
|
|
26
|
+
* `presence_diff` for the cluster rather than one per replica.
|
|
27
|
+
*/
|
|
28
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
29
|
+
/** A tracked client, as any instance sees it. */
|
|
30
|
+
export interface PresenceRow {
|
|
31
|
+
channel: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
state: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
export declare class ChannelPresenceStore {
|
|
36
|
+
private readonly db;
|
|
37
|
+
private readonly instanceId;
|
|
38
|
+
private tablesReady;
|
|
39
|
+
constructor(db: NodePgDatabase<Record<string, unknown>>, instanceId: string);
|
|
40
|
+
/** Create the roster table. Idempotent. */
|
|
41
|
+
ensureTables(): Promise<void>;
|
|
42
|
+
/** Record (or refresh) a client's presence. */
|
|
43
|
+
track(channel: string, clientId: string, state: Record<string, unknown>): Promise<void>;
|
|
44
|
+
/** Drop one client's presence in one channel. */
|
|
45
|
+
remove(channel: string, clientId: string): Promise<void>;
|
|
46
|
+
/** Drop a client from every channel — used when its socket closes. */
|
|
47
|
+
removeClient(clientId: string): Promise<void>;
|
|
48
|
+
/** The global roster for a channel. */
|
|
49
|
+
roster(channel: string): Promise<Record<string, Record<string, unknown>>>;
|
|
50
|
+
/**
|
|
51
|
+
* Reap rows this instance is not responsible for and that have gone quiet.
|
|
52
|
+
*
|
|
53
|
+
* Own rows are excluded because the in-process sweep already handles them —
|
|
54
|
+
* and handles them better, since it can tell "the socket is gone" from "the
|
|
55
|
+
* heartbeat is late". What is left is precisely the interesting case: rows
|
|
56
|
+
* written by an instance that is no longer writing.
|
|
57
|
+
*
|
|
58
|
+
* Returns what was removed, so the caller can announce it.
|
|
59
|
+
*/
|
|
60
|
+
sweepStale(ttlMs: number): Promise<PresenceRow[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Remove every row this instance owns. Called on graceful shutdown so a
|
|
63
|
+
* rolling deploy does not leave a TTL window of ghosts in every roster.
|
|
64
|
+
*/
|
|
65
|
+
removeInstance(): Promise<void>;
|
|
66
|
+
}
|