mindoodb 0.0.16 → 0.0.18
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/README.md +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +6 -0
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +95 -3
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/index.mjs +1 -0
- package/dist/core/BaseMindooDB.d.ts +74 -8
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +496 -182
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.js +46 -23
- package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
- package/dist/core/DocumentDagAnalysis.d.ts +8 -0
- package/dist/core/DocumentDagAnalysis.d.ts.map +1 -1
- package/dist/core/DocumentDagAnalysis.js +61 -3
- package/dist/core/DocumentDagAnalysis.js.map +1 -1
- package/dist/core/appendonlystores/MaterializationPlanner.d.ts +1 -1
- package/dist/core/appendonlystores/MaterializationPlanner.js +2 -2
- package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -1
- package/dist/core/types.d.ts +175 -9
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -1
- package/dist/core/types.js.map +1 -1
- package/package.json +1 -1
- package/scripts/regen-custom-id-initial-change.js +56 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BaseMindooDB = void 0;
|
|
3
|
+
exports.BaseMindooDB = exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = void 0;
|
|
4
4
|
// Import platform-appropriate Automerge implementation
|
|
5
5
|
// React Native: native Rust (react-native-automerge-generated)
|
|
6
6
|
// Browser/Node.js: WASM (@automerge/automerge/slim)
|
|
7
7
|
const automerge_adapter_1 = require("./automerge-adapter");
|
|
8
8
|
const uuid_1 = require("uuid");
|
|
9
|
-
const types_1 = require("./
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
const types_2 = require("./appendonlystores/types");
|
|
10
11
|
const bloom_1 = require("./appendonlystores/bloom");
|
|
11
12
|
const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
|
|
12
13
|
const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
|
|
@@ -23,6 +24,50 @@ const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
|
|
|
23
24
|
const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
|
|
24
25
|
const DEFAULT_MAX_CACHED_DOCS = 128;
|
|
25
26
|
const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 0;
|
|
27
|
+
/**
|
|
28
|
+
* Hard-coded initial Automerge change bytes that seed every document created
|
|
29
|
+
* with a caller-provided id (`createDocument({ id })`).
|
|
30
|
+
*
|
|
31
|
+
* Two replicas independently invoking `createDocument({ id: ... })` must
|
|
32
|
+
* produce the same initial Automerge change bytes/hash so the resulting
|
|
33
|
+
* `doc_create` entries converge. We hard-code the bytes (rather than
|
|
34
|
+
* regenerating them at runtime) for two reasons recommended by the Automerge
|
|
35
|
+
* cookbook:
|
|
36
|
+
*
|
|
37
|
+
* 1. Future Automerge releases may change change-byte encoding. Hard-coding
|
|
38
|
+
* pins the wire format we shipped, so old custom-id docs stay mergeable.
|
|
39
|
+
* 2. It prevents accidental schema drift in this file from silently shifting
|
|
40
|
+
* the doc_create entry id of every newly-created custom-id document.
|
|
41
|
+
*
|
|
42
|
+
* To intentionally regenerate these bytes (a breaking change for existing
|
|
43
|
+
* custom-id documents in the field), run
|
|
44
|
+
* `node scripts/regen-custom-id-initial-change.js`
|
|
45
|
+
* paste the new array below, then update the expected hash in
|
|
46
|
+
* `__tests__/CustomIdInitialChange.test.ts`.
|
|
47
|
+
*
|
|
48
|
+
* The encoded change is:
|
|
49
|
+
* actor: 00000000000000c0 (well-known, 8 bytes; only used for this seed)
|
|
50
|
+
* time: 0
|
|
51
|
+
* deps: []
|
|
52
|
+
* ops: set `_attachments` = []
|
|
53
|
+
* hash: b55efb45769e62bff921fd6f4fbb325a446d788ded077ec2a625c32e7631a190
|
|
54
|
+
*/
|
|
55
|
+
/** @internal Exported for tests only; do not import from app code. */
|
|
56
|
+
exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = new Uint8Array([
|
|
57
|
+
133, 111, 74, 131, 181, 94, 251, 69, 1, 47, 0, 8, 0, 0, 0, 0,
|
|
58
|
+
0, 0, 0, 192, 1, 1, 0, 0, 0, 5, 21, 14, 52, 1, 66, 2,
|
|
59
|
+
86, 2, 112, 2, 127, 12, 95, 97, 116, 116, 97, 99, 104, 109, 101, 110,
|
|
60
|
+
116, 115, 1, 127, 2, 127, 0, 127, 0,
|
|
61
|
+
]);
|
|
62
|
+
/**
|
|
63
|
+
* Returns the hard-coded initial Automerge change bytes used to seed every
|
|
64
|
+
* custom-id document. Callers must apply these bytes into a fresh
|
|
65
|
+
* `Automerge.init()` so that subsequent edits use the replica's own actor id
|
|
66
|
+
* while still sharing Automerge ancestry with peers.
|
|
67
|
+
*/
|
|
68
|
+
function getCustomIdInitialChangeBytes() {
|
|
69
|
+
return exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES;
|
|
70
|
+
}
|
|
26
71
|
/**
|
|
27
72
|
* BaseMindooDB is a platform-agnostic implementation of MindooDB
|
|
28
73
|
* that works in both browser and server environments.
|
|
@@ -66,11 +111,11 @@ class BaseMindooDB {
|
|
|
66
111
|
this.cachePrefix = null;
|
|
67
112
|
this.dirtyDocIds = new Set();
|
|
68
113
|
this.cacheMetaDirty = false;
|
|
69
|
-
if (store.getStoreKind() !==
|
|
70
|
-
throw new Error(`[BaseMindooDB] Expected primary store kind ${
|
|
114
|
+
if (store.getStoreKind() !== types_2.StoreKind.docs) {
|
|
115
|
+
throw new Error(`[BaseMindooDB] Expected primary store kind ${types_2.StoreKind.docs} but received ${store.getStoreKind()}`);
|
|
71
116
|
}
|
|
72
|
-
if (attachmentStore.getStoreKind() !==
|
|
73
|
-
throw new Error(`[BaseMindooDB] Expected attachment store kind ${
|
|
117
|
+
if (attachmentStore.getStoreKind() !== types_2.StoreKind.attachments) {
|
|
118
|
+
throw new Error(`[BaseMindooDB] Expected attachment store kind ${types_2.StoreKind.attachments} but received ${attachmentStore.getStoreKind()}`);
|
|
74
119
|
}
|
|
75
120
|
this.tenant = tenant;
|
|
76
121
|
this.store = store;
|
|
@@ -499,6 +544,41 @@ class BaseMindooDB {
|
|
|
499
544
|
getSubtle() {
|
|
500
545
|
return this.tenant.getCryptoAdapter().getSubtle();
|
|
501
546
|
}
|
|
547
|
+
isDocumentReplayEntry(entry) {
|
|
548
|
+
// Only these entry types participate in Automerge replay and document
|
|
549
|
+
// lifecycle state. Snapshots and attachment entries are derived side data.
|
|
550
|
+
return (entry.entryType === "doc_create" ||
|
|
551
|
+
entry.entryType === "doc_change" ||
|
|
552
|
+
entry.entryType === "doc_delete" ||
|
|
553
|
+
entry.entryType === "doc_undelete");
|
|
554
|
+
}
|
|
555
|
+
findActiveReplayHeadEntryIds(replayEntries) {
|
|
556
|
+
const replayIds = new Set(replayEntries.map((entry) => entry.id));
|
|
557
|
+
const referencedIds = new Set();
|
|
558
|
+
// A replay entry is no longer an active head once another replay entry
|
|
559
|
+
// depends on it. Remaining entries are the branch heads visible in metadata.
|
|
560
|
+
for (const entry of replayEntries) {
|
|
561
|
+
for (const depId of entry.dependencyIds) {
|
|
562
|
+
if (replayIds.has(depId)) {
|
|
563
|
+
referencedIds.add(depId);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return replayEntries
|
|
568
|
+
.filter((entry) => !referencedIds.has(entry.id))
|
|
569
|
+
.sort((left, right) => left.createdAt !== right.createdAt
|
|
570
|
+
? left.createdAt - right.createdAt
|
|
571
|
+
: left.id.localeCompare(right.id))
|
|
572
|
+
.map((entry) => entry.id);
|
|
573
|
+
}
|
|
574
|
+
computeIsDeletedFromMetadata(metadata, headEntryIds) {
|
|
575
|
+
// Deletion is intentionally metadata-derived: the latest reachable
|
|
576
|
+
// doc_delete/doc_undelete lifecycle entry wins without materializing the doc.
|
|
577
|
+
const replayEntries = metadata.filter((entry) => this.isDocumentReplayEntry(entry));
|
|
578
|
+
const replayById = new Map(replayEntries.map((entry) => [entry.id, entry]));
|
|
579
|
+
const heads = headEntryIds ?? this.findActiveReplayHeadEntryIds(replayEntries);
|
|
580
|
+
return (0, DocumentDagAnalysis_1.isDeletedFromHeads)(heads, replayById);
|
|
581
|
+
}
|
|
502
582
|
/**
|
|
503
583
|
* Initialize the database instance.
|
|
504
584
|
* If a cache store is available, attempts to restore from cache first,
|
|
@@ -526,6 +606,7 @@ class BaseMindooDB {
|
|
|
526
606
|
.filter((entry) => entry.entryType === "doc_create"
|
|
527
607
|
|| entry.entryType === "doc_change"
|
|
528
608
|
|| entry.entryType === "doc_delete"
|
|
609
|
+
|| entry.entryType === "doc_undelete"
|
|
529
610
|
|| entry.entryType === "doc_snapshot")
|
|
530
611
|
.map((entry) => entry.docId))).sort((left, right) => left.localeCompare(right));
|
|
531
612
|
const missingDocIds = lifecycleDocIds.filter((docId) => !this.indexLookup.has(docId));
|
|
@@ -620,12 +701,13 @@ class BaseMindooDB {
|
|
|
620
701
|
}
|
|
621
702
|
else {
|
|
622
703
|
// Metadata-first startup: avoid full materialization for uncached docs.
|
|
623
|
-
// Index based on doc-lifecycle entries (create/change/delete) plus
|
|
704
|
+
// Index based on doc-lifecycle entries (create/change/delete/undelete) plus
|
|
624
705
|
// doc_snapshot which may be the sole entry after a dense sync.
|
|
625
706
|
// Attachment-only batches should not trigger document index updates.
|
|
626
707
|
const docLifecycleEntries = entryMetadataList.filter((e) => e.entryType === "doc_create" ||
|
|
627
708
|
e.entryType === "doc_change" ||
|
|
628
709
|
e.entryType === "doc_delete" ||
|
|
710
|
+
e.entryType === "doc_undelete" ||
|
|
629
711
|
e.entryType === "doc_snapshot");
|
|
630
712
|
if (docLifecycleEntries.length === 0) {
|
|
631
713
|
this.logger.debug(`Skipping metadata-first index for doc ${docId} — no document lifecycle entries (${entryMetadataList.length} attachment/other entries only)`);
|
|
@@ -639,14 +721,13 @@ class BaseMindooDB {
|
|
|
639
721
|
this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
|
|
640
722
|
}
|
|
641
723
|
else {
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
e.entryType === "doc_delete");
|
|
724
|
+
const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
725
|
+
const mutationEntries = allDocMetadata.filter((e) => this.isDocumentReplayEntry(e));
|
|
645
726
|
// Snapshots compact replay history but should not change the user-visible
|
|
646
|
-
// modification time when the original create/change/delete entries still exist.
|
|
727
|
+
// modification time when the original create/change/delete/undelete entries still exist.
|
|
647
728
|
const entriesForLastModified = mutationEntries.length > 0 ? mutationEntries : docLifecycleEntries;
|
|
648
729
|
const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
|
|
649
|
-
const isDeleted =
|
|
730
|
+
const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
|
|
650
731
|
this.updateIndex(docId, lastModified, isDeleted);
|
|
651
732
|
this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted})`);
|
|
652
733
|
}
|
|
@@ -833,7 +914,7 @@ class BaseMindooDB {
|
|
|
833
914
|
if (options?.transferBatchSize && options.transferBatchSize > 0) {
|
|
834
915
|
return options.transferBatchSize;
|
|
835
916
|
}
|
|
836
|
-
if (options?.storeKind ===
|
|
917
|
+
if (options?.storeKind === types_2.StoreKind.attachments) {
|
|
837
918
|
return 100;
|
|
838
919
|
}
|
|
839
920
|
return options?.pageSize ?? 1000;
|
|
@@ -1053,9 +1134,9 @@ class BaseMindooDB {
|
|
|
1053
1134
|
*
|
|
1054
1135
|
* Algorithm:
|
|
1055
1136
|
* 1. Discover all documents on the source via `doc_create` metadata.
|
|
1056
|
-
* 2. Also fetch `doc_delete` metadata so
|
|
1137
|
+
* 2. Also fetch `doc_delete` and `doc_undelete` metadata so lifecycle markers are transferred.
|
|
1057
1138
|
* 3. Ask the source's batch planner for the optimal replay set per document.
|
|
1058
|
-
* 4. Merge all entry IDs: doc_create + doc_delete + snapshot + uncovered changes.
|
|
1139
|
+
* 4. Merge all entry IDs: doc_create + doc_delete + doc_undelete + snapshot + uncovered changes.
|
|
1059
1140
|
* 5. Filter out IDs the target already has (bloom + exact check).
|
|
1060
1141
|
* 6. Transfer only the missing entries.
|
|
1061
1142
|
*
|
|
@@ -1076,6 +1157,7 @@ class BaseMindooDB {
|
|
|
1076
1157
|
// ── Phase 1: discover documents ──────────────────────────────────
|
|
1077
1158
|
const docCreateEntries = await sourceStore.findEntries("doc_create", null, null);
|
|
1078
1159
|
const docDeleteEntries = await sourceStore.findEntries("doc_delete", null, null);
|
|
1160
|
+
const docUndeleteEntries = await sourceStore.findEntries("doc_undelete", null, null);
|
|
1079
1161
|
const docIds = [...new Set(docCreateEntries.map((e) => e.docId))];
|
|
1080
1162
|
this.logger.info(`Dense sync: found ${docIds.length} documents on source`);
|
|
1081
1163
|
if (docIds.length === 0) {
|
|
@@ -1100,6 +1182,9 @@ class BaseMindooDB {
|
|
|
1100
1182
|
for (const entry of docDeleteEntries) {
|
|
1101
1183
|
neededIds.add(entry.id);
|
|
1102
1184
|
}
|
|
1185
|
+
for (const entry of docUndeleteEntries) {
|
|
1186
|
+
neededIds.add(entry.id);
|
|
1187
|
+
}
|
|
1103
1188
|
for (const plan of batchPlan.plans) {
|
|
1104
1189
|
if (plan.snapshotEntryId) {
|
|
1105
1190
|
neededIds.add(plan.snapshotEntryId);
|
|
@@ -1156,23 +1241,48 @@ class BaseMindooDB {
|
|
|
1156
1241
|
getTenant() {
|
|
1157
1242
|
return this.tenant;
|
|
1158
1243
|
}
|
|
1159
|
-
async createDocument() {
|
|
1160
|
-
return this.
|
|
1244
|
+
async createDocument(options) {
|
|
1245
|
+
return this.createDocumentInternal(options ?? {});
|
|
1161
1246
|
}
|
|
1247
|
+
/**
|
|
1248
|
+
* @deprecated Use `createDocument({ decryptionKeyId })` instead.
|
|
1249
|
+
*/
|
|
1162
1250
|
async createEncryptedDocument(decryptionKeyId) {
|
|
1163
|
-
return this.createDocumentInternal(decryptionKeyId
|
|
1251
|
+
return this.createDocumentInternal({ decryptionKeyId });
|
|
1164
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* @deprecated Use `createDocument({ signingKeyPair, signingKeyPassword, decryptionKeyId })` instead.
|
|
1255
|
+
*/
|
|
1165
1256
|
async createDocumentWithSigningKey(signingKeyPair, signingKeyPassword, decryptionKeyId) {
|
|
1166
|
-
return this.createDocumentInternal(
|
|
1257
|
+
return this.createDocumentInternal({ signingKeyPair, signingKeyPassword, decryptionKeyId });
|
|
1167
1258
|
}
|
|
1168
1259
|
/**
|
|
1169
1260
|
* Internal method to create a new document.
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1261
|
+
*
|
|
1262
|
+
* Handles all four creation flavors expressible through `CreateOptions`:
|
|
1263
|
+
* - generated UUID7 ID, current user signs, default tenant key
|
|
1264
|
+
* - generated UUID7 ID, custom signing key (e.g. directory admin operations)
|
|
1265
|
+
* - caller-provided ID, current user signs (idempotent on existing IDs)
|
|
1266
|
+
* - caller-provided ID with custom signing key
|
|
1267
|
+
*
|
|
1268
|
+
* For caller-provided IDs the initial Automerge change is seeded from a
|
|
1269
|
+
* deterministic, hard-coded change so that two replicas creating the same
|
|
1270
|
+
* custom ID converge when synced (see `getCustomIdInitialChangeBytes`).
|
|
1172
1271
|
*/
|
|
1173
|
-
async createDocumentInternal(
|
|
1174
|
-
const
|
|
1272
|
+
async createDocumentInternal(options) {
|
|
1273
|
+
const { signingKeyPair, signingKeyPassword } = options;
|
|
1274
|
+
if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
|
|
1275
|
+
throw new Error("createDocument: signingKeyPair and signingKeyPassword must be provided together");
|
|
1276
|
+
}
|
|
1175
1277
|
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
1278
|
+
const keyId = options.decryptionKeyId ?? "default";
|
|
1279
|
+
const useCustomDocId = options.id !== undefined;
|
|
1280
|
+
if (useCustomDocId) {
|
|
1281
|
+
if (!types_1.CUSTOM_DOC_ID_REGEX.test(options.id)) {
|
|
1282
|
+
throw new Error(`createDocument: invalid document id "${options.id}". ` +
|
|
1283
|
+
`Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1176
1286
|
// Admin-only validation: only admin key can modify data in admin-only databases
|
|
1177
1287
|
if (this._isAdminOnlyDb) {
|
|
1178
1288
|
const adminPublicKey = this.getAdminPublicKey();
|
|
@@ -1183,32 +1293,70 @@ class BaseMindooDB {
|
|
|
1183
1293
|
throw new Error("Admin-only database: only the admin key can modify data");
|
|
1184
1294
|
}
|
|
1185
1295
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1296
|
+
const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
|
|
1297
|
+
// Idempotent create: when a caller-provided id already exists locally,
|
|
1298
|
+
// return the existing document instead of producing a duplicate doc_create.
|
|
1299
|
+
if (useCustomDocId) {
|
|
1300
|
+
const existing = await this.loadDocumentInternal(docId);
|
|
1301
|
+
if (existing) {
|
|
1302
|
+
if (existing.isDeleted) {
|
|
1303
|
+
this.logger.debug(`Document ${docId} exists as a tombstone; undeleting existing custom-id document`);
|
|
1304
|
+
await this.undeleteDocInternal(docId, signingKeyPair, signingKeyPassword);
|
|
1305
|
+
const undeleted = await this.loadDocumentInternal(docId);
|
|
1306
|
+
if (!undeleted || undeleted.isDeleted) {
|
|
1307
|
+
throw new Error(`Document ${docId} could not be undeleted`);
|
|
1308
|
+
}
|
|
1309
|
+
return this.wrapDocument(undeleted);
|
|
1310
|
+
}
|
|
1311
|
+
this.logger.debug(`Document ${docId} already exists locally; returning existing document`);
|
|
1312
|
+
return this.wrapDocument(existing);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
this.logger.debug(`Creating document ${docId} with key ${keyId}${useCustomSigningKey ? ' using custom signing key' : ''}${useCustomDocId ? ' with caller-provided id' : ''}`);
|
|
1316
|
+
// Build the initial Automerge document and its first change bytes.
|
|
1317
|
+
//
|
|
1318
|
+
// For UUID7 documents we use the historical path (Automerge.init + change).
|
|
1319
|
+
// For custom-ID documents we apply a hard-coded initial change so that
|
|
1320
|
+
// independent replicas using the same id produce the same Automerge hash
|
|
1321
|
+
// and `doc_create` entry id, allowing later changes to merge.
|
|
1192
1322
|
const now = Date.now();
|
|
1193
|
-
this.logger.debug(`Creating initial Automerge change for document ${docId}`);
|
|
1194
1323
|
let newDoc;
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1324
|
+
let changeBytes;
|
|
1325
|
+
if (useCustomDocId) {
|
|
1326
|
+
this.logger.debug(`Seeding custom-id document ${docId} with hard-coded initial Automerge change`);
|
|
1327
|
+
changeBytes = getCustomIdInitialChangeBytes();
|
|
1328
|
+
try {
|
|
1329
|
+
const fresh = automerge_adapter_1.Automerge.init();
|
|
1330
|
+
const [appliedDoc] = automerge_adapter_1.Automerge.applyChanges(fresh, [changeBytes]);
|
|
1331
|
+
newDoc = appliedDoc;
|
|
1332
|
+
this.logger.debug(`Applied hard-coded initial change, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
1333
|
+
}
|
|
1334
|
+
catch (error) {
|
|
1335
|
+
this.logger.error(`Error applying hard-coded initial change for document ${docId}:`, error);
|
|
1336
|
+
throw error;
|
|
1337
|
+
}
|
|
1206
1338
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1339
|
+
else {
|
|
1340
|
+
this.logger.debug(`Creating initial Automerge change for document ${docId}`);
|
|
1341
|
+
const initialDoc = automerge_adapter_1.Automerge.init();
|
|
1342
|
+
try {
|
|
1343
|
+
newDoc = automerge_adapter_1.Automerge.change(initialDoc, (doc) => {
|
|
1344
|
+
// Store metadata in the document payload
|
|
1345
|
+
// We need to modify the document to ensure a change is created
|
|
1346
|
+
doc._attachments = [];
|
|
1347
|
+
});
|
|
1348
|
+
this.logger.debug(`Successfully created Automerge change, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
1349
|
+
}
|
|
1350
|
+
catch (error) {
|
|
1351
|
+
this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
|
|
1352
|
+
throw error;
|
|
1353
|
+
}
|
|
1354
|
+
this.logger.debug(`Getting change bytes from document ${docId}`);
|
|
1355
|
+
const localChange = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
|
|
1356
|
+
if (!localChange) {
|
|
1357
|
+
throw new Error("Failed to get change bytes from Automerge document");
|
|
1358
|
+
}
|
|
1359
|
+
changeBytes = localChange;
|
|
1212
1360
|
}
|
|
1213
1361
|
this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
|
|
1214
1362
|
// Decode the change to get hash and dependencies
|
|
@@ -1314,6 +1462,7 @@ class BaseMindooDB {
|
|
|
1314
1462
|
&& (em.entryType === "doc_create"
|
|
1315
1463
|
|| em.entryType === "doc_change"
|
|
1316
1464
|
|| em.entryType === "doc_delete"
|
|
1465
|
+
|| em.entryType === "doc_undelete"
|
|
1317
1466
|
|| em.entryType === "doc_snapshot"))
|
|
1318
1467
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
1319
1468
|
if (relevantEntries.length === 0) {
|
|
@@ -1394,16 +1543,14 @@ class BaseMindooDB {
|
|
|
1394
1543
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
|
|
1395
1544
|
}
|
|
1396
1545
|
}
|
|
1397
|
-
const replayEntries = relevantEntries.filter((entry) => entry
|
|
1398
|
-
|| entry.entryType === "doc_change"
|
|
1399
|
-
|| entry.entryType === "doc_delete");
|
|
1546
|
+
const replayEntries = relevantEntries.filter((entry) => this.isDocumentReplayEntry(entry));
|
|
1400
1547
|
const firstReplayEntry = replayEntries.length > 0 ? replayEntries[0] : null;
|
|
1401
1548
|
const lastReplayEntry = replayEntries.length > 0 ? replayEntries[replayEntries.length - 1] : null;
|
|
1402
1549
|
const createdAt = firstReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? timestamp;
|
|
1403
1550
|
const decryptionKeyId = firstReplayEntry?.decryptionKeyId
|
|
1404
1551
|
?? snapshotMeta?.decryptionKeyId
|
|
1405
1552
|
?? "default";
|
|
1406
|
-
const isDeleted =
|
|
1553
|
+
const isDeleted = this.computeIsDeletedFromMetadata(relevantEntries);
|
|
1407
1554
|
const lastModified = lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? timestamp;
|
|
1408
1555
|
const internalDoc = {
|
|
1409
1556
|
id: docId,
|
|
@@ -1427,9 +1574,9 @@ class BaseMindooDB {
|
|
|
1427
1574
|
this.logger.debug(`Iterating document history for ${docId}`);
|
|
1428
1575
|
// Get all entry metadata for this document
|
|
1429
1576
|
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
1430
|
-
// Filter to
|
|
1577
|
+
// Filter to document replay entries (exclude snapshots)
|
|
1431
1578
|
const relevantEntries = allEntryMetadata
|
|
1432
|
-
.filter(em =>
|
|
1579
|
+
.filter((em) => this.isDocumentReplayEntry(em))
|
|
1433
1580
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
1434
1581
|
if (relevantEntries.length === 0) {
|
|
1435
1582
|
return; // Document has no history
|
|
@@ -1481,10 +1628,10 @@ class BaseMindooDB {
|
|
|
1481
1628
|
currentDoc = automerge_adapter_1.Automerge.loadIncremental(currentDoc, decryptedPayload);
|
|
1482
1629
|
// Check if document actually changed by comparing heads
|
|
1483
1630
|
// For first entry (doc_create), always yield since it's the initial creation
|
|
1484
|
-
// For
|
|
1631
|
+
// For lifecycle terminal entries, always yield
|
|
1485
1632
|
// For other entries, only yield if heads changed
|
|
1486
1633
|
let shouldYield = false;
|
|
1487
|
-
if (isFirstEntry || entryMetadata.entryType === "doc_delete") {
|
|
1634
|
+
if (isFirstEntry || entryMetadata.entryType === "doc_delete" || entryMetadata.entryType === "doc_undelete") {
|
|
1488
1635
|
shouldYield = true;
|
|
1489
1636
|
}
|
|
1490
1637
|
else {
|
|
@@ -1508,20 +1655,18 @@ class BaseMindooDB {
|
|
|
1508
1655
|
createdAt: createdAt,
|
|
1509
1656
|
lastModified: entryMetadata.createdAt,
|
|
1510
1657
|
decryptionKeyId,
|
|
1511
|
-
isDeleted: entryMetadata.
|
|
1658
|
+
isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((entry) => entry.createdAt < entryMetadata.createdAt ||
|
|
1659
|
+
(entry.createdAt === entryMetadata.createdAt && entry.id <= entryMetadata.id))),
|
|
1512
1660
|
};
|
|
1513
|
-
// Wrap and yield (including
|
|
1661
|
+
// Wrap and yield (including lifecycle terminal entries)
|
|
1514
1662
|
const wrappedDoc = this.wrapDocument(internalDoc);
|
|
1515
1663
|
yield {
|
|
1664
|
+
changeEntryId: entryMetadata.id,
|
|
1516
1665
|
doc: wrappedDoc,
|
|
1517
1666
|
changeCreatedAt: entryMetadata.createdAt,
|
|
1518
1667
|
changeCreatedByPublicKey: entryMetadata.createdByPublicKey,
|
|
1519
1668
|
};
|
|
1520
1669
|
}
|
|
1521
|
-
// Stop after delete entry (document is deleted, no more changes)
|
|
1522
|
-
if (entryMetadata.entryType === "doc_delete") {
|
|
1523
|
-
break;
|
|
1524
|
-
}
|
|
1525
1670
|
}
|
|
1526
1671
|
}
|
|
1527
1672
|
async getDocumentHistoryPage(docId, options) {
|
|
@@ -1534,7 +1679,8 @@ class BaseMindooDB {
|
|
|
1534
1679
|
const relevantEntries = allEntryMetadata
|
|
1535
1680
|
.filter((em) => em.entryType === "doc_create" ||
|
|
1536
1681
|
em.entryType === "doc_change" ||
|
|
1537
|
-
em.entryType === "doc_delete"
|
|
1682
|
+
em.entryType === "doc_delete" ||
|
|
1683
|
+
em.entryType === "doc_undelete")
|
|
1538
1684
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
1539
1685
|
// Cursor paging is offset-based because the result is a bounded timeline view
|
|
1540
1686
|
// over the doc's sorted change metadata, not a resumable store scan cursor.
|
|
@@ -1545,7 +1691,8 @@ class BaseMindooDB {
|
|
|
1545
1691
|
changeCreatedAt: entry.createdAt,
|
|
1546
1692
|
changeCreatedByPublicKey: entry.createdByPublicKey,
|
|
1547
1693
|
dependencyIds: [...entry.dependencyIds],
|
|
1548
|
-
isDeleted: entry.
|
|
1694
|
+
isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((candidate) => candidate.createdAt < entry.createdAt ||
|
|
1695
|
+
(candidate.createdAt === entry.createdAt && candidate.id <= entry.id))),
|
|
1549
1696
|
}));
|
|
1550
1697
|
const nextOffset = offset + entries.length;
|
|
1551
1698
|
const hasMore = nextOffset < relevantEntries.length;
|
|
@@ -1827,63 +1974,65 @@ class BaseMindooDB {
|
|
|
1827
1974
|
// Query up to timestamp + 1 so entries created exactly at `timestamp` are included
|
|
1828
1975
|
// for the strict checks below (createTime < timestamp, deleteTime > timestamp).
|
|
1829
1976
|
const upperBoundExclusive = timestamp === Number.MAX_SAFE_INTEGER ? timestamp : timestamp + 1;
|
|
1830
|
-
//
|
|
1831
|
-
|
|
1977
|
+
// We only need lifecycle metadata for this query. Changes and snapshots can
|
|
1978
|
+
// affect document contents, but create/delete/undelete determine existence.
|
|
1979
|
+
const [creates, deletes, undeletes] = await Promise.all([
|
|
1832
1980
|
this.store.findEntries("doc_create", null, upperBoundExclusive),
|
|
1833
|
-
this.store.findEntries("doc_delete", null, upperBoundExclusive)
|
|
1981
|
+
this.store.findEntries("doc_delete", null, upperBoundExclusive),
|
|
1982
|
+
this.store.findEntries("doc_undelete", null, upperBoundExclusive),
|
|
1834
1983
|
]);
|
|
1835
|
-
|
|
1836
|
-
const
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1984
|
+
const lifecycleEntriesByDocId = new Map();
|
|
1985
|
+
const addLifecycleEntry = (entry) => {
|
|
1986
|
+
// Group entries by document so each document's lifecycle can be evaluated
|
|
1987
|
+
// independently without materializing its Automerge state.
|
|
1988
|
+
const entries = lifecycleEntriesByDocId.get(entry.docId) ?? [];
|
|
1989
|
+
entries.push(entry);
|
|
1990
|
+
lifecycleEntriesByDocId.set(entry.docId, entries);
|
|
1991
|
+
};
|
|
1840
1992
|
for (const entry of creates) {
|
|
1841
|
-
|
|
1842
|
-
if (!existingTime || entry.createdAt < existingTime) {
|
|
1843
|
-
createTimes.set(entry.docId, entry.createdAt);
|
|
1844
|
-
createdDocIds.add(entry.docId);
|
|
1845
|
-
}
|
|
1993
|
+
addLifecycleEntry(entry);
|
|
1846
1994
|
}
|
|
1847
|
-
// Track earliest delete time for each doc
|
|
1848
|
-
const deleteTimes = new Map();
|
|
1849
1995
|
for (const entry of deletes) {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
}
|
|
1996
|
+
addLifecycleEntry(entry);
|
|
1997
|
+
}
|
|
1998
|
+
for (const entry of undeletes) {
|
|
1999
|
+
addLifecycleEntry(entry);
|
|
1855
2000
|
}
|
|
1856
|
-
// Find documents that existed at the timestamp
|
|
1857
2001
|
const docIds = [];
|
|
1858
|
-
for (const docId of
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
//
|
|
1863
|
-
//
|
|
1864
|
-
if (
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2002
|
+
for (const [docId, lifecycleEntries] of lifecycleEntriesByDocId.entries()) {
|
|
2003
|
+
// Sort deterministically so same-timestamp lifecycle entries resolve the
|
|
2004
|
+
// same way on every replica.
|
|
2005
|
+
const ordered = lifecycleEntries.sort((a, b) => a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id.localeCompare(b.id));
|
|
2006
|
+
// A document cannot exist before its create entry, even if malformed or
|
|
2007
|
+
// partial metadata somehow contains later lifecycle entries.
|
|
2008
|
+
if (!ordered.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
// Delete and undelete are terminal lifecycle markers. The latest one at
|
|
2012
|
+
// the timestamp decides whether the document existed then.
|
|
2013
|
+
const latestTerminal = ordered
|
|
2014
|
+
.filter((entry) => entry.entryType === "doc_delete" || entry.entryType === "doc_undelete")
|
|
2015
|
+
.at(-1);
|
|
2016
|
+
if (!latestTerminal || latestTerminal.entryType === "doc_undelete") {
|
|
2017
|
+
docIds.push(docId);
|
|
1868
2018
|
}
|
|
1869
2019
|
}
|
|
1870
2020
|
return docIds;
|
|
1871
2021
|
}
|
|
1872
|
-
async deleteDocument(docId) {
|
|
1873
|
-
return this.deleteDocInternal(docId);
|
|
2022
|
+
async deleteDocument(docId, options = {}) {
|
|
2023
|
+
return this.deleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
|
|
1874
2024
|
}
|
|
1875
2025
|
async deleteDocumentWithSigningKey(docId, signingKeyPair, signingKeyPassword) {
|
|
1876
|
-
return this.
|
|
2026
|
+
return this.deleteDocument(docId, { signingKeyPair, signingKeyPassword });
|
|
1877
2027
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
*/
|
|
1883
|
-
async deleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
|
|
2028
|
+
async undeleteDocument(docId, options = {}) {
|
|
2029
|
+
return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
|
|
2030
|
+
}
|
|
2031
|
+
async assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword) {
|
|
1884
2032
|
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
1885
|
-
|
|
1886
|
-
|
|
2033
|
+
if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
|
|
2034
|
+
throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
|
|
2035
|
+
}
|
|
1887
2036
|
if (this._isAdminOnlyDb) {
|
|
1888
2037
|
const adminPublicKey = this.getAdminPublicKey();
|
|
1889
2038
|
const signerPublicKey = useCustomSigningKey
|
|
@@ -1893,58 +2042,57 @@ class BaseMindooDB {
|
|
|
1893
2042
|
throw new Error("Admin-only database: only the admin key can modify data");
|
|
1894
2043
|
}
|
|
1895
2044
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Write a delete or undelete lifecycle entry for an existing document.
|
|
2048
|
+
*
|
|
2049
|
+
* The document body is preserved; this method only writes a small Automerge
|
|
2050
|
+
* change that advances `_lastModified`, wraps it in the requested lifecycle
|
|
2051
|
+
* StoreEntry type, signs/encrypts it, and updates local caches/indexes.
|
|
2052
|
+
*/
|
|
2053
|
+
async writeLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword) {
|
|
2054
|
+
// Delete and undelete are encoded as normal Automerge changes so the DAG
|
|
2055
|
+
// keeps causal ancestry, while the StoreEntry type carries lifecycle intent.
|
|
2056
|
+
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
1901
2057
|
const now = Date.now();
|
|
1902
|
-
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
}
|
|
1910
|
-
// Mark as deleted for clarity
|
|
1911
|
-
doc._deleted = true;
|
|
2058
|
+
const docId = internalDoc.id;
|
|
2059
|
+
// Lifecycle changes are intentionally non-destructive: they only bump
|
|
2060
|
+
// `_lastModified` and repair missing legacy attachment arrays. The document
|
|
2061
|
+
// body remains available for history and future undeletion.
|
|
2062
|
+
const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, { time: now }, (doc) => {
|
|
2063
|
+
if (!Array.isArray(doc._attachments)) {
|
|
2064
|
+
doc._attachments = [];
|
|
2065
|
+
}
|
|
1912
2066
|
doc._lastModified = now;
|
|
1913
2067
|
});
|
|
1914
|
-
// Get the change bytes from the document
|
|
1915
2068
|
const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
|
|
1916
2069
|
if (!changeBytes) {
|
|
1917
2070
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
1918
2071
|
}
|
|
1919
|
-
// Decode the change to get hash and dependencies
|
|
1920
2072
|
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
1921
2073
|
const automergeHash = decodedChange.hash;
|
|
1922
|
-
const automergeDepHashes = decodedChange.deps || [];
|
|
1923
|
-
// Encrypt the change payload first
|
|
2074
|
+
const automergeDepHashes = decodedChange.deps || [];
|
|
1924
2075
|
const encryptedPayload = await this.tenant.encryptPayload(changeBytes, internalDoc.decryptionKeyId);
|
|
1925
|
-
// Compute content hash from encrypted data
|
|
1926
2076
|
const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
|
|
1927
|
-
// Generate entry ID with blockchain-like chaining
|
|
1928
2077
|
const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, automergeHash, automergeDepHashes, this.getSubtle());
|
|
1929
|
-
//
|
|
2078
|
+
// Convert Automerge dependency hashes back into store entry IDs so metadata
|
|
2079
|
+
// consumers can traverse the lifecycle DAG without decrypting payloads.
|
|
1930
2080
|
const dependencyIds = await this.ensureAutomergeDepsResolved(docId, automergeDepHashes);
|
|
1931
|
-
// Sign the encrypted payload - either with custom key or current user's key
|
|
1932
2081
|
let signature;
|
|
1933
2082
|
let createdByPublicKey;
|
|
1934
2083
|
if (useCustomSigningKey) {
|
|
1935
|
-
this.logger.debug(`Signing deletion for document ${docId} with provided key`);
|
|
1936
2084
|
signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
|
|
1937
2085
|
createdByPublicKey = signingKeyPair.publicKey;
|
|
1938
2086
|
}
|
|
1939
2087
|
else {
|
|
1940
|
-
this.logger.debug(`Signing deletion for document ${docId} with current user's key`);
|
|
1941
2088
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
1942
2089
|
signature = await this.tenant.signPayload(encryptedPayload);
|
|
1943
2090
|
createdByPublicKey = currentUser.userSigningPublicKey;
|
|
1944
2091
|
}
|
|
1945
|
-
//
|
|
2092
|
+
// The encrypted Automerge change is the payload; the metadata classifies it
|
|
2093
|
+
// as a delete or undelete lifecycle entry for fast history/view queries.
|
|
1946
2094
|
const entryMetadata = {
|
|
1947
|
-
entryType
|
|
2095
|
+
entryType,
|
|
1948
2096
|
id: entryId,
|
|
1949
2097
|
contentHash,
|
|
1950
2098
|
docId,
|
|
@@ -1956,32 +2104,127 @@ class BaseMindooDB {
|
|
|
1956
2104
|
originalSize: changeBytes.length,
|
|
1957
2105
|
encryptedSize: encryptedPayload.length,
|
|
1958
2106
|
};
|
|
1959
|
-
// Create full entry object
|
|
1960
2107
|
const fullEntry = {
|
|
1961
2108
|
...entryMetadata,
|
|
1962
2109
|
encryptedData: encryptedPayload,
|
|
1963
2110
|
};
|
|
1964
|
-
// Store entry
|
|
1965
2111
|
await this.store.putEntries([fullEntry]);
|
|
1966
|
-
// Register automerge hash -> entry ID mapping
|
|
1967
2112
|
this.registerAutomergeHashMapping(docId, automergeHash, entryId);
|
|
1968
|
-
//
|
|
1969
|
-
|
|
2113
|
+
// Keep the loaded document, cache, index, and sync dirty tracking in step
|
|
2114
|
+
// with the append-only store entry we just wrote.
|
|
2115
|
+
internalDoc.doc = newDoc;
|
|
2116
|
+
internalDoc.isDeleted = entryType === "doc_delete";
|
|
1970
2117
|
internalDoc.lastModified = now;
|
|
1971
2118
|
this.storeCachedDocument(internalDoc);
|
|
1972
|
-
this.updateIndex(docId, internalDoc.lastModified,
|
|
2119
|
+
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
|
|
1973
2120
|
this.markDocDirty(docId);
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Internal method to delete a document.
|
|
2124
|
+
* Handles both regular deletion (signed by current user) and
|
|
2125
|
+
* deletion with a custom signing key (e.g., for directory operations).
|
|
2126
|
+
*/
|
|
2127
|
+
async deleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
|
|
2128
|
+
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
2129
|
+
this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
|
|
2130
|
+
await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
|
|
2131
|
+
// Get current document
|
|
2132
|
+
const internalDoc = await this.loadDocumentInternal(docId);
|
|
2133
|
+
if (!internalDoc || internalDoc.isDeleted) {
|
|
2134
|
+
throw new Error(`Document ${docId} not found or already deleted`);
|
|
2135
|
+
}
|
|
2136
|
+
await this.writeLifecycleEntry(internalDoc, "doc_delete", signingKeyPair, signingKeyPassword);
|
|
1974
2137
|
this.logger.info(`Document ${docId} deleted successfully`);
|
|
1975
2138
|
}
|
|
1976
|
-
async
|
|
1977
|
-
|
|
2139
|
+
async undeleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
|
|
2140
|
+
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
2141
|
+
this.logger.debug(`Undeleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
|
|
2142
|
+
await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
|
|
2143
|
+
const internalDoc = await this.loadDocumentInternal(docId);
|
|
2144
|
+
if (!internalDoc) {
|
|
2145
|
+
throw new Error(`Document ${docId} not found`);
|
|
2146
|
+
}
|
|
2147
|
+
if (!internalDoc.isDeleted) {
|
|
2148
|
+
this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
await this.writeLifecycleEntry(internalDoc, "doc_undelete", signingKeyPair, signingKeyPassword);
|
|
2152
|
+
this.logger.info(`Document ${docId} undeleted successfully`);
|
|
2153
|
+
}
|
|
2154
|
+
async changeDoc(doc, changeFunc, options = {}) {
|
|
2155
|
+
return this.changeDocInternal(doc, changeFunc, options.signingKeyPair, options.signingKeyPassword);
|
|
1978
2156
|
}
|
|
1979
2157
|
async changeDocWithSigningKey(doc, changeFunc, signingKeyPair, signingKeyPassword) {
|
|
1980
|
-
return this.
|
|
2158
|
+
return this.changeDoc(doc, changeFunc, { signingKeyPair, signingKeyPassword });
|
|
2159
|
+
}
|
|
2160
|
+
async applyTextPatch(doc, patch) {
|
|
2161
|
+
const docId = doc.getId();
|
|
2162
|
+
this.logger.debug(`===== applyTextPatch called for document ${docId} =====`);
|
|
2163
|
+
if (this._isAdminOnlyDb) {
|
|
2164
|
+
const adminPublicKey = this.getAdminPublicKey();
|
|
2165
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
2166
|
+
if (currentUser.userSigningPublicKey !== adminPublicKey) {
|
|
2167
|
+
throw new Error("Admin-only database: only the admin key can modify data");
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
let internalDoc = this.getCachedDocument(docId);
|
|
2171
|
+
if (!internalDoc) {
|
|
2172
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
2173
|
+
if (!loadedDoc) {
|
|
2174
|
+
throw new Error(`Document ${docId} not found`);
|
|
2175
|
+
}
|
|
2176
|
+
internalDoc = loadedDoc;
|
|
2177
|
+
}
|
|
2178
|
+
if (internalDoc.isDeleted) {
|
|
2179
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
2180
|
+
}
|
|
2181
|
+
this.validateTextPatch(patch);
|
|
2182
|
+
const now = Date.now();
|
|
2183
|
+
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
2184
|
+
const baseHeads = patch.baseHeads;
|
|
2185
|
+
const applyEdits = (automergeDoc) => {
|
|
2186
|
+
this.ensureTextPath(automergeDoc, patch.path);
|
|
2187
|
+
for (const edit of patch.edits) {
|
|
2188
|
+
automerge_adapter_1.Automerge.splice(automergeDoc, patch.path, edit.index, edit.deleteCount, edit.insert ?? "");
|
|
2189
|
+
}
|
|
2190
|
+
automergeDoc._lastModified = now;
|
|
2191
|
+
};
|
|
2192
|
+
let newDoc;
|
|
2193
|
+
try {
|
|
2194
|
+
if (baseHeads && baseHeads.length > 0) {
|
|
2195
|
+
const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads, applyEdits);
|
|
2196
|
+
newDoc = result.newDoc;
|
|
2197
|
+
}
|
|
2198
|
+
else {
|
|
2199
|
+
newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyEdits);
|
|
2200
|
+
}
|
|
2201
|
+
this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
2202
|
+
}
|
|
2203
|
+
catch (error) {
|
|
2204
|
+
this.logger.error(`Error applying text patch for document ${docId}:`, error);
|
|
2205
|
+
throw error;
|
|
2206
|
+
}
|
|
2207
|
+
await this.persistDocumentChange({
|
|
2208
|
+
internalDoc,
|
|
2209
|
+
newDoc,
|
|
2210
|
+
now,
|
|
2211
|
+
headsBeforeChange,
|
|
2212
|
+
useCustomKey: false,
|
|
2213
|
+
successMessage: "text patched",
|
|
2214
|
+
});
|
|
2215
|
+
const wrapped = this.wrapDocument(internalDoc);
|
|
2216
|
+
return {
|
|
2217
|
+
doc: wrapped,
|
|
2218
|
+
heads: wrapped.getHeads(),
|
|
2219
|
+
data: wrapped.getData(),
|
|
2220
|
+
};
|
|
1981
2221
|
}
|
|
1982
2222
|
async changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword) {
|
|
1983
2223
|
const docId = doc.getId();
|
|
1984
|
-
|
|
2224
|
+
if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
|
|
2225
|
+
throw new Error("changeDoc: signingKeyPair and signingKeyPassword must be provided together");
|
|
2226
|
+
}
|
|
2227
|
+
const useCustomKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
1985
2228
|
this.logger.debug(`===== ${useCustomKey ? 'changeDocWithSigningKey' : 'changeDoc'} called for document ${docId} =====`);
|
|
1986
2229
|
// Admin-only validation: only admin key can modify data in admin-only databases
|
|
1987
2230
|
if (this._isAdminOnlyDb) {
|
|
@@ -2046,6 +2289,7 @@ class BaseMindooDB {
|
|
|
2046
2289
|
getLastModified: () => internalDoc.lastModified,
|
|
2047
2290
|
getDecryptionKeyId: () => internalDoc.decryptionKeyId,
|
|
2048
2291
|
isDeleted: () => false,
|
|
2292
|
+
getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
|
|
2049
2293
|
getData: () => {
|
|
2050
2294
|
// Return a proxy that collects property assignments and deletions
|
|
2051
2295
|
const currentData = internalDoc.doc;
|
|
@@ -2187,6 +2431,7 @@ class BaseMindooDB {
|
|
|
2187
2431
|
// Deactivate the callback guard - no more changes can be made via collectingDoc
|
|
2188
2432
|
isCallbackActive = false;
|
|
2189
2433
|
// Now apply the collected changes synchronously in Automerge.change()
|
|
2434
|
+
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
2190
2435
|
let newDoc;
|
|
2191
2436
|
try {
|
|
2192
2437
|
newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, (automergeDoc) => {
|
|
@@ -2234,31 +2479,93 @@ class BaseMindooDB {
|
|
|
2234
2479
|
this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
|
|
2235
2480
|
throw error;
|
|
2236
2481
|
}
|
|
2237
|
-
|
|
2482
|
+
await this.persistDocumentChange({
|
|
2483
|
+
internalDoc,
|
|
2484
|
+
newDoc,
|
|
2485
|
+
now,
|
|
2486
|
+
headsBeforeChange,
|
|
2487
|
+
useCustomKey: Boolean(useCustomKey),
|
|
2488
|
+
signingKeyPair,
|
|
2489
|
+
signingKeyPassword,
|
|
2490
|
+
successMessage: useCustomKey ? "changed with custom signing key" : "changed",
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
validateTextPatch(patch) {
|
|
2494
|
+
if (!Array.isArray(patch.path) || patch.path.length === 0) {
|
|
2495
|
+
throw new Error("Text patch path must contain at least one segment");
|
|
2496
|
+
}
|
|
2497
|
+
for (const segment of patch.path) {
|
|
2498
|
+
if (typeof segment !== "string" && typeof segment !== "number") {
|
|
2499
|
+
throw new Error("Text patch path segments must be strings or numbers");
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
if (!Array.isArray(patch.edits) || patch.edits.length === 0) {
|
|
2503
|
+
throw new Error("Text patch must include at least one edit");
|
|
2504
|
+
}
|
|
2505
|
+
for (const edit of patch.edits) {
|
|
2506
|
+
if (!Number.isInteger(edit.index) || edit.index < 0) {
|
|
2507
|
+
throw new Error("Text edit index must be a non-negative integer");
|
|
2508
|
+
}
|
|
2509
|
+
if (!Number.isInteger(edit.deleteCount) || edit.deleteCount < 0) {
|
|
2510
|
+
throw new Error("Text edit deleteCount must be a non-negative integer");
|
|
2511
|
+
}
|
|
2512
|
+
if (edit.insert !== undefined && typeof edit.insert !== "string") {
|
|
2513
|
+
throw new Error("Text edit insert value must be a string when provided");
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
ensureTextPath(automergeDoc, path) {
|
|
2518
|
+
let target = automergeDoc;
|
|
2519
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
2520
|
+
const segment = path[i];
|
|
2521
|
+
const nextSegment = path[i + 1];
|
|
2522
|
+
if (target[segment] === undefined || target[segment] === null) {
|
|
2523
|
+
target[segment] = typeof nextSegment === "number" ? [] : {};
|
|
2524
|
+
}
|
|
2525
|
+
target = target[segment];
|
|
2526
|
+
if (target === null || typeof target !== "object") {
|
|
2527
|
+
throw new Error(`Cannot apply text patch through non-object path segment '${String(segment)}'`);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
const leaf = path[path.length - 1];
|
|
2531
|
+
const currentValue = target[leaf];
|
|
2532
|
+
if (currentValue === undefined || currentValue === null) {
|
|
2533
|
+
target[leaf] = "";
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
if (typeof currentValue !== "string") {
|
|
2537
|
+
throw new Error(`Cannot apply text patch to non-string value at '${path.map(String).join(".")}'`);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
async persistDocumentChange(options) {
|
|
2541
|
+
const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage } = options;
|
|
2542
|
+
const docId = internalDoc.id;
|
|
2238
2543
|
this.logger.debug(`Getting change bytes from document ${docId}`);
|
|
2239
|
-
const
|
|
2544
|
+
const changesSincePreviousHeads = headsBeforeChange
|
|
2545
|
+
? automerge_adapter_1.Automerge.getChangesSince(newDoc, headsBeforeChange)
|
|
2546
|
+
: [];
|
|
2547
|
+
if (changesSincePreviousHeads.length > 1) {
|
|
2548
|
+
throw new Error(`Expected one Automerge change, got ${changesSincePreviousHeads.length}`);
|
|
2549
|
+
}
|
|
2550
|
+
const changeBytes = changesSincePreviousHeads[0] ?? automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
|
|
2240
2551
|
if (!changeBytes) {
|
|
2241
|
-
//TODO decide if we just exit here or throw an error
|
|
2242
2552
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
2243
2553
|
}
|
|
2244
2554
|
this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
|
|
2245
|
-
// Decode the change to get hash and dependencies
|
|
2246
2555
|
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
2247
2556
|
const automergeHash = decodedChange.hash;
|
|
2248
|
-
const automergeDepHashes = decodedChange.deps || [];
|
|
2249
|
-
// Encrypt the change payload first
|
|
2557
|
+
const automergeDepHashes = decodedChange.deps || [];
|
|
2250
2558
|
const encryptedPayload = await this.tenant.encryptPayload(changeBytes, internalDoc.decryptionKeyId);
|
|
2251
|
-
// Compute content hash from encrypted data
|
|
2252
2559
|
const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
|
|
2253
|
-
// Generate entry ID with blockchain-like chaining
|
|
2254
2560
|
const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, automergeHash, automergeDepHashes, this.getSubtle());
|
|
2255
2561
|
this.logger.debug(`Generated entry ID for change: ${entryId}`);
|
|
2256
|
-
// Resolve Automerge dependency hashes to entry IDs
|
|
2257
2562
|
const dependencyIds = await this.ensureAutomergeDepsResolved(docId, automergeDepHashes);
|
|
2258
|
-
// Sign the encrypted payload - use custom key if provided, otherwise current user's key
|
|
2259
2563
|
let signature;
|
|
2260
2564
|
let createdByPublicKey;
|
|
2261
2565
|
if (useCustomKey) {
|
|
2566
|
+
if (!signingKeyPair || !signingKeyPassword) {
|
|
2567
|
+
throw new Error("Custom signing requires both signingKeyPair and signingKeyPassword");
|
|
2568
|
+
}
|
|
2262
2569
|
signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
|
|
2263
2570
|
createdByPublicKey = signingKeyPair.publicKey;
|
|
2264
2571
|
}
|
|
@@ -2267,7 +2574,6 @@ class BaseMindooDB {
|
|
|
2267
2574
|
signature = await this.tenant.signPayload(encryptedPayload);
|
|
2268
2575
|
createdByPublicKey = currentUser.userSigningPublicKey;
|
|
2269
2576
|
}
|
|
2270
|
-
// Create entry metadata
|
|
2271
2577
|
const entryMetadata = {
|
|
2272
2578
|
entryType: "doc_change",
|
|
2273
2579
|
id: entryId,
|
|
@@ -2281,23 +2587,18 @@ class BaseMindooDB {
|
|
|
2281
2587
|
originalSize: changeBytes.length,
|
|
2282
2588
|
encryptedSize: encryptedPayload.length,
|
|
2283
2589
|
};
|
|
2284
|
-
// Create full entry object
|
|
2285
2590
|
const fullEntry = {
|
|
2286
2591
|
...entryMetadata,
|
|
2287
2592
|
encryptedData: encryptedPayload,
|
|
2288
2593
|
};
|
|
2289
|
-
// Store entry
|
|
2290
2594
|
await this.store.putEntries([fullEntry]);
|
|
2291
|
-
// Register automerge hash -> entry ID mapping
|
|
2292
2595
|
this.registerAutomergeHashMapping(docId, automergeHash, entryId);
|
|
2293
|
-
// Update cache and index
|
|
2294
2596
|
internalDoc.doc = newDoc;
|
|
2295
2597
|
internalDoc.lastModified = now;
|
|
2296
2598
|
this.storeCachedDocument(internalDoc);
|
|
2297
2599
|
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
|
|
2298
2600
|
this.markDocDirty(docId);
|
|
2299
|
-
this.logger.info(`Document ${docId} ${
|
|
2300
|
-
// Snapshot creation runs as a best-effort background optimization.
|
|
2601
|
+
this.logger.info(`Document ${docId} ${successMessage} successfully`);
|
|
2301
2602
|
await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
|
|
2302
2603
|
? { signingKeyPair, signingKeyPassword, createdByPublicKey }
|
|
2303
2604
|
: { createdByPublicKey });
|
|
@@ -2311,7 +2612,7 @@ class BaseMindooDB {
|
|
|
2311
2612
|
const docId = internalDoc.id;
|
|
2312
2613
|
try {
|
|
2313
2614
|
const allMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
2314
|
-
const replayEntries = allMetadata.filter((em) =>
|
|
2615
|
+
const replayEntries = allMetadata.filter((em) => this.isDocumentReplayEntry(em));
|
|
2315
2616
|
const snapshots = allMetadata
|
|
2316
2617
|
.filter((em) => em.entryType === "doc_snapshot")
|
|
2317
2618
|
.sort((a, b) => b.createdAt - a.createdAt);
|
|
@@ -2674,7 +2975,7 @@ class BaseMindooDB {
|
|
|
2674
2975
|
createdAt: firstReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
|
|
2675
2976
|
lastModified: lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
|
|
2676
2977
|
decryptionKeyId: firstReplayEntry?.decryptionKeyId ?? snapshotMeta?.decryptionKeyId ?? "default",
|
|
2677
|
-
isDeleted:
|
|
2978
|
+
isDeleted: this.computeIsDeletedFromMetadata(replayEntriesForState),
|
|
2678
2979
|
};
|
|
2679
2980
|
}
|
|
2680
2981
|
async *iterateChangeMetadataSince(cursor) {
|
|
@@ -2809,7 +3110,7 @@ class BaseMindooDB {
|
|
|
2809
3110
|
// Get current document heads to check if it changes
|
|
2810
3111
|
const headsBefore = automerge_adapter_1.Automerge.getHeads(cachedDoc.doc);
|
|
2811
3112
|
// Filter entries to only include change entries (exclude snapshots)
|
|
2812
|
-
const entriesToApply = newEntryMetadata.filter(em =>
|
|
3113
|
+
const entriesToApply = newEntryMetadata.filter((em) => this.isDocumentReplayEntry(em));
|
|
2813
3114
|
if (entriesToApply.length === 0) {
|
|
2814
3115
|
this.logger.debug(`No change entries to apply for document ${docId}`);
|
|
2815
3116
|
return null; // No changes
|
|
@@ -2908,9 +3209,8 @@ class BaseMindooDB {
|
|
|
2908
3209
|
const lastEntryCreatedAt = entries.reduce((maxCreatedAt, entry) => Math.max(maxCreatedAt, entry.createdAt), cachedDoc.lastModified);
|
|
2909
3210
|
const lastModified = payload._lastModified ||
|
|
2910
3211
|
lastEntryCreatedAt;
|
|
2911
|
-
|
|
2912
|
-
const
|
|
2913
|
-
const isDeleted = hasDeleteEntry || cachedDoc.isDeleted;
|
|
3212
|
+
const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
3213
|
+
const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
|
|
2914
3214
|
const updatedInternalDoc = {
|
|
2915
3215
|
id: docId,
|
|
2916
3216
|
doc: updatedDoc,
|
|
@@ -3186,9 +3486,7 @@ class BaseMindooDB {
|
|
|
3186
3486
|
this.logger.debug(`All entries applied successfully for document ${docId}`);
|
|
3187
3487
|
this.logger.debug(`Final document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
|
|
3188
3488
|
const payload = doc;
|
|
3189
|
-
|
|
3190
|
-
const hasDeleteEntry = allEntryMetadata.some(em => em.entryType === "doc_delete");
|
|
3191
|
-
const isDeleted = hasDeleteEntry;
|
|
3489
|
+
const isDeleted = this.computeIsDeletedFromMetadata(allEntryMetadata);
|
|
3192
3490
|
this.logger.debug(`Document ${docId} isDeleted: ${isDeleted}`);
|
|
3193
3491
|
// The authoritative decryptionKeyId comes from the doc_create entry's metadata,
|
|
3194
3492
|
// not from the Automerge payload (which does not store encryption metadata).
|
|
@@ -3307,6 +3605,7 @@ class BaseMindooDB {
|
|
|
3307
3605
|
getLastModified: () => internalDoc.lastModified,
|
|
3308
3606
|
getDecryptionKeyId: () => internalDoc.decryptionKeyId,
|
|
3309
3607
|
isDeleted: () => internalDoc.isDeleted,
|
|
3608
|
+
getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
|
|
3310
3609
|
getData: () => {
|
|
3311
3610
|
// Convert Automerge document to plain JS object, converting Text objects to strings
|
|
3312
3611
|
const jsDoc = this.convertAutomergeToJS(internalDoc.doc);
|
|
@@ -3333,13 +3632,13 @@ class BaseMindooDB {
|
|
|
3333
3632
|
return payload._attachments || [];
|
|
3334
3633
|
},
|
|
3335
3634
|
getAttachment: async (attachmentId) => {
|
|
3336
|
-
return db.
|
|
3635
|
+
return db.getAttachmentForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId));
|
|
3337
3636
|
},
|
|
3338
3637
|
getAttachmentRange: async (attachmentId, startByte, endByte) => {
|
|
3339
|
-
return db.
|
|
3638
|
+
return db.getAttachmentRangeForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId), startByte, endByte);
|
|
3340
3639
|
},
|
|
3341
3640
|
streamAttachment: (attachmentId, startOffset = 0) => {
|
|
3342
|
-
return db.
|
|
3641
|
+
return db.streamAttachmentForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId), startOffset);
|
|
3343
3642
|
},
|
|
3344
3643
|
};
|
|
3345
3644
|
}
|
|
@@ -3351,11 +3650,14 @@ class BaseMindooDB {
|
|
|
3351
3650
|
if (!internalDoc) {
|
|
3352
3651
|
throw new Error(`Document ${docId} not found while reading attachment ${attachmentId}`);
|
|
3353
3652
|
}
|
|
3653
|
+
return this.getAttachmentRefFromDoc(internalDoc, attachmentId);
|
|
3654
|
+
}
|
|
3655
|
+
getAttachmentRefFromDoc(internalDoc, attachmentId) {
|
|
3354
3656
|
const payload = internalDoc.doc;
|
|
3355
3657
|
const attachments = payload._attachments || [];
|
|
3356
3658
|
const ref = attachments.find(a => a.attachmentId === attachmentId);
|
|
3357
3659
|
if (!ref) {
|
|
3358
|
-
throw new Error(`Attachment ${attachmentId} not found in document ${
|
|
3660
|
+
throw new Error(`Attachment ${attachmentId} not found in document ${internalDoc.id}`);
|
|
3359
3661
|
}
|
|
3360
3662
|
return ref;
|
|
3361
3663
|
}
|
|
@@ -3398,10 +3700,13 @@ class BaseMindooDB {
|
|
|
3398
3700
|
async getAttachmentInternal(docId, attachmentId) {
|
|
3399
3701
|
this.logger.debug(`Getting attachment ${attachmentId} from document ${docId}`);
|
|
3400
3702
|
const ref = await this.getAttachmentRefInternal(docId, attachmentId);
|
|
3703
|
+
return await this.getAttachmentForRefInternal(ref);
|
|
3704
|
+
}
|
|
3705
|
+
async getAttachmentForRefInternal(ref) {
|
|
3401
3706
|
const store = this.getEffectiveAttachmentStore();
|
|
3402
3707
|
// Resolve dependency chain to get all chunk IDs in order (oldest first)
|
|
3403
3708
|
const chunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
|
|
3404
|
-
this.logger.debug(`Resolved ${chunkIds.length} chunks for attachment ${attachmentId}`);
|
|
3709
|
+
this.logger.debug(`Resolved ${chunkIds.length} chunks for attachment ${ref.attachmentId}`);
|
|
3405
3710
|
// Fetch all chunks
|
|
3406
3711
|
const chunks = await store.getEntries(chunkIds);
|
|
3407
3712
|
// Verify signatures, decrypt, and collect plaintext chunks
|
|
@@ -3419,7 +3724,7 @@ class BaseMindooDB {
|
|
|
3419
3724
|
result.set(chunk, offset);
|
|
3420
3725
|
offset += chunk.length;
|
|
3421
3726
|
}
|
|
3422
|
-
this.logger.debug(`Retrieved attachment ${attachmentId}: ${result.length} bytes`);
|
|
3727
|
+
this.logger.debug(`Retrieved attachment ${ref.attachmentId}: ${result.length} bytes`);
|
|
3423
3728
|
return result;
|
|
3424
3729
|
}
|
|
3425
3730
|
/**
|
|
@@ -3431,6 +3736,12 @@ class BaseMindooDB {
|
|
|
3431
3736
|
throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
|
|
3432
3737
|
}
|
|
3433
3738
|
const ref = await this.getAttachmentRefInternal(docId, attachmentId);
|
|
3739
|
+
return await this.getAttachmentRangeForRefInternal(ref, startByte, endByte);
|
|
3740
|
+
}
|
|
3741
|
+
async getAttachmentRangeForRefInternal(ref, startByte, endByte) {
|
|
3742
|
+
if (startByte < 0 || endByte <= startByte) {
|
|
3743
|
+
throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
|
|
3744
|
+
}
|
|
3434
3745
|
const store = this.getEffectiveAttachmentStore();
|
|
3435
3746
|
const readPlan = await this.planAttachmentRead(store, ref, startByte, endByte);
|
|
3436
3747
|
const neededChunkIds = readPlan.chunkPlans.map((chunkPlan) => chunkPlan.id);
|
|
@@ -3454,7 +3765,7 @@ class BaseMindooDB {
|
|
|
3454
3765
|
resultOffset += bytesToCopy;
|
|
3455
3766
|
bytesRemaining -= bytesToCopy;
|
|
3456
3767
|
}
|
|
3457
|
-
this.logger.debug(`Retrieved attachment ${attachmentId} range: ${result.length} bytes`);
|
|
3768
|
+
this.logger.debug(`Retrieved attachment ${ref.attachmentId} range: ${result.length} bytes`);
|
|
3458
3769
|
return result;
|
|
3459
3770
|
}
|
|
3460
3771
|
/**
|
|
@@ -3463,6 +3774,9 @@ class BaseMindooDB {
|
|
|
3463
3774
|
async *streamAttachmentInternal(docId, attachmentId, startOffset) {
|
|
3464
3775
|
this.logger.debug(`Streaming attachment ${attachmentId} from offset ${startOffset}`);
|
|
3465
3776
|
const ref = await this.getAttachmentRefInternal(docId, attachmentId);
|
|
3777
|
+
yield* this.streamAttachmentForRefInternal(ref, startOffset);
|
|
3778
|
+
}
|
|
3779
|
+
async *streamAttachmentForRefInternal(ref, startOffset) {
|
|
3466
3780
|
if (startOffset < 0 || startOffset > ref.size) {
|
|
3467
3781
|
throw new Error(`Invalid stream offset ${startOffset} for attachment size ${ref.size}`);
|
|
3468
3782
|
}
|
|
@@ -3492,7 +3806,7 @@ class BaseMindooDB {
|
|
|
3492
3806
|
}
|
|
3493
3807
|
}
|
|
3494
3808
|
}
|
|
3495
|
-
this.logger.debug(`Finished streaming attachment ${attachmentId}`);
|
|
3809
|
+
this.logger.debug(`Finished streaming attachment ${ref.attachmentId}`);
|
|
3496
3810
|
}
|
|
3497
3811
|
/**
|
|
3498
3812
|
* Get the attachment store for this database.
|
|
@@ -3693,12 +4007,12 @@ class BaseMindooDB {
|
|
|
3693
4007
|
* Resolve a sync target to a ContentAddressedStore.
|
|
3694
4008
|
* Accepts either a raw store or a MindooDB instance (calls getStore()).
|
|
3695
4009
|
*/
|
|
3696
|
-
getStoreForKind(storeKind =
|
|
3697
|
-
return storeKind ===
|
|
4010
|
+
getStoreForKind(storeKind = types_2.StoreKind.docs) {
|
|
4011
|
+
return storeKind === types_2.StoreKind.attachments ? this.attachmentStore : this.store;
|
|
3698
4012
|
}
|
|
3699
|
-
resolveStore(remote, storeKind =
|
|
4013
|
+
resolveStore(remote, storeKind = types_2.StoreKind.docs) {
|
|
3700
4014
|
if ('getStore' in remote && typeof remote.getStore === 'function') {
|
|
3701
|
-
return storeKind ===
|
|
4015
|
+
return storeKind === types_2.StoreKind.attachments
|
|
3702
4016
|
? remote.getAttachmentStore()
|
|
3703
4017
|
: remote.getStore();
|
|
3704
4018
|
}
|
|
@@ -3768,7 +4082,7 @@ class BaseMindooDB {
|
|
|
3768
4082
|
* @return A promise that resolves with the sync result
|
|
3769
4083
|
*/
|
|
3770
4084
|
async pullChangesFrom(remote, options) {
|
|
3771
|
-
const storeKind = options?.storeKind ??
|
|
4085
|
+
const storeKind = options?.storeKind ?? types_2.StoreKind.docs;
|
|
3772
4086
|
const localStore = this.getStoreForKind(storeKind);
|
|
3773
4087
|
const remoteStore = this.resolveStore(remote, storeKind);
|
|
3774
4088
|
if (localStore.getId() !== remoteStore.getId() || localStore.getStoreKind() !== remoteStore.getStoreKind()) {
|
|
@@ -3807,7 +4121,7 @@ class BaseMindooDB {
|
|
|
3807
4121
|
cancelled: true,
|
|
3808
4122
|
};
|
|
3809
4123
|
}
|
|
3810
|
-
if (storeKind ===
|
|
4124
|
+
if (storeKind === types_2.StoreKind.docs) {
|
|
3811
4125
|
await this.syncStoreChanges();
|
|
3812
4126
|
}
|
|
3813
4127
|
if (options?.signal?.aborted) {
|
|
@@ -3851,7 +4165,7 @@ class BaseMindooDB {
|
|
|
3851
4165
|
* @return A promise that resolves with the sync result
|
|
3852
4166
|
*/
|
|
3853
4167
|
async pushChangesTo(remote, options) {
|
|
3854
|
-
const storeKind = options?.storeKind ??
|
|
4168
|
+
const storeKind = options?.storeKind ?? types_2.StoreKind.docs;
|
|
3855
4169
|
const localStore = this.getStoreForKind(storeKind);
|
|
3856
4170
|
const remoteStore = this.resolveStore(remote, storeKind);
|
|
3857
4171
|
if (localStore.getId() !== remoteStore.getId() || localStore.getStoreKind() !== remoteStore.getStoreKind()) {
|