omgbase 0.11.1 → 0.13.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.
@@ -1,17 +1,19 @@
1
1
  import { parseArgs } from "node:util";
2
- import { docsUpdate, renderOpsetPlan, surfaceDocsUpdate } from "@omgbase/core";
2
+ import { applyEffectsDetailed, planEffects, referencePath, renderIdentityPlan, surfacePlan, surfaceReceipt } from "@omgbase/core";
3
3
  import { CliUsageError, EXIT_OK, renderHelp } from "../output.js";
4
- import { readContent, extractContentOpts } from "./_mutate.js";
4
+ import { readContent, extractContentOpts, receiptIds, defaultActor } from "./_mutate.js";
5
5
  import { cmdUpdate as cmdUpdateBlock } from "./mutate.js";
6
6
  import { remoteCall, docLocator, requireDoc } from "./_remote.js";
7
7
  // `omg update <target> (-f file | -)`: a polymorphic-target command (the omgbase
8
8
  // north-star — every command accepts any entity ref). A block id (b_…) replaces
9
9
  // that block's markdown (CAS-pinned, the low-level op). A doc id or path is the
10
- // note's docs.update: submit a complete proposed document; the engine reconciles
11
- // it against the current tree, preserves stable block ids for recognizably-same
12
- // structure, and commits the derived opset through the kernel write path. Global
13
- // --dry-run (or --plan) prints the plan (identity effects + summary), commits
14
- // nothing.
10
+ // whole-document update: the complete proposed document is ONE `set content`
11
+ // effect (spec/mutate §11.3; the `docs_update` tool's sugar, spec/surface 4.0) —
12
+ // the engine reconciles it against the current tree, preserves stable block ids
13
+ // for recognizably-same structure, and commits the derived ops as one
14
+ // transaction. The global --dry-run is the plan: it prints the identity
15
+ // consequences from the Plan's `preview.identity` (spec/cli §3.6, 4.0: every dry
16
+ // run is a plan and burns no id) and commits nothing.
15
17
  function runUpdate(cli, args) {
16
18
  // Peek at the target to dispatch: a `b_` token is a block replace — a token
17
19
  // that names no live block fails there as block_missing (spec/cli §6), never
@@ -28,18 +30,18 @@ async function runDocUpdate(cli, args) {
28
30
  const { values, positionals } = parseArgs({
29
31
  args: content.rest,
30
32
  allowPositionals: true,
31
- options: { actor: { type: "string" }, reason: { type: "string" }, plan: { type: "boolean" }, help: { type: "boolean" } },
33
+ options: { actor: { type: "string" }, reason: { type: "string" }, help: { type: "boolean" } },
32
34
  });
33
35
  if (values.help) {
34
36
  return renderHelp(cli, {
35
37
  name: "update",
36
38
  summary: "Replace a block's markdown (b_… target), or reconcile a whole document from complete new bytes (doc id/path)",
37
- usage: "update <block|doc> (-m <markdown> | -f <file> | -) [--plan] [--expect <hash>] [--reason <s>] [--actor <s>] [--dry-run]",
39
+ usage: "update <block|doc> (-m <markdown> | -f <file> | -) [--expect <hash>] [--reason <s>] [--actor <s>] [--dry-run]",
38
40
  options: [
39
41
  ["-m <markdown>", "content inline"],
40
42
  ["-f <file>", "content from a file"],
41
43
  ["-", "content from stdin"],
42
- ["--plan", "(doc) print the reconciliation opset — identity effects + summary — and commit nothing"],
44
+ ["--dry-run", "(doc) print the reconciliation plan — identity effects + summary — and commit nothing"],
43
45
  ["--expect <hash>", "(block) fail with stale_expectation unless the block's hash still matches"],
44
46
  ["--reason <s>", "commit reason recorded in history"],
45
47
  ["--actor <s>", "commit actor (default human:$USER)"],
@@ -50,54 +52,63 @@ async function runDocUpdate(cli, args) {
50
52
  if (!doc)
51
53
  throw new CliUsageError("update requires a <doc> (id or path)");
52
54
  const bytes = readContent(content);
53
- const dryRun = Boolean(values.plan) || cli.flags.dryRun;
54
- let opset;
55
- let result;
55
+ const dryRun = cli.flags.dryRun;
56
+ let plan;
57
+ let receipt;
56
58
  if (cli.flags.server) {
57
- // Remote: docs_update reconciles + commits server-side, returning the same
58
- // { opset, result } — render unchanged.
59
- const r = await remoteCall(cli, "docs_update", {
60
- ...docLocator(doc),
61
- content: bytes,
62
- ...(values.reason ? { reason: values.reason } : {}),
63
- ...(dryRun ? { dry_run: true } : {}),
64
- });
65
- opset = r.opset;
66
- result = r.result;
59
+ // Remote: the `docs_update` tool commits the `set content` server-side and
60
+ // returns the Receipt (the confirmation then lists the ids it minted, as a
61
+ // remote `apply` does); the dry run is the `plan` tool over the same effect.
62
+ if (dryRun)
63
+ plan = await remoteCall(cli, "plan", { effects: [{ op: "set", target: { kind: "document", ...docLocator(doc) }, field: "content", value: bytes }] });
64
+ else
65
+ receipt = await remoteCall(cli, "docs_update", { ...docLocator(doc), content: bytes, ...(values.reason ? { reason: values.reason } : {}) });
67
66
  }
68
67
  else {
69
68
  const ws = cli.workspace();
70
69
  const repo = cli.repo(ws);
71
70
  if (!repo.rootPath)
72
71
  throw new CliUsageError(`repo '${repo.slug}' has no filesystem source; 'update' needs a working tree`);
73
- const ctx = {
72
+ const effects = [{ op: "set", target: { kind: "document", id: requireDoc(ws.store, repo.repoId, doc) }, field: "content", value: bytes }];
73
+ const req = {
74
74
  repoId: repo.repoId,
75
75
  rootPath: repo.rootPath,
76
+ effects,
77
+ origin: { actor: values.actor ?? defaultActor(), ...(values.reason ? { reason: values.reason } : {}) },
76
78
  ...(ws.omgbaseDir ? { omgbaseDir: ws.omgbaseDir } : {}),
77
- ...(values.actor ? { actor: values.actor } : {}),
78
79
  };
79
- ({ opset, result } = surfaceDocsUpdate(docsUpdate(ws.store, ctx, requireDoc(ws.store, repo.repoId, doc), bytes, {
80
- dryRun,
81
- ...(values.reason ? { reason: values.reason } : {}),
82
- })));
80
+ if (dryRun) {
81
+ plan = surfacePlan(planEffects(ws.store, req));
82
+ }
83
+ else {
84
+ const res = applyEffectsDetailed(ws.store, req);
85
+ plan = surfacePlan(res.plan);
86
+ receipt = res.receipt === undefined ? undefined : surfaceReceipt(res.receipt);
87
+ }
83
88
  }
84
89
  if (cli.flags.mode !== "human") {
85
- cli.io.out(JSON.stringify({ opset, result }));
90
+ cli.io.out(JSON.stringify(receipt ?? plan));
86
91
  return EXIT_OK;
87
92
  }
88
- if (dryRun || result === null) {
89
- const s = opset.summary;
90
- cli.io.err(cli.style.dim(` plan for ${cli.style.accent(opset.target.path)}${opset.converges ? "" : " — DOES NOT CONVERGE (will not apply)"}`));
91
- cli.io.out(renderOpsetPlan(opset));
92
- void s;
93
+ if (receipt === undefined) {
94
+ // The plan: its one effect names the document; `preview.identity[<id>]` is
95
+ // the opset plan (one line per op, an empty line, the summary).
96
+ const target = plan.effects[0] && "target" in plan.effects[0] ? plan.effects[0].target : undefined;
97
+ const docId = target && target.kind === "document" ? target.id : undefined;
98
+ const path = target && target.kind === "document" ? referencePath(target.path) : doc;
99
+ const identity = docId !== undefined ? plan.preview?.identity?.[docId] : undefined;
100
+ cli.io.err(cli.style.dim(` plan for ${cli.style.accent(path)}`));
101
+ if (identity)
102
+ cli.io.out(renderIdentityPlan(identity));
93
103
  return EXIT_OK;
94
104
  }
105
+ // The Receipt confirmation (§4): one revision per touched document; every id
106
+ // the plan's results name, once each (a block appears in several ops' results
107
+ // — an update then a retile of the same block — and is listed a single time).
95
108
  const g = cli.render.g;
96
- cli.io.err(cli.style.dim(` ${cli.style.ok(g.ok)} updated ${cli.style.accent(opset.target.path)} · ${opset.summary.preserved} preserved, ${opset.summary.updated} updated, ${opset.summary.moved} moved, ${opset.summary.created} created, ${opset.summary.removed} removed`));
97
- // Every id the opset touched, once each: a block appears in several ops'
98
- // results (an update then a retile of the same block), the confirmation
99
- // lists it a single time.
100
- for (const id of new Set(result.results.flatMap((r) => r.ids)))
109
+ const n = receipt.revisions.length;
110
+ cli.io.err(cli.style.dim(` ${cli.style.ok(g.ok)} committed · ${n} document${n === 1 ? "" : "s"} touched`));
111
+ for (const id of new Set(receiptIds(receipt, plan)))
101
112
  cli.io.out(id);
102
113
  return EXIT_OK;
103
114
  }
@@ -13,7 +13,7 @@ import { cmdDiff } from "./cmd/diff.js";
13
13
  import { cmdLinks } from "./cmd/links.js";
14
14
  import { cmdSync } from "./cmd/sync.js";
15
15
  import { cmdMcp } from "./cmd/mcp.js";
16
- import { cmdApply, cmdInsert, cmdMove, cmdRm, cmdDone, cmdAppend, cmdSplit, cmdMerge } from "./cmd/mutate.js";
16
+ import { cmdApply, cmdExecute, cmdInsert, cmdMove, cmdRm, cmdDone, cmdAppend, cmdSplit, cmdMerge } from "./cmd/mutate.js";
17
17
  import { cmdEdit } from "./cmd/edit.js";
18
18
  import { cmdRetarget } from "./cmd/retarget.js";
19
19
  import { cmdNode } from "./cmd/node.js";
@@ -40,6 +40,7 @@ export const COMMANDS = [
40
40
  cmdDiff,
41
41
  cmdLinks,
42
42
  cmdApply,
43
+ cmdExecute,
43
44
  cmdInsert,
44
45
  cmdUpdateDoc,
45
46
  cmdEdit,
@@ -85,8 +85,9 @@ export const REMOTE_OK = new Set([
85
85
  // doc-level mutators
86
86
  "new", "mv", "meta", "rm", "update", "retarget",
87
87
  // block-level sugar (server-side ref resolution + CAS via the blocks_* /
88
- // blocks_check / blocks_append / nodes_set / apply tools)
89
- "apply", "insert", "move", "split", "merge", "done", "append", "node",
88
+ // blocks_check / blocks_append / nodes_set / apply tools); `execute` is the
89
+ // `execute` tool's plan form
90
+ "apply", "execute", "insert", "move", "split", "merge", "done", "append", "node",
90
91
  // shell accepts --server itself, then threads it into every line it runs.
91
92
  "shell",
92
93
  // version asks the REMOTE engine which engine/versions it is (spec/cli §6).
@@ -1,4 +1,4 @@
1
- import { freshnessSweep, watchLeaseLive } from "@omgbase/core";
1
+ import { freshnessSweep, watchLeaseLive, withWriterLock } from "@omgbase/core";
2
2
  import { NO_WORKSPACE_OK, SKIP_FRESHNESS, REMOTE_OK, asksForHelp } from "./context.js";
3
3
  import { CliUsageError, EngineErrorLike, renderError, usageFromParseArgs } from "./output.js";
4
4
  import { resolveCommand, unknownCommandError } from "./commands.js";
@@ -135,7 +135,11 @@ export async function runCommand(cli, command, rest) {
135
135
  try {
136
136
  // Freshness sweep (11 §3.3): current-by-default, unless --stale, a live
137
137
  // watcher holds the lease, remote (`--server`) mode (no local tree to sweep),
138
- // or the command manages sync itself.
138
+ // or the command manages sync itself. The sweep is a checkpoint ingest, so it
139
+ // runs under the workspace writer lock (spec/sync §7) like every other
140
+ // host's; a writer that follows re-enters the lock. A lock held past the
141
+ // wait fails the command as the catch-all engine error (exit 1), like any
142
+ // write would — `--stale` skips the sweep and so never contends.
139
143
  if (!help && !cli.flags.stale && cli.flags.server === undefined && !SKIP_FRESHNESS.has(resolved.name) && !NO_WORKSPACE_OK.has(resolved.name)) {
140
144
  const ws = cli.workspace();
141
145
  if (!watchLeaseLive(ws.omgbaseDir)) {
@@ -145,8 +149,9 @@ export async function runCommand(cli, command, rest) {
145
149
  // repo_not_found if it truly needs a repo.
146
150
  try {
147
151
  const repo = cli.repo(ws);
148
- if (repo.rootPath)
149
- freshnessSweep(ws.store, repo.repoId, repo.rootPath);
152
+ const root = repo.rootPath;
153
+ if (root)
154
+ withWriterLock(ws.omgbaseDir, () => freshnessSweep(ws.store, repo.repoId, root));
150
155
  }
151
156
  catch (err) {
152
157
  if (!(err instanceof EngineErrorLike && err.code === "repo_not_found"))
@@ -1,8 +1,11 @@
1
+ export declare const WRITER_LOCK_TIMEOUT_ENV = "OMGBASE_WRITER_LOCK_TIMEOUT_MS";
1
2
  export interface SpecSeams {
2
3
  /** the sequential minter is installed */
3
4
  minter: boolean;
4
5
  /** the pinned instant (canonical ISO form), when the clock is pinned */
5
6
  clock: string | null;
7
+ /** the writer lock's wait in ms, when overridden */
8
+ writerLockTimeoutMs: number | null;
6
9
  }
7
10
  /**
8
11
  * Read + install the seams (idempotent: a second call returns what the first
package/dist/src/seams.js CHANGED
@@ -1,5 +1,32 @@
1
- import { pinClock, sequentialMinter, setIdMinter } from "@omgbase/core";
1
+ import { pinClock, sequentialMinter, setIdMinter, setWriterLockDefaults } from "@omgbase/core";
2
2
  import { CliUsageError } from "./output.js";
3
+ // The two conformance seams of spec/surface §7.1 (and spec/cli §2.6), read
4
+ // from the environment at process start — BEFORE any workspace opens, so no id
5
+ // is minted and no timestamp read without them. Every verb honors them (the
6
+ // CLI fixtures of spec/cli run each verb under them); `omg mcp` additionally
7
+ // announces them on stderr. For conformance runs only — never set in production:
8
+ //
9
+ // OMGBASE_SPEC_MINTER=sequential the fixture minter of spec/store §2.2 for
10
+ // the whole process (`d_0, d_1, …`, each
11
+ // prefix from 0); any other value is a
12
+ // usage error (exit 2)
13
+ // OMGBASE_SPEC_CLOCK=<RFC 3339> "now" is that instant for the whole
14
+ // process: every commit a verb stamps, every
15
+ // `ts` reported as current; not an instant →
16
+ // usage error (exit 2)
17
+ //
18
+ // A third seam is this engine's own, not the spec's (the Rust binary reads the
19
+ // same variable):
20
+ //
21
+ // OMGBASE_WRITER_LOCK_TIMEOUT_MS=<n> how long a write waits for the workspace
22
+ // writer lock (spec/sync §7: 5 s when unset)
23
+ // — for a test that proves a held lock fails
24
+ // a write without sitting out the full wait.
25
+ // Honored by every lock this process takes
26
+ // (a verb's kernel-level lock, `omg mcp`'s
27
+ // tool boundary, the watcher's checkpoints);
28
+ // empty = unset, not an integer → usage error
29
+ export const WRITER_LOCK_TIMEOUT_ENV = "OMGBASE_WRITER_LOCK_TIMEOUT_MS";
3
30
  let installed = null;
4
31
  /**
5
32
  * Read + install the seams (idempotent: a second call returns what the first
@@ -8,7 +35,7 @@ let installed = null;
8
35
  export function installSpecSeams(env = process.env) {
9
36
  if (installed)
10
37
  return installed;
11
- const seams = { minter: false, clock: null };
38
+ const seams = { minter: false, clock: null, writerLockTimeoutMs: null };
12
39
  const minter = env.OMGBASE_SPEC_MINTER;
13
40
  if (minter !== undefined && minter !== "") {
14
41
  if (minter !== "sequential") {
@@ -27,11 +54,20 @@ export function installSpecSeams(env = process.env) {
27
54
  }
28
55
  seams.clock = new Date().toISOString();
29
56
  }
57
+ const lockWait = env[WRITER_LOCK_TIMEOUT_ENV];
58
+ if (lockWait !== undefined && lockWait !== "") {
59
+ const ms = /^\s*\d+\s*$/.test(lockWait) ? Number(lockWait) : NaN;
60
+ if (!Number.isSafeInteger(ms)) {
61
+ throw new CliUsageError(`${WRITER_LOCK_TIMEOUT_ENV}=${JSON.stringify(lockWait)}: not a number of milliseconds`, `unset it, or e.g. ${WRITER_LOCK_TIMEOUT_ENV}=200 for a test run`);
62
+ }
63
+ setWriterLockDefaults({ timeoutMs: ms });
64
+ seams.writerLockTimeoutMs = ms;
65
+ }
30
66
  installed = seams;
31
67
  return seams;
32
68
  }
33
69
  /** The seams installed by `installSpecSeams` (none when it has not run). */
34
70
  export function activeSpecSeams() {
35
- return installed ?? { minter: false, clock: null };
71
+ return installed ?? { minter: false, clock: null, writerLockTimeoutMs: null };
36
72
  }
37
73
  //# sourceMappingURL=seams.js.map
@@ -43,16 +43,9 @@ export function deriveRows(value) {
43
43
  return o.hits.map(rowOf); // query/oqx
44
44
  if (Array.isArray(o.digests))
45
45
  return o.digests.map(rowOf); // log
46
- if (Array.isArray(o.results) && Array.isArray(o.revisions)) {
47
- // ApplyResult: the minted/affected ids, flattened (one row per id).
48
- const ids = [];
49
- for (const r of o.results) {
50
- if (Array.isArray(r.ids))
51
- for (const id of r.ids)
52
- if (typeof id === "string")
53
- ids.push(id);
54
- }
55
- return ids.map((id) => ({ ref: id, label: id, value: id }));
46
+ if (typeof o.plan_id === "string" && Array.isArray(o.ids)) {
47
+ // A write's Receipt as the verbs capture it: the ids the write names (one row per id).
48
+ return o.ids.filter((id) => typeof id === "string").map((id) => ({ ref: id, label: id, value: id }));
56
49
  }
57
50
  if (Array.isArray(o.in) || Array.isArray(o.out)) {
58
51
  // links: both directions, each edge's far node is the selectable ref.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omgbase",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Open Markdown Graph Base — CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,9 +45,9 @@
45
45
  "omg": "./dist/src/main.js"
46
46
  },
47
47
  "dependencies": {
48
- "@omgbase/core": "^0.11.1",
48
+ "@omgbase/core": "^0.15.0",
49
49
  "@omgbase/fs-adapter": "^0.2.0",
50
- "@omgbase/sync": "^0.5.1",
50
+ "@omgbase/sync": "^0.5.8",
51
51
  "yaml": "^2.9.0"
52
52
  },
53
53
  "devDependencies": {