@omgbase/core 0.4.0 → 0.5.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/ids.d.ts +34 -3
- package/dist/src/core/ids.js +79 -6
- package/dist/src/core/store/fts.d.ts +9 -0
- package/dist/src/core/store/fts.js +13 -0
- package/dist/src/core/store/store.d.ts +4 -0
- package/dist/src/core/store/store.js +40 -1
- package/dist/src/format/index.d.ts +12 -1
- package/dist/src/format/index.js +14 -1
- package/dist/src/format/markdown.d.ts +7 -0
- package/dist/src/format/markdown.js +7 -0
- package/dist/src/graph/history.d.ts +21 -0
- package/dist/src/graph/history.js +48 -6
- package/dist/src/graph/links.d.ts +2 -0
- package/dist/src/graph/links.js +22 -11
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +8 -5
- package/dist/src/mcp/errors.d.ts +8 -0
- package/dist/src/mcp/errors.js +17 -3
- package/dist/src/mcp/server.d.ts +8 -0
- package/dist/src/mcp/server.js +47 -50
- package/dist/src/mutate/apply.js +12 -4
- package/dist/src/mutate/doc-store.js +4 -1
- package/dist/src/mutate/docs.d.ts +19 -0
- package/dist/src/mutate/docs.js +64 -16
- package/dist/src/mutate/ops.js +25 -13
- package/dist/src/oqx-js/sql/translate.d.ts +36 -13
- package/dist/src/oqx-js/sql/translate.js +122 -33
- package/dist/src/search/tasks.d.ts +9 -0
- package/dist/src/search/tasks.js +23 -1
- package/dist/src/spec-versions.d.ts +14 -0
- package/dist/src/spec-versions.js +25 -0
- package/dist/src/sync/admin.d.ts +13 -0
- package/dist/src/sync/admin.js +11 -0
- package/dist/src/version.d.ts +39 -0
- package/dist/src/version.js +103 -0
- package/package.json +1 -1
package/dist/src/mcp/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { FilterInvalid } from "../search/cel/parser.js";
|
|
2
|
+
import { CursorInvalid } from "../core/cursor.js";
|
|
3
|
+
import { MutationError } from "../mutate/tree.js";
|
|
4
|
+
import { RevisionNotFound } from "../graph/history.js";
|
|
4
5
|
export class EngineError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
data;
|
|
@@ -15,4 +16,17 @@ export class EngineError extends Error {
|
|
|
15
16
|
return { error: this.code, message: this.message, data: this.data, retriable: this.retriable };
|
|
16
17
|
}
|
|
17
18
|
}
|
|
19
|
+
export function errorBody(err) {
|
|
20
|
+
if (err instanceof EngineError)
|
|
21
|
+
return { error: err.code, message: err.message, ...(err.data !== undefined ? { data: err.data } : {}), retriable: err.retriable };
|
|
22
|
+
if (err instanceof FilterInvalid)
|
|
23
|
+
return { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
|
|
24
|
+
if (err instanceof CursorInvalid)
|
|
25
|
+
return { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
|
|
26
|
+
if (err instanceof MutationError)
|
|
27
|
+
return { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
|
|
28
|
+
if (err instanceof RevisionNotFound)
|
|
29
|
+
return { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
|
|
30
|
+
return { error: "repo_not_found", message: String(err), retriable: false };
|
|
31
|
+
}
|
|
18
32
|
//# sourceMappingURL=errors.js.map
|
package/dist/src/mcp/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { Store } from "../core/store/store.js";
|
|
3
|
+
import { type HostInfo } from "../version.js";
|
|
3
4
|
export interface ServerContext {
|
|
4
5
|
store: Store;
|
|
5
6
|
/** The DEFAULT repo for calls that omit `repo` (ADR-014). Tools accept an
|
|
@@ -26,5 +27,12 @@ export interface ServerContext {
|
|
|
26
27
|
* embedding happens off it. Absent ⇒ mutations don't auto-drain.
|
|
27
28
|
*/
|
|
28
29
|
onMutation?: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* The serving binary's identity for the `version` tool (spec/surface §4): its
|
|
32
|
+
* own version, build info and the module its component packages resolve from.
|
|
33
|
+
* Set by `omg mcp`; absent for a library embedding (the tool then reports the
|
|
34
|
+
* engine's own version and whatever components resolve from core).
|
|
35
|
+
*/
|
|
36
|
+
host?: HostInfo;
|
|
29
37
|
}
|
|
30
38
|
export declare function buildServer(ctx: ServerContext): McpServer;
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -4,7 +4,6 @@ import { docsOutline } from "../core/read/outline.js";
|
|
|
4
4
|
import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
|
|
5
5
|
import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
|
|
6
6
|
import { findDoc, findDocByRef, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
|
|
7
|
-
import { CursorInvalid } from "../core/cursor.js";
|
|
8
7
|
import { resolveRef } from "../core/read/refs.js";
|
|
9
8
|
import { isValidId } from "../core/ids.js";
|
|
10
9
|
import { VERSION } from "../core/index.js";
|
|
@@ -12,20 +11,19 @@ import { normalizeText, normalizeVisibleText } from "../core/hash.js";
|
|
|
12
11
|
import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
|
|
13
12
|
import { graphNeighborhood } from "./graph.js";
|
|
14
13
|
import { textSearch } from "../search/text.js";
|
|
15
|
-
import {
|
|
16
|
-
import { EngineError } from "./errors.js";
|
|
14
|
+
import { EngineError, errorBody } from "./errors.js";
|
|
17
15
|
import { apply } from "../mutate/apply.js";
|
|
18
|
-
import { MutationError } from "../mutate/tree.js";
|
|
19
16
|
import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair, nodeSet } from "../mutate/macros.js";
|
|
20
17
|
import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
|
|
21
18
|
import { planUpdate, docsUpdate } from "../mutate/plan-update.js";
|
|
22
19
|
import { renderOpsetPlan } from "../mutate/opset.js";
|
|
23
|
-
import { historyNode, diffBlocks,
|
|
20
|
+
import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
|
|
24
21
|
import { linksStale, linksStaleSummary } from "../graph/link-health.js";
|
|
25
22
|
import { resolve as resolveThing } from "../search/resolve.js";
|
|
26
|
-
import { reposStatus, syncStatus } from "../sync/admin.js";
|
|
23
|
+
import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
|
|
27
24
|
import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
|
|
28
25
|
import { QUERY_SYNTAX } from "./reference.js";
|
|
26
|
+
import { versionInfo } from "../version.js";
|
|
29
27
|
// Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
|
|
30
28
|
// each op a real schema (rather than z.any()) makes the required fields
|
|
31
29
|
// discoverable in the tool's JSON schema and turns a malformed op — e.g. an
|
|
@@ -70,20 +68,9 @@ function ok(payload) {
|
|
|
70
68
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
71
69
|
}
|
|
72
70
|
function fail(err) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
else if (err instanceof FilterInvalid)
|
|
77
|
-
body = { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
|
|
78
|
-
else if (err instanceof CursorInvalid)
|
|
79
|
-
body = { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
|
|
80
|
-
else if (err instanceof MutationError)
|
|
81
|
-
body = { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
|
|
82
|
-
else if (err instanceof RevisionNotFound)
|
|
83
|
-
body = { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
|
|
84
|
-
else
|
|
85
|
-
body = { error: "repo_not_found", message: String(err), retriable: false };
|
|
86
|
-
return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
|
|
71
|
+
// One mapping for every client (errors.ts `errorBody`): the CLI renders the
|
|
72
|
+
// same body, so a code can never differ between `omg` and `omg mcp`.
|
|
73
|
+
return { content: [{ type: "text", text: JSON.stringify(errorBody(err)) }], isError: true };
|
|
87
74
|
}
|
|
88
75
|
export function buildServer(ctx) {
|
|
89
76
|
const server = new McpServer({ name: "omgbase", version: VERSION });
|
|
@@ -549,7 +536,7 @@ export function buildServer(ctx) {
|
|
|
549
536
|
// local: the resolution that used to live in the CLI now lives next to the
|
|
550
537
|
// store, so a remote client needs no local database.
|
|
551
538
|
server.registerTool("blocks_insert", {
|
|
552
|
-
description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block, or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
|
|
539
|
+
description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block — it must be a CONTAINER (a list, list item, task, blockquote or table); a leaf such as a heading or paragraph fails `type_mismatch` (spec/mutate §1.1) — use sections_append for \"under this heading\" or `at: {after: <ref>}` for \"right after this block\"; or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
|
|
553
540
|
inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
554
541
|
}, async (args) => {
|
|
555
542
|
try {
|
|
@@ -594,7 +581,7 @@ export function buildServer(ctx) {
|
|
|
594
581
|
}
|
|
595
582
|
});
|
|
596
583
|
server.registerTool("blocks_move", {
|
|
597
|
-
description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref, 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. One move op through the kernel writer.",
|
|
584
|
+
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. One move op through the kernel writer.",
|
|
598
585
|
inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
599
586
|
}, async (args) => {
|
|
600
587
|
try {
|
|
@@ -802,53 +789,56 @@ export function buildServer(ctx) {
|
|
|
802
789
|
// Doc-level operations (06 §API). The MCP server serializes writes in-process,
|
|
803
790
|
// so these pass no omgbaseDir (the flock is for cross-process CLI writers);
|
|
804
791
|
// MCP-originated writes are actor agent:mcp.
|
|
805
|
-
|
|
792
|
+
// `dry_run` validates and returns the per-file `diffs` without writing
|
|
793
|
+
// (`DocOpContext.dryRun`), the docs-verb peer of `apply`'s preview.
|
|
794
|
+
const docCtx = (scope, dryRun) => {
|
|
806
795
|
if (!scope.rootPath)
|
|
807
796
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
808
|
-
return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp" };
|
|
797
|
+
return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp", ...(dryRun ? { dryRun: true } : {}) };
|
|
809
798
|
};
|
|
799
|
+
const docResult = (res, dryRun) => (dryRun ? ok(res) : okMutated(res));
|
|
810
800
|
server.registerTool("docs_create", {
|
|
811
|
-
description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists.",
|
|
812
|
-
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
|
|
801
|
+
description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists. dry_run:true validates and returns the would-be file under `diffs` (committed:false) without writing.",
|
|
802
|
+
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
813
803
|
}, async (args) => {
|
|
814
804
|
try {
|
|
815
|
-
return
|
|
805
|
+
return docResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter), args.dry_run);
|
|
816
806
|
}
|
|
817
807
|
catch (e) {
|
|
818
808
|
return fail(e);
|
|
819
809
|
}
|
|
820
810
|
});
|
|
821
811
|
server.registerTool("docs_move", {
|
|
822
|
-
description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs.",
|
|
823
|
-
inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
|
|
812
|
+
description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus any rewritten sources) — without renaming or committing.",
|
|
813
|
+
inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
824
814
|
}, async (args) => {
|
|
825
815
|
try {
|
|
826
|
-
return
|
|
816
|
+
return docResult(docsMove(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, args.to_path, { retargetInbound: args.retarget_inbound === true }), args.dry_run);
|
|
827
817
|
}
|
|
828
818
|
catch (e) {
|
|
829
819
|
return fail(e);
|
|
830
820
|
}
|
|
831
821
|
});
|
|
832
822
|
server.registerTool("docs_delete", {
|
|
833
|
-
description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path.",
|
|
834
|
-
inputSchema: { doc: z.string(), ...REPO_ARG },
|
|
823
|
+
description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path. dry_run:true validates and returns the file's removal under `diffs` without deleting.",
|
|
824
|
+
inputSchema: { doc: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
835
825
|
}, async (args) => {
|
|
836
826
|
try {
|
|
837
|
-
return
|
|
827
|
+
return docResult(docsDelete(store, docCtx(repoScope(args.repo), args.dry_run), args.doc), args.dry_run);
|
|
838
828
|
}
|
|
839
829
|
catch (e) {
|
|
840
830
|
return fail(e);
|
|
841
831
|
}
|
|
842
832
|
});
|
|
843
833
|
server.registerTool("docs_set_meta", {
|
|
844
|
-
description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved.",
|
|
845
|
-
inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
|
|
834
|
+
description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved. dry_run:true validates and returns the rewritten file under `diffs` without writing.",
|
|
835
|
+
inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
846
836
|
}, async (args) => {
|
|
847
837
|
try {
|
|
848
|
-
return
|
|
838
|
+
return docResult(docsSetMeta(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, {
|
|
849
839
|
...(args.set ? { set: args.set } : {}),
|
|
850
840
|
...(args.unset ? { unset: args.unset } : {}),
|
|
851
|
-
}));
|
|
841
|
+
}), args.dry_run);
|
|
852
842
|
}
|
|
853
843
|
catch (e) {
|
|
854
844
|
return fail(e);
|
|
@@ -956,15 +946,11 @@ export function buildServer(ctx) {
|
|
|
956
946
|
try {
|
|
957
947
|
const { repoId } = repoScope(args.repo);
|
|
958
948
|
const docId = resolveDocId(repoId, { doc: args.doc });
|
|
959
|
-
|
|
960
|
-
.
|
|
961
|
-
.
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
if (!toRev || !fromRev)
|
|
965
|
-
throw new EngineError("target_missing", `no revisions to diff for ${JSON.stringify(args.doc)}`);
|
|
966
|
-
const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path ?? "";
|
|
967
|
-
return ok({ doc: docId, path, from: fromRev, to: toRev, diff: diffUnified(store, docId, fromRev, toRev) });
|
|
949
|
+
return ok(docDiffUnified(store, docId, {
|
|
950
|
+
...(args.from_rev !== undefined ? { fromRev: args.from_rev } : {}),
|
|
951
|
+
...(args.to_rev !== undefined ? { toRev: args.to_rev } : {}),
|
|
952
|
+
label: JSON.stringify(args.doc),
|
|
953
|
+
}));
|
|
968
954
|
}
|
|
969
955
|
catch (e) {
|
|
970
956
|
return fail(e);
|
|
@@ -1078,10 +1064,21 @@ export function buildServer(ctx) {
|
|
|
1078
1064
|
inputSchema: {},
|
|
1079
1065
|
}, async () => {
|
|
1080
1066
|
try {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
return
|
|
1067
|
+
return ok(reposList(store));
|
|
1068
|
+
}
|
|
1069
|
+
catch (e) {
|
|
1070
|
+
return fail(e);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
// Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
|
|
1074
|
+
// answer is about the binary and its database, not a repo.
|
|
1075
|
+
server.registerTool("version", {
|
|
1076
|
+
description: "Which engine and which versions you are talking to: `{ engine: \"typescript\"|\"rust\", version, components: { <package>: <version> }, specs: { oqx, format, reconcile, store, properties, graph, search, mutate, sync, surface, cli }, schema, mcp: { protocol, sdk? }, runtime, commit, built }`. " +
|
|
1077
|
+
"`version` is the serving binary's own; `components` every omgbase package it is built from; `specs` the spec versions it implements (compile-time constants); `schema` the database's `PRAGMA user_version`; `commit`/`built` the build's git revision and RFC 3339 time (null when unknown). No arguments, no repo scope.",
|
|
1078
|
+
inputSchema: {},
|
|
1079
|
+
}, async () => {
|
|
1080
|
+
try {
|
|
1081
|
+
return ok(versionInfo(store, ctx.host));
|
|
1085
1082
|
}
|
|
1086
1083
|
catch (e) {
|
|
1087
1084
|
return fail(e);
|
package/dist/src/mutate/apply.js
CHANGED
|
@@ -9,6 +9,7 @@ import { renderDoc, markContainerDirty, MutationError } from "./tree.js";
|
|
|
9
9
|
import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams, checkParentChildrenHash } from "./ops.js";
|
|
10
10
|
import { withWriterLock } from "../sync/writer-lock.js";
|
|
11
11
|
import { resolveDocStore } from "./doc-store.js";
|
|
12
|
+
import { isContainerBlock } from "../format/index.js";
|
|
12
13
|
// Resolve "$n.ids[i]" placeholders in a string against prior op results.
|
|
13
14
|
function resolvePlaceholder(value, results) {
|
|
14
15
|
const m = /^\$(\d+)\.ids\[(\d+)\]$/.exec(value);
|
|
@@ -248,9 +249,11 @@ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
|
|
|
248
249
|
throw new MutationError("block_missing", `block ${id} not found in source doc`, { op_index: opIndex });
|
|
249
250
|
moving.push(found.block);
|
|
250
251
|
}
|
|
251
|
-
// §1.
|
|
252
|
+
// §1.1 container rule + §1.2 destination CAS (in `dst`), resolved once before
|
|
253
|
+
// either tree moves so a leaf destination errors with both trees untouched.
|
|
254
|
+
const pre = resolveDstTarget(dst, to, opIndex);
|
|
252
255
|
if (expect?.parent_children_hash !== undefined)
|
|
253
|
-
checkParentChildrenHash(
|
|
256
|
+
checkParentChildrenHash(pre.siblings, expect, opIndex);
|
|
254
257
|
// remove from their source lists (marking each emptied owner dirty)
|
|
255
258
|
for (const id of blockIds) {
|
|
256
259
|
const f = locateMut(src, id);
|
|
@@ -260,7 +263,7 @@ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
|
|
|
260
263
|
}
|
|
261
264
|
}
|
|
262
265
|
// Insert into dst at the resolved target; the destination owner re-renders.
|
|
263
|
-
const target = resolveDstTarget(dst, to);
|
|
266
|
+
const target = resolveDstTarget(dst, to, opIndex);
|
|
264
267
|
target.siblings.splice(target.index, 0, ...moving);
|
|
265
268
|
markContainerDirty(dst, target.siblings);
|
|
266
269
|
// Heal both documents' top-level seams (spec/mutate §2.3): a block that was
|
|
@@ -283,7 +286,7 @@ function locateMut(doc, blockId) {
|
|
|
283
286
|
};
|
|
284
287
|
return search(doc.children);
|
|
285
288
|
}
|
|
286
|
-
function resolveDstTarget(dst, to) {
|
|
289
|
+
function resolveDstTarget(dst, to, opIndex = 0) {
|
|
287
290
|
if (typeof to.parent === "object" && "doc" in to.parent) {
|
|
288
291
|
return { siblings: dst.children, index: resolveAt(dst.children, to.at) };
|
|
289
292
|
}
|
|
@@ -291,6 +294,11 @@ function resolveDstTarget(dst, to) {
|
|
|
291
294
|
const f = locateMut(dst, to.parent);
|
|
292
295
|
if (!f)
|
|
293
296
|
throw new MutationError("parent_missing", `parent ${to.parent} not found in dest`);
|
|
297
|
+
// spec/mutate §1.1 (1.2): a leaf destination would be re-rendered from its
|
|
298
|
+
// children and lose its own text — the same rule as the in-document ops.
|
|
299
|
+
if (!isContainerBlock(dst.format, f.block)) {
|
|
300
|
+
throw new MutationError("type_mismatch", `${to.parent} is a ${f.block.type}, not a container; place relative to it with at.before/at.after or append to its section`, { op_index: opIndex, block: to.parent, type: f.block.type });
|
|
301
|
+
}
|
|
294
302
|
return { siblings: f.block.children, index: resolveAt(f.block.children, to.at) };
|
|
295
303
|
}
|
|
296
304
|
// heading/section: insert into top-level list at the anchor.
|
|
@@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync, mkdirS
|
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { sha256 } from "../core/hash.js";
|
|
4
4
|
import { recordFileStat } from "../sync/freshness.js";
|
|
5
|
+
import { EngineError } from "../mcp/errors.js";
|
|
5
6
|
/** Filesystem-backed DocStore rooted at a working-tree directory (v1 default). */
|
|
6
7
|
export class FsDocStore {
|
|
7
8
|
root;
|
|
@@ -66,6 +67,8 @@ export function resolveDocStore(req) {
|
|
|
66
67
|
return req.docStore;
|
|
67
68
|
if (req.rootPath != null)
|
|
68
69
|
return new FsDocStore(req.rootPath);
|
|
69
|
-
|
|
70
|
+
// Typed, so every client renders the same `repo_not_found` the MCP server's
|
|
71
|
+
// pre-checks emit (spec/surface §4; spec/cli §9 "sourceless writes").
|
|
72
|
+
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
70
73
|
}
|
|
71
74
|
//# sourceMappingURL=doc-store.js.map
|
|
@@ -13,11 +13,30 @@ export interface DocOpContext {
|
|
|
13
13
|
actor?: string;
|
|
14
14
|
/** Commit timestamp (RFC 3339 UTC) for every commit the operation records; defaults to now (see `ApplyRequest.ts`). */
|
|
15
15
|
ts?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Validate and plan only: every check the real operation makes (`doc_missing`,
|
|
18
|
+
* `path_taken`, …) runs, nothing is written or committed, and the result
|
|
19
|
+
* carries `committed: false` plus the per-file `diffs` the operation would
|
|
20
|
+
* produce — the same shape `apply`'s `dryRun` returns. A create's `docId` is
|
|
21
|
+
* the id the real run would mint next (a dry run consumes it, as `apply`'s does).
|
|
22
|
+
*/
|
|
23
|
+
dryRun?: boolean;
|
|
16
24
|
}
|
|
17
25
|
export interface DocOpResult {
|
|
18
26
|
docId: string;
|
|
19
27
|
path: string;
|
|
20
28
|
committed: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* With `dryRun`: the file changes the operation would make, keyed by
|
|
31
|
+
* repo-relative path — `before` the current bytes (`""` for a file that
|
|
32
|
+
* would be created), `after` the bytes the operation would leave (`""` for a
|
|
33
|
+
* file that would be removed). A rename is two entries: the old path
|
|
34
|
+
* emptied, the new path filled. Absent on a committed result.
|
|
35
|
+
*/
|
|
36
|
+
diffs?: Record<string, {
|
|
37
|
+
before: string;
|
|
38
|
+
after: string;
|
|
39
|
+
}>;
|
|
21
40
|
}
|
|
22
41
|
export interface DocMoveOptions {
|
|
23
42
|
/**
|
package/dist/src/mutate/docs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ingestFile } from "../core/ingest.js";
|
|
|
3
3
|
import { findDoc, findDocByRef } from "../core/read/reader.js";
|
|
4
4
|
import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
|
|
5
5
|
import { newCommit } from "../core/store/writers.js";
|
|
6
|
+
import { mintId } from "../core/ids.js";
|
|
6
7
|
import { ftsDeleteDoc } from "../core/store/fts.js";
|
|
7
8
|
import { adoptPhantoms, rebuildDocEdges } from "../core/store/edges.js";
|
|
8
9
|
import { withWriterLock } from "../sync/writer-lock.js";
|
|
@@ -38,6 +39,11 @@ export function docsCreate(store, ctx, path, markdown, frontmatter) {
|
|
|
38
39
|
throw new MutationError("path_taken", `document already exists at ${rel}`);
|
|
39
40
|
const content = composeFile(markdown, frontmatter);
|
|
40
41
|
const docStore = resolveDocStore(ctx);
|
|
42
|
+
if (ctx.dryRun) {
|
|
43
|
+
if (docStore.exists(rel))
|
|
44
|
+
throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
|
|
45
|
+
return { docId: mintId("d"), path: rel, committed: false, diffs: { [rel]: { before: "", after: content } } };
|
|
46
|
+
}
|
|
41
47
|
return underLock(ctx, () => {
|
|
42
48
|
if (docStore.exists(rel))
|
|
43
49
|
throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
|
|
@@ -67,6 +73,21 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
|
|
|
67
73
|
// Who links here BY PATH, as written — before the path changes.
|
|
68
74
|
const inbound = inboundLinksTo(store, ctx.repoId, info.docId, info.path);
|
|
69
75
|
const docStore = resolveDocStore(ctx);
|
|
76
|
+
if (ctx.dryRun) {
|
|
77
|
+
if (docStore.exists(toRel))
|
|
78
|
+
throw new MutationError("path_taken", `file already exists on disk at ${toRel}`);
|
|
79
|
+
const content = docStore.read(info.path) ?? "";
|
|
80
|
+
const diffs = { [info.path]: { before: content, after: "" }, [toRel]: { before: "", after: content } };
|
|
81
|
+
const planned = { docId: info.docId, path: toRel, committed: false, diffs };
|
|
82
|
+
if (!opts.retargetInbound || inbound.length === 0)
|
|
83
|
+
return { ...planned, dangling: inbound, retargeted: null };
|
|
84
|
+
const plan = planInboundRetarget(store, info.docId, info.path, toRel, inbound);
|
|
85
|
+
if (plan.ops.length > 0) {
|
|
86
|
+
const preview = apply(store, { ...applyCtx(ctx), ops: plan.ops, dryRun: true, origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` } });
|
|
87
|
+
Object.assign(diffs, preview.diffs ?? {});
|
|
88
|
+
}
|
|
89
|
+
return { ...planned, dangling: plan.dangling, retargeted: { blocks: plan.blocks, docs: plan.docs } };
|
|
90
|
+
}
|
|
70
91
|
const moved = underLock(ctx, () => {
|
|
71
92
|
if (docStore.exists(toRel))
|
|
72
93
|
throw new MutationError("path_taken", `file already exists on disk at ${toRel}`);
|
|
@@ -118,6 +139,34 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
|
|
|
118
139
|
// Rewrite the dangling links block by block (one coalesced update per block,
|
|
119
140
|
// CAS on the block's current hash) and apply as one changeset; the re-ingest
|
|
120
141
|
// re-extracts each source doc, whose links now resolve to the moved doc.
|
|
142
|
+
const plan = planInboundRetarget(store, info.docId, info.path, toRel, inbound);
|
|
143
|
+
if (plan.ops.length > 0) {
|
|
144
|
+
apply(store, {
|
|
145
|
+
...applyCtx(ctx),
|
|
146
|
+
ops: plan.ops,
|
|
147
|
+
origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return { ...moved, dangling: plan.dangling, retargeted: { blocks: plan.blocks, docs: plan.docs } };
|
|
151
|
+
}
|
|
152
|
+
/** The `ApplyRequest` fields a document operation's context supplies to a follow-up changeset. */
|
|
153
|
+
function applyCtx(ctx) {
|
|
154
|
+
return {
|
|
155
|
+
repoId: ctx.repoId,
|
|
156
|
+
...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
|
|
157
|
+
...(ctx.docStore ? { docStore: ctx.docStore } : {}),
|
|
158
|
+
...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
|
|
159
|
+
...(ctx.ts ? { ts: ctx.ts } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The `retarget_inbound` plan of a move `fromPath → toRel`: one coalesced,
|
|
164
|
+
* CAS-pinned `update` per deepest hit block, the blocks and docs it touches,
|
|
165
|
+
* and the inbound links it leaves dangling. Pure over the store's current
|
|
166
|
+
* blocks — the move itself changes no block — so the real move and its dry run
|
|
167
|
+
* plan identically.
|
|
168
|
+
*/
|
|
169
|
+
function planInboundRetarget(store, docId, fromPath, toRel, inbound) {
|
|
121
170
|
const byBlock = new Map();
|
|
122
171
|
for (const l of inbound)
|
|
123
172
|
if (l.block !== null && !byBlock.has(l.block))
|
|
@@ -150,29 +199,18 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
|
|
|
150
199
|
// Match against the directory the link was RESOLVED in (the source's path at
|
|
151
200
|
// extraction time); write relative forms against the source's CURRENT
|
|
152
201
|
// directory — they differ only for links inside the moved doc itself.
|
|
153
|
-
const writeDir = src.doc ===
|
|
154
|
-
const newRaw = retargetLinksInRaw(row.bytes.toString("utf8"), dirOf(src.path), writeDir,
|
|
202
|
+
const writeDir = src.doc === docId ? dirOf(toRel) : dirOf(src.path);
|
|
203
|
+
const newRaw = retargetLinksInRaw(row.bytes.toString("utf8"), dirOf(src.path), writeDir, fromPath, toRel);
|
|
155
204
|
if (newRaw === null)
|
|
156
205
|
continue;
|
|
157
206
|
ops.push({ op: "update", block: blockId, markdown: newRaw, expect: { content_hash: row.raw_hash.toString("hex") } });
|
|
158
207
|
blocks.push(blockId);
|
|
159
208
|
docs.add(src.doc);
|
|
160
209
|
}
|
|
161
|
-
if (ops.length > 0) {
|
|
162
|
-
apply(store, {
|
|
163
|
-
repoId: ctx.repoId,
|
|
164
|
-
...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
|
|
165
|
-
...(ctx.docStore ? { docStore: ctx.docStore } : {}),
|
|
166
|
-
...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
|
|
167
|
-
...(ctx.ts ? { ts: ctx.ts } : {}),
|
|
168
|
-
ops,
|
|
169
|
-
origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
210
|
// Containers whose rewritten child covered them count as rewritten too.
|
|
173
211
|
const rewritten = new Set([...blocks, ...containers]);
|
|
174
212
|
const dangling = inbound.filter((l) => l.block === null || !rewritten.has(l.block));
|
|
175
|
-
return {
|
|
213
|
+
return { ops, blocks, docs: [...docs], dangling };
|
|
176
214
|
}
|
|
177
215
|
/** docs_delete: mark a document deleted and remove its file (resurrection-poolable). */
|
|
178
216
|
export function docsDelete(store, ctx, docRef) {
|
|
@@ -180,6 +218,9 @@ export function docsDelete(store, ctx, docRef) {
|
|
|
180
218
|
if (!info)
|
|
181
219
|
throw new MutationError("doc_missing", `no document ${docRef}`);
|
|
182
220
|
const docStore = resolveDocStore(ctx);
|
|
221
|
+
if (ctx.dryRun) {
|
|
222
|
+
return { docId: info.docId, path: info.path, committed: false, diffs: { [info.path]: { before: docStore.read(info.path) ?? "", after: "" } } };
|
|
223
|
+
}
|
|
183
224
|
return underLock(ctx, () => {
|
|
184
225
|
const ts = nowOr(ctx);
|
|
185
226
|
store.write((db) => {
|
|
@@ -201,13 +242,20 @@ export function docsSetMeta(store, ctx, docRef, patch) {
|
|
|
201
242
|
if (!info)
|
|
202
243
|
throw new MutationError("doc_missing", `no document ${docRef}`);
|
|
203
244
|
const docStore = resolveDocStore(ctx);
|
|
204
|
-
|
|
245
|
+
const patched = () => {
|
|
205
246
|
const original = docStore.read(info.path) ?? "";
|
|
206
247
|
const { frontmatter, body } = splitFrontmatter(original);
|
|
207
248
|
const merged = { ...frontmatter, ...(patch.set ?? {}) };
|
|
208
249
|
for (const k of patch.unset ?? [])
|
|
209
250
|
delete merged[k];
|
|
210
|
-
|
|
251
|
+
return { original, content: composeFile(body, merged) };
|
|
252
|
+
};
|
|
253
|
+
if (ctx.dryRun) {
|
|
254
|
+
const { original, content } = patched();
|
|
255
|
+
return { docId: info.docId, path: info.path, committed: false, diffs: { [info.path]: { before: original, after: content } } };
|
|
256
|
+
}
|
|
257
|
+
return underLock(ctx, () => {
|
|
258
|
+
const { content } = patched();
|
|
211
259
|
docStore.write(info.path, content);
|
|
212
260
|
const ts = nowOr(ctx);
|
|
213
261
|
const res = ingestFile(store, ctx.repoId, info.path, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `set_meta ${info.path}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: info.path }) });
|
package/dist/src/mutate/ops.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mintId } from "../core/ids.js";
|
|
2
2
|
import { parseTree } from "../core/parse/tree.js";
|
|
3
|
-
import { adapterForFormat } from "../format/index.js";
|
|
3
|
+
import { adapterForFormat, isContainerBlock } from "../format/index.js";
|
|
4
4
|
import { codeUnitIndexOf } from "../core/utf8.js";
|
|
5
5
|
import { MutationError, locate, rawHashHex, parentChildrenHash, markContainerDirty, ownerOf } from "./tree.js";
|
|
6
6
|
function defaultTrivia(format) {
|
|
@@ -34,8 +34,10 @@ function parseContentToBlocks(content, format) {
|
|
|
34
34
|
});
|
|
35
35
|
return tree.children.filter((b) => b.type !== "frontmatter").map(toMut);
|
|
36
36
|
}
|
|
37
|
-
// Resolve a To into a target sibling list + insertion index.
|
|
38
|
-
|
|
37
|
+
// Resolve a To into a target sibling list + insertion index (spec/mutate §1.1).
|
|
38
|
+
// Shared by insert, move and the destination `parent_children_hash` CAS, so the
|
|
39
|
+
// container rule below is seen by all three.
|
|
40
|
+
function resolveTarget(doc, to, opIndex) {
|
|
39
41
|
let siblings;
|
|
40
42
|
if (to.parent === null || (typeof to.parent === "object" && "doc" in to.parent)) {
|
|
41
43
|
siblings = doc.children;
|
|
@@ -46,9 +48,18 @@ function resolveTarget(doc, to) {
|
|
|
46
48
|
return resolveSection(doc, to.parent.heading, to.at);
|
|
47
49
|
}
|
|
48
50
|
else {
|
|
49
|
-
const
|
|
51
|
+
const parentId = to.parent;
|
|
52
|
+
const found = locate(doc, parentId);
|
|
50
53
|
if (!found)
|
|
51
|
-
throw new MutationError("parent_missing", `parent ${
|
|
54
|
+
throw new MutationError("parent_missing", `parent ${parentId} not found`);
|
|
55
|
+
// §1.1 (since 1.2): `parent: <id>` must name a CONTAINER. A leaf's `children`
|
|
56
|
+
// is empty by construction, not by content — placing a block there marked
|
|
57
|
+
// the leaf dirty, renderBlock rebuilt it from its children, and the leaf's
|
|
58
|
+
// own text (a heading's line) vanished from the file. The error carries the
|
|
59
|
+
// two ways to say what the caller meant: next to it, or into its section.
|
|
60
|
+
if (!isContainerBlock(doc.format, found.block)) {
|
|
61
|
+
throw new MutationError("type_mismatch", `${parentId} is a ${found.block.type}, not a container; place relative to it with at.before/at.after or append to its section`, { op_index: opIndex, block: parentId, type: found.block.type });
|
|
62
|
+
}
|
|
52
63
|
siblings = found.block.children;
|
|
53
64
|
}
|
|
54
65
|
return { siblings, index: resolveIndex(siblings, to.at) };
|
|
@@ -155,7 +166,7 @@ function markSubtreeClean(b) {
|
|
|
155
166
|
}
|
|
156
167
|
// ---- the six ops ------------------------------------------------------------
|
|
157
168
|
export function opInsert(doc, to, markdown, expect, opIndex = 0) {
|
|
158
|
-
const { siblings, index } = resolveTarget(doc, to);
|
|
169
|
+
const { siblings, index } = resolveTarget(doc, to, opIndex);
|
|
159
170
|
// §1.2: the destination parent's order CAS — `siblings` IS the resolved parent
|
|
160
171
|
// list (a section scope or a `{ doc: true }` anchor both name the top level).
|
|
161
172
|
checkParentChildrenHash(siblings, expect, opIndex);
|
|
@@ -352,12 +363,13 @@ export function opMove(doc, blockIds, to, opIndex, expect) {
|
|
|
352
363
|
if (typeof to.parent === "string" && movedIds.has(to.parent)) {
|
|
353
364
|
throw new MutationError("cycle_move", "target is inside the moved subtree", { op_index: opIndex });
|
|
354
365
|
}
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
// Resolve the destination on the PRE-removal tree first: §1.1's container rule
|
|
367
|
+
// and a missing parent/anchor fail before anything moves, and §1.2's
|
|
368
|
+
// DESTINATION-parent order CAS is checked once for the whole run against the
|
|
369
|
+
// children as they stand. When source and destination are one list this is the
|
|
370
|
+
// "same siblings reordered under me" guard: the hash covers the moved blocks'
|
|
371
|
+
// current positions too. The index is re-resolved after extraction below.
|
|
372
|
+
checkParentChildrenHash(resolveTarget(doc, to, opIndex).siblings, expect, opIndex);
|
|
361
373
|
// Extract (preserve order) then re-insert at the target.
|
|
362
374
|
const moving = located.map((l) => l.block);
|
|
363
375
|
for (const m of moving) {
|
|
@@ -365,7 +377,7 @@ export function opMove(doc, blockIds, to, opIndex, expect) {
|
|
|
365
377
|
if (idx >= 0)
|
|
366
378
|
siblings.splice(idx, 1);
|
|
367
379
|
}
|
|
368
|
-
const { siblings: dstSiblings, index } = resolveTarget(doc, to);
|
|
380
|
+
const { siblings: dstSiblings, index } = resolveTarget(doc, to, opIndex);
|
|
369
381
|
dstSiblings.splice(index, 0, ...moving);
|
|
370
382
|
// A move within/into a nested container invalidates that container's cached
|
|
371
383
|
// raw (and the source's) — mark them so they re-render from children. No-op
|