@nylorun/runtime 0.12.0-beta → 0.14.0-beta
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/CHANGELOG.md +361 -0
- package/README.md +69 -15
- package/dist/adapters/execution/restate.d.ts +7 -2
- package/dist/adapters/execution/restate.js +38 -8
- package/dist/adapters/replication/pgoutput.d.ts +16 -0
- package/dist/adapters/replication/pgoutput.js +169 -0
- package/dist/adapters/streams/s2.js +37 -20
- package/dist/admin-openapi.json +1071 -0
- package/dist/api/a2a/card.d.ts +15 -0
- package/dist/api/a2a/card.js +24 -0
- package/dist/api/a2a/endpoint.d.ts +8 -0
- package/dist/api/a2a/endpoint.js +81 -0
- package/dist/api/a2a/protocol.d.ts +144 -0
- package/dist/api/a2a/protocol.js +188 -0
- package/dist/api/a2a/routes.d.ts +19 -0
- package/dist/api/a2a/routes.js +289 -0
- package/dist/api/a2a/tasks.d.ts +50 -0
- package/dist/api/a2a/tasks.js +186 -0
- package/dist/api/ag-ui/endpoint.d.ts +8 -0
- package/dist/api/ag-ui/endpoint.js +126 -0
- package/dist/api/ag-ui/history.d.ts +7 -0
- package/dist/api/ag-ui/history.js +60 -0
- package/dist/api/ag-ui/routes.d.ts +32 -0
- package/dist/api/ag-ui/routes.js +305 -0
- package/dist/api/ag-ui/session-id.d.ts +1 -0
- package/dist/api/ag-ui/session-id.js +12 -0
- package/dist/api/ag-ui/sse.d.ts +9 -0
- package/dist/api/ag-ui/sse.js +7 -0
- package/dist/api/ag-ui/translate.d.ts +38 -0
- package/dist/api/ag-ui/translate.js +222 -0
- package/dist/api/components.d.ts +7825 -0
- package/dist/api/components.js +83 -0
- package/dist/api/http/app.d.ts +18 -0
- package/dist/api/http/app.js +58 -0
- package/dist/api/http/body.d.ts +4 -0
- package/dist/api/http/body.js +37 -0
- package/dist/api/http/define.d.ts +39 -0
- package/dist/api/http/define.js +130 -0
- package/dist/api/http/respond.d.ts +9 -0
- package/dist/api/http/respond.js +44 -0
- package/dist/api/http/routes/access.d.ts +24 -0
- package/dist/api/http/routes/access.js +259 -0
- package/dist/api/http/routes/actions.d.ts +8 -0
- package/dist/api/http/routes/actions.js +76 -0
- package/dist/api/http/routes/endpoints.d.ts +7 -0
- package/dist/api/http/routes/endpoints.js +100 -0
- package/dist/api/http/routes/sessions.d.ts +3 -0
- package/dist/api/http/routes/sessions.js +205 -0
- package/dist/api/http/routes/tenant.d.ts +10 -0
- package/dist/api/http/routes/tenant.js +172 -0
- package/dist/api/http/routes/vaults.d.ts +8 -0
- package/dist/api/http/routes/vaults.js +167 -0
- package/dist/api/openapi.d.ts +19 -0
- package/dist/api/openapi.js +138 -0
- package/dist/api/route.d.ts +12 -0
- package/dist/api/route.js +4 -0
- package/dist/core/bearer.d.ts +8 -0
- package/dist/core/bearer.js +11 -0
- package/dist/core/flow-host.d.ts +7 -15
- package/dist/core/flow-host.js +19 -41
- package/dist/core/sandbox-routes.d.ts +12 -7
- package/dist/core/sandbox-routes.js +24 -25
- package/dist/execution/memory.d.ts +8 -5
- package/dist/execution/memory.js +33 -13
- package/dist/execution/types.d.ts +38 -6
- package/dist/execution/types.js +12 -3
- package/dist/host/admin-api.d.ts +22 -0
- package/dist/host/admin-api.js +178 -0
- package/dist/host/app.d.ts +34 -0
- package/dist/host/app.js +185 -0
- package/dist/host/config-for.d.ts +2 -0
- package/dist/host/config-for.js +1 -0
- package/dist/host/config.d.ts +11 -0
- package/dist/host/cors.d.ts +27 -0
- package/dist/host/cors.js +64 -0
- package/dist/host/create-host.d.ts +31 -3
- package/dist/host/create-host.js +168 -327
- package/dist/host/http.d.ts +14 -3
- package/dist/host/http.js +64 -23
- package/dist/host/main.js +55 -4
- package/dist/host/stack-config.d.ts +20 -0
- package/dist/host/stack-config.js +55 -0
- package/dist/mcp/pool.d.ts +19 -1
- package/dist/mcp/pool.js +60 -10
- package/dist/model/catalog.d.ts +2 -10
- package/dist/model/classify.d.ts +13 -0
- package/dist/model/classify.js +61 -0
- package/dist/model/models.d.ts +10 -1
- package/dist/model/models.js +14 -6
- package/dist/model/pi-model.d.ts +17 -2
- package/dist/model/pi-model.js +366 -222
- package/dist/openapi.json +25696 -0
- package/dist/sandbox/manager.d.ts +13 -1
- package/dist/sandbox/manager.js +8 -11
- package/dist/sandbox/resolve.d.ts +33 -0
- package/dist/sandbox/resolve.js +81 -0
- package/dist/sandbox/session-sandbox.d.ts +11 -0
- package/dist/sandbox/session-sandbox.js +51 -0
- package/dist/sandbox/share.d.ts +14 -2
- package/dist/sandbox/share.js +33 -3
- package/dist/sandbox/tenant-config.d.ts +37 -0
- package/dist/sandbox/tenant-config.js +51 -0
- package/dist/sandbox/tools.d.ts +7 -9
- package/dist/sandbox/tools.js +11 -3
- package/dist/sandbox/types.d.ts +8 -2
- package/dist/store/event.d.ts +19 -0
- package/dist/store/event.js +49 -0
- package/dist/store/memory.d.ts +20 -8
- package/dist/store/memory.js +265 -100
- package/dist/store/postgres/connect.d.ts +13 -0
- package/dist/store/postgres/connect.js +5 -0
- package/dist/store/postgres/migrations/003_subject_tokens.d.ts +8 -0
- package/dist/store/postgres/migrations/003_subject_tokens.js +42 -0
- package/dist/store/postgres/migrations/004_publishable_keys.d.ts +6 -0
- package/dist/store/postgres/migrations/004_publishable_keys.js +18 -0
- package/dist/store/postgres/migrations/005_action_endpoints.d.ts +6 -0
- package/dist/store/postgres/migrations/005_action_endpoints.js +32 -0
- package/dist/store/postgres/migrations/006_drop_executors.d.ts +11 -0
- package/dist/store/postgres/migrations/006_drop_executors.js +32 -0
- package/dist/store/postgres/migrations/007_event_time.d.ts +7 -0
- package/dist/store/postgres/migrations/007_event_time.js +14 -0
- package/dist/store/postgres/migrations/008_durable_streams.d.ts +13 -0
- package/dist/store/postgres/migrations/008_durable_streams.js +35 -0
- package/dist/store/postgres/migrations/index.d.ts +5 -1
- package/dist/store/postgres/migrations/index.js +22 -2
- package/dist/store/postgres/migrations/shared/index.d.ts +31 -0
- package/dist/store/postgres/migrations/shared/index.js +92 -0
- package/dist/store/postgres/record.d.ts +11 -0
- package/dist/store/postgres/record.js +64 -0
- package/dist/store/postgres/store.js +337 -95
- package/dist/store/postgres/tenants.js +14 -0
- package/dist/store/types.d.ts +176 -81
- package/dist/streams/basin.d.ts +18 -1
- package/dist/streams/basin.js +34 -2
- package/dist/streams/control.d.ts +18 -0
- package/dist/streams/control.js +29 -0
- package/dist/streams/memory.d.ts +3 -1
- package/dist/streams/memory.js +14 -8
- package/dist/streams/relay/core.d.ts +35 -0
- package/dist/streams/relay/core.js +366 -0
- package/dist/streams/relay/memory.d.ts +69 -0
- package/dist/streams/relay/memory.js +176 -0
- package/dist/streams/relay/types.d.ts +77 -0
- package/dist/streams/relay/types.js +7 -0
- package/dist/streams/types.d.ts +66 -36
- package/dist/streams/types.js +18 -53
- package/dist/tenant/access-policy.d.ts +30 -0
- package/dist/tenant/access-policy.js +52 -0
- package/dist/tenant/actions.d.ts +10 -149
- package/dist/tenant/actions.js +5 -167
- package/dist/tenant/advance.js +32 -0
- package/dist/tenant/auth.d.ts +19 -17
- package/dist/tenant/auth.js +90 -93
- package/dist/tenant/browser.d.ts +15 -0
- package/dist/tenant/browser.js +39 -0
- package/dist/tenant/commands.d.ts +18 -2
- package/dist/tenant/commands.js +121 -129
- package/dist/tenant/context.d.ts +75 -18
- package/dist/tenant/context.js +14 -10
- package/dist/tenant/delivery-token.d.ts +41 -0
- package/dist/tenant/delivery-token.js +105 -0
- package/dist/tenant/delivery.d.ts +62 -0
- package/dist/tenant/delivery.js +476 -0
- package/dist/tenant/effects.d.ts +5 -0
- package/dist/tenant/effects.js +138 -48
- package/dist/tenant/endpoints.d.ts +67 -0
- package/dist/tenant/endpoints.js +90 -0
- package/dist/tenant/ephemeral.d.ts +6 -0
- package/dist/tenant/ephemeral.js +60 -36
- package/dist/tenant/http.d.ts +8 -9
- package/dist/tenant/http.js +13 -27
- package/dist/tenant/jwt.d.ts +16 -0
- package/dist/tenant/jwt.js +50 -0
- package/dist/tenant/module.d.ts +3 -0
- package/dist/tenant/module.js +6 -12
- package/dist/tenant/outbound.d.ts +44 -0
- package/dist/tenant/outbound.js +127 -0
- package/dist/tenant/pool.d.ts +0 -2
- package/dist/tenant/pool.js +0 -18
- package/dist/tenant/reset.d.ts +0 -4
- package/dist/tenant/reset.js +0 -5
- package/dist/tenant/runtime.d.ts +10 -20
- package/dist/tenant/runtime.js +59 -42
- package/dist/tenant/scheduler.d.ts +7 -2
- package/dist/tenant/scheduler.js +7 -3
- package/dist/tenant/session-streams.d.ts +125 -0
- package/dist/tenant/session-streams.js +399 -0
- package/dist/tenant/sessions.d.ts +28 -4
- package/dist/tenant/sessions.js +118 -24
- package/dist/tenant/signing-keys.d.ts +55 -0
- package/dist/tenant/signing-keys.js +172 -0
- package/dist/tenant/slim.d.ts +8 -0
- package/dist/tenant/slim.js +21 -0
- package/dist/tenant/status.d.ts +0 -3
- package/dist/tenant/status.js +19 -12
- package/dist/tenant/streams.d.ts +60 -67
- package/dist/tenant/streams.js +140 -208
- package/dist/tenant/subject-limits.d.ts +16 -0
- package/dist/tenant/subject-limits.js +45 -0
- package/dist/tenant/sweep.d.ts +1 -10
- package/dist/tenant/sweep.js +29 -69
- package/dist/tenant/tokens.d.ts +14 -0
- package/dist/tenant/tokens.js +178 -0
- package/dist/tenant/transcript.d.ts +48 -5
- package/dist/tenant/transcript.js +58 -1
- package/dist/tenant/types.d.ts +29 -7
- package/dist/tenant/worker.d.ts +3 -1
- package/dist/tenant/worker.js +9 -3
- package/dist/vault/service.d.ts +18 -2
- package/dist/vault/service.js +57 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +20 -8
- package/dist/core/executors.d.ts +0 -44
- package/dist/core/executors.js +0 -95
- package/dist/streams/relay.d.ts +0 -37
- package/dist/streams/relay.js +0 -254
- package/dist/tenant/live.d.ts +0 -92
- package/dist/tenant/live.js +0 -290
- package/dist/tenant/routes-tenant.d.ts +0 -12
- package/dist/tenant/routes-tenant.js +0 -137
- package/dist/tenant/routes.d.ts +0 -9
- package/dist/tenant/routes.js +0 -151
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stream relay (Durable Streams §7): feeds S2 from the record, exactly once and in order
|
|
3
|
+
* per session.
|
|
4
|
+
*
|
|
5
|
+
* - **Input.** Committed transactions from a `ChangeSource`, in commit order. The source
|
|
6
|
+
* keeps each one until it is acknowledged, so a crash replays what S2 may not have.
|
|
7
|
+
* - **Pumps.** Each session has a queue and one pump. The pump appends the contiguous rows
|
|
8
|
+
* at the front of the queue as one batch to `sessions/<id>` in the row's basin generation,
|
|
9
|
+
* with `matchSeq` = the first row's `seq`:
|
|
10
|
+
* - `ok`: the rows are done;
|
|
11
|
+
* - tail past the batch: S2 already has those rows (a replay); they are done;
|
|
12
|
+
* - tail before the batch: S2 is missing rows (a lost stream, or rows the source never
|
|
13
|
+
* delivered); they are read from the record and put in front;
|
|
14
|
+
* - an error: the rows' generation is checked against the Tenant's (a reset or Tenant
|
|
15
|
+
* deletion makes them obsolete: done), the basin is ensured, and the batch is retried
|
|
16
|
+
* with backoff (50 ms to 2 s). The pump is per session, so order is kept.
|
|
17
|
+
* - **Acknowledgement.** A transaction is acknowledged once all its rows are done, in commit
|
|
18
|
+
* order, never past one that is not.
|
|
19
|
+
* - **Reconciliation.** When the source starts fresh (a new or lost slot), every session's S2
|
|
20
|
+
* tail is compared with its log head and the difference is re-sent from the record.
|
|
21
|
+
* `matchSeq` makes overlap with the live input harmless.
|
|
22
|
+
*
|
|
23
|
+
* The relay never writes the record and never deletes from S2.
|
|
24
|
+
*/
|
|
25
|
+
import { basinOf } from "../basin.js";
|
|
26
|
+
import { sessionStream } from "../types.js";
|
|
27
|
+
const RETRY_MIN_MS = 50;
|
|
28
|
+
const RETRY_MAX_MS = 2000;
|
|
29
|
+
const HEADS_PAGE = 500;
|
|
30
|
+
export function createStreamRelay(options) {
|
|
31
|
+
const { source, record, streams } = options;
|
|
32
|
+
const log = options.log ?? (() => { });
|
|
33
|
+
const maxRecords = options.maxBatchRecords ?? 500;
|
|
34
|
+
const maxBytes = options.maxBatchBytes ?? 768 * 1024;
|
|
35
|
+
const concurrency = options.reconcileConcurrency ?? 8;
|
|
36
|
+
/** Committed transactions not yet fully in S2, in commit order. */
|
|
37
|
+
const txs = [];
|
|
38
|
+
const sessions = new Map();
|
|
39
|
+
let confirmed = null;
|
|
40
|
+
let active = false;
|
|
41
|
+
let stopped = false;
|
|
42
|
+
let reconciliations = 0;
|
|
43
|
+
let lastError = null;
|
|
44
|
+
let idleWaiters = [];
|
|
45
|
+
/** Woken when queued rows settle or the activation ends (`reconcile` waits on its rows). */
|
|
46
|
+
let progressWaiters = [];
|
|
47
|
+
/** Moves on with every activation and every loss of the source. */
|
|
48
|
+
let activation = 0;
|
|
49
|
+
const stopping = new AbortController();
|
|
50
|
+
const keyOf = (tenantId, sessionId) => `${tenantId}\u0000${sessionId}`;
|
|
51
|
+
function queueOf(tenantId, sessionId) {
|
|
52
|
+
const key = keyOf(tenantId, sessionId);
|
|
53
|
+
let queue = sessions.get(key);
|
|
54
|
+
if (!queue)
|
|
55
|
+
sessions.set(key, (queue = { tenantId, sessionId, rows: [], running: false }));
|
|
56
|
+
return queue;
|
|
57
|
+
}
|
|
58
|
+
function queued(row, tx) {
|
|
59
|
+
const body = JSON.stringify(row.body) ?? "null";
|
|
60
|
+
return { ...row, seq: Number(row.seq), tx, bytes: Buffer.byteLength(body) };
|
|
61
|
+
}
|
|
62
|
+
function onTx(committed) {
|
|
63
|
+
if (stopped)
|
|
64
|
+
return;
|
|
65
|
+
const tx = { endLsn: committed.endLsn, remaining: committed.rows.length };
|
|
66
|
+
txs.push(tx);
|
|
67
|
+
// Queue every row before pumping, so a transaction's rows go out in as few batches as fit.
|
|
68
|
+
const touched = new Set();
|
|
69
|
+
for (const row of committed.rows) {
|
|
70
|
+
const queue = queueOf(row.tenantId, row.sessionId);
|
|
71
|
+
queue.rows.push(queued(row, tx));
|
|
72
|
+
touched.add(queue);
|
|
73
|
+
}
|
|
74
|
+
for (const queue of touched)
|
|
75
|
+
pumpSoon(queue);
|
|
76
|
+
advance();
|
|
77
|
+
}
|
|
78
|
+
function pumpSoon(queue) {
|
|
79
|
+
if (!queue.running)
|
|
80
|
+
void pump(queue);
|
|
81
|
+
}
|
|
82
|
+
function done(row) {
|
|
83
|
+
row.tx.remaining -= 1;
|
|
84
|
+
}
|
|
85
|
+
/** The contiguous rows at the front of the queue, within the batch limits. */
|
|
86
|
+
function takeBatch(rows) {
|
|
87
|
+
const batch = [rows[0]];
|
|
88
|
+
let bytes = rows[0].bytes;
|
|
89
|
+
for (let i = 1; i < rows.length && batch.length < maxRecords; i += 1) {
|
|
90
|
+
const row = rows[i];
|
|
91
|
+
const previous = batch[batch.length - 1];
|
|
92
|
+
if (row.seq !== previous.seq + 1 || row.generation !== previous.generation)
|
|
93
|
+
break;
|
|
94
|
+
if (bytes + row.bytes > maxBytes)
|
|
95
|
+
break;
|
|
96
|
+
bytes += row.bytes;
|
|
97
|
+
batch.push(row);
|
|
98
|
+
}
|
|
99
|
+
return batch;
|
|
100
|
+
}
|
|
101
|
+
async function pump(queue) {
|
|
102
|
+
queue.running = true;
|
|
103
|
+
let delay = RETRY_MIN_MS;
|
|
104
|
+
try {
|
|
105
|
+
while (queue.rows.length > 0 && !stopped) {
|
|
106
|
+
// Duplicates (a replay overlapping a refill) and stale rows sort out here.
|
|
107
|
+
queue.rows.sort((a, b) => a.generation - b.generation || a.seq - b.seq);
|
|
108
|
+
const batch = takeBatch(queue.rows);
|
|
109
|
+
const first = batch[0];
|
|
110
|
+
const basin = basinOf(first.tenantId, first.generation);
|
|
111
|
+
try {
|
|
112
|
+
const result = await streams.append(basin, sessionStream(first.sessionId), batch.map((row) => row.body), { matchSeq: first.seq });
|
|
113
|
+
if (result.status === "ok") {
|
|
114
|
+
settle(queue, (row) => batch.includes(row));
|
|
115
|
+
}
|
|
116
|
+
else if (result.tail > first.seq) {
|
|
117
|
+
// A replay: S2 already has every row below its tail.
|
|
118
|
+
settle(queue, (row) => row.generation === first.generation && row.seq < result.tail);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// S2 is behind the record: re-send the gap first.
|
|
122
|
+
await refill(queue, first.generation, result.tail, first.seq);
|
|
123
|
+
}
|
|
124
|
+
delay = RETRY_MIN_MS;
|
|
125
|
+
lastError = null;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (stopped)
|
|
129
|
+
return;
|
|
130
|
+
lastError = messageOf(error);
|
|
131
|
+
if (await obsolete(first)) {
|
|
132
|
+
settle(queue, (row) => row.generation <= first.generation);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
log("stream relay append failed; retrying", {
|
|
136
|
+
tenantId: first.tenantId,
|
|
137
|
+
sessionId: first.sessionId,
|
|
138
|
+
seq: first.seq,
|
|
139
|
+
retryInMs: delay,
|
|
140
|
+
message: lastError,
|
|
141
|
+
});
|
|
142
|
+
// The basin may be missing (S2 lost it, or it was never created).
|
|
143
|
+
await streams.ensureTenant(basin).catch(() => undefined);
|
|
144
|
+
await sleep(delay, stopping.signal);
|
|
145
|
+
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
queue.running = false;
|
|
151
|
+
if (queue.rows.length === 0)
|
|
152
|
+
sessions.delete(keyOf(queue.tenantId, queue.sessionId));
|
|
153
|
+
else if (!stopped)
|
|
154
|
+
pumpSoon(queue);
|
|
155
|
+
advance();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Marks the queue's rows matching `match` done and drops them. */
|
|
159
|
+
function settle(queue, match) {
|
|
160
|
+
const keep = [];
|
|
161
|
+
for (const row of queue.rows) {
|
|
162
|
+
if (match(row))
|
|
163
|
+
done(row);
|
|
164
|
+
else
|
|
165
|
+
keep.push(row);
|
|
166
|
+
}
|
|
167
|
+
queue.rows = keep;
|
|
168
|
+
progressed();
|
|
169
|
+
}
|
|
170
|
+
function progressed() {
|
|
171
|
+
const waiters = progressWaiters;
|
|
172
|
+
progressWaiters = [];
|
|
173
|
+
for (const wake of waiters)
|
|
174
|
+
wake();
|
|
175
|
+
}
|
|
176
|
+
/** The rows' generation is no longer the Tenant's (a reset), or the Tenant is gone. */
|
|
177
|
+
async function obsolete(row) {
|
|
178
|
+
try {
|
|
179
|
+
const current = await record.generation(row.tenantId);
|
|
180
|
+
return current === undefined || current > row.generation;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Puts record rows `[from, to)` in front of the queue (not part of the ack order). Returns
|
|
188
|
+
* their pending count, which reaches 0 once they are all in S2.
|
|
189
|
+
*/
|
|
190
|
+
async function refill(queue, generation, from, to) {
|
|
191
|
+
const rows = await record.readRange(queue.tenantId, queue.sessionId, from, to);
|
|
192
|
+
const own = rows.filter((row) => row.generation === generation);
|
|
193
|
+
if (own.length < to - from) {
|
|
194
|
+
// The record no longer has them (a reset deleted the session): nothing to re-send.
|
|
195
|
+
if (await obsolete({ ...queue, seq: from, generation, body: null })) {
|
|
196
|
+
settle(queue, (row) => row.generation <= generation);
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`The record is missing rows ${from}..${to - 1} of session ${queue.sessionId}`);
|
|
200
|
+
}
|
|
201
|
+
const tx = { endLsn: undefined, remaining: own.length };
|
|
202
|
+
log("stream relay refilling a session from the record", {
|
|
203
|
+
tenantId: queue.tenantId,
|
|
204
|
+
sessionId: queue.sessionId,
|
|
205
|
+
from,
|
|
206
|
+
to,
|
|
207
|
+
});
|
|
208
|
+
queue.rows.unshift(...own.map((row) => queued(row, tx)));
|
|
209
|
+
return tx;
|
|
210
|
+
}
|
|
211
|
+
/** Acknowledges the newest transaction whose rows, and all before it, are in S2. */
|
|
212
|
+
function advance() {
|
|
213
|
+
let lsn;
|
|
214
|
+
while (txs.length > 0 && txs[0].remaining <= 0)
|
|
215
|
+
lsn = txs.shift().endLsn ?? lsn;
|
|
216
|
+
if (lsn)
|
|
217
|
+
acknowledge(lsn);
|
|
218
|
+
if (txs.length === 0 && sessions.size === 0) {
|
|
219
|
+
const waiters = idleWaiters;
|
|
220
|
+
idleWaiters = [];
|
|
221
|
+
for (const wake of waiters)
|
|
222
|
+
wake();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function acknowledge(lsn) {
|
|
226
|
+
confirmed = lsn;
|
|
227
|
+
source.acknowledge(lsn);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Re-sends, for every session, what the record has beyond its S2 tail, and resolves true
|
|
231
|
+
* once all of it is in S2. False when activation `of` ended first (the source was lost, so
|
|
232
|
+
* the queued rows were dropped) or the relay stopped: the slot must stay unreconciled.
|
|
233
|
+
*/
|
|
234
|
+
async function reconcile(of) {
|
|
235
|
+
reconciliations += 1;
|
|
236
|
+
const started = Date.now();
|
|
237
|
+
const current = () => !stopped && activation === of;
|
|
238
|
+
let sessionsChecked = 0;
|
|
239
|
+
let resent = 0;
|
|
240
|
+
const refilled = [];
|
|
241
|
+
let after;
|
|
242
|
+
for (;;) {
|
|
243
|
+
if (!current())
|
|
244
|
+
return false;
|
|
245
|
+
const page = await record.heads(after, HEADS_PAGE);
|
|
246
|
+
if (page.length === 0)
|
|
247
|
+
break;
|
|
248
|
+
let next = 0;
|
|
249
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, page.length) }, async () => {
|
|
250
|
+
while (next < page.length && current()) {
|
|
251
|
+
const head = page[next++];
|
|
252
|
+
sessionsChecked += 1;
|
|
253
|
+
const tx = await reconcileOne(head);
|
|
254
|
+
if (tx) {
|
|
255
|
+
refilled.push(tx);
|
|
256
|
+
resent += tx.remaining;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
const last = page[page.length - 1];
|
|
261
|
+
after = { tenantId: last.tenantId, sessionId: last.sessionId };
|
|
262
|
+
}
|
|
263
|
+
// Queued is not delivered: wait until S2 has every re-sent row.
|
|
264
|
+
while (current() && refilled.some((tx) => tx.remaining > 0))
|
|
265
|
+
await new Promise((resolve) => progressWaiters.push(resolve));
|
|
266
|
+
if (!current())
|
|
267
|
+
return false;
|
|
268
|
+
log("stream relay reconciled the record with S2", {
|
|
269
|
+
sessions: sessionsChecked,
|
|
270
|
+
resent,
|
|
271
|
+
durationMs: Date.now() - started,
|
|
272
|
+
});
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
async function reconcileOne(head) {
|
|
276
|
+
const current = await record.generation(head.tenantId);
|
|
277
|
+
if (current === undefined || current !== head.generation)
|
|
278
|
+
return undefined;
|
|
279
|
+
const basin = basinOf(head.tenantId, head.generation);
|
|
280
|
+
await streams.ensureTenant(basin);
|
|
281
|
+
const tail = await streams.tail(basin, sessionStream(head.sessionId));
|
|
282
|
+
if (tail >= head.head)
|
|
283
|
+
return undefined;
|
|
284
|
+
const queue = queueOf(head.tenantId, head.sessionId);
|
|
285
|
+
const tx = await refill(queue, head.generation, tail, head.head);
|
|
286
|
+
pumpSoon(queue);
|
|
287
|
+
return tx;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
start() {
|
|
291
|
+
source.start({
|
|
292
|
+
onActive({ fresh }) {
|
|
293
|
+
active = true;
|
|
294
|
+
const of = (activation += 1);
|
|
295
|
+
log("stream relay active", { fresh });
|
|
296
|
+
if (fresh)
|
|
297
|
+
void reconcile(of)
|
|
298
|
+
.then((complete) => (complete ? source.reconciled() : undefined))
|
|
299
|
+
.catch((error) => {
|
|
300
|
+
lastError = messageOf(error);
|
|
301
|
+
log("stream relay reconciliation failed", { message: lastError });
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
onTx,
|
|
305
|
+
onKeepalive(lsn) {
|
|
306
|
+
// Caught up: let the source move past WAL that holds none of our rows.
|
|
307
|
+
if (txs.length === 0) {
|
|
308
|
+
confirmed = lsn;
|
|
309
|
+
return lsn;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
},
|
|
313
|
+
onInactive(error) {
|
|
314
|
+
active = false;
|
|
315
|
+
activation += 1;
|
|
316
|
+
// The source replays everything not acknowledged; what is queued is dropped.
|
|
317
|
+
txs.length = 0;
|
|
318
|
+
for (const queue of sessions.values())
|
|
319
|
+
queue.rows = [];
|
|
320
|
+
progressed();
|
|
321
|
+
if (error) {
|
|
322
|
+
lastError = messageOf(error);
|
|
323
|
+
log("stream relay lost its source; retrying", { message: lastError });
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
async stop() {
|
|
329
|
+
stopped = true;
|
|
330
|
+
stopping.abort();
|
|
331
|
+
progressed();
|
|
332
|
+
await source.stop();
|
|
333
|
+
const waiters = idleWaiters;
|
|
334
|
+
idleWaiters = [];
|
|
335
|
+
for (const wake of waiters)
|
|
336
|
+
wake();
|
|
337
|
+
},
|
|
338
|
+
status: () => ({
|
|
339
|
+
active,
|
|
340
|
+
pendingTxs: txs.length,
|
|
341
|
+
pendingRows: [...sessions.values()].reduce((n, queue) => n + queue.rows.length, 0),
|
|
342
|
+
confirmed,
|
|
343
|
+
reconciliations,
|
|
344
|
+
lastError,
|
|
345
|
+
}),
|
|
346
|
+
idle() {
|
|
347
|
+
if (txs.length === 0 && sessions.size === 0)
|
|
348
|
+
return Promise.resolve();
|
|
349
|
+
return new Promise((resolve) => idleWaiters.push(resolve));
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
354
|
+
function sleep(ms, signal) {
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
if (signal.aborted)
|
|
357
|
+
return resolve();
|
|
358
|
+
const done = () => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
signal.removeEventListener("abort", done);
|
|
361
|
+
resolve();
|
|
362
|
+
};
|
|
363
|
+
const timer = setTimeout(done, ms);
|
|
364
|
+
signal.addEventListener("abort", done, { once: true });
|
|
365
|
+
});
|
|
366
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory record with a change source that behaves like a logical replication slot:
|
|
3
|
+
* committed transactions are kept until acknowledged and replayed to the next reader, one
|
|
4
|
+
* reader holds the slot at a time, and a dropped slot starts fresh. For unit tests and the
|
|
5
|
+
* in-memory Session Store. Not durable.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChangeHandlers, ChangeSource, CommittedTx, LogHead, RecordReader, RecordRow } from "./types.js";
|
|
8
|
+
export declare class MemoryRecord implements RecordReader {
|
|
9
|
+
/** `tenantId\0sessionId` → rows by seq. */
|
|
10
|
+
private readonly rows;
|
|
11
|
+
private readonly generations;
|
|
12
|
+
/** Committed transactions since the slot's confirmed position. */
|
|
13
|
+
private retained;
|
|
14
|
+
private lsn;
|
|
15
|
+
private slot;
|
|
16
|
+
private reader;
|
|
17
|
+
/** Commits rows as one transaction (the record is written by the Session Store). */
|
|
18
|
+
commit(rows: readonly RecordRow[]): void;
|
|
19
|
+
/** Sets a Tenant's basin generation (a reset moves it on). */
|
|
20
|
+
setGeneration(tenantId: string, generation: number): void;
|
|
21
|
+
/** Deletes a Tenant's rows, or one session's, as a reset or deletion does. */
|
|
22
|
+
deleteRows(tenantId: string, sessionId?: string): void;
|
|
23
|
+
/** Forgets a Tenant (deletion). */
|
|
24
|
+
deleteTenant(tenantId: string): void;
|
|
25
|
+
/** Drops the slot, as `max_slot_wal_keep_size` or an operator would. */
|
|
26
|
+
dropSlot(): void;
|
|
27
|
+
/** Ends the reader's connection but keeps the slot, as a network failure would. */
|
|
28
|
+
disconnect(): void;
|
|
29
|
+
/** A change source over this record; one at a time holds the slot. */
|
|
30
|
+
source(): ChangeSource;
|
|
31
|
+
readRange(tenantId: string, sessionId: string, from: number, to: number): Promise<RecordRow[]>;
|
|
32
|
+
heads(after: {
|
|
33
|
+
tenantId: string;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
} | undefined, limit: number): Promise<LogHead[]>;
|
|
36
|
+
generation(tenantId: string): Promise<number | undefined>;
|
|
37
|
+
/** @internal */
|
|
38
|
+
attach(reader: MemoryChangeSource): {
|
|
39
|
+
fresh: boolean;
|
|
40
|
+
replay: CommittedTx[];
|
|
41
|
+
} | undefined;
|
|
42
|
+
/** @internal */
|
|
43
|
+
detach(reader: MemoryChangeSource): void;
|
|
44
|
+
/** @internal */
|
|
45
|
+
reconciled(): void;
|
|
46
|
+
/** @internal */
|
|
47
|
+
confirm(lsn: string): void;
|
|
48
|
+
/** The slot's confirmed position (tests). */
|
|
49
|
+
confirmed(): number | undefined;
|
|
50
|
+
}
|
|
51
|
+
declare class MemoryChangeSource implements ChangeSource {
|
|
52
|
+
private readonly record;
|
|
53
|
+
private handlers;
|
|
54
|
+
private timer;
|
|
55
|
+
private active;
|
|
56
|
+
private stopped;
|
|
57
|
+
constructor(record: MemoryRecord);
|
|
58
|
+
start(handlers: ChangeHandlers): void;
|
|
59
|
+
acknowledge(lsn: string): void;
|
|
60
|
+
reconciled(): Promise<void>;
|
|
61
|
+
stop(): Promise<void>;
|
|
62
|
+
/** @internal */
|
|
63
|
+
deliver(tx: CommittedTx): void;
|
|
64
|
+
/** @internal */
|
|
65
|
+
lost(error: Error): void;
|
|
66
|
+
private tryAttach;
|
|
67
|
+
private retry;
|
|
68
|
+
}
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export class MemoryRecord {
|
|
2
|
+
/** `tenantId\0sessionId` → rows by seq. */
|
|
3
|
+
rows = new Map();
|
|
4
|
+
generations = new Map();
|
|
5
|
+
/** Committed transactions since the slot's confirmed position. */
|
|
6
|
+
retained = [];
|
|
7
|
+
lsn = 0;
|
|
8
|
+
slot;
|
|
9
|
+
reader;
|
|
10
|
+
/** Commits rows as one transaction (the record is written by the Session Store). */
|
|
11
|
+
commit(rows) {
|
|
12
|
+
if (rows.length === 0)
|
|
13
|
+
return;
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
const key = keyOf(row.tenantId, row.sessionId);
|
|
16
|
+
let list = this.rows.get(key);
|
|
17
|
+
if (!list)
|
|
18
|
+
this.rows.set(key, (list = []));
|
|
19
|
+
list[row.seq] = structuredClone(row);
|
|
20
|
+
if (!this.generations.has(row.tenantId))
|
|
21
|
+
this.generations.set(row.tenantId, row.generation);
|
|
22
|
+
}
|
|
23
|
+
this.lsn += 1;
|
|
24
|
+
const tx = { endLsn: lsnText(this.lsn), rows: rows.map((row) => structuredClone(row)) };
|
|
25
|
+
// A slot keeps what was committed after it was created.
|
|
26
|
+
if (this.slot)
|
|
27
|
+
this.retained.push({ lsn: this.lsn, tx });
|
|
28
|
+
this.reader?.deliver(tx);
|
|
29
|
+
}
|
|
30
|
+
/** Sets a Tenant's basin generation (a reset moves it on). */
|
|
31
|
+
setGeneration(tenantId, generation) {
|
|
32
|
+
this.generations.set(tenantId, generation);
|
|
33
|
+
}
|
|
34
|
+
/** Deletes a Tenant's rows, or one session's, as a reset or deletion does. */
|
|
35
|
+
deleteRows(tenantId, sessionId) {
|
|
36
|
+
for (const key of [...this.rows.keys()])
|
|
37
|
+
if (sessionId === undefined ? key.startsWith(`${tenantId}\u0000`) : key === keyOf(tenantId, sessionId))
|
|
38
|
+
this.rows.delete(key);
|
|
39
|
+
}
|
|
40
|
+
/** Forgets a Tenant (deletion). */
|
|
41
|
+
deleteTenant(tenantId) {
|
|
42
|
+
this.deleteRows(tenantId);
|
|
43
|
+
this.generations.delete(tenantId);
|
|
44
|
+
}
|
|
45
|
+
/** Drops the slot, as `max_slot_wal_keep_size` or an operator would. */
|
|
46
|
+
dropSlot() {
|
|
47
|
+
this.slot = undefined;
|
|
48
|
+
this.retained = [];
|
|
49
|
+
this.reader?.lost(new Error("replication slot dropped"));
|
|
50
|
+
}
|
|
51
|
+
/** Ends the reader's connection but keeps the slot, as a network failure would. */
|
|
52
|
+
disconnect() {
|
|
53
|
+
this.reader?.lost(new Error("replication connection lost"));
|
|
54
|
+
}
|
|
55
|
+
/** A change source over this record; one at a time holds the slot. */
|
|
56
|
+
source() {
|
|
57
|
+
return new MemoryChangeSource(this);
|
|
58
|
+
}
|
|
59
|
+
async readRange(tenantId, sessionId, from, to) {
|
|
60
|
+
const list = this.rows.get(keyOf(tenantId, sessionId)) ?? [];
|
|
61
|
+
return list.slice(from, to).filter(Boolean).map((row) => structuredClone(row));
|
|
62
|
+
}
|
|
63
|
+
async heads(after, limit) {
|
|
64
|
+
const heads = [];
|
|
65
|
+
for (const [key, list] of [...this.rows].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
66
|
+
const [tenantId, sessionId] = key.split("\u0000");
|
|
67
|
+
if (after && key <= keyOf(after.tenantId, after.sessionId))
|
|
68
|
+
continue;
|
|
69
|
+
const last = list[list.length - 1];
|
|
70
|
+
if (!last)
|
|
71
|
+
continue;
|
|
72
|
+
heads.push({ tenantId, sessionId, generation: last.generation, head: list.length });
|
|
73
|
+
if (heads.length >= limit)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return heads;
|
|
77
|
+
}
|
|
78
|
+
async generation(tenantId) {
|
|
79
|
+
return this.generations.get(tenantId);
|
|
80
|
+
}
|
|
81
|
+
/** @internal */
|
|
82
|
+
attach(reader) {
|
|
83
|
+
if (this.reader && this.reader !== reader)
|
|
84
|
+
return undefined;
|
|
85
|
+
this.reader = reader;
|
|
86
|
+
if (!this.slot)
|
|
87
|
+
this.slot = { confirmed: this.lsn, reconcilePending: true };
|
|
88
|
+
return {
|
|
89
|
+
fresh: this.slot.reconcilePending,
|
|
90
|
+
replay: this.retained.map((entry) => entry.tx),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** @internal */
|
|
94
|
+
detach(reader) {
|
|
95
|
+
if (this.reader === reader)
|
|
96
|
+
this.reader = undefined;
|
|
97
|
+
}
|
|
98
|
+
/** @internal */
|
|
99
|
+
reconciled() {
|
|
100
|
+
if (this.slot)
|
|
101
|
+
this.slot.reconcilePending = false;
|
|
102
|
+
}
|
|
103
|
+
/** @internal */
|
|
104
|
+
confirm(lsn) {
|
|
105
|
+
const position = Number(lsn.split("/")[1] ?? lsn);
|
|
106
|
+
if (!this.slot || position <= this.slot.confirmed)
|
|
107
|
+
return;
|
|
108
|
+
this.slot.confirmed = position;
|
|
109
|
+
this.retained = this.retained.filter((entry) => entry.lsn > position);
|
|
110
|
+
}
|
|
111
|
+
/** The slot's confirmed position (tests). */
|
|
112
|
+
confirmed() {
|
|
113
|
+
return this.slot?.confirmed;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
class MemoryChangeSource {
|
|
117
|
+
record;
|
|
118
|
+
handlers;
|
|
119
|
+
timer;
|
|
120
|
+
active = false;
|
|
121
|
+
stopped = false;
|
|
122
|
+
constructor(record) {
|
|
123
|
+
this.record = record;
|
|
124
|
+
}
|
|
125
|
+
start(handlers) {
|
|
126
|
+
this.handlers = handlers;
|
|
127
|
+
this.tryAttach();
|
|
128
|
+
}
|
|
129
|
+
acknowledge(lsn) {
|
|
130
|
+
if (this.active)
|
|
131
|
+
this.record.confirm(lsn);
|
|
132
|
+
}
|
|
133
|
+
async reconciled() {
|
|
134
|
+
this.record.reconciled();
|
|
135
|
+
}
|
|
136
|
+
async stop() {
|
|
137
|
+
this.stopped = true;
|
|
138
|
+
clearTimeout(this.timer);
|
|
139
|
+
this.record.detach(this);
|
|
140
|
+
this.active = false;
|
|
141
|
+
}
|
|
142
|
+
/** @internal */
|
|
143
|
+
deliver(tx) {
|
|
144
|
+
if (this.active)
|
|
145
|
+
this.handlers?.onTx(tx);
|
|
146
|
+
}
|
|
147
|
+
/** @internal */
|
|
148
|
+
lost(error) {
|
|
149
|
+
if (!this.active)
|
|
150
|
+
return;
|
|
151
|
+
this.active = false;
|
|
152
|
+
this.record.detach(this);
|
|
153
|
+
this.handlers?.onInactive(error);
|
|
154
|
+
this.retry();
|
|
155
|
+
}
|
|
156
|
+
tryAttach() {
|
|
157
|
+
if (this.stopped)
|
|
158
|
+
return;
|
|
159
|
+
const attached = this.record.attach(this);
|
|
160
|
+
if (!attached)
|
|
161
|
+
return this.retry();
|
|
162
|
+
this.active = true;
|
|
163
|
+
this.handlers.onActive({ fresh: attached.fresh });
|
|
164
|
+
for (const tx of attached.replay)
|
|
165
|
+
this.handlers.onTx(tx);
|
|
166
|
+
}
|
|
167
|
+
retry() {
|
|
168
|
+
if (this.stopped)
|
|
169
|
+
return;
|
|
170
|
+
this.timer = setTimeout(() => this.tryAttach(), 20);
|
|
171
|
+
this.timer.unref?.();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const keyOf = (tenantId, sessionId) => `${tenantId}\u0000${sessionId}`;
|
|
175
|
+
/** A Postgres-looking LSN, so positions read the same in logs. */
|
|
176
|
+
const lsnText = (n) => `0/${n}`;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts of the stream relay (Durable Streams §7): what it reads committed events from,
|
|
3
|
+
* and how it reads the record back to refill and reconcile. Postgres implements both with
|
|
4
|
+
* logical replication (`adapters/replication/pgoutput.ts`) and the record tables
|
|
5
|
+
* (`store/postgres/record.ts`); `streams/relay/memory.ts` is the in-memory pair.
|
|
6
|
+
*/
|
|
7
|
+
/** One committed row of the record: a session event and the basin generation it goes to. */
|
|
8
|
+
export interface RecordRow {
|
|
9
|
+
tenantId: string;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
seq: number;
|
|
12
|
+
generation: number;
|
|
13
|
+
/** The event, as stored. */
|
|
14
|
+
body: unknown;
|
|
15
|
+
}
|
|
16
|
+
/** One committed transaction's record rows, in commit order. */
|
|
17
|
+
export interface CommittedTx {
|
|
18
|
+
/** Where the source resumes after this transaction once it is acknowledged. */
|
|
19
|
+
readonly endLsn: string;
|
|
20
|
+
readonly rows: readonly RecordRow[];
|
|
21
|
+
}
|
|
22
|
+
export interface ChangeHandlers {
|
|
23
|
+
/**
|
|
24
|
+
* This process now reads the source (the slot is ours). `fresh` while the source has no
|
|
25
|
+
* history to replay for rows committed before it existed (a new or lost slot): the record
|
|
26
|
+
* must be reconciled with S2, then `reconciled()` called.
|
|
27
|
+
*/
|
|
28
|
+
onActive(state: {
|
|
29
|
+
fresh: boolean;
|
|
30
|
+
}): void;
|
|
31
|
+
/** A committed transaction, in commit order. Replayed after a restart until acknowledged. */
|
|
32
|
+
onTx(tx: CommittedTx): void;
|
|
33
|
+
/**
|
|
34
|
+
* The source asks how far it may move on: return the position to acknowledge (the
|
|
35
|
+
* server's `lsn` when nothing is pending), or undefined to acknowledge nothing new.
|
|
36
|
+
*/
|
|
37
|
+
onKeepalive(lsn: string): string | undefined;
|
|
38
|
+
/** This process stopped reading the source (connection lost); it tries again. */
|
|
39
|
+
onInactive(error?: unknown): void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Committed record rows in commit order, kept by the source until acknowledged: a
|
|
43
|
+
* transaction not acknowledged before a crash is delivered again.
|
|
44
|
+
*/
|
|
45
|
+
export interface ChangeSource {
|
|
46
|
+
/** Starts reading; keeps trying (another process may hold the source) until `stop`. */
|
|
47
|
+
start(handlers: ChangeHandlers): void;
|
|
48
|
+
/** Everything up to `lsn` is delivered; the source may forget it. */
|
|
49
|
+
acknowledge(lsn: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* The relay reconciled the record after a fresh start. Until this is called, every process
|
|
52
|
+
* that becomes active is told `fresh` again (the reconciliation may have been cut short).
|
|
53
|
+
*/
|
|
54
|
+
reconciled(): Promise<void>;
|
|
55
|
+
stop(): Promise<void>;
|
|
56
|
+
/** How far the source is behind, in bytes, when known. */
|
|
57
|
+
lag?(): Promise<number | undefined>;
|
|
58
|
+
}
|
|
59
|
+
/** A session's log head: the next `seq` to allocate. */
|
|
60
|
+
export interface LogHead {
|
|
61
|
+
tenantId: string;
|
|
62
|
+
sessionId: string;
|
|
63
|
+
generation: number;
|
|
64
|
+
head: number;
|
|
65
|
+
}
|
|
66
|
+
/** Reads the record back (refill and reconciliation). */
|
|
67
|
+
export interface RecordReader {
|
|
68
|
+
/** Rows `[from, to)` of a session, in `seq` order. */
|
|
69
|
+
readRange(tenantId: string, sessionId: string, from: number, to: number): Promise<RecordRow[]>;
|
|
70
|
+
/** Log heads with `head > 0`, in (Tenant, session) order, after `after`, at most `limit`. */
|
|
71
|
+
heads(after: {
|
|
72
|
+
tenantId: string;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
} | undefined, limit: number): Promise<LogHead[]>;
|
|
75
|
+
/** The Tenant's current basin generation; undefined when the Tenant is gone. */
|
|
76
|
+
generation(tenantId: string): Promise<number | undefined>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts of the stream relay (Durable Streams §7): what it reads committed events from,
|
|
3
|
+
* and how it reads the record back to refill and reconcile. Postgres implements both with
|
|
4
|
+
* logical replication (`adapters/replication/pgoutput.ts`) and the record tables
|
|
5
|
+
* (`store/postgres/record.ts`); `streams/relay/memory.ts` is the in-memory pair.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|