@amalgm/shell 0.1.142 → 0.1.144
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/PURPOSE.md +14 -3
- package/dist/detection/runtime.d.ts +6 -0
- package/dist/detection/runtime.js +33 -10
- package/dist/detection/runtime.js.map +1 -1
- package/dist/entity-apply-host.d.ts +3 -0
- package/dist/entity-apply-host.js +275 -11
- package/dist/entity-apply-host.js.map +1 -1
- package/dist/entity-apply-store.js +3 -1
- package/dist/entity-apply-store.js.map +1 -1
- package/dist/git-registration-host.d.ts +2 -1
- package/dist/git-registration-host.js +2 -1
- package/dist/git-registration-host.js.map +1 -1
- package/dist/git-repository-host.js +22 -14
- package/dist/git-repository-host.js.map +1 -1
- package/dist/materialized-graph.js +3 -1
- package/dist/materialized-graph.js.map +1 -1
- package/dist/transfer-instrumentation.d.ts +2 -1
- package/dist/transfer-instrumentation.js +7 -4
- package/dist/transfer-instrumentation.js.map +1 -1
- package/dist/user-ground-host.d.ts +3 -1
- package/dist/user-ground-host.js +130 -14
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.js +1 -1
- package/dist/workspace-add-staging.d.ts +9 -5
- package/dist/workspace-add-staging.js +4 -4
- package/dist/workspace-add-staging.js.map +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Node filesystem, immutable-cache, Git, and Verify effects for Live Apply. */
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
-
import { applyRepositoryIdentity, buildEntityApplyEffects, createUserGroundEnrollmentPolicy, indexRepositoryTerritory, repositoryIdentityHash, } from "@amalgm/live";
|
|
3
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { applyRepositoryIdentity, buildEntityApplyEffects, createUserGroundEnrollmentPolicy, indexRepositoryTerritory, materializedSlotNames, portableFileMode, repositoryIdentityHash, } from "@amalgm/live";
|
|
6
6
|
import Database from "better-sqlite3";
|
|
7
7
|
import { ensurePrivateDir, ensureUserDir } from "./filesystem.js";
|
|
8
8
|
import { applyRepositoryFiles, hasGitMarker, inspectRepositoryTransportFile, markRepositoryReveal, nestedRepositoryPaths, observeRepository, repositoryObservationMatches, repositoryRevealInProgress, } from "./git-repository-host.js";
|
|
@@ -31,6 +31,7 @@ const portable = (row) => ({
|
|
|
31
31
|
version: row.version,
|
|
32
32
|
payloadVersion: row.payloadVersion,
|
|
33
33
|
transportVersion: row.transportVersion,
|
|
34
|
+
fileMode: portableFileMode(row.type, row.filesystemMode),
|
|
34
35
|
});
|
|
35
36
|
function fsyncFile(path) {
|
|
36
37
|
const fd = openSync(path, "r");
|
|
@@ -101,6 +102,14 @@ export class EntityApplyHost {
|
|
|
101
102
|
#rowAtPath;
|
|
102
103
|
#entityAtPath;
|
|
103
104
|
#rowsUnderRoot;
|
|
105
|
+
#notebookUnderRoot;
|
|
106
|
+
#rowsByParent;
|
|
107
|
+
#entitiesByParent;
|
|
108
|
+
#relocateNotebookRow;
|
|
109
|
+
#relocateEntityRow;
|
|
110
|
+
#refreshNotebookCtime;
|
|
111
|
+
#refreshEntityCtime;
|
|
112
|
+
#onLawRelocationStage;
|
|
104
113
|
#prepared = new Map();
|
|
105
114
|
#groundReleases = new Map();
|
|
106
115
|
/** Repository moments one intent has already read, keyed by intent then by
|
|
@@ -127,6 +136,14 @@ export class EntityApplyHost {
|
|
|
127
136
|
this.#rowAtPath = this.#database.prepare(`${groundSelect("detection_notebook")} WHERE absolute_path = ? AND status = 'active' LIMIT 1`);
|
|
128
137
|
this.#entityAtPath = this.#database.prepare(`${groundSelect("entities")} WHERE absolute_path = ? AND status = 'active' LIMIT 1`);
|
|
129
138
|
this.#rowsUnderRoot = this.#database.prepare(`${groundSelect("entities")} WHERE root_uuid = ? ORDER BY relative_path`);
|
|
139
|
+
this.#notebookUnderRoot = this.#database.prepare(`${groundSelect("detection_notebook")} WHERE root_uuid = ? ORDER BY relative_path`);
|
|
140
|
+
this.#rowsByParent = this.#database.prepare(`${groundSelect("detection_notebook")} WHERE parent_uuid = ?`);
|
|
141
|
+
this.#entitiesByParent = this.#database.prepare(`${groundSelect("entities")} WHERE parent_uuid = ?`);
|
|
142
|
+
this.#relocateNotebookRow = this.#database.prepare("UPDATE detection_notebook SET relative_path = ?, absolute_path = ? WHERE uuid = ?");
|
|
143
|
+
this.#relocateEntityRow = this.#database.prepare("UPDATE entities SET relative_path = ?, absolute_path = ? WHERE uuid = ?");
|
|
144
|
+
this.#refreshNotebookCtime = this.#database.prepare("UPDATE detection_notebook SET changed_time_ms = ? WHERE uuid = ?");
|
|
145
|
+
this.#refreshEntityCtime = this.#database.prepare("UPDATE entities SET changed_time_ms = ? WHERE uuid = ?");
|
|
146
|
+
this.#onLawRelocationStage = options.onLawRelocationStage;
|
|
130
147
|
const adapters = [
|
|
131
148
|
"workspace", "folder", "repo.git", "file.text", "file.binary", "link", "reference",
|
|
132
149
|
].map((type) => this.#adapter(type));
|
|
@@ -337,6 +354,8 @@ export class EntityApplyHost {
|
|
|
337
354
|
}
|
|
338
355
|
if (current
|
|
339
356
|
&& exactRecord(portable(current), plan.target)
|
|
357
|
+
&& context.beforeRelocations.length === 0
|
|
358
|
+
&& context.afterRelocations.length === 0
|
|
340
359
|
&& (repositoryTargetMatches
|
|
341
360
|
|| await this.#matches(intent, plan.target, context.targetPath, plan, current))) {
|
|
342
361
|
return { kind: "already-target" };
|
|
@@ -348,6 +367,8 @@ export class EntityApplyHost {
|
|
|
348
367
|
// can advance the row directly. Equal bytes at a different address are
|
|
349
368
|
// deliberately not identity evidence.
|
|
350
369
|
if (current?.absolutePath === context.targetPath
|
|
370
|
+
&& context.beforeRelocations.length === 0
|
|
371
|
+
&& context.afterRelocations.length === 0
|
|
351
372
|
&& await this.#matches(intent, plan.target, context.targetPath, plan)) {
|
|
352
373
|
return { kind: "already-target" };
|
|
353
374
|
}
|
|
@@ -360,12 +381,24 @@ export class EntityApplyHost {
|
|
|
360
381
|
if (await this.#matches(intent, plan.target, context.targetPath, plan)) {
|
|
361
382
|
return { kind: "already-target" };
|
|
362
383
|
}
|
|
384
|
+
if (context.collisionEligible
|
|
385
|
+
&& await this.#lawRelocationsReady(intent, plan, context.beforeRelocations)) {
|
|
386
|
+
if (intent.phase === "declared")
|
|
387
|
+
this.#releaseGround(intent);
|
|
388
|
+
return { kind: "safe" };
|
|
389
|
+
}
|
|
363
390
|
this.#releaseGround(intent);
|
|
364
391
|
return this.#heldDestination(intent, context.targetPath);
|
|
365
392
|
}
|
|
366
393
|
// A move/rename may never erase a different thing already occupying the
|
|
367
394
|
// accepted destination.
|
|
368
395
|
if (current.absolutePath !== context.targetPath && tryLstat(context.targetPath)) {
|
|
396
|
+
if (context.collisionEligible
|
|
397
|
+
&& await this.#lawRelocationsReady(intent, plan, context.beforeRelocations)) {
|
|
398
|
+
if (intent.phase === "declared")
|
|
399
|
+
this.#releaseGround(intent);
|
|
400
|
+
return { kind: "safe" };
|
|
401
|
+
}
|
|
369
402
|
this.#releaseGround(intent);
|
|
370
403
|
return this.#heldDestination(intent, context.targetPath);
|
|
371
404
|
}
|
|
@@ -419,8 +452,10 @@ export class EntityApplyHost {
|
|
|
419
452
|
try {
|
|
420
453
|
const context = this.#context(plan.target);
|
|
421
454
|
const prepared = await this.#ensurePrepared(intent, plan);
|
|
455
|
+
this.#relocateLawSiblings(context, context.beforeRelocations);
|
|
422
456
|
if (plan.kind === "lifecycle") {
|
|
423
457
|
this.#removeWithRecovery(intent, context);
|
|
458
|
+
this.#relocateAfterTarget(plan.target, context);
|
|
424
459
|
return;
|
|
425
460
|
}
|
|
426
461
|
if (plan.kind === "repo.git") {
|
|
@@ -434,6 +469,7 @@ export class EntityApplyHost {
|
|
|
434
469
|
markRepositoryReveal(context.current.absolutePath);
|
|
435
470
|
}
|
|
436
471
|
if (context.current && context.current.absolutePath !== context.targetPath
|
|
472
|
+
&& !context.currentShared
|
|
437
473
|
&& tryLstat(context.current.absolutePath)) {
|
|
438
474
|
this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
|
|
439
475
|
}
|
|
@@ -443,10 +479,12 @@ export class EntityApplyHost {
|
|
|
443
479
|
// ground, the same moment, no second capture.
|
|
444
480
|
this.#observe(intent, context.targetPath, applied.identity, { stateId: applied.stateId, card: applied.card });
|
|
445
481
|
this.#moveRepositoryRelocations(intent, context, repository.identity);
|
|
482
|
+
this.#relocateAfterTarget(plan.target, context);
|
|
446
483
|
return;
|
|
447
484
|
}
|
|
448
485
|
if (plan.kind === "structure") {
|
|
449
|
-
if (context.current && context.current.absolutePath !== context.targetPath
|
|
486
|
+
if (context.current && context.current.absolutePath !== context.targetPath
|
|
487
|
+
&& !context.currentShared) {
|
|
450
488
|
this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
|
|
451
489
|
}
|
|
452
490
|
else if (!tryLstat(context.targetPath)) {
|
|
@@ -464,6 +502,14 @@ export class EntityApplyHost {
|
|
|
464
502
|
&& hasGitMarker(context.targetPath)) {
|
|
465
503
|
rmSync(join(context.targetPath, ".git"), { recursive: true, force: true });
|
|
466
504
|
}
|
|
505
|
+
if (plan.target.type === "file.text" || plan.target.type === "file.binary") {
|
|
506
|
+
if (plan.target.fileMode === null) {
|
|
507
|
+
throw new Error(`ordinary file ${plan.target.uuid} has no portable mode`);
|
|
508
|
+
}
|
|
509
|
+
chmodSync(context.targetPath, plan.target.fileMode);
|
|
510
|
+
fsyncFile(context.targetPath);
|
|
511
|
+
}
|
|
512
|
+
this.#relocateAfterTarget(plan.target, context);
|
|
467
513
|
return;
|
|
468
514
|
}
|
|
469
515
|
// Identity owns the address transition. A content or link result may
|
|
@@ -472,21 +518,25 @@ export class EntityApplyHost {
|
|
|
472
518
|
// installing at the new address would leave the old address behind for
|
|
473
519
|
// Detect to misidentify as a new local entity and echo back to cloud.
|
|
474
520
|
if (context.current && context.current.absolutePath !== context.targetPath
|
|
521
|
+
&& !context.currentShared
|
|
475
522
|
&& tryLstat(context.current.absolutePath)) {
|
|
476
523
|
this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
|
|
477
524
|
}
|
|
478
525
|
if (plan.kind === "reference") {
|
|
479
526
|
this.#installLink(intent, context, relative(dirname(context.targetPath), join(this.#bindingDir, plan.targetEntityId)));
|
|
527
|
+
this.#relocateAfterTarget(plan.target, context);
|
|
480
528
|
return;
|
|
481
529
|
}
|
|
482
530
|
if (plan.kind === "link") {
|
|
483
531
|
this.#installLink(intent, context, plan.targetText);
|
|
532
|
+
this.#relocateAfterTarget(plan.target, context);
|
|
484
533
|
return;
|
|
485
534
|
}
|
|
486
535
|
const artifact = prepared.artifact;
|
|
487
536
|
if (!artifact)
|
|
488
537
|
throw new Error(`${plan.kind} Apply has no prepared immutable artifact`);
|
|
489
538
|
this.#installArtifact(intent, context, plan.target, artifact);
|
|
539
|
+
this.#relocateAfterTarget(plan.target, context);
|
|
490
540
|
}
|
|
491
541
|
catch (error) {
|
|
492
542
|
this.#releaseGround(intent);
|
|
@@ -558,6 +608,10 @@ export class EntityApplyHost {
|
|
|
558
608
|
const addresses = new Set([context.targetPath]);
|
|
559
609
|
if (context.current)
|
|
560
610
|
addresses.add(context.current.absolutePath);
|
|
611
|
+
for (const relocation of [...context.beforeRelocations, ...context.afterRelocations]) {
|
|
612
|
+
addresses.add(relocation.source);
|
|
613
|
+
addresses.add(relocation.destination);
|
|
614
|
+
}
|
|
561
615
|
if (plan.kind === "repo.git") {
|
|
562
616
|
const repository = this.#prepared.get(this.#key(intent))?.repository;
|
|
563
617
|
if (repository) {
|
|
@@ -577,6 +631,99 @@ export class EntityApplyHost {
|
|
|
577
631
|
this.#groundReleases.delete(key);
|
|
578
632
|
release();
|
|
579
633
|
}
|
|
634
|
+
/** A collision relocation is safe only when every source still proves its
|
|
635
|
+
* own portable identity and every destination is either free or another
|
|
636
|
+
* relocation source. The final reveal then performs the whole finite chain
|
|
637
|
+
* while holding all source and destination cones. */
|
|
638
|
+
async #lawRelocationsReady(intent, plan, relocations) {
|
|
639
|
+
if (relocations.length === 0)
|
|
640
|
+
return false;
|
|
641
|
+
const sources = new Set(relocations.map(({ source }) => source));
|
|
642
|
+
for (const { row, source, destination } of relocations) {
|
|
643
|
+
if (tryLstat(destination) && !sources.has(destination))
|
|
644
|
+
return false;
|
|
645
|
+
if (!await this.#matches(intent, portable(row), source, plan, row))
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
/** Relocate a derived-name chain without ever overwriting ground. Each move
|
|
651
|
+
* whose destination is free commits row addresses first, then renames disk.
|
|
652
|
+
* A crash between those facts is repaired by Detect from the same portable
|
|
653
|
+
* identity; the derived suffix can never become a rename mutation. */
|
|
654
|
+
#relocateLawSiblings(_context, relocations) {
|
|
655
|
+
const pending = [...relocations];
|
|
656
|
+
while (pending.length > 0) {
|
|
657
|
+
const sources = new Set(pending.map(({ source }) => source));
|
|
658
|
+
const index = pending.findIndex(({ destination }) => !sources.has(destination) && !tryLstat(destination));
|
|
659
|
+
if (index < 0) {
|
|
660
|
+
throw new Error("derived address relocations have no free destination");
|
|
661
|
+
}
|
|
662
|
+
const relocation = pending.splice(index, 1)[0];
|
|
663
|
+
if (!relocation)
|
|
664
|
+
throw new Error("derived address relocation vanished");
|
|
665
|
+
const { row, source, destination } = relocation;
|
|
666
|
+
if (!tryLstat(source))
|
|
667
|
+
continue;
|
|
668
|
+
const destinationRelative = relative(this.#root(row).absolutePath, destination)
|
|
669
|
+
.split(sep).join("/");
|
|
670
|
+
const descendants = (rows) => rows.filter((candidate) => pathInside(source, candidate.absolutePath));
|
|
671
|
+
const entityRows = descendants(this.#rowsUnderRoot.all(row.rootUUID));
|
|
672
|
+
const notebookRows = descendants(this.#notebookUnderRoot.all(row.rootUUID));
|
|
673
|
+
this.#database.transaction(() => {
|
|
674
|
+
for (const candidate of entityRows) {
|
|
675
|
+
this.#relocateEntityRow.run(destinationRelative + candidate.relativePath.slice(row.relativePath.length), destination + candidate.absolutePath.slice(source.length), candidate.uuid);
|
|
676
|
+
}
|
|
677
|
+
for (const candidate of notebookRows) {
|
|
678
|
+
this.#relocateNotebookRow.run(destinationRelative + candidate.relativePath.slice(row.relativePath.length), destination + candidate.absolutePath.slice(source.length), candidate.uuid);
|
|
679
|
+
}
|
|
680
|
+
})();
|
|
681
|
+
this.#onLawRelocationStage?.("rows-committed", row.uuid, destination);
|
|
682
|
+
renameSync(source, destination);
|
|
683
|
+
this.#onLawRelocationStage?.("ground-moved", row.uuid, destination);
|
|
684
|
+
const moved = lstatSync(destination);
|
|
685
|
+
this.#refreshEntityCtime.run(moved.ctimeMs, row.uuid);
|
|
686
|
+
this.#refreshNotebookCtime.run(moved.ctimeMs, row.uuid);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Reveal has already moved or removed the target ground. Release its old
|
|
690
|
+
* SQLite address before a sibling takes that slot; final Verify will write
|
|
691
|
+
* the complete accepted row. The durable Apply intent owns any crash in
|
|
692
|
+
* this short interval. */
|
|
693
|
+
#relocateAfterTarget(target, context) {
|
|
694
|
+
if (context.afterRelocations.length === 0)
|
|
695
|
+
return;
|
|
696
|
+
const current = context.current;
|
|
697
|
+
if (current) {
|
|
698
|
+
const entityRows = this.#rowsUnderRoot.all(current.rootUUID)
|
|
699
|
+
.filter((row) => pathInside(current.absolutePath, row.absolutePath));
|
|
700
|
+
const notebookRows = this.#notebookUnderRoot.all(current.rootUUID)
|
|
701
|
+
.filter((row) => pathInside(current.absolutePath, row.absolutePath));
|
|
702
|
+
if (target.status === "active") {
|
|
703
|
+
const destinationRelative = relative(this.#root(current).absolutePath, context.targetPath)
|
|
704
|
+
.split(sep).join("/");
|
|
705
|
+
this.#database.transaction(() => {
|
|
706
|
+
for (const row of entityRows) {
|
|
707
|
+
this.#relocateEntityRow.run(destinationRelative + row.relativePath.slice(current.relativePath.length), context.targetPath + row.absolutePath.slice(current.absolutePath.length), row.uuid);
|
|
708
|
+
}
|
|
709
|
+
for (const row of notebookRows) {
|
|
710
|
+
this.#relocateNotebookRow.run(destinationRelative + row.relativePath.slice(current.relativePath.length), context.targetPath + row.absolutePath.slice(current.absolutePath.length), row.uuid);
|
|
711
|
+
}
|
|
712
|
+
})();
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
const removeEntity = this.#database.prepare("DELETE FROM entities WHERE uuid = ?");
|
|
716
|
+
const removeNotebook = this.#database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
|
|
717
|
+
this.#database.transaction(() => {
|
|
718
|
+
for (const row of entityRows)
|
|
719
|
+
removeEntity.run(row.uuid);
|
|
720
|
+
for (const row of notebookRows)
|
|
721
|
+
removeNotebook.run(row.uuid);
|
|
722
|
+
})();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
this.#relocateLawSiblings(context, context.afterRelocations);
|
|
726
|
+
}
|
|
580
727
|
#emit(intent, plan, stage) {
|
|
581
728
|
this.#onStage?.({
|
|
582
729
|
entityId: intent.entityId,
|
|
@@ -661,8 +808,10 @@ export class EntityApplyHost {
|
|
|
661
808
|
const staged = join(control, "prepared");
|
|
662
809
|
rmSync(staged, { recursive: true, force: true });
|
|
663
810
|
copyFileSync(artifact.file, staged);
|
|
664
|
-
|
|
665
|
-
|
|
811
|
+
if (target.fileMode === null) {
|
|
812
|
+
throw new Error(`ordinary file ${target.uuid} has no portable mode`);
|
|
813
|
+
}
|
|
814
|
+
chmodSync(staged, target.fileMode);
|
|
666
815
|
// The completion transaction is durable; the revealed bytes must be too,
|
|
667
816
|
// or a power loss leaves the notebook naming a target whose file is
|
|
668
817
|
// truncated on disk, and Detect would publish that truncation as head.
|
|
@@ -729,6 +878,7 @@ export class EntityApplyHost {
|
|
|
729
878
|
if (record.type === "file.text" || record.type === "file.binary") {
|
|
730
879
|
return stats.isFile() && !stats.isSymbolicLink()
|
|
731
880
|
&& record.payloadVersion !== null
|
|
881
|
+
&& record.fileMode === portableFileMode(record.type, stats.mode)
|
|
732
882
|
&& await this.#hashFile(path) === record.payloadVersion;
|
|
733
883
|
}
|
|
734
884
|
if (record.type === "link") {
|
|
@@ -790,7 +940,7 @@ export class EntityApplyHost {
|
|
|
790
940
|
record: target,
|
|
791
941
|
relativePath: target.parentUUID === null
|
|
792
942
|
? prior.relativePath
|
|
793
|
-
: [context.parent?.relativePath,
|
|
943
|
+
: [context.parent?.relativePath, context.targetName].filter(Boolean).join("/"),
|
|
794
944
|
absolutePath: context.targetPath,
|
|
795
945
|
deviceNumber: "dev" in evidence ? evidence.dev : evidence.deviceNumber,
|
|
796
946
|
inode: "ino" in evidence ? evidence.ino : evidence.inode,
|
|
@@ -931,10 +1081,124 @@ export class EntityApplyHost {
|
|
|
931
1081
|
if (target.parentUUID !== null && !parent) {
|
|
932
1082
|
throw new Error(`entity ${target.uuid} waits for materialized parent ${target.parentUUID}`);
|
|
933
1083
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1084
|
+
if (!parent) {
|
|
1085
|
+
const targetPath = current?.absolutePath;
|
|
1086
|
+
if (!targetPath)
|
|
1087
|
+
throw new Error(`root entity ${target.uuid} has no machine binding`);
|
|
1088
|
+
return {
|
|
1089
|
+
current,
|
|
1090
|
+
parent,
|
|
1091
|
+
targetPath,
|
|
1092
|
+
targetName: target.name,
|
|
1093
|
+
beforeRelocations: [],
|
|
1094
|
+
afterRelocations: [],
|
|
1095
|
+
currentShared: false,
|
|
1096
|
+
collisionEligible: false,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
const finalByParent = new Map();
|
|
1100
|
+
const affectedParents = new Map();
|
|
1101
|
+
affectedParents.set(parent.uuid, parent);
|
|
1102
|
+
if (current?.parentUUID && current.parentUUID !== parent.uuid) {
|
|
1103
|
+
const oldParent = this.#current(current.parentUUID);
|
|
1104
|
+
if (!oldParent)
|
|
1105
|
+
throw new Error(`entity ${target.uuid} lost materialized parent ${current.parentUUID}`);
|
|
1106
|
+
affectedParents.set(oldParent.uuid, oldParent);
|
|
1107
|
+
}
|
|
1108
|
+
for (const parentRow of affectedParents.values()) {
|
|
1109
|
+
finalByParent.set(parentRow.uuid, [...this.#activeSiblings(parentRow.uuid)].filter((row) => row.uuid !== target.uuid));
|
|
1110
|
+
}
|
|
1111
|
+
if (target.status === "active") {
|
|
1112
|
+
finalByParent.get(parent.uuid)?.push({
|
|
1113
|
+
...(current ?? parent),
|
|
1114
|
+
uuid: target.uuid,
|
|
1115
|
+
type: target.type,
|
|
1116
|
+
parentUUID: target.parentUUID,
|
|
1117
|
+
name: target.name,
|
|
1118
|
+
status: target.status,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
const derivedByUuid = new Map();
|
|
1122
|
+
for (const siblings of finalByParent.values()) {
|
|
1123
|
+
for (const [uuid, name] of materializedSlotNames(siblings))
|
|
1124
|
+
derivedByUuid.set(uuid, name);
|
|
1125
|
+
}
|
|
1126
|
+
const collisionEligible = current === null
|
|
1127
|
+
|| (current.parentUUID === target.parentUUID && current.name === target.name);
|
|
1128
|
+
const targetName = target.status === "active"
|
|
1129
|
+
? collisionEligible ? derivedByUuid.get(target.uuid) ?? target.name : target.name
|
|
1130
|
+
: current ? basename(current.absolutePath) : target.name;
|
|
1131
|
+
const targetPath = target.status === "active"
|
|
1132
|
+
? join(parent.absolutePath, targetName)
|
|
1133
|
+
: current?.absolutePath ?? join(parent.absolutePath, targetName);
|
|
1134
|
+
const beforeRelocations = [];
|
|
1135
|
+
const afterRelocations = [];
|
|
1136
|
+
for (const [parentUUID, siblings] of finalByParent) {
|
|
1137
|
+
const parentRow = affectedParents.get(parentUUID);
|
|
1138
|
+
if (!parentRow)
|
|
1139
|
+
continue;
|
|
1140
|
+
for (const row of siblings) {
|
|
1141
|
+
if (row.uuid === target.uuid)
|
|
1142
|
+
continue;
|
|
1143
|
+
if (!collisionEligible
|
|
1144
|
+
&& row.parentUUID === target.parentUUID
|
|
1145
|
+
&& row.name === target.name)
|
|
1146
|
+
continue;
|
|
1147
|
+
const destination = join(parentRow.absolutePath, derivedByUuid.get(row.uuid) ?? row.name);
|
|
1148
|
+
const source = destination === row.absolutePath && !tryLstat(destination)
|
|
1149
|
+
? this.#physicalSiblingPath(parentRow.absolutePath, row)
|
|
1150
|
+
: row.absolutePath;
|
|
1151
|
+
if (source === null || source === destination)
|
|
1152
|
+
continue;
|
|
1153
|
+
const relocation = { row, source, destination };
|
|
1154
|
+
const leavesOldSlot = current !== null
|
|
1155
|
+
&& row.parentUUID === current.parentUUID
|
|
1156
|
+
&& row.name === current.name
|
|
1157
|
+
&& (target.status !== "active"
|
|
1158
|
+
|| target.parentUUID !== current.parentUUID
|
|
1159
|
+
|| target.name !== current.name);
|
|
1160
|
+
(leavesOldSlot ? afterRelocations : beforeRelocations).push(relocation);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
current,
|
|
1165
|
+
parent,
|
|
1166
|
+
targetPath,
|
|
1167
|
+
targetName,
|
|
1168
|
+
beforeRelocations,
|
|
1169
|
+
afterRelocations,
|
|
1170
|
+
currentShared: current !== null
|
|
1171
|
+
&& [...finalByParent.values()].flat()
|
|
1172
|
+
.some((row) => row.uuid !== current.uuid && row.absolutePath === current.absolutePath),
|
|
1173
|
+
// A new identity can arrive concurrently in an already-held portable
|
|
1174
|
+
// slot. An existing identity renaming or moving into another identity's
|
|
1175
|
+
// slot is not concurrency; mutation-time law forbids it and Apply keeps
|
|
1176
|
+
// treating that accepted anomaly as a conflict.
|
|
1177
|
+
collisionEligible,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
/** Active children across accepted and Detect truth; the notebook row wins
|
|
1181
|
+
* for a UUID exactly as #current does. */
|
|
1182
|
+
#activeSiblings(parentUUID) {
|
|
1183
|
+
const rows = new Map();
|
|
1184
|
+
for (const row of this.#entitiesByParent.all(parentUUID)) {
|
|
1185
|
+
rows.set(row.uuid, row);
|
|
1186
|
+
}
|
|
1187
|
+
for (const row of this.#rowsByParent.all(parentUUID)) {
|
|
1188
|
+
rows.set(row.uuid, row);
|
|
1189
|
+
}
|
|
1190
|
+
return [...rows.values()].filter((row) => row.status === "active");
|
|
1191
|
+
}
|
|
1192
|
+
/** Rows are committed before a derived-address rename. After a crash, the
|
|
1193
|
+
* row therefore names the destination while the same inode can still stand
|
|
1194
|
+
* at the old sibling address. Recover that source without guessing by name. */
|
|
1195
|
+
#physicalSiblingPath(parent, row) {
|
|
1196
|
+
const matches = readdirSync(parent, { withFileTypes: true }).flatMap((entry) => {
|
|
1197
|
+
const path = join(parent, entry.name);
|
|
1198
|
+
const stats = tryLstat(path);
|
|
1199
|
+
return stats && stats.dev === row.deviceNumber && stats.ino === row.inode ? [path] : [];
|
|
1200
|
+
});
|
|
1201
|
+
return matches.length === 1 ? matches[0] : null;
|
|
938
1202
|
}
|
|
939
1203
|
#current(entityId) {
|
|
940
1204
|
return this.#rowByUuid.get(entityId)
|