@plinth-music/cli 0.16.0 → 0.17.0
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/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +428 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@plinth-music/cli`. Grouped by what a user notices, not by PR.
|
|
4
4
|
|
|
5
|
+
## 0.17.0 - 2026-09-03
|
|
6
|
+
|
|
7
|
+
One code PR since `0.16.0` (`v0.16.0..a6209c8`, #165; #167 is tests and comments,
|
|
8
|
+
#163 / #164 / #166 docs). A minor because sync gains a new refusal and a file on your
|
|
9
|
+
disk can now move on its own.
|
|
10
|
+
|
|
11
|
+
- ⚠️ **Renaming a task or project in Plinth now moves the mirror file instead of
|
|
12
|
+
writing a second one.** The server regenerates a row's slug when its name changes.
|
|
13
|
+
Until now the mirror wrote the new name to a new file and left the old one orphaned,
|
|
14
|
+
and an edit to that orphan created a duplicate row in Plinth. The file is moved now,
|
|
15
|
+
and a local copy that had diverged from the cloud is stashed to
|
|
16
|
+
`~/.plinth/_conflicts/<workspace>/` before the move rather than pushed.
|
|
17
|
+
- ⚠️ **A rename that would land on a file already sitting there is refused, not
|
|
18
|
+
overwritten.** Nothing is moved, `plinth status` names it, and Plinth re-offers the
|
|
19
|
+
rename on every poll until the name is free. Where the file in the way belongs to
|
|
20
|
+
another record the message says so and tells you **not** to delete it — deleting it
|
|
21
|
+
would delete that record in Plinth. Where it is a file of yours, rename or move it
|
|
22
|
+
and the rename lands on the next poll.
|
|
23
|
+
- **An unreadable file at a renamed row's old path no longer stalls the whole sync.**
|
|
24
|
+
It used to abort the pull, and in the daemon back that off to a five-minute retry
|
|
25
|
+
indefinitely, leaving the mirror silently stale behind a notice naming no file. Only
|
|
26
|
+
that row is held now, and `plinth status` names the file and says to check its
|
|
27
|
+
permissions.
|
|
28
|
+
|
|
29
|
+
⚠️ **One boundary changed underneath this release, with no CLI change in it.** Plinth's
|
|
30
|
+
MCP server renamed its calendar tools (`create_calendar_event` and its siblings became
|
|
31
|
+
`create_calendar_entry`), which is what lets the cockpit write calendar entries. The
|
|
32
|
+
side effect is that the generated deny list no longer covers calendar *deletion* on
|
|
33
|
+
Plinth's own server, which it had been doing by accident of the name. No such tool
|
|
34
|
+
exists today, so the hole is latent rather than open. #167 measured this and pinned it
|
|
35
|
+
with tests; no deny pattern was added, removed or edited, and the list stays at 91.
|
|
36
|
+
|
|
37
|
+
The desktop app pins `@plinth-music/cli` exact (0.16.0 as of v1.9.4) and picks none of
|
|
38
|
+
this up until its own release repins.
|
|
39
|
+
|
|
5
40
|
## 0.16.0 - 2026-09-02
|
|
6
41
|
|
|
7
42
|
Three code PRs since `0.15.0` (`v0.15.0..5c60627`, #157, #159, #161; #158 and #160 are
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ plinth start # run the daemon: continuous pull + watch + pu
|
|
|
23
23
|
## Commands
|
|
24
24
|
|
|
25
25
|
- `plinth login --workspace <slug> [--reset-sync]` — issue a personal access token in the browser, paste it back, store it in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager). Logging in again to the same workspace is a token rotation and **keeps your sync cursors**, so the next sync picks up where the last one left off; it clears them only when the token belongs to a different workspace or user. `--reset-sync` discards them deliberately, making the next sync a full re-pull — use it after rebuilding a mirror from scratch, which is otherwise unreachable now that rotation preserves them.
|
|
26
|
-
- `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted. **Every mirrored task, project, document, artist, meeting and thread file carries its row `id:` in frontmatter**, so an agent holding a file can address the MCP write for that row directly instead of going through `search_entities` and risking a duplicate on an empty result; files already on disk when you upgrade get it from a one-time backfill, since a cursor feed never re-sends an unchanged row. The key is server-authoritative and stripped before the wire, so it moves no hash and pushes nothing. It also **scaffolds `files/` and the six entity folders**, so a new workspace arrives with somewhere to put things instead of an empty directory, and the folder map in the generated `CLAUDE.md` reads the disk rather than asserting what ought to be there.
|
|
26
|
+
- `plinth sync [--workspace <slug>]` — one-shot pull into `~/Plinth/<workspace>/`. Also generates the workspace-root `CLAUDE.md` (a thin TOC + entity model + skill pointers), an empty `CLAUDE.local.md` stub on first sync, and the two session-boundary commands (below), and silently refreshes `CLAUDE.md` on a schema-version change. Defaults to the active workspace if `--workspace` is omitted. **Every mirrored task, project, document, artist, meeting and thread file carries its row `id:` in frontmatter**, so an agent holding a file can address the MCP write for that row directly instead of going through `search_entities` and risking a duplicate on an empty result; files already on disk when you upgrade get it from a one-time backfill, since a cursor feed never re-sends an unchanged row. The key is server-authoritative and stripped before the wire, so it moves no hash and pushes nothing. Since 0.17.0 a row **renamed in Plinth moves its mirror file** rather than leaving a second copy at the old name; a file already sitting at the new name is never overwritten, and `plinth status` names the clash until it clears. It also **scaffolds `files/` and the six entity folders**, so a new workspace arrives with somewhere to put things instead of an empty directory, and the folder map in the generated `CLAUDE.md` reads the disk rather than asserting what ought to be there.
|
|
27
27
|
- `plinth start` — run the long-running sync daemon: pull the latest, then watch the mirror for local edits and push them. It also projects each mapped artist's Google calendar into `views/calendar/` in the mirror — a file per artist plus an `upcoming.md` — on its own 15-minute timer (`PLINTH_CALENDAR_INTERVAL_MS`), with one run at boot. That is deliberately not the 15-second pull cadence: one tick makes a Google call per mapped calendar, which would breach the server route's rate limit inside a minute. A refresh that fails keeps the last known bodies and rewrites only the header, so a stale view says it is stale rather than reading as an empty diary. Logs to `~/.plinth/daemon.log`. **One daemon per workspace:** it holds an exclusive kernel lock on `~/.plinth/daemon-<workspace>.lock` for its life (macOS only; other platforms are refused and told why), so a second `plinth start`, or a standalone `plinth sync` while it runs, exits 1 naming the holder's pid. **Never delete the lock file** — the lock lives on the inode, and removing the path is what lets a second daemon in beside a live one. It also writes `views/conflicts.md` on every sync: every local edit the cloud won and the daemon set aside in `~/.plinth/_conflicts/`, each with a `cp` command that restores it; the zero case is written too, so an absent file means an old daemon, not a clean one.
|
|
28
28
|
- `plinth status` — local sync state: daemon liveness, last sync and per-type counts, any destructive batches being held, writes awaiting retry or permanently rejected, **every local write that has not reached Plinth** (scanned from the mirror against the last acknowledged state, with the remedy that clears each — start the daemon, or open and save a file that was created while it was down), the state of `voice.md`, and any conflict stashes in `~/.plinth/_conflicts/<workspace>/` — local copies that were set aside when the cloud version won. Warns when a source install's `dist/` is behind its checkout. Four further rows. **Commands** warns when the customisable block in a generated command still names machinery that command retired — the block is preserved verbatim forever and renders after the generated body, so a hand-written step that runs a retired ritual wins, and the generator may never edit it. **Its verdict is derived from the command registry, not asserted**: a token that still resolves to a live subcommand is described as still working and left to you, because the one token it lists is `memory-gate`, which the grounding block hands every session. **Links** names `[[pointers]]` in `workspace-memory.md` and `artists/*/memory.md` that resolve to nothing — a memory fact is a hook plus a pointer to its source, so a dead pointer can mean the hook is the only surviving copy of the fact. It reuses `plinth backlinks`'s resolution ladder, reports prose links only (never one inside a code fence), never reports an ambiguous target, and strips anchors before resolving. **Injection** reports the session-start block's size against its 20,000-byte budget and **where those bytes go** — seven categories that sum to the block by construction, marking which are yours to change — plus how many facts of each index ride whole, how many ride as their opening line only, and which file to shorten. **Too long** names each memory file past the size it is useful at: a rule file (`voice.md`, `workspace-rules.md`) at **6,000 bytes**, a fact store (`artists/<slug>/memory.md`) at **12,000**. The two bars are not one bar: what a long rule file spends is the model's instruction-following budget, which reference facts do not touch. A file under its bar renders nothing at all. Since 0.16.0 it also reports master-context walls (one block past 2,000 characters), played entries still under an "upcoming" label, and a "Current state" heading with no as-of or a stale one.
|
|
29
29
|
- `plinth confirm` — review and release destructive batches the daemon has quarantined (apply, or `--discard`).
|
package/dist/build-stamp.json
CHANGED
package/dist/cli.js
CHANGED
|
@@ -7584,7 +7584,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
|
|
|
7584
7584
|
// package.json
|
|
7585
7585
|
var package_default = {
|
|
7586
7586
|
name: "@plinth-music/cli",
|
|
7587
|
-
version: "0.
|
|
7587
|
+
version: "0.17.0",
|
|
7588
7588
|
description: "Plinth capstone sync daemon and CLI",
|
|
7589
7589
|
type: "module",
|
|
7590
7590
|
private: false,
|
|
@@ -23654,6 +23654,46 @@ function writeMirrorFile(workspaceSlug, entityType, entitySlug, content) {
|
|
|
23654
23654
|
throw err;
|
|
23655
23655
|
}
|
|
23656
23656
|
}
|
|
23657
|
+
|
|
23658
|
+
class MirrorDestinationOccupiedError extends MirrorPathError {
|
|
23659
|
+
fromSlug;
|
|
23660
|
+
toSlug;
|
|
23661
|
+
constructor(fromSlug, toSlug) {
|
|
23662
|
+
super(`Cannot move ${JSON.stringify(fromSlug)} to ${JSON.stringify(toSlug)}: ` + `a file already exists there`);
|
|
23663
|
+
this.name = "MirrorDestinationOccupiedError";
|
|
23664
|
+
this.fromSlug = fromSlug;
|
|
23665
|
+
this.toSlug = toSlug;
|
|
23666
|
+
}
|
|
23667
|
+
}
|
|
23668
|
+
function moveEntityFile(workspaceSlug, entityType, fromSlug, toSlug, beforeMove) {
|
|
23669
|
+
const strategy = getStrategy(entityType);
|
|
23670
|
+
const rel = strategy ? strategy.mirrorRelativePath(fromSlug) : flatMirrorRelativePath(entityType)(fromSlug);
|
|
23671
|
+
if (rel.split("/").length !== 2) {
|
|
23672
|
+
throw new MirrorPathError(`Entity type ${JSON.stringify(entityType)} keeps its rows in a folder; ` + `moving one is not supported`);
|
|
23673
|
+
}
|
|
23674
|
+
const from = entityFilePath(workspaceSlug, entityType, fromSlug);
|
|
23675
|
+
const to = entityFilePath(workspaceSlug, entityType, toSlug);
|
|
23676
|
+
if (from === to)
|
|
23677
|
+
return "absent";
|
|
23678
|
+
try {
|
|
23679
|
+
lstatSync(to);
|
|
23680
|
+
throw new MirrorDestinationOccupiedError(fromSlug, toSlug);
|
|
23681
|
+
} catch (err) {
|
|
23682
|
+
if (err instanceof MirrorDestinationOccupiedError)
|
|
23683
|
+
throw err;
|
|
23684
|
+
if (err?.code !== "ENOENT")
|
|
23685
|
+
throw err;
|
|
23686
|
+
}
|
|
23687
|
+
beforeMove?.();
|
|
23688
|
+
try {
|
|
23689
|
+
renameSync(from, to);
|
|
23690
|
+
return "moved";
|
|
23691
|
+
} catch (err) {
|
|
23692
|
+
if (err?.code === "ENOENT")
|
|
23693
|
+
return "absent";
|
|
23694
|
+
throw err;
|
|
23695
|
+
}
|
|
23696
|
+
}
|
|
23657
23697
|
var ARTIST_MEMORY_ANCHOR = "memory.md";
|
|
23658
23698
|
function artistMemoryFilePath(workspaceSlug, entitySlug) {
|
|
23659
23699
|
assertSlug("entity slug", entitySlug);
|
|
@@ -31007,6 +31047,31 @@ function recordEntitySymlinkRefused(args) {
|
|
|
31007
31047
|
rejected_at: args.now().toISOString()
|
|
31008
31048
|
}));
|
|
31009
31049
|
}
|
|
31050
|
+
function recordEntitySlugClash(args) {
|
|
31051
|
+
const path3 = args.mirrorRelativePath;
|
|
31052
|
+
const withoutStale = removeUnsynced(args.state, "entity", path3).state;
|
|
31053
|
+
const held = `Until then new ${args.entityType} changes are held on this machine.`;
|
|
31054
|
+
const message = args.reason === "source_unreadable" ? `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${path3} could not be read, so it was left exactly ` + `where it is. Check the file's permissions; Plinth will move it once ` + `it can read it. ${held}` : args.occupantTracked ? `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${args.entityType}/${args.toSlug}.md belongs to ` + `another ${args.entityType}, so nothing was moved or overwritten. ` + `This usually clears itself once that record's own rename arrives. ` + `Do not delete that file — it would delete that record in Plinth. ` + `${held}` : `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${args.entityType}/${args.toSlug}.md already ` + `exists here, so nothing was moved or overwritten. Rename or remove ` + `that file and Plinth will move this one into place. ${held}`;
|
|
31055
|
+
const existing = (withoutStale.rejected ?? []).find((r) => r.target === "entity" && r.path === path3 && r.reason === "slug_clash");
|
|
31056
|
+
if (existing && existing.message === message)
|
|
31057
|
+
return args.state;
|
|
31058
|
+
return persist(withoutStale, upsertRejected(withoutStale, {
|
|
31059
|
+
target: "entity",
|
|
31060
|
+
op: "upsert",
|
|
31061
|
+
path: path3,
|
|
31062
|
+
status: 0,
|
|
31063
|
+
reason: "slug_clash",
|
|
31064
|
+
message,
|
|
31065
|
+
rejected_at: args.now().toISOString()
|
|
31066
|
+
}));
|
|
31067
|
+
}
|
|
31068
|
+
function clearSlugClash(state, mirrorRelativePaths) {
|
|
31069
|
+
const list = state.rejected ?? [];
|
|
31070
|
+
const next = list.filter((o) => !(o.target === "entity" && o.reason === "slug_clash" && mirrorRelativePaths.includes(o.path)));
|
|
31071
|
+
if (next.length === list.length)
|
|
31072
|
+
return state;
|
|
31073
|
+
return commitState(state, { ...state, rejected: next });
|
|
31074
|
+
}
|
|
31010
31075
|
function clearSymlinkRefused(state, target, path3) {
|
|
31011
31076
|
const list = state.rejected ?? [];
|
|
31012
31077
|
const next = list.filter((o) => !(o.target === target && o.path === path3 && o.reason === "symlink"));
|
|
@@ -32727,10 +32792,102 @@ function computeMirrorHashes(strategy, content) {
|
|
|
32727
32792
|
function hashableBody(strategy, body) {
|
|
32728
32793
|
return strategy.stripLocalBlocks?.(body) ?? body;
|
|
32729
32794
|
}
|
|
32730
|
-
function
|
|
32795
|
+
function relocateForSlugChange(args) {
|
|
32796
|
+
const { workspaceSlug, strategy, fromSlug, toSlug, acked, echo, now } = args;
|
|
32797
|
+
if (fromSlug === toSlug)
|
|
32798
|
+
return { kind: "not_needed", writeSlug: toSlug };
|
|
32799
|
+
if (!echo) {
|
|
32800
|
+
return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "unguarded" };
|
|
32801
|
+
}
|
|
32802
|
+
if (strategy.mirrorRelativePath(fromSlug).split("/").length !== 2) {
|
|
32803
|
+
return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "foldered" };
|
|
32804
|
+
}
|
|
32805
|
+
let fromPath;
|
|
32806
|
+
try {
|
|
32807
|
+
fromPath = entityFilePath(workspaceSlug, strategy.type, fromSlug);
|
|
32808
|
+
entityFilePath(workspaceSlug, strategy.type, toSlug);
|
|
32809
|
+
} catch (err) {
|
|
32810
|
+
if (err instanceof MirrorPathError) {
|
|
32811
|
+
return {
|
|
32812
|
+
kind: "skipped",
|
|
32813
|
+
writeSlug: toSlug,
|
|
32814
|
+
fromSlug,
|
|
32815
|
+
reason: "unresolvable"
|
|
32816
|
+
};
|
|
32817
|
+
}
|
|
32818
|
+
throw err;
|
|
32819
|
+
}
|
|
32820
|
+
let raw = null;
|
|
32821
|
+
try {
|
|
32822
|
+
raw = readFileSync15(fromPath, "utf8");
|
|
32823
|
+
} catch (err) {
|
|
32824
|
+
const code = err?.code;
|
|
32825
|
+
if (code === "ENOENT") {
|
|
32826
|
+
return { kind: "absent", writeSlug: toSlug, fromSlug };
|
|
32827
|
+
}
|
|
32828
|
+
return {
|
|
32829
|
+
kind: "refused",
|
|
32830
|
+
writeSlug: fromSlug,
|
|
32831
|
+
fromSlug,
|
|
32832
|
+
reason: "source_unreadable"
|
|
32833
|
+
};
|
|
32834
|
+
}
|
|
32835
|
+
let diverged = true;
|
|
32836
|
+
try {
|
|
32837
|
+
const hashes = computeMirrorHashes(strategy, raw);
|
|
32838
|
+
diverged = hashes.bodyHash !== acked.bodyHash || hashes.frontmatterHash !== acked.frontmatterHash;
|
|
32839
|
+
} catch {
|
|
32840
|
+
diverged = true;
|
|
32841
|
+
}
|
|
32842
|
+
const stashPath = diverged ? stashConflict({
|
|
32843
|
+
workspaceSlug,
|
|
32844
|
+
entityType: strategy.type,
|
|
32845
|
+
entitySlug: fromSlug,
|
|
32846
|
+
timestamp: now(),
|
|
32847
|
+
contents: raw,
|
|
32848
|
+
side: "local"
|
|
32849
|
+
}) : undefined;
|
|
32850
|
+
try {
|
|
32851
|
+
let registered = false;
|
|
32852
|
+
const result = moveEntityFile(workspaceSlug, strategy.type, fromSlug, toSlug, () => {
|
|
32853
|
+
if (echo.watcher === "live") {
|
|
32854
|
+
echo.suppressor.registerDelete(fromPath);
|
|
32855
|
+
registered = true;
|
|
32856
|
+
}
|
|
32857
|
+
});
|
|
32858
|
+
if (result === "moved") {
|
|
32859
|
+
return { kind: "moved", writeSlug: toSlug, fromSlug, stashPath };
|
|
32860
|
+
}
|
|
32861
|
+
if (registered && echo.watcher === "live") {
|
|
32862
|
+
echo.suppressor.cancelDelete(fromPath);
|
|
32863
|
+
}
|
|
32864
|
+
return { kind: "absent", writeSlug: toSlug, fromSlug };
|
|
32865
|
+
} catch (err) {
|
|
32866
|
+
if (err instanceof MirrorDestinationOccupiedError) {
|
|
32867
|
+
return {
|
|
32868
|
+
kind: "refused",
|
|
32869
|
+
writeSlug: fromSlug,
|
|
32870
|
+
fromSlug,
|
|
32871
|
+
reason: "destination_occupied",
|
|
32872
|
+
stashPath
|
|
32873
|
+
};
|
|
32874
|
+
}
|
|
32875
|
+
if (err instanceof MirrorPathError) {
|
|
32876
|
+
return {
|
|
32877
|
+
kind: "skipped",
|
|
32878
|
+
writeSlug: toSlug,
|
|
32879
|
+
fromSlug,
|
|
32880
|
+
reason: "unresolvable"
|
|
32881
|
+
};
|
|
32882
|
+
}
|
|
32883
|
+
throw err;
|
|
32884
|
+
}
|
|
32885
|
+
}
|
|
32886
|
+
function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor, pathSlug) {
|
|
32887
|
+
const targetSlug = pathSlug ?? doc2.slug;
|
|
32731
32888
|
let content = strategy.serialise(doc2);
|
|
32732
32889
|
if (strategy.preserveLocalBodyOnPull) {
|
|
32733
|
-
const target = entityFilePath(workspaceSlug, strategy.type,
|
|
32890
|
+
const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
|
|
32734
32891
|
let existing = null;
|
|
32735
32892
|
try {
|
|
32736
32893
|
existing = readFileSync15(target, "utf8");
|
|
@@ -32748,10 +32905,10 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
|
|
|
32748
32905
|
}
|
|
32749
32906
|
}
|
|
32750
32907
|
if (suppressor) {
|
|
32751
|
-
const target = entityFilePath(workspaceSlug, strategy.type,
|
|
32908
|
+
const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
|
|
32752
32909
|
suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
|
|
32753
32910
|
}
|
|
32754
|
-
writeMirrorFile(workspaceSlug, strategy.type,
|
|
32911
|
+
writeMirrorFile(workspaceSlug, strategy.type, targetSlug, content);
|
|
32755
32912
|
return content;
|
|
32756
32913
|
}
|
|
32757
32914
|
|
|
@@ -32859,7 +33016,8 @@ function cloudMatchesAcked(strategy, cloud, acked) {
|
|
|
32859
33016
|
}
|
|
32860
33017
|
function stampStateFromMirror(args) {
|
|
32861
33018
|
const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
|
|
32862
|
-
const
|
|
33019
|
+
const targetSlug = args.pathSlug ?? doc2.slug;
|
|
33020
|
+
const mirrorPath = entityFilePath(workspaceSlug, strategy.type, targetSlug);
|
|
32863
33021
|
const mirrorRaw = readFileSync16(mirrorPath, "utf8");
|
|
32864
33022
|
const mirrorParsed = import_gray_matter10.default(mirrorRaw);
|
|
32865
33023
|
const mirrorFm = mirrorParsed.data ?? {};
|
|
@@ -32868,7 +33026,7 @@ function stampStateFromMirror(args) {
|
|
|
32868
33026
|
const mirrorHashBody = hashableBody(strategy, mirrorParsed.content);
|
|
32869
33027
|
const refreshed = {
|
|
32870
33028
|
id: doc2.id,
|
|
32871
|
-
slug:
|
|
33029
|
+
slug: targetSlug,
|
|
32872
33030
|
updated_at: doc2.updated_at,
|
|
32873
33031
|
revision: doc2.revision,
|
|
32874
33032
|
body_hash: stableHash(mirrorHashBody),
|
|
@@ -32879,6 +33037,73 @@ function stampStateFromMirror(args) {
|
|
|
32879
33037
|
};
|
|
32880
33038
|
return persistEntry(state, strategy.type, refreshed);
|
|
32881
33039
|
}
|
|
33040
|
+
function adoptCloudCopy(args) {
|
|
33041
|
+
const { strategy, workspaceSlug, doc: doc2, output } = args;
|
|
33042
|
+
const now = args.now ?? (() => new Date);
|
|
33043
|
+
let state = args.state;
|
|
33044
|
+
const prior = getEntry(state, strategy.type, doc2.id);
|
|
33045
|
+
let writeSlug = doc2.slug;
|
|
33046
|
+
let refused = false;
|
|
33047
|
+
if (prior && prior.slug !== doc2.slug) {
|
|
33048
|
+
const relocation = relocateForSlugChange({
|
|
33049
|
+
workspaceSlug,
|
|
33050
|
+
strategy,
|
|
33051
|
+
fromSlug: prior.slug,
|
|
33052
|
+
toSlug: doc2.slug,
|
|
33053
|
+
acked: {
|
|
33054
|
+
bodyHash: prior.body_hash,
|
|
33055
|
+
frontmatterHash: prior.frontmatter_hash
|
|
33056
|
+
},
|
|
33057
|
+
echo: args.echo,
|
|
33058
|
+
now
|
|
33059
|
+
});
|
|
33060
|
+
writeSlug = relocation.writeSlug;
|
|
33061
|
+
const move = `entityType=${strategy.type} from=${prior.slug} to=${doc2.slug} id=${doc2.id}`;
|
|
33062
|
+
if (relocation.kind === "moved") {
|
|
33063
|
+
output?.log(`[SLUG_MOVE ${move} source=push]`);
|
|
33064
|
+
state = clearSlugClash(state, [
|
|
33065
|
+
strategy.mirrorRelativePath(prior.slug),
|
|
33066
|
+
strategy.mirrorRelativePath(doc2.slug)
|
|
33067
|
+
]);
|
|
33068
|
+
} else if (relocation.kind === "refused") {
|
|
33069
|
+
output?.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason} source=push]`);
|
|
33070
|
+
refused = true;
|
|
33071
|
+
state = recordEntitySlugClash({
|
|
33072
|
+
state,
|
|
33073
|
+
mirrorRelativePath: strategy.mirrorRelativePath(prior.slug),
|
|
33074
|
+
entityType: strategy.type,
|
|
33075
|
+
fromSlug: prior.slug,
|
|
33076
|
+
toSlug: doc2.slug,
|
|
33077
|
+
reason: relocation.reason,
|
|
33078
|
+
occupantTracked: getEntryBySlug(state, strategy.type, doc2.slug) !== undefined,
|
|
33079
|
+
now
|
|
33080
|
+
});
|
|
33081
|
+
} else if (relocation.kind === "skipped") {
|
|
33082
|
+
output?.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason} source=push]`);
|
|
33083
|
+
}
|
|
33084
|
+
if (relocation.kind === "moved" || relocation.kind === "refused") {
|
|
33085
|
+
if (relocation.stashPath) {
|
|
33086
|
+
output?.warn(`Unsynced local edit on ${strategy.type}/${prior.slug} → stashed to ` + `${relocation.stashPath} (the row was renamed in Plinth; the cloud ` + `copy is authoritative).`);
|
|
33087
|
+
}
|
|
33088
|
+
}
|
|
33089
|
+
}
|
|
33090
|
+
if (refused) {
|
|
33091
|
+
return prior ? persistEntry(state, strategy.type, {
|
|
33092
|
+
...prior,
|
|
33093
|
+
updated_at: doc2.updated_at,
|
|
33094
|
+
revision: doc2.revision
|
|
33095
|
+
}) : state;
|
|
33096
|
+
}
|
|
33097
|
+
writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, undefined, writeSlug);
|
|
33098
|
+
return stampStateFromMirror({
|
|
33099
|
+
strategy,
|
|
33100
|
+
workspaceSlug,
|
|
33101
|
+
doc: doc2,
|
|
33102
|
+
state,
|
|
33103
|
+
priorMemoryHash: args.priorMemoryHash,
|
|
33104
|
+
pathSlug: writeSlug
|
|
33105
|
+
});
|
|
33106
|
+
}
|
|
32882
33107
|
async function pullAndWriteMirror(args) {
|
|
32883
33108
|
const cloud = await getEntity({
|
|
32884
33109
|
strategy: args.strategy,
|
|
@@ -32887,13 +33112,15 @@ async function pullAndWriteMirror(args) {
|
|
|
32887
33112
|
id: args.id,
|
|
32888
33113
|
fetchImpl: args.fetchImpl
|
|
32889
33114
|
});
|
|
32890
|
-
|
|
32891
|
-
return stampStateFromMirror({
|
|
33115
|
+
return adoptCloudCopy({
|
|
32892
33116
|
strategy: args.strategy,
|
|
32893
33117
|
workspaceSlug: args.workspaceSlug,
|
|
32894
33118
|
doc: cloud,
|
|
32895
33119
|
state: args.state,
|
|
32896
|
-
priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash
|
|
33120
|
+
priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash,
|
|
33121
|
+
echo: args.echo,
|
|
33122
|
+
output: args.output,
|
|
33123
|
+
now: args.now
|
|
32897
33124
|
});
|
|
32898
33125
|
}
|
|
32899
33126
|
async function pushFile(args) {
|
|
@@ -32914,6 +33141,7 @@ async function pushFile(args) {
|
|
|
32914
33141
|
const mutex = deps?.mutex ?? new PerIdMutex;
|
|
32915
33142
|
const output = deps?.output;
|
|
32916
33143
|
const suppressor = deps?.suppressor;
|
|
33144
|
+
const echo = deps?.echo;
|
|
32917
33145
|
const strategy = getStrategy(entityType);
|
|
32918
33146
|
if (!strategy) {
|
|
32919
33147
|
return {
|
|
@@ -32927,6 +33155,8 @@ async function pushFile(args) {
|
|
|
32927
33155
|
}
|
|
32928
33156
|
if (!exists) {
|
|
32929
33157
|
return runPushDelete({
|
|
33158
|
+
echo,
|
|
33159
|
+
now,
|
|
32930
33160
|
strategy,
|
|
32931
33161
|
workspaceSlug,
|
|
32932
33162
|
baseUrl,
|
|
@@ -32945,6 +33175,8 @@ async function pushFile(args) {
|
|
|
32945
33175
|
} catch (err) {
|
|
32946
33176
|
if (err?.code === "ENOENT") {
|
|
32947
33177
|
return runPushDelete({
|
|
33178
|
+
echo,
|
|
33179
|
+
now,
|
|
32948
33180
|
strategy,
|
|
32949
33181
|
workspaceSlug,
|
|
32950
33182
|
baseUrl,
|
|
@@ -33004,6 +33236,7 @@ async function pushFile(args) {
|
|
|
33004
33236
|
const stateEntry = identity === "path" ? byInode : byInode ?? getEntryBySlug(state, entityType, slug);
|
|
33005
33237
|
if (!stateEntry) {
|
|
33006
33238
|
return runPushCreate({
|
|
33239
|
+
echo,
|
|
33007
33240
|
strategy,
|
|
33008
33241
|
workspaceSlug,
|
|
33009
33242
|
baseUrl,
|
|
@@ -33024,6 +33257,7 @@ async function pushFile(args) {
|
|
|
33024
33257
|
});
|
|
33025
33258
|
}
|
|
33026
33259
|
return runPushUpsert({
|
|
33260
|
+
echo,
|
|
33027
33261
|
strategy,
|
|
33028
33262
|
workspaceSlug,
|
|
33029
33263
|
baseUrl,
|
|
@@ -33082,7 +33316,10 @@ async function reconcileContextDocConflict(args) {
|
|
|
33082
33316
|
pat,
|
|
33083
33317
|
id: existingId,
|
|
33084
33318
|
state,
|
|
33085
|
-
fetchImpl
|
|
33319
|
+
fetchImpl,
|
|
33320
|
+
echo: args.echo,
|
|
33321
|
+
output,
|
|
33322
|
+
now
|
|
33086
33323
|
});
|
|
33087
33324
|
output?.log(contextDocConflictLine({
|
|
33088
33325
|
entityType: strategy.type,
|
|
@@ -33199,7 +33436,10 @@ async function reconcileContextDocConflict(args) {
|
|
|
33199
33436
|
pat,
|
|
33200
33437
|
id: existingId,
|
|
33201
33438
|
state,
|
|
33202
|
-
fetchImpl
|
|
33439
|
+
fetchImpl,
|
|
33440
|
+
echo: args.echo,
|
|
33441
|
+
output,
|
|
33442
|
+
now
|
|
33203
33443
|
});
|
|
33204
33444
|
output?.log(contextDocConflictLine({
|
|
33205
33445
|
entityType: strategy.type,
|
|
@@ -33421,6 +33661,7 @@ async function runPushUpsert(args) {
|
|
|
33421
33661
|
}
|
|
33422
33662
|
if (patchResult.reason === "context_doc_conflict") {
|
|
33423
33663
|
return reconcileContextDocConflict({
|
|
33664
|
+
echo: args.echo,
|
|
33424
33665
|
strategy,
|
|
33425
33666
|
workspaceSlug: args.workspaceSlug,
|
|
33426
33667
|
baseUrl: args.baseUrl,
|
|
@@ -33439,6 +33680,7 @@ async function runPushUpsert(args) {
|
|
|
33439
33680
|
}
|
|
33440
33681
|
if (strategy.fieldTypedConflict) {
|
|
33441
33682
|
return reconcileTaskConflict({
|
|
33683
|
+
echo: args.echo,
|
|
33442
33684
|
strategy,
|
|
33443
33685
|
workspaceSlug: args.workspaceSlug,
|
|
33444
33686
|
baseUrl: args.baseUrl,
|
|
@@ -33469,7 +33711,10 @@ async function runPushUpsert(args) {
|
|
|
33469
33711
|
pat: args.pat,
|
|
33470
33712
|
id: liveEntry.id,
|
|
33471
33713
|
state: workingState,
|
|
33472
|
-
fetchImpl: args.fetchImpl
|
|
33714
|
+
fetchImpl: args.fetchImpl,
|
|
33715
|
+
echo: args.echo,
|
|
33716
|
+
output: args.output,
|
|
33717
|
+
now: args.now
|
|
33473
33718
|
});
|
|
33474
33719
|
return {
|
|
33475
33720
|
outcome: {
|
|
@@ -33517,7 +33762,10 @@ async function reconcileTaskConflict(args) {
|
|
|
33517
33762
|
pat,
|
|
33518
33763
|
id: liveEntry.id,
|
|
33519
33764
|
state,
|
|
33520
|
-
fetchImpl
|
|
33765
|
+
fetchImpl,
|
|
33766
|
+
echo: args.echo,
|
|
33767
|
+
output,
|
|
33768
|
+
now
|
|
33521
33769
|
});
|
|
33522
33770
|
return {
|
|
33523
33771
|
outcome: {
|
|
@@ -33566,13 +33814,15 @@ async function reconcileTaskConflict(args) {
|
|
|
33566
33814
|
});
|
|
33567
33815
|
if (patchResult.ok) {
|
|
33568
33816
|
const stashPath2 = stashCloudSide();
|
|
33569
|
-
|
|
33570
|
-
state = stampStateFromMirror({
|
|
33817
|
+
state = adoptCloudCopy({
|
|
33571
33818
|
strategy,
|
|
33572
33819
|
workspaceSlug,
|
|
33573
33820
|
doc: patchResult.doc,
|
|
33574
33821
|
state,
|
|
33575
|
-
priorMemoryHash: liveEntry.memory_hash
|
|
33822
|
+
priorMemoryHash: liveEntry.memory_hash,
|
|
33823
|
+
echo: args.echo,
|
|
33824
|
+
output,
|
|
33825
|
+
now
|
|
33576
33826
|
});
|
|
33577
33827
|
output?.log(`[PATCH entityType=${strategy.type} slug=${patchResult.doc.slug} id=${patchResult.doc.id} revision=${patchResult.doc.revision} note=field_typed_conflict]`);
|
|
33578
33828
|
return {
|
|
@@ -33604,7 +33854,10 @@ async function reconcileTaskConflict(args) {
|
|
|
33604
33854
|
pat,
|
|
33605
33855
|
id: liveEntry.id,
|
|
33606
33856
|
state,
|
|
33607
|
-
fetchImpl
|
|
33857
|
+
fetchImpl,
|
|
33858
|
+
echo: args.echo,
|
|
33859
|
+
output,
|
|
33860
|
+
now
|
|
33608
33861
|
});
|
|
33609
33862
|
return {
|
|
33610
33863
|
outcome: {
|
|
@@ -33641,6 +33894,7 @@ async function runPushCreate(args) {
|
|
|
33641
33894
|
const inLock = args.identity === "path" ? undefined : getEntryBySlug(args.state, strategy.type, args.slug);
|
|
33642
33895
|
if (inLock) {
|
|
33643
33896
|
return runPushUpsert({
|
|
33897
|
+
echo: args.echo,
|
|
33644
33898
|
strategy,
|
|
33645
33899
|
workspaceSlug: args.workspaceSlug,
|
|
33646
33900
|
baseUrl: args.baseUrl,
|
|
@@ -33737,6 +33991,7 @@ async function runPushCreate(args) {
|
|
|
33737
33991
|
}
|
|
33738
33992
|
if (createResult.reason === "context_doc_conflict") {
|
|
33739
33993
|
return reconcileContextDocConflict({
|
|
33994
|
+
echo: args.echo,
|
|
33740
33995
|
strategy,
|
|
33741
33996
|
workspaceSlug: args.workspaceSlug,
|
|
33742
33997
|
baseUrl: args.baseUrl,
|
|
@@ -33768,7 +34023,10 @@ async function runPushCreate(args) {
|
|
|
33768
34023
|
pat: args.pat,
|
|
33769
34024
|
id: createResult.existing.id,
|
|
33770
34025
|
state: args.state,
|
|
33771
|
-
fetchImpl: args.fetchImpl
|
|
34026
|
+
fetchImpl: args.fetchImpl,
|
|
34027
|
+
echo: args.echo,
|
|
34028
|
+
output: args.output,
|
|
34029
|
+
now: args.now
|
|
33772
34030
|
});
|
|
33773
34031
|
return {
|
|
33774
34032
|
outcome: {
|
|
@@ -33856,7 +34114,10 @@ async function runPushDelete(args) {
|
|
|
33856
34114
|
pat: args.pat,
|
|
33857
34115
|
id,
|
|
33858
34116
|
state: args.state,
|
|
33859
|
-
fetchImpl: args.fetchImpl
|
|
34117
|
+
fetchImpl: args.fetchImpl,
|
|
34118
|
+
echo: args.echo,
|
|
34119
|
+
output: args.output,
|
|
34120
|
+
now: args.now
|
|
33860
34121
|
});
|
|
33861
34122
|
return {
|
|
33862
34123
|
outcome: {
|
|
@@ -33887,6 +34148,7 @@ async function pushRename(args) {
|
|
|
33887
34148
|
const now = deps?.now ?? (() => new Date);
|
|
33888
34149
|
const mutex = deps?.mutex ?? new PerIdMutex;
|
|
33889
34150
|
const output = deps?.output;
|
|
34151
|
+
const echo = deps?.echo;
|
|
33890
34152
|
const strategy = requireStrategy(entityType);
|
|
33891
34153
|
let toSlug;
|
|
33892
34154
|
try {
|
|
@@ -33995,7 +34257,10 @@ async function pushRename(args) {
|
|
|
33995
34257
|
pat,
|
|
33996
34258
|
id: patchResult.existing.id,
|
|
33997
34259
|
state,
|
|
33998
|
-
fetchImpl
|
|
34260
|
+
fetchImpl,
|
|
34261
|
+
echo,
|
|
34262
|
+
output,
|
|
34263
|
+
now
|
|
33999
34264
|
});
|
|
34000
34265
|
const finalState = await pullAndWriteMirror({
|
|
34001
34266
|
strategy,
|
|
@@ -34004,7 +34269,10 @@ async function pushRename(args) {
|
|
|
34004
34269
|
pat,
|
|
34005
34270
|
id,
|
|
34006
34271
|
state: afterColliding,
|
|
34007
|
-
fetchImpl
|
|
34272
|
+
fetchImpl,
|
|
34273
|
+
echo,
|
|
34274
|
+
output,
|
|
34275
|
+
now
|
|
34008
34276
|
});
|
|
34009
34277
|
return {
|
|
34010
34278
|
outcome: {
|
|
@@ -34036,7 +34304,10 @@ async function pushRename(args) {
|
|
|
34036
34304
|
pat,
|
|
34037
34305
|
id,
|
|
34038
34306
|
state,
|
|
34039
|
-
fetchImpl
|
|
34307
|
+
fetchImpl,
|
|
34308
|
+
echo,
|
|
34309
|
+
output,
|
|
34310
|
+
now
|
|
34040
34311
|
});
|
|
34041
34312
|
return {
|
|
34042
34313
|
outcome: {
|
|
@@ -34094,7 +34365,8 @@ async function applyBatch(batch, deps, mutex) {
|
|
|
34094
34365
|
fetchImpl: deps.fetchImpl,
|
|
34095
34366
|
now: deps.now,
|
|
34096
34367
|
mutex,
|
|
34097
|
-
output: deps.output
|
|
34368
|
+
output: deps.output,
|
|
34369
|
+
echo: deps.echo
|
|
34098
34370
|
}
|
|
34099
34371
|
});
|
|
34100
34372
|
deps.setState(result.state);
|
|
@@ -34163,7 +34435,10 @@ async function discardBatchOps(batch, deps) {
|
|
|
34163
34435
|
pat: deps.pat,
|
|
34164
34436
|
id: op.id,
|
|
34165
34437
|
state: deps.getState(),
|
|
34166
|
-
fetchImpl: deps.fetchImpl
|
|
34438
|
+
fetchImpl: deps.fetchImpl,
|
|
34439
|
+
echo: deps.echo,
|
|
34440
|
+
output: deps.output,
|
|
34441
|
+
now: deps.now
|
|
34167
34442
|
});
|
|
34168
34443
|
deps.setState(next);
|
|
34169
34444
|
} catch (err) {
|
|
@@ -34756,7 +35031,7 @@ function recordFileUnsynced(args) {
|
|
|
34756
35031
|
});
|
|
34757
35032
|
}
|
|
34758
35033
|
async function drainUnsyncedOps(args) {
|
|
34759
|
-
const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output } = args;
|
|
35034
|
+
const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output, echo } = args;
|
|
34760
35035
|
const now = args.now ?? (() => new Date);
|
|
34761
35036
|
const ops = listUnsynced(getState());
|
|
34762
35037
|
let attempted = 0;
|
|
@@ -34789,7 +35064,7 @@ async function drainUnsyncedOps(args) {
|
|
|
34789
35064
|
state: getState(),
|
|
34790
35065
|
exists: op.op === "upsert",
|
|
34791
35066
|
unlinkContext: null,
|
|
34792
|
-
deps: { fetchImpl, now, mutex, output }
|
|
35067
|
+
deps: { fetchImpl, now, mutex, output, echo }
|
|
34793
35068
|
});
|
|
34794
35069
|
setState(result.state);
|
|
34795
35070
|
} else {
|
|
@@ -38304,6 +38579,11 @@ class PullSuppressor {
|
|
|
38304
38579
|
expiresAt: this.now() + this.ttlMs
|
|
38305
38580
|
});
|
|
38306
38581
|
}
|
|
38582
|
+
cancelDelete(absPath) {
|
|
38583
|
+
const entry2 = this.entries.get(absPath);
|
|
38584
|
+
if (entry2?.kind === "delete")
|
|
38585
|
+
this.entries.delete(absPath);
|
|
38586
|
+
}
|
|
38307
38587
|
shouldSuppressUpsert(absPath, hashes) {
|
|
38308
38588
|
const entry2 = this.entries.get(absPath);
|
|
38309
38589
|
if (!entry2)
|
|
@@ -38872,7 +39152,7 @@ async function reconcileDivergedRows(args) {
|
|
|
38872
39152
|
state,
|
|
38873
39153
|
exists: true,
|
|
38874
39154
|
unlinkContext: null,
|
|
38875
|
-
deps: { fetchImpl, now, output }
|
|
39155
|
+
deps: { fetchImpl, now, output, echo: args.echo }
|
|
38876
39156
|
});
|
|
38877
39157
|
state = result.state;
|
|
38878
39158
|
switch (result.outcome.kind) {
|
|
@@ -41373,7 +41653,13 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
41373
41653
|
let cursor = startCursor;
|
|
41374
41654
|
let lastAdvancedCursor = startCursor;
|
|
41375
41655
|
let cursorBlocked = false;
|
|
41376
|
-
const ctr = {
|
|
41656
|
+
const ctr = {
|
|
41657
|
+
fetched: 0,
|
|
41658
|
+
written: 0,
|
|
41659
|
+
deleted: 0,
|
|
41660
|
+
moved: 0,
|
|
41661
|
+
refused: 0
|
|
41662
|
+
};
|
|
41377
41663
|
while (true) {
|
|
41378
41664
|
const page = await syncEntity({
|
|
41379
41665
|
strategy,
|
|
@@ -41407,6 +41693,12 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
41407
41693
|
ctr.written++;
|
|
41408
41694
|
else if (outcome.action === "deleted")
|
|
41409
41695
|
ctr.deleted++;
|
|
41696
|
+
else if (outcome.action === "moved")
|
|
41697
|
+
ctr.moved = (ctr.moved ?? 0) + 1;
|
|
41698
|
+
else if (outcome.action === "slug_refused") {
|
|
41699
|
+
ctr.refused = (ctr.refused ?? 0) + 1;
|
|
41700
|
+
cursorBlocked = true;
|
|
41701
|
+
}
|
|
41410
41702
|
}
|
|
41411
41703
|
commitPending();
|
|
41412
41704
|
if (page.next_cursor === null)
|
|
@@ -41628,7 +41920,9 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
41628
41920
|
await syncFileRejections();
|
|
41629
41921
|
const totalWritten = Object.values(counters).reduce((sum, c) => sum + c.written, 0);
|
|
41630
41922
|
const totalDeleted = Object.values(counters).reduce((sum, c) => sum + c.deleted, 0);
|
|
41631
|
-
|
|
41923
|
+
const totalMoved = Object.values(counters).reduce((sum, c) => sum + (c.moved ?? 0), 0);
|
|
41924
|
+
const totalRefused = Object.values(counters).reduce((sum, c) => sum + (c.refused ?? 0), 0);
|
|
41925
|
+
if (totalWritten === 0 && totalDeleted === 0 && totalMoved === 0 && totalRefused === 0) {
|
|
41632
41926
|
out.success("Already up to date.");
|
|
41633
41927
|
return { exitCode: 0, applied: 0 };
|
|
41634
41928
|
}
|
|
@@ -41639,12 +41933,16 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
41639
41933
|
const parts = [`${c.fetched} fetched`];
|
|
41640
41934
|
if (c.written > 0)
|
|
41641
41935
|
parts.push(`${c.written} written`);
|
|
41936
|
+
if ((c.moved ?? 0) > 0)
|
|
41937
|
+
parts.push(`${c.moved} renamed`);
|
|
41938
|
+
if ((c.refused ?? 0) > 0)
|
|
41939
|
+
parts.push(`${c.refused} rename held`);
|
|
41642
41940
|
if (c.deleted > 0)
|
|
41643
41941
|
parts.push(`${c.deleted} deleted`);
|
|
41644
41942
|
out.info(` ${type}: ${parts.join(", ")}`);
|
|
41645
41943
|
}
|
|
41646
41944
|
out.success(`Done in ${elapsed}s.`);
|
|
41647
|
-
return { exitCode: 0, applied: totalWritten + totalDeleted };
|
|
41945
|
+
return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted };
|
|
41648
41946
|
}
|
|
41649
41947
|
function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
|
|
41650
41948
|
if (isFrozen?.(strategy.type, row.id)) {
|
|
@@ -41704,7 +42002,59 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
41704
42002
|
if (strategy.shouldMirror && !strategy.shouldMirror(live) && !existingEntry) {
|
|
41705
42003
|
return { state, action: "skipped" };
|
|
41706
42004
|
}
|
|
41707
|
-
|
|
42005
|
+
let relocation = { kind: "not_needed", writeSlug: live.slug };
|
|
42006
|
+
if (existingEntry && existingEntry.slug !== live.slug) {
|
|
42007
|
+
const echo = suppressor ? { watcher: "live", suppressor } : { watcher: "none" };
|
|
42008
|
+
relocation = relocateForSlugChange({
|
|
42009
|
+
workspaceSlug,
|
|
42010
|
+
strategy,
|
|
42011
|
+
fromSlug: existingEntry.slug,
|
|
42012
|
+
toSlug: live.slug,
|
|
42013
|
+
acked: {
|
|
42014
|
+
bodyHash: existingEntry.body_hash,
|
|
42015
|
+
frontmatterHash: existingEntry.frontmatter_hash
|
|
42016
|
+
},
|
|
42017
|
+
echo,
|
|
42018
|
+
now
|
|
42019
|
+
});
|
|
42020
|
+
const move = `entityType=${strategy.type} from=${existingEntry.slug} to=${live.slug} id=${live.id}`;
|
|
42021
|
+
switch (relocation.kind) {
|
|
42022
|
+
case "moved":
|
|
42023
|
+
out.log(`[SLUG_MOVE ${move}]`);
|
|
42024
|
+
state = clearSlugClash(state, [
|
|
42025
|
+
strategy.mirrorRelativePath(existingEntry.slug),
|
|
42026
|
+
strategy.mirrorRelativePath(live.slug)
|
|
42027
|
+
]);
|
|
42028
|
+
break;
|
|
42029
|
+
case "refused":
|
|
42030
|
+
out.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason}]`);
|
|
42031
|
+
const occupantTracked = getEntryBySlug(state, strategy.type, live.slug) !== undefined;
|
|
42032
|
+
state = recordEntitySlugClash({
|
|
42033
|
+
state,
|
|
42034
|
+
mirrorRelativePath: strategy.mirrorRelativePath(existingEntry.slug),
|
|
42035
|
+
entityType: strategy.type,
|
|
42036
|
+
fromSlug: existingEntry.slug,
|
|
42037
|
+
toSlug: live.slug,
|
|
42038
|
+
reason: relocation.reason,
|
|
42039
|
+
occupantTracked,
|
|
42040
|
+
now
|
|
42041
|
+
});
|
|
42042
|
+
out.warn(relocation.reason === "source_unreadable" ? `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(existingEntry.slug)} could ` + `not be read — it was left exactly where it is. Check its permissions.` : occupantTracked ? `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(live.slug)} belongs to ` + `another ${strategy.type} — nothing was moved or overwritten. This ` + `usually clears itself once that row's own rename arrives; do not ` + `delete the file, which would delete that record in Plinth.` : `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(live.slug)} already exists ` + `here — nothing was moved or overwritten. Rename or remove that file ` + `and Plinth will move this one into place.`);
|
|
42043
|
+
return { state, action: "slug_refused" };
|
|
42044
|
+
case "skipped":
|
|
42045
|
+
out.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason}]`);
|
|
42046
|
+
break;
|
|
42047
|
+
case "absent":
|
|
42048
|
+
case "not_needed":
|
|
42049
|
+
break;
|
|
42050
|
+
}
|
|
42051
|
+
const stashPath = relocation.kind === "moved" ? relocation.stashPath : undefined;
|
|
42052
|
+
if (stashPath) {
|
|
42053
|
+
out.warn(`Unsynced local edit on ${strategy.type}/${existingEntry.slug} → stashed ` + `to ${stashPath} (the row was renamed in Plinth; the cloud copy is ` + `authoritative).`);
|
|
42054
|
+
}
|
|
42055
|
+
}
|
|
42056
|
+
const writeSlug = relocation.writeSlug;
|
|
42057
|
+
if (existingEntry && existingEntry.updated_at === live.updated_at && relocation.kind === "not_needed") {
|
|
41708
42058
|
const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
|
|
41709
42059
|
if (existsSync7(path5)) {
|
|
41710
42060
|
if (strategy.type === "artists") {
|
|
@@ -41730,10 +42080,10 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
41730
42080
|
return { state, action: "noop_overwrite" };
|
|
41731
42081
|
}
|
|
41732
42082
|
}
|
|
41733
|
-
if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
|
|
42083
|
+
if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at && relocation.kind !== "moved") {
|
|
41734
42084
|
let localRaw = null;
|
|
41735
42085
|
try {
|
|
41736
|
-
localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type,
|
|
42086
|
+
localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, writeSlug), "utf8");
|
|
41737
42087
|
} catch {
|
|
41738
42088
|
localRaw = null;
|
|
41739
42089
|
}
|
|
@@ -41753,11 +42103,11 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
41753
42103
|
}
|
|
41754
42104
|
}
|
|
41755
42105
|
}
|
|
41756
|
-
const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor);
|
|
42106
|
+
const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor, writeSlug);
|
|
41757
42107
|
const hashes = computeMirrorHashes(strategy, content);
|
|
41758
42108
|
let inode;
|
|
41759
42109
|
try {
|
|
41760
|
-
inode = statSync16(entityFilePath(workspaceSlug, strategy.type,
|
|
42110
|
+
inode = statSync16(entityFilePath(workspaceSlug, strategy.type, writeSlug)).ino;
|
|
41761
42111
|
} catch {
|
|
41762
42112
|
inode = undefined;
|
|
41763
42113
|
}
|
|
@@ -41780,7 +42130,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
41780
42130
|
}
|
|
41781
42131
|
const { state: nextState } = upsertEntry(state, strategy.type, {
|
|
41782
42132
|
id: live.id,
|
|
41783
|
-
slug:
|
|
42133
|
+
slug: writeSlug,
|
|
41784
42134
|
updated_at: live.updated_at,
|
|
41785
42135
|
revision: live.revision,
|
|
41786
42136
|
body_hash: hashes.bodyHash,
|
|
@@ -41790,7 +42140,8 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
41790
42140
|
memory_hash: memoryHash,
|
|
41791
42141
|
memory_generated: memoryGenerated
|
|
41792
42142
|
});
|
|
41793
|
-
|
|
42143
|
+
const action = relocation.kind === "moved" ? "moved" : "written";
|
|
42144
|
+
return { state: nextState, action };
|
|
41794
42145
|
}
|
|
41795
42146
|
var syncCommand = defineCommand({
|
|
41796
42147
|
meta: {
|
|
@@ -42266,6 +42617,22 @@ function makeWatchHandler(deps) {
|
|
|
42266
42617
|
});
|
|
42267
42618
|
if (!classifiedFrom)
|
|
42268
42619
|
return;
|
|
42620
|
+
if (suppressor?.shouldSuppressDelete(event.fromPath)) {
|
|
42621
|
+
const strategy = getStrategy(classifiedFrom.entityType);
|
|
42622
|
+
let toRaw = null;
|
|
42623
|
+
try {
|
|
42624
|
+
toRaw = readFileSync25(event.toPath, "utf8");
|
|
42625
|
+
} catch {
|
|
42626
|
+
toRaw = null;
|
|
42627
|
+
}
|
|
42628
|
+
const isEcho = strategy !== undefined && toRaw !== null && suppressor.shouldSuppressUpsert(event.toPath, computeMirrorHashes(strategy, toRaw));
|
|
42629
|
+
if (isEcho) {
|
|
42630
|
+
output?.log(`[RENAME_SUPPRESSED entityType=${classifiedFrom.entityType} ` + `from=${event.fromSlug} id=${event.id} reason=pull_echo]`);
|
|
42631
|
+
return;
|
|
42632
|
+
}
|
|
42633
|
+
await handle({ kind: "upsert", absolutePath: event.toPath });
|
|
42634
|
+
return;
|
|
42635
|
+
}
|
|
42269
42636
|
const result2 = await pushRename({
|
|
42270
42637
|
workspaceSlug,
|
|
42271
42638
|
baseUrl,
|
|
@@ -42277,7 +42644,13 @@ function makeWatchHandler(deps) {
|
|
|
42277
42644
|
expectedUpdatedAt: event.expectedUpdatedAt,
|
|
42278
42645
|
fromSlug: event.fromSlug,
|
|
42279
42646
|
state: getState(),
|
|
42280
|
-
deps: {
|
|
42647
|
+
deps: {
|
|
42648
|
+
fetchImpl,
|
|
42649
|
+
now,
|
|
42650
|
+
mutex,
|
|
42651
|
+
output,
|
|
42652
|
+
echo: suppressor ? { watcher: "live", suppressor } : undefined
|
|
42653
|
+
}
|
|
42281
42654
|
});
|
|
42282
42655
|
setState(result2.state);
|
|
42283
42656
|
reportOutcome(spinner, workspaceSlug, result2.state, result2.outcome);
|
|
@@ -42409,7 +42782,14 @@ function makeWatchHandler(deps) {
|
|
|
42409
42782
|
slug: event.stateEntry.slug,
|
|
42410
42783
|
updated_at: event.stateEntry.updated_at
|
|
42411
42784
|
} : null,
|
|
42412
|
-
deps: {
|
|
42785
|
+
deps: {
|
|
42786
|
+
fetchImpl,
|
|
42787
|
+
now,
|
|
42788
|
+
mutex,
|
|
42789
|
+
output,
|
|
42790
|
+
suppressor,
|
|
42791
|
+
echo: suppressor ? { watcher: "live", suppressor } : undefined
|
|
42792
|
+
}
|
|
42413
42793
|
});
|
|
42414
42794
|
setState(clearEntityRejected(result.state, workspaceSlug, absolutePath));
|
|
42415
42795
|
reportOutcome(spinner, workspaceSlug, getState(), result.outcome);
|
|
@@ -42470,7 +42850,8 @@ async function flushHeldEntityOp(deps) {
|
|
|
42470
42850
|
fetchImpl: deps.fetchImpl,
|
|
42471
42851
|
now,
|
|
42472
42852
|
mutex: deps.mutex,
|
|
42473
|
-
output: deps.output
|
|
42853
|
+
output: deps.output,
|
|
42854
|
+
echo: deps.echo
|
|
42474
42855
|
}
|
|
42475
42856
|
});
|
|
42476
42857
|
deps.setState(result.state);
|
|
@@ -42606,6 +42987,7 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
42606
42987
|
frozenFiles = computeFrozenFiles(slug);
|
|
42607
42988
|
};
|
|
42608
42989
|
const recon = await reconcileDivergedRows({
|
|
42990
|
+
echo: { watcher: "none" },
|
|
42609
42991
|
workspaceSlug: slug,
|
|
42610
42992
|
baseUrl: entry2.api_base_url,
|
|
42611
42993
|
pat,
|
|
@@ -42819,6 +43201,7 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
42819
43201
|
spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
42820
43202
|
const mutex = new PerIdMutex;
|
|
42821
43203
|
const bootDrain = await drainResolvedBatches({
|
|
43204
|
+
echo: { watcher: "none" },
|
|
42822
43205
|
workspaceSlug: slug,
|
|
42823
43206
|
baseUrl: entry2.api_base_url,
|
|
42824
43207
|
pat,
|
|
@@ -42836,6 +43219,7 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
42836
43219
|
out.info(`Resolved ${bootDrain.applied + bootDrain.discarded} held batch${bootDrain.applied + bootDrain.discarded === 1 ? "" : "es"}: ${bootDrain.applied} applied, ${bootDrain.discarded} discarded.`);
|
|
42837
43220
|
}
|
|
42838
43221
|
const bootUnsynced = await drainUnsyncedOps({
|
|
43222
|
+
echo: { watcher: "none" },
|
|
42839
43223
|
workspaceSlug: slug,
|
|
42840
43224
|
baseUrl: entry2.api_base_url,
|
|
42841
43225
|
pat,
|
|
@@ -42873,6 +43257,7 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
42873
43257
|
};
|
|
42874
43258
|
const suppressor = new PullSuppressor;
|
|
42875
43259
|
const flushHeldOp = (op) => flushHeldEntityOp({
|
|
43260
|
+
echo: { watcher: "live", suppressor },
|
|
42876
43261
|
workspaceSlug: slug,
|
|
42877
43262
|
baseUrl: entry2.api_base_url,
|
|
42878
43263
|
pat,
|
|
@@ -43060,6 +43445,7 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
43060
43445
|
draining = true;
|
|
43061
43446
|
try {
|
|
43062
43447
|
const drained = await drainResolvedBatches({
|
|
43448
|
+
echo: { watcher: "live", suppressor },
|
|
43063
43449
|
workspaceSlug: slug,
|
|
43064
43450
|
baseUrl: entry2.api_base_url,
|
|
43065
43451
|
pat,
|
|
@@ -43077,6 +43463,7 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
43077
43463
|
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
43078
43464
|
}
|
|
43079
43465
|
const redriven = await drainUnsyncedOps({
|
|
43466
|
+
echo: { watcher: "live", suppressor },
|
|
43080
43467
|
workspaceSlug: slug,
|
|
43081
43468
|
baseUrl: entry2.api_base_url,
|
|
43082
43469
|
pat,
|