@enbox/dwn-sdk-js 0.4.10 → 0.4.12
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/browser.mjs +4 -1
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/src/handlers/records-query.js +9 -11
- package/dist/esm/src/handlers/records-query.js.map +1 -1
- package/dist/esm/src/handlers/records-read.js +15 -6
- package/dist/esm/src/handlers/records-read.js.map +1 -1
- package/dist/esm/src/handlers/records-subscribe.js +8 -11
- package/dist/esm/src/handlers/records-subscribe.js.map +1 -1
- package/dist/esm/src/handlers/records-write.js +6 -12
- package/dist/esm/src/handlers/records-write.js.map +1 -1
- package/dist/esm/src/interfaces/records-write-query.js +3 -0
- package/dist/esm/src/interfaces/records-write-query.js.map +1 -1
- package/dist/esm/src/store/message-store-level.js +104 -3
- package/dist/esm/src/store/message-store-level.js.map +1 -1
- package/dist/esm/src/store/storage-controller.js +44 -33
- package/dist/esm/src/store/storage-controller.js.map +1 -1
- package/dist/esm/src/utils/initial-write-attachment.js +48 -0
- package/dist/esm/src/utils/initial-write-attachment.js.map +1 -0
- package/dist/esm/tests/handlers/records-query.spec.js +82 -0
- package/dist/esm/tests/handlers/records-query.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-read.spec.js +25 -0
- package/dist/esm/tests/handlers/records-read.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-subscribe.spec.js +60 -1
- package/dist/esm/tests/handlers/records-subscribe.spec.js.map +1 -1
- package/dist/esm/tests/store/message-store.spec.js +105 -0
- package/dist/esm/tests/store/message-store.spec.js.map +1 -1
- package/dist/types/src/handlers/records-query.d.ts.map +1 -1
- package/dist/types/src/handlers/records-read.d.ts.map +1 -1
- package/dist/types/src/handlers/records-subscribe.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/interfaces/records-write-query.d.ts.map +1 -1
- package/dist/types/src/store/message-store-level.d.ts +9 -1
- package/dist/types/src/store/message-store-level.d.ts.map +1 -1
- package/dist/types/src/store/storage-controller.d.ts +18 -8
- package/dist/types/src/store/storage-controller.d.ts.map +1 -1
- package/dist/types/src/types/message-store.d.ts +36 -0
- package/dist/types/src/types/message-store.d.ts.map +1 -1
- package/dist/types/src/utils/initial-write-attachment.d.ts +17 -0
- package/dist/types/src/utils/initial-write-attachment.d.ts.map +1 -0
- package/dist/types/tests/handlers/records-query.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-read.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-subscribe.spec.d.ts.map +1 -1
- package/dist/types/tests/store/message-store.spec.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/handlers/records-query.ts +21 -22
- package/src/handlers/records-read.ts +22 -9
- package/src/handlers/records-subscribe.ts +9 -15
- package/src/handlers/records-write.ts +15 -15
- package/src/index.ts +1 -1
- package/src/interfaces/records-write-query.ts +3 -0
- package/src/store/message-store-level.ts +160 -5
- package/src/store/storage-controller.ts +57 -43
- package/src/types/message-store.ts +37 -0
- package/src/utils/initial-write-attachment.ts +64 -0
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
import type { Filter, KeyValues, PaginationCursor, QueryOptions } from '../types/query-types.js';
|
|
14
14
|
import type { GenericMessage, MessageSort, Pagination } from '../types/message-types.js';
|
|
15
15
|
import type { LevelDatabase, LevelWrapperBatchOperation } from './level-wrapper.js';
|
|
16
|
-
import type { MessageStore, MessageStoreOptions, MessageStorePutResult } from '../types/message-store.js';
|
|
16
|
+
import type { MessageStore, MessageStoreLatestStateTransition, MessageStoreOptions, MessageStorePutResult } from '../types/message-store.js';
|
|
17
17
|
|
|
18
18
|
import * as block from 'multiformats/block';
|
|
19
19
|
import * as cbor from '@ipld/dag-cbor';
|
|
@@ -441,6 +441,140 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
441
441
|
});
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
async commitLatestState(
|
|
445
|
+
tenant: string,
|
|
446
|
+
transition: MessageStoreLatestStateTransition,
|
|
447
|
+
options?: MessageStoreOptions
|
|
448
|
+
): Promise<MessageStorePutResult> {
|
|
449
|
+
options?.signal?.throwIfAborted();
|
|
450
|
+
|
|
451
|
+
const partitions = await executeUnlessAborted(this.partitions(), options?.signal);
|
|
452
|
+
const index = await executeUnlessAborted(this.index(), options?.signal);
|
|
453
|
+
|
|
454
|
+
const { message, indexes } = transition.put;
|
|
455
|
+
const encodedMessageBlock = await executeUnlessAborted(block.encode({ value: message, codec: cbor, hasher: sha256 }), options?.signal);
|
|
456
|
+
const messageCid = Cid.parseCid(await Message.getCid(message)).toString();
|
|
457
|
+
|
|
458
|
+
// Validate and encode the retained replacements before entering the write lock.
|
|
459
|
+
const retains: { messageCid: string, message: GenericMessage, indexes: KeyValues, blockBytes: Uint8Array }[] = [];
|
|
460
|
+
for (const retain of transition.retains ?? []) {
|
|
461
|
+
const computedMessageCid = Cid.parseCid(await Message.getCid(retain.message)).toString();
|
|
462
|
+
const normalizedMessageCid = Cid.parseCid(retain.messageCid).toString();
|
|
463
|
+
if (computedMessageCid !== normalizedMessageCid) {
|
|
464
|
+
throw new DwnError(
|
|
465
|
+
DwnErrorCode.MessageStoreUpdateMessageAndIndexesCidMismatch,
|
|
466
|
+
`replacement message CID ${computedMessageCid} does not match target CID ${normalizedMessageCid}`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const encodedRetainBlock = await executeUnlessAborted(block.encode({ value: retain.message, codec: cbor, hasher: sha256 }), options?.signal);
|
|
471
|
+
retains.push({
|
|
472
|
+
messageCid : normalizedMessageCid,
|
|
473
|
+
message : retain.message,
|
|
474
|
+
indexes : retain.indexes,
|
|
475
|
+
blockBytes : encodedRetainBlock.bytes,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const deleteCids = (transition.deletes ?? []).map((cidString): string => CID.parse(cidString).toString());
|
|
479
|
+
|
|
480
|
+
return this.withTenantWriteLock(tenant, async () => {
|
|
481
|
+
options?.signal?.throwIfAborted();
|
|
482
|
+
|
|
483
|
+
const tenantBlocks = await partitions.blocks.partition(tenant);
|
|
484
|
+
const tenantLog = await partitions.log.partition(tenant);
|
|
485
|
+
const tenantCidToSeq = await partitions.cidToSeq.partition(tenant);
|
|
486
|
+
|
|
487
|
+
const operations: LevelWrapperBatchOperation<string>[] = [];
|
|
488
|
+
const fingerprintFolds: { messageCid: string, scopes: string[] }[] = [];
|
|
489
|
+
|
|
490
|
+
// The new message insert. A duplicate inserts nothing but still applies the retains and
|
|
491
|
+
// deletes below, so replaying a transition heals one that previously stopped mid-plan.
|
|
492
|
+
const existingSeq = await tenantCidToSeq.get(messageCid, options);
|
|
493
|
+
const inserted = existingSeq === undefined;
|
|
494
|
+
let seq = 0n;
|
|
495
|
+
if (inserted) {
|
|
496
|
+
const head = await this.getHead(partitions, tenant);
|
|
497
|
+
seq = head + 1n;
|
|
498
|
+
const fingerprintScopes = Replication.computeFingerprintScopes(message, indexes);
|
|
499
|
+
|
|
500
|
+
const logEntry: LogEntryValue = {
|
|
501
|
+
seq: seq.toString(),
|
|
502
|
+
messageCid,
|
|
503
|
+
indexes,
|
|
504
|
+
fingerprintScopes,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
operations.push(
|
|
508
|
+
tenantBlocks.createOperation({ type: 'put', key: messageCid, value: encodedMessageBlock.bytes }) as unknown as LevelWrapperBatchOperation<string>,
|
|
509
|
+
...await index.createPutOperations(tenant, messageCid, indexes),
|
|
510
|
+
tenantLog.createOperation({ type: 'put', key: Replication.encodePositionKey(seq), value: JSON.stringify(logEntry) }),
|
|
511
|
+
tenantCidToSeq.createOperation({ type: 'put', key: messageCid, value: seq.toString() }),
|
|
512
|
+
partitions.heads.createOperation({ type: 'put', key: tenant, value: seq.toString() }),
|
|
513
|
+
);
|
|
514
|
+
fingerprintFolds.push({ messageCid, scopes: fingerprintScopes });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Retained displaced writes: same-CID in-place replacement, exactly as `updateMessageAndIndexes`.
|
|
518
|
+
for (const retain of retains) {
|
|
519
|
+
const { entry, positionKey } = await this.getLogEntryForMutation(
|
|
520
|
+
partitions, tenant, retain.messageCid, DwnErrorCode.MessageStoreUpdateMessageAndIndexesMessageNotFound
|
|
521
|
+
);
|
|
522
|
+
Replication.assertFingerprintScopesUntouched(entry.fingerprintScopes, retain.message, retain.messageCid, retain.indexes);
|
|
523
|
+
|
|
524
|
+
const updatedEntry: LogEntryValue = { ...entry, indexes: retain.indexes };
|
|
525
|
+
operations.push(
|
|
526
|
+
tenantBlocks.createOperation({ type: 'put', key: retain.messageCid, value: retain.blockBytes }) as unknown as LevelWrapperBatchOperation<string>,
|
|
527
|
+
...await index.createDeleteOperations(tenant, retain.messageCid),
|
|
528
|
+
...await index.createPutOperations(tenant, retain.messageCid, retain.indexes),
|
|
529
|
+
tenantLog.createOperation({ type: 'put', key: positionKey, value: JSON.stringify(updatedEntry) }),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Non-retained displaced rows: full removal, exactly as `delete`. Absent rows are no-ops.
|
|
534
|
+
for (const deleteCid of deleteCids) {
|
|
535
|
+
const seqString = await tenantCidToSeq.get(deleteCid, options);
|
|
536
|
+
if (seqString === undefined) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const positionKey = Replication.encodePositionKey(BigInt(seqString));
|
|
541
|
+
const serializedEntry = await tenantLog.get(positionKey);
|
|
542
|
+
if (serializedEntry === undefined) {
|
|
543
|
+
throw new DwnError(
|
|
544
|
+
DwnErrorCode.MessageStoreDeleteLogEntryMissing,
|
|
545
|
+
`cid index for tenant ${tenant} points to missing log entry at seq ${seqString} (CID ${deleteCid})`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const entry = JSON.parse(serializedEntry) as LogEntryValue;
|
|
550
|
+
operations.push(
|
|
551
|
+
tenantBlocks.createOperation({ type: 'del', key: deleteCid }) as unknown as LevelWrapperBatchOperation<string>,
|
|
552
|
+
...await index.createDeleteOperations(tenant, deleteCid),
|
|
553
|
+
tenantLog.createOperation({ type: 'del', key: positionKey }),
|
|
554
|
+
tenantCidToSeq.createOperation({ type: 'del', key: deleteCid }),
|
|
555
|
+
);
|
|
556
|
+
fingerprintFolds.push({ messageCid: deleteCid, scopes: entry.fingerprintScopes });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
operations.push(...await this.createFingerprintFoldOperationsForMessages(partitions, tenant, fingerprintFolds));
|
|
560
|
+
|
|
561
|
+
if (operations.length > 0) {
|
|
562
|
+
await partitions.root.batch(operations);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (!inserted) {
|
|
566
|
+
return { status: 'duplicate' as const };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
this.publishWake(tenant, seq);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
status : 'inserted' as const,
|
|
573
|
+
position : await this.buildToken(tenant, seq, messageCid),
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
444
578
|
async updateIndexes(
|
|
445
579
|
tenant: string,
|
|
446
580
|
messageCid: string,
|
|
@@ -907,12 +1041,33 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
907
1041
|
messageCid: string,
|
|
908
1042
|
scopes: string[],
|
|
909
1043
|
): Promise<LevelWrapperBatchOperation<string>[]> {
|
|
910
|
-
|
|
911
|
-
|
|
1044
|
+
return this.createFingerprintFoldOperationsForMessages(partitions, tenant, [{ messageCid, scopes }]);
|
|
1045
|
+
}
|
|
912
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* Creates the batch operations that fold multiple messages' contributions into their fingerprint
|
|
1049
|
+
* domains. Contributions targeting the same domain are XOR-combined before the single
|
|
1050
|
+
* read-fold-write per domain — concatenating per-message fold operations into one batch would
|
|
1051
|
+
* let the last write to a shared domain silently discard the earlier folds.
|
|
1052
|
+
*/
|
|
1053
|
+
private async createFingerprintFoldOperationsForMessages(
|
|
1054
|
+
partitions: StorePartitions,
|
|
1055
|
+
tenant: string,
|
|
1056
|
+
folds: { messageCid: string, scopes: string[] }[],
|
|
1057
|
+
): Promise<LevelWrapperBatchOperation<string>[]> {
|
|
1058
|
+
const combinedContributionByKey = new Map<string, Uint8Array>();
|
|
1059
|
+
for (const { messageCid, scopes } of folds) {
|
|
1060
|
+
const contribution = await Replication.hashMessageCid(messageCid);
|
|
1061
|
+
for (const scope of scopes) {
|
|
1062
|
+
const key = MessageStoreLevel.fingerprintKey(scope);
|
|
1063
|
+
const combined = combinedContributionByKey.get(key);
|
|
1064
|
+
combinedContributionByKey.set(key, combined === undefined ? contribution : Replication.xorFingerprint(combined, contribution));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const tenantFingerprints = await partitions.fingerprints.partition(tenant);
|
|
913
1069
|
const operations: LevelWrapperBatchOperation<string>[] = [];
|
|
914
|
-
for (const
|
|
915
|
-
const key = MessageStoreLevel.fingerprintKey(scope);
|
|
1070
|
+
for (const [key, contribution] of combinedContributionByKey) {
|
|
916
1071
|
const storedHex = await tenantFingerprints.get(key);
|
|
917
1072
|
const current = storedHex === undefined ? Replication.emptyFingerprint() : Replication.hexToFingerprint(storedHex);
|
|
918
1073
|
const folded = Replication.xorFingerprint(current, contribution);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { DataStore } from '../types/data-store.js';
|
|
2
|
-
import type { Filter } from '../types/query-types.js';
|
|
3
2
|
import type { GenericMessage } from '../types/message-types.js';
|
|
4
|
-
import type { MessageStore } from '../types/message-store.js';
|
|
5
3
|
import type { ProgressToken } from '../types/subscriptions.js';
|
|
4
|
+
import type { Filter, KeyValues } from '../types/query-types.js';
|
|
5
|
+
import type { MessageStore, MessageStorePutResult } from '../types/message-store.js';
|
|
6
6
|
import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js';
|
|
7
7
|
|
|
8
8
|
import { DwnConstant } from '../core/dwn-constant.js';
|
|
@@ -80,7 +80,12 @@ export class StorageController {
|
|
|
80
80
|
// tombstones carry no visibility facts in their descriptors, so the retained write is the
|
|
81
81
|
// durable source of truth for later prune/replay/replication index reconstruction.
|
|
82
82
|
const indexes = recordsDelete.constructIndexes(initialWrite, newestPreDeleteWrite);
|
|
83
|
-
|
|
83
|
+
|
|
84
|
+
// store the tombstone and displace every other message for this record in one atomic commit,
|
|
85
|
+
// retaining the writes needed for replay and future tombstone visibility
|
|
86
|
+
const { position } = await StorageController.commitLatestStateTransition(
|
|
87
|
+
tenant, existingMessages, { message, indexes }, [newestPreDeleteWrite], this.messageStore, this.dataStore
|
|
88
|
+
);
|
|
84
89
|
|
|
85
90
|
if (message.descriptor.prune) {
|
|
86
91
|
// purge/hard-delete all descendant records. Cascade is intentionally protocol-agnostic:
|
|
@@ -92,11 +97,6 @@ export class StorageController {
|
|
|
92
97
|
);
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
// displace every other message for this record, retaining the writes needed for replay and future tombstone visibility
|
|
96
|
-
await StorageController.deleteDisplacedMessagesAndRetainWrites(
|
|
97
|
-
tenant, existingMessages, message, this.messageStore, this.dataStore, [newestPreDeleteWrite]
|
|
98
|
-
);
|
|
99
|
-
|
|
100
100
|
return { position };
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -285,56 +285,70 @@ export class StorageController {
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
/**
|
|
288
|
-
*
|
|
289
|
-
* the initial write and the caller-supplied writes
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
288
|
+
* Stores the new latest message and displaces every other message in `existingMessages` as ONE
|
|
289
|
+
* atomic message-store commit: the initial write and the caller-supplied writes are retained as
|
|
290
|
+
* non-latest state for future replay and tombstone visibility reconstruction, and the remaining
|
|
291
|
+
* displaced messages are deleted. Readers can never observe an intermediate state where both the
|
|
292
|
+
* new message and a displaced message carry latest-state indexes.
|
|
293
|
+
*
|
|
294
|
+
* Displacement is deliberately NOT a timestamp comparison: a RecordsDelete displaces even a
|
|
295
|
+
* newer RecordsWrite (delete-wins convergence), and on resumable-task replay the new message
|
|
296
|
+
* itself is back in `existingMessages` and must survive — so membership is decided by CID.
|
|
297
|
+
*
|
|
298
|
+
* Displaced messages' unreferenced data is deleted from the data store only AFTER the commit:
|
|
299
|
+
* a crash in between leaves at most an orphaned data blob, never a live row whose data is gone.
|
|
294
300
|
*/
|
|
295
|
-
public static async
|
|
301
|
+
public static async commitLatestStateTransition(
|
|
296
302
|
tenant: string,
|
|
297
303
|
existingMessages: GenericMessage[],
|
|
298
|
-
|
|
304
|
+
newMessage: { message: GenericMessage, indexes: KeyValues },
|
|
305
|
+
additionalRetainedRecordsWrites: RecordsWriteMessage[],
|
|
299
306
|
messageStore: MessageStore,
|
|
300
307
|
dataStore: DataStore,
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const retainedMessageCid = await Message.getCid(retainedMessage);
|
|
308
|
+
): Promise<MessageStorePutResult> {
|
|
309
|
+
const newMessageCid = await Message.getCid(newMessage.message);
|
|
304
310
|
const additionalRetainedRecordsWriteCids = new Set<string>();
|
|
305
311
|
for (const retainedRecordsWrite of additionalRetainedRecordsWrites) {
|
|
306
312
|
additionalRetainedRecordsWriteCids.add(await Message.getCid(retainedRecordsWrite));
|
|
307
313
|
}
|
|
308
314
|
|
|
309
315
|
// NOTE: under normal operation, there should only be at most two existing records per `recordId` (initial + a potential subsequent write/delete),
|
|
310
|
-
// but the
|
|
316
|
+
// but the plan-then-commit shape below heals any lingering rows a previous partial application left behind.
|
|
317
|
+
const retains: { messageCid: string, message: GenericMessage, indexes: KeyValues }[] = [];
|
|
318
|
+
const deletes: string[] = [];
|
|
319
|
+
const displacedMessages: GenericMessage[] = [];
|
|
311
320
|
for (const message of existingMessages) {
|
|
312
321
|
const messageCid = await Message.getCid(message);
|
|
313
|
-
const messageIsDisplaced = messageCid !==
|
|
314
|
-
if (messageIsDisplaced) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// delete message from message store
|
|
334
|
-
await messageStore.delete(tenant, messageCid);
|
|
335
|
-
}
|
|
322
|
+
const messageIsDisplaced = messageCid !== newMessageCid;
|
|
323
|
+
if (!messageIsDisplaced) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
displacedMessages.push(message);
|
|
328
|
+
|
|
329
|
+
// Retained writes must stay in the message store and state index so future deletes and
|
|
330
|
+
// replicas can reconstruct tombstone visibility, but they must no longer be latest state.
|
|
331
|
+
const shouldKeepAsNonLatestWrite =
|
|
332
|
+
await RecordsWrite.isInitialWrite(message) ||
|
|
333
|
+
additionalRetainedRecordsWriteCids.has(messageCid);
|
|
334
|
+
if (shouldKeepAsNonLatestWrite) {
|
|
335
|
+
const retainedRecordsWriteMessage = StorageController.stripInlineData(message as RecordsWriteMessage);
|
|
336
|
+
const existingRecordsWrite = await RecordsWrite.parse(retainedRecordsWriteMessage);
|
|
337
|
+
const isLatestBaseState = false;
|
|
338
|
+
const indexes = await existingRecordsWrite.constructIndexes(isLatestBaseState);
|
|
339
|
+
retains.push({ messageCid, message: retainedRecordsWriteMessage, indexes });
|
|
340
|
+
} else {
|
|
341
|
+
deletes.push(messageCid);
|
|
336
342
|
}
|
|
337
343
|
}
|
|
344
|
+
|
|
345
|
+
const putResult = await messageStore.commitLatestState(tenant, { put: newMessage, retains, deletes });
|
|
346
|
+
|
|
347
|
+
for (const message of displacedMessages) {
|
|
348
|
+
await StorageController.deleteFromDataStoreIfNeeded(dataStore, tenant, message, newMessage.message);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return putResult;
|
|
338
352
|
}
|
|
339
353
|
|
|
340
354
|
private static stripInlineData(message: RecordsWriteMessage): RecordsWriteMessage {
|
|
@@ -24,6 +24,28 @@ export type MessageStorePutResult = {
|
|
|
24
24
|
position?: ProgressToken;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A latest-state transition applied by {@link MessageStore.commitLatestState}: the insert of a new
|
|
29
|
+
* message together with the displacement of the messages it supersedes.
|
|
30
|
+
*/
|
|
31
|
+
export type MessageStoreLatestStateTransition = {
|
|
32
|
+
/**
|
|
33
|
+
* The new message to insert, with its insert-time indexes.
|
|
34
|
+
*/
|
|
35
|
+
put: { message: GenericMessage; indexes: KeyValues };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Displaced writes retained as non-latest state: same-CID in-place replacements
|
|
39
|
+
* (same row, same log sequence) applied with the insert.
|
|
40
|
+
*/
|
|
41
|
+
retains?: { messageCid: string; message: GenericMessage; indexes: KeyValues }[];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Message CIDs of displaced messages that are not retained, deleted with the insert.
|
|
45
|
+
*/
|
|
46
|
+
deletes?: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
27
49
|
export interface MessageStore {
|
|
28
50
|
/**
|
|
29
51
|
* opens a connection to the underlying store
|
|
@@ -46,6 +68,21 @@ export interface MessageStore {
|
|
|
46
68
|
options?: MessageStoreOptions
|
|
47
69
|
): Promise<MessageStorePutResult>;
|
|
48
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Atomically inserts a new message and displaces the messages it supersedes: retained writes are
|
|
73
|
+
* replaced in place with their demoted state, and the remaining displaced rows are deleted, all
|
|
74
|
+
* in one commit. Readers can never observe an intermediate state where both the new message and
|
|
75
|
+
* a displaced message carry latest-state indexes.
|
|
76
|
+
*
|
|
77
|
+
* When the new message already exists, returns `duplicate` and still applies the retains and
|
|
78
|
+
* deletes, so replaying a transition heals one that was only partially planned before a crash.
|
|
79
|
+
*/
|
|
80
|
+
commitLatestState(
|
|
81
|
+
tenant: string,
|
|
82
|
+
transition: MessageStoreLatestStateTransition,
|
|
83
|
+
options?: MessageStoreOptions
|
|
84
|
+
): Promise<MessageStorePutResult>;
|
|
85
|
+
|
|
49
86
|
/**
|
|
50
87
|
* Fetches a single message by `cid` from the underlying store.
|
|
51
88
|
* Returns `undefined` no message was found.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { MessageStore } from '../types/message-store.js';
|
|
2
|
+
import type { RecordsQueryReplyEntry } from '../types/records-types.js';
|
|
3
|
+
|
|
4
|
+
import { DwnErrorCode } from '../core/dwn-error.js';
|
|
5
|
+
import { Messages } from './messages.js';
|
|
6
|
+
import { RecordsWrite } from '../interfaces/records-write.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Attaches each returned update's retained initial write, resolved for the whole result page in
|
|
10
|
+
* one batched lookup by the stable identity `entryId === recordId` — unlike the mutable
|
|
11
|
+
* latest-state index, that identity holds at every point of the record's lifecycle.
|
|
12
|
+
*
|
|
13
|
+
* An update whose initial write is genuinely missing (store corruption) is omitted with a
|
|
14
|
+
* warning rather than failing the whole reply.
|
|
15
|
+
*/
|
|
16
|
+
export async function attachInitialWrites(input: {
|
|
17
|
+
messageStore: MessageStore;
|
|
18
|
+
tenant: string;
|
|
19
|
+
recordsWrites: RecordsQueryReplyEntry[];
|
|
20
|
+
operationName: 'RecordsQuery' | 'RecordsSubscribe';
|
|
21
|
+
}): Promise<RecordsQueryReplyEntry[]> {
|
|
22
|
+
const updateRecordIds = new Set<string>();
|
|
23
|
+
const initialWriteState = new Map<RecordsQueryReplyEntry, boolean>();
|
|
24
|
+
for (const recordsWrite of input.recordsWrites) {
|
|
25
|
+
const isInitialWrite = await RecordsWrite.isInitialWrite(recordsWrite);
|
|
26
|
+
initialWriteState.set(recordsWrite, isInitialWrite);
|
|
27
|
+
if (!isInitialWrite) {
|
|
28
|
+
updateRecordIds.add(recordsWrite.recordId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (updateRecordIds.size === 0) {
|
|
33
|
+
return input.recordsWrites;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const initialWriteByRecordId = new Map<string, RecordsQueryReplyEntry>();
|
|
37
|
+
const { messages } = await input.messageStore.query(input.tenant, [{ entryId: [...updateRecordIds] }]);
|
|
38
|
+
for (const message of messages) {
|
|
39
|
+
const initialWrite = message as RecordsQueryReplyEntry;
|
|
40
|
+
initialWriteByRecordId.set(initialWrite.recordId, initialWrite);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const completeRecordsWrites: RecordsQueryReplyEntry[] = [];
|
|
44
|
+
for (const recordsWrite of input.recordsWrites) {
|
|
45
|
+
if (initialWriteState.get(recordsWrite) === true) {
|
|
46
|
+
completeRecordsWrites.push(recordsWrite);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const storedInitialWrite = initialWriteByRecordId.get(recordsWrite.recordId);
|
|
51
|
+
if (storedInitialWrite === undefined) {
|
|
52
|
+
console.warn(
|
|
53
|
+
`${DwnErrorCode.RecordsWriteGetInitialWriteNotFound}: ` +
|
|
54
|
+
`${input.operationName} skipped record ${recordsWrite.recordId}`,
|
|
55
|
+
);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { message: initialWrite } = Messages.detachEncodedData(storedInitialWrite);
|
|
60
|
+
completeRecordsWrites.push({ ...recordsWrite, initialWrite: initialWrite as RecordsQueryReplyEntry });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return completeRecordsWrites;
|
|
64
|
+
}
|