@omgbase/core 0.19.0 → 0.21.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/dist/src/core/ingest.d.ts +2 -0
- package/dist/src/core/ingest.js +13 -0
- package/dist/src/core/read/document.d.ts +9 -8
- package/dist/src/core/read/document.js +0 -2
- package/dist/src/core/store/gc.d.ts +13 -1
- package/dist/src/core/store/gc.js +24 -5
- package/dist/src/core/store/size.d.ts +4 -1
- package/dist/src/core/store/size.js +11 -2
- package/dist/src/mcp/arguments.d.ts +10 -2
- package/dist/src/mcp/arguments.js +37 -3
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +36 -4
- package/dist/src/mcp/server.js +50 -23
- package/dist/src/mutate/effects.d.ts +23 -5
- package/dist/src/mutate/effects.js +111 -22
- package/dist/src/mutate/known-ids.d.ts +19 -0
- package/dist/src/mutate/known-ids.js +47 -7
- package/dist/src/mutate/ops.d.ts +1 -0
- package/dist/src/mutate/ops.js +20 -1
- package/dist/src/mutate/select.d.ts +0 -11
- package/dist/src/mutate/select.js +33 -9
- package/dist/src/oqx-js/context.d.ts +9 -2
- package/dist/src/oqx-js/context.js +74 -28
- package/dist/src/oqx-js/names.d.ts +40 -0
- package/dist/src/oqx-js/names.js +347 -0
- package/dist/src/oqx-js/planner.d.ts +3 -3
- package/dist/src/oqx-js/planner.js +13 -9
- package/dist/src/oqx-js/root-row.d.ts +15 -4
- package/dist/src/oqx-js/root-row.js +18 -10
- package/dist/src/oqx-js/run.d.ts +18 -1
- package/dist/src/oqx-js/run.js +31 -12
- package/dist/src/oqx-js/sql/translate.js +1 -1
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +4 -4
- package/dist/src/sync/observe.js +9 -3
- package/package.json +2 -2
package/dist/src/mcp/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
|
|
|
12
12
|
import { graphNeighborhood } from "./graph.js";
|
|
13
13
|
import { textSearch } from "../search/text.js";
|
|
14
14
|
import { EngineError, errorBody } from "./errors.js";
|
|
15
|
-
import { argumentError, validateArguments } from "./arguments.js";
|
|
15
|
+
import { argumentError, stringList, validateArguments } from "./arguments.js";
|
|
16
16
|
import { linksRetarget, linksRepair } from "../mutate/macros.js";
|
|
17
17
|
import { canonicalDocPath } from "../mutate/docs.js";
|
|
18
18
|
import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
|
|
@@ -84,7 +84,7 @@ const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
|
|
|
84
84
|
function selectorArgs(args) {
|
|
85
85
|
return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
|
|
86
86
|
}
|
|
87
|
-
const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }
|
|
87
|
+
const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target: document, field: \"frontmatter.<key>\", expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }. Target: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"attrs.checked\"; a document's \"frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
|
|
88
88
|
// An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
|
|
89
89
|
// each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
|
|
90
90
|
// a rung is a line beginning `{` with the call's arguments as JSON, three
|
|
@@ -99,6 +99,10 @@ const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "uns
|
|
|
99
99
|
// thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
|
|
100
100
|
// `mcp/examples.test.ts`).
|
|
101
101
|
const ladder = (...lines) => lines.join("\n");
|
|
102
|
+
// The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
|
|
103
|
+
// the spec minter gives `/processes/calcination.md`'s ingest revision over the
|
|
104
|
+
// alchemy corpus (`examples.json` runs the rung before any write).
|
|
105
|
+
const REVISION_RUNG = "{\"path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: from revisions where path == \"/processes/calcination.md\")";
|
|
102
106
|
function ok(payload) {
|
|
103
107
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
104
108
|
}
|
|
@@ -236,6 +240,15 @@ export function buildServer(ctx) {
|
|
|
236
240
|
}
|
|
237
241
|
return (byId ?? byPath).docId;
|
|
238
242
|
}
|
|
243
|
+
// `docs_read { at }` and its sugar `docs_read_at` (spec/surface §2, 4.7): the
|
|
244
|
+
// document AS OF a revision, `spec/store` §6.2 reconstruct; a revision that is
|
|
245
|
+
// not the document's is `target_missing`.
|
|
246
|
+
function docsReadAt(docId, rev) {
|
|
247
|
+
const res = readDocumentAtRevision(store, docId, rev);
|
|
248
|
+
if (!res)
|
|
249
|
+
throw new EngineError("target_missing", `no revision ${JSON.stringify(rev)} for document ${docId}`, { data: { doc: docId, rev } });
|
|
250
|
+
return res;
|
|
251
|
+
}
|
|
239
252
|
// The owning document of a block id (`blocks_read`): the block must be live.
|
|
240
253
|
function docOfBlock(blockId) {
|
|
241
254
|
const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
|
|
@@ -415,16 +428,26 @@ export function buildServer(ctx) {
|
|
|
415
428
|
}
|
|
416
429
|
});
|
|
417
430
|
tool("docs_read", {
|
|
418
|
-
description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
|
|
431
|
+
description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
|
|
419
432
|
inputSchema: {
|
|
420
433
|
...DOC_ARGS,
|
|
421
434
|
include_ids: z.boolean().optional(),
|
|
435
|
+
at: z.string().optional(),
|
|
422
436
|
...REPO_ARG,
|
|
423
437
|
},
|
|
424
438
|
}, async (args) => {
|
|
425
439
|
try {
|
|
426
440
|
const { repoId } = repoScope(args.repo);
|
|
427
|
-
const
|
|
441
|
+
const shape = registry.get("docs_read").shape;
|
|
442
|
+
const docId = locateDoc(repoId, "docs_read", shape, args);
|
|
443
|
+
if (args.at !== undefined) {
|
|
444
|
+
// `at` (4.7): the document AS OF that revision — `docs_read_at`'s
|
|
445
|
+
// code path, so the two return the same bytes. Block ids at a past
|
|
446
|
+
// revision are a later question: `include_ids` is refused with `at`.
|
|
447
|
+
if (args.include_ids)
|
|
448
|
+
throw argumentError("docs_read", "`include_ids` is not available with `at`", shape);
|
|
449
|
+
return ok(docsReadAt(docId, args.at));
|
|
450
|
+
}
|
|
428
451
|
const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
|
|
429
452
|
if (!res)
|
|
430
453
|
throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
|
|
@@ -437,7 +460,7 @@ export function buildServer(ctx) {
|
|
|
437
460
|
tool("docs_read_many", {
|
|
438
461
|
description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`raw` = complete file bytes verbatim, `properties` grouped by source, plus `id`/`path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `id` → block `hash`} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/blocks_search/blocks_resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline (docs_get_many before surface 3.0).",
|
|
439
462
|
inputSchema: {
|
|
440
|
-
docs:
|
|
463
|
+
docs: stringList(),
|
|
441
464
|
include_ids: z.boolean().optional(),
|
|
442
465
|
budget_tokens: z.number().int().optional(),
|
|
443
466
|
...REPO_ARG,
|
|
@@ -502,7 +525,7 @@ export function buildServer(ctx) {
|
|
|
502
525
|
tool("blocks_read_many", {
|
|
503
526
|
description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
|
|
504
527
|
inputSchema: {
|
|
505
|
-
ids:
|
|
528
|
+
ids: stringList(),
|
|
506
529
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
507
530
|
budget_tokens: z.number().int().optional(),
|
|
508
531
|
...REPO_ARG,
|
|
@@ -573,7 +596,7 @@ export function buildServer(ctx) {
|
|
|
573
596
|
inputSchema: {},
|
|
574
597
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
575
598
|
tool("query", {
|
|
576
|
-
description: ladder("query —
|
|
599
|
+
description: ladder("query — rows (id, path, projection) from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where \\\"process\\\" in tags order by path limit 5\"} frontmatter; membership; order, limit", "{\"query\":\"select type, text from blocks where path == \\\"/processes/calcination.md\\\"\"} a document's blocks in order", "{\"query\":\"select block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id", "{\"query\":\"select path, value from nodes where kind == \\\"md:task\\\" and not checked\"} open tasks", "{\"query\":\"select path, block_id from links where dst_path == \\\"/processes/calcination.md\\\"\"} inbound links (path = source)", "{\"query\":\"select path from docs where title ~* \\\"salt\\\"\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
|
|
577
600
|
inputSchema: {
|
|
578
601
|
query: z.string(),
|
|
579
602
|
limit: z.number().int().optional(),
|
|
@@ -601,11 +624,11 @@ export function buildServer(ctx) {
|
|
|
601
624
|
tool("graph", {
|
|
602
625
|
description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow out_links collect { from dst }` / `follow in_links collect { from doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"path\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `id`/`path`/`degree` = min hops from a root/`frontier` bool + your projections — a bare-name selection such as \"title\" is its own key, `id`/`path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `id`, `doc_id`/`block_id` (the source), `via_id`, `dst_id`, `dst_path`, `uri`, `dst_kind`, `predicate`, `provenance`, `anchor`, `field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ id, path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
|
|
603
626
|
inputSchema: {
|
|
604
|
-
roots:
|
|
627
|
+
roots: stringList({ min: 1 }),
|
|
605
628
|
degrees: z.number().int().optional(),
|
|
606
629
|
direction: z.enum(["in", "out", "both"]).optional(),
|
|
607
630
|
predicate: z.string().optional(),
|
|
608
|
-
select:
|
|
631
|
+
select: stringList().optional(),
|
|
609
632
|
max_documents: z.number().int().optional(),
|
|
610
633
|
...REPO_ARG,
|
|
611
634
|
},
|
|
@@ -678,14 +701,21 @@ export function buildServer(ctx) {
|
|
|
678
701
|
// commits exactly a plan (the plan form, or the digest form `plan_id` +
|
|
679
702
|
// `effects`); `apply { effects }` above is plan + execute in one call.
|
|
680
703
|
tool("plan", {
|
|
681
|
-
description: ladder("plan — Preview a write: the
|
|
682
|
-
inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
|
|
704
|
+
description: ladder("plan — Preview a write: the Plan (summary, $new.n ids) burns no id; execute next. diffs: true for file bodies.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target: the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget; grammar: query_syntax."),
|
|
705
|
+
inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
|
|
683
706
|
}, async (args) => {
|
|
684
707
|
try {
|
|
685
708
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
686
709
|
if (!rootPath)
|
|
687
710
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
688
|
-
|
|
711
|
+
const plan = surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR }));
|
|
712
|
+
// (4.5) `preview.diffs` — whole before/after bodies of every touched
|
|
713
|
+
// document — only on request: `diffs: true`. The digest is over the
|
|
714
|
+
// core, never the preview, so `plan_id` is the same either way.
|
|
715
|
+
if (args.diffs === true || plan.preview === undefined)
|
|
716
|
+
return ok(plan);
|
|
717
|
+
const { diffs: _diffs, ...preview } = plan.preview;
|
|
718
|
+
return ok({ ...plan, preview });
|
|
689
719
|
}
|
|
690
720
|
catch (e) {
|
|
691
721
|
return fail(e);
|
|
@@ -762,7 +792,7 @@ export function buildServer(ctx) {
|
|
|
762
792
|
});
|
|
763
793
|
tool("blocks_move", {
|
|
764
794
|
description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
|
|
765
|
-
inputSchema: { blocks:
|
|
795
|
+
inputSchema: { blocks: stringList(), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
|
|
766
796
|
}, async (args) => {
|
|
767
797
|
try {
|
|
768
798
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -783,7 +813,7 @@ export function buildServer(ctx) {
|
|
|
783
813
|
});
|
|
784
814
|
tool("blocks_remove", {
|
|
785
815
|
description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
|
|
786
|
-
inputSchema: { blocks:
|
|
816
|
+
inputSchema: { blocks: stringList(), ...REPO_ARG },
|
|
787
817
|
}, async (args) => {
|
|
788
818
|
try {
|
|
789
819
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -813,7 +843,7 @@ export function buildServer(ctx) {
|
|
|
813
843
|
});
|
|
814
844
|
tool("blocks_merge", {
|
|
815
845
|
description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
|
|
816
|
-
inputSchema: { blocks:
|
|
846
|
+
inputSchema: { blocks: stringList(), separator: z.string().optional(), ...REPO_ARG },
|
|
817
847
|
}, async (args) => {
|
|
818
848
|
try {
|
|
819
849
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -828,7 +858,7 @@ export function buildServer(ctx) {
|
|
|
828
858
|
});
|
|
829
859
|
tool("blocks_check", {
|
|
830
860
|
description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
|
|
831
|
-
inputSchema: { ids:
|
|
861
|
+
inputSchema: { ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
|
|
832
862
|
}, async (args) => {
|
|
833
863
|
try {
|
|
834
864
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -1056,7 +1086,7 @@ export function buildServer(ctx) {
|
|
|
1056
1086
|
});
|
|
1057
1087
|
tool("docs_set_meta", {
|
|
1058
1088
|
description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
|
|
1059
|
-
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset:
|
|
1089
|
+
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
|
|
1060
1090
|
}, async (args) => {
|
|
1061
1091
|
try {
|
|
1062
1092
|
const scope = repoScope(args.repo);
|
|
@@ -1127,7 +1157,7 @@ export function buildServer(ctx) {
|
|
|
1127
1157
|
}
|
|
1128
1158
|
});
|
|
1129
1159
|
tool("blocks_history", {
|
|
1130
|
-
description: "A block's biography: the commits that
|
|
1160
|
+
description: "A block's biography: the commits that CHANGED the block `id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
|
|
1131
1161
|
inputSchema: { id: z.string(), limit: z.number().int().optional() },
|
|
1132
1162
|
}, async (args) => {
|
|
1133
1163
|
try {
|
|
@@ -1172,16 +1202,13 @@ export function buildServer(ctx) {
|
|
|
1172
1202
|
}
|
|
1173
1203
|
});
|
|
1174
1204
|
tool("docs_read_at", {
|
|
1175
|
-
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. `properties
|
|
1205
|
+
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Sugar for `docs_read { id | path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`from revisions where path == … order by seq`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `id` or `path` plus a `rev` id.",
|
|
1176
1206
|
inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
|
|
1177
1207
|
}, async (args) => {
|
|
1178
1208
|
try {
|
|
1179
1209
|
const { repoId } = repoScope(args.repo);
|
|
1180
1210
|
const docId = locateDoc(repoId, "docs_read_at", registry.get("docs_read_at").shape, args);
|
|
1181
|
-
|
|
1182
|
-
if (!res)
|
|
1183
|
-
throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
|
|
1184
|
-
return ok(res);
|
|
1211
|
+
return ok(docsReadAt(docId, args.rev));
|
|
1185
1212
|
}
|
|
1186
1213
|
catch (e) {
|
|
1187
1214
|
return fail(e);
|
|
@@ -8,6 +8,7 @@ import { type LinkRepairCount, type RetargetHit } from "./macros.js";
|
|
|
8
8
|
import type { At, Expect } from "./ops.js";
|
|
9
9
|
import type { IdentityPreview } from "./opset.js";
|
|
10
10
|
import { MutationError } from "./tree.js";
|
|
11
|
+
import { type FixRequest } from "./canonical.js";
|
|
11
12
|
/** What an effect addresses. `section` is a derived range (ADR-005), never a stored id.
|
|
12
13
|
* `symbol` (3.2, §11.8 "Sugar"): a bare `"$new.n"` target whose kind — document or block — the
|
|
13
14
|
* symbol table decides when the effect is resolved; it never reaches a resolved effect. */
|
|
@@ -172,8 +173,25 @@ export type ResolvedEffect = {
|
|
|
172
173
|
}[];
|
|
173
174
|
path_glob?: string;
|
|
174
175
|
};
|
|
175
|
-
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }
|
|
176
|
-
export declare function effectInvalid(message: string): MutationError;
|
|
176
|
+
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }` — with a `fix` (3.4, §8) when the shape check can spell the corrected call. */
|
|
177
|
+
export declare function effectInvalid(message: string, fix?: string): MutationError;
|
|
178
|
+
/**
|
|
179
|
+
* What the shape check needs to spell a `fix` for the shape errors of §8
|
|
180
|
+
* (3.4): the request as given (the corrected call is that request with the
|
|
181
|
+
* offending effect rewritten — the effect at `effects[i]` AS WRITTEN, before
|
|
182
|
+
* any desugaring) and a live-row lookup for the one fix that needs the store:
|
|
183
|
+
* `parentOf(block id)` → the block's parent id (`null` at the top level) and
|
|
184
|
+
* its document's path (storage form), or `null` when no live block has the id
|
|
185
|
+
* (then there is no fix). The planner's compile (`select.ts`) supplies it; a
|
|
186
|
+
* library call without it gets the same errors with no `fix`.
|
|
187
|
+
*/
|
|
188
|
+
export interface ShapeFixContext {
|
|
189
|
+
request: FixRequest;
|
|
190
|
+
parentOf: (blockId: string) => {
|
|
191
|
+
parent: string | null;
|
|
192
|
+
path: string;
|
|
193
|
+
} | null;
|
|
194
|
+
}
|
|
177
195
|
/** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
|
|
178
196
|
export declare const EXPRESSION_LEADER = "-> ";
|
|
179
197
|
/** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
|
|
@@ -191,10 +209,10 @@ export declare function isTargetId(s: string): boolean;
|
|
|
191
209
|
*/
|
|
192
210
|
export declare function bareTarget(ref: string, here: string): EffectTarget;
|
|
193
211
|
export declare function validateTarget(v: unknown, here: string, kinds: readonly EffectTarget["kind"][]): EffectTarget;
|
|
194
|
-
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
|
|
195
|
-
export declare function validateEffect(v: unknown, i: number): Effect;
|
|
212
|
+
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`); `fix` (3.4) lets the shape errors of §8 carry their corrected call. */
|
|
213
|
+
export declare function validateEffect(v: unknown, i: number, fix?: ShapeFixContext): Effect;
|
|
196
214
|
/** The whole list; a non-array or an empty list is a shape error too. */
|
|
197
|
-
export declare function validateEffects(v: unknown): Effect[];
|
|
215
|
+
export declare function validateEffects(v: unknown, fix?: ShapeFixContext): Effect[];
|
|
198
216
|
export declare const FRONTMATTER_FIELD: RegExp;
|
|
199
217
|
/** The focus a conflict names: `{ kind, target, field? }`. */
|
|
200
218
|
export interface Focus {
|
|
@@ -8,6 +8,7 @@ import { canonicalDocPath, composeFile, parseNewDoc, planInboundRetarget, splitF
|
|
|
8
8
|
import { linksRepair, nodeSet } from "./macros.js";
|
|
9
9
|
import { planUpdate } from "./plan-update.js";
|
|
10
10
|
import { MutationError, rawHashHex, renderDoc } from "./tree.js";
|
|
11
|
+
import { fixRequest, withMember } from "./canonical.js";
|
|
11
12
|
export const EFFECT_OPS = ["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"];
|
|
12
13
|
/** `$new.<n>`: an identity an earlier effect of the same request creates (§11.1). */
|
|
13
14
|
export const NEW_SYMBOL_RE = /^\$new\.(0|[1-9][0-9]*)$/;
|
|
@@ -18,10 +19,86 @@ export function isNewSymbol(s) {
|
|
|
18
19
|
function isRecord(v) {
|
|
19
20
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
20
21
|
}
|
|
21
|
-
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }
|
|
22
|
-
export function effectInvalid(message) {
|
|
23
|
-
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
|
|
22
|
+
/** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }` — with a `fix` (3.4, §8) when the shape check can spell the corrected call. */
|
|
23
|
+
export function effectInvalid(message, fix) {
|
|
24
|
+
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments", ...(fix !== undefined ? { fix } : {}) });
|
|
24
25
|
}
|
|
26
|
+
function fixerFor(ctx, i, v) {
|
|
27
|
+
if (ctx === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const list = Array.isArray(ctx.request.effects) ? ctx.request.effects : undefined;
|
|
30
|
+
const written = list !== undefined && isRecord(list[i]) ? list[i] : v;
|
|
31
|
+
return {
|
|
32
|
+
written,
|
|
33
|
+
build: (effect, expect) => fixRequest({
|
|
34
|
+
...ctx.request,
|
|
35
|
+
effects: withMember(ctx.request.effects, [String(i)], effect),
|
|
36
|
+
...(expect !== undefined ? { expect: expect.value } : {}),
|
|
37
|
+
}),
|
|
38
|
+
parentOf: ctx.parentOf,
|
|
39
|
+
request: ctx.request,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** A record whose every value is a string. */
|
|
43
|
+
function isStringRecord(v) {
|
|
44
|
+
return isRecord(v) && Object.values(v).every((x) => typeof x === "string");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* §8 (3.4): an effect-level `expect.max` — the bound belongs to the request's
|
|
48
|
+
* top-level `expect`, beside `select`. The fix drops it from the effect (the
|
|
49
|
+
* rest of that `expect` stays; an emptied `expect` goes) and, when the request
|
|
50
|
+
* has a `select`, moves it to the top level unless a top-level `expect.max` is
|
|
51
|
+
* already there (then the top-level one is kept as written); without a
|
|
52
|
+
* `select` a top-level `expect` would itself be refused, so the fix only drops it.
|
|
53
|
+
*/
|
|
54
|
+
function effectLevelMax(v, here, f) {
|
|
55
|
+
const message = `${here}: \`expect.max\` bounds a selection — it is the request's top-level \`expect\`, beside \`select\`, not an effect's`;
|
|
56
|
+
if (f === undefined)
|
|
57
|
+
return effectInvalid(message);
|
|
58
|
+
const { max, ...rest } = v;
|
|
59
|
+
const effect = { ...f.written, expect: Object.keys(rest).length > 0 ? rest : undefined };
|
|
60
|
+
if (f.request.select === undefined)
|
|
61
|
+
return effectInvalid(message, f.build(effect));
|
|
62
|
+
const top = isRecord(f.request.expect) ? f.request.expect : {};
|
|
63
|
+
return effectInvalid(message, f.build(effect, { value: "max" in top ? top : { ...top, max } }));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* §8 (3.4): `at` given as the bare string `"after"` / `"before"` with a block
|
|
67
|
+
* `into` — "right after block X" is `into: X's parent, at: { after: X }`. The
|
|
68
|
+
* fix needs the block's live row (its parent, or its document when it is top-
|
|
69
|
+
* level, spelled as the rooted path); a block the store does not know gets no fix.
|
|
70
|
+
*/
|
|
71
|
+
function bareAnchor(at, into, here, f) {
|
|
72
|
+
const message = `${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> } — "${at} block X" is into: X's parent (a block, or the document when X is top-level), at: { ${at}: X }`;
|
|
73
|
+
if (f === undefined || into.kind !== "block")
|
|
74
|
+
return effectInvalid(message);
|
|
75
|
+
const row = f.parentOf(into.id);
|
|
76
|
+
if (row === null)
|
|
77
|
+
return effectInvalid(message);
|
|
78
|
+
const parent = row.parent === null ? referencePath(row.path) : { kind: "block", id: row.parent };
|
|
79
|
+
return effectInvalid(message, f.build({ ...f.written, into: parent, at: { [at]: into.id } }));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* §8 (3.4): a plural-target `expect` written as `{ content_hash: { <id>: <hash>, … } }`
|
|
83
|
+
* (every key a guard name, every value a map of strings) — the fix inverts the
|
|
84
|
+
* nesting to `{ <id>: { content_hash: <hash> }, … }`.
|
|
85
|
+
*/
|
|
86
|
+
function invertedExpectMap(v, here, f) {
|
|
87
|
+
const keys = Object.keys(v);
|
|
88
|
+
if (keys.length === 0 || !keys.every((k) => k === "content_hash" || k === "parent_children_hash") || !Object.values(v).every(isStringRecord))
|
|
89
|
+
return null;
|
|
90
|
+
const message = `${here}: a plural-target \`expect\` is keyed by block id — { <block id>: { content_hash?, parent_children_hash? }, … }`;
|
|
91
|
+
if (f === undefined)
|
|
92
|
+
return effectInvalid(message);
|
|
93
|
+
const inverted = {};
|
|
94
|
+
for (const [guard, byId] of Object.entries(v)) {
|
|
95
|
+
for (const [id, hash] of Object.entries(byId))
|
|
96
|
+
inverted[id] = { ...inverted[id], [guard]: hash };
|
|
97
|
+
}
|
|
98
|
+
return effectInvalid(message, f.build({ ...f.written, expect: inverted }));
|
|
99
|
+
}
|
|
100
|
+
/** §8 (3.4): the block-field misspellings the fix corrects to `markdown`. */
|
|
101
|
+
const BLOCK_FIELD_ALIASES = new Set(["text", "content", "body", "raw"]);
|
|
25
102
|
function onlyKeys(e, allowed, here) {
|
|
26
103
|
const extra = Object.keys(e).filter((k) => !allowed.includes(k));
|
|
27
104
|
if (extra.length > 0)
|
|
@@ -118,7 +195,7 @@ function validateTargets(v, here) {
|
|
|
118
195
|
throw effectInvalid(`${here}: \`targets\` must be a non-empty array of block targets`);
|
|
119
196
|
return v.map((t, i) => validateTarget(t, `${here}.targets[${i}]`, ["block"]));
|
|
120
197
|
}
|
|
121
|
-
function validateAt(v, here) {
|
|
198
|
+
function validateAt(v, here, into, f) {
|
|
122
199
|
if (v === undefined)
|
|
123
200
|
return undefined;
|
|
124
201
|
if (v === "start" || v === "end")
|
|
@@ -129,13 +206,17 @@ function validateAt(v, here) {
|
|
|
129
206
|
if (typeof v.after === "string")
|
|
130
207
|
return { after: v.after };
|
|
131
208
|
}
|
|
209
|
+
if ((v === "before" || v === "after") && into !== undefined)
|
|
210
|
+
throw bareAnchor(v, into, here, f);
|
|
132
211
|
throw effectInvalid(`${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> }`);
|
|
133
212
|
}
|
|
134
|
-
function validateExpect(v, here) {
|
|
213
|
+
function validateExpect(v, here, f) {
|
|
135
214
|
if (v === undefined)
|
|
136
215
|
return undefined;
|
|
137
216
|
if (!isRecord(v))
|
|
138
217
|
throw effectInvalid(`${here}: \`expect\` must be an object`);
|
|
218
|
+
if ("max" in v)
|
|
219
|
+
throw effectLevelMax(v, here, f);
|
|
139
220
|
onlyKeys(v, ["content_hash", "parent_children_hash", "revision"], `${here}.expect`);
|
|
140
221
|
const out = {};
|
|
141
222
|
for (const k of ["content_hash", "parent_children_hash", "revision"]) {
|
|
@@ -145,11 +226,16 @@ function validateExpect(v, here) {
|
|
|
145
226
|
}
|
|
146
227
|
return out;
|
|
147
228
|
}
|
|
148
|
-
function validateExpectMap(v, here) {
|
|
229
|
+
function validateExpectMap(v, here, f) {
|
|
149
230
|
if (v === undefined)
|
|
150
231
|
return undefined;
|
|
151
232
|
if (!isRecord(v))
|
|
152
233
|
throw effectInvalid(`${here}: \`expect\` must be an object keyed by block id`);
|
|
234
|
+
if ("max" in v)
|
|
235
|
+
throw effectLevelMax(v, here, f);
|
|
236
|
+
const inverted = invertedExpectMap(v, here, f);
|
|
237
|
+
if (inverted !== null)
|
|
238
|
+
throw inverted;
|
|
153
239
|
const out = {};
|
|
154
240
|
for (const [id, x] of Object.entries(v)) {
|
|
155
241
|
if (!isRecord(x))
|
|
@@ -166,13 +252,14 @@ function validateExpectMap(v, here) {
|
|
|
166
252
|
}
|
|
167
253
|
return out;
|
|
168
254
|
}
|
|
169
|
-
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
|
|
170
|
-
export function validateEffect(v, i) {
|
|
255
|
+
/** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`); `fix` (3.4) lets the shape errors of §8 carry their corrected call. */
|
|
256
|
+
export function validateEffect(v, i, fix) {
|
|
171
257
|
const here = `effects[${i}]`;
|
|
172
258
|
if (!isRecord(v) || typeof v.op !== "string")
|
|
173
259
|
throw effectInvalid(`${here}: an effect is an object whose \`op\` is one of ${EFFECT_OPS.join("/")}`);
|
|
174
260
|
if (!EFFECT_OPS.includes(v.op))
|
|
175
261
|
throw effectInvalid(`${here}: \`op\` must be one of ${EFFECT_OPS.join("/")} (got "${v.op}")`);
|
|
262
|
+
const f = fixerFor(fix, i, v);
|
|
176
263
|
switch (v.op) {
|
|
177
264
|
case "create": {
|
|
178
265
|
onlyKeys(v, ["op", "path", "markdown", "frontmatter"], here);
|
|
@@ -185,9 +272,9 @@ export function validateEffect(v, i) {
|
|
|
185
272
|
case "insert": {
|
|
186
273
|
onlyKeys(v, ["op", "into", "at", "markdown", "expect"], here);
|
|
187
274
|
const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
|
|
188
|
-
const at = validateAt(v.at, here);
|
|
275
|
+
const at = validateAt(v.at, here, into, f);
|
|
189
276
|
const markdown = requireString(v, "markdown", here);
|
|
190
|
-
const expect = validateExpect(v.expect, here);
|
|
277
|
+
const expect = validateExpect(v.expect, here, f);
|
|
191
278
|
return { op: "insert", into, ...(at !== undefined ? { at } : {}), markdown, ...(expect !== undefined ? { expect } : {}) };
|
|
192
279
|
}
|
|
193
280
|
case "set": {
|
|
@@ -197,8 +284,8 @@ export function validateEffect(v, i) {
|
|
|
197
284
|
if (!("value" in v))
|
|
198
285
|
throw effectInvalid(`${here}: \`value\` is required`);
|
|
199
286
|
if (target.kind !== "symbol")
|
|
200
|
-
checkField(target.kind, field, here, "set");
|
|
201
|
-
const expect = validateExpect(v.expect, here);
|
|
287
|
+
checkField(target.kind, field, here, "set", f);
|
|
288
|
+
const expect = validateExpect(v.expect, here, f);
|
|
202
289
|
return { op: "set", target, field, value: v.value, ...(expect !== undefined ? { expect } : {}) };
|
|
203
290
|
}
|
|
204
291
|
case "unset": {
|
|
@@ -207,7 +294,7 @@ export function validateEffect(v, i) {
|
|
|
207
294
|
const field = requireString(v, "field", here);
|
|
208
295
|
if (target.kind !== "symbol")
|
|
209
296
|
checkField(target.kind, field, here, "unset");
|
|
210
|
-
const expect = validateExpect(v.expect, here);
|
|
297
|
+
const expect = validateExpect(v.expect, here, f);
|
|
211
298
|
return { op: "unset", target, field, ...(expect !== undefined ? { expect } : {}) };
|
|
212
299
|
}
|
|
213
300
|
case "move": {
|
|
@@ -215,8 +302,8 @@ export function validateEffect(v, i) {
|
|
|
215
302
|
onlyKeys(v, ["op", "targets", "into", "at", "expect"], here);
|
|
216
303
|
const targets = validateTargets(v.targets, here);
|
|
217
304
|
const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
|
|
218
|
-
const at = validateAt(v.at, here);
|
|
219
|
-
const expect = validateExpect(v.expect, here);
|
|
305
|
+
const at = validateAt(v.at, here, into, f);
|
|
306
|
+
const expect = validateExpect(v.expect, here, f);
|
|
220
307
|
return { op: "move", targets, into, ...(at !== undefined ? { at } : {}), ...(expect !== undefined ? { expect } : {}) };
|
|
221
308
|
}
|
|
222
309
|
onlyKeys(v, ["op", "target", "to", "retarget_inbound"], here);
|
|
@@ -230,7 +317,7 @@ export function validateEffect(v, i) {
|
|
|
230
317
|
if ("targets" in v) {
|
|
231
318
|
onlyKeys(v, ["op", "targets", "expect"], here);
|
|
232
319
|
const targets = validateTargets(v.targets, here);
|
|
233
|
-
const expect = validateExpectMap(v.expect, here);
|
|
320
|
+
const expect = validateExpectMap(v.expect, here, f);
|
|
234
321
|
return { op: "remove", targets, ...(expect !== undefined ? { expect } : {}) };
|
|
235
322
|
}
|
|
236
323
|
onlyKeys(v, ["op", "target"], here);
|
|
@@ -241,14 +328,14 @@ export function validateEffect(v, i) {
|
|
|
241
328
|
const target = validateTarget(v.target, `${here}.target`, ["block"]);
|
|
242
329
|
if (!Array.isArray(v.at) || v.at.some((n) => typeof n !== "number" || !Number.isInteger(n) || n < 0))
|
|
243
330
|
throw effectInvalid(`${here}: \`at\` must be an array of byte offsets`);
|
|
244
|
-
const expect = validateExpect(v.expect, here);
|
|
331
|
+
const expect = validateExpect(v.expect, here, f);
|
|
245
332
|
return { op: "split", target, at: v.at, ...(expect !== undefined ? { expect } : {}) };
|
|
246
333
|
}
|
|
247
334
|
case "merge": {
|
|
248
335
|
onlyKeys(v, ["op", "targets", "separator", "expect"], here);
|
|
249
336
|
const targets = validateTargets(v.targets, here);
|
|
250
337
|
const separator = optionalString(v, "separator", here);
|
|
251
|
-
const expect = validateExpectMap(v.expect, here);
|
|
338
|
+
const expect = validateExpectMap(v.expect, here, f);
|
|
252
339
|
return { op: "merge", targets, ...(separator !== undefined ? { separator } : {}), ...(expect !== undefined ? { expect } : {}) };
|
|
253
340
|
}
|
|
254
341
|
case "retarget": {
|
|
@@ -267,16 +354,16 @@ export function validateEffect(v, i) {
|
|
|
267
354
|
}
|
|
268
355
|
}
|
|
269
356
|
/** The whole list; a non-array or an empty list is a shape error too. */
|
|
270
|
-
export function validateEffects(v) {
|
|
357
|
+
export function validateEffects(v, fix) {
|
|
271
358
|
if (!Array.isArray(v))
|
|
272
359
|
throw effectInvalid("`effects` must be an array of effects");
|
|
273
360
|
if (v.length === 0)
|
|
274
361
|
throw effectInvalid("`effects` must name at least one effect");
|
|
275
|
-
return v.map((e, i) => validateEffect(e, i));
|
|
362
|
+
return v.map((e, i) => validateEffect(e, i, fix));
|
|
276
363
|
}
|
|
277
364
|
// §11.2: the `field` spellings per target kind.
|
|
278
365
|
export const FRONTMATTER_FIELD = /^frontmatter\.(.+)$/;
|
|
279
|
-
function checkField(kind, field, here, op) {
|
|
366
|
+
function checkField(kind, field, here, op, f) {
|
|
280
367
|
if (kind === "document") {
|
|
281
368
|
if (FRONTMATTER_FIELD.test(field) || (op === "set" && field === "content"))
|
|
282
369
|
return;
|
|
@@ -285,7 +372,9 @@ function checkField(kind, field, here, op) {
|
|
|
285
372
|
if (kind === "block") {
|
|
286
373
|
if (field === "markdown" || field === "attrs.checked")
|
|
287
374
|
return;
|
|
288
|
-
|
|
375
|
+
// (3.4, §8) `text` / `content` / `body` / `raw` on a block: the fix spells `markdown`.
|
|
376
|
+
const fix = f !== undefined && BLOCK_FIELD_ALIASES.has(field) ? f.build({ ...f.written, field: "markdown" }) : undefined;
|
|
377
|
+
throw effectInvalid(`${here}: a block's \`field\` is \`markdown\` or \`attrs.checked\` (got "${field}")`, fix);
|
|
289
378
|
}
|
|
290
379
|
// node: the adapter's editable props (`node_not_editable` names them when unknown)
|
|
291
380
|
if (field === "" || field.includes("."))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Store } from "../core/store/store.js";
|
|
2
|
+
import type { TreeInputBlock } from "../core/store/writers.js";
|
|
2
3
|
import type { IdResolver } from "../core/ingest.js";
|
|
3
4
|
import type { MutDoc } from "./tree.js";
|
|
4
5
|
/**
|
|
@@ -10,3 +11,21 @@ import type { MutDoc } from "./tree.js";
|
|
|
10
11
|
export declare function makeKnownIdResolver(store: Store, repoId: string, doc: MutDoc, priorIds: Set<string>, opts?: {
|
|
11
12
|
path?: string;
|
|
12
13
|
}): IdResolver;
|
|
14
|
+
/** A carried block's live row, as spec/reconcile §2's carry classification reads it. */
|
|
15
|
+
interface PriorRow {
|
|
16
|
+
parent: string | null;
|
|
17
|
+
ordinal: number;
|
|
18
|
+
rawHashHex: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* spec/reconcile §2 "Classifying a carry", applied to the api path's known ids:
|
|
22
|
+
* `moved` = the parent or the sibling index differs from the live row,
|
|
23
|
+
* `content_equal` = the raw bytes hash the same; `same` / `moved` / `edited` /
|
|
24
|
+
* `edited_moved` follow. An id with no prior row (new to this document — a
|
|
25
|
+
* mint or a cross-document arrival) is `inserted`. Pre-order over the tree.
|
|
26
|
+
*/
|
|
27
|
+
export declare function classifyCarries(blocks: TreeInputBlock[], prior: Map<string, PriorRow>, priorIds: Set<string>): {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
}[];
|
|
31
|
+
export {};
|