mindoodb 0.0.6 → 0.0.8
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/index.mjs +63 -0
- package/dist/core/BaseMindooDB.d.ts +64 -1
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +495 -0
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts +1 -1
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +2 -6
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/DocumentDagAnalysis.d.ts +40 -0
- package/dist/core/DocumentDagAnalysis.d.ts.map +1 -0
- package/dist/core/DocumentDagAnalysis.js +401 -0
- package/dist/core/DocumentDagAnalysis.js.map +1 -0
- package/dist/core/types.d.ts +126 -1
- package/dist/core/types.d.ts.map +1 -1
- package/package.json +11 -5
- package/scripts/generate-browser-esm-wrapper.js +36 -0
|
@@ -8,6 +8,7 @@ const automerge_adapter_1 = require("./automerge-adapter");
|
|
|
8
8
|
const uuid_1 = require("uuid");
|
|
9
9
|
const bloom_1 = require("./appendonlystores/bloom");
|
|
10
10
|
const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
|
|
11
|
+
const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
|
|
11
12
|
const AttachmentReadPlanner_1 = require("./appendonlystores/AttachmentReadPlanner");
|
|
12
13
|
const idGeneration_1 = require("./utils/idGeneration");
|
|
13
14
|
const errors_1 = require("./errors");
|
|
@@ -1370,6 +1371,245 @@ class BaseMindooDB {
|
|
|
1370
1371
|
});
|
|
1371
1372
|
return result;
|
|
1372
1373
|
}
|
|
1374
|
+
async analyzeDocumentDagAtTimestamp(docId, timestamp) {
|
|
1375
|
+
const startedAt = Date.now();
|
|
1376
|
+
const resolvedTimestamp = this.resolveDagTimestamp(timestamp);
|
|
1377
|
+
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
1378
|
+
const relevantEntries = allEntryMetadata
|
|
1379
|
+
.filter((entry) => entry.createdAt <= resolvedTimestamp && (0, DocumentDagAnalysis_1.isDagEntry)(entry));
|
|
1380
|
+
const result = (0, DocumentDagAnalysis_1.computeDocumentDagAnalysis)(docId, relevantEntries, resolvedTimestamp);
|
|
1381
|
+
const actorIdByEntryId = await this.decodeAutomergeActorIds(relevantEntries);
|
|
1382
|
+
result.entries = result.entries.map((entry) => ({
|
|
1383
|
+
...entry,
|
|
1384
|
+
automergeActorId: actorIdByEntryId.get(entry.entryId) ?? null,
|
|
1385
|
+
}));
|
|
1386
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1387
|
+
operation: "analyzeDocumentDagAtTimestamp",
|
|
1388
|
+
docId,
|
|
1389
|
+
time: Date.now() - startedAt,
|
|
1390
|
+
scannedEntries: relevantEntries.length,
|
|
1391
|
+
returnedEntries: result.entries.length,
|
|
1392
|
+
bounded: true,
|
|
1393
|
+
});
|
|
1394
|
+
return result;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Decodes Automerge actor ids for replay entries so analysis consumers can color
|
|
1398
|
+
* or group nodes by the logical Automerge actor instead of transport metadata.
|
|
1399
|
+
*/
|
|
1400
|
+
async decodeAutomergeActorIds(relevantEntries) {
|
|
1401
|
+
const result = new Map();
|
|
1402
|
+
const replayEntries = relevantEntries.filter((entry) => entry.entryType !== "doc_snapshot");
|
|
1403
|
+
if (replayEntries.length === 0) {
|
|
1404
|
+
return result;
|
|
1405
|
+
}
|
|
1406
|
+
const loadedEntries = await this.store.getEntries(replayEntries.map((entry) => entry.id));
|
|
1407
|
+
const entryById = new Map(loadedEntries.map((entry) => [entry.id, entry]));
|
|
1408
|
+
for (const metadata of replayEntries) {
|
|
1409
|
+
const entry = entryById.get(metadata.id);
|
|
1410
|
+
if (!entry) {
|
|
1411
|
+
result.set(metadata.id, null);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
1415
|
+
result.set(metadata.id, null);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
const isValid = await this.tenant.verifySignature(entry.encryptedData, entry.signature, entry.createdByPublicKey);
|
|
1419
|
+
if (!isValid) {
|
|
1420
|
+
result.set(metadata.id, null);
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const decryptedPayload = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
|
|
1424
|
+
const decodedAutomergeChange = automerge_adapter_1.Automerge.decodeChange(decryptedPayload);
|
|
1425
|
+
result.set(metadata.id, typeof decodedAutomergeChange.actor === "string" ? decodedAutomergeChange.actor : null);
|
|
1426
|
+
}
|
|
1427
|
+
return result;
|
|
1428
|
+
}
|
|
1429
|
+
async materializeDocumentBranchAtEntry(docId, headEntryId) {
|
|
1430
|
+
const startedAt = Date.now();
|
|
1431
|
+
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
1432
|
+
const plan = (0, DocumentDagAnalysis_1.computeBranchMaterializationPlan)(docId, allEntryMetadata, headEntryId);
|
|
1433
|
+
if (!plan) {
|
|
1434
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1435
|
+
operation: "materializeDocumentBranchAtEntry",
|
|
1436
|
+
docId,
|
|
1437
|
+
time: Date.now() - startedAt,
|
|
1438
|
+
scannedEntries: allEntryMetadata.length,
|
|
1439
|
+
returnedEntries: 0,
|
|
1440
|
+
bounded: true,
|
|
1441
|
+
});
|
|
1442
|
+
return null;
|
|
1443
|
+
}
|
|
1444
|
+
const metadataById = new Map(allEntryMetadata.map((entry) => [entry.id, entry]));
|
|
1445
|
+
const branchEntries = plan.branchEntryIds
|
|
1446
|
+
.map((entryId) => metadataById.get(entryId))
|
|
1447
|
+
.filter((entry) => entry !== undefined);
|
|
1448
|
+
const internalDoc = await this.materializeDocumentFromPlan(docId, allEntryMetadata, branchEntries, plan.snapshotEntryId, plan.entryIdsToApply, plan.headCreatedAt);
|
|
1449
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1450
|
+
operation: "materializeDocumentBranchAtEntry",
|
|
1451
|
+
docId,
|
|
1452
|
+
time: Date.now() - startedAt,
|
|
1453
|
+
scannedEntries: allEntryMetadata.length,
|
|
1454
|
+
returnedEntries: internalDoc ? 1 : 0,
|
|
1455
|
+
bounded: true,
|
|
1456
|
+
});
|
|
1457
|
+
if (!internalDoc) {
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
return {
|
|
1461
|
+
docId,
|
|
1462
|
+
headEntryId: plan.headEntryId,
|
|
1463
|
+
headCreatedAt: plan.headCreatedAt,
|
|
1464
|
+
headCreatedByPublicKey: plan.headCreatedByPublicKey,
|
|
1465
|
+
snapshotEntryId: plan.snapshotEntryId,
|
|
1466
|
+
entryIdsApplied: [...plan.entryIdsToApply],
|
|
1467
|
+
branchEntryIds: [...plan.branchEntryIds],
|
|
1468
|
+
doc: this.wrapDocument(internalDoc),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
async materializeDocumentBranchAtTimestamp(docId, timestamp, headEntryId) {
|
|
1472
|
+
const startedAt = Date.now();
|
|
1473
|
+
const resolvedTimestamp = this.resolveDagTimestamp(timestamp);
|
|
1474
|
+
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
1475
|
+
const relevantEntries = allEntryMetadata
|
|
1476
|
+
.filter((entry) => entry.createdAt <= resolvedTimestamp && (0, DocumentDagAnalysis_1.isDagEntry)(entry));
|
|
1477
|
+
const plan = (0, DocumentDagAnalysis_1.computeBranchMaterializationPlan)(docId, relevantEntries, headEntryId);
|
|
1478
|
+
if (!plan) {
|
|
1479
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1480
|
+
operation: "materializeDocumentBranchAtTimestamp",
|
|
1481
|
+
docId,
|
|
1482
|
+
time: Date.now() - startedAt,
|
|
1483
|
+
scannedEntries: relevantEntries.length,
|
|
1484
|
+
returnedEntries: 0,
|
|
1485
|
+
bounded: true,
|
|
1486
|
+
});
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
const metadataById = new Map(relevantEntries.map((entry) => [entry.id, entry]));
|
|
1490
|
+
const branchEntries = plan.branchEntryIds
|
|
1491
|
+
.map((entryId) => metadataById.get(entryId))
|
|
1492
|
+
.filter((entry) => entry !== undefined);
|
|
1493
|
+
const internalDoc = await this.materializeDocumentFromPlan(docId, relevantEntries, branchEntries, plan.snapshotEntryId, plan.entryIdsToApply, resolvedTimestamp);
|
|
1494
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1495
|
+
operation: "materializeDocumentBranchAtTimestamp",
|
|
1496
|
+
docId,
|
|
1497
|
+
time: Date.now() - startedAt,
|
|
1498
|
+
scannedEntries: relevantEntries.length,
|
|
1499
|
+
returnedEntries: internalDoc ? 1 : 0,
|
|
1500
|
+
bounded: true,
|
|
1501
|
+
});
|
|
1502
|
+
if (!internalDoc) {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
return {
|
|
1506
|
+
docId,
|
|
1507
|
+
headEntryId: plan.headEntryId,
|
|
1508
|
+
headCreatedAt: plan.headCreatedAt,
|
|
1509
|
+
headCreatedByPublicKey: plan.headCreatedByPublicKey,
|
|
1510
|
+
snapshotEntryId: plan.snapshotEntryId,
|
|
1511
|
+
entryIdsApplied: [...plan.entryIdsToApply],
|
|
1512
|
+
branchEntryIds: [...plan.branchEntryIds],
|
|
1513
|
+
doc: this.wrapDocument(internalDoc),
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
async describeDocumentDagEntry(docId, entryId) {
|
|
1517
|
+
const startedAt = Date.now();
|
|
1518
|
+
const metadata = await this.store.getEntryMetadata(entryId);
|
|
1519
|
+
if (!metadata || metadata.docId !== docId || !(0, DocumentDagAnalysis_1.isDagEntry)(metadata)) {
|
|
1520
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1521
|
+
operation: "describeDocumentDagEntry",
|
|
1522
|
+
docId,
|
|
1523
|
+
time: Date.now() - startedAt,
|
|
1524
|
+
scannedEntries: metadata ? 1 : 0,
|
|
1525
|
+
returnedEntries: 0,
|
|
1526
|
+
bounded: true,
|
|
1527
|
+
});
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
let decodedChange = null;
|
|
1531
|
+
if (metadata.entryType !== "doc_snapshot") {
|
|
1532
|
+
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
1533
|
+
const entries = await this.store.getEntries([entryId]);
|
|
1534
|
+
const entry = entries[0];
|
|
1535
|
+
if (entry) {
|
|
1536
|
+
let isValid = false;
|
|
1537
|
+
if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
1538
|
+
this.logger.warn(`Admin-only DB: skipping DAG details for ${entry.id} not signed by admin key`);
|
|
1539
|
+
}
|
|
1540
|
+
else {
|
|
1541
|
+
isValid = await this.tenant.verifySignature(entry.encryptedData, entry.signature, entry.createdByPublicKey);
|
|
1542
|
+
}
|
|
1543
|
+
if (isValid) {
|
|
1544
|
+
const decryptedPayload = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
|
|
1545
|
+
const decodedAutomergeChange = automerge_adapter_1.Automerge.decodeChange(decryptedPayload);
|
|
1546
|
+
decodedChange = this.summarizeDecodedChange(decodedAutomergeChange);
|
|
1547
|
+
decodedChange.touchedPaths = await this.deriveReadableTouchedPaths(docId, metadata, allEntryMetadata, decodedChange.touchedKeys);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(metadata.id);
|
|
1552
|
+
const result = {
|
|
1553
|
+
docId,
|
|
1554
|
+
entryId: metadata.id,
|
|
1555
|
+
entryType: metadata.entryType,
|
|
1556
|
+
createdAt: metadata.createdAt,
|
|
1557
|
+
createdByPublicKey: metadata.createdByPublicKey,
|
|
1558
|
+
dependencyIds: [...metadata.dependencyIds],
|
|
1559
|
+
snapshotHeadEntryIds: [...(metadata.snapshotHeadEntryIds ?? [])],
|
|
1560
|
+
snapshotHeadHashes: [...(metadata.snapshotHeadHashes ?? [])],
|
|
1561
|
+
automergeHash: parsed?.automergeHash ?? null,
|
|
1562
|
+
decodedChange,
|
|
1563
|
+
};
|
|
1564
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
1565
|
+
operation: "describeDocumentDagEntry",
|
|
1566
|
+
docId,
|
|
1567
|
+
time: Date.now() - startedAt,
|
|
1568
|
+
scannedEntries: 1,
|
|
1569
|
+
returnedEntries: 1,
|
|
1570
|
+
bounded: true,
|
|
1571
|
+
});
|
|
1572
|
+
return result;
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Derives human-readable changed field paths for one DAG entry.
|
|
1576
|
+
*
|
|
1577
|
+
* This compares the branch-local document state at the selected entry with each
|
|
1578
|
+
* reachable parent state, then normalizes array indices so the UI can show stable
|
|
1579
|
+
* paths such as `_attachments[].fileName` instead of raw Automerge op keys only.
|
|
1580
|
+
*/
|
|
1581
|
+
async deriveReadableTouchedPaths(docId, entryMetadata, allEntryMetadata, touchedKeys) {
|
|
1582
|
+
if (entryMetadata.entryType === "doc_snapshot") {
|
|
1583
|
+
return [];
|
|
1584
|
+
}
|
|
1585
|
+
const currentDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, entryMetadata.id);
|
|
1586
|
+
const currentData = this.getReadableDiffValueForDoc(currentDoc);
|
|
1587
|
+
const parentEntryIds = entryMetadata.dependencyIds.filter((dependencyId) => allEntryMetadata.some((entry) => entry.id === dependencyId));
|
|
1588
|
+
const diffPaths = new Set();
|
|
1589
|
+
if (parentEntryIds.length === 0) {
|
|
1590
|
+
this.collectReadableDiffPaths(undefined, currentData, "", diffPaths);
|
|
1591
|
+
}
|
|
1592
|
+
else {
|
|
1593
|
+
for (const parentEntryId of parentEntryIds) {
|
|
1594
|
+
const parentDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, parentEntryId);
|
|
1595
|
+
const parentData = this.getReadableDiffValueForDoc(parentDoc);
|
|
1596
|
+
this.collectReadableDiffPaths(parentData, currentData, "", diffPaths);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const normalizedPaths = Array.from(diffPaths)
|
|
1600
|
+
.map((path) => path.replace(/\[\d+\]/g, "[]"))
|
|
1601
|
+
.filter((path, index, allPaths) => path.length > 0 && allPaths.indexOf(path) === index)
|
|
1602
|
+
.sort();
|
|
1603
|
+
if (normalizedPaths.length === 0) {
|
|
1604
|
+
return [];
|
|
1605
|
+
}
|
|
1606
|
+
const touchedKeySet = new Set(touchedKeys);
|
|
1607
|
+
if (touchedKeySet.size === 0) {
|
|
1608
|
+
return normalizedPaths;
|
|
1609
|
+
}
|
|
1610
|
+
const filteredPaths = normalizedPaths.filter((path) => touchedKeySet.has(this.extractLeafPathSegment(path)));
|
|
1611
|
+
return filteredPaths.length > 0 ? filteredPaths : normalizedPaths;
|
|
1612
|
+
}
|
|
1373
1613
|
async getAllDocumentIds() {
|
|
1374
1614
|
// Return all non-deleted document IDs from index
|
|
1375
1615
|
const docIds = [];
|
|
@@ -1988,6 +2228,261 @@ class BaseMindooDB {
|
|
|
1988
2228
|
}
|
|
1989
2229
|
return startIndex;
|
|
1990
2230
|
}
|
|
2231
|
+
resolveDagTimestamp(timestamp) {
|
|
2232
|
+
return timestamp === "now" ? Date.now() : timestamp;
|
|
2233
|
+
}
|
|
2234
|
+
previewChangeValue(value) {
|
|
2235
|
+
if (value === undefined) {
|
|
2236
|
+
return null;
|
|
2237
|
+
}
|
|
2238
|
+
if (typeof value === "string") {
|
|
2239
|
+
return value.length > 80 ? `${value.slice(0, 77)}...` : value;
|
|
2240
|
+
}
|
|
2241
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
2242
|
+
return String(value);
|
|
2243
|
+
}
|
|
2244
|
+
if (value === null) {
|
|
2245
|
+
return "null";
|
|
2246
|
+
}
|
|
2247
|
+
try {
|
|
2248
|
+
const serialized = JSON.stringify(value);
|
|
2249
|
+
if (!serialized) {
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
return serialized.length > 80 ? `${serialized.slice(0, 77)}...` : serialized;
|
|
2253
|
+
}
|
|
2254
|
+
catch {
|
|
2255
|
+
return String(value);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Materializes a single branch-local document state for a specific DAG head.
|
|
2260
|
+
*
|
|
2261
|
+
* This is the internal building block used by DAG inspection helpers when they
|
|
2262
|
+
* need the document exactly as that branch would have looked at `headEntryId`.
|
|
2263
|
+
*/
|
|
2264
|
+
async materializeBranchInternalDoc(docId, allEntryMetadata, headEntryId) {
|
|
2265
|
+
const plan = (0, DocumentDagAnalysis_1.computeBranchMaterializationPlan)(docId, allEntryMetadata, headEntryId);
|
|
2266
|
+
if (!plan) {
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
const metadataById = new Map(allEntryMetadata.map((entry) => [entry.id, entry]));
|
|
2270
|
+
const branchEntries = plan.branchEntryIds
|
|
2271
|
+
.map((entryId) => metadataById.get(entryId))
|
|
2272
|
+
.filter((entry) => entry !== undefined);
|
|
2273
|
+
return this.materializeDocumentFromPlan(docId, allEntryMetadata, branchEntries, plan.snapshotEntryId, plan.entryIdsToApply, plan.headCreatedAt);
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Converts an internal doc into a plain JS value that is safe to diff for UI summaries.
|
|
2277
|
+
*
|
|
2278
|
+
* Deleted or missing docs are normalized to `undefined` so the diff code can treat
|
|
2279
|
+
* creation/deletion as ordinary before/after transitions.
|
|
2280
|
+
*/
|
|
2281
|
+
getReadableDiffValueForDoc(internalDoc) {
|
|
2282
|
+
if (!internalDoc || internalDoc.isDeleted) {
|
|
2283
|
+
return undefined;
|
|
2284
|
+
}
|
|
2285
|
+
return this.wrapDocument(internalDoc).getData();
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Walks two plain JS values and records the changed field paths.
|
|
2289
|
+
*
|
|
2290
|
+
* Arrays are tracked by index first and later normalized to `[]` for display,
|
|
2291
|
+
* which keeps attachment-style changes readable in the DAG explorer.
|
|
2292
|
+
*/
|
|
2293
|
+
collectReadableDiffPaths(beforeValue, afterValue, basePath, results) {
|
|
2294
|
+
if (Object.is(beforeValue, afterValue)) {
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
if (Array.isArray(beforeValue) || Array.isArray(afterValue)) {
|
|
2298
|
+
const beforeArray = Array.isArray(beforeValue) ? beforeValue : [];
|
|
2299
|
+
const afterArray = Array.isArray(afterValue) ? afterValue : [];
|
|
2300
|
+
const maxLength = Math.max(beforeArray.length, afterArray.length);
|
|
2301
|
+
if (maxLength === 0 && basePath) {
|
|
2302
|
+
results.add(basePath);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
for (let index = 0; index < maxLength; index++) {
|
|
2306
|
+
this.collectReadableDiffPaths(beforeArray[index], afterArray[index], `${basePath}[${index}]`, results);
|
|
2307
|
+
}
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (this.isPlainDiffObject(beforeValue) || this.isPlainDiffObject(afterValue)) {
|
|
2311
|
+
const beforeObject = this.isPlainDiffObject(beforeValue) ? beforeValue : {};
|
|
2312
|
+
const afterObject = this.isPlainDiffObject(afterValue) ? afterValue : {};
|
|
2313
|
+
const childKeys = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)]);
|
|
2314
|
+
if (childKeys.size === 0 && basePath) {
|
|
2315
|
+
results.add(basePath);
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
for (const key of childKeys) {
|
|
2319
|
+
const nextPath = basePath ? `${basePath}.${key}` : key;
|
|
2320
|
+
this.collectReadableDiffPaths(beforeObject[key], afterObject[key], nextPath, results);
|
|
2321
|
+
}
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
if (basePath) {
|
|
2325
|
+
results.add(basePath);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Returns true for diffable object literals and false for arrays/binary payloads.
|
|
2330
|
+
*/
|
|
2331
|
+
isPlainDiffObject(value) {
|
|
2332
|
+
return value !== null
|
|
2333
|
+
&& typeof value === "object"
|
|
2334
|
+
&& !Array.isArray(value)
|
|
2335
|
+
&& !(value instanceof Uint8Array);
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* Extracts the terminal field name from a normalized path for loose key/path matching.
|
|
2339
|
+
*/
|
|
2340
|
+
extractLeafPathSegment(path) {
|
|
2341
|
+
const normalizedPath = path.replace(/\[\]/g, "");
|
|
2342
|
+
const segments = normalizedPath.split(".");
|
|
2343
|
+
return segments[segments.length - 1] ?? normalizedPath;
|
|
2344
|
+
}
|
|
2345
|
+
/**
|
|
2346
|
+
* Produces a compact, UI-oriented summary of a decoded Automerge change.
|
|
2347
|
+
*
|
|
2348
|
+
* The raw Automerge ops are reduced to counts, touched keys, and a short list of
|
|
2349
|
+
* previewable operations so Haven can show useful change context without dumping
|
|
2350
|
+
* the full decoded structure.
|
|
2351
|
+
*/
|
|
2352
|
+
summarizeDecodedChange(decodedChange) {
|
|
2353
|
+
const operations = Array.isArray(decodedChange.ops) ? decodedChange.ops : [];
|
|
2354
|
+
const actionCounts = {};
|
|
2355
|
+
const touchedKeys = new Set();
|
|
2356
|
+
const summarizedOperations = operations.slice(0, 12).map((operation) => {
|
|
2357
|
+
const op = operation;
|
|
2358
|
+
const action = typeof op.action === "string" ? op.action : "unknown";
|
|
2359
|
+
actionCounts[action] = (actionCounts[action] ?? 0) + 1;
|
|
2360
|
+
const key = typeof op.key === "string"
|
|
2361
|
+
? op.key
|
|
2362
|
+
: typeof op.elemId === "string"
|
|
2363
|
+
? op.elemId
|
|
2364
|
+
: null;
|
|
2365
|
+
if (typeof op.key === "string") {
|
|
2366
|
+
touchedKeys.add(op.key);
|
|
2367
|
+
}
|
|
2368
|
+
return {
|
|
2369
|
+
action,
|
|
2370
|
+
key,
|
|
2371
|
+
obj: typeof op.obj === "string" ? op.obj : null,
|
|
2372
|
+
insert: typeof op.insert === "boolean" ? op.insert : undefined,
|
|
2373
|
+
valuePreview: this.previewChangeValue(op.value),
|
|
2374
|
+
};
|
|
2375
|
+
});
|
|
2376
|
+
for (const operation of operations.slice(12)) {
|
|
2377
|
+
const op = operation;
|
|
2378
|
+
const action = typeof op.action === "string" ? op.action : "unknown";
|
|
2379
|
+
actionCounts[action] = (actionCounts[action] ?? 0) + 1;
|
|
2380
|
+
if (typeof op.key === "string") {
|
|
2381
|
+
touchedKeys.add(op.key);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
return {
|
|
2385
|
+
actorId: typeof decodedChange.actor === "string" ? decodedChange.actor : null,
|
|
2386
|
+
hash: typeof decodedChange.hash === "string" ? decodedChange.hash : null,
|
|
2387
|
+
seq: typeof decodedChange.seq === "number" ? decodedChange.seq : null,
|
|
2388
|
+
message: typeof decodedChange.message === "string" ? decodedChange.message : null,
|
|
2389
|
+
dependencyHashes: Array.isArray(decodedChange.deps)
|
|
2390
|
+
? decodedChange.deps.filter((dep) => typeof dep === "string")
|
|
2391
|
+
: [],
|
|
2392
|
+
opCount: operations.length,
|
|
2393
|
+
actionCounts,
|
|
2394
|
+
touchedKeys: Array.from(touchedKeys).sort(),
|
|
2395
|
+
touchedPaths: [],
|
|
2396
|
+
operations: summarizedOperations,
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Rebuilds a document from a materialization plan produced by the DAG/history planners.
|
|
2401
|
+
*
|
|
2402
|
+
* The plan may start from a compatible snapshot and then replay incremental changes,
|
|
2403
|
+
* or fall back to a full replay if no usable snapshot is available.
|
|
2404
|
+
*/
|
|
2405
|
+
async materializeDocumentFromPlan(docId, allEntryMetadata, replayEntriesForState, snapshotEntryId, entryIdsToApply, fallbackTimestamp) {
|
|
2406
|
+
if (replayEntriesForState.length === 0 && !snapshotEntryId) {
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
const metadataById = new Map(allEntryMetadata.map((entry) => [entry.id, entry]));
|
|
2410
|
+
let startFromSnapshot = snapshotEntryId !== null;
|
|
2411
|
+
const snapshotMeta = snapshotEntryId ? (metadataById.get(snapshotEntryId) || null) : null;
|
|
2412
|
+
if (startFromSnapshot && !snapshotMeta) {
|
|
2413
|
+
this.logger.warn(`Materialization referenced snapshot ${snapshotEntryId} not found in metadata for ${docId}; falling back to replay without snapshot`);
|
|
2414
|
+
startFromSnapshot = false;
|
|
2415
|
+
}
|
|
2416
|
+
let doc = undefined;
|
|
2417
|
+
if (startFromSnapshot && snapshotMeta) {
|
|
2418
|
+
const snapshotEntries = await this.store.getEntries([snapshotMeta.id]);
|
|
2419
|
+
if (snapshotEntries.length > 0) {
|
|
2420
|
+
const snapshotData = snapshotEntries[0];
|
|
2421
|
+
let isValid = false;
|
|
2422
|
+
if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
2423
|
+
this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
|
|
2424
|
+
}
|
|
2425
|
+
else {
|
|
2426
|
+
isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
|
|
2427
|
+
}
|
|
2428
|
+
if (!isValid) {
|
|
2429
|
+
this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to replay without snapshot`);
|
|
2430
|
+
startFromSnapshot = false;
|
|
2431
|
+
}
|
|
2432
|
+
else {
|
|
2433
|
+
const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
|
|
2434
|
+
doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
|
|
2435
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
|
|
2436
|
+
if (parsed) {
|
|
2437
|
+
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (!doc) {
|
|
2443
|
+
doc = automerge_adapter_1.Automerge.init();
|
|
2444
|
+
}
|
|
2445
|
+
const entriesToApply = entryIdsToApply
|
|
2446
|
+
.map((id) => metadataById.get(id))
|
|
2447
|
+
.filter((entry) => entry !== undefined);
|
|
2448
|
+
const loadedEntries = entriesToApply.length > 0
|
|
2449
|
+
? await this.store.getEntries(entriesToApply.map((entry) => entry.id))
|
|
2450
|
+
: [];
|
|
2451
|
+
const entryById = new Map(loadedEntries.map((entry) => [entry.id, entry]));
|
|
2452
|
+
for (const entryMeta of entriesToApply) {
|
|
2453
|
+
const entryData = entryById.get(entryMeta.id);
|
|
2454
|
+
if (!entryData) {
|
|
2455
|
+
this.logger.warn(`Entry ${entryMeta.id} not found in store, skipping`);
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
2459
|
+
this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
|
|
2463
|
+
if (!isValid) {
|
|
2464
|
+
this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
|
|
2465
|
+
continue;
|
|
2466
|
+
}
|
|
2467
|
+
const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
|
|
2468
|
+
doc = automerge_adapter_1.Automerge.loadIncremental(doc, decryptedPayload);
|
|
2469
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
|
|
2470
|
+
if (parsed) {
|
|
2471
|
+
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
const orderedReplayEntries = [...replayEntriesForState].sort((left, right) => left.createdAt !== right.createdAt ? left.createdAt - right.createdAt : left.id.localeCompare(right.id));
|
|
2475
|
+
const firstReplayEntry = orderedReplayEntries[0] ?? null;
|
|
2476
|
+
const lastReplayEntry = orderedReplayEntries[orderedReplayEntries.length - 1] ?? null;
|
|
2477
|
+
return {
|
|
2478
|
+
id: docId,
|
|
2479
|
+
doc,
|
|
2480
|
+
createdAt: firstReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
|
|
2481
|
+
lastModified: lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
|
|
2482
|
+
decryptionKeyId: firstReplayEntry?.decryptionKeyId ?? snapshotMeta?.decryptionKeyId ?? "default",
|
|
2483
|
+
isDeleted: lastReplayEntry?.entryType === "doc_delete",
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
1991
2486
|
async *iterateChangeMetadataSince(cursor) {
|
|
1992
2487
|
const startedAt = Date.now();
|
|
1993
2488
|
const actualCursor = cursor ?? {
|