@powerhousedao/reactor 6.0.0-dev.35 → 6.0.0-dev.37
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/src/cache/kysely-operation-index.js +1 -1
- package/dist/src/cache/kysely-operation-index.js.map +1 -1
- package/dist/src/client/reactor-client.d.ts +2 -1
- package/dist/src/client/reactor-client.d.ts.map +1 -1
- package/dist/src/client/reactor-client.js +96 -19
- package/dist/src/client/reactor-client.js.map +1 -1
- package/dist/src/client/types.d.ts +10 -0
- package/dist/src/client/types.d.ts.map +1 -1
- package/dist/src/client/types.js.map +1 -1
- package/dist/src/core/reactor-builder.d.ts.map +1 -1
- package/dist/src/core/reactor-builder.js +1 -3
- package/dist/src/core/reactor-builder.js.map +1 -1
- package/dist/src/core/reactor.d.ts +2 -1
- package/dist/src/core/reactor.d.ts.map +1 -1
- package/dist/src/core/reactor.js +98 -4
- package/dist/src/core/reactor.js.map +1 -1
- package/dist/src/core/types.d.ts +32 -0
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/core/utils.d.ts +29 -0
- package/dist/src/core/utils.d.ts.map +1 -1
- package/dist/src/core/utils.js +31 -2
- package/dist/src/core/utils.js.map +1 -1
- package/dist/src/executor/document-action-handler.d.ts +37 -0
- package/dist/src/executor/document-action-handler.d.ts.map +1 -0
- package/dist/src/executor/document-action-handler.js +349 -0
- package/dist/src/executor/document-action-handler.js.map +1 -0
- package/dist/src/executor/signature-verifier.d.ts +9 -0
- package/dist/src/executor/signature-verifier.d.ts.map +1 -0
- package/dist/src/executor/signature-verifier.js +70 -0
- package/dist/src/executor/signature-verifier.js.map +1 -0
- package/dist/src/executor/simple-job-executor.d.ts +3 -39
- package/dist/src/executor/simple-job-executor.d.ts.map +1 -1
- package/dist/src/executor/simple-job-executor.js +32 -510
- package/dist/src/executor/simple-job-executor.js.map +1 -1
- package/dist/src/executor/util.d.ts +11 -1
- package/dist/src/executor/util.d.ts.map +1 -1
- package/dist/src/executor/util.js +47 -1
- package/dist/src/executor/util.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/sync/channels/gql-channel.d.ts +1 -1
- package/dist/src/sync/channels/gql-channel.d.ts.map +1 -1
- package/dist/src/sync/channels/gql-channel.js +54 -19
- package/dist/src/sync/channels/gql-channel.js.map +1 -1
- package/dist/src/sync/channels/utils.d.ts.map +1 -1
- package/dist/src/sync/channels/utils.js +2 -2
- package/dist/src/sync/channels/utils.js.map +1 -1
- package/dist/src/sync/sync-manager.d.ts +6 -2
- package/dist/src/sync/sync-manager.d.ts.map +1 -1
- package/dist/src/sync/sync-manager.js +213 -129
- package/dist/src/sync/sync-manager.js.map +1 -1
- package/dist/src/sync/sync-operation.d.ts +2 -1
- package/dist/src/sync/sync-operation.d.ts.map +1 -1
- package/dist/src/sync/sync-operation.js +3 -1
- package/dist/src/sync/sync-operation.js.map +1 -1
- package/dist/src/sync/types.d.ts +2 -0
- package/dist/src/sync/types.d.ts.map +1 -1
- package/dist/src/sync/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { driveCollectionId } from "../cache/operation-index-types.js";
|
|
1
|
+
import { isUndoRedo } from "document-model/core";
|
|
3
2
|
import { ReactorEventTypes } from "../events/types.js";
|
|
4
|
-
import { DocumentDeletedError
|
|
3
|
+
import { DocumentDeletedError } from "../shared/errors.js";
|
|
5
4
|
import { reshuffleByTimestamp } from "../utils/reshuffle.js";
|
|
6
|
-
import {
|
|
5
|
+
import { DocumentActionHandler } from "./document-action-handler.js";
|
|
6
|
+
import { SignatureVerifier } from "./signature-verifier.js";
|
|
7
|
+
import { buildErrorResult } from "./util.js";
|
|
7
8
|
const MAX_SKIP_THRESHOLD = 1000;
|
|
8
9
|
const documentScopeActions = [
|
|
9
10
|
"CREATE_DOCUMENT",
|
|
@@ -24,8 +25,9 @@ export class SimpleJobExecutor {
|
|
|
24
25
|
operationIndex;
|
|
25
26
|
documentMetaCache;
|
|
26
27
|
collectionMembershipCache;
|
|
27
|
-
signatureVerifier;
|
|
28
28
|
config;
|
|
29
|
+
signatureVerifierModule;
|
|
30
|
+
documentActionHandler;
|
|
29
31
|
constructor(logger, registry, operationStore, eventBus, writeCache, operationIndex, documentMetaCache, collectionMembershipCache, config, signatureVerifier) {
|
|
30
32
|
this.logger = logger;
|
|
31
33
|
this.registry = registry;
|
|
@@ -35,7 +37,6 @@ export class SimpleJobExecutor {
|
|
|
35
37
|
this.operationIndex = operationIndex;
|
|
36
38
|
this.documentMetaCache = documentMetaCache;
|
|
37
39
|
this.collectionMembershipCache = collectionMembershipCache;
|
|
38
|
-
this.signatureVerifier = signatureVerifier;
|
|
39
40
|
this.config = {
|
|
40
41
|
maxSkipThreshold: config.maxSkipThreshold ?? MAX_SKIP_THRESHOLD,
|
|
41
42
|
maxConcurrency: config.maxConcurrency ?? 1,
|
|
@@ -43,6 +44,8 @@ export class SimpleJobExecutor {
|
|
|
43
44
|
retryBaseDelayMs: config.retryBaseDelayMs ?? 100,
|
|
44
45
|
retryMaxDelayMs: config.retryMaxDelayMs ?? 5000,
|
|
45
46
|
};
|
|
47
|
+
this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
|
|
48
|
+
this.documentActionHandler = new DocumentActionHandler(writeCache, operationStore, documentMetaCache, collectionMembershipCache, registry, logger);
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* Execute a single job by applying all its actions through the appropriate reducers.
|
|
@@ -118,7 +121,7 @@ export class SimpleJobExecutor {
|
|
|
118
121
|
const generatedOperations = [];
|
|
119
122
|
const operationsWithContext = [];
|
|
120
123
|
try {
|
|
121
|
-
await this.
|
|
124
|
+
await this.signatureVerifierModule.verifyActions(job.documentId, job.branch, actions);
|
|
122
125
|
}
|
|
123
126
|
catch (error) {
|
|
124
127
|
return {
|
|
@@ -134,7 +137,7 @@ export class SimpleJobExecutor {
|
|
|
134
137
|
const sourceOperation = sourceOperations?.[actionIndex];
|
|
135
138
|
const isDocumentAction = documentScopeActions.includes(action.type);
|
|
136
139
|
const result = isDocumentAction
|
|
137
|
-
? await this.
|
|
140
|
+
? await this.documentActionHandler.execute(job, action, startTime, indexTxn, skip)
|
|
138
141
|
: await this.executeRegularAction(job, action, startTime, indexTxn, skip, sourceOperation);
|
|
139
142
|
const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
|
|
140
143
|
if (error !== null) {
|
|
@@ -152,381 +155,31 @@ export class SimpleJobExecutor {
|
|
|
152
155
|
operationsWithContext,
|
|
153
156
|
};
|
|
154
157
|
}
|
|
155
|
-
/**
|
|
156
|
-
* Execute a document scope action (CREATE_DOCUMENT, DELETE_DOCUMENT, UPGRADE_DOCUMENT,
|
|
157
|
-
* ADD_RELATIONSHIP, REMOVE_RELATIONSHIP) by dispatching to the appropriate handler.
|
|
158
|
-
*/
|
|
159
|
-
async executeDocumentAction(job, action, startTime, indexTxn, skip = 0) {
|
|
160
|
-
switch (action.type) {
|
|
161
|
-
case "CREATE_DOCUMENT":
|
|
162
|
-
return this.executeCreateDocumentAction(job, action, startTime, indexTxn, skip);
|
|
163
|
-
case "DELETE_DOCUMENT":
|
|
164
|
-
return this.executeDeleteDocumentAction(job, action, startTime, indexTxn);
|
|
165
|
-
case "UPGRADE_DOCUMENT":
|
|
166
|
-
return this.executeUpgradeDocumentAction(job, action, startTime, indexTxn, skip);
|
|
167
|
-
case "ADD_RELATIONSHIP":
|
|
168
|
-
return this.executeAddRelationshipAction(job, action, startTime, indexTxn);
|
|
169
|
-
case "REMOVE_RELATIONSHIP":
|
|
170
|
-
return this.executeRemoveRelationshipAction(job, action, startTime, indexTxn);
|
|
171
|
-
default:
|
|
172
|
-
return this.buildErrorResult(job, new Error(`Unknown document action type: ${action.type}`), startTime);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Execute a CREATE_DOCUMENT system action.
|
|
177
|
-
* This creates a new document in storage along with its initial operation.
|
|
178
|
-
* For a new document, the operation index is always 0.
|
|
179
|
-
*/
|
|
180
|
-
async executeCreateDocumentAction(job, action, startTime, indexTxn, skip = 0) {
|
|
181
|
-
if (job.scope !== "document") {
|
|
182
|
-
return {
|
|
183
|
-
job,
|
|
184
|
-
success: false,
|
|
185
|
-
error: new Error(`CREATE_DOCUMENT must be in "document" scope, got "${job.scope}"`),
|
|
186
|
-
duration: Date.now() - startTime,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
const document = createDocumentFromAction(action);
|
|
190
|
-
const operation = this.createOperation(action, 0, skip, {
|
|
191
|
-
documentId: document.header.id,
|
|
192
|
-
scope: job.scope,
|
|
193
|
-
branch: job.branch,
|
|
194
|
-
});
|
|
195
|
-
// Compute resultingState for passing via context (not persisted)
|
|
196
|
-
// Include header and all scopes present in the document state (auth, document, etc.)
|
|
197
|
-
// but not global/local which aren't initialized by CREATE_DOCUMENT
|
|
198
|
-
const resultingStateObj = {
|
|
199
|
-
header: document.header,
|
|
200
|
-
...document.state,
|
|
201
|
-
};
|
|
202
|
-
const resultingState = JSON.stringify(resultingStateObj);
|
|
203
|
-
const writeError = await this.writeOperationToStore(document.header.id, document.header.documentType, job.scope, job.branch, operation, job, startTime);
|
|
204
|
-
if (writeError !== null) {
|
|
205
|
-
return writeError;
|
|
206
|
-
}
|
|
207
|
-
this.updateDocumentRevision(document, job.scope, operation.index);
|
|
208
|
-
this.writeCacheState(document.header.id, job.scope, job.branch, operation.index, document);
|
|
209
|
-
indexTxn.write([
|
|
210
|
-
{
|
|
211
|
-
...operation,
|
|
212
|
-
documentId: document.header.id,
|
|
213
|
-
documentType: document.header.documentType,
|
|
214
|
-
branch: job.branch,
|
|
215
|
-
scope: job.scope,
|
|
216
|
-
},
|
|
217
|
-
]);
|
|
218
|
-
// collection membership has to be _after_ the write, as it requires the
|
|
219
|
-
// ordinal of the operation to be set
|
|
220
|
-
if (document.header.documentType === "powerhouse/document-drive") {
|
|
221
|
-
const collectionId = driveCollectionId(job.branch, document.header.id);
|
|
222
|
-
indexTxn.createCollection(collectionId);
|
|
223
|
-
indexTxn.addToCollection(collectionId, document.header.id);
|
|
224
|
-
}
|
|
225
|
-
this.documentMetaCache.putDocumentMeta(document.header.id, job.branch, {
|
|
226
|
-
state: document.state.document,
|
|
227
|
-
documentType: document.header.documentType,
|
|
228
|
-
documentScopeRevision: 1,
|
|
229
|
-
});
|
|
230
|
-
return this.buildSuccessResult(job, operation, document.header.id, document.header.documentType, resultingState, startTime);
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Execute a DELETE_DOCUMENT system action.
|
|
234
|
-
* This deletes a document from legacy storage and writes the operation to IOperationStore.
|
|
235
|
-
* The operation index is determined from the document's current operation count.
|
|
236
|
-
*/
|
|
237
|
-
async executeDeleteDocumentAction(job, action, startTime, indexTxn) {
|
|
238
|
-
const input = action.input;
|
|
239
|
-
if (!input.documentId) {
|
|
240
|
-
return this.buildErrorResult(job, new Error("DELETE_DOCUMENT action requires a documentId in input"), startTime);
|
|
241
|
-
}
|
|
242
|
-
const documentId = input.documentId;
|
|
243
|
-
let document;
|
|
244
|
-
try {
|
|
245
|
-
document = await this.writeCache.getState(documentId, job.scope, job.branch);
|
|
246
|
-
}
|
|
247
|
-
catch (error) {
|
|
248
|
-
return this.buildErrorResult(job, new Error(`Failed to fetch document before deletion: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
249
|
-
}
|
|
250
|
-
// Check if document is already deleted
|
|
251
|
-
const documentState = document.state.document;
|
|
252
|
-
if (documentState.isDeleted) {
|
|
253
|
-
return this.buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
254
|
-
}
|
|
255
|
-
const nextIndex = getNextIndexForScope(document, job.scope);
|
|
256
|
-
const operation = this.createOperation(action, nextIndex, 0, {
|
|
257
|
-
documentId,
|
|
258
|
-
scope: job.scope,
|
|
259
|
-
branch: job.branch,
|
|
260
|
-
});
|
|
261
|
-
// Mark the document as deleted in the state for read model indexing
|
|
262
|
-
applyDeleteDocumentAction(document, action);
|
|
263
|
-
// Compute resultingState for passing via context (not persisted)
|
|
264
|
-
// DELETE_DOCUMENT only affects header and document scopes
|
|
265
|
-
const resultingStateObj = {
|
|
266
|
-
header: document.header,
|
|
267
|
-
document: document.state.document,
|
|
268
|
-
};
|
|
269
|
-
const resultingState = JSON.stringify(resultingStateObj);
|
|
270
|
-
const writeError = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime);
|
|
271
|
-
if (writeError !== null) {
|
|
272
|
-
return writeError;
|
|
273
|
-
}
|
|
274
|
-
indexTxn.write([
|
|
275
|
-
{
|
|
276
|
-
...operation,
|
|
277
|
-
documentId: documentId,
|
|
278
|
-
documentType: document.header.documentType,
|
|
279
|
-
branch: job.branch,
|
|
280
|
-
scope: job.scope,
|
|
281
|
-
},
|
|
282
|
-
]);
|
|
283
|
-
this.documentMetaCache.putDocumentMeta(documentId, job.branch, {
|
|
284
|
-
state: document.state.document,
|
|
285
|
-
documentType: document.header.documentType,
|
|
286
|
-
documentScopeRevision: operation.index + 1,
|
|
287
|
-
});
|
|
288
|
-
return this.buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Execute an UPGRADE_DOCUMENT system action.
|
|
292
|
-
* Handles initial upgrades (version 0 to N), same-version no-ops, and multi-step upgrade chains.
|
|
293
|
-
* The operation index is determined from the document's current operation count.
|
|
294
|
-
*/
|
|
295
|
-
async executeUpgradeDocumentAction(job, action, startTime, indexTxn, skip = 0) {
|
|
296
|
-
const input = action.input;
|
|
297
|
-
if (!input.documentId) {
|
|
298
|
-
return this.buildErrorResult(job, new Error("UPGRADE_DOCUMENT action requires a documentId in input"), startTime);
|
|
299
|
-
}
|
|
300
|
-
const documentId = input.documentId;
|
|
301
|
-
const fromVersion = input.fromVersion;
|
|
302
|
-
const toVersion = input.toVersion;
|
|
303
|
-
let document;
|
|
304
|
-
try {
|
|
305
|
-
document = await this.writeCache.getState(documentId, job.scope, job.branch);
|
|
306
|
-
}
|
|
307
|
-
catch (error) {
|
|
308
|
-
return this.buildErrorResult(job, new Error(`Failed to fetch document for upgrade: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
309
|
-
}
|
|
310
|
-
const documentState = document.state.document;
|
|
311
|
-
if (documentState.isDeleted) {
|
|
312
|
-
return this.buildErrorResult(job, new DocumentDeletedError(documentId, documentState.deletedAtUtcIso), startTime);
|
|
313
|
-
}
|
|
314
|
-
const nextIndex = getNextIndexForScope(document, job.scope);
|
|
315
|
-
let upgradePath;
|
|
316
|
-
if (fromVersion > 0 && fromVersion < toVersion) {
|
|
317
|
-
try {
|
|
318
|
-
upgradePath = this.registry.computeUpgradePath(document.header.documentType, fromVersion, toVersion);
|
|
319
|
-
}
|
|
320
|
-
catch (error) {
|
|
321
|
-
return this.buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (fromVersion === toVersion && fromVersion > 0) {
|
|
325
|
-
return {
|
|
326
|
-
job,
|
|
327
|
-
success: true,
|
|
328
|
-
operations: [],
|
|
329
|
-
operationsWithContext: [],
|
|
330
|
-
duration: Date.now() - startTime,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
try {
|
|
334
|
-
document = applyUpgradeDocumentAction(document, action, upgradePath);
|
|
335
|
-
}
|
|
336
|
-
catch (error) {
|
|
337
|
-
return this.buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
338
|
-
}
|
|
339
|
-
const operation = this.createOperation(action, nextIndex, skip, {
|
|
340
|
-
documentId,
|
|
341
|
-
scope: job.scope,
|
|
342
|
-
branch: job.branch,
|
|
343
|
-
});
|
|
344
|
-
// Compute resultingState for passing via context (not persisted)
|
|
345
|
-
const resultingStateObj = {
|
|
346
|
-
header: document.header,
|
|
347
|
-
...document.state,
|
|
348
|
-
};
|
|
349
|
-
const resultingState = JSON.stringify(resultingStateObj);
|
|
350
|
-
const writeError = await this.writeOperationToStore(documentId, document.header.documentType, job.scope, job.branch, operation, job, startTime);
|
|
351
|
-
if (writeError !== null) {
|
|
352
|
-
return writeError;
|
|
353
|
-
}
|
|
354
|
-
this.updateDocumentRevision(document, job.scope, operation.index);
|
|
355
|
-
this.writeCacheState(documentId, job.scope, job.branch, operation.index, document);
|
|
356
|
-
indexTxn.write([
|
|
357
|
-
{
|
|
358
|
-
...operation,
|
|
359
|
-
documentId: documentId,
|
|
360
|
-
documentType: document.header.documentType,
|
|
361
|
-
branch: job.branch,
|
|
362
|
-
scope: job.scope,
|
|
363
|
-
},
|
|
364
|
-
]);
|
|
365
|
-
this.documentMetaCache.putDocumentMeta(documentId, job.branch, {
|
|
366
|
-
state: document.state.document,
|
|
367
|
-
documentType: document.header.documentType,
|
|
368
|
-
documentScopeRevision: operation.index + 1,
|
|
369
|
-
});
|
|
370
|
-
return this.buildSuccessResult(job, operation, documentId, document.header.documentType, resultingState, startTime);
|
|
371
|
-
}
|
|
372
|
-
async executeAddRelationshipAction(job, action, startTime, indexTxn) {
|
|
373
|
-
if (job.scope !== "document") {
|
|
374
|
-
return this.buildErrorResult(job, new Error(`ADD_RELATIONSHIP must be in "document" scope, got "${job.scope}"`), startTime);
|
|
375
|
-
}
|
|
376
|
-
const input = action.input;
|
|
377
|
-
if (!input.sourceId || !input.targetId || !input.relationshipType) {
|
|
378
|
-
return this.buildErrorResult(job, new Error("ADD_RELATIONSHIP action requires sourceId, targetId, and relationshipType in input"), startTime);
|
|
379
|
-
}
|
|
380
|
-
if (input.sourceId === input.targetId) {
|
|
381
|
-
return this.buildErrorResult(job, new Error("ADD_RELATIONSHIP: sourceId and targetId cannot be the same (self-relationships not allowed)"), startTime);
|
|
382
|
-
}
|
|
383
|
-
let sourceDoc;
|
|
384
|
-
try {
|
|
385
|
-
sourceDoc = await this.writeCache.getState(input.sourceId, "document", job.branch);
|
|
386
|
-
}
|
|
387
|
-
catch (error) {
|
|
388
|
-
return this.buildErrorResult(job, new Error(`ADD_RELATIONSHIP: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
389
|
-
}
|
|
390
|
-
const nextIndex = getNextIndexForScope(sourceDoc, job.scope);
|
|
391
|
-
const operation = this.createOperation(action, nextIndex, 0, {
|
|
392
|
-
documentId: input.sourceId,
|
|
393
|
-
scope: job.scope,
|
|
394
|
-
branch: job.branch,
|
|
395
|
-
});
|
|
396
|
-
const writeError = await this.writeOperationToStore(input.sourceId, sourceDoc.header.documentType, job.scope, job.branch, operation, job, startTime);
|
|
397
|
-
if (writeError !== null) {
|
|
398
|
-
return writeError;
|
|
399
|
-
}
|
|
400
|
-
sourceDoc.header.lastModifiedAtUtcIso =
|
|
401
|
-
operation.timestampUtcMs || new Date().toISOString();
|
|
402
|
-
this.updateDocumentRevision(sourceDoc, job.scope, operation.index);
|
|
403
|
-
sourceDoc.operations = {
|
|
404
|
-
...sourceDoc.operations,
|
|
405
|
-
[job.scope]: [...(sourceDoc.operations[job.scope] ?? []), operation],
|
|
406
|
-
};
|
|
407
|
-
const scopeState = sourceDoc.state[job.scope];
|
|
408
|
-
const resultingStateObj = {
|
|
409
|
-
header: structuredClone(sourceDoc.header),
|
|
410
|
-
[job.scope]: scopeState === undefined ? {} : structuredClone(scopeState),
|
|
411
|
-
};
|
|
412
|
-
const resultingState = JSON.stringify(resultingStateObj);
|
|
413
|
-
this.writeCacheState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
|
|
414
|
-
indexTxn.write([
|
|
415
|
-
{
|
|
416
|
-
...operation,
|
|
417
|
-
documentId: input.sourceId,
|
|
418
|
-
documentType: sourceDoc.header.documentType,
|
|
419
|
-
branch: job.branch,
|
|
420
|
-
scope: job.scope,
|
|
421
|
-
},
|
|
422
|
-
]);
|
|
423
|
-
// collection membership has to be _after_ the write, as it requires the
|
|
424
|
-
// ordinal of the operation to be set
|
|
425
|
-
if (sourceDoc.header.documentType === "powerhouse/document-drive") {
|
|
426
|
-
const collectionId = driveCollectionId(job.branch, input.sourceId);
|
|
427
|
-
indexTxn.addToCollection(collectionId, input.targetId);
|
|
428
|
-
this.collectionMembershipCache.invalidate(input.targetId);
|
|
429
|
-
}
|
|
430
|
-
this.documentMetaCache.putDocumentMeta(input.sourceId, job.branch, {
|
|
431
|
-
state: sourceDoc.state.document,
|
|
432
|
-
documentType: sourceDoc.header.documentType,
|
|
433
|
-
documentScopeRevision: operation.index + 1,
|
|
434
|
-
});
|
|
435
|
-
return this.buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
|
|
436
|
-
}
|
|
437
|
-
async executeRemoveRelationshipAction(job, action, startTime, indexTxn) {
|
|
438
|
-
if (job.scope !== "document") {
|
|
439
|
-
return this.buildErrorResult(job, new Error(`REMOVE_RELATIONSHIP must be in "document" scope, got "${job.scope}"`), startTime);
|
|
440
|
-
}
|
|
441
|
-
const input = action.input;
|
|
442
|
-
if (!input.sourceId || !input.targetId || !input.relationshipType) {
|
|
443
|
-
return this.buildErrorResult(job, new Error("REMOVE_RELATIONSHIP action requires sourceId, targetId, and relationshipType in input"), startTime);
|
|
444
|
-
}
|
|
445
|
-
let sourceDoc;
|
|
446
|
-
try {
|
|
447
|
-
sourceDoc = await this.writeCache.getState(input.sourceId, "document", job.branch);
|
|
448
|
-
}
|
|
449
|
-
catch (error) {
|
|
450
|
-
return this.buildErrorResult(job, new Error(`REMOVE_RELATIONSHIP: source document ${input.sourceId} not found: ${error instanceof Error ? error.message : String(error)}`), startTime);
|
|
451
|
-
}
|
|
452
|
-
const nextIndex = getNextIndexForScope(sourceDoc, job.scope);
|
|
453
|
-
const operation = this.createOperation(action, nextIndex, 0, {
|
|
454
|
-
documentId: input.sourceId,
|
|
455
|
-
scope: job.scope,
|
|
456
|
-
branch: job.branch,
|
|
457
|
-
});
|
|
458
|
-
const writeError = await this.writeOperationToStore(input.sourceId, sourceDoc.header.documentType, job.scope, job.branch, operation, job, startTime);
|
|
459
|
-
if (writeError !== null) {
|
|
460
|
-
return writeError;
|
|
461
|
-
}
|
|
462
|
-
sourceDoc.header.lastModifiedAtUtcIso =
|
|
463
|
-
operation.timestampUtcMs || new Date().toISOString();
|
|
464
|
-
this.updateDocumentRevision(sourceDoc, job.scope, operation.index);
|
|
465
|
-
sourceDoc.operations = {
|
|
466
|
-
...sourceDoc.operations,
|
|
467
|
-
[job.scope]: [...(sourceDoc.operations[job.scope] ?? []), operation],
|
|
468
|
-
};
|
|
469
|
-
const scopeState = sourceDoc.state[job.scope];
|
|
470
|
-
const resultingStateObj = {
|
|
471
|
-
header: structuredClone(sourceDoc.header),
|
|
472
|
-
[job.scope]: scopeState === undefined ? {} : structuredClone(scopeState),
|
|
473
|
-
};
|
|
474
|
-
const resultingState = JSON.stringify(resultingStateObj);
|
|
475
|
-
this.writeCacheState(input.sourceId, job.scope, job.branch, operation.index, sourceDoc);
|
|
476
|
-
indexTxn.write([
|
|
477
|
-
{
|
|
478
|
-
...operation,
|
|
479
|
-
documentId: input.sourceId,
|
|
480
|
-
documentType: sourceDoc.header.documentType,
|
|
481
|
-
branch: job.branch,
|
|
482
|
-
scope: job.scope,
|
|
483
|
-
},
|
|
484
|
-
]);
|
|
485
|
-
// collection membership has to be _after_ the write, as it requires the
|
|
486
|
-
// ordinal of the operation to be set
|
|
487
|
-
if (sourceDoc.header.documentType === "powerhouse/document-drive") {
|
|
488
|
-
const collectionId = driveCollectionId(job.branch, input.sourceId);
|
|
489
|
-
indexTxn.removeFromCollection(collectionId, input.targetId);
|
|
490
|
-
this.collectionMembershipCache.invalidate(input.targetId);
|
|
491
|
-
}
|
|
492
|
-
this.documentMetaCache.putDocumentMeta(input.sourceId, job.branch, {
|
|
493
|
-
state: sourceDoc.state.document,
|
|
494
|
-
documentType: sourceDoc.header.documentType,
|
|
495
|
-
documentScopeRevision: operation.index + 1,
|
|
496
|
-
});
|
|
497
|
-
return this.buildSuccessResult(job, operation, input.sourceId, sourceDoc.header.documentType, resultingState, startTime);
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Execute a regular document action by applying it through the document model reducer.
|
|
501
|
-
* If sourceOperation is provided (for load jobs), its id and timestamp are preserved.
|
|
502
|
-
*/
|
|
503
158
|
async executeRegularAction(job, action, startTime, indexTxn, skip = 0, sourceOperation) {
|
|
504
159
|
let docMeta;
|
|
505
160
|
try {
|
|
506
161
|
docMeta = await this.documentMetaCache.getDocumentMeta(job.documentId, job.branch);
|
|
507
162
|
}
|
|
508
163
|
catch (error) {
|
|
509
|
-
return
|
|
164
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
510
165
|
}
|
|
511
166
|
if (docMeta.state.isDeleted) {
|
|
512
|
-
return
|
|
167
|
+
return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
|
|
513
168
|
}
|
|
514
169
|
let document;
|
|
515
170
|
try {
|
|
516
171
|
document = await this.writeCache.getState(job.documentId, job.scope, job.branch);
|
|
517
172
|
}
|
|
518
173
|
catch (error) {
|
|
519
|
-
return
|
|
174
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
520
175
|
}
|
|
521
176
|
let module;
|
|
522
177
|
try {
|
|
523
|
-
// Use document version to get the correct module
|
|
524
|
-
// Version 0 means not yet upgraded - use latest version
|
|
525
178
|
const moduleVersion = docMeta.state.version === 0 ? undefined : docMeta.state.version;
|
|
526
179
|
module = this.registry.getModule(document.header.documentType, moduleVersion);
|
|
527
180
|
}
|
|
528
181
|
catch (error) {
|
|
529
|
-
return
|
|
182
|
+
return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
|
|
530
183
|
}
|
|
531
184
|
let updatedDocument;
|
|
532
185
|
try {
|
|
@@ -547,12 +200,12 @@ export class SimpleJobExecutor {
|
|
|
547
200
|
if (error instanceof Error && error.stack) {
|
|
548
201
|
enhancedError.stack = `${contextMessage}\n\nOriginal stack trace:\n${error.stack}`;
|
|
549
202
|
}
|
|
550
|
-
return
|
|
203
|
+
return buildErrorResult(job, enhancedError, startTime);
|
|
551
204
|
}
|
|
552
205
|
const scope = job.scope;
|
|
553
206
|
const operations = updatedDocument.operations[scope];
|
|
554
207
|
if (operations.length === 0) {
|
|
555
|
-
return
|
|
208
|
+
return buildErrorResult(job, new Error("No operation generated from action"), startTime);
|
|
556
209
|
}
|
|
557
210
|
const newOperation = operations[operations.length - 1];
|
|
558
211
|
if (!isUndoRedo(action)) {
|
|
@@ -562,9 +215,20 @@ export class SimpleJobExecutor {
|
|
|
562
215
|
...updatedDocument.state,
|
|
563
216
|
header: updatedDocument.header,
|
|
564
217
|
});
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
218
|
+
try {
|
|
219
|
+
await this.operationStore.apply(job.documentId, document.header.documentType, scope, job.branch, newOperation.index, (txn) => {
|
|
220
|
+
txn.addOperations(newOperation);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
this.logger.error("Error writing @Operation to IOperationStore: @Error", newOperation, error);
|
|
225
|
+
this.writeCache.invalidate(job.documentId, scope, job.branch);
|
|
226
|
+
return {
|
|
227
|
+
job,
|
|
228
|
+
success: false,
|
|
229
|
+
error: new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
|
|
230
|
+
duration: Date.now() - startTime,
|
|
231
|
+
};
|
|
568
232
|
}
|
|
569
233
|
updatedDocument.header.revision = {
|
|
570
234
|
...updatedDocument.header.revision,
|
|
@@ -600,20 +264,9 @@ export class SimpleJobExecutor {
|
|
|
600
264
|
duration: Date.now() - startTime,
|
|
601
265
|
};
|
|
602
266
|
}
|
|
603
|
-
createOperation(action, index, skip = 0, context) {
|
|
604
|
-
const id = deriveOperationId(context.documentId, context.scope, context.branch, action.id);
|
|
605
|
-
return {
|
|
606
|
-
id,
|
|
607
|
-
index: index,
|
|
608
|
-
timestampUtcMs: action.timestampUtcMs || new Date().toISOString(),
|
|
609
|
-
hash: "",
|
|
610
|
-
skip: skip,
|
|
611
|
-
action: action,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
267
|
async executeLoadJob(job, startTime, indexTxn) {
|
|
615
268
|
if (job.operations.length === 0) {
|
|
616
|
-
return
|
|
269
|
+
return buildErrorResult(job, new Error("Load job must include at least one operation"), startTime);
|
|
617
270
|
}
|
|
618
271
|
const scope = job.scope;
|
|
619
272
|
let latestRevision = 0;
|
|
@@ -650,10 +303,6 @@ export class SimpleJobExecutor {
|
|
|
650
303
|
catch {
|
|
651
304
|
conflictingOps = [];
|
|
652
305
|
}
|
|
653
|
-
// To properly detect superseded operations, we need to look at ALL operations
|
|
654
|
-
// from the minimum conflicting index onwards, not just the conflicting ones.
|
|
655
|
-
// An operation with an earlier timestamp (not in conflictingOps) might have
|
|
656
|
-
// a skip value that supersedes operations in conflictingOps.
|
|
657
306
|
let allOpsFromMinConflictingIndex = conflictingOps;
|
|
658
307
|
if (conflictingOps.length > 0) {
|
|
659
308
|
const minConflictingIndex = Math.min(...conflictingOps.map((op) => op.index));
|
|
@@ -665,11 +314,6 @@ export class SimpleJobExecutor {
|
|
|
665
314
|
allOpsFromMinConflictingIndex = conflictingOps;
|
|
666
315
|
}
|
|
667
316
|
}
|
|
668
|
-
// Filter out operations that have been superseded by later operations with skip values.
|
|
669
|
-
// An operation at index N is superseded if there exists an operation at index M > N
|
|
670
|
-
// where (M - skip_M) <= N, meaning the later operation's logical index covers N.
|
|
671
|
-
// We check against ALL operations (not just conflicting) to catch superseding ops
|
|
672
|
-
// that have earlier timestamps.
|
|
673
317
|
const nonSupersededOps = conflictingOps.filter((op) => {
|
|
674
318
|
for (const laterOp of allOpsFromMinConflictingIndex) {
|
|
675
319
|
if (laterOp.index > op.index && laterOp.skip > 0) {
|
|
@@ -681,9 +325,7 @@ export class SimpleJobExecutor {
|
|
|
681
325
|
}
|
|
682
326
|
return true;
|
|
683
327
|
});
|
|
684
|
-
// All non-superseded conflicting operations need to be reshuffled
|
|
685
328
|
const existingOpsToReshuffle = nonSupersededOps;
|
|
686
|
-
// Skip count is the number of existing operations that need to be rewound
|
|
687
329
|
const skipCount = existingOpsToReshuffle.length;
|
|
688
330
|
if (skipCount > this.config.maxSkipThreshold) {
|
|
689
331
|
return {
|
|
@@ -694,8 +336,6 @@ export class SimpleJobExecutor {
|
|
|
694
336
|
duration: Date.now() - startTime,
|
|
695
337
|
};
|
|
696
338
|
}
|
|
697
|
-
// Filter out incoming operations that are duplicates (action already exists locally
|
|
698
|
-
// or appears multiple times in incoming)
|
|
699
339
|
const existingActionIds = new Set(nonSupersededOps.map((op) => op.action.id));
|
|
700
340
|
const seenIncomingActionIds = new Set();
|
|
701
341
|
const incomingOpsToApply = job.operations.filter((op) => {
|
|
@@ -713,7 +353,6 @@ export class SimpleJobExecutor {
|
|
|
713
353
|
...operation,
|
|
714
354
|
id: operation.id,
|
|
715
355
|
})));
|
|
716
|
-
// For v2, all NOOPs have skip=1 - consecutive NOOPs are handled during state rebuild
|
|
717
356
|
for (const operation of reshuffledOperations) {
|
|
718
357
|
if (operation.action.type === "NOOP") {
|
|
719
358
|
operation.skip = 1;
|
|
@@ -742,123 +381,6 @@ export class SimpleJobExecutor {
|
|
|
742
381
|
duration: Date.now() - startTime,
|
|
743
382
|
};
|
|
744
383
|
}
|
|
745
|
-
async writeOperationToStore(documentId, documentType, scope, branch, operation, job, startTime) {
|
|
746
|
-
try {
|
|
747
|
-
await this.operationStore.apply(documentId, documentType, scope, branch, operation.index, (txn) => {
|
|
748
|
-
txn.addOperations(operation);
|
|
749
|
-
});
|
|
750
|
-
return null;
|
|
751
|
-
}
|
|
752
|
-
catch (error) {
|
|
753
|
-
this.logger.error("Error writing @Operation to IOperationStore: @Error", operation, error);
|
|
754
|
-
this.writeCache.invalidate(documentId, scope, branch);
|
|
755
|
-
return {
|
|
756
|
-
job,
|
|
757
|
-
success: false,
|
|
758
|
-
error: new Error(`Failed to write operation to IOperationStore: ${error instanceof Error ? error.message : String(error)}`),
|
|
759
|
-
duration: Date.now() - startTime,
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
updateDocumentRevision(document, scope, operationIndex) {
|
|
764
|
-
document.header.revision = {
|
|
765
|
-
...document.header.revision,
|
|
766
|
-
[scope]: operationIndex + 1,
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
writeCacheState(documentId, scope, branch, operationIndex, document) {
|
|
770
|
-
this.writeCache.putState(documentId, scope, branch, operationIndex, document);
|
|
771
|
-
}
|
|
772
|
-
buildSuccessResult(job, operation, documentId, documentType, resultingState, startTime) {
|
|
773
|
-
return {
|
|
774
|
-
job,
|
|
775
|
-
success: true,
|
|
776
|
-
operations: [operation],
|
|
777
|
-
operationsWithContext: [
|
|
778
|
-
{
|
|
779
|
-
operation,
|
|
780
|
-
context: {
|
|
781
|
-
documentId: documentId,
|
|
782
|
-
scope: job.scope,
|
|
783
|
-
branch: job.branch,
|
|
784
|
-
documentType: documentType,
|
|
785
|
-
resultingState,
|
|
786
|
-
ordinal: 0,
|
|
787
|
-
},
|
|
788
|
-
},
|
|
789
|
-
],
|
|
790
|
-
duration: Date.now() - startTime,
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
buildErrorResult(job, error, startTime) {
|
|
794
|
-
return {
|
|
795
|
-
job,
|
|
796
|
-
success: false,
|
|
797
|
-
error: error,
|
|
798
|
-
duration: Date.now() - startTime,
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
async verifyOperationSignatures(job, operations) {
|
|
802
|
-
if (!this.signatureVerifier) {
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
for (let i = 0; i < operations.length; i++) {
|
|
806
|
-
const operation = operations[i];
|
|
807
|
-
const signer = operation.action.context?.signer;
|
|
808
|
-
if (!signer) {
|
|
809
|
-
continue;
|
|
810
|
-
}
|
|
811
|
-
if (signer.signatures.length === 0) {
|
|
812
|
-
throw new InvalidSignatureError(job.documentId, `Operation ${operation.id} at index ${operation.index} has signer but no signatures`);
|
|
813
|
-
}
|
|
814
|
-
const publicKey = signer.app.key;
|
|
815
|
-
let isValid = false;
|
|
816
|
-
try {
|
|
817
|
-
isValid = await this.signatureVerifier(operation, publicKey);
|
|
818
|
-
}
|
|
819
|
-
catch (error) {
|
|
820
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
821
|
-
throw new InvalidSignatureError(job.documentId, `Operation ${operation.id} at index ${operation.index} verification failed: ${errorMessage}`);
|
|
822
|
-
}
|
|
823
|
-
if (!isValid) {
|
|
824
|
-
throw new InvalidSignatureError(job.documentId, `Operation ${operation.id} at index ${operation.index} signature verification returned false`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
async verifyActionSignatures(job, actions) {
|
|
829
|
-
if (!this.signatureVerifier) {
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
for (const action of actions) {
|
|
833
|
-
const signer = action.context?.signer;
|
|
834
|
-
if (!signer) {
|
|
835
|
-
continue;
|
|
836
|
-
}
|
|
837
|
-
if (signer.signatures.length === 0) {
|
|
838
|
-
throw new InvalidSignatureError(job.documentId, `Action ${action.id} has signer but no signatures`);
|
|
839
|
-
}
|
|
840
|
-
const publicKey = signer.app.key;
|
|
841
|
-
let isValid = false;
|
|
842
|
-
try {
|
|
843
|
-
const tempOperation = {
|
|
844
|
-
id: deriveOperationId(job.documentId, action.scope, job.branch, action.id),
|
|
845
|
-
index: 0,
|
|
846
|
-
timestampUtcMs: action.timestampUtcMs || new Date().toISOString(),
|
|
847
|
-
hash: "",
|
|
848
|
-
skip: 0,
|
|
849
|
-
action: action,
|
|
850
|
-
};
|
|
851
|
-
isValid = await this.signatureVerifier(tempOperation, publicKey);
|
|
852
|
-
}
|
|
853
|
-
catch (error) {
|
|
854
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
855
|
-
throw new InvalidSignatureError(job.documentId, `Action ${action.id} verification failed: ${errorMessage}`);
|
|
856
|
-
}
|
|
857
|
-
if (!isValid) {
|
|
858
|
-
throw new InvalidSignatureError(job.documentId, `Action ${action.id} signature verification returned false`);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
384
|
accumulateResultOrReturnError(result, generatedOperations, operationsWithContext) {
|
|
863
385
|
if (!result.success) {
|
|
864
386
|
return result;
|