@powerhousedao/reactor 6.2.3-dev.5 → 6.2.3-dev.50
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/document-indexer-DwAMDVRI.js +1844 -0
- package/dist/document-indexer-DwAMDVRI.js.map +1 -0
- package/dist/drive-container-types-Ci1pdv4X.js +3766 -0
- package/dist/drive-container-types-Ci1pdv4X.js.map +1 -0
- package/dist/entry.d.ts +2 -1
- package/dist/entry.js +111 -7
- package/dist/entry.js.map +1 -1
- package/dist/{errors-D3S6Eysd.js → errors-BM_ULVWd.js} +9 -1
- package/dist/{errors-D3S6Eysd.js.map → errors-BM_ULVWd.js.map} +1 -1
- package/dist/forwarding-logger-ByfBFgMS.js +3635 -0
- package/dist/forwarding-logger-ByfBFgMS.js.map +1 -0
- package/dist/index-CNhT69De.d.ts +6383 -0
- package/dist/index-CNhT69De.d.ts.map +1 -0
- package/dist/index.d.ts +935 -5509
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5189 -1022
- package/dist/index.js.map +1 -1
- package/dist/load-spec-ZLjuyhxz.js +246 -0
- package/dist/load-spec-ZLjuyhxz.js.map +1 -0
- package/dist/projection-entry.d.ts +2 -1
- package/dist/projection-entry.js +99 -21
- package/dist/projection-entry.js.map +1 -1
- package/dist/{projection-shard-manager-CPQc5XHL.js → projection-shard-manager-CQ5FwQmv.js} +24 -4
- package/dist/projection-shard-manager-CQ5FwQmv.js.map +1 -0
- package/dist/{projection-worker-wI4PwcV2.js → projection-worker-D8uNFdwX.js} +1 -1
- package/dist/{projection-worker-wI4PwcV2.js.map → projection-worker-D8uNFdwX.js.map} +1 -1
- package/dist/rpc.d.ts +332 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +571 -0
- package/dist/rpc.js.map +1 -0
- package/dist/{transport-CuogVKN_.js → transport-B3G50KVd.js} +1 -1
- package/dist/{transport-CuogVKN_.js.map → transport-B3G50KVd.js.map} +1 -1
- package/dist/{transport-ByGviWdZ.js → transport-CZDHzfvy.js} +1 -1
- package/dist/{transport-ByGviWdZ.js.map → transport-CZDHzfvy.js.map} +1 -1
- package/dist/{worker-handle-CrERzl8s.js → worker-handle-CN2-oRv-.js} +30 -17
- package/dist/worker-handle-CN2-oRv-.js.map +1 -0
- package/package.json +34 -18
- package/dist/build-worker-executor-DnU3ZU4H.js +0 -83
- package/dist/build-worker-executor-DnU3ZU4H.js.map +0 -1
- package/dist/document-indexer-C5Gsa1B3.js +0 -958
- package/dist/document-indexer-C5Gsa1B3.js.map +0 -1
- package/dist/drive-container-types-yZrksiJR.js +0 -5310
- package/dist/drive-container-types-yZrksiJR.js.map +0 -1
- package/dist/error-info-Cpu4OY3o.js +0 -62
- package/dist/error-info-Cpu4OY3o.js.map +0 -1
- package/dist/forwarding-logger-BBkMSxuJ.js +0 -85
- package/dist/forwarding-logger-BBkMSxuJ.js.map +0 -1
- package/dist/projection-shard-manager-CPQc5XHL.js.map +0 -1
- package/dist/types-DMKLa0Ok.js +0 -33
- package/dist/types-DMKLa0Ok.js.map +0 -1
- package/dist/worker-HPysBfhx.js +0 -22
- package/dist/worker-HPysBfhx.js.map +0 -1
- package/dist/worker-handle-CrERzl8s.js.map +0 -1
|
@@ -0,0 +1,3635 @@
|
|
|
1
|
+
import { DOCUMENT_DELETED_REASON, DOCUMENT_SCOPE_ACTION_TYPES, PURGE_DOCUMENT, appendWithoutApplying, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createDocumentState, createPresignedHeader, defaultBaseState, deriveOperationId, isDenied, isPurgeMarker, mentionedGroupIds, normalizeDocumentModelVersion, operationOutcome, purgedProtocolVersions, targetDocumentId, withProtocolVersions } from "@powerhousedao/shared/document-model";
|
|
2
|
+
import { v4 } from "uuid";
|
|
3
|
+
import { Migrator, TableNode, sql } from "kysely";
|
|
4
|
+
//#region \0rolldown/runtime.js
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __exportAll = (all, no_symbols) => {
|
|
7
|
+
let target = {};
|
|
8
|
+
for (var name in all) __defProp(target, name, {
|
|
9
|
+
get: all[name],
|
|
10
|
+
enumerable: true
|
|
11
|
+
});
|
|
12
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
13
|
+
return target;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/shared/utils.ts
|
|
17
|
+
function matchesScope(view = {}, scope) {
|
|
18
|
+
if (view.scopes) return view.scopes.includes(scope);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
function yieldToMain() {
|
|
22
|
+
const s = globalThis.scheduler;
|
|
23
|
+
if (s?.yield) return s.yield();
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
25
|
+
}
|
|
26
|
+
const defaultAbortError = () => /* @__PURE__ */ new Error("Operation aborted");
|
|
27
|
+
function throwIfAborted(signal, makeError = defaultAbortError) {
|
|
28
|
+
if (signal?.aborted) throw makeError();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validates PagingOptions and returns a normalized offset and limit.
|
|
32
|
+
* Throws if the cursor is not empty and not a non-negative integer, or if
|
|
33
|
+
* limit is less than 1. When `paging` is undefined, returns offset 0 and
|
|
34
|
+
* the caller-supplied `defaultLimit`.
|
|
35
|
+
*/
|
|
36
|
+
function parsePagingOptions(paging, defaultLimit) {
|
|
37
|
+
if (paging === void 0) return {
|
|
38
|
+
offset: 0,
|
|
39
|
+
limit: defaultLimit
|
|
40
|
+
};
|
|
41
|
+
if (!Number.isInteger(paging.limit) || paging.limit < 1) throw new Error(`Invalid paging limit: ${String(paging.limit)} (must be an integer >= 1)`);
|
|
42
|
+
if (paging.cursor === "") return {
|
|
43
|
+
offset: 0,
|
|
44
|
+
limit: paging.limit
|
|
45
|
+
};
|
|
46
|
+
const parsed = Number(paging.cursor);
|
|
47
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`Invalid paging cursor: ${JSON.stringify(paging.cursor)} (must be empty or a non-negative integer)`);
|
|
48
|
+
return {
|
|
49
|
+
offset: parsed,
|
|
50
|
+
limit: paging.limit
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/events/types.ts
|
|
55
|
+
/**
|
|
56
|
+
* Custom error class that aggregates multiple errors from event subscribers.
|
|
57
|
+
*/
|
|
58
|
+
var EventBusAggregateError = class extends Error {
|
|
59
|
+
errors;
|
|
60
|
+
constructor(errors) {
|
|
61
|
+
const message = `EventBus emit failed with ${errors.length} error(s): ${errors.map((e) => {
|
|
62
|
+
if (e && typeof e === "object" && "message" in e) return e.message;
|
|
63
|
+
return String(e);
|
|
64
|
+
}).join("; ")}`;
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "EventBusAggregateError";
|
|
67
|
+
this.errors = errors;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Event types for reactor lifecycle events.
|
|
72
|
+
*/
|
|
73
|
+
const ReactorEventTypes = {
|
|
74
|
+
JOB_PENDING: 10001,
|
|
75
|
+
JOB_RUNNING: 10002,
|
|
76
|
+
JOB_WRITE_READY: 10003,
|
|
77
|
+
JOB_READ_READY: 10004,
|
|
78
|
+
JOB_FAILED: 10005,
|
|
79
|
+
READMODEL_BATCH_COMPLETED: 10006,
|
|
80
|
+
READMODEL_INDEXED: 10007,
|
|
81
|
+
MODEL_LOADED: 10008,
|
|
82
|
+
SIGNATURE_REFUSED: 10009,
|
|
83
|
+
CATCHUP_SWEPT: 10010
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/shared/errors.ts
|
|
87
|
+
/**
|
|
88
|
+
* Error thrown when attempting to access a deleted document.
|
|
89
|
+
*/
|
|
90
|
+
var DocumentDeletedError = class DocumentDeletedError extends Error {
|
|
91
|
+
documentId;
|
|
92
|
+
deletedAtUtcIso;
|
|
93
|
+
constructor(documentId, deletedAtUtcIso = null) {
|
|
94
|
+
const message = deletedAtUtcIso ? `Document ${documentId} was deleted at ${deletedAtUtcIso}` : `Document ${documentId} has been deleted`;
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = "DocumentDeletedError";
|
|
97
|
+
this.documentId = documentId;
|
|
98
|
+
this.deletedAtUtcIso = deletedAtUtcIso;
|
|
99
|
+
Error.captureStackTrace(this, DocumentDeletedError);
|
|
100
|
+
}
|
|
101
|
+
static isError(error) {
|
|
102
|
+
return Error.isError(error) && error.name === "DocumentDeletedError";
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Error thrown when the auth policy denies an action at the executor gate.
|
|
107
|
+
*/
|
|
108
|
+
var AuthorizationDeniedError = class AuthorizationDeniedError extends Error {
|
|
109
|
+
documentId;
|
|
110
|
+
scope;
|
|
111
|
+
operation;
|
|
112
|
+
subject;
|
|
113
|
+
constructor(documentId, scope, operation, subject) {
|
|
114
|
+
super(`Authorization denied: ${subject ?? "anonymous"} may not execute ${operation} in scope "${scope}" of document ${documentId}`);
|
|
115
|
+
this.name = "AuthorizationDeniedError";
|
|
116
|
+
this.documentId = documentId;
|
|
117
|
+
this.scope = scope;
|
|
118
|
+
this.operation = operation;
|
|
119
|
+
this.subject = subject;
|
|
120
|
+
Error.captureStackTrace(this, AuthorizationDeniedError);
|
|
121
|
+
}
|
|
122
|
+
static isError(error) {
|
|
123
|
+
return Error.isError(error) && error.name === "AuthorizationDeniedError";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* An auth operation did not strictly exceed the newest timestamp in its stream.
|
|
128
|
+
*
|
|
129
|
+
* Terminal and asymmetric by design: no ordering rule can reconcile two replicas
|
|
130
|
+
* that each accepted an auth operation offline, because either order hands one
|
|
131
|
+
* authority the other never granted, so the replica ahead holds the arrival.
|
|
132
|
+
*/
|
|
133
|
+
var AuthTimestampNotMonotonicError = class AuthTimestampNotMonotonicError extends Error {
|
|
134
|
+
documentId;
|
|
135
|
+
branch;
|
|
136
|
+
timestampUtcMs;
|
|
137
|
+
newestTimestampUtcMs;
|
|
138
|
+
constructor(documentId, branch, timestampUtcMs, newestTimestampUtcMs) {
|
|
139
|
+
super(`Auth timestamp not monotonic: ${timestampUtcMs} does not exceed ${newestTimestampUtcMs} in the auth stream of document ${documentId} on branch ${branch}`);
|
|
140
|
+
this.name = "AuthTimestampNotMonotonicError";
|
|
141
|
+
this.documentId = documentId;
|
|
142
|
+
this.branch = branch;
|
|
143
|
+
this.timestampUtcMs = timestampUtcMs;
|
|
144
|
+
this.newestTimestampUtcMs = newestTimestampUtcMs;
|
|
145
|
+
Error.captureStackTrace(this, AuthTimestampNotMonotonicError);
|
|
146
|
+
}
|
|
147
|
+
static isError(error) {
|
|
148
|
+
return Error.isError(error) && error.name === "AuthTimestampNotMonotonicError";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* An operation or action carried a timestamp that is not an ISO-8601 UTC
|
|
153
|
+
* instant.
|
|
154
|
+
*
|
|
155
|
+
* Terminal rather than retryable: the value does not change between attempts,
|
|
156
|
+
* so a retry re-runs the whole job to fail identically. Quarantining, unlike a
|
|
157
|
+
* held auth operation — this is malformed data rather than two replicas
|
|
158
|
+
* disagreeing, and nothing further from that source should be trusted until it
|
|
159
|
+
* is looked at.
|
|
160
|
+
*/
|
|
161
|
+
var InvalidOperationTimestampError = class InvalidOperationTimestampError extends Error {
|
|
162
|
+
documentId;
|
|
163
|
+
scope;
|
|
164
|
+
timestampUtcMs;
|
|
165
|
+
constructor(documentId, scope, timestampUtcMs, context) {
|
|
166
|
+
super(`Invalid timestamp "${timestampUtcMs}" on ${context} in scope "${scope}" of document ${documentId}`);
|
|
167
|
+
this.name = "InvalidOperationTimestampError";
|
|
168
|
+
this.documentId = documentId;
|
|
169
|
+
this.scope = scope;
|
|
170
|
+
this.timestampUtcMs = timestampUtcMs;
|
|
171
|
+
Error.captureStackTrace(this, InvalidOperationTimestampError);
|
|
172
|
+
}
|
|
173
|
+
static isError(error) {
|
|
174
|
+
return Error.isError(error) && error.name === "InvalidOperationTimestampError";
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/** A document requires a protocol version this reactor does not run. Terminal. */
|
|
178
|
+
var UnsupportedProtocolVersionError = class UnsupportedProtocolVersionError extends Error {
|
|
179
|
+
documentId;
|
|
180
|
+
protocol;
|
|
181
|
+
version;
|
|
182
|
+
constructor(documentId, protocol, version) {
|
|
183
|
+
super(`Document ${documentId} requires ${protocol} ${version}, which this reactor does not support`);
|
|
184
|
+
this.name = "UnsupportedProtocolVersionError";
|
|
185
|
+
this.documentId = documentId;
|
|
186
|
+
this.protocol = protocol;
|
|
187
|
+
this.version = version;
|
|
188
|
+
Error.captureStackTrace(this, UnsupportedProtocolVersionError);
|
|
189
|
+
}
|
|
190
|
+
static isError(error) {
|
|
191
|
+
return Error.isError(error) && error.name === "UnsupportedProtocolVersionError";
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* A load would move more operations than the bound allows, indicating a real
|
|
196
|
+
* divergence between local and incoming history. Counts only first-time moves,
|
|
197
|
+
* so a re-evaluation pass's re-appends do not make busy documents
|
|
198
|
+
* revocation-proof. Terminal: the condition is deterministic.
|
|
199
|
+
*/
|
|
200
|
+
var ExcessiveReshuffleError = class ExcessiveReshuffleError extends Error {
|
|
201
|
+
documentId;
|
|
202
|
+
scope;
|
|
203
|
+
count;
|
|
204
|
+
threshold;
|
|
205
|
+
constructor(documentId, scope, count, threshold) {
|
|
206
|
+
super(`Excessive reshuffle detected: ${count} operations in scope "${scope}" of document ${documentId} exceeds the threshold of ${threshold}. This indicates a significant divergence between local and incoming operations.`);
|
|
207
|
+
this.name = "ExcessiveReshuffleError";
|
|
208
|
+
this.documentId = documentId;
|
|
209
|
+
this.scope = scope;
|
|
210
|
+
this.count = count;
|
|
211
|
+
this.threshold = threshold;
|
|
212
|
+
Error.captureStackTrace(this, ExcessiveReshuffleError);
|
|
213
|
+
}
|
|
214
|
+
static isError(error) {
|
|
215
|
+
return Error.isError(error) && error.name === "ExcessiveReshuffleError";
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
/** The message carries the code: only name and message cross the queue. */
|
|
219
|
+
var InvalidSignatureError = class InvalidSignatureError extends Error {
|
|
220
|
+
documentId;
|
|
221
|
+
code;
|
|
222
|
+
reason;
|
|
223
|
+
constructor(documentId, code, reason) {
|
|
224
|
+
super(`Invalid signature in document ${documentId} [${code}]: ${reason}`);
|
|
225
|
+
this.name = "InvalidSignatureError";
|
|
226
|
+
this.documentId = documentId;
|
|
227
|
+
this.code = code;
|
|
228
|
+
this.reason = reason;
|
|
229
|
+
Error.captureStackTrace(this, InvalidSignatureError);
|
|
230
|
+
}
|
|
231
|
+
static isError(error) {
|
|
232
|
+
return Error.isError(error) && error.name === "InvalidSignatureError";
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* An UPGRADE_DOCUMENT action's preconditions (fromVersion and the per-scope
|
|
237
|
+
* revision snapshot) did not match the document state the executor loaded.
|
|
238
|
+
*
|
|
239
|
+
* Terminal rather than retryable: the action carries the client's snapshot,
|
|
240
|
+
* which stays stale no matter how often the job re-runs. The client is
|
|
241
|
+
* expected to re-read the document and submit a fresh action instead.
|
|
242
|
+
*/
|
|
243
|
+
var UpgradePreconditionFailedError = class UpgradePreconditionFailedError extends Error {
|
|
244
|
+
documentId;
|
|
245
|
+
detail;
|
|
246
|
+
constructor(documentId, detail) {
|
|
247
|
+
super(`Upgrade precondition failed for document ${documentId}: ${detail}`);
|
|
248
|
+
this.name = "UpgradePreconditionFailedError";
|
|
249
|
+
this.documentId = documentId;
|
|
250
|
+
this.detail = detail;
|
|
251
|
+
Error.captureStackTrace(this, UpgradePreconditionFailedError);
|
|
252
|
+
}
|
|
253
|
+
static isError(error) {
|
|
254
|
+
return Error.isError(error) && error.name === "UpgradePreconditionFailedError";
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Error thrown when a document is not found (no operations exist for the document ID).
|
|
259
|
+
*/
|
|
260
|
+
var DocumentNotFoundError = class DocumentNotFoundError extends Error {
|
|
261
|
+
documentId;
|
|
262
|
+
/**
|
|
263
|
+
* @param message Overrides the default text. A handler that knows which of
|
|
264
|
+
* several documents an action reads - a relationship's source, say - says so
|
|
265
|
+
* here rather than rewrapping in a bare Error, which would strip the name the
|
|
266
|
+
* executor classifies by.
|
|
267
|
+
*/
|
|
268
|
+
constructor(documentId, message) {
|
|
269
|
+
super(message ?? `Document ${documentId} not found`);
|
|
270
|
+
this.name = "DocumentNotFoundError";
|
|
271
|
+
this.documentId = documentId;
|
|
272
|
+
Error.captureStackTrace(this, DocumentNotFoundError);
|
|
273
|
+
}
|
|
274
|
+
/** Also true for DocumentPurgedError: only the name crosses the queue. */
|
|
275
|
+
static isError(error) {
|
|
276
|
+
return Error.isError(error) && (error.name === "DocumentNotFoundError" || error.name === "DocumentPurgedError");
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/** The document was purged; readers treat it as absent. Terminal. */
|
|
280
|
+
var DocumentPurgedError = class DocumentPurgedError extends DocumentNotFoundError {
|
|
281
|
+
purged = true;
|
|
282
|
+
constructor(documentId, message) {
|
|
283
|
+
super(documentId, message ?? `Document ${documentId} was purged`);
|
|
284
|
+
this.name = "DocumentPurgedError";
|
|
285
|
+
Error.captureStackTrace(this, DocumentPurgedError);
|
|
286
|
+
}
|
|
287
|
+
static isError(error) {
|
|
288
|
+
return Error.isError(error) && error.name === "DocumentPurgedError";
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
/** A purge was asked for a document that is not deleted. Terminal. */
|
|
292
|
+
var DocumentNotDeletedError = class DocumentNotDeletedError extends Error {
|
|
293
|
+
documentId;
|
|
294
|
+
constructor(documentId, message) {
|
|
295
|
+
super(message ?? `Document ${documentId} is not deleted`);
|
|
296
|
+
this.name = "DocumentNotDeletedError";
|
|
297
|
+
this.documentId = documentId;
|
|
298
|
+
Error.captureStackTrace(this, DocumentNotDeletedError);
|
|
299
|
+
}
|
|
300
|
+
static isError(error) {
|
|
301
|
+
return Error.isError(error) && error.name === "DocumentNotDeletedError";
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
/** A surviving document's accepted auth history names the group. Terminal. */
|
|
305
|
+
var GroupInUseError = class GroupInUseError extends Error {
|
|
306
|
+
groupId;
|
|
307
|
+
referencingDocumentIds;
|
|
308
|
+
constructor(groupId, referencingDocumentIds) {
|
|
309
|
+
super(`Group ${groupId} cannot be purged: the auth history of ${referencingDocumentIds.join(", ")} names it`);
|
|
310
|
+
this.name = "GroupInUseError";
|
|
311
|
+
this.groupId = groupId;
|
|
312
|
+
this.referencingDocumentIds = referencingDocumentIds;
|
|
313
|
+
Error.captureStackTrace(this, GroupInUseError);
|
|
314
|
+
}
|
|
315
|
+
static isError(error) {
|
|
316
|
+
return Error.isError(error) && error.name === "GroupInUseError";
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
/** A purge above maxPurgeOperations without allowLarge. Terminal. */
|
|
320
|
+
var PurgeTooLargeError = class PurgeTooLargeError extends Error {
|
|
321
|
+
documentId;
|
|
322
|
+
operationCount;
|
|
323
|
+
maxPurgeOperations;
|
|
324
|
+
constructor(documentId, operationCount, maxPurgeOperations) {
|
|
325
|
+
super(`Document ${documentId} has ${operationCount} operations, above the purge limit of ${maxPurgeOperations}; request it with allowLarge`);
|
|
326
|
+
this.name = "PurgeTooLargeError";
|
|
327
|
+
this.documentId = documentId;
|
|
328
|
+
this.operationCount = operationCount;
|
|
329
|
+
this.maxPurgeOperations = maxPurgeOperations;
|
|
330
|
+
Error.captureStackTrace(this, PurgeTooLargeError);
|
|
331
|
+
}
|
|
332
|
+
static isError(error) {
|
|
333
|
+
return Error.isError(error) && error.name === "PurgeTooLargeError";
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
/** A regular write carried an action type only the reactor issues. Terminal. */
|
|
337
|
+
var ReservedActionError = class ReservedActionError extends Error {
|
|
338
|
+
documentId;
|
|
339
|
+
actionType;
|
|
340
|
+
constructor(documentId, actionType) {
|
|
341
|
+
super(`Action ${actionType} is reserved and cannot be submitted to document ${documentId}`);
|
|
342
|
+
this.name = "ReservedActionError";
|
|
343
|
+
this.documentId = documentId;
|
|
344
|
+
this.actionType = actionType;
|
|
345
|
+
Error.captureStackTrace(this, ReservedActionError);
|
|
346
|
+
}
|
|
347
|
+
static isError(error) {
|
|
348
|
+
return Error.isError(error) && error.name === "ReservedActionError";
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* An authorization preflight was asked for while the reactor's decision model
|
|
353
|
+
* is off, so there is no model to answer from.
|
|
354
|
+
*
|
|
355
|
+
* Thrown rather than answered from the legacy host-side permission tables. The
|
|
356
|
+
* two systems do not compose: the tables record which addresses a host lets
|
|
357
|
+
* near a drive, the policy records what a document's own grants permit, and an
|
|
358
|
+
* answer stitched from both would report an admission verdict neither system
|
|
359
|
+
* would reach. A caller that cannot get a prediction disables nothing, which
|
|
360
|
+
* leaves the submit path -- and its real gate -- as the only authority.
|
|
361
|
+
*
|
|
362
|
+
* Detection is by `name`, not `instanceof`: the SharedWorker RPC boundary
|
|
363
|
+
* rebuilds a thrown error from `{ name, message, stack, cause }` alone
|
|
364
|
+
* (`reactor-browser/src/rpc/error-info.ts`), so the class identity and any
|
|
365
|
+
* custom field are lost in transit. This error therefore carries no fields.
|
|
366
|
+
*/
|
|
367
|
+
var AuthEnforcementDisabledError = class AuthEnforcementDisabledError extends Error {
|
|
368
|
+
constructor() {
|
|
369
|
+
super("Authorization evaluation requires the authEnforcement feature flag; this reactor holds no decision model, and the legacy host-table permission system cannot answer for one");
|
|
370
|
+
this.name = "AuthEnforcementDisabledError";
|
|
371
|
+
Error.captureStackTrace(this, AuthEnforcementDisabledError);
|
|
372
|
+
}
|
|
373
|
+
static isError(error) {
|
|
374
|
+
return Error.isError(error) && error.name === "AuthEnforcementDisabledError";
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
/**
|
|
378
|
+
* Error thrown when a relationship edge an operation names does not exist.
|
|
379
|
+
*
|
|
380
|
+
* Detection is by `name`, not `instanceof`: the SharedWorker RPC boundary
|
|
381
|
+
* rebuilds a thrown error from `{ name, message, stack, cause }` alone
|
|
382
|
+
* (`reactor-browser/src/rpc/error-info.ts`), so the class identity is lost in
|
|
383
|
+
* transit.
|
|
384
|
+
*/
|
|
385
|
+
var RelationshipNotFoundError = class RelationshipNotFoundError extends Error {
|
|
386
|
+
sourceId;
|
|
387
|
+
targetId;
|
|
388
|
+
relationshipType;
|
|
389
|
+
constructor(sourceId, targetId, relationshipType) {
|
|
390
|
+
super(`No ${relationshipType} relationship from ${sourceId} to ${targetId}`);
|
|
391
|
+
this.name = "RelationshipNotFoundError";
|
|
392
|
+
this.sourceId = sourceId;
|
|
393
|
+
this.targetId = targetId;
|
|
394
|
+
this.relationshipType = relationshipType;
|
|
395
|
+
Error.captureStackTrace(this, RelationshipNotFoundError);
|
|
396
|
+
}
|
|
397
|
+
static isError(error) {
|
|
398
|
+
return Error.isError(error) && error.name === "RelationshipNotFoundError";
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
/** The store holds documents at protocol versions this reactor does not run. */
|
|
402
|
+
var UnsupportedStoredProtocolError = class UnsupportedStoredProtocolError extends Error {
|
|
403
|
+
versions;
|
|
404
|
+
documents;
|
|
405
|
+
constructor(versions, documents) {
|
|
406
|
+
const named = versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
|
|
407
|
+
super(`${documents} stored document(s) require ${named}, which this reactor does not run. Start a build that runs them, or accept them read-only with withUnsupportedStoredDocuments("read-only")`);
|
|
408
|
+
this.name = "UnsupportedStoredProtocolError";
|
|
409
|
+
this.versions = versions;
|
|
410
|
+
this.documents = documents;
|
|
411
|
+
Error.captureStackTrace(this, UnsupportedStoredProtocolError);
|
|
412
|
+
}
|
|
413
|
+
static isError(error) {
|
|
414
|
+
return Error.isError(error) && error.name === "UnsupportedStoredProtocolError";
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* A batch job failed. A batch is not atomic, so `jobs` holds every job's final
|
|
419
|
+
* state, keyed by plan key; `cause` carries the failed job's own error name.
|
|
420
|
+
*
|
|
421
|
+
* The SharedWorker RPC boundary rebuilds a thrown error from `{ name, message,
|
|
422
|
+
* stack, cause }` alone (`src/rpc/error-info.ts`), so `key` and
|
|
423
|
+
* `jobs` do not survive it. `isError` therefore checks for them, not only the
|
|
424
|
+
* name; across that boundary, classify by the `cause` chain's names instead.
|
|
425
|
+
*/
|
|
426
|
+
var BatchJobFailedError = class BatchJobFailedError extends Error {
|
|
427
|
+
key;
|
|
428
|
+
jobs;
|
|
429
|
+
constructor(key, jobs) {
|
|
430
|
+
const failure = jobs[key].error;
|
|
431
|
+
const cause = new Error(failure?.message ?? "Job failed");
|
|
432
|
+
cause.name = failure?.name ?? "Error";
|
|
433
|
+
super(failure?.message ?? "Job failed", { cause });
|
|
434
|
+
this.name = "BatchJobFailedError";
|
|
435
|
+
this.key = key;
|
|
436
|
+
this.jobs = jobs;
|
|
437
|
+
Error.captureStackTrace(this, BatchJobFailedError);
|
|
438
|
+
}
|
|
439
|
+
static isError(error) {
|
|
440
|
+
return Error.isError(error) && error.name === "BatchJobFailedError" && typeof error.key === "string" && typeof error.jobs === "object";
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/storage/interfaces.ts
|
|
445
|
+
/**
|
|
446
|
+
* Thrown when an operation with the same identity already exists in the store.
|
|
447
|
+
*/
|
|
448
|
+
var DuplicateOperationError = class extends Error {
|
|
449
|
+
constructor(description) {
|
|
450
|
+
super(`Duplicate operation: ${description}`);
|
|
451
|
+
this.name = "DuplicateOperationError";
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* Thrown when a concurrent write conflict is detected during an atomic apply.
|
|
456
|
+
*/
|
|
457
|
+
var OptimisticLockError = class extends Error {
|
|
458
|
+
constructor(message) {
|
|
459
|
+
super(message);
|
|
460
|
+
this.name = "OptimisticLockError";
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
/**
|
|
464
|
+
* Thrown when the caller-provided revision does not match the current
|
|
465
|
+
* stored revision, indicating a stale read.
|
|
466
|
+
*/
|
|
467
|
+
var RevisionMismatchError = class extends Error {
|
|
468
|
+
constructor(expected, actual) {
|
|
469
|
+
super(`Revision mismatch: expected ${expected}, got ${actual}`);
|
|
470
|
+
this.name = "RevisionMismatchError";
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* A create based on an empty stream that already has operations: the id is
|
|
475
|
+
* taken, and no retry can resolve it. Matched by `name`, all the RPC boundary keeps.
|
|
476
|
+
*/
|
|
477
|
+
var DocumentAlreadyExistsError = class extends Error {
|
|
478
|
+
documentId;
|
|
479
|
+
constructor(documentId, scope, headRevision) {
|
|
480
|
+
super(`Document ${documentId} already exists: create requested revision 0 but the "${scope}" stream is at revision ${headRevision}`);
|
|
481
|
+
this.name = "DocumentAlreadyExistsError";
|
|
482
|
+
this.documentId = documentId;
|
|
483
|
+
}
|
|
484
|
+
static isError(error) {
|
|
485
|
+
return Error.isError(error) && error.name === "DocumentAlreadyExistsError";
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
/** Error history keeps messages, not classes, so failures match by prefix. */
|
|
489
|
+
const APPEND_CONDITION_FAILED_PREFIX = "Append condition failed: ";
|
|
490
|
+
/**
|
|
491
|
+
* A read-set stream grew before the append committed. A concurrency
|
|
492
|
+
* conflict, not a fault: the caller retries against the new stream heads.
|
|
493
|
+
*/
|
|
494
|
+
var AppendConditionFailedError = class extends Error {
|
|
495
|
+
condition;
|
|
496
|
+
constructor(condition) {
|
|
497
|
+
const streams = condition.streams.map((s) => `${s.documentId}:${s.scope}:${s.branch}@${s.revision}`).join(", ");
|
|
498
|
+
super(`${APPEND_CONDITION_FAILED_PREFIX}a read-set stream advanced [${streams}]`);
|
|
499
|
+
this.condition = condition;
|
|
500
|
+
this.name = "AppendConditionFailedError";
|
|
501
|
+
}
|
|
502
|
+
static isError(error) {
|
|
503
|
+
return Error.isError(error) && error.name === "AppendConditionFailedError";
|
|
504
|
+
}
|
|
505
|
+
/** True when a recorded error message is an append-condition failure. */
|
|
506
|
+
static isFailureMessage(message) {
|
|
507
|
+
return message.startsWith(APPEND_CONDITION_FAILED_PREFIX);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
/**
|
|
511
|
+
* Which sense of "exists" a caller of {@link IDocumentView.exists} means.
|
|
512
|
+
*/
|
|
513
|
+
let DocumentExistence = /* @__PURE__ */ function(DocumentExistence) {
|
|
514
|
+
/**
|
|
515
|
+
* A document that is present and not deleted. This is the question a read
|
|
516
|
+
* asks: a deleted document is not readable, so it does not exist.
|
|
517
|
+
*/
|
|
518
|
+
DocumentExistence["LiveOnly"] = "LiveOnly";
|
|
519
|
+
/**
|
|
520
|
+
* Whether the id is taken, deleted or not. This is the question a write
|
|
521
|
+
* asks: a deleted document keeps its operation stream, so its id can never
|
|
522
|
+
* be reused, and answering from snapshots alone would say otherwise whenever
|
|
523
|
+
* a snapshot row is missing while the stream is intact.
|
|
524
|
+
*/
|
|
525
|
+
DocumentExistence["IncludingDeleted"] = "IncludingDeleted";
|
|
526
|
+
return DocumentExistence;
|
|
527
|
+
}({});
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/executor/util.ts
|
|
530
|
+
/** Actions the reactor reduces itself, onto the document scope. */
|
|
531
|
+
const DOCUMENT_SCOPE_ACTIONS = DOCUMENT_SCOPE_ACTION_TYPES;
|
|
532
|
+
/**
|
|
533
|
+
* `CREATE_DOCUMENT` is exempt by necessity: it runs before the document exists,
|
|
534
|
+
* so building a decision model would throw and defer the job forever.
|
|
535
|
+
*/
|
|
536
|
+
const GATED_DOCUMENT_ACTIONS = new Set([...DOCUMENT_SCOPE_ACTIONS].filter((type) => type !== "CREATE_DOCUMENT"));
|
|
537
|
+
/**
|
|
538
|
+
* Creates a PHDocument from a CREATE_DOCUMENT action input.
|
|
539
|
+
* Reconstructs the document header and initializes the base state.
|
|
540
|
+
*
|
|
541
|
+
* @param action - The CREATE_DOCUMENT action containing the document parameters
|
|
542
|
+
* @returns A newly constructed PHDocument with initialized header and base state
|
|
543
|
+
*/
|
|
544
|
+
function createDocumentFromAction(action) {
|
|
545
|
+
const input = action.input;
|
|
546
|
+
const header = createPresignedHeader();
|
|
547
|
+
header.id = input.documentId;
|
|
548
|
+
header.documentType = input.model;
|
|
549
|
+
if (input.signing) {
|
|
550
|
+
header.createdAtUtcIso = input.signing.createdAtUtcIso;
|
|
551
|
+
header.lastModifiedAtUtcIso = input.signing.createdAtUtcIso;
|
|
552
|
+
header.sig = {
|
|
553
|
+
publicKey: input.signing.publicKey,
|
|
554
|
+
nonce: input.signing.nonce
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
if (input.slug !== void 0) header.slug = input.slug;
|
|
558
|
+
if (!header.slug) header.slug = input.documentId;
|
|
559
|
+
if (input.name !== void 0) header.name = input.name;
|
|
560
|
+
if (input.branch !== void 0) header.branch = input.branch;
|
|
561
|
+
if (input.meta !== void 0) header.meta = input.meta;
|
|
562
|
+
if (input.protocolVersions !== void 0) header.protocolVersions = input.protocolVersions;
|
|
563
|
+
const baseState = defaultBaseState();
|
|
564
|
+
return {
|
|
565
|
+
header,
|
|
566
|
+
operations: {},
|
|
567
|
+
state: baseState,
|
|
568
|
+
initialState: baseState,
|
|
569
|
+
clipboard: []
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Calculate the next operation index for a specific scope.
|
|
574
|
+
* Each scope maintains its own independent index sequence.
|
|
575
|
+
*
|
|
576
|
+
* Per-scope indexing means:
|
|
577
|
+
* - Each scope (document, global, local, etc.) has independent indexes
|
|
578
|
+
* - Indexes start at 0 for each scope
|
|
579
|
+
* - Different scopes can have operations with the same index value
|
|
580
|
+
*
|
|
581
|
+
* This function uses header.revision which is populated by the cache/storage layer
|
|
582
|
+
* and contains the next available index for each scope. This design avoids requiring
|
|
583
|
+
* the full operation history to be loaded, which is crucial for snapshot-based caching.
|
|
584
|
+
*
|
|
585
|
+
* @param document - The document whose header.revision to inspect
|
|
586
|
+
* @param scope - The scope to calculate the next index for
|
|
587
|
+
* @returns The next available index in the specified scope
|
|
588
|
+
*/
|
|
589
|
+
const getNextIndexForScope = (document, scope) => {
|
|
590
|
+
return document.header.revision[scope] || 0;
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Creates an empty consistency token with no coordinates.
|
|
594
|
+
* Used when a job is registered or fails without writing operations.
|
|
595
|
+
*
|
|
596
|
+
* @returns A consistency token with an empty coordinates array
|
|
597
|
+
*/
|
|
598
|
+
function createEmptyConsistencyToken() {
|
|
599
|
+
return {
|
|
600
|
+
version: 1,
|
|
601
|
+
createdAtUtcIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
602
|
+
coordinates: []
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Creates a consistency token from operations written during job execution.
|
|
607
|
+
* Maps each operation to a consistency coordinate tracking (documentId, scope, branch, operationIndex).
|
|
608
|
+
* If no operations are provided, returns an empty token.
|
|
609
|
+
*
|
|
610
|
+
* @param operationsWithContext - Array of operations with their execution context
|
|
611
|
+
* @returns A consistency token representing all operations written
|
|
612
|
+
*/
|
|
613
|
+
function createConsistencyToken(operationsWithContext) {
|
|
614
|
+
if (operationsWithContext.length === 0) return createEmptyConsistencyToken();
|
|
615
|
+
const coordinates = [];
|
|
616
|
+
for (let i = 0; i < operationsWithContext.length; i++) {
|
|
617
|
+
const opWithContext = operationsWithContext[i];
|
|
618
|
+
coordinates.push({
|
|
619
|
+
documentId: opWithContext.context.documentId,
|
|
620
|
+
scope: opWithContext.context.scope,
|
|
621
|
+
branch: opWithContext.context.branch,
|
|
622
|
+
operationIndex: opWithContext.operation.index
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
version: 1,
|
|
627
|
+
createdAtUtcIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
628
|
+
coordinates
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function createOperation(action, index, skip, context) {
|
|
632
|
+
return {
|
|
633
|
+
id: deriveOperationId(context.documentId, context.scope, context.branch, action.id),
|
|
634
|
+
index,
|
|
635
|
+
timestampUtcMs: action.timestampUtcMs || (/* @__PURE__ */ new Date()).toISOString(),
|
|
636
|
+
hash: "",
|
|
637
|
+
skip,
|
|
638
|
+
action
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function updateDocumentRevision(document, scope, operationIndex) {
|
|
642
|
+
document.header.revision = {
|
|
643
|
+
...document.header.revision,
|
|
644
|
+
[scope]: operationIndex + 1
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function buildSuccessResult(job, operation, documentId, documentType, resultingState, startTime) {
|
|
648
|
+
return {
|
|
649
|
+
job,
|
|
650
|
+
success: true,
|
|
651
|
+
operations: [operation],
|
|
652
|
+
operationsWithContext: [{
|
|
653
|
+
operation,
|
|
654
|
+
context: {
|
|
655
|
+
documentId,
|
|
656
|
+
scope: job.scope,
|
|
657
|
+
branch: job.branch,
|
|
658
|
+
documentType,
|
|
659
|
+
resultingState,
|
|
660
|
+
ordinal: 0
|
|
661
|
+
}
|
|
662
|
+
}],
|
|
663
|
+
duration: Date.now() - startTime
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
function buildErrorResult(job, error, startTime) {
|
|
667
|
+
return {
|
|
668
|
+
job,
|
|
669
|
+
success: false,
|
|
670
|
+
error,
|
|
671
|
+
duration: Date.now() - startTime
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The error a refusal surfaces as. Both classes are already terminal in the job
|
|
676
|
+
* result handler, so a refusal never burns a retry.
|
|
677
|
+
*/
|
|
678
|
+
function refusalError(reason, documentId, deletedAtUtcIso, action) {
|
|
679
|
+
if (reason === DOCUMENT_DELETED_REASON) return new DocumentDeletedError(documentId, deletedAtUtcIso);
|
|
680
|
+
return new AuthorizationDeniedError(documentId, action.scope, action.type, action.context?.signer?.user.address);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Whether this operation is part of the document's creation. The create and the
|
|
684
|
+
* upgrade from version zero hold the first two indexes for the life of the
|
|
685
|
+
* document, so a reshuffle has to leave them where they are.
|
|
686
|
+
*/
|
|
687
|
+
function isGenesisOperation(operation) {
|
|
688
|
+
if (operation.action.type === "CREATE_DOCUMENT") return true;
|
|
689
|
+
if (operation.action.type !== "UPGRADE_DOCUMENT") return false;
|
|
690
|
+
return operation.action.input.fromVersion === 0;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The distinct streams a job wrote, collected so a rollback knows what to evict.
|
|
694
|
+
*
|
|
695
|
+
* A write records its stream on every apply, and a long run applies the same
|
|
696
|
+
* stream hundreds of times, so this keeps one entry per stream rather than one
|
|
697
|
+
* per write: the eviction only cares which streams were touched, and the
|
|
698
|
+
* successful jobs that never read this back pay for a lookup instead of an
|
|
699
|
+
* allocation.
|
|
700
|
+
*/
|
|
701
|
+
var TouchedStreams = class {
|
|
702
|
+
streams = /* @__PURE__ */ new Map();
|
|
703
|
+
add(documentId, scope, branch) {
|
|
704
|
+
const key = `${documentId}\u0000${scope}\u0000${branch}`;
|
|
705
|
+
if (this.streams.has(key)) return;
|
|
706
|
+
this.streams.set(key, {
|
|
707
|
+
documentId,
|
|
708
|
+
scope,
|
|
709
|
+
branch
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
[Symbol.iterator]() {
|
|
713
|
+
return this.streams.values();
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
/**
|
|
717
|
+
* The ids of the actions the caller handed to a job. Load and reevaluation
|
|
718
|
+
* jobs write operations nobody submitted, so they report none.
|
|
719
|
+
*/
|
|
720
|
+
function submittedActionIds(job) {
|
|
721
|
+
return job.kind === "mutation" ? job.actions.map((action) => action.id) : [];
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Reports what became of each submitted action.
|
|
725
|
+
*
|
|
726
|
+
* A job's operations can include ones it only moved to a new index, so only
|
|
727
|
+
* those carrying a submitted action are reported. Returns undefined when the
|
|
728
|
+
* job submitted nothing, which keeps `JobInfo.result` null for the jobs that
|
|
729
|
+
* have no caller to answer to.
|
|
730
|
+
*/
|
|
731
|
+
function summarizeSubmittedActions(operations, submitted) {
|
|
732
|
+
if (!submitted || submitted.length === 0) return;
|
|
733
|
+
const ids = new Set(submitted);
|
|
734
|
+
const actions = [];
|
|
735
|
+
for (const { operation, context } of operations) {
|
|
736
|
+
if (!ids.has(operation.action.id)) continue;
|
|
737
|
+
actions.push({
|
|
738
|
+
actionId: operation.action.id,
|
|
739
|
+
scope: context.scope,
|
|
740
|
+
index: operation.index,
|
|
741
|
+
...operationOutcome(operation)
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
if (actions.length === 0) return;
|
|
745
|
+
return {
|
|
746
|
+
actions,
|
|
747
|
+
allApplied: actions.every((action) => action.kind === "applied")
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/** Tombstones a job has read, each under the shared lock it took first. */
|
|
751
|
+
var PurgeFence = class {
|
|
752
|
+
locks;
|
|
753
|
+
checked;
|
|
754
|
+
purged;
|
|
755
|
+
constructor(locks, checked, purged) {
|
|
756
|
+
this.locks = locks;
|
|
757
|
+
this.checked = checked;
|
|
758
|
+
this.purged = purged;
|
|
759
|
+
}
|
|
760
|
+
/** Locks and reads an id the job start did not resolve. */
|
|
761
|
+
async isPurged(documentId) {
|
|
762
|
+
await this.isPurgedMany([documentId]);
|
|
763
|
+
return this.purged.has(documentId);
|
|
764
|
+
}
|
|
765
|
+
/** Locks and reads the unresolved ids in one lock and one lookup. */
|
|
766
|
+
async isPurgedMany(documentIds) {
|
|
767
|
+
const unchecked = [...new Set(documentIds)].filter((id) => !this.checked.has(id));
|
|
768
|
+
if (unchecked.length > 0) {
|
|
769
|
+
await this.locks.shared(unchecked);
|
|
770
|
+
const found = await this.locks.purged(unchecked);
|
|
771
|
+
for (const id of unchecked) this.checked.add(id);
|
|
772
|
+
for (const id of found) this.purged.add(id);
|
|
773
|
+
}
|
|
774
|
+
return new Set(documentIds.filter((id) => this.purged.has(id)));
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
/** Locks and checks an id on its first read; a purged id reads as purged. */
|
|
778
|
+
var FencedWriteCache = class {
|
|
779
|
+
inner;
|
|
780
|
+
purgeFence;
|
|
781
|
+
evict;
|
|
782
|
+
evicted = /* @__PURE__ */ new Set();
|
|
783
|
+
constructor(inner, purgeFence, evict) {
|
|
784
|
+
this.inner = inner;
|
|
785
|
+
this.purgeFence = purgeFence;
|
|
786
|
+
this.evict = evict;
|
|
787
|
+
}
|
|
788
|
+
/** Fences ids known before their reads in one round trip. */
|
|
789
|
+
async fence(documentIds) {
|
|
790
|
+
await this.purgeFence.isPurgedMany(documentIds);
|
|
791
|
+
}
|
|
792
|
+
async getState(documentId, scope, branch, targetRevision, signal) {
|
|
793
|
+
if (await this.purgeFence.isPurged(documentId)) {
|
|
794
|
+
if (!this.evicted.has(documentId)) {
|
|
795
|
+
this.evicted.add(documentId);
|
|
796
|
+
this.evict(documentId);
|
|
797
|
+
}
|
|
798
|
+
throw new DocumentPurgedError(documentId);
|
|
799
|
+
}
|
|
800
|
+
return this.inner.getState(documentId, scope, branch, targetRevision, signal);
|
|
801
|
+
}
|
|
802
|
+
putState(documentId, scope, branch, revision, document, position) {
|
|
803
|
+
this.inner.putState(documentId, scope, branch, revision, document, position);
|
|
804
|
+
}
|
|
805
|
+
putRun(documentId, scope, branch, run) {
|
|
806
|
+
this.inner.putRun(documentId, scope, branch, run);
|
|
807
|
+
}
|
|
808
|
+
invalidate(documentId, scope, branch) {
|
|
809
|
+
return this.inner.invalidate(documentId, scope, branch);
|
|
810
|
+
}
|
|
811
|
+
clear() {
|
|
812
|
+
this.inner.clear();
|
|
813
|
+
}
|
|
814
|
+
startup() {
|
|
815
|
+
return this.inner.startup();
|
|
816
|
+
}
|
|
817
|
+
shutdown() {
|
|
818
|
+
return this.inner.shutdown();
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
/** The target whose membership an ADD or REMOVE_RELATIONSHIP writes. */
|
|
822
|
+
function relationshipTarget(action) {
|
|
823
|
+
if (action.type !== "ADD_RELATIONSHIP" && action.type !== "REMOVE_RELATIONSHIP") return;
|
|
824
|
+
const target = action.input?.targetId;
|
|
825
|
+
return typeof target === "string" && target.length > 0 ? target : void 0;
|
|
826
|
+
}
|
|
827
|
+
/** The ids a job's locks cover, sorted: its write ids and groups it names. */
|
|
828
|
+
function jobWriteIds(job) {
|
|
829
|
+
const ids = /* @__PURE__ */ new Set([job.documentId]);
|
|
830
|
+
const actions = [...job.actions, ...job.operations.map((operation) => operation.action)];
|
|
831
|
+
for (const action of actions) {
|
|
832
|
+
if (DOCUMENT_SCOPE_ACTIONS.has(action.type)) ids.add(targetDocumentId(action, job.documentId));
|
|
833
|
+
const target = relationshipTarget(action);
|
|
834
|
+
if (target !== void 0) ids.add(target);
|
|
835
|
+
if (action.scope === "auth") for (const groupId of mentionedGroupIds(action)) ids.add(groupId);
|
|
836
|
+
}
|
|
837
|
+
return [...ids].sort();
|
|
838
|
+
}
|
|
839
|
+
//#endregion
|
|
840
|
+
//#region src/cache/collection-membership-cache.ts
|
|
841
|
+
var CollectionMembershipCache = class CollectionMembershipCache {
|
|
842
|
+
operationIndex;
|
|
843
|
+
cache = /* @__PURE__ */ new Map();
|
|
844
|
+
constructor(operationIndex) {
|
|
845
|
+
this.operationIndex = operationIndex;
|
|
846
|
+
}
|
|
847
|
+
withScopedIndex(operationIndex) {
|
|
848
|
+
const scoped = new CollectionMembershipCache(operationIndex);
|
|
849
|
+
scoped.cache = this.cache;
|
|
850
|
+
return scoped;
|
|
851
|
+
}
|
|
852
|
+
async getCollectionsForDocuments(documentIds) {
|
|
853
|
+
const result = {};
|
|
854
|
+
const missing = [];
|
|
855
|
+
for (const docId of documentIds) {
|
|
856
|
+
const cached = this.cache.get(docId);
|
|
857
|
+
if (cached !== void 0) result[docId] = cached;
|
|
858
|
+
else missing.push(docId);
|
|
859
|
+
}
|
|
860
|
+
if (missing.length > 0) {
|
|
861
|
+
const fromDb = await this.operationIndex.getCollectionsForDocuments(missing);
|
|
862
|
+
for (const docId of missing) {
|
|
863
|
+
const collections = fromDb[docId] ?? [];
|
|
864
|
+
result[docId] = collections;
|
|
865
|
+
this.cache.set(docId, collections);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
invalidate(documentId) {
|
|
871
|
+
this.cache.delete(documentId);
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/cache/lru/lru-tracker.ts
|
|
876
|
+
var LRUNode = class {
|
|
877
|
+
key;
|
|
878
|
+
prev;
|
|
879
|
+
next;
|
|
880
|
+
constructor(key) {
|
|
881
|
+
this.key = key;
|
|
882
|
+
this.prev = void 0;
|
|
883
|
+
this.next = void 0;
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
var LRUTracker = class {
|
|
887
|
+
map;
|
|
888
|
+
head;
|
|
889
|
+
tail;
|
|
890
|
+
constructor() {
|
|
891
|
+
this.map = /* @__PURE__ */ new Map();
|
|
892
|
+
this.head = void 0;
|
|
893
|
+
this.tail = void 0;
|
|
894
|
+
}
|
|
895
|
+
get size() {
|
|
896
|
+
return this.map.size;
|
|
897
|
+
}
|
|
898
|
+
touch(key) {
|
|
899
|
+
const node = this.map.get(key);
|
|
900
|
+
if (node) this.moveToFront(node);
|
|
901
|
+
else this.addToFront(key);
|
|
902
|
+
}
|
|
903
|
+
evict() {
|
|
904
|
+
if (!this.tail) return;
|
|
905
|
+
const key = this.tail.key;
|
|
906
|
+
this.remove(key);
|
|
907
|
+
return key;
|
|
908
|
+
}
|
|
909
|
+
remove(key) {
|
|
910
|
+
const node = this.map.get(key);
|
|
911
|
+
if (!node) return;
|
|
912
|
+
this.removeNode(node);
|
|
913
|
+
this.map.delete(key);
|
|
914
|
+
}
|
|
915
|
+
clear() {
|
|
916
|
+
this.map.clear();
|
|
917
|
+
this.head = void 0;
|
|
918
|
+
this.tail = void 0;
|
|
919
|
+
}
|
|
920
|
+
addToFront(key) {
|
|
921
|
+
const node = new LRUNode(key);
|
|
922
|
+
this.map.set(key, node);
|
|
923
|
+
if (!this.head) {
|
|
924
|
+
this.head = node;
|
|
925
|
+
this.tail = node;
|
|
926
|
+
} else {
|
|
927
|
+
node.next = this.head;
|
|
928
|
+
this.head.prev = node;
|
|
929
|
+
this.head = node;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
moveToFront(node) {
|
|
933
|
+
if (node === this.head) return;
|
|
934
|
+
this.removeNode(node);
|
|
935
|
+
node.prev = void 0;
|
|
936
|
+
node.next = this.head;
|
|
937
|
+
if (this.head) this.head.prev = node;
|
|
938
|
+
this.head = node;
|
|
939
|
+
if (!this.tail) this.tail = node;
|
|
940
|
+
}
|
|
941
|
+
removeNode(node) {
|
|
942
|
+
if (node.prev) node.prev.next = node.next;
|
|
943
|
+
else this.head = node.next;
|
|
944
|
+
if (node.next) node.next.prev = node.prev;
|
|
945
|
+
else this.tail = node.prev;
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/cache/document-meta-cache.ts
|
|
950
|
+
/**
|
|
951
|
+
* In-memory document metadata cache with LRU eviction.
|
|
952
|
+
*
|
|
953
|
+
* Caches PHDocumentState per (documentId, branch) key. On cache miss,
|
|
954
|
+
* rebuilds from document scope operations. Provides an explicit cross-scope
|
|
955
|
+
* contract for accessing document scope metadata.
|
|
956
|
+
*
|
|
957
|
+
* **Thread Safety:**
|
|
958
|
+
* Not thread-safe. Designed for single-threaded job executor environment.
|
|
959
|
+
*/
|
|
960
|
+
var DocumentMetaCache = class DocumentMetaCache {
|
|
961
|
+
cache;
|
|
962
|
+
lruTracker;
|
|
963
|
+
operationStore;
|
|
964
|
+
config;
|
|
965
|
+
constructor(operationStore, config) {
|
|
966
|
+
this.operationStore = operationStore;
|
|
967
|
+
this.config = { maxDocuments: config.maxDocuments };
|
|
968
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
969
|
+
this.lruTracker = new LRUTracker();
|
|
970
|
+
}
|
|
971
|
+
withScopedStore(operationStore) {
|
|
972
|
+
const scoped = new DocumentMetaCache(operationStore, this.config);
|
|
973
|
+
scoped.cache = this.cache;
|
|
974
|
+
scoped.lruTracker = this.lruTracker;
|
|
975
|
+
return scoped;
|
|
976
|
+
}
|
|
977
|
+
async startup() {
|
|
978
|
+
return Promise.resolve();
|
|
979
|
+
}
|
|
980
|
+
async shutdown() {
|
|
981
|
+
return Promise.resolve();
|
|
982
|
+
}
|
|
983
|
+
async getDocumentMeta(documentId, branch, signal) {
|
|
984
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
985
|
+
const key = this.makeKey(documentId, branch);
|
|
986
|
+
const cached = this.cache.get(key);
|
|
987
|
+
if (cached) {
|
|
988
|
+
this.lruTracker.touch(key);
|
|
989
|
+
return cached;
|
|
990
|
+
}
|
|
991
|
+
const meta = await this.rebuildLatest(documentId, branch, signal);
|
|
992
|
+
this.putDocumentMeta(documentId, branch, meta);
|
|
993
|
+
return meta;
|
|
994
|
+
}
|
|
995
|
+
async rebuildAtRevision(documentId, branch, targetRevision, signal) {
|
|
996
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
997
|
+
return this.rebuildFromOperations(documentId, branch, targetRevision, signal);
|
|
998
|
+
}
|
|
999
|
+
putDocumentMeta(documentId, branch, meta) {
|
|
1000
|
+
const key = this.makeKey(documentId, branch);
|
|
1001
|
+
if (!this.cache.has(key) && this.cache.size >= this.config.maxDocuments) {
|
|
1002
|
+
const evictKey = this.lruTracker.evict();
|
|
1003
|
+
if (evictKey) this.cache.delete(evictKey);
|
|
1004
|
+
}
|
|
1005
|
+
this.cache.set(key, structuredClone(meta));
|
|
1006
|
+
this.lruTracker.touch(key);
|
|
1007
|
+
}
|
|
1008
|
+
invalidate(documentId, branch) {
|
|
1009
|
+
let evicted = 0;
|
|
1010
|
+
if (branch === void 0) {
|
|
1011
|
+
for (const key of this.cache.keys()) if (key.startsWith(`${documentId}:`)) {
|
|
1012
|
+
this.cache.delete(key);
|
|
1013
|
+
this.lruTracker.remove(key);
|
|
1014
|
+
evicted++;
|
|
1015
|
+
}
|
|
1016
|
+
} else {
|
|
1017
|
+
const key = this.makeKey(documentId, branch);
|
|
1018
|
+
if (this.cache.has(key)) {
|
|
1019
|
+
this.cache.delete(key);
|
|
1020
|
+
this.lruTracker.remove(key);
|
|
1021
|
+
evicted = 1;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return evicted;
|
|
1025
|
+
}
|
|
1026
|
+
clear() {
|
|
1027
|
+
this.cache.clear();
|
|
1028
|
+
this.lruTracker.clear();
|
|
1029
|
+
}
|
|
1030
|
+
makeKey(documentId, branch) {
|
|
1031
|
+
return `${documentId}:${branch}`;
|
|
1032
|
+
}
|
|
1033
|
+
async rebuildLatest(documentId, branch, signal) {
|
|
1034
|
+
return this.rebuildFromOperations(documentId, branch, void 0, signal);
|
|
1035
|
+
}
|
|
1036
|
+
async rebuildFromOperations(documentId, branch, targetRevision, signal) {
|
|
1037
|
+
const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, void 0, signal);
|
|
1038
|
+
if (docScopeOps.results.length === 0) throw new DocumentNotFoundError(documentId);
|
|
1039
|
+
const createOp = docScopeOps.results[0];
|
|
1040
|
+
if (isPurgeMarker(createOp)) return purgedMeta(createOp.action);
|
|
1041
|
+
if (createOp.action.type !== "CREATE_DOCUMENT") throw new Error(`Invalid document: first operation must be CREATE_DOCUMENT, found ${createOp.action.type}`);
|
|
1042
|
+
const createAction = createOp.action;
|
|
1043
|
+
const documentType = createAction.input.model;
|
|
1044
|
+
let document = createDocumentFromAction(createAction);
|
|
1045
|
+
let documentScopeRevision = 0;
|
|
1046
|
+
let purged = false;
|
|
1047
|
+
for (const op of docScopeOps.results) {
|
|
1048
|
+
if (targetRevision !== void 0 && op.index > targetRevision) break;
|
|
1049
|
+
documentScopeRevision = op.index;
|
|
1050
|
+
if (op.action.type === "UPGRADE_DOCUMENT") {
|
|
1051
|
+
const upgradeAction = op.action;
|
|
1052
|
+
document = applyUpgradeDocumentAction$1(document, upgradeAction);
|
|
1053
|
+
} else if (op.action.type === "DELETE_DOCUMENT" || op.action.type === PURGE_DOCUMENT) {
|
|
1054
|
+
document = applyDeleteDocumentAction$1(document, op.action);
|
|
1055
|
+
purged ||= op.action.type === PURGE_DOCUMENT;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
state: document.state.document,
|
|
1060
|
+
documentType,
|
|
1061
|
+
protocolVersions: purged ? purgedProtocolVersions() : createAction.input.protocolVersions,
|
|
1062
|
+
documentScopeRevision: documentScopeRevision + 1
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
/** A lone marker has no CREATE_DOCUMENT; the marker describes the stream. */
|
|
1067
|
+
function purgedMeta(action) {
|
|
1068
|
+
return {
|
|
1069
|
+
state: createDocumentState({
|
|
1070
|
+
isDeleted: true,
|
|
1071
|
+
deletedAtUtcIso: action.input.purgedAtUtcIso
|
|
1072
|
+
}),
|
|
1073
|
+
documentType: action.input.documentType,
|
|
1074
|
+
protocolVersions: purgedProtocolVersions(),
|
|
1075
|
+
documentScopeRevision: 1
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/catch-up/settled-watermark.ts
|
|
1080
|
+
const MAX_PENDING_PROBES = 64;
|
|
1081
|
+
/** pg_sequence_last_value needs 10; pg_current_* replaced txid_* in 13. */
|
|
1082
|
+
function snapshotFunctionsFor(serverVersionNum) {
|
|
1083
|
+
if (serverVersionNum >= 13e4) return {
|
|
1084
|
+
snapshot: "pg_current_snapshot",
|
|
1085
|
+
currentXid: "pg_current_xact_id",
|
|
1086
|
+
xidIfAssigned: "pg_current_xact_id_if_assigned"
|
|
1087
|
+
};
|
|
1088
|
+
if (serverVersionNum >= 1e5) return {
|
|
1089
|
+
snapshot: "txid_current_snapshot",
|
|
1090
|
+
currentXid: "txid_current",
|
|
1091
|
+
xidIfAssigned: "txid_current_if_assigned"
|
|
1092
|
+
};
|
|
1093
|
+
throw new Error(`PostgreSQL server_version_num ${serverVersionNum} is not supported: read-side catch-up needs 10 or later`);
|
|
1094
|
+
}
|
|
1095
|
+
/** Parses the `xmin:xmax:xip,...` text form of a snapshot. */
|
|
1096
|
+
function parseSnapshot(text) {
|
|
1097
|
+
const parts = text.split(":");
|
|
1098
|
+
if (parts.length !== 3) throw new Error(`Malformed snapshot: ${text}`);
|
|
1099
|
+
const [xmin, xmax, xip] = parts;
|
|
1100
|
+
return {
|
|
1101
|
+
xmin: BigInt(xmin),
|
|
1102
|
+
xmax: BigInt(xmax),
|
|
1103
|
+
xip: xip === "" ? [] : xip.split(",").map((xid) => BigInt(xid))
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
async function readSnapshotFunctions(db) {
|
|
1107
|
+
const result = await sql`select current_setting('server_version_num') as version`.execute(db);
|
|
1108
|
+
return snapshotFunctionsFor(Number(result.rows[0].version));
|
|
1109
|
+
}
|
|
1110
|
+
/** A probe settles once an xmin passes the xid it took after its head. */
|
|
1111
|
+
var ProbeSettler = class {
|
|
1112
|
+
settled = 0;
|
|
1113
|
+
lastHead = 0;
|
|
1114
|
+
covered = 0;
|
|
1115
|
+
lastXip = [];
|
|
1116
|
+
pending = [];
|
|
1117
|
+
get settledThrough() {
|
|
1118
|
+
return this.settled;
|
|
1119
|
+
}
|
|
1120
|
+
get head() {
|
|
1121
|
+
return this.lastHead;
|
|
1122
|
+
}
|
|
1123
|
+
/** The highest head a settled or pending probe covers. */
|
|
1124
|
+
get probedHead() {
|
|
1125
|
+
return this.covered;
|
|
1126
|
+
}
|
|
1127
|
+
observe(head, xid, snapshot, nowUtcMs) {
|
|
1128
|
+
this.lastHead = Math.max(this.lastHead, head);
|
|
1129
|
+
this.lastXip = snapshot.xip;
|
|
1130
|
+
if (xid !== null && head > this.covered) {
|
|
1131
|
+
this.covered = head;
|
|
1132
|
+
this.pending.push({
|
|
1133
|
+
head,
|
|
1134
|
+
xid,
|
|
1135
|
+
takenAtUtcMs: nowUtcMs
|
|
1136
|
+
});
|
|
1137
|
+
if (this.pending.length > MAX_PENDING_PROBES) this.pending.shift();
|
|
1138
|
+
}
|
|
1139
|
+
const stillPending = [];
|
|
1140
|
+
for (const probe of this.pending) if (snapshot.xmin > probe.xid) this.settled = Math.max(this.settled, probe.head);
|
|
1141
|
+
else stillPending.push(probe);
|
|
1142
|
+
this.pending = stillPending.filter((probe) => probe.head > this.settled);
|
|
1143
|
+
return this.settled;
|
|
1144
|
+
}
|
|
1145
|
+
/** Xids an unsettled probe still waits on. */
|
|
1146
|
+
waitingOn() {
|
|
1147
|
+
if (this.pending.length === 0) return [];
|
|
1148
|
+
const xid = this.pending[0].xid;
|
|
1149
|
+
return this.lastXip.filter((open) => open < xid).map((open) => open.toString());
|
|
1150
|
+
}
|
|
1151
|
+
stalledSinceUtcMs() {
|
|
1152
|
+
return this.pending[0]?.takenAtUtcMs;
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
function operationIndexTableName(db) {
|
|
1156
|
+
const from = db.selectFrom("operation_index_operations").selectAll().toOperationNode().from?.froms[0];
|
|
1157
|
+
const schema = from !== void 0 && TableNode.is(from) ? from.table.schema?.name : void 0;
|
|
1158
|
+
const quote = (identifier) => `"${identifier.replace(/"/g, "\"\"")}"`;
|
|
1159
|
+
return schema === void 0 ? quote("operation_index_operations") : `${quote(schema)}.${quote("operation_index_operations")}`;
|
|
1160
|
+
}
|
|
1161
|
+
/** Reads the head, takes and commits an xid, then reads a snapshot, in order. */
|
|
1162
|
+
function createKyselyWatermarkProbe(db) {
|
|
1163
|
+
let resolved = null;
|
|
1164
|
+
const resolve = async () => {
|
|
1165
|
+
const fns = await readSnapshotFunctions(db);
|
|
1166
|
+
const table = operationIndexTableName(db);
|
|
1167
|
+
const sequence = (await sql`select pg_get_serial_sequence(${table}, 'ordinal') as sequence`.execute(db)).rows[0]?.sequence;
|
|
1168
|
+
if (!sequence) throw new Error(`No ordinal sequence found for ${table}`);
|
|
1169
|
+
return {
|
|
1170
|
+
fns,
|
|
1171
|
+
sequence
|
|
1172
|
+
};
|
|
1173
|
+
};
|
|
1174
|
+
return async (probedHead) => {
|
|
1175
|
+
resolved ??= resolve().catch((error) => {
|
|
1176
|
+
resolved = null;
|
|
1177
|
+
throw error;
|
|
1178
|
+
});
|
|
1179
|
+
const { fns, sequence } = await resolved;
|
|
1180
|
+
const headRow = (await sql`select coalesce(pg_sequence_last_value(${sequence}::regclass), 0) as head, ${sql.raw(fns.xidIfAssigned)}() is null as outside_write`.execute(db)).rows[0];
|
|
1181
|
+
const head = Number(headRow.head);
|
|
1182
|
+
let xid = null;
|
|
1183
|
+
if (headRow.outside_write && head > probedHead) {
|
|
1184
|
+
const xidResult = await sql`select ${sql.raw(fns.currentXid)}()::text as xid`.execute(db);
|
|
1185
|
+
xid = BigInt(xidResult.rows[0].xid);
|
|
1186
|
+
}
|
|
1187
|
+
const row = (await sql`select ${sql.raw(fns.snapshot)}()::text as snapshot, ${sql.raw(fns.xidIfAssigned)}() is null as outside_write`.execute(db)).rows[0];
|
|
1188
|
+
return {
|
|
1189
|
+
head,
|
|
1190
|
+
xid,
|
|
1191
|
+
snapshot: parseSnapshot(row.snapshot),
|
|
1192
|
+
outsideWrite: headRow.outside_write && row.outside_write
|
|
1193
|
+
};
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
/** Knows when an ordinal gap is final by asking which transactions are open. */
|
|
1197
|
+
var SettledWatermark = class {
|
|
1198
|
+
probe;
|
|
1199
|
+
logger;
|
|
1200
|
+
now;
|
|
1201
|
+
settler = new ProbeSettler();
|
|
1202
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1203
|
+
current;
|
|
1204
|
+
next;
|
|
1205
|
+
warnedInsideWrite = false;
|
|
1206
|
+
constructor(probe, logger, now = Date.now) {
|
|
1207
|
+
this.probe = probe;
|
|
1208
|
+
this.logger = logger;
|
|
1209
|
+
this.now = now;
|
|
1210
|
+
}
|
|
1211
|
+
get settledThrough() {
|
|
1212
|
+
return this.settler.settledThrough;
|
|
1213
|
+
}
|
|
1214
|
+
refresh(signal) {
|
|
1215
|
+
signal?.throwIfAborted();
|
|
1216
|
+
if (this.next !== void 0) return this.next;
|
|
1217
|
+
if (this.current === void 0) return this.start();
|
|
1218
|
+
const next = this.current.catch(() => this.settledThrough).then(() => {
|
|
1219
|
+
this.next = void 0;
|
|
1220
|
+
return this.start();
|
|
1221
|
+
});
|
|
1222
|
+
this.next = next;
|
|
1223
|
+
return next;
|
|
1224
|
+
}
|
|
1225
|
+
onAdvance(listener) {
|
|
1226
|
+
this.listeners.add(listener);
|
|
1227
|
+
return () => {
|
|
1228
|
+
this.listeners.delete(listener);
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
status() {
|
|
1232
|
+
const stalledSinceUtcMs = this.settler.head > this.settledThrough ? this.settler.stalledSinceUtcMs() : void 0;
|
|
1233
|
+
return {
|
|
1234
|
+
head: this.settler.head,
|
|
1235
|
+
settledThrough: this.settledThrough,
|
|
1236
|
+
waitingOn: this.settler.waitingOn(),
|
|
1237
|
+
...stalledSinceUtcMs !== void 0 ? { stalledSinceUtcMs } : {}
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
start() {
|
|
1241
|
+
const run = this.probeOnce().finally(() => {
|
|
1242
|
+
if (this.current === run) this.current = void 0;
|
|
1243
|
+
});
|
|
1244
|
+
this.current = run;
|
|
1245
|
+
return run;
|
|
1246
|
+
}
|
|
1247
|
+
async probeOnce() {
|
|
1248
|
+
const reading = await this.probe(this.settler.probedHead);
|
|
1249
|
+
if (!reading.outsideWrite) {
|
|
1250
|
+
if (!this.warnedInsideWrite) {
|
|
1251
|
+
this.warnedInsideWrite = true;
|
|
1252
|
+
this.logger.warn("settled watermark probe ran inside another session's write transaction; discarded");
|
|
1253
|
+
}
|
|
1254
|
+
return this.settledThrough;
|
|
1255
|
+
}
|
|
1256
|
+
const before = this.settledThrough;
|
|
1257
|
+
const after = this.settler.observe(reading.head, reading.xid, reading.snapshot, this.now());
|
|
1258
|
+
if (after > before) for (const listener of this.listeners) try {
|
|
1259
|
+
listener(after);
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
this.logger.error("settled watermark listener failed: @error", error);
|
|
1262
|
+
}
|
|
1263
|
+
return after;
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
/** Sessions holding these xids open; empty without pg_stat_activity. */
|
|
1267
|
+
async function describeWaitingSessions(db, xids) {
|
|
1268
|
+
if (xids.length === 0) return [];
|
|
1269
|
+
const epochless = xids.map((xid) => (BigInt(xid) % 2n ** 32n).toString());
|
|
1270
|
+
try {
|
|
1271
|
+
return (await sql`select pid, application_name, state, xact_start, backend_xid::text as xid
|
|
1272
|
+
from pg_stat_activity
|
|
1273
|
+
where backend_xid::text in (${sql.join(epochless)})`.execute(db)).rows.map((row) => ({
|
|
1274
|
+
pid: Number(row.pid),
|
|
1275
|
+
applicationName: row.application_name ?? "",
|
|
1276
|
+
state: row.state ?? "",
|
|
1277
|
+
xactStart: row.xact_start === null ? null : new Date(row.xact_start).toISOString(),
|
|
1278
|
+
xid: row.xid
|
|
1279
|
+
}));
|
|
1280
|
+
} catch {
|
|
1281
|
+
return [];
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/storage/kysely/document-purges.ts
|
|
1286
|
+
/** Class key of every purge lock; the stream locks use the single-key space. */
|
|
1287
|
+
const PURGE_NS = 1347571013;
|
|
1288
|
+
/** Ids share this many lock keys, so a job's lock count stays bounded. */
|
|
1289
|
+
const PURGE_LOCK_BUCKETS = 1024;
|
|
1290
|
+
async function acquirePurgeLocks(trx, ids, mode) {
|
|
1291
|
+
const unique = [...new Set(ids)];
|
|
1292
|
+
if (unique.length === 0) return;
|
|
1293
|
+
const lock = sql.raw(mode === "shared" ? "pg_advisory_xact_lock_shared" : "pg_advisory_xact_lock");
|
|
1294
|
+
await sql`
|
|
1295
|
+
with keys as materialized (
|
|
1296
|
+
select distinct hashtext(id) & ${sql.lit(1023)} as key
|
|
1297
|
+
from unnest(${unique}::text[]) as t(id)
|
|
1298
|
+
order by key
|
|
1299
|
+
)
|
|
1300
|
+
select ${lock}(${sql.lit(PURGE_NS)}, key) from keys order by key
|
|
1301
|
+
`.execute(trx);
|
|
1302
|
+
}
|
|
1303
|
+
/** The ids among `ids` that have a tombstone, read in the handle's schema. */
|
|
1304
|
+
async function findPurged(db, ids) {
|
|
1305
|
+
const unique = [...new Set(ids)];
|
|
1306
|
+
if (unique.length === 0) return /* @__PURE__ */ new Set();
|
|
1307
|
+
const rows = await db.selectFrom("document_purges").select("documentId").where(sql`"documentId" = any(${unique}::text[])`).execute();
|
|
1308
|
+
return new Set(rows.map((row) => row.documentId));
|
|
1309
|
+
}
|
|
1310
|
+
/** Every tombstoned id, read in the handle's schema. */
|
|
1311
|
+
async function listPurged(db) {
|
|
1312
|
+
return (await db.selectFrom("document_purges").select("documentId").orderBy("documentId").execute()).map((row) => row.documentId);
|
|
1313
|
+
}
|
|
1314
|
+
/** Bind slots per statement: 16-bit on the wire, read signed in-process. */
|
|
1315
|
+
const MAX_BIND_PARAMETERS = 32767;
|
|
1316
|
+
/** Splits rows into runs that each bind at most MAX_BIND_PARAMETERS. */
|
|
1317
|
+
function chunkRows(rows) {
|
|
1318
|
+
if (rows.length === 0) return [];
|
|
1319
|
+
const columnsPerRow = Math.max(1, Object.keys(rows[0]).length);
|
|
1320
|
+
const perChunk = Math.max(1, Math.floor(MAX_BIND_PARAMETERS / columnsPerRow));
|
|
1321
|
+
if (rows.length <= perChunk) return [rows];
|
|
1322
|
+
const chunks = [];
|
|
1323
|
+
for (let i = 0; i < rows.length; i += perChunk) chunks.push(rows.slice(i, i + perChunk));
|
|
1324
|
+
return chunks;
|
|
1325
|
+
}
|
|
1326
|
+
/** Per database: the function that assigns the transaction's xid. */
|
|
1327
|
+
const xidFunctions = /* @__PURE__ */ new WeakMap();
|
|
1328
|
+
var KyselyOperationIndexTxn = class {
|
|
1329
|
+
collections = [];
|
|
1330
|
+
collectionMemberships = [];
|
|
1331
|
+
collectionRemovals = [];
|
|
1332
|
+
groupReferences = [];
|
|
1333
|
+
operations = [];
|
|
1334
|
+
membershipInvalidations = /* @__PURE__ */ new Set();
|
|
1335
|
+
/** Called by the commit as it writes each document_collections row. */
|
|
1336
|
+
recordMembershipInvalidation(documentId) {
|
|
1337
|
+
this.membershipInvalidations.add(documentId);
|
|
1338
|
+
}
|
|
1339
|
+
getMembershipInvalidations() {
|
|
1340
|
+
return [...this.membershipInvalidations];
|
|
1341
|
+
}
|
|
1342
|
+
createCollection(collectionId) {
|
|
1343
|
+
this.collections.push(collectionId);
|
|
1344
|
+
}
|
|
1345
|
+
addToCollection(collectionId, documentId) {
|
|
1346
|
+
const lastOpIndex = this.operations.length - 1;
|
|
1347
|
+
if (lastOpIndex < 0) throw new Error("addToCollection must be called after write() - no operations in transaction");
|
|
1348
|
+
this.collectionMemberships.push({
|
|
1349
|
+
collectionId,
|
|
1350
|
+
documentId,
|
|
1351
|
+
operationIndex: lastOpIndex
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
removeFromCollection(collectionId, documentId) {
|
|
1355
|
+
const lastOpIndex = this.operations.length - 1;
|
|
1356
|
+
if (lastOpIndex < 0) throw new Error("removeFromCollection must be called after write() - no operations in transaction");
|
|
1357
|
+
this.collectionRemovals.push({
|
|
1358
|
+
collectionId,
|
|
1359
|
+
documentId,
|
|
1360
|
+
operationIndex: lastOpIndex
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
recordGroupReferences(documentId, groupIds) {
|
|
1364
|
+
const lastOpIndex = this.operations.length - 1;
|
|
1365
|
+
if (lastOpIndex < 0) throw new Error("recordGroupReferences must be called after write() - no operations in transaction");
|
|
1366
|
+
if (groupIds.length === 0) return;
|
|
1367
|
+
this.groupReferences.push({
|
|
1368
|
+
documentId,
|
|
1369
|
+
groupIds,
|
|
1370
|
+
operationIndex: lastOpIndex
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
write(operations) {
|
|
1374
|
+
this.operations.push(...operations);
|
|
1375
|
+
}
|
|
1376
|
+
getCollections() {
|
|
1377
|
+
return this.collections;
|
|
1378
|
+
}
|
|
1379
|
+
getGroupReferenceRecords() {
|
|
1380
|
+
return this.groupReferences;
|
|
1381
|
+
}
|
|
1382
|
+
getCollectionMembershipRecords() {
|
|
1383
|
+
return this.collectionMemberships;
|
|
1384
|
+
}
|
|
1385
|
+
getCollectionRemovals() {
|
|
1386
|
+
return this.collectionRemovals;
|
|
1387
|
+
}
|
|
1388
|
+
getOperations() {
|
|
1389
|
+
return this.operations;
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
var KyselyOperationIndex = class KyselyOperationIndex {
|
|
1393
|
+
db;
|
|
1394
|
+
trx;
|
|
1395
|
+
liveIds;
|
|
1396
|
+
constructor(db) {
|
|
1397
|
+
this.db = db;
|
|
1398
|
+
}
|
|
1399
|
+
get queryExecutor() {
|
|
1400
|
+
return this.trx ?? this.db;
|
|
1401
|
+
}
|
|
1402
|
+
/** `liveIds`: ids the transaction read untombstoned under its shared lock. */
|
|
1403
|
+
withTransaction(trx, liveIds) {
|
|
1404
|
+
const instance = new KyselyOperationIndex(this.db);
|
|
1405
|
+
instance.trx = trx;
|
|
1406
|
+
instance.liveIds = liveIds;
|
|
1407
|
+
return instance;
|
|
1408
|
+
}
|
|
1409
|
+
start() {
|
|
1410
|
+
return new KyselyOperationIndexTxn();
|
|
1411
|
+
}
|
|
1412
|
+
async commit(txn, signal) {
|
|
1413
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1414
|
+
const kyselyTxn = txn;
|
|
1415
|
+
if (this.trx) return this.executeCommit(this.trx, kyselyTxn);
|
|
1416
|
+
let resultOrdinals = [];
|
|
1417
|
+
await this.db.transaction().execute(async (trx) => {
|
|
1418
|
+
resultOrdinals = await this.executeCommit(trx, kyselyTxn);
|
|
1419
|
+
});
|
|
1420
|
+
return resultOrdinals;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* A policy-driven join: keeps the earliest join so a rediscovered reference
|
|
1424
|
+
* never shrinks a backfill window remotes already rely on, and reopens a
|
|
1425
|
+
* closed membership because a policy reference is not a removable one.
|
|
1426
|
+
*/
|
|
1427
|
+
async joinKeepingEarliest(trx, kyselyTxn, documentId, collectionId, ordinal) {
|
|
1428
|
+
kyselyTxn.recordMembershipInvalidation(documentId);
|
|
1429
|
+
await trx.insertInto("document_collections").values({
|
|
1430
|
+
documentId,
|
|
1431
|
+
collectionId,
|
|
1432
|
+
joinedOrdinal: ordinal,
|
|
1433
|
+
leftOrdinal: null
|
|
1434
|
+
}).onConflict((oc) => oc.columns(["documentId", "collectionId"]).doUpdateSet({
|
|
1435
|
+
joinedOrdinal: sql`LEAST("document_collections"."joinedOrdinal", EXCLUDED."joinedOrdinal")`,
|
|
1436
|
+
leftOrdinal: null
|
|
1437
|
+
})).execute();
|
|
1438
|
+
}
|
|
1439
|
+
async executeCommit(trx, kyselyTxn) {
|
|
1440
|
+
const collections = kyselyTxn.getCollections();
|
|
1441
|
+
const memberships = kyselyTxn.getCollectionMembershipRecords();
|
|
1442
|
+
const removals = kyselyTxn.getCollectionRemovals();
|
|
1443
|
+
const groupReferences = kyselyTxn.getGroupReferenceRecords();
|
|
1444
|
+
const operations = kyselyTxn.getOperations();
|
|
1445
|
+
if (collections.length > 0) {
|
|
1446
|
+
const collectionRows = collections.map((collectionId) => ({
|
|
1447
|
+
documentId: collectionId,
|
|
1448
|
+
collectionId,
|
|
1449
|
+
joinedOrdinal: BigInt(0),
|
|
1450
|
+
leftOrdinal: null
|
|
1451
|
+
}));
|
|
1452
|
+
for (const collectionId of collections) kyselyTxn.recordMembershipInvalidation(collectionId);
|
|
1453
|
+
for (const chunk of chunkRows(collectionRows)) await trx.insertInto("document_collections").values(chunk).onConflict((oc) => oc.doNothing()).execute();
|
|
1454
|
+
}
|
|
1455
|
+
let operationOrdinals = [];
|
|
1456
|
+
if (operations.length > 0) {
|
|
1457
|
+
await this.refusePurgedOperations(trx, operations);
|
|
1458
|
+
await this.assignXid(trx);
|
|
1459
|
+
const operationRows = operations.map((op) => ({
|
|
1460
|
+
opId: op.id || "",
|
|
1461
|
+
documentId: op.documentId,
|
|
1462
|
+
documentType: op.documentType,
|
|
1463
|
+
scope: op.scope,
|
|
1464
|
+
branch: op.branch,
|
|
1465
|
+
timestampUtcMs: op.timestampUtcMs,
|
|
1466
|
+
index: op.index,
|
|
1467
|
+
skip: op.skip,
|
|
1468
|
+
hash: op.hash,
|
|
1469
|
+
action: op.action,
|
|
1470
|
+
deniedReason: op.deniedReason ?? null,
|
|
1471
|
+
sourceRemote: op.sourceRemote
|
|
1472
|
+
}));
|
|
1473
|
+
for (const chunk of chunkRows(operationRows)) {
|
|
1474
|
+
const insertedOps = await trx.insertInto("operation_index_operations").values(chunk).returning("ordinal").execute();
|
|
1475
|
+
operationOrdinals = operationOrdinals.concat(insertedOps.map((row) => row.ordinal));
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (memberships.length > 0) for (const m of memberships) {
|
|
1479
|
+
const ordinal = operationOrdinals[m.operationIndex];
|
|
1480
|
+
kyselyTxn.recordMembershipInvalidation(m.documentId);
|
|
1481
|
+
await trx.insertInto("document_collections").values({
|
|
1482
|
+
documentId: m.documentId,
|
|
1483
|
+
collectionId: m.collectionId,
|
|
1484
|
+
joinedOrdinal: BigInt(ordinal),
|
|
1485
|
+
leftOrdinal: null
|
|
1486
|
+
}).onConflict((oc) => oc.columns(["documentId", "collectionId"]).doUpdateSet({
|
|
1487
|
+
joinedOrdinal: BigInt(ordinal),
|
|
1488
|
+
leftOrdinal: null
|
|
1489
|
+
})).execute();
|
|
1490
|
+
const references = await trx.selectFrom("group_references").select("groupId").where("documentId", "=", m.documentId).execute();
|
|
1491
|
+
for (const { groupId } of references) await this.joinKeepingEarliest(trx, kyselyTxn, groupId, m.collectionId, BigInt(ordinal));
|
|
1492
|
+
}
|
|
1493
|
+
if (removals.length > 0) for (const r of removals) {
|
|
1494
|
+
const ordinal = operationOrdinals[r.operationIndex];
|
|
1495
|
+
kyselyTxn.recordMembershipInvalidation(r.documentId);
|
|
1496
|
+
await trx.updateTable("document_collections").set({ leftOrdinal: BigInt(ordinal) }).where("collectionId", "=", r.collectionId).where("documentId", "=", r.documentId).where("leftOrdinal", "is", null).execute();
|
|
1497
|
+
}
|
|
1498
|
+
if (groupReferences.length > 0) for (const record of groupReferences) {
|
|
1499
|
+
const ordinal = operationOrdinals[record.operationIndex];
|
|
1500
|
+
await trx.insertInto("group_references").values(record.groupIds.map((groupId) => ({
|
|
1501
|
+
documentId: record.documentId,
|
|
1502
|
+
groupId
|
|
1503
|
+
}))).onConflict((oc) => oc.doNothing()).execute();
|
|
1504
|
+
const rows = await trx.selectFrom("document_collections").select("collectionId").where("documentId", "=", record.documentId).execute();
|
|
1505
|
+
for (const groupId of record.groupIds) for (const { collectionId } of rows) await this.joinKeepingEarliest(trx, kyselyTxn, groupId, collectionId, BigInt(ordinal));
|
|
1506
|
+
}
|
|
1507
|
+
return operationOrdinals;
|
|
1508
|
+
}
|
|
1509
|
+
async getCollectionsInRange(after, through, among, signal) {
|
|
1510
|
+
signal?.throwIfAborted();
|
|
1511
|
+
if (through <= after || among?.length === 0) return [];
|
|
1512
|
+
let query = this.queryExecutor.selectFrom("operation_index_operations as oi").innerJoin("document_collections as dc", "oi.documentId", "dc.documentId").select("dc.collectionId").distinct().where("oi.ordinal", ">", after).where("oi.ordinal", "<=", through);
|
|
1513
|
+
if (among !== void 0) query = query.where("dc.collectionId", "in", [...among]);
|
|
1514
|
+
return (await query.execute()).map((row) => row.collectionId);
|
|
1515
|
+
}
|
|
1516
|
+
async getOrdinalsInRange(after, through, limit, signal) {
|
|
1517
|
+
signal?.throwIfAborted();
|
|
1518
|
+
if (through <= after || limit <= 0) return [];
|
|
1519
|
+
return (await this.queryExecutor.selectFrom("operation_index_operations").select("ordinal").where("ordinal", ">", after).where("ordinal", "<=", through).orderBy("ordinal", "asc").limit(limit).execute()).map((row) => Number(row.ordinal));
|
|
1520
|
+
}
|
|
1521
|
+
async getByOrdinals(ordinals, signal) {
|
|
1522
|
+
signal?.throwIfAborted();
|
|
1523
|
+
if (ordinals.length === 0) return [];
|
|
1524
|
+
const results = [];
|
|
1525
|
+
for (let i = 0; i < ordinals.length; i += MAX_BIND_PARAMETERS) {
|
|
1526
|
+
const chunk = ordinals.slice(i, i + MAX_BIND_PARAMETERS);
|
|
1527
|
+
const rows = await this.queryExecutor.selectFrom("operation_index_operations").selectAll().where("ordinal", "in", chunk).orderBy("ordinal", "asc").execute();
|
|
1528
|
+
for (const row of rows) results.push(this.rowToOperationWithContext(row));
|
|
1529
|
+
}
|
|
1530
|
+
results.sort((a, b) => a.context.ordinal - b.context.ordinal);
|
|
1531
|
+
return results;
|
|
1532
|
+
}
|
|
1533
|
+
async getStreamAfter(stream, after, signal, limit) {
|
|
1534
|
+
signal?.throwIfAborted();
|
|
1535
|
+
return (await this.queryExecutor.selectFrom("operation_index_operations").selectAll().where("documentId", "=", stream.documentId).where("branch", "=", stream.branch).where("scope", "=", stream.scope).where("ordinal", ">", after).orderBy("ordinal", "asc").$if(limit !== void 0, (qb) => qb.limit(limit)).execute()).map((row) => this.rowToOperationWithContext(row));
|
|
1536
|
+
}
|
|
1537
|
+
async getGroupReferencers(groupId, signal) {
|
|
1538
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1539
|
+
return (await this.queryExecutor.selectFrom("group_references").select("documentId").where("groupId", "=", groupId).orderBy("documentId").execute()).map((row) => row.documentId);
|
|
1540
|
+
}
|
|
1541
|
+
async getOrdinalsByOpIds(documentId, scope, branch, opIds, signal) {
|
|
1542
|
+
signal?.throwIfAborted();
|
|
1543
|
+
if (opIds.length === 0) return /* @__PURE__ */ new Map();
|
|
1544
|
+
const rows = await this.queryExecutor.selectFrom("operation_index_operations").select("opId").select((eb) => eb.fn.max("ordinal").as("ordinal")).where("documentId", "=", documentId).where("branch", "=", branch).where("scope", "=", scope).where("opId", "in", opIds).groupBy("opId").execute();
|
|
1545
|
+
return new Map(rows.map((row) => [row.opId, Number(row.ordinal)]));
|
|
1546
|
+
}
|
|
1547
|
+
async find(collectionId, cursor, view, paging, signal) {
|
|
1548
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1549
|
+
const outerCursor = cursor ?? -1;
|
|
1550
|
+
const limit = paging?.limit ?? 500;
|
|
1551
|
+
const pagingCursorOrdinal = paging?.cursor !== void 0 ? Number.parseInt(paging.cursor, 10) : -1;
|
|
1552
|
+
const buildBranch = (kind) => {
|
|
1553
|
+
let qb = this.queryExecutor.selectFrom("operation_index_operations as oi").innerJoin("document_collections as dc", "oi.documentId", "dc.documentId").selectAll("oi").select(["dc.documentId", "dc.collectionId"]).where("dc.collectionId", "=", collectionId).where(sql`(dc."leftOrdinal" IS NULL OR oi.ordinal < dc."leftOrdinal")`);
|
|
1554
|
+
if (kind === "joiner") qb = qb.where("dc.joinedOrdinal", ">", BigInt(outerCursor)).where("oi.ordinal", "<=", outerCursor);
|
|
1555
|
+
else qb = qb.where("oi.ordinal", ">", outerCursor);
|
|
1556
|
+
qb = qb.where("oi.ordinal", ">", pagingCursorOrdinal);
|
|
1557
|
+
if (view?.branch) qb = qb.where("oi.branch", "=", view.branch);
|
|
1558
|
+
if (view?.scopes && view.scopes.length > 0) qb = qb.where("oi.scope", "in", view.scopes);
|
|
1559
|
+
if (view?.excludeSourceRemote) qb = qb.where("oi.sourceRemote", "!=", view.excludeSourceRemote);
|
|
1560
|
+
if (view?.throughOrdinal !== void 0) qb = qb.where("oi.ordinal", "<=", view.throughOrdinal).where("dc.joinedOrdinal", "<=", BigInt(view.throughOrdinal));
|
|
1561
|
+
return qb;
|
|
1562
|
+
};
|
|
1563
|
+
const rows = await buildBranch("joiner").unionAll(buildBranch("newOps")).orderBy("ordinal", "asc").limit(limit + 1).execute();
|
|
1564
|
+
let hasMore = false;
|
|
1565
|
+
let items = rows;
|
|
1566
|
+
if (rows.length > limit) {
|
|
1567
|
+
hasMore = true;
|
|
1568
|
+
items = rows.slice(0, limit);
|
|
1569
|
+
}
|
|
1570
|
+
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].ordinal.toString() : void 0;
|
|
1571
|
+
const cursorValue = paging?.cursor || "0";
|
|
1572
|
+
return {
|
|
1573
|
+
results: items.map((row) => this.rowToOperationIndexEntry(row)),
|
|
1574
|
+
options: {
|
|
1575
|
+
cursor: cursorValue,
|
|
1576
|
+
limit
|
|
1577
|
+
},
|
|
1578
|
+
nextCursor,
|
|
1579
|
+
next: hasMore ? () => this.find(collectionId, cursor, view, {
|
|
1580
|
+
cursor: nextCursor,
|
|
1581
|
+
limit
|
|
1582
|
+
}, signal) : void 0
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
async get(documentId, view, paging, signal) {
|
|
1586
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1587
|
+
const limit = paging?.limit ?? 500;
|
|
1588
|
+
let query = this.queryExecutor.selectFrom("operation_index_operations").selectAll().where("documentId", "=", documentId).orderBy("ordinal", "asc");
|
|
1589
|
+
if (view?.branch) query = query.where("branch", "=", view.branch);
|
|
1590
|
+
if (view?.scopes && view.scopes.length > 0) query = query.where("scope", "in", view.scopes);
|
|
1591
|
+
if (paging?.cursor) {
|
|
1592
|
+
const cursorOrdinal = Number.parseInt(paging.cursor, 10);
|
|
1593
|
+
query = query.where("ordinal", ">", cursorOrdinal);
|
|
1594
|
+
}
|
|
1595
|
+
query = query.limit(limit + 1);
|
|
1596
|
+
const rows = await query.execute();
|
|
1597
|
+
let hasMore = false;
|
|
1598
|
+
let items = rows;
|
|
1599
|
+
if (rows.length > limit) {
|
|
1600
|
+
hasMore = true;
|
|
1601
|
+
items = rows.slice(0, limit);
|
|
1602
|
+
}
|
|
1603
|
+
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].ordinal.toString() : void 0;
|
|
1604
|
+
const cursorValue = paging?.cursor || "0";
|
|
1605
|
+
return {
|
|
1606
|
+
results: items.map((row) => this.rowToOperationIndexEntry(row)),
|
|
1607
|
+
options: {
|
|
1608
|
+
cursor: cursorValue,
|
|
1609
|
+
limit
|
|
1610
|
+
},
|
|
1611
|
+
nextCursor,
|
|
1612
|
+
next: hasMore ? () => this.get(documentId, view, {
|
|
1613
|
+
cursor: nextCursor,
|
|
1614
|
+
limit
|
|
1615
|
+
}, signal) : void 0
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
async getSinceOrdinal(ordinal, paging, signal) {
|
|
1619
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1620
|
+
const limit = paging?.limit ?? 500;
|
|
1621
|
+
let query = this.queryExecutor.selectFrom("operation_index_operations").selectAll().where("ordinal", ">", ordinal).orderBy("ordinal", "asc");
|
|
1622
|
+
if (paging?.cursor) {
|
|
1623
|
+
const cursorOrdinal = Number.parseInt(paging.cursor, 10);
|
|
1624
|
+
query = query.where("ordinal", ">", cursorOrdinal);
|
|
1625
|
+
}
|
|
1626
|
+
query = query.limit(limit + 1);
|
|
1627
|
+
const rows = await query.execute();
|
|
1628
|
+
let hasMore = false;
|
|
1629
|
+
let items = rows;
|
|
1630
|
+
if (rows.length > limit) {
|
|
1631
|
+
hasMore = true;
|
|
1632
|
+
items = rows.slice(0, limit);
|
|
1633
|
+
}
|
|
1634
|
+
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].ordinal.toString() : void 0;
|
|
1635
|
+
const cursorValue = paging?.cursor || "0";
|
|
1636
|
+
return {
|
|
1637
|
+
results: items.map((row) => this.rowToOperationWithContext(row)),
|
|
1638
|
+
options: {
|
|
1639
|
+
cursor: cursorValue,
|
|
1640
|
+
limit
|
|
1641
|
+
},
|
|
1642
|
+
nextCursor,
|
|
1643
|
+
next: hasMore ? () => this.getSinceOrdinal(ordinal, {
|
|
1644
|
+
cursor: nextCursor,
|
|
1645
|
+
limit
|
|
1646
|
+
}, signal) : void 0
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
/** Backstop to the store's refusal: only a marker indexes a purged id. */
|
|
1650
|
+
async refusePurgedOperations(trx, operations) {
|
|
1651
|
+
const ids = new Set(operations.filter((operation) => !isPurgeMarker(operation)).map((operation) => operation.documentId).filter((id) => !this.liveIds?.has(id)));
|
|
1652
|
+
const purged = await findPurged(trx, ids);
|
|
1653
|
+
for (const id of ids) if (purged.has(id)) throw new DocumentPurgedError(id);
|
|
1654
|
+
}
|
|
1655
|
+
/** Takes the xid before the first ordinal, as the settled watermark needs. */
|
|
1656
|
+
async assignXid(trx) {
|
|
1657
|
+
let fn = xidFunctions.get(this.db);
|
|
1658
|
+
if (fn === void 0) {
|
|
1659
|
+
fn = readSnapshotFunctions(trx).then((fns) => fns.currentXid);
|
|
1660
|
+
xidFunctions.set(this.db, fn);
|
|
1661
|
+
fn.catch(() => xidFunctions.delete(this.db));
|
|
1662
|
+
}
|
|
1663
|
+
const name = await fn;
|
|
1664
|
+
await sql`select ${sql.raw(name)}()`.execute(trx);
|
|
1665
|
+
}
|
|
1666
|
+
rowToOperationWithContext(row) {
|
|
1667
|
+
return {
|
|
1668
|
+
operation: {
|
|
1669
|
+
index: row.index,
|
|
1670
|
+
timestampUtcMs: row.timestampUtcMs,
|
|
1671
|
+
hash: row.hash,
|
|
1672
|
+
skip: row.skip,
|
|
1673
|
+
action: row.action,
|
|
1674
|
+
deniedReason: row.deniedReason ?? void 0,
|
|
1675
|
+
id: row.opId
|
|
1676
|
+
},
|
|
1677
|
+
context: {
|
|
1678
|
+
documentId: row.documentId,
|
|
1679
|
+
documentType: row.documentType,
|
|
1680
|
+
scope: row.scope,
|
|
1681
|
+
branch: row.branch,
|
|
1682
|
+
ordinal: row.ordinal
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
rowToOperationIndexEntry(row) {
|
|
1687
|
+
return {
|
|
1688
|
+
ordinal: row.ordinal,
|
|
1689
|
+
documentId: row.documentId,
|
|
1690
|
+
documentType: row.documentType,
|
|
1691
|
+
branch: row.branch,
|
|
1692
|
+
scope: row.scope,
|
|
1693
|
+
index: row.index,
|
|
1694
|
+
timestampUtcMs: row.timestampUtcMs,
|
|
1695
|
+
hash: row.hash,
|
|
1696
|
+
skip: row.skip,
|
|
1697
|
+
action: row.action,
|
|
1698
|
+
deniedReason: row.deniedReason ?? void 0,
|
|
1699
|
+
id: row.opId,
|
|
1700
|
+
sourceRemote: row.sourceRemote
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
async getLatestTimestampForCollection(collectionId, signal) {
|
|
1704
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1705
|
+
return (await this.queryExecutor.selectFrom("operation_index_operations as oi").innerJoin("document_collections as dc", "oi.documentId", "dc.documentId").select("oi.timestampUtcMs").where("dc.collectionId", "=", collectionId).where(sql`(dc."leftOrdinal" IS NULL OR oi.ordinal < dc."leftOrdinal")`).orderBy("oi.ordinal", "desc").limit(1).executeTakeFirst())?.timestampUtcMs ?? null;
|
|
1706
|
+
}
|
|
1707
|
+
async getCollectionsForDocuments(documentIds) {
|
|
1708
|
+
if (documentIds.length === 0) return {};
|
|
1709
|
+
const rows = await this.queryExecutor.selectFrom("document_collections").select(["documentId", "collectionId"]).where("documentId", "in", documentIds).where("leftOrdinal", "is", null).execute();
|
|
1710
|
+
const result = {};
|
|
1711
|
+
for (const row of rows) {
|
|
1712
|
+
if (!(row.documentId in result)) result[row.documentId] = [];
|
|
1713
|
+
result[row.documentId].push(row.collectionId);
|
|
1714
|
+
}
|
|
1715
|
+
return result;
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
//#endregion
|
|
1719
|
+
//#region src/cache/buffer/ring-buffer.ts
|
|
1720
|
+
/**
|
|
1721
|
+
* RingBuffer is a generic circular buffer implementation that stores a fixed number
|
|
1722
|
+
* of items. When the buffer is full, new items overwrite the oldest items.
|
|
1723
|
+
*
|
|
1724
|
+
* This implementation maintains O(1) time complexity for push operations and provides
|
|
1725
|
+
* items in chronological order (oldest to newest) via getAll().
|
|
1726
|
+
*
|
|
1727
|
+
* @template T - The type of items stored in the buffer
|
|
1728
|
+
*/
|
|
1729
|
+
var RingBuffer = class {
|
|
1730
|
+
buffer;
|
|
1731
|
+
head = 0;
|
|
1732
|
+
size = 0;
|
|
1733
|
+
capacity;
|
|
1734
|
+
constructor(capacity) {
|
|
1735
|
+
if (capacity <= 0) throw new Error("Ring buffer capacity must be greater than 0");
|
|
1736
|
+
this.capacity = capacity;
|
|
1737
|
+
this.buffer = new Array(capacity);
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Adds an item to the buffer. If the buffer is full, overwrites the oldest item.
|
|
1741
|
+
*
|
|
1742
|
+
* @param item - The item to add
|
|
1743
|
+
*/
|
|
1744
|
+
push(item) {
|
|
1745
|
+
const index = (this.head + this.size) % this.capacity;
|
|
1746
|
+
if (this.size < this.capacity) {
|
|
1747
|
+
this.buffer[index] = item;
|
|
1748
|
+
this.size++;
|
|
1749
|
+
} else {
|
|
1750
|
+
this.buffer[this.head] = item;
|
|
1751
|
+
this.head = (this.head + 1) % this.capacity;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Returns all items in the buffer in chronological order (oldest to newest).
|
|
1756
|
+
*
|
|
1757
|
+
* @returns Array of items in insertion order
|
|
1758
|
+
*/
|
|
1759
|
+
getAll() {
|
|
1760
|
+
if (this.size === 0) return [];
|
|
1761
|
+
const result = [];
|
|
1762
|
+
for (let i = 0; i < this.size; i++) {
|
|
1763
|
+
const index = (this.head + i) % this.capacity;
|
|
1764
|
+
result.push(this.buffer[index]);
|
|
1765
|
+
}
|
|
1766
|
+
return result;
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Clears all items from the buffer.
|
|
1770
|
+
*/
|
|
1771
|
+
clear() {
|
|
1772
|
+
this.buffer = new Array(this.capacity);
|
|
1773
|
+
this.head = 0;
|
|
1774
|
+
this.size = 0;
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Gets the current number of items in the buffer.
|
|
1778
|
+
*/
|
|
1779
|
+
get length() {
|
|
1780
|
+
return this.size;
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/cache/kysely-write-cache.ts
|
|
1785
|
+
/**
|
|
1786
|
+
* The last operation index a keyframe's document reflects for the scope. A
|
|
1787
|
+
* keyframe only exists for a scope that has operations, so a missing entry
|
|
1788
|
+
* means the stored row is corrupt.
|
|
1789
|
+
*/
|
|
1790
|
+
function keyframeRevision(keyframe, documentId, scope) {
|
|
1791
|
+
const nextIndex = keyframe.document.header.revision[scope];
|
|
1792
|
+
if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
|
|
1793
|
+
return nextIndex - 1;
|
|
1794
|
+
}
|
|
1795
|
+
function extractModuleVersion(doc) {
|
|
1796
|
+
const v = doc.state.document.version;
|
|
1797
|
+
return normalizeDocumentModelVersion(v);
|
|
1798
|
+
}
|
|
1799
|
+
/** The highest revision held, latest push winning a tie. */
|
|
1800
|
+
function highestRevision(snapshots) {
|
|
1801
|
+
let newest = void 0;
|
|
1802
|
+
for (const snapshot of snapshots) if (!newest || snapshot.revision >= newest.revision) newest = snapshot;
|
|
1803
|
+
return newest;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Copies a document far enough that the caller cannot write through it. Inside
|
|
1807
|
+
* this class, callers only ever replace whole fields on these four, so one
|
|
1808
|
+
* level each is enough.
|
|
1809
|
+
*/
|
|
1810
|
+
function copyDocument(document) {
|
|
1811
|
+
return {
|
|
1812
|
+
...document,
|
|
1813
|
+
header: { ...document.header },
|
|
1814
|
+
state: { ...document.state },
|
|
1815
|
+
operations: { ...document.operations }
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
var KyselyWriteCache = class KyselyWriteCache {
|
|
1819
|
+
streams;
|
|
1820
|
+
lruTracker;
|
|
1821
|
+
keyframeStore;
|
|
1822
|
+
operationStore;
|
|
1823
|
+
registry;
|
|
1824
|
+
config;
|
|
1825
|
+
reads = /* @__PURE__ */ new Map();
|
|
1826
|
+
constructor(keyframeStore, operationStore, registry, config) {
|
|
1827
|
+
this.keyframeStore = keyframeStore;
|
|
1828
|
+
this.operationStore = operationStore;
|
|
1829
|
+
this.registry = registry;
|
|
1830
|
+
this.config = {
|
|
1831
|
+
maxDocuments: config.maxDocuments,
|
|
1832
|
+
ringBufferSize: config.ringBufferSize,
|
|
1833
|
+
keyframeInterval: config.keyframeInterval
|
|
1834
|
+
};
|
|
1835
|
+
this.streams = /* @__PURE__ */ new Map();
|
|
1836
|
+
this.lruTracker = new LRUTracker();
|
|
1837
|
+
}
|
|
1838
|
+
withScopedStores(operationStore, keyframeStore) {
|
|
1839
|
+
const scoped = new KyselyWriteCache(keyframeStore, operationStore, this.registry, this.config);
|
|
1840
|
+
scoped.streams = this.streams;
|
|
1841
|
+
scoped.lruTracker = this.lruTracker;
|
|
1842
|
+
scoped.reads = this.reads;
|
|
1843
|
+
return scoped;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Initializes the write cache.
|
|
1847
|
+
* Currently a no-op as keyframe store lifecycle is managed externally.
|
|
1848
|
+
*/
|
|
1849
|
+
async startup() {
|
|
1850
|
+
return Promise.resolve();
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Shuts down the write cache.
|
|
1854
|
+
* Currently a no-op as keyframe store lifecycle is managed externally.
|
|
1855
|
+
*/
|
|
1856
|
+
async shutdown() {
|
|
1857
|
+
return Promise.resolve();
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Retrieves document state at a specific revision from cache or rebuilds it.
|
|
1861
|
+
*
|
|
1862
|
+
* Note: this returns a _shallow_ copy of the document.
|
|
1863
|
+
*
|
|
1864
|
+
* Cache hit path: Returns cached snapshot if available (O(1))
|
|
1865
|
+
* Warm miss path: Rebuilds from cached base revision + incremental ops
|
|
1866
|
+
* Cold miss path: Rebuilds from keyframe or from scratch using all operations
|
|
1867
|
+
*
|
|
1868
|
+
* @param documentId - The document identifier
|
|
1869
|
+
* @param scope - The operation scope
|
|
1870
|
+
* @param branch - The operation branch
|
|
1871
|
+
* @param targetRevision - The target revision, or undefined for newest
|
|
1872
|
+
* @param signal - Optional abort signal to cancel the operation
|
|
1873
|
+
* @returns The document at the target revision
|
|
1874
|
+
* @throws {Error} "Operation aborted" if signal is aborted
|
|
1875
|
+
* @throws {ModuleNotFoundError} If document type not registered in registry
|
|
1876
|
+
* @throws {DocumentPurgedError} If the stream's only row is a purge marker
|
|
1877
|
+
* @throws {Error} "Failed to rebuild document" if operation store fails
|
|
1878
|
+
* @throws {Error} If reducer throws during operation application
|
|
1879
|
+
* @throws {Error} If document serialization fails
|
|
1880
|
+
*/
|
|
1881
|
+
async getState(documentId, scope, branch, targetRevision, signal) {
|
|
1882
|
+
const read = { evicted: false };
|
|
1883
|
+
let pending = this.reads.get(documentId);
|
|
1884
|
+
if (!pending) {
|
|
1885
|
+
pending = /* @__PURE__ */ new Set();
|
|
1886
|
+
this.reads.set(documentId, pending);
|
|
1887
|
+
}
|
|
1888
|
+
pending.add(read);
|
|
1889
|
+
try {
|
|
1890
|
+
return await this.readState(documentId, scope, branch, targetRevision, signal, read);
|
|
1891
|
+
} finally {
|
|
1892
|
+
pending.delete(read);
|
|
1893
|
+
if (pending.size === 0) this.reads.delete(documentId);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
async readState(documentId, scope, branch, targetRevision, signal, read) {
|
|
1897
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
1898
|
+
const streamKey = this.makeStreamKey(documentId, scope, branch);
|
|
1899
|
+
const stream = this.streams.get(streamKey);
|
|
1900
|
+
if (stream) {
|
|
1901
|
+
const snapshots = stream.ringBuffer.getAll();
|
|
1902
|
+
if (targetRevision === void 0) {
|
|
1903
|
+
const newest = highestRevision(snapshots);
|
|
1904
|
+
if (newest?.position === "head") {
|
|
1905
|
+
this.lruTracker.touch(streamKey);
|
|
1906
|
+
return copyDocument(newest.document);
|
|
1907
|
+
}
|
|
1908
|
+
if (newest) {
|
|
1909
|
+
const document = await this.warmMissRebuild(newest.document, newest.revision, documentId, scope, branch, void 0, signal);
|
|
1910
|
+
this.storeUnlessEvicted(read, documentId, scope, branch, (document.header.revision[scope] ?? 0) - 1, document, "head");
|
|
1911
|
+
this.lruTracker.touch(streamKey);
|
|
1912
|
+
return document;
|
|
1913
|
+
}
|
|
1914
|
+
} else {
|
|
1915
|
+
const exactMatch = snapshots.findLast((s) => s.revision === targetRevision);
|
|
1916
|
+
if (exactMatch) {
|
|
1917
|
+
this.lruTracker.touch(streamKey);
|
|
1918
|
+
return copyDocument(exactMatch.document);
|
|
1919
|
+
}
|
|
1920
|
+
const newestOlder = this.findNearestOlderSnapshot(snapshots, targetRevision);
|
|
1921
|
+
if (newestOlder) {
|
|
1922
|
+
const document = await this.warmMissRebuild(newestOlder.document, newestOlder.revision, documentId, scope, branch, targetRevision, signal);
|
|
1923
|
+
this.storeUnlessEvicted(read, documentId, scope, branch, targetRevision, document, "historical");
|
|
1924
|
+
this.lruTracker.touch(streamKey);
|
|
1925
|
+
return document;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
const document = await this.coldMissRebuild(documentId, scope, branch, targetRevision, signal);
|
|
1930
|
+
const revision = targetRevision ?? (document.header.revision[scope] ?? 0) - 1;
|
|
1931
|
+
this.storeUnlessEvicted(read, documentId, scope, branch, revision, document, targetRevision === void 0 ? "head" : "historical");
|
|
1932
|
+
return document;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Stores a document snapshot in the cache at a specific revision.
|
|
1936
|
+
*
|
|
1937
|
+
* The cached document is a shallow copy of the input with its operation history
|
|
1938
|
+
* truncated to the last operation per scope and its clipboard cleared. This keeps
|
|
1939
|
+
* memory use and copy costs constant regardless of operation count. Consumers of
|
|
1940
|
+
* getState() must not rely on the full operation history being present; the only
|
|
1941
|
+
* guaranteed invariant is that operations[scope].at(-1) reflects the latest
|
|
1942
|
+
* operation index for each scope.
|
|
1943
|
+
*
|
|
1944
|
+
* Updates LRU tracker and may evict least recently used stream if at capacity.
|
|
1945
|
+
* Asynchronously persists keyframes at configured intervals (fire-and-forget).
|
|
1946
|
+
*
|
|
1947
|
+
* @param documentId - The document identifier
|
|
1948
|
+
* @param scope - The operation scope
|
|
1949
|
+
* @param branch - The operation branch
|
|
1950
|
+
* @param revision - The revision number
|
|
1951
|
+
* @param document - The document to cache
|
|
1952
|
+
* @throws {Error} If document serialization fails
|
|
1953
|
+
*/
|
|
1954
|
+
putState(documentId, scope, branch, revision, document, position) {
|
|
1955
|
+
this.store(documentId, scope, branch, revision, document, position);
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Stores the run's head and mints a keyframe for every interval the run
|
|
1959
|
+
* crossed on its way there. Only the head enters the ring buffer; the
|
|
1960
|
+
* earlier revisions are keyframe candidates and nothing more.
|
|
1961
|
+
*/
|
|
1962
|
+
putRun(documentId, scope, branch, run) {
|
|
1963
|
+
if (run.length === 0) return;
|
|
1964
|
+
for (const entry of run.slice(0, -1)) this.persistKeyframe(documentId, scope, branch, entry.revision, entry.document);
|
|
1965
|
+
const head = run[run.length - 1];
|
|
1966
|
+
this.store(documentId, scope, branch, head.revision, head.document, "head");
|
|
1967
|
+
}
|
|
1968
|
+
storeUnlessEvicted(read, documentId, scope, branch, revision, document, position) {
|
|
1969
|
+
if (!read.evicted) this.store(documentId, scope, branch, revision, document, position);
|
|
1970
|
+
}
|
|
1971
|
+
store(documentId, scope, branch, revision, document, position) {
|
|
1972
|
+
const streamKey = this.makeStreamKey(documentId, scope, branch);
|
|
1973
|
+
const stream = this.getOrCreateStream(streamKey);
|
|
1974
|
+
const snapshot = {
|
|
1975
|
+
revision,
|
|
1976
|
+
document: {
|
|
1977
|
+
...copyDocument(document),
|
|
1978
|
+
operations: Object.fromEntries(Object.entries(document.operations).map(([k, ops]) => [k, ops.length ? [ops.at(-1)] : []])),
|
|
1979
|
+
clipboard: []
|
|
1980
|
+
},
|
|
1981
|
+
position
|
|
1982
|
+
};
|
|
1983
|
+
stream.ringBuffer.push(snapshot);
|
|
1984
|
+
this.persistKeyframe(documentId, scope, branch, revision, document);
|
|
1985
|
+
}
|
|
1986
|
+
/** Persists the snapshot if this revision is one the interval falls on. */
|
|
1987
|
+
persistKeyframe(documentId, scope, branch, revision, document) {
|
|
1988
|
+
if (!this.isKeyframeRevision(revision)) return;
|
|
1989
|
+
this.keyframeStore.putKeyframe(documentId, scope, branch, revision, {
|
|
1990
|
+
...document,
|
|
1991
|
+
operations: {},
|
|
1992
|
+
clipboard: []
|
|
1993
|
+
}).catch((err) => {
|
|
1994
|
+
console.error(`Failed to persist keyframe ${documentId}@${revision}:`, err);
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Invalidates cached document streams.
|
|
1999
|
+
*
|
|
2000
|
+
* Supports three invalidation scopes:
|
|
2001
|
+
* - Document-level: invalidate(documentId) - removes all streams for document
|
|
2002
|
+
* - Scope-level: invalidate(documentId, scope) - removes all branches for scope
|
|
2003
|
+
* - Stream-level: invalidate(documentId, scope, branch) - removes specific stream
|
|
2004
|
+
*
|
|
2005
|
+
* @param documentId - The document identifier
|
|
2006
|
+
* @param scope - Optional scope to narrow invalidation
|
|
2007
|
+
* @param branch - Optional branch to narrow invalidation (requires scope)
|
|
2008
|
+
* @returns The number of streams evicted
|
|
2009
|
+
*/
|
|
2010
|
+
invalidate(documentId, scope, branch) {
|
|
2011
|
+
for (const read of this.reads.get(documentId) ?? []) read.evicted = true;
|
|
2012
|
+
let evicted = 0;
|
|
2013
|
+
if (scope === void 0 && branch === void 0) {
|
|
2014
|
+
for (const [key] of this.streams.entries()) if (key.startsWith(`${documentId}:`)) {
|
|
2015
|
+
this.streams.delete(key);
|
|
2016
|
+
this.lruTracker.remove(key);
|
|
2017
|
+
evicted++;
|
|
2018
|
+
}
|
|
2019
|
+
} else if (scope !== void 0 && branch === void 0) {
|
|
2020
|
+
for (const [key] of this.streams.entries()) if (key.startsWith(`${documentId}:${scope}:`)) {
|
|
2021
|
+
this.streams.delete(key);
|
|
2022
|
+
this.lruTracker.remove(key);
|
|
2023
|
+
evicted++;
|
|
2024
|
+
}
|
|
2025
|
+
} else if (scope !== void 0 && branch !== void 0) {
|
|
2026
|
+
const key = this.makeStreamKey(documentId, scope, branch);
|
|
2027
|
+
if (this.streams.has(key)) {
|
|
2028
|
+
this.streams.delete(key);
|
|
2029
|
+
this.lruTracker.remove(key);
|
|
2030
|
+
evicted = 1;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
return evicted;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Clears the entire cache, removing all cached document streams.
|
|
2037
|
+
* Resets LRU tracking state. This operation always succeeds.
|
|
2038
|
+
*/
|
|
2039
|
+
clear() {
|
|
2040
|
+
for (const pending of this.reads.values()) for (const read of pending) read.evicted = true;
|
|
2041
|
+
this.streams.clear();
|
|
2042
|
+
this.lruTracker.clear();
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Retrieves a specific stream for a document. Exposed on the implementation
|
|
2046
|
+
* for testing, but not on the interface.
|
|
2047
|
+
*
|
|
2048
|
+
* @internal
|
|
2049
|
+
*/
|
|
2050
|
+
getStream(documentId, scope, branch) {
|
|
2051
|
+
const key = this.makeStreamKey(documentId, scope, branch);
|
|
2052
|
+
return this.streams.get(key);
|
|
2053
|
+
}
|
|
2054
|
+
async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
|
|
2055
|
+
if (targetRevision === Number.MAX_SAFE_INTEGER || targetRevision <= 0) return;
|
|
2056
|
+
const keyframe = await this.keyframeStore.findNearestKeyframe(documentId, scope, branch, targetRevision, signal);
|
|
2057
|
+
if (!keyframe) return;
|
|
2058
|
+
return {
|
|
2059
|
+
revision: Math.min(keyframeRevision(keyframe, documentId, scope), keyframe.revision),
|
|
2060
|
+
document: keyframe.document
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Rebuilds a scope from a keyframe or from the whole operation history.
|
|
2065
|
+
*
|
|
2066
|
+
* The document scope is always rebuilt first, because it carries the type,
|
|
2067
|
+
* the upgrades and the deletion marker. Its version-changing upgrades are not
|
|
2068
|
+
* applied there though: an upgrade reducer must see the state the requested
|
|
2069
|
+
* scope has reached at that upgrade's boundary, so each one is held back and
|
|
2070
|
+
* applied when the replay below crosses the boundary that
|
|
2071
|
+
* resolveModuleVersionForOp derives from it. Upgrades whose boundary lies past
|
|
2072
|
+
* the last replayed operation are applied at the end. Creation-time 0->N seed
|
|
2073
|
+
* upgrades carry the initial state, so they still apply immediately.
|
|
2074
|
+
*/
|
|
2075
|
+
async coldMissRebuild(documentId, scope, branch, targetRevision, signal, fromKeyframe = true) {
|
|
2076
|
+
const effectiveTargetRevision = targetRevision || Number.MAX_SAFE_INTEGER;
|
|
2077
|
+
const keyframe = fromKeyframe ? await this.findNearestKeyframe(documentId, scope, branch, effectiveTargetRevision, signal) : void 0;
|
|
2078
|
+
const documentScopeBound = scope === "document" ? targetRevision : void 0;
|
|
2079
|
+
let document;
|
|
2080
|
+
let startRevision;
|
|
2081
|
+
let documentType;
|
|
2082
|
+
const validatedUpgrades = [];
|
|
2083
|
+
const pendingUpgrades = [];
|
|
2084
|
+
let lastDocumentScopeOperation;
|
|
2085
|
+
if (keyframe) {
|
|
2086
|
+
document = keyframe.document;
|
|
2087
|
+
startRevision = keyframe.revision;
|
|
2088
|
+
documentType = keyframe.document.header.documentType;
|
|
2089
|
+
const documentScopeResume = scope === "document" ? keyframe.revision : keyframeRevision(keyframe, documentId, "document");
|
|
2090
|
+
const docScopeOpsAfterKeyframe = await this.operationStore.getSince(documentId, "document", branch, documentScopeResume, void 0, void 0, signal);
|
|
2091
|
+
for (const operation of docScopeOpsAfterKeyframe.results) {
|
|
2092
|
+
if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
|
|
2093
|
+
lastDocumentScopeOperation = operation;
|
|
2094
|
+
if (operation.error || isDenied(operation)) continue;
|
|
2095
|
+
if (operation.action.type === "UPGRADE_DOCUMENT") {
|
|
2096
|
+
const upgradeAction = operation.action;
|
|
2097
|
+
const fromVersion = upgradeAction.input.fromVersion;
|
|
2098
|
+
const toVersion = upgradeAction.input.toVersion;
|
|
2099
|
+
if (fromVersion > 0 && fromVersion < toVersion) {
|
|
2100
|
+
let upgradePath;
|
|
2101
|
+
try {
|
|
2102
|
+
upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
|
|
2103
|
+
} catch (err) {
|
|
2104
|
+
if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
|
|
2105
|
+
else throw new Error(`Failed to rebuild document ${documentId}: no upgrade manifest for ${documentType} v${fromVersion}→v${toVersion} and no initialState snapshot. ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
2106
|
+
}
|
|
2107
|
+
validatedUpgrades.push({
|
|
2108
|
+
fromVersion,
|
|
2109
|
+
toVersion,
|
|
2110
|
+
revision: upgradeAction.input.revision,
|
|
2111
|
+
timestampUtcMs: operation.timestampUtcMs
|
|
2112
|
+
});
|
|
2113
|
+
pendingUpgrades.push({
|
|
2114
|
+
action: upgradeAction,
|
|
2115
|
+
upgradePath,
|
|
2116
|
+
index: operation.index,
|
|
2117
|
+
subsequentDeletes: []
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
} else if (operation.action.type === "DELETE_DOCUMENT" || operation.action.type === PURGE_DOCUMENT) {
|
|
2121
|
+
applyDeleteDocumentAction(document, operation.action);
|
|
2122
|
+
for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
} else {
|
|
2126
|
+
startRevision = -1;
|
|
2127
|
+
const createOpResult = await this.operationStore.getSince(documentId, "document", branch, -1, void 0, {
|
|
2128
|
+
cursor: "0",
|
|
2129
|
+
limit: 1
|
|
2130
|
+
}, signal);
|
|
2131
|
+
if (createOpResult.results.length === 0) throw new DocumentNotFoundError(documentId);
|
|
2132
|
+
const createOp = createOpResult.results[0];
|
|
2133
|
+
if (isPurgeMarker(createOp)) throw new DocumentPurgedError(documentId);
|
|
2134
|
+
if (createOp.action.type !== "CREATE_DOCUMENT") throw new Error(`Failed to rebuild document ${documentId}: first operation in document scope must be CREATE_DOCUMENT, found ${createOp.action.type}`);
|
|
2135
|
+
const documentCreateAction = createOp.action;
|
|
2136
|
+
documentType = documentCreateAction.input.model;
|
|
2137
|
+
if (!documentType) throw new Error(`Failed to rebuild document ${documentId}: CREATE_DOCUMENT action missing model in input`);
|
|
2138
|
+
document = createDocumentFromAction(documentCreateAction);
|
|
2139
|
+
lastDocumentScopeOperation = createOp;
|
|
2140
|
+
let docModule = this.registry.getModule(documentType, extractModuleVersion(document));
|
|
2141
|
+
const docScopeOps = await this.operationStore.getSince(documentId, "document", branch, 0, void 0, void 0, signal);
|
|
2142
|
+
for (const operation of docScopeOps.results) {
|
|
2143
|
+
if (documentScopeBound !== void 0 && operation.index > documentScopeBound) break;
|
|
2144
|
+
lastDocumentScopeOperation = operation;
|
|
2145
|
+
if (operation.index === 0) continue;
|
|
2146
|
+
if (operation.error || isDenied(operation)) continue;
|
|
2147
|
+
if (operation.action.type === "UPGRADE_DOCUMENT") {
|
|
2148
|
+
const upgradeAction = operation.action;
|
|
2149
|
+
const fromVersion = upgradeAction.input.fromVersion;
|
|
2150
|
+
const toVersion = upgradeAction.input.toVersion;
|
|
2151
|
+
if (fromVersion > 0 && fromVersion < toVersion) {
|
|
2152
|
+
let upgradePath;
|
|
2153
|
+
try {
|
|
2154
|
+
upgradePath = this.registry.computeUpgradePath(documentType, fromVersion, toVersion);
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
if (upgradeAction.input.initialState !== void 0) upgradePath = void 0;
|
|
2157
|
+
else throw new Error(`Failed to rebuild document ${documentId}: no upgrade manifest for ${documentType} v${fromVersion}→v${toVersion} and no initialState snapshot. ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
2158
|
+
}
|
|
2159
|
+
validatedUpgrades.push({
|
|
2160
|
+
fromVersion,
|
|
2161
|
+
toVersion,
|
|
2162
|
+
revision: upgradeAction.input.revision,
|
|
2163
|
+
timestampUtcMs: operation.timestampUtcMs
|
|
2164
|
+
});
|
|
2165
|
+
pendingUpgrades.push({
|
|
2166
|
+
action: upgradeAction,
|
|
2167
|
+
upgradePath,
|
|
2168
|
+
index: operation.index,
|
|
2169
|
+
subsequentDeletes: []
|
|
2170
|
+
});
|
|
2171
|
+
} else document = applyUpgradeDocumentAction(document, upgradeAction, void 0);
|
|
2172
|
+
docModule = this.registry.getModule(documentType, normalizeDocumentModelVersion(toVersion));
|
|
2173
|
+
} else if (operation.action.type === "DELETE_DOCUMENT" || operation.action.type === PURGE_DOCUMENT) {
|
|
2174
|
+
applyDeleteDocumentAction(document, operation.action);
|
|
2175
|
+
for (const pending of pendingUpgrades) pending.subsequentDeletes.push(operation.action);
|
|
2176
|
+
} else {
|
|
2177
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
2178
|
+
document = withProtocolVersions(docModule.reducer(document, operation.action, void 0, {
|
|
2179
|
+
skip: operation.skip,
|
|
2180
|
+
protocolVersion
|
|
2181
|
+
}), document.header.protocolVersions);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
if (scope === "document") {
|
|
2186
|
+
document = this.applyPendingUpgrades(document, pendingUpgrades, Number.MAX_SAFE_INTEGER);
|
|
2187
|
+
const last = lastDocumentScopeOperation ?? await this.operationAt(documentId, "document", branch, startRevision, signal);
|
|
2188
|
+
document.operations = {
|
|
2189
|
+
...document.operations,
|
|
2190
|
+
document: last ? [last] : []
|
|
2191
|
+
};
|
|
2192
|
+
return this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
|
|
2193
|
+
}
|
|
2194
|
+
if (keyframe) {
|
|
2195
|
+
const resumeOperation = await this.operationAt(documentId, scope, branch, startRevision, signal);
|
|
2196
|
+
if (resumeOperation) document.operations = {
|
|
2197
|
+
...document.operations,
|
|
2198
|
+
[scope]: [resumeOperation]
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
const moduleCache = /* @__PURE__ */ new Map();
|
|
2202
|
+
const getModuleCached = (version) => {
|
|
2203
|
+
const key = version ?? 0;
|
|
2204
|
+
let mod = moduleCache.get(key);
|
|
2205
|
+
if (!mod) {
|
|
2206
|
+
mod = this.registry.getModule(documentType, version);
|
|
2207
|
+
moduleCache.set(key, mod);
|
|
2208
|
+
}
|
|
2209
|
+
return mod;
|
|
2210
|
+
};
|
|
2211
|
+
const finalVersion = validatedUpgrades.at(-1)?.toVersion ?? extractModuleVersion(document);
|
|
2212
|
+
let cursor = void 0;
|
|
2213
|
+
const pageSize = 100;
|
|
2214
|
+
let hasMorePages;
|
|
2215
|
+
let needsFullReplay = false;
|
|
2216
|
+
do {
|
|
2217
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
2218
|
+
const paging = {
|
|
2219
|
+
cursor: cursor || "0",
|
|
2220
|
+
limit: pageSize
|
|
2221
|
+
};
|
|
2222
|
+
try {
|
|
2223
|
+
const result = await this.operationStore.getSince(documentId, scope, branch, startRevision, void 0, paging, signal);
|
|
2224
|
+
for (const operation of result.results) {
|
|
2225
|
+
if (targetRevision !== void 0 && operation.index > targetRevision) break;
|
|
2226
|
+
if (keyframe && operation.skip > 0) {
|
|
2227
|
+
needsFullReplay = true;
|
|
2228
|
+
break;
|
|
2229
|
+
}
|
|
2230
|
+
const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, finalVersion);
|
|
2231
|
+
document = this.applyPendingUpgrades(document, pendingUpgrades, moduleVersion ?? Number.MAX_SAFE_INTEGER);
|
|
2232
|
+
if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
|
|
2233
|
+
else {
|
|
2234
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
2235
|
+
document = withProtocolVersions(getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
|
|
2236
|
+
skip: operation.skip,
|
|
2237
|
+
protocolVersion,
|
|
2238
|
+
replayOptions: { operation },
|
|
2239
|
+
skipIndexValidation: true
|
|
2240
|
+
}), document.header.protocolVersions);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
const reachedTarget = targetRevision !== void 0 && result.results.some((op) => op.index >= targetRevision);
|
|
2244
|
+
hasMorePages = Boolean(result.nextCursor) && !reachedTarget && !needsFullReplay;
|
|
2245
|
+
if (hasMorePages) cursor = result.nextCursor;
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
2248
|
+
}
|
|
2249
|
+
} while (hasMorePages);
|
|
2250
|
+
if (needsFullReplay) return this.coldMissRebuild(documentId, scope, branch, targetRevision, signal, false);
|
|
2251
|
+
document = this.applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision);
|
|
2252
|
+
document = await this.stampRevisions(document, documentId, scope, branch, targetRevision, signal);
|
|
2253
|
+
if (pendingUpgrades.length > 0) {
|
|
2254
|
+
const firstHeldBack = pendingUpgrades[0];
|
|
2255
|
+
const stamped = document.header.revision["document"] ?? 0;
|
|
2256
|
+
document.header.revision = {
|
|
2257
|
+
...document.header.revision,
|
|
2258
|
+
document: Math.min(stamped, firstHeldBack.index)
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
return document;
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Applies and removes every held-back upgrade whose target version is at or
|
|
2265
|
+
* below `throughVersion`, in the order the document scope recorded them.
|
|
2266
|
+
*/
|
|
2267
|
+
applyPendingUpgrades(document, pendingUpgrades, throughVersion) {
|
|
2268
|
+
while (pendingUpgrades.length > 0) {
|
|
2269
|
+
const pending = pendingUpgrades[0];
|
|
2270
|
+
if (throughVersion < pending.action.input.toVersion) break;
|
|
2271
|
+
pendingUpgrades.shift();
|
|
2272
|
+
document = this.applyPendingUpgrade(document, pending);
|
|
2273
|
+
}
|
|
2274
|
+
return document;
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Applies the remaining held-back upgrades after the requested scope's
|
|
2278
|
+
* replay has finished. A head read applies them all. A positional read
|
|
2279
|
+
* applies only those whose boundary for this scope lies at or before the
|
|
2280
|
+
* target position: applying a later one would label migrated state with a
|
|
2281
|
+
* pre-upgrade revision, and a keyframe stored from that poisons every
|
|
2282
|
+
* rebuild that resumes from it. Boundaries come from the upgrade's revision
|
|
2283
|
+
* snapshot; an upgrade without one records no position for this scope, and
|
|
2284
|
+
* the replay loop not having crossed it already places it past the target.
|
|
2285
|
+
*/
|
|
2286
|
+
applyTailPendingUpgrades(document, pendingUpgrades, scope, targetRevision) {
|
|
2287
|
+
while (pendingUpgrades.length > 0) {
|
|
2288
|
+
const pending = pendingUpgrades[0];
|
|
2289
|
+
if (targetRevision !== void 0) {
|
|
2290
|
+
const snapshot = pending.action.input.revision;
|
|
2291
|
+
if (snapshot === void 0) break;
|
|
2292
|
+
if ((snapshot[scope] ?? 0) > targetRevision) break;
|
|
2293
|
+
}
|
|
2294
|
+
pendingUpgrades.shift();
|
|
2295
|
+
document = this.applyPendingUpgrade(document, pending);
|
|
2296
|
+
}
|
|
2297
|
+
return document;
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Applies one held-back upgrade, then re-applies the deletes the document
|
|
2301
|
+
* scope recorded after it so the hold-back cannot invert their order.
|
|
2302
|
+
*/
|
|
2303
|
+
applyPendingUpgrade(document, pending) {
|
|
2304
|
+
document = applyUpgradeDocumentAction(document, pending.action, pending.upgradePath);
|
|
2305
|
+
for (const deleteAction of pending.subsequentDeletes) document = applyDeleteDocumentAction(document, deleteAction);
|
|
2306
|
+
return document;
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Copies the current document revisions onto the document. Overwrites the
|
|
2310
|
+
* requested scope revision with the target revision, if provided.
|
|
2311
|
+
*/
|
|
2312
|
+
async stampRevisions(document, documentId, scope, branch, targetRevision, signal) {
|
|
2313
|
+
const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
|
|
2314
|
+
document.header.revision = revisions.revision;
|
|
2315
|
+
if (targetRevision !== void 0) document.header.revision = {
|
|
2316
|
+
...document.header.revision,
|
|
2317
|
+
[scope]: targetRevision + 1
|
|
2318
|
+
};
|
|
2319
|
+
document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
|
|
2320
|
+
return document;
|
|
2321
|
+
}
|
|
2322
|
+
/** The stored operation at `index`, or undefined if it is no longer there. */
|
|
2323
|
+
async operationAt(documentId, scope, branch, index, signal) {
|
|
2324
|
+
if (index < 0) return;
|
|
2325
|
+
const operation = (await this.operationStore.getSince(documentId, scope, branch, index - 1, void 0, {
|
|
2326
|
+
cursor: "0",
|
|
2327
|
+
limit: 1
|
|
2328
|
+
}, signal)).results[0];
|
|
2329
|
+
return operation && operation.index === index ? operation : void 0;
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Resolves which module version to use for a given operation in phase 2.
|
|
2333
|
+
*
|
|
2334
|
+
* Uses the validated-upgrade boundary rules from D7:
|
|
2335
|
+
* - If `input.revision` is present: op.index < revision[scope] → before the upgrade boundary
|
|
2336
|
+
* - Otherwise: timestamp fallback
|
|
2337
|
+
* - Falls back to final module version when neither is decidable
|
|
2338
|
+
*/
|
|
2339
|
+
resolveModuleVersionForOp(opIndex, opTimestamp, scope, validatedUpgrades, finalVersion) {
|
|
2340
|
+
if (validatedUpgrades.length === 0) return finalVersion;
|
|
2341
|
+
let currentVersion = validatedUpgrades[0]?.fromVersion;
|
|
2342
|
+
for (const upgrade of validatedUpgrades) {
|
|
2343
|
+
let beforeUpgrade;
|
|
2344
|
+
if (upgrade.revision !== void 0) beforeUpgrade = opIndex < (upgrade.revision[scope] ?? 0);
|
|
2345
|
+
else beforeUpgrade = opTimestamp < upgrade.timestampUtcMs;
|
|
2346
|
+
if (beforeUpgrade) return currentVersion;
|
|
2347
|
+
currentVersion = upgrade.toVersion;
|
|
2348
|
+
}
|
|
2349
|
+
return currentVersion;
|
|
2350
|
+
}
|
|
2351
|
+
async warmMissRebuild(baseDocument, baseRevision, documentId, scope, branch, targetRevision, signal) {
|
|
2352
|
+
const documentType = baseDocument.header.documentType;
|
|
2353
|
+
const docScopeNextIndex = baseDocument.header.revision["document"] ?? 0;
|
|
2354
|
+
if ((await this.operationStore.getSince(documentId, "document", branch, docScopeNextIndex - 1, void 0, void 0, signal)).results.length > 0) return this.coldMissRebuild(documentId, scope, branch, targetRevision, signal);
|
|
2355
|
+
const module = this.registry.getModule(documentType, extractModuleVersion(baseDocument));
|
|
2356
|
+
let document = copyDocument(baseDocument);
|
|
2357
|
+
let needsFullReplay = false;
|
|
2358
|
+
try {
|
|
2359
|
+
const pagedResults = await this.operationStore.getSince(documentId, scope, branch, baseRevision, void 0, void 0, signal);
|
|
2360
|
+
for (const operation of pagedResults.results) {
|
|
2361
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
2362
|
+
if (targetRevision !== void 0 && operation.index > targetRevision) break;
|
|
2363
|
+
if (operation.skip > 0) {
|
|
2364
|
+
needsFullReplay = true;
|
|
2365
|
+
break;
|
|
2366
|
+
}
|
|
2367
|
+
if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
|
|
2368
|
+
else {
|
|
2369
|
+
const protocolVersion = baseReducerVersion(document.header);
|
|
2370
|
+
document = withProtocolVersions(module.reducer(document, operation.action, void 0, {
|
|
2371
|
+
skip: operation.skip,
|
|
2372
|
+
protocolVersion,
|
|
2373
|
+
replayOptions: { operation },
|
|
2374
|
+
skipIndexValidation: true
|
|
2375
|
+
}), document.header.protocolVersions);
|
|
2376
|
+
}
|
|
2377
|
+
if (targetRevision !== void 0 && operation.index === targetRevision) break;
|
|
2378
|
+
}
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
throw new Error(`Failed to rebuild document ${documentId}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
2381
|
+
}
|
|
2382
|
+
if (needsFullReplay) return this.coldMissRebuild(documentId, scope, branch, targetRevision, signal, false);
|
|
2383
|
+
const revisions = await this.operationStore.getRevisions(documentId, branch, signal);
|
|
2384
|
+
document.header.revision = revisions.revision;
|
|
2385
|
+
if (targetRevision !== void 0) document.header.revision = {
|
|
2386
|
+
...document.header.revision,
|
|
2387
|
+
[scope]: targetRevision + 1
|
|
2388
|
+
};
|
|
2389
|
+
document.header.lastModifiedAtUtcIso = revisions.latestTimestamp;
|
|
2390
|
+
return document;
|
|
2391
|
+
}
|
|
2392
|
+
findNearestOlderSnapshot(snapshots, targetRevision) {
|
|
2393
|
+
let nearest = void 0;
|
|
2394
|
+
for (const snapshot of snapshots) if (snapshot.revision < targetRevision) {
|
|
2395
|
+
if (!nearest || snapshot.revision > nearest.revision) nearest = snapshot;
|
|
2396
|
+
}
|
|
2397
|
+
return nearest;
|
|
2398
|
+
}
|
|
2399
|
+
makeStreamKey(documentId, scope, branch) {
|
|
2400
|
+
return `${documentId}:${scope}:${branch}`;
|
|
2401
|
+
}
|
|
2402
|
+
getOrCreateStream(key) {
|
|
2403
|
+
let stream = this.streams.get(key);
|
|
2404
|
+
if (!stream) {
|
|
2405
|
+
if (this.streams.size >= this.config.maxDocuments) {
|
|
2406
|
+
const evictKey = this.lruTracker.evict();
|
|
2407
|
+
if (evictKey) this.streams.delete(evictKey);
|
|
2408
|
+
}
|
|
2409
|
+
stream = {
|
|
2410
|
+
key,
|
|
2411
|
+
ringBuffer: new RingBuffer(this.config.ringBufferSize)
|
|
2412
|
+
};
|
|
2413
|
+
this.streams.set(key, stream);
|
|
2414
|
+
}
|
|
2415
|
+
this.lruTracker.touch(key);
|
|
2416
|
+
return stream;
|
|
2417
|
+
}
|
|
2418
|
+
isKeyframeRevision(revision) {
|
|
2419
|
+
return revision > 0 && revision % this.config.keyframeInterval === 0;
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
//#endregion
|
|
2423
|
+
//#region src/events/event-bus.ts
|
|
2424
|
+
var EventBus = class {
|
|
2425
|
+
eventTypeToSubscribers = /* @__PURE__ */ new Map();
|
|
2426
|
+
subscribe(type, subscriber) {
|
|
2427
|
+
let list = this.eventTypeToSubscribers.get(type);
|
|
2428
|
+
if (!list) {
|
|
2429
|
+
list = [];
|
|
2430
|
+
this.eventTypeToSubscribers.set(type, list);
|
|
2431
|
+
}
|
|
2432
|
+
list.push(subscriber);
|
|
2433
|
+
let done = false;
|
|
2434
|
+
return () => {
|
|
2435
|
+
if (done) return;
|
|
2436
|
+
done = true;
|
|
2437
|
+
const arr = this.eventTypeToSubscribers.get(type);
|
|
2438
|
+
if (!arr) return;
|
|
2439
|
+
const idx = arr.indexOf(subscriber);
|
|
2440
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
2441
|
+
if (arr.length === 0) this.eventTypeToSubscribers.delete(type);
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
async emit(type, data) {
|
|
2445
|
+
const list = this.eventTypeToSubscribers.get(type);
|
|
2446
|
+
if (!list || list.length === 0) return;
|
|
2447
|
+
const snapshot = list.slice();
|
|
2448
|
+
const errors = [];
|
|
2449
|
+
for (const fn of snapshot) try {
|
|
2450
|
+
await Promise.resolve(fn(type, data));
|
|
2451
|
+
} catch (err) {
|
|
2452
|
+
errors.push(err);
|
|
2453
|
+
}
|
|
2454
|
+
if (errors.length > 0) throw new EventBusAggregateError(errors);
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
//#endregion
|
|
2458
|
+
//#region src/storage/kysely/keyframe-store.ts
|
|
2459
|
+
var KyselyKeyframeStore = class KyselyKeyframeStore {
|
|
2460
|
+
db;
|
|
2461
|
+
trx;
|
|
2462
|
+
constructor(db) {
|
|
2463
|
+
this.db = db;
|
|
2464
|
+
}
|
|
2465
|
+
get queryExecutor() {
|
|
2466
|
+
return this.trx ?? this.db;
|
|
2467
|
+
}
|
|
2468
|
+
withTransaction(trx) {
|
|
2469
|
+
const instance = new KyselyKeyframeStore(this.db);
|
|
2470
|
+
instance.trx = trx;
|
|
2471
|
+
return instance;
|
|
2472
|
+
}
|
|
2473
|
+
async putKeyframe(documentId, scope, branch, revision, document, signal) {
|
|
2474
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
2475
|
+
const write = async (trx) => {
|
|
2476
|
+
await acquirePurgeLocks(trx, [documentId], "shared");
|
|
2477
|
+
if ((await findPurged(trx, [documentId])).has(documentId)) return;
|
|
2478
|
+
await trx.insertInto("Keyframe").values({
|
|
2479
|
+
documentId,
|
|
2480
|
+
documentType: document.header.documentType,
|
|
2481
|
+
scope,
|
|
2482
|
+
branch,
|
|
2483
|
+
revision,
|
|
2484
|
+
document
|
|
2485
|
+
}).onConflict((oc) => oc.columns([
|
|
2486
|
+
"documentId",
|
|
2487
|
+
"scope",
|
|
2488
|
+
"branch",
|
|
2489
|
+
"revision"
|
|
2490
|
+
]).doUpdateSet({ document })).execute();
|
|
2491
|
+
};
|
|
2492
|
+
if (this.trx) {
|
|
2493
|
+
await write(this.trx);
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
await this.db.transaction().execute(write);
|
|
2497
|
+
}
|
|
2498
|
+
async findNearestKeyframe(documentId, scope, branch, targetRevision, signal) {
|
|
2499
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
2500
|
+
const row = await this.queryExecutor.selectFrom("Keyframe").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).where("revision", "<=", targetRevision).orderBy("revision", "desc").limit(1).executeTakeFirst();
|
|
2501
|
+
if (!row) return;
|
|
2502
|
+
return {
|
|
2503
|
+
revision: row.revision,
|
|
2504
|
+
document: row.document
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
async listKeyframes(documentId, scope, branch, signal) {
|
|
2508
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
2509
|
+
let query = this.queryExecutor.selectFrom("Keyframe").selectAll().where("documentId", "=", documentId).orderBy("revision", "asc");
|
|
2510
|
+
if (scope !== void 0) query = query.where("scope", "=", scope);
|
|
2511
|
+
if (branch !== void 0) query = query.where("branch", "=", branch);
|
|
2512
|
+
return (await query.execute()).map((row) => ({
|
|
2513
|
+
scope: row.scope,
|
|
2514
|
+
branch: row.branch,
|
|
2515
|
+
revision: row.revision,
|
|
2516
|
+
document: row.document
|
|
2517
|
+
}));
|
|
2518
|
+
}
|
|
2519
|
+
async deleteKeyframes(documentId, scope, branch, signal) {
|
|
2520
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
2521
|
+
let query = this.queryExecutor.deleteFrom("Keyframe").where("documentId", "=", documentId);
|
|
2522
|
+
if (scope !== void 0 && branch !== void 0) query = query.where("scope", "=", scope).where("branch", "=", branch);
|
|
2523
|
+
else if (scope !== void 0) query = query.where("scope", "=", scope);
|
|
2524
|
+
const result = await query.executeTakeFirst();
|
|
2525
|
+
return Number(result.numDeletedRows || 0n);
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
//#endregion
|
|
2529
|
+
//#region src/storage/kysely/pagination.ts
|
|
2530
|
+
const DEFAULT_LIMIT = 100;
|
|
2531
|
+
function paginateRows(rows, paging, cursorOf, toItem, refetch) {
|
|
2532
|
+
let hasMore = false;
|
|
2533
|
+
let items = rows;
|
|
2534
|
+
if (paging?.limit && rows.length > paging.limit) {
|
|
2535
|
+
hasMore = true;
|
|
2536
|
+
items = rows.slice(0, paging.limit);
|
|
2537
|
+
}
|
|
2538
|
+
const nextCursor = hasMore && items.length > 0 ? cursorOf(items[items.length - 1]).toString() : void 0;
|
|
2539
|
+
const cursor = paging?.cursor || "0";
|
|
2540
|
+
const limit = paging?.limit || DEFAULT_LIMIT;
|
|
2541
|
+
return {
|
|
2542
|
+
results: items.map(toItem),
|
|
2543
|
+
options: {
|
|
2544
|
+
cursor,
|
|
2545
|
+
limit
|
|
2546
|
+
},
|
|
2547
|
+
nextCursor,
|
|
2548
|
+
next: hasMore ? () => refetch(nextCursor, limit) : void 0
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
//#endregion
|
|
2552
|
+
//#region src/storage/txn.ts
|
|
2553
|
+
var AtomicTransaction = class {
|
|
2554
|
+
documentId;
|
|
2555
|
+
documentType;
|
|
2556
|
+
scope;
|
|
2557
|
+
branch;
|
|
2558
|
+
baseRevision;
|
|
2559
|
+
operations = [];
|
|
2560
|
+
constructor(documentId, documentType, scope, branch, baseRevision) {
|
|
2561
|
+
this.documentId = documentId;
|
|
2562
|
+
this.documentType = documentType;
|
|
2563
|
+
this.scope = scope;
|
|
2564
|
+
this.branch = branch;
|
|
2565
|
+
this.baseRevision = baseRevision;
|
|
2566
|
+
}
|
|
2567
|
+
addOperations(...operations) {
|
|
2568
|
+
for (const op of operations) this.operations.push({
|
|
2569
|
+
jobId: v4(),
|
|
2570
|
+
opId: op.id,
|
|
2571
|
+
prevOpId: "",
|
|
2572
|
+
documentId: this.documentId,
|
|
2573
|
+
documentType: this.documentType,
|
|
2574
|
+
scope: this.scope,
|
|
2575
|
+
branch: this.branch,
|
|
2576
|
+
timestampUtcMs: new Date(op.timestampUtcMs),
|
|
2577
|
+
index: op.index,
|
|
2578
|
+
action: JSON.stringify(op.action),
|
|
2579
|
+
skip: op.skip,
|
|
2580
|
+
error: op.error || null,
|
|
2581
|
+
deniedReason: op.deniedReason || null,
|
|
2582
|
+
hash: op.hash
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
getOperations() {
|
|
2586
|
+
return this.operations;
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
//#endregion
|
|
2590
|
+
//#region src/storage/kysely/store.ts
|
|
2591
|
+
var _UniqueConstraintContext = class extends Error {
|
|
2592
|
+
documentId;
|
|
2593
|
+
scope;
|
|
2594
|
+
branch;
|
|
2595
|
+
revision;
|
|
2596
|
+
stagedOps;
|
|
2597
|
+
constructor(documentId, scope, branch, revision, stagedOps) {
|
|
2598
|
+
super("unique constraint");
|
|
2599
|
+
this.documentId = documentId;
|
|
2600
|
+
this.scope = scope;
|
|
2601
|
+
this.branch = branch;
|
|
2602
|
+
this.revision = revision;
|
|
2603
|
+
this.stagedOps = stagedOps;
|
|
2604
|
+
this.name = "UniqueConstraintContext";
|
|
2605
|
+
}
|
|
2606
|
+
};
|
|
2607
|
+
function duplicateOf(ctx) {
|
|
2608
|
+
const op = ctx.stagedOps[0];
|
|
2609
|
+
return new DuplicateOperationError(`${op.opId} at index ${op.index} with skip ${op.skip}`);
|
|
2610
|
+
}
|
|
2611
|
+
var KyselyOperationStore = class KyselyOperationStore {
|
|
2612
|
+
db;
|
|
2613
|
+
trx;
|
|
2614
|
+
liveIds;
|
|
2615
|
+
constructor(db) {
|
|
2616
|
+
this.db = db;
|
|
2617
|
+
}
|
|
2618
|
+
get queryExecutor() {
|
|
2619
|
+
return this.trx ?? this.db;
|
|
2620
|
+
}
|
|
2621
|
+
/** `liveIds`: ids the transaction read untombstoned under its shared lock. */
|
|
2622
|
+
withTransaction(trx, liveIds) {
|
|
2623
|
+
const instance = new KyselyOperationStore(this.db);
|
|
2624
|
+
instance.trx = trx;
|
|
2625
|
+
instance.liveIds = liveIds;
|
|
2626
|
+
return instance;
|
|
2627
|
+
}
|
|
2628
|
+
async apply(documentId, documentType, scope, branch, revision, fn, signal, condition) {
|
|
2629
|
+
if (this.trx) {
|
|
2630
|
+
let executeResult = null;
|
|
2631
|
+
let uniqueCtx = null;
|
|
2632
|
+
try {
|
|
2633
|
+
executeResult = await this.executeApply(this.trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
|
|
2634
|
+
} catch (error) {
|
|
2635
|
+
if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
|
|
2636
|
+
else throw error;
|
|
2637
|
+
}
|
|
2638
|
+
if (uniqueCtx !== null) throw duplicateOf(uniqueCtx);
|
|
2639
|
+
return executeResult;
|
|
2640
|
+
} else {
|
|
2641
|
+
let transactionResult = null;
|
|
2642
|
+
let uniqueCtx = null;
|
|
2643
|
+
try {
|
|
2644
|
+
transactionResult = await this.db.transaction().execute(async (trx) => {
|
|
2645
|
+
return this.executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition);
|
|
2646
|
+
});
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
if (error instanceof _UniqueConstraintContext) uniqueCtx = error;
|
|
2649
|
+
else throw error;
|
|
2650
|
+
}
|
|
2651
|
+
if (uniqueCtx !== null) return this.resolveUniqueConstraint(uniqueCtx);
|
|
2652
|
+
return transactionResult;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
async resolveUniqueConstraint(ctx) {
|
|
2656
|
+
let replayOps = null;
|
|
2657
|
+
try {
|
|
2658
|
+
replayOps = await this.findIdempotentReplay(this.db, ctx.documentId, ctx.scope, ctx.branch, ctx.revision, ctx.stagedOps);
|
|
2659
|
+
} catch {}
|
|
2660
|
+
if (replayOps !== null) return replayOps;
|
|
2661
|
+
throw duplicateOf(ctx);
|
|
2662
|
+
}
|
|
2663
|
+
async executeApply(trx, documentId, documentType, scope, branch, revision, fn, signal, condition) {
|
|
2664
|
+
throwIfAborted(signal);
|
|
2665
|
+
const atomicTxn = new AtomicTransaction(documentId, documentType, scope, branch, revision);
|
|
2666
|
+
await fn(atomicTxn);
|
|
2667
|
+
const operations = atomicTxn.getOperations();
|
|
2668
|
+
if (operations.length === 0) return [];
|
|
2669
|
+
const purgedHead = this.liveIds?.has(documentId) ? void 0 : await this.refusePurgedAppend(trx, documentId, operations);
|
|
2670
|
+
if (purgedHead !== void 0) return purgedHead;
|
|
2671
|
+
if (condition) await this.acquireStreamLocks(trx, documentId, scope, branch, condition);
|
|
2672
|
+
const latestOp = await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "desc").limit(1).executeTakeFirst();
|
|
2673
|
+
const currentRevision = latestOp ? latestOp.index : -1;
|
|
2674
|
+
if (currentRevision !== revision - 1) {
|
|
2675
|
+
let replayOps = null;
|
|
2676
|
+
try {
|
|
2677
|
+
replayOps = await this.findIdempotentReplay(trx, documentId, scope, branch, revision, operations);
|
|
2678
|
+
} catch {}
|
|
2679
|
+
if (replayOps !== null) return replayOps;
|
|
2680
|
+
if (revision === 0 && this.isCreate(operations)) throw new DocumentAlreadyExistsError(documentId, scope, currentRevision);
|
|
2681
|
+
throw new RevisionMismatchError(currentRevision + 1, revision);
|
|
2682
|
+
}
|
|
2683
|
+
let prevOpId = latestOp?.opId || "";
|
|
2684
|
+
for (const op of operations) {
|
|
2685
|
+
op.prevOpId = prevOpId;
|
|
2686
|
+
prevOpId = op.opId;
|
|
2687
|
+
}
|
|
2688
|
+
let insertedCount = operations.length;
|
|
2689
|
+
try {
|
|
2690
|
+
if (condition && condition.streams.length > 0) insertedCount = await this.insertGuarded(trx, operations, condition);
|
|
2691
|
+
else await trx.insertInto("Operation").values(operations).execute();
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
if (error instanceof Error && error.message.includes("unique constraint")) throw new _UniqueConstraintContext(documentId, scope, branch, revision, operations);
|
|
2694
|
+
throw error;
|
|
2695
|
+
}
|
|
2696
|
+
if (insertedCount !== operations.length) throw new AppendConditionFailedError(condition);
|
|
2697
|
+
return operations.map((op) => ({
|
|
2698
|
+
index: op.index,
|
|
2699
|
+
timestampUtcMs: op.timestampUtcMs.toISOString(),
|
|
2700
|
+
hash: op.hash,
|
|
2701
|
+
skip: op.skip,
|
|
2702
|
+
error: op.error || void 0,
|
|
2703
|
+
deniedReason: op.deniedReason || void 0,
|
|
2704
|
+
id: op.opId,
|
|
2705
|
+
action: JSON.parse(op.action)
|
|
2706
|
+
}));
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* Locks the written stream and every read-set stream, in sorted key order
|
|
2710
|
+
* so that overlapping concurrent appends serialize rather than deadlock.
|
|
2711
|
+
* The locks are still taken one row at a time, so the query preserves that
|
|
2712
|
+
* order. It must stay separate from the guarded insert, which would
|
|
2713
|
+
* otherwise read a snapshot taken before the locks were held.
|
|
2714
|
+
*/
|
|
2715
|
+
async acquireStreamLocks(trx, documentId, scope, branch, condition) {
|
|
2716
|
+
const keys = /* @__PURE__ */ new Set([`${documentId}:${scope}:${branch}`]);
|
|
2717
|
+
for (const stream of condition.streams) keys.add(`${stream.documentId}:${stream.scope}:${stream.branch}`);
|
|
2718
|
+
const sortedKeys = sql.join([...keys].sort());
|
|
2719
|
+
await sql`
|
|
2720
|
+
with ordered as materialized (
|
|
2721
|
+
select key
|
|
2722
|
+
from unnest(array[${sortedKeys}]::text[]) with ordinality as t(key, ord)
|
|
2723
|
+
order by ord
|
|
2724
|
+
)
|
|
2725
|
+
select pg_advisory_xact_lock(hashtext(key)) from ordered
|
|
2726
|
+
`.execute(trx);
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Inserts the staged operations with the condition compiled in as a WHERE
|
|
2730
|
+
* NOT EXISTS guard, making the check and the append one statement. Returns
|
|
2731
|
+
* the rows inserted; zero means the guard failed and nothing was written.
|
|
2732
|
+
*/
|
|
2733
|
+
async insertGuarded(trx, operations, condition) {
|
|
2734
|
+
const branches = operations.map((op) => trx.selectNoFrom([
|
|
2735
|
+
sql`${op.jobId}::text`.as("jobId"),
|
|
2736
|
+
sql`${op.opId}::text`.as("opId"),
|
|
2737
|
+
sql`${op.prevOpId}::text`.as("prevOpId"),
|
|
2738
|
+
sql`${op.documentId}::text`.as("documentId"),
|
|
2739
|
+
sql`${op.documentType}::text`.as("documentType"),
|
|
2740
|
+
sql`${op.scope}::text`.as("scope"),
|
|
2741
|
+
sql`${op.branch}::text`.as("branch"),
|
|
2742
|
+
sql`${op.timestampUtcMs}::timestamptz`.as("timestampUtcMs"),
|
|
2743
|
+
sql`${op.index}::integer`.as("index"),
|
|
2744
|
+
sql`${op.action}::jsonb`.as("action"),
|
|
2745
|
+
sql`${op.skip}::integer`.as("skip"),
|
|
2746
|
+
sql`${op.error ?? null}::text`.as("error"),
|
|
2747
|
+
sql`${op.deniedReason ?? null}::text`.as("deniedReason"),
|
|
2748
|
+
sql`${op.hash}::text`.as("hash")
|
|
2749
|
+
]).where((eb) => eb.not(eb.exists(eb.selectFrom("Operation").select("Operation.id").where((web) => web.or(condition.streams.map((s) => web.and([
|
|
2750
|
+
web("Operation.documentId", "=", s.documentId),
|
|
2751
|
+
web("Operation.scope", "=", s.scope),
|
|
2752
|
+
web("Operation.branch", "=", s.branch),
|
|
2753
|
+
web("Operation.index", ">", s.revision)
|
|
2754
|
+
]))))))));
|
|
2755
|
+
let expression = branches[0];
|
|
2756
|
+
for (let i = 1; i < branches.length; i++) expression = expression.unionAll(branches[i]);
|
|
2757
|
+
return (await trx.insertInto("Operation").columns([
|
|
2758
|
+
"jobId",
|
|
2759
|
+
"opId",
|
|
2760
|
+
"prevOpId",
|
|
2761
|
+
"documentId",
|
|
2762
|
+
"documentType",
|
|
2763
|
+
"scope",
|
|
2764
|
+
"branch",
|
|
2765
|
+
"timestampUtcMs",
|
|
2766
|
+
"index",
|
|
2767
|
+
"action",
|
|
2768
|
+
"skip",
|
|
2769
|
+
"error",
|
|
2770
|
+
"deniedReason",
|
|
2771
|
+
"hash"
|
|
2772
|
+
]).expression(expression).returning("id").execute()).length;
|
|
2773
|
+
}
|
|
2774
|
+
async findIdempotentReplay(executor, documentId, scope, branch, revision, stagedOps) {
|
|
2775
|
+
const minIndex = revision;
|
|
2776
|
+
const maxIndex = revision + stagedOps.length - 1;
|
|
2777
|
+
const storedRows = await executor.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).where("index", ">=", minIndex).where("index", "<=", maxIndex).orderBy("index", "asc").execute();
|
|
2778
|
+
if (storedRows.length !== stagedOps.length) return null;
|
|
2779
|
+
for (let i = 0; i < stagedOps.length; i++) {
|
|
2780
|
+
const staged = stagedOps[i];
|
|
2781
|
+
const stored = storedRows[i];
|
|
2782
|
+
if (stored.opId !== staged.opId || stored.index !== staged.index || stored.skip !== staged.skip) return null;
|
|
2783
|
+
}
|
|
2784
|
+
return storedRows.map((row) => this.rowToOperation(row));
|
|
2785
|
+
}
|
|
2786
|
+
/** A purged stream refuses appends; a marker gets the stored one back. */
|
|
2787
|
+
async refusePurgedAppend(trx, documentId, operations) {
|
|
2788
|
+
if (!(await findPurged(trx, [documentId])).has(documentId)) return;
|
|
2789
|
+
if (!operations.every((operation) => this.isMarker(operation))) throw new DocumentPurgedError(documentId);
|
|
2790
|
+
return (await trx.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where(sql`action->>'type' = ${PURGE_DOCUMENT}`).orderBy("index", "asc").execute()).map((row) => this.rowToOperation(row));
|
|
2791
|
+
}
|
|
2792
|
+
isMarker(operation) {
|
|
2793
|
+
return this.actionType(operation) === PURGE_DOCUMENT;
|
|
2794
|
+
}
|
|
2795
|
+
actionType(operation) {
|
|
2796
|
+
let action = operation.action;
|
|
2797
|
+
if (typeof action === "string") try {
|
|
2798
|
+
action = JSON.parse(action);
|
|
2799
|
+
} catch {
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
if (typeof action === "object" && action !== null && "type" in action && typeof action.type === "string") return action.type;
|
|
2803
|
+
}
|
|
2804
|
+
/** True when the staged write creates a document rather than appending to one. */
|
|
2805
|
+
isCreate(operations) {
|
|
2806
|
+
for (const operation of operations) {
|
|
2807
|
+
let action = operation.action;
|
|
2808
|
+
if (typeof action === "string") try {
|
|
2809
|
+
action = JSON.parse(action);
|
|
2810
|
+
} catch {
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
if (typeof action === "object" && action !== null && "type" in action && typeof action.type === "string" && action.type === "CREATE_DOCUMENT") return true;
|
|
2814
|
+
}
|
|
2815
|
+
return false;
|
|
2816
|
+
}
|
|
2817
|
+
/**
|
|
2818
|
+
* The paging cursor here encodes the index to resume from (one past the
|
|
2819
|
+
* last row returned), not the last row's own index. This keeps "0" an
|
|
2820
|
+
* unambiguous start-of-stream sentinel even when a page ends at index 0
|
|
2821
|
+
* (e.g. `limit: 1` on a fresh stream), which would otherwise make
|
|
2822
|
+
* `nextCursor` equal the start cursor and loop a caller that walks pages
|
|
2823
|
+
* forever.
|
|
2824
|
+
*/
|
|
2825
|
+
async getSince(documentId, scope, branch, revision, filter, paging, signal) {
|
|
2826
|
+
throwIfAborted(signal);
|
|
2827
|
+
let query = this.queryExecutor.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).where("index", ">", revision).orderBy("index", "asc");
|
|
2828
|
+
if (filter) {
|
|
2829
|
+
if (filter.actionTypes && filter.actionTypes.length > 0) {
|
|
2830
|
+
const actionTypesArray = filter.actionTypes.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
|
|
2831
|
+
query = query.where(sql`action->>'type' = ANY(ARRAY[${sql.raw(actionTypesArray)}]::text[])`);
|
|
2832
|
+
}
|
|
2833
|
+
if (filter.timestampFrom) query = query.where("timestampUtcMs", ">=", new Date(filter.timestampFrom));
|
|
2834
|
+
if (filter.timestampTo) query = query.where("timestampUtcMs", "<=", new Date(filter.timestampTo));
|
|
2835
|
+
if (filter.sinceRevision !== void 0) query = query.where("index", ">=", filter.sinceRevision);
|
|
2836
|
+
}
|
|
2837
|
+
if (paging) {
|
|
2838
|
+
const cursorValue = Number.parseInt(paging.cursor, 10);
|
|
2839
|
+
if (Number.isFinite(cursorValue) && cursorValue > 0) query = query.where("index", ">=", cursorValue);
|
|
2840
|
+
if (paging.limit) query = query.limit(paging.limit + 1);
|
|
2841
|
+
}
|
|
2842
|
+
return paginateRows(await query.execute(), paging, (row) => row.index + 1, (row) => this.rowToOperation(row), (cursor, limit) => this.getSince(documentId, scope, branch, revision, filter, {
|
|
2843
|
+
cursor,
|
|
2844
|
+
limit
|
|
2845
|
+
}, signal));
|
|
2846
|
+
}
|
|
2847
|
+
async getSinceId(id, paging, signal) {
|
|
2848
|
+
throwIfAborted(signal);
|
|
2849
|
+
let query = this.queryExecutor.selectFrom("Operation").selectAll().where("id", ">", id).orderBy("id", "asc");
|
|
2850
|
+
if (paging) {
|
|
2851
|
+
const cursorValue = Number.parseInt(paging.cursor, 10);
|
|
2852
|
+
if (cursorValue > 0) query = query.where("id", ">", cursorValue);
|
|
2853
|
+
if (paging.limit) query = query.limit(paging.limit + 1);
|
|
2854
|
+
}
|
|
2855
|
+
return paginateRows(await query.execute(), paging, (row) => row.id, (row) => this.rowToOperationWithContext(row), (cursor, limit) => this.getSinceId(id, {
|
|
2856
|
+
cursor,
|
|
2857
|
+
limit
|
|
2858
|
+
}, signal));
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* The paging cursor here encodes the index to resume from (one past the
|
|
2862
|
+
* last row returned), not the last row's own index, for the same reason as
|
|
2863
|
+
* `getSince`: a page ending at index 0 must not produce a cursor that is
|
|
2864
|
+
* indistinguishable from the start-of-stream sentinel.
|
|
2865
|
+
*/
|
|
2866
|
+
async getConflicting(documentId, scope, branch, minTimestamp, paging, signal) {
|
|
2867
|
+
throwIfAborted(signal);
|
|
2868
|
+
let query = this.queryExecutor.selectFrom("Operation").selectAll().where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).where("timestampUtcMs", ">=", new Date(minTimestamp)).orderBy("index", "asc");
|
|
2869
|
+
if (paging) {
|
|
2870
|
+
const cursorValue = Number.parseInt(paging.cursor, 10);
|
|
2871
|
+
if (Number.isFinite(cursorValue) && cursorValue > 0) query = query.where("index", ">=", cursorValue);
|
|
2872
|
+
if (paging.limit) query = query.limit(paging.limit + 1);
|
|
2873
|
+
}
|
|
2874
|
+
return paginateRows(await query.execute(), paging, (row) => row.index + 1, (row) => this.rowToOperation(row), (cursor, limit) => this.getConflicting(documentId, scope, branch, minTimestamp, {
|
|
2875
|
+
cursor,
|
|
2876
|
+
limit
|
|
2877
|
+
}, signal));
|
|
2878
|
+
}
|
|
2879
|
+
async getRevisions(documentId, branch, signal) {
|
|
2880
|
+
throwIfAborted(signal);
|
|
2881
|
+
const scopeRevisions = await this.queryExecutor.selectFrom("Operation").select("scope").select((eb) => eb.fn.max("index").as("index")).where("documentId", "=", documentId).where("branch", "=", branch).groupBy("scope").execute();
|
|
2882
|
+
const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("branch", "=", branch).executeTakeFirst();
|
|
2883
|
+
const revision = {};
|
|
2884
|
+
for (const row of scopeRevisions) revision[row.scope] = row.index + 1;
|
|
2885
|
+
return {
|
|
2886
|
+
revision,
|
|
2887
|
+
latestTimestamp: latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
async getStreamLatestTimestamp(documentId, scope, branch, signal) {
|
|
2891
|
+
const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).executeTakeFirst();
|
|
2892
|
+
return latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : void 0;
|
|
2893
|
+
}
|
|
2894
|
+
async findOperationIds(documentId, scope, branch, opIds, signal) {
|
|
2895
|
+
throwIfAborted(signal);
|
|
2896
|
+
if (opIds.length === 0) return /* @__PURE__ */ new Set();
|
|
2897
|
+
const rows = await this.queryExecutor.selectFrom("Operation").select("opId").distinct().where("opId", "in", opIds).where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).execute();
|
|
2898
|
+
return new Set(rows.map((row) => row.opId));
|
|
2899
|
+
}
|
|
2900
|
+
async getOperationsByIds(documentId, scope, branch, opIds, signal) {
|
|
2901
|
+
throwIfAborted(signal);
|
|
2902
|
+
if (opIds.length === 0) return [];
|
|
2903
|
+
const rows = await this.queryExecutor.selectFrom("Operation").selectAll().where("opId", "in", opIds).where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).orderBy("index", "asc").execute();
|
|
2904
|
+
const latest = /* @__PURE__ */ new Map();
|
|
2905
|
+
for (const row of rows) latest.set(row.opId, this.rowToOperation(row));
|
|
2906
|
+
return [...latest.values()];
|
|
2907
|
+
}
|
|
2908
|
+
rowToOperation(row) {
|
|
2909
|
+
return {
|
|
2910
|
+
index: row.index,
|
|
2911
|
+
timestampUtcMs: row.timestampUtcMs.toISOString(),
|
|
2912
|
+
hash: row.hash,
|
|
2913
|
+
skip: row.skip,
|
|
2914
|
+
error: row.error || void 0,
|
|
2915
|
+
deniedReason: row.deniedReason || void 0,
|
|
2916
|
+
id: row.opId,
|
|
2917
|
+
action: row.action
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
rowToOperationWithContext(row) {
|
|
2921
|
+
return {
|
|
2922
|
+
operation: this.rowToOperation(row),
|
|
2923
|
+
context: {
|
|
2924
|
+
documentId: row.documentId,
|
|
2925
|
+
documentType: row.documentType,
|
|
2926
|
+
scope: row.scope,
|
|
2927
|
+
branch: row.branch,
|
|
2928
|
+
ordinal: row.id
|
|
2929
|
+
}
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
};
|
|
2933
|
+
//#endregion
|
|
2934
|
+
//#region src/storage/pool-instrumentation.ts
|
|
2935
|
+
/**
|
|
2936
|
+
* Wraps an existing pg.Pool with acquire-wait timing and an event
|
|
2937
|
+
* subscription surface. The pool is mutated in place: pool.connect()
|
|
2938
|
+
* is replaced with a timing wrapper so all callers (Kysely included)
|
|
2939
|
+
* pick up the instrumentation transparently.
|
|
2940
|
+
*/
|
|
2941
|
+
function instrumentPgPool(pool, name) {
|
|
2942
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2943
|
+
const originalConnect = pool.connect.bind(pool);
|
|
2944
|
+
const wrappedConnect = async () => {
|
|
2945
|
+
const start = performance.now();
|
|
2946
|
+
const client = await originalConnect();
|
|
2947
|
+
const durationMs = performance.now() - start;
|
|
2948
|
+
for (const listener of listeners) try {
|
|
2949
|
+
listener(durationMs);
|
|
2950
|
+
} catch {}
|
|
2951
|
+
return client;
|
|
2952
|
+
};
|
|
2953
|
+
pool.connect = wrappedConnect;
|
|
2954
|
+
return {
|
|
2955
|
+
name,
|
|
2956
|
+
getStats() {
|
|
2957
|
+
return {
|
|
2958
|
+
size: pool.totalCount,
|
|
2959
|
+
idle: pool.idleCount,
|
|
2960
|
+
waiting: pool.waitingCount
|
|
2961
|
+
};
|
|
2962
|
+
},
|
|
2963
|
+
onAcquire(listener) {
|
|
2964
|
+
listeners.add(listener);
|
|
2965
|
+
return () => {
|
|
2966
|
+
listeners.delete(listener);
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
function createForwardingPoolInstrumentation(name) {
|
|
2972
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2973
|
+
let stats = {
|
|
2974
|
+
size: 0,
|
|
2975
|
+
idle: 0,
|
|
2976
|
+
waiting: 0
|
|
2977
|
+
};
|
|
2978
|
+
return {
|
|
2979
|
+
name,
|
|
2980
|
+
getStats() {
|
|
2981
|
+
return stats;
|
|
2982
|
+
},
|
|
2983
|
+
onAcquire(listener) {
|
|
2984
|
+
listeners.add(listener);
|
|
2985
|
+
return () => {
|
|
2986
|
+
listeners.delete(listener);
|
|
2987
|
+
};
|
|
2988
|
+
},
|
|
2989
|
+
pushSamples(durations) {
|
|
2990
|
+
for (const durationMs of durations) for (const listener of listeners) try {
|
|
2991
|
+
listener(durationMs);
|
|
2992
|
+
} catch {}
|
|
2993
|
+
},
|
|
2994
|
+
updateStats(next) {
|
|
2995
|
+
stats = next;
|
|
2996
|
+
}
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
//#endregion
|
|
3000
|
+
//#region src/storage/migrations/001_create_operation_table.ts
|
|
3001
|
+
var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$25 });
|
|
3002
|
+
async function up$25(db) {
|
|
3003
|
+
await db.schema.createTable("Operation").addColumn("id", "serial", (col) => col.primaryKey()).addColumn("jobId", "text", (col) => col.notNull()).addColumn("opId", "text", (col) => col.notNull()).addColumn("prevOpId", "text", (col) => col.notNull()).addColumn("writeTimestampUtcMs", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("timestampUtcMs", "timestamptz", (col) => col.notNull()).addColumn("index", "integer", (col) => col.notNull()).addColumn("action", "jsonb", (col) => col.notNull()).addColumn("skip", "integer", (col) => col.notNull()).addColumn("error", "text").addColumn("hash", "text", (col) => col.notNull()).addUniqueConstraint("unique_revision", [
|
|
3004
|
+
"documentId",
|
|
3005
|
+
"scope",
|
|
3006
|
+
"branch",
|
|
3007
|
+
"index"
|
|
3008
|
+
]).addUniqueConstraint("unique_operation_instance", [
|
|
3009
|
+
"opId",
|
|
3010
|
+
"index",
|
|
3011
|
+
"skip"
|
|
3012
|
+
]).execute();
|
|
3013
|
+
await db.schema.createIndex("streamOperations").on("Operation").columns([
|
|
3014
|
+
"documentId",
|
|
3015
|
+
"scope",
|
|
3016
|
+
"branch",
|
|
3017
|
+
"id"
|
|
3018
|
+
]).execute();
|
|
3019
|
+
await db.schema.createIndex("branchlessStreamOperations").on("Operation").columns([
|
|
3020
|
+
"documentId",
|
|
3021
|
+
"scope",
|
|
3022
|
+
"id"
|
|
3023
|
+
]).execute();
|
|
3024
|
+
}
|
|
3025
|
+
//#endregion
|
|
3026
|
+
//#region src/storage/migrations/002_create_keyframe_table.ts
|
|
3027
|
+
var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$24 });
|
|
3028
|
+
async function up$24(db) {
|
|
3029
|
+
await db.schema.createTable("Keyframe").addColumn("id", "serial", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("revision", "integer", (col) => col.notNull()).addColumn("document", "jsonb", (col) => col.notNull()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_keyframe", [
|
|
3030
|
+
"documentId",
|
|
3031
|
+
"scope",
|
|
3032
|
+
"branch",
|
|
3033
|
+
"revision"
|
|
3034
|
+
]).execute();
|
|
3035
|
+
await db.schema.createIndex("keyframe_lookup").on("Keyframe").columns([
|
|
3036
|
+
"documentId",
|
|
3037
|
+
"scope",
|
|
3038
|
+
"branch",
|
|
3039
|
+
"revision"
|
|
3040
|
+
]).execute();
|
|
3041
|
+
}
|
|
3042
|
+
//#endregion
|
|
3043
|
+
//#region src/storage/migrations/003_create_document_table.ts
|
|
3044
|
+
var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$23 });
|
|
3045
|
+
async function up$23(db) {
|
|
3046
|
+
await db.schema.createTable("Document").addColumn("id", "text", (col) => col.primaryKey()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3047
|
+
}
|
|
3048
|
+
//#endregion
|
|
3049
|
+
//#region src/storage/migrations/004_create_document_relationship_table.ts
|
|
3050
|
+
var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$22 });
|
|
3051
|
+
async function up$22(db) {
|
|
3052
|
+
await db.schema.createTable("DocumentRelationship").addColumn("id", "text", (col) => col.primaryKey()).addColumn("sourceId", "text", (col) => col.notNull().references("Document.id").onDelete("cascade")).addColumn("targetId", "text", (col) => col.notNull().references("Document.id").onDelete("cascade")).addColumn("relationshipType", "text", (col) => col.notNull()).addColumn("metadata", "jsonb").addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_source_target_type", [
|
|
3053
|
+
"sourceId",
|
|
3054
|
+
"targetId",
|
|
3055
|
+
"relationshipType"
|
|
3056
|
+
]).execute();
|
|
3057
|
+
await db.schema.createIndex("idx_relationship_source").on("DocumentRelationship").column("sourceId").execute();
|
|
3058
|
+
await db.schema.createIndex("idx_relationship_target").on("DocumentRelationship").column("targetId").execute();
|
|
3059
|
+
await db.schema.createIndex("idx_relationship_type").on("DocumentRelationship").column("relationshipType").execute();
|
|
3060
|
+
}
|
|
3061
|
+
//#endregion
|
|
3062
|
+
//#region src/storage/migrations/005_create_indexer_state_table.ts
|
|
3063
|
+
var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$21 });
|
|
3064
|
+
async function up$21(db) {
|
|
3065
|
+
await db.schema.createTable("IndexerState").addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()).addColumn("lastOperationId", "integer", (col) => col.notNull()).addColumn("lastOperationTimestamp", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3066
|
+
}
|
|
3067
|
+
//#endregion
|
|
3068
|
+
//#region src/storage/migrations/006_create_document_snapshot_table.ts
|
|
3069
|
+
var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$20 });
|
|
3070
|
+
async function up$20(db) {
|
|
3071
|
+
await db.schema.createTable("DocumentSnapshot").addColumn("id", "text", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("slug", "text").addColumn("name", "text").addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("content", "jsonb", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("lastOperationIndex", "integer", (col) => col.notNull()).addColumn("lastOperationHash", "text", (col) => col.notNull()).addColumn("lastUpdatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("snapshotVersion", "integer", (col) => col.notNull().defaultTo(1)).addColumn("identifiers", "jsonb").addColumn("metadata", "jsonb").addColumn("isDeleted", "boolean", (col) => col.notNull().defaultTo(false)).addColumn("deletedAt", "timestamptz").addUniqueConstraint("unique_doc_scope_branch", [
|
|
3072
|
+
"documentId",
|
|
3073
|
+
"scope",
|
|
3074
|
+
"branch"
|
|
3075
|
+
]).execute();
|
|
3076
|
+
await db.schema.createIndex("idx_slug_scope_branch").on("DocumentSnapshot").columns([
|
|
3077
|
+
"slug",
|
|
3078
|
+
"scope",
|
|
3079
|
+
"branch"
|
|
3080
|
+
]).execute();
|
|
3081
|
+
await db.schema.createIndex("idx_doctype_scope_branch").on("DocumentSnapshot").columns([
|
|
3082
|
+
"documentType",
|
|
3083
|
+
"scope",
|
|
3084
|
+
"branch"
|
|
3085
|
+
]).execute();
|
|
3086
|
+
await db.schema.createIndex("idx_last_updated").on("DocumentSnapshot").column("lastUpdatedAt").execute();
|
|
3087
|
+
await db.schema.createIndex("idx_is_deleted").on("DocumentSnapshot").column("isDeleted").execute();
|
|
3088
|
+
}
|
|
3089
|
+
//#endregion
|
|
3090
|
+
//#region src/storage/migrations/007_create_slug_mapping_table.ts
|
|
3091
|
+
var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$19 });
|
|
3092
|
+
async function up$19(db) {
|
|
3093
|
+
await db.schema.createTable("SlugMapping").addColumn("slug", "text", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_docid_scope_branch", [
|
|
3094
|
+
"documentId",
|
|
3095
|
+
"scope",
|
|
3096
|
+
"branch"
|
|
3097
|
+
]).execute();
|
|
3098
|
+
await db.schema.createIndex("idx_slug_documentid").on("SlugMapping").column("documentId").execute();
|
|
3099
|
+
}
|
|
3100
|
+
//#endregion
|
|
3101
|
+
//#region src/storage/migrations/008_create_view_state_table.ts
|
|
3102
|
+
var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$18 });
|
|
3103
|
+
async function up$18(db) {
|
|
3104
|
+
await db.schema.createTable("ViewState").addColumn("readModelId", "text", (col) => col.primaryKey()).addColumn("lastOrdinal", "integer", (col) => col.notNull().defaultTo(0)).addColumn("lastOperationTimestamp", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3105
|
+
}
|
|
3106
|
+
//#endregion
|
|
3107
|
+
//#region src/storage/migrations/009_create_operation_index_tables.ts
|
|
3108
|
+
var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$17 });
|
|
3109
|
+
async function up$17(db) {
|
|
3110
|
+
await db.schema.createTable("document_collections").addColumn("documentId", "text", (col) => col.notNull()).addColumn("collectionId", "text", (col) => col.notNull()).addColumn("joinedOrdinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("leftOrdinal", "bigint").addPrimaryKeyConstraint("document_collections_pkey", ["documentId", "collectionId"]).execute();
|
|
3111
|
+
await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
|
|
3112
|
+
await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
|
|
3113
|
+
await db.schema.createTable("operation_index_operations").addColumn("ordinal", "serial", (col) => col.primaryKey()).addColumn("opId", "text", (col) => col.notNull()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("timestampUtcMs", "text", (col) => col.notNull()).addColumn("writeTimestampUtcMs", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("index", "integer", (col) => col.notNull()).addColumn("skip", "integer", (col) => col.notNull()).addColumn("hash", "text", (col) => col.notNull()).addColumn("action", "jsonb", (col) => col.notNull()).execute();
|
|
3114
|
+
await db.schema.createIndex("idx_operation_index_operations_document").on("operation_index_operations").columns([
|
|
3115
|
+
"documentId",
|
|
3116
|
+
"branch",
|
|
3117
|
+
"scope"
|
|
3118
|
+
]).execute();
|
|
3119
|
+
await db.schema.createIndex("idx_operation_index_operations_ordinal").on("operation_index_operations").column("ordinal").execute();
|
|
3120
|
+
}
|
|
3121
|
+
//#endregion
|
|
3122
|
+
//#region src/storage/migrations/010_create_sync_tables.ts
|
|
3123
|
+
var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$16 });
|
|
3124
|
+
async function up$16(db) {
|
|
3125
|
+
await db.schema.createTable("sync_remotes").addColumn("name", "text", (col) => col.primaryKey()).addColumn("collection_id", "text", (col) => col.notNull()).addColumn("channel_type", "text", (col) => col.notNull()).addColumn("channel_id", "text", (col) => col.notNull().defaultTo("")).addColumn("remote_name", "text", (col) => col.notNull().defaultTo("")).addColumn("channel_parameters", "jsonb", (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)).addColumn("filter_document_ids", "jsonb").addColumn("filter_scopes", "jsonb").addColumn("filter_branch", "text", (col) => col.notNull().defaultTo("main")).addColumn("push_state", "text", (col) => col.notNull().defaultTo("idle")).addColumn("push_last_success_utc_ms", "text").addColumn("push_last_failure_utc_ms", "text").addColumn("push_failure_count", "integer", (col) => col.notNull().defaultTo(0)).addColumn("pull_state", "text", (col) => col.notNull().defaultTo("idle")).addColumn("pull_last_success_utc_ms", "text").addColumn("pull_last_failure_utc_ms", "text").addColumn("pull_failure_count", "integer", (col) => col.notNull().defaultTo(0)).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3126
|
+
await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
|
|
3127
|
+
await db.schema.createTable("sync_cursors").addColumn("remote_name", "text", (col) => col.primaryKey().references("sync_remotes.name").onDelete("cascade")).addColumn("cursor_ordinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("last_synced_at_utc_ms", "text").addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3128
|
+
await db.schema.createIndex("idx_sync_cursors_ordinal").on("sync_cursors").column("cursor_ordinal").execute();
|
|
3129
|
+
}
|
|
3130
|
+
//#endregion
|
|
3131
|
+
//#region src/storage/migrations/011_add_cursor_type_column.ts
|
|
3132
|
+
var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$15 });
|
|
3133
|
+
async function up$15(db) {
|
|
3134
|
+
await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
|
|
3135
|
+
await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
|
|
3136
|
+
await db.schema.dropTable("sync_cursors").execute();
|
|
3137
|
+
await db.schema.createTable("sync_cursors").addColumn("remote_name", "text", (col) => col.notNull()).addColumn("cursor_type", "text", (col) => col.notNull().defaultTo("inbox")).addColumn("cursor_ordinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("last_synced_at_utc_ms", "text").addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addPrimaryKeyConstraint("sync_cursors_pk", ["remote_name", "cursor_type"]).execute();
|
|
3138
|
+
await db.schema.createIndex("idx_sync_cursors_ordinal").on("sync_cursors").column("cursor_ordinal").execute();
|
|
3139
|
+
}
|
|
3140
|
+
//#endregion
|
|
3141
|
+
//#region src/storage/migrations/012_add_source_remote_column.ts
|
|
3142
|
+
var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
|
|
3143
|
+
async function up$14(db) {
|
|
3144
|
+
await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
|
|
3145
|
+
}
|
|
3146
|
+
//#endregion
|
|
3147
|
+
//#region src/storage/migrations/013_create_sync_dead_letters_table.ts
|
|
3148
|
+
var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
|
|
3149
|
+
async function up$13(db) {
|
|
3150
|
+
await db.schema.createTable("sync_dead_letters").addColumn("ordinal", "serial", (col) => col.primaryKey()).addColumn("id", "text", (col) => col.unique().notNull()).addColumn("job_id", "text", (col) => col.notNull()).addColumn("job_dependencies", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("remote_name", "text", (col) => col.notNull().references("sync_remotes.name").onDelete("cascade")).addColumn("document_id", "text", (col) => col.notNull()).addColumn("scopes", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("branch", "text", (col) => col.notNull()).addColumn("operations", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("error_source", "text", (col) => col.notNull()).addColumn("error_message", "text", (col) => col.notNull()).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3151
|
+
await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
|
|
3152
|
+
}
|
|
3153
|
+
//#endregion
|
|
3154
|
+
//#region src/storage/migrations/014_create_processor_cursor_table.ts
|
|
3155
|
+
var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
|
|
3156
|
+
async function up$12(db) {
|
|
3157
|
+
await db.schema.createTable("ProcessorCursor").addColumn("processorId", "text", (col) => col.primaryKey()).addColumn("factoryId", "text", (col) => col.notNull()).addColumn("driveId", "text", (col) => col.notNull()).addColumn("processorIndex", "integer", (col) => col.notNull()).addColumn("lastOrdinal", "integer", (col) => col.notNull().defaultTo(sql`0`)).addColumn("status", "text", (col) => col.notNull().defaultTo(sql`'active'`)).addColumn("lastError", "text").addColumn("lastErrorTimestamp", "timestamptz").addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
|
|
3158
|
+
}
|
|
3159
|
+
//#endregion
|
|
3160
|
+
//#region src/storage/migrations/015_add_operation_denied_reason.ts
|
|
3161
|
+
var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
|
|
3162
|
+
down: () => down$11,
|
|
3163
|
+
up: () => up$11
|
|
3164
|
+
});
|
|
3165
|
+
/**
|
|
3166
|
+
* Records why authorization refused an operation. Separate from `error` so a
|
|
3167
|
+
* denial is distinguishable from a reducer failure without matching on a
|
|
3168
|
+
* message. Null for every operation written before decisions were enforced.
|
|
3169
|
+
*/
|
|
3170
|
+
async function up$11(db) {
|
|
3171
|
+
await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
|
|
3172
|
+
await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
|
|
3173
|
+
}
|
|
3174
|
+
async function down$11(db) {
|
|
3175
|
+
await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
|
|
3176
|
+
await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
|
|
3177
|
+
}
|
|
3178
|
+
//#endregion
|
|
3179
|
+
//#region src/storage/migrations/016_add_dead_letter_error_type.ts
|
|
3180
|
+
var _016_add_dead_letter_error_type_exports = /* @__PURE__ */ __exportAll({
|
|
3181
|
+
down: () => down$10,
|
|
3182
|
+
up: () => up$10
|
|
3183
|
+
});
|
|
3184
|
+
/**
|
|
3185
|
+
* The classification a dead letter falls into, stored because it decides whether
|
|
3186
|
+
* the document stays quarantined and the in-memory error is gone after a restart.
|
|
3187
|
+
* Defaulted rather than nullable, so a pre-existing row rehydrates.
|
|
3188
|
+
*/
|
|
3189
|
+
async function up$10(db) {
|
|
3190
|
+
await db.schema.alterTable("sync_dead_letters").addColumn("error_type", "text", (col) => col.notNull().defaultTo("UNCLASSIFIED")).execute();
|
|
3191
|
+
}
|
|
3192
|
+
async function down$10(db) {
|
|
3193
|
+
await db.schema.alterTable("sync_dead_letters").dropColumn("error_type").execute();
|
|
3194
|
+
}
|
|
3195
|
+
//#endregion
|
|
3196
|
+
//#region src/storage/migrations/017_create_group_references.ts
|
|
3197
|
+
var _017_create_group_references_exports = /* @__PURE__ */ __exportAll({
|
|
3198
|
+
down: () => down$9,
|
|
3199
|
+
up: () => up$9
|
|
3200
|
+
});
|
|
3201
|
+
/**
|
|
3202
|
+
* One row per (document, group) reference ever discovered from an auth
|
|
3203
|
+
* operation's input. Rows are never updated or deleted: auth evaluation is
|
|
3204
|
+
* positional, so a grant that named a group at any position keeps that
|
|
3205
|
+
* group's stream in the document's read-set even after a later operation
|
|
3206
|
+
* removes the reference. Read by documentId for the groups a document
|
|
3207
|
+
* requires (sync), and by groupId for the documents a group change affects
|
|
3208
|
+
* (re-evaluation).
|
|
3209
|
+
*/
|
|
3210
|
+
async function up$9(db) {
|
|
3211
|
+
await db.schema.createTable("group_references").addColumn("documentId", "text", (col) => col.notNull()).addColumn("groupId", "text", (col) => col.notNull()).addPrimaryKeyConstraint("group_references_pkey", ["documentId", "groupId"]).execute();
|
|
3212
|
+
await db.schema.createIndex("idx_group_references_groupId").on("group_references").column("groupId").execute();
|
|
3213
|
+
}
|
|
3214
|
+
async function down$9(db) {
|
|
3215
|
+
await db.schema.dropTable("group_references").execute();
|
|
3216
|
+
}
|
|
3217
|
+
//#endregion
|
|
3218
|
+
//#region src/storage/migrations/018_add_sync_remote_bound_address.ts
|
|
3219
|
+
var _018_add_sync_remote_bound_address_exports = /* @__PURE__ */ __exportAll({
|
|
3220
|
+
down: () => down$8,
|
|
3221
|
+
up: () => up$8
|
|
3222
|
+
});
|
|
3223
|
+
/**
|
|
3224
|
+
* The address a sync channel is bound to, so a channel created by one subject
|
|
3225
|
+
* cannot be polled by another.
|
|
3226
|
+
*
|
|
3227
|
+
* Nullable rather than defaulted: null is a channel nobody has claimed, which is
|
|
3228
|
+
* what every pre-existing row is and what an anonymously created channel stays
|
|
3229
|
+
* until its first authenticated poll adopts it. A default would claim them all
|
|
3230
|
+
* for one address.
|
|
3231
|
+
*/
|
|
3232
|
+
async function up$8(db) {
|
|
3233
|
+
await db.schema.alterTable("sync_remotes").addColumn("bound_address", "text").execute();
|
|
3234
|
+
}
|
|
3235
|
+
async function down$8(db) {
|
|
3236
|
+
await db.schema.alterTable("sync_remotes").dropColumn("bound_address").execute();
|
|
3237
|
+
}
|
|
3238
|
+
//#endregion
|
|
3239
|
+
//#region src/storage/migrations/019_require_action_id.ts
|
|
3240
|
+
var _019_require_action_id_exports = /* @__PURE__ */ __exportAll({
|
|
3241
|
+
down: () => down$7,
|
|
3242
|
+
up: () => up$7
|
|
3243
|
+
});
|
|
3244
|
+
/**
|
|
3245
|
+
* Makes an operation whose action carries no id physically unstorable.
|
|
3246
|
+
*
|
|
3247
|
+
* The id is not decoration: `deriveOperationId` hashes it into the operation id
|
|
3248
|
+
* and replay dedupes incoming operations by it, so an action without one
|
|
3249
|
+
* collapses every id-less operation on a document/scope/branch onto a single
|
|
3250
|
+
* derived operation id. The API rejects such an action now, and this is the
|
|
3251
|
+
* last line of defense behind it.
|
|
3252
|
+
*
|
|
3253
|
+
* Both tables are constrained because sync reads operations from the index
|
|
3254
|
+
* rather than the operation table, so poison reaching only the index would
|
|
3255
|
+
* still be served to a replica.
|
|
3256
|
+
*
|
|
3257
|
+
* Pre-existing rows are backfilled rather than left behind a NOT VALID
|
|
3258
|
+
* constraint: a row the index and the operation table disagree about is worse
|
|
3259
|
+
* than a missing id, because dedup keys off the value each side serves. The
|
|
3260
|
+
* backfill therefore mints one id per operation and writes that same id to both
|
|
3261
|
+
* tables, joined on the identity they share. Rewriting the action is safe: the
|
|
3262
|
+
* operation hash is taken over the resulting state, not over the action, and a
|
|
3263
|
+
* signature is verified from the params carried in the signature tuple, which
|
|
3264
|
+
* do not include the action id.
|
|
3265
|
+
*
|
|
3266
|
+
* The empty string is rejected alongside null. It derives the same colliding
|
|
3267
|
+
* operation id as an absent id, so admitting it would leave the hole open.
|
|
3268
|
+
*/
|
|
3269
|
+
async function up$7(db) {
|
|
3270
|
+
await db.updateTable("Operation").set({ action: sql`jsonb_set(action, '{id}', to_jsonb(gen_random_uuid()::text))` }).where(sql`jsonb_typeof(action) = 'object' and coalesce(action->>'id', '') = ''`).execute();
|
|
3271
|
+
await db.updateTable("operation_index_operations as oio").from("Operation as op").set({ action: sql`jsonb_set(oio.action, '{id}', to_jsonb(op.action->>'id'))` }).whereRef("oio.opId", "=", "op.opId").whereRef("oio.index", "=", "op.index").whereRef("oio.skip", "=", "op.skip").where(sql`jsonb_typeof(oio.action) = 'object' and coalesce(oio.action->>'id', '') = ''`).where(sql`coalesce(op.action->>'id', '') <> ''`).execute();
|
|
3272
|
+
await db.updateTable("operation_index_operations").set({ action: sql`jsonb_set(action, '{id}', to_jsonb(gen_random_uuid()::text))` }).where(sql`jsonb_typeof(action) = 'object' and coalesce(action->>'id', '') = ''`).execute();
|
|
3273
|
+
await db.schema.alterTable("Operation").addCheckConstraint("action_must_have_id", sql`action->>'id' is not null and action->>'id' <> ''`).execute();
|
|
3274
|
+
await db.schema.alterTable("operation_index_operations").addCheckConstraint("action_must_have_id", sql`action->>'id' is not null and action->>'id' <> ''`).execute();
|
|
3275
|
+
}
|
|
3276
|
+
/**
|
|
3277
|
+
* Only the constraints are dropped. The backfilled ids stay: they are the ids
|
|
3278
|
+
* their operations are now known by, and reverting them would reintroduce the
|
|
3279
|
+
* collision the migration removed.
|
|
3280
|
+
*/
|
|
3281
|
+
async function down$7(db) {
|
|
3282
|
+
await db.schema.alterTable("operation_index_operations").dropConstraint("action_must_have_id").execute();
|
|
3283
|
+
await db.schema.alterTable("Operation").dropConstraint("action_must_have_id").execute();
|
|
3284
|
+
}
|
|
3285
|
+
//#endregion
|
|
3286
|
+
//#region src/storage/migrations/020_add_snapshot_operation_ordinal.ts
|
|
3287
|
+
var _020_add_snapshot_operation_ordinal_exports = /* @__PURE__ */ __exportAll({
|
|
3288
|
+
down: () => down$6,
|
|
3289
|
+
up: () => up$6
|
|
3290
|
+
});
|
|
3291
|
+
/** Orders header row writes across scopes; `lastOperationIndex` is per scope. */
|
|
3292
|
+
async function up$6(db) {
|
|
3293
|
+
await db.schema.alterTable("DocumentSnapshot").addColumn("lastOperationOrdinal", "integer", (col) => col.notNull().defaultTo(0)).execute();
|
|
3294
|
+
}
|
|
3295
|
+
async function down$6(db) {
|
|
3296
|
+
await db.schema.alterTable("DocumentSnapshot").dropColumn("lastOperationOrdinal").execute();
|
|
3297
|
+
}
|
|
3298
|
+
//#endregion
|
|
3299
|
+
//#region src/storage/migrations/021_add_sync_remote_peer.ts
|
|
3300
|
+
var _021_add_sync_remote_peer_exports = /* @__PURE__ */ __exportAll({
|
|
3301
|
+
down: () => down$5,
|
|
3302
|
+
up: () => up$5
|
|
3303
|
+
});
|
|
3304
|
+
/**
|
|
3305
|
+
* The peer's manifest as canonical JSON, and when it was heard. Both null: not
|
|
3306
|
+
* yet heard. A time with no manifest: a silent peer, which gets the baselines.
|
|
3307
|
+
*/
|
|
3308
|
+
async function up$5(db) {
|
|
3309
|
+
await db.schema.alterTable("sync_remotes").addColumn("peer_manifest", "text").addColumn("peer_manifest_at_utc_ms", "bigint").execute();
|
|
3310
|
+
}
|
|
3311
|
+
async function down$5(db) {
|
|
3312
|
+
await db.schema.alterTable("sync_remotes").dropColumn("peer_manifest").dropColumn("peer_manifest_at_utc_ms").execute();
|
|
3313
|
+
}
|
|
3314
|
+
//#endregion
|
|
3315
|
+
//#region src/storage/migrations/022_create_sync_holds.ts
|
|
3316
|
+
var _022_create_sync_holds_exports = /* @__PURE__ */ __exportAll({
|
|
3317
|
+
down: () => down$4,
|
|
3318
|
+
up: () => up$4
|
|
3319
|
+
});
|
|
3320
|
+
/** Documents held back from a remote whose peer cannot run their versions. */
|
|
3321
|
+
async function up$4(db) {
|
|
3322
|
+
await db.schema.createTable("sync_holds").addColumn("remote_name", "text", (col) => col.notNull().references("sync_remotes.name").onDelete("cascade")).addColumn("document_id", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("protocol", "text", (col) => col.notNull()).addColumn("version", "integer", (col) => col.notNull()).addColumn("held_at_utc_ms", "bigint", (col) => col.notNull()).addPrimaryKeyConstraint("sync_holds_pkey", [
|
|
3323
|
+
"remote_name",
|
|
3324
|
+
"document_id",
|
|
3325
|
+
"branch"
|
|
3326
|
+
]).execute();
|
|
3327
|
+
}
|
|
3328
|
+
async function down$4(db) {
|
|
3329
|
+
await db.schema.dropTable("sync_holds").execute();
|
|
3330
|
+
}
|
|
3331
|
+
//#endregion
|
|
3332
|
+
//#region src/storage/migrations/023_index_created_protocol_versions.ts
|
|
3333
|
+
var _023_index_created_protocol_versions_exports = /* @__PURE__ */ __exportAll({
|
|
3334
|
+
down: () => down$3,
|
|
3335
|
+
up: () => up$3
|
|
3336
|
+
});
|
|
3337
|
+
/**
|
|
3338
|
+
* One entry per document created with protocolVersions, keyed by a hash so an
|
|
3339
|
+
* oversized header cannot make its creation unstorable. Startup reads the
|
|
3340
|
+
* distinct values without visiting every document.
|
|
3341
|
+
*/
|
|
3342
|
+
async function up$3(db) {
|
|
3343
|
+
await db.schema.createIndex("idx_operation_created_protocol_versions").on("Operation").expression(sql`md5((action->'input'->'protocolVersions')::text)`).where(sql`scope = 'document' and "index" = 0 and (action->'input'->'protocolVersions') is not null`).execute();
|
|
3344
|
+
}
|
|
3345
|
+
async function down$3(db) {
|
|
3346
|
+
await db.schema.dropIndex("idx_operation_created_protocol_versions").execute();
|
|
3347
|
+
}
|
|
3348
|
+
//#endregion
|
|
3349
|
+
//#region src/storage/migrations/024_create_document_purges.ts
|
|
3350
|
+
var _024_create_document_purges_exports = /* @__PURE__ */ __exportAll({
|
|
3351
|
+
down: () => down$2,
|
|
3352
|
+
up: () => up$2
|
|
3353
|
+
});
|
|
3354
|
+
/** One row per purged document: the tombstone. Written once, never deleted. */
|
|
3355
|
+
async function up$2(db) {
|
|
3356
|
+
await db.schema.createTable("document_purges").addColumn("documentId", "text", (col) => col.primaryKey()).addColumn("ordinal", "bigint", (col) => col.notNull()).addColumn("removedRows", "jsonb", (col) => col.notNull()).addColumn("purgedAtUtc", "timestamptz", (col) => col.notNull()).addColumn("requestId", "text", (col) => col.notNull()).execute();
|
|
3357
|
+
}
|
|
3358
|
+
async function down$2(db) {
|
|
3359
|
+
await db.schema.dropTable("document_purges").execute();
|
|
3360
|
+
}
|
|
3361
|
+
//#endregion
|
|
3362
|
+
//#region src/storage/migrations/025_create_sync_received_markers.ts
|
|
3363
|
+
var _025_create_sync_received_markers_exports = /* @__PURE__ */ __exportAll({
|
|
3364
|
+
down: () => down$1,
|
|
3365
|
+
up: () => up$1
|
|
3366
|
+
});
|
|
3367
|
+
/** Received purge markers awaiting their outcome; removed with their remote. */
|
|
3368
|
+
async function up$1(db) {
|
|
3369
|
+
await db.schema.createTable("sync_received_markers").addColumn("remote_name", "text", (col) => col.notNull()).addColumn("marker_id", "text", (col) => col.notNull()).addColumn("document_id", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("operation", "jsonb", (col) => col.notNull()).addColumn("received_at_utc_ms", "bigint", (col) => col.notNull()).addPrimaryKeyConstraint("sync_received_markers_pkey", ["remote_name", "marker_id"]).execute();
|
|
3370
|
+
}
|
|
3371
|
+
async function down$1(db) {
|
|
3372
|
+
await db.schema.dropTable("sync_received_markers").execute();
|
|
3373
|
+
}
|
|
3374
|
+
//#endregion
|
|
3375
|
+
//#region src/storage/migrations/026_create_sync_purge_refusals.ts
|
|
3376
|
+
var _026_create_sync_purge_refusals_exports = /* @__PURE__ */ __exportAll({
|
|
3377
|
+
down: () => down,
|
|
3378
|
+
up: () => up
|
|
3379
|
+
});
|
|
3380
|
+
/** A remote's refusal of a purge marker; payload-free, kept like the tombstone. */
|
|
3381
|
+
async function up(db) {
|
|
3382
|
+
await db.schema.createTable("sync_purge_refusals").addColumn("remote_name", "text", (col) => col.notNull()).addColumn("document_id", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("refused_at_utc_ms", "bigint", (col) => col.notNull()).addPrimaryKeyConstraint("sync_purge_refusals_pkey", [
|
|
3383
|
+
"remote_name",
|
|
3384
|
+
"document_id",
|
|
3385
|
+
"branch"
|
|
3386
|
+
]).execute();
|
|
3387
|
+
await db.schema.createIndex("idx_sync_purge_refusals_document").on("sync_purge_refusals").column("document_id").execute();
|
|
3388
|
+
}
|
|
3389
|
+
async function down(db) {
|
|
3390
|
+
await db.schema.dropTable("sync_purge_refusals").execute();
|
|
3391
|
+
}
|
|
3392
|
+
//#endregion
|
|
3393
|
+
//#region src/storage/migrations/migrator.ts
|
|
3394
|
+
const REACTOR_SCHEMA = "reactor";
|
|
3395
|
+
const migrations = {
|
|
3396
|
+
"001_create_operation_table": _001_create_operation_table_exports,
|
|
3397
|
+
"002_create_keyframe_table": _002_create_keyframe_table_exports,
|
|
3398
|
+
"003_create_document_table": _003_create_document_table_exports,
|
|
3399
|
+
"004_create_document_relationship_table": _004_create_document_relationship_table_exports,
|
|
3400
|
+
"005_create_indexer_state_table": _005_create_indexer_state_table_exports,
|
|
3401
|
+
"006_create_document_snapshot_table": _006_create_document_snapshot_table_exports,
|
|
3402
|
+
"007_create_slug_mapping_table": _007_create_slug_mapping_table_exports,
|
|
3403
|
+
"008_create_view_state_table": _008_create_view_state_table_exports,
|
|
3404
|
+
"009_create_operation_index_tables": _009_create_operation_index_tables_exports,
|
|
3405
|
+
"010_create_sync_tables": _010_create_sync_tables_exports,
|
|
3406
|
+
"011_add_cursor_type_column": _011_add_cursor_type_column_exports,
|
|
3407
|
+
"012_add_source_remote_column": _012_add_source_remote_column_exports,
|
|
3408
|
+
"013_create_sync_dead_letters_table": _013_create_sync_dead_letters_table_exports,
|
|
3409
|
+
"014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
|
|
3410
|
+
"015_add_operation_denied_reason": _015_add_operation_denied_reason_exports,
|
|
3411
|
+
"016_add_dead_letter_error_type": _016_add_dead_letter_error_type_exports,
|
|
3412
|
+
"017_create_group_references": _017_create_group_references_exports,
|
|
3413
|
+
"018_add_sync_remote_bound_address": _018_add_sync_remote_bound_address_exports,
|
|
3414
|
+
"019_require_action_id": _019_require_action_id_exports,
|
|
3415
|
+
"020_add_snapshot_operation_ordinal": _020_add_snapshot_operation_ordinal_exports,
|
|
3416
|
+
"021_add_sync_remote_peer": _021_add_sync_remote_peer_exports,
|
|
3417
|
+
"022_create_sync_holds": _022_create_sync_holds_exports,
|
|
3418
|
+
"023_index_created_protocol_versions": _023_index_created_protocol_versions_exports,
|
|
3419
|
+
"024_create_document_purges": _024_create_document_purges_exports,
|
|
3420
|
+
"025_create_sync_received_markers": _025_create_sync_received_markers_exports,
|
|
3421
|
+
"026_create_sync_purge_refusals": _026_create_sync_purge_refusals_exports
|
|
3422
|
+
};
|
|
3423
|
+
var ProgrammaticMigrationProvider = class {
|
|
3424
|
+
getMigrations() {
|
|
3425
|
+
return Promise.resolve(migrations);
|
|
3426
|
+
}
|
|
3427
|
+
};
|
|
3428
|
+
/**
|
|
3429
|
+
* Applies every pending migration, or every one up to and including `upTo`.
|
|
3430
|
+
*
|
|
3431
|
+
* The bound exists so a test can reach the schema a data migration is written
|
|
3432
|
+
* against, populate it, and then migrate across the migration under test.
|
|
3433
|
+
*/
|
|
3434
|
+
async function runMigrations(db, schema = REACTOR_SCHEMA, upTo) {
|
|
3435
|
+
try {
|
|
3436
|
+
await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(schema)}`.execute(db);
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
return {
|
|
3439
|
+
success: false,
|
|
3440
|
+
migrationsExecuted: [],
|
|
3441
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Failed to create schema")
|
|
3442
|
+
};
|
|
3443
|
+
}
|
|
3444
|
+
const migrator = new Migrator({
|
|
3445
|
+
db: db.withSchema(schema),
|
|
3446
|
+
provider: new ProgrammaticMigrationProvider(),
|
|
3447
|
+
migrationTableSchema: schema
|
|
3448
|
+
});
|
|
3449
|
+
let error;
|
|
3450
|
+
let results;
|
|
3451
|
+
try {
|
|
3452
|
+
const result = upTo ? await migrator.migrateTo(upTo) : await migrator.migrateToLatest();
|
|
3453
|
+
error = result.error;
|
|
3454
|
+
results = result.results;
|
|
3455
|
+
} catch (e) {
|
|
3456
|
+
error = e;
|
|
3457
|
+
results = [];
|
|
3458
|
+
}
|
|
3459
|
+
const migrationsExecuted = results?.map((result) => result.migrationName) ?? [];
|
|
3460
|
+
if (error) return {
|
|
3461
|
+
success: false,
|
|
3462
|
+
migrationsExecuted,
|
|
3463
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Unknown migration error")
|
|
3464
|
+
};
|
|
3465
|
+
return {
|
|
3466
|
+
success: true,
|
|
3467
|
+
migrationsExecuted
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
|
|
3471
|
+
return await new Migrator({
|
|
3472
|
+
db: db.withSchema(schema),
|
|
3473
|
+
provider: new ProgrammaticMigrationProvider(),
|
|
3474
|
+
migrationTableSchema: schema
|
|
3475
|
+
}).getMigrations();
|
|
3476
|
+
}
|
|
3477
|
+
//#endregion
|
|
3478
|
+
//#region src/executor/worker/error-info.ts
|
|
3479
|
+
const MAX_STACK_LENGTH = 8192;
|
|
3480
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
3481
|
+
function toErrorInfo(err, depth = 0) {
|
|
3482
|
+
if (depth > MAX_CAUSE_DEPTH) return {
|
|
3483
|
+
name: "Error",
|
|
3484
|
+
message: "cause chain truncated"
|
|
3485
|
+
};
|
|
3486
|
+
if (err instanceof Error) {
|
|
3487
|
+
const info = {
|
|
3488
|
+
name: err.name || "Error",
|
|
3489
|
+
message: err.message
|
|
3490
|
+
};
|
|
3491
|
+
if (typeof err.stack === "string") info.stack = err.stack.length > MAX_STACK_LENGTH ? err.stack.slice(0, MAX_STACK_LENGTH) : err.stack;
|
|
3492
|
+
if (err.cause !== void 0) info.cause = toErrorInfo(err.cause, depth + 1);
|
|
3493
|
+
const documentId = err.documentId;
|
|
3494
|
+
if (typeof documentId === "string") info.documentId = documentId;
|
|
3495
|
+
const { documentType, requestedVersion } = err;
|
|
3496
|
+
if (typeof documentType === "string") info.documentType = documentType;
|
|
3497
|
+
if (typeof requestedVersion === "number") info.requestedVersion = requestedVersion;
|
|
3498
|
+
return info;
|
|
3499
|
+
}
|
|
3500
|
+
if (typeof err === "string") return {
|
|
3501
|
+
name: "Error",
|
|
3502
|
+
message: err
|
|
3503
|
+
};
|
|
3504
|
+
if (err && typeof err === "object") {
|
|
3505
|
+
const record = err;
|
|
3506
|
+
return {
|
|
3507
|
+
name: typeof record.name === "string" ? record.name : "Error",
|
|
3508
|
+
message: typeof record.message === "string" ? record.message : safeStringify(err)
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
return {
|
|
3512
|
+
name: "Error",
|
|
3513
|
+
message: safeStringify(err)
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
function safeStringify(value) {
|
|
3517
|
+
if (value === null || value === void 0) return String(value);
|
|
3518
|
+
if (typeof value === "string") return value;
|
|
3519
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
3520
|
+
try {
|
|
3521
|
+
return JSON.stringify(value);
|
|
3522
|
+
} catch {
|
|
3523
|
+
return "[unserializable]";
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
function fromErrorInfo(info) {
|
|
3527
|
+
const err = new Error(info.message);
|
|
3528
|
+
Object.defineProperty(err, "name", {
|
|
3529
|
+
value: info.name,
|
|
3530
|
+
configurable: true,
|
|
3531
|
+
writable: true
|
|
3532
|
+
});
|
|
3533
|
+
if (info.stack !== void 0) err.stack = info.stack;
|
|
3534
|
+
if (info.documentId !== void 0) Object.defineProperty(err, "documentId", {
|
|
3535
|
+
value: info.documentId,
|
|
3536
|
+
configurable: true,
|
|
3537
|
+
writable: true
|
|
3538
|
+
});
|
|
3539
|
+
for (const key of ["documentType", "requestedVersion"]) if (info[key] !== void 0) Object.defineProperty(err, key, {
|
|
3540
|
+
value: info[key],
|
|
3541
|
+
configurable: true,
|
|
3542
|
+
writable: true
|
|
3543
|
+
});
|
|
3544
|
+
if (info.cause !== void 0) Object.defineProperty(err, "cause", {
|
|
3545
|
+
value: fromErrorInfo(info.cause),
|
|
3546
|
+
configurable: true,
|
|
3547
|
+
writable: true
|
|
3548
|
+
});
|
|
3549
|
+
return err;
|
|
3550
|
+
}
|
|
3551
|
+
//#endregion
|
|
3552
|
+
//#region src/executor/worker/sanitize.ts
|
|
3553
|
+
const MAX_DEPTH = 8;
|
|
3554
|
+
function errorToInfo(err) {
|
|
3555
|
+
return toErrorInfo(err);
|
|
3556
|
+
}
|
|
3557
|
+
function sanitizeArg(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
|
|
3558
|
+
if (depth > MAX_DEPTH) return "[Truncated]";
|
|
3559
|
+
if (value === null || value === void 0) return null;
|
|
3560
|
+
if (typeof value === "boolean") return value;
|
|
3561
|
+
if (typeof value === "number") return value;
|
|
3562
|
+
if (typeof value === "string") return value;
|
|
3563
|
+
if (typeof value === "bigint") return value.toString();
|
|
3564
|
+
if (typeof value === "function" || typeof value === "symbol") return null;
|
|
3565
|
+
if (value instanceof Error) return toErrorInfo(value);
|
|
3566
|
+
if (value instanceof Date) return value.toISOString();
|
|
3567
|
+
if (value instanceof Map) {
|
|
3568
|
+
const obj = {};
|
|
3569
|
+
for (const [k, v] of value.entries()) obj[String(k)] = sanitizeArg(v, depth + 1, seen);
|
|
3570
|
+
return obj;
|
|
3571
|
+
}
|
|
3572
|
+
if (value instanceof Set) {
|
|
3573
|
+
const arr = [];
|
|
3574
|
+
for (const v of value.values()) arr.push(sanitizeArg(v, depth + 1, seen));
|
|
3575
|
+
return arr;
|
|
3576
|
+
}
|
|
3577
|
+
if (Array.isArray(value)) {
|
|
3578
|
+
if (seen.has(value)) return "[Circular]";
|
|
3579
|
+
seen.add(value);
|
|
3580
|
+
const result = value.map((v) => sanitizeArg(v, depth + 1, seen));
|
|
3581
|
+
seen.delete(value);
|
|
3582
|
+
return result;
|
|
3583
|
+
}
|
|
3584
|
+
if (typeof value === "object") {
|
|
3585
|
+
const proto = Object.getPrototypeOf(value);
|
|
3586
|
+
if (proto !== Object.prototype && proto !== null) return { __nonClonable: value.constructor?.name || "Unknown" };
|
|
3587
|
+
if (seen.has(value)) return "[Circular]";
|
|
3588
|
+
seen.add(value);
|
|
3589
|
+
const result = {};
|
|
3590
|
+
for (const [k, v] of Object.entries(value)) result[k] = sanitizeArg(v, depth + 1, seen);
|
|
3591
|
+
seen.delete(value);
|
|
3592
|
+
return result;
|
|
3593
|
+
}
|
|
3594
|
+
return null;
|
|
3595
|
+
}
|
|
3596
|
+
//#endregion
|
|
3597
|
+
//#region src/executor/worker/forwarding-logger.ts
|
|
3598
|
+
function createForwardingLogger(post, level = "info", tags = []) {
|
|
3599
|
+
function safePost(msg) {
|
|
3600
|
+
try {
|
|
3601
|
+
post(msg);
|
|
3602
|
+
} catch {
|
|
3603
|
+
try {
|
|
3604
|
+
process.stderr.write(`[forwarding-logger] postMessage failed: ${msg.level} ${msg.message}\n`);
|
|
3605
|
+
} catch {}
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
function emit(wireLevel, message, args) {
|
|
3609
|
+
const sanitizedArgs = args.map((a) => sanitizeArg(a));
|
|
3610
|
+
safePost({
|
|
3611
|
+
type: "log",
|
|
3612
|
+
level: wireLevel,
|
|
3613
|
+
message,
|
|
3614
|
+
args: tags.length > 0 ? [sanitizeArg({ tags }), ...sanitizedArgs] : sanitizedArgs,
|
|
3615
|
+
timestamp: Date.now()
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
return {
|
|
3619
|
+
level,
|
|
3620
|
+
verbose: (message, ...args) => emit("debug", message, args),
|
|
3621
|
+
debug: (message, ...args) => emit("debug", message, args),
|
|
3622
|
+
info: (message, ...args) => emit("info", message, args),
|
|
3623
|
+
warn: (message, ...args) => emit("warn", message, args),
|
|
3624
|
+
error: (message, ...args) => emit("error", message, args),
|
|
3625
|
+
errorHandler: ((...data) => {
|
|
3626
|
+
const first = data[0];
|
|
3627
|
+
emit("error", typeof first === "string" ? first : "", data.slice(1));
|
|
3628
|
+
}),
|
|
3629
|
+
child: (childTags) => createForwardingLogger(post, level, [...tags, ...childTags])
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
//#endregion
|
|
3633
|
+
export { DocumentExistence as $, GATED_DOCUMENT_ACTIONS as A, createOperation as B, describeWaitingSessions as C, matchesScope as Ct, CollectionMembershipCache as D, __exportAll as Dt, DocumentMetaCache as E, yieldToMain as Et, buildErrorResult as F, relationshipTarget as G, isGenesisOperation as H, buildSuccessResult as I, targetDocumentId as J, submittedActionIds as K, createConsistencyToken as L, TouchedStreams as M, applyDeleteDocumentAction$1 as N, DOCUMENT_SCOPE_ACTIONS as O, applyUpgradeDocumentAction$1 as P, DocumentAlreadyExistsError as Q, createDocumentFromAction as R, createKyselyWatermarkProbe as S, ReactorEventTypes as St, snapshotFunctionsFor as T, throwIfAborted as Tt, jobWriteIds as U, getNextIndexForScope as V, refusalError as W, APPEND_CONDITION_FAILED_PREFIX as X, updateDocumentRevision as Y, AppendConditionFailedError as Z, acquirePurgeLocks as _, ReservedActionError as _t, REACTOR_SCHEMA as a, AuthorizationDeniedError as at, ProbeSettler as b, UpgradePreconditionFailedError as bt, createForwardingPoolInstrumentation as c, DocumentNotDeletedError as ct, KyselyKeyframeStore as d, ExcessiveReshuffleError as dt, DuplicateOperationError as et, EventBus as f, GroupInUseError as ft, PURGE_NS as g, RelationshipNotFoundError as gt, PURGE_LOCK_BUCKETS as h, PurgeTooLargeError as ht, fromErrorInfo as i, AuthTimestampNotMonotonicError as it, PurgeFence as j, FencedWriteCache as k, instrumentPgPool as l, DocumentNotFoundError as lt, KyselyOperationIndex as m, InvalidSignatureError as mt, errorToInfo as n, RevisionMismatchError as nt, getMigrationStatus as o, BatchJobFailedError as ot, KyselyWriteCache as p, InvalidOperationTimestampError as pt, summarizeSubmittedActions as q, sanitizeArg as r, AuthEnforcementDisabledError as rt, runMigrations as s, DocumentDeletedError as st, createForwardingLogger as t, OptimisticLockError as tt, KyselyOperationStore as u, DocumentPurgedError as ut, findPurged as v, UnsupportedProtocolVersionError as vt, parseSnapshot as w, parsePagingOptions as wt, SettledWatermark as x, EventBusAggregateError as xt, listPurged as y, UnsupportedStoredProtocolError as yt, createEmptyConsistencyToken as z };
|
|
3634
|
+
|
|
3635
|
+
//# sourceMappingURL=forwarding-logger-ByfBFgMS.js.map
|