@omgbase/core 0.3.0 → 0.4.1
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/ingest.js +15 -12
- package/dist/src/core/read/document.js +6 -1
- package/dist/src/core/read/nodes.js +5 -1
- package/dist/src/core/store/fts.d.ts +47 -7
- package/dist/src/core/store/fts.js +73 -9
- package/dist/src/core/store/rebuild.js +5 -4
- 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 -6
- package/dist/src/index.js +7 -5
- package/dist/src/mcp/errors.d.ts +8 -0
- package/dist/src/mcp/errors.js +17 -3
- package/dist/src/mcp/server.js +50 -57
- 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/context.d.ts +11 -0
- package/dist/src/oqx-js/context.js +16 -0
- package/dist/src/oqx-js/planner.d.ts +20 -1
- package/dist/src/oqx-js/planner.js +95 -2
- package/dist/src/oqx-js/run.js +27 -2
- package/dist/src/oqx-js/sql/translate.d.ts +94 -1
- package/dist/src/oqx-js/sql/translate.js +263 -79
- package/dist/src/search/tasks.d.ts +15 -1
- package/dist/src/search/tasks.js +31 -3
- package/dist/src/sync/admin.d.ts +13 -0
- package/dist/src/sync/admin.js +11 -0
- package/dist/src/sync/external-source.js +20 -7
- package/dist/src/sync/plugin.d.ts +30 -5
- package/dist/src/sync/plugin.js +13 -1
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +10 -2
- package/package.json +1 -1
|
@@ -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
|
|
@@ -20,4 +20,15 @@ export declare function makeStoreContext(store: Store, repoId: string, opts?: St
|
|
|
20
20
|
/** Tag SQL rows with their target so the store context resolves them (used by the
|
|
21
21
|
* tier-3 planner to hand produced rows back for residual evaluation). */
|
|
22
22
|
export declare function tagRows(rows: Record<string, unknown>[], target: Target): unknown[];
|
|
23
|
+
/**
|
|
24
|
+
* A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
|
|
25
|
+
* values, 1.2) is rendered as `{ id, path }` — its id column and its document's
|
|
26
|
+
* path, the same two keys every hit carries — never the store row itself.
|
|
27
|
+
* Returns undefined for anything that is not a tagged row (run.ts leaves those
|
|
28
|
+
* alone).
|
|
29
|
+
*/
|
|
30
|
+
export declare function rowRef(value: unknown): {
|
|
31
|
+
id: string;
|
|
32
|
+
path: string;
|
|
33
|
+
} | undefined;
|
|
23
34
|
export { TARGET };
|
|
@@ -610,5 +610,21 @@ function hex(b) {
|
|
|
610
610
|
export function tagRows(rows, target) {
|
|
611
611
|
return tagAll(rows, target);
|
|
612
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
|
|
615
|
+
* values, 1.2) is rendered as `{ id, path }` — its id column and its document's
|
|
616
|
+
* path, the same two keys every hit carries — never the store row itself.
|
|
617
|
+
* Returns undefined for anything that is not a tagged row (run.ts leaves those
|
|
618
|
+
* alone).
|
|
619
|
+
*/
|
|
620
|
+
export function rowRef(value) {
|
|
621
|
+
const t = value?.[TARGET];
|
|
622
|
+
if (!t)
|
|
623
|
+
return undefined;
|
|
624
|
+
const r = value;
|
|
625
|
+
const id = t === "docs" ? r.doc_id : t === "blocks" ? r.block_id : t === "nodes" ? r.node_id : r.edge_id;
|
|
626
|
+
const path = t === "docs" ? r.path : r.__path;
|
|
627
|
+
return { id: String(id), path: String(path ?? "") };
|
|
628
|
+
}
|
|
613
629
|
export { TARGET };
|
|
614
630
|
//# sourceMappingURL=context.js.map
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import type { Query, QueryPlanner, Plan } from "@omgbase/oqx";
|
|
1
|
+
import type { Query, Where, QueryPlanner, Plan } from "@omgbase/oqx";
|
|
2
2
|
import type { Store } from "../core/store/store.js";
|
|
3
3
|
import { type StoreContextOptions } from "./context.js";
|
|
4
|
+
import { type Target } from "./sql/translate.js";
|
|
5
|
+
/**
|
|
6
|
+
* True when evaluating `where` in memory could raise an OQX error the pushed
|
|
7
|
+
* conjuncts might hide by emptying the scan. The walk is generic over the whole
|
|
8
|
+
* residual tree — every `Where` node, every nested block (its `from`, `where`,
|
|
9
|
+
* `select`, `order by`, `follow`, `limit`/`offset`) and every `Expr` — and flags:
|
|
10
|
+
*
|
|
11
|
+
* • any `call` node (a function or method: unknown name, bad regex, wrong target…)
|
|
12
|
+
* • any nested block whose consumer is `single` (raises on 0 or >1 rows)
|
|
13
|
+
* • any `^`-escaped name: an `outer` reference or a `^name:` lift
|
|
14
|
+
* • a bare reserved docs basename (`id path updated_at content_hash body`): as
|
|
15
|
+
* an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
|
|
16
|
+
* on any target at any depth (the reach-through row is a doc)
|
|
17
|
+
*
|
|
18
|
+
* A residual made only of comparisons, logical operators, `in`/ranges, `!`,
|
|
19
|
+
* literals, bindings and plain reads (including exists/none/count/collect/first
|
|
20
|
+
* blocks of the same) cannot raise, so the pushed conjuncts are kept.
|
|
21
|
+
*/
|
|
22
|
+
export declare function residualMayRaise(where: Where | null, target: Target): boolean;
|
|
4
23
|
export declare class SQLiteQueryPlanner implements QueryPlanner {
|
|
5
24
|
private store;
|
|
6
25
|
private repoId;
|
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
//
|
|
8
8
|
// This is deliberately conservative: only simple top-level target scans (no
|
|
9
9
|
// `follow`, no source re-projection) with at least one pushable scalar conjunct
|
|
10
|
-
// are planned; everything else declines to a full in-memory run.
|
|
10
|
+
// are planned; everything else declines to a full in-memory run. One more
|
|
11
|
+
// decline keeps the planned path invisible (spec/surface §1 decline (c)): a
|
|
12
|
+
// pushed conjunct that empties the scan would also hide an OQX eval error a
|
|
13
|
+
// residual conjunct raises in memory (`path == "x" && $path == "nope.md"` was
|
|
14
|
+
// `[]` planned, `filter_invalid` in memory), so when any residual conjunct could
|
|
15
|
+
// raise — a function or method call, a `single` block, a `^`-escaped name, or a
|
|
16
|
+
// bare reserved docs basename — the WHOLE query runs unplanned.
|
|
11
17
|
import { ROWS_ROOT, partitionPushable, residualQuery } from "@omgbase/oqx";
|
|
12
18
|
import { makeStoreContext, tagRows } from "./context.js";
|
|
13
|
-
import { translatePredicate } from "./sql/translate.js";
|
|
19
|
+
import { translatePredicate, RESERVED_DOC_BASENAMES } from "./sql/translate.js";
|
|
14
20
|
const ALIAS = {
|
|
15
21
|
docs: { self: "d", doc: "d" },
|
|
16
22
|
blocks: { self: "b", doc: "d" },
|
|
@@ -56,6 +62,91 @@ function rootTarget(source) {
|
|
|
56
62
|
}
|
|
57
63
|
return null;
|
|
58
64
|
}
|
|
65
|
+
// ---- the residual walk (decline (c)) -----------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* True when evaluating `where` in memory could raise an OQX error the pushed
|
|
68
|
+
* conjuncts might hide by emptying the scan. The walk is generic over the whole
|
|
69
|
+
* residual tree — every `Where` node, every nested block (its `from`, `where`,
|
|
70
|
+
* `select`, `order by`, `follow`, `limit`/`offset`) and every `Expr` — and flags:
|
|
71
|
+
*
|
|
72
|
+
* • any `call` node (a function or method: unknown name, bad regex, wrong target…)
|
|
73
|
+
* • any nested block whose consumer is `single` (raises on 0 or >1 rows)
|
|
74
|
+
* • any `^`-escaped name: an `outer` reference or a `^name:` lift
|
|
75
|
+
* • a bare reserved docs basename (`id path updated_at content_hash body`): as
|
|
76
|
+
* an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
|
|
77
|
+
* on any target at any depth (the reach-through row is a doc)
|
|
78
|
+
*
|
|
79
|
+
* A residual made only of comparisons, logical operators, `in`/ranges, `!`,
|
|
80
|
+
* literals, bindings and plain reads (including exists/none/count/collect/first
|
|
81
|
+
* blocks of the same) cannot raise, so the pushed conjuncts are kept.
|
|
82
|
+
*/
|
|
83
|
+
export function residualMayRaise(where, target) {
|
|
84
|
+
return where !== null && whereMayRaise(where, target, true);
|
|
85
|
+
}
|
|
86
|
+
function whereMayRaise(w, target, root) {
|
|
87
|
+
switch (w.kind) {
|
|
88
|
+
case "and":
|
|
89
|
+
case "or": return w.parts.some((p) => whereMayRaise(p, target, root));
|
|
90
|
+
case "not": return whereMayRaise(w.expr, target, root);
|
|
91
|
+
case "scalar": return exprMayRaise(w.expr, target, root);
|
|
92
|
+
case "op": return opMayRaise(w, target, root);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function opMayRaise(op, target, root) {
|
|
96
|
+
if (op.op === "single")
|
|
97
|
+
return true;
|
|
98
|
+
return exprMayRaise(op.receiver, target, root) || subMayRaise(op.sub, target);
|
|
99
|
+
}
|
|
100
|
+
// Inside a block the rows are a different scope (a relation's rows), so a bare
|
|
101
|
+
// reserved name is an ordinary read there — only `doc.<reserved>` still raises.
|
|
102
|
+
function subMayRaise(s, target) {
|
|
103
|
+
if (s.from.some((e) => exprMayRaise(e, target, false)))
|
|
104
|
+
return true;
|
|
105
|
+
if (s.where && whereMayRaise(s.where, target, false))
|
|
106
|
+
return true;
|
|
107
|
+
for (const item of s.select) {
|
|
108
|
+
if (item.kind === "field") {
|
|
109
|
+
if (item.lift > 0 || exprMayRaise(item.expr, target, false))
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
else if (opMayRaise(item.op, target, false))
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (s.orderBy?.some((o) => exprMayRaise(o.expr, target, false)))
|
|
116
|
+
return true;
|
|
117
|
+
if (s.follow && followMayRaise(s.follow, target))
|
|
118
|
+
return true;
|
|
119
|
+
if (s.limit && exprMayRaise(s.limit, target, false))
|
|
120
|
+
return true;
|
|
121
|
+
if (s.offset && exprMayRaise(s.offset, target, false))
|
|
122
|
+
return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function followMayRaise(f, target) {
|
|
126
|
+
return [f.receiver, f.where, f.frontier, f.by].some((e) => e !== null && exprMayRaise(e, target, false));
|
|
127
|
+
}
|
|
128
|
+
function exprMayRaise(e, target, root) {
|
|
129
|
+
switch (e.kind) {
|
|
130
|
+
case "call": return true;
|
|
131
|
+
case "outer": return true;
|
|
132
|
+
case "ident": return root && target === "docs" && RESERVED_DOC_BASENAMES.has(e.name);
|
|
133
|
+
case "member":
|
|
134
|
+
if (e.recv.kind === "ident" && e.recv.name === "doc" && RESERVED_DOC_BASENAMES.has(e.name))
|
|
135
|
+
return true;
|
|
136
|
+
return exprMayRaise(e.recv, target, root);
|
|
137
|
+
case "index": return exprMayRaise(e.recv, target, root) || exprMayRaise(e.index, target, root);
|
|
138
|
+
case "unary": return exprMayRaise(e.expr, target, root);
|
|
139
|
+
case "binary":
|
|
140
|
+
case "logical":
|
|
141
|
+
case "in":
|
|
142
|
+
return exprMayRaise(e.left, target, root) || exprMayRaise(e.right, target, root);
|
|
143
|
+
case "range":
|
|
144
|
+
return (e.lo !== null && exprMayRaise(e.lo, target, root)) || (e.hi !== null && exprMayRaise(e.hi, target, root));
|
|
145
|
+
case "lit":
|
|
146
|
+
case "binding": return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ---- the planner -------------------------------------------------------------
|
|
59
150
|
export class SQLiteQueryPlanner {
|
|
60
151
|
store;
|
|
61
152
|
repoId;
|
|
@@ -76,6 +167,8 @@ export class SQLiteQueryPlanner {
|
|
|
76
167
|
const { pushed, residual } = partitionPushable(query.where, (e) => translatePredicate(e, ctx) !== null);
|
|
77
168
|
if (pushed.length === 0)
|
|
78
169
|
return null; // nothing to push — let the engine do it all
|
|
170
|
+
if (residualMayRaise(residual, target))
|
|
171
|
+
return null; // decline (c): run unplanned
|
|
79
172
|
const frags = pushed.map((e) => translatePredicate(e, ctx));
|
|
80
173
|
const g = guards(target, this.repoId);
|
|
81
174
|
const whereSql = [g.sql, ...frags.map((f) => `(${f.sql})`)].join(" AND ");
|
package/dist/src/oqx-js/run.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// keyset pagination, count/exists scalars). Same signature + shape as the former
|
|
5
5
|
// in-tree compiler, so every caller and the corpus are unchanged.
|
|
6
6
|
import { parse, PlannedEngine, InMemoryEngine, OqxError } from "@omgbase/oqx";
|
|
7
|
-
import { makeStoreContext } from "./context.js";
|
|
7
|
+
import { makeStoreContext, rowRef } from "./context.js";
|
|
8
8
|
import { SQLiteQueryPlanner } from "./planner.js";
|
|
9
9
|
import { FilterInvalid } from "../search/cel/parser.js";
|
|
10
10
|
import { float32ToBlob } from "../core/vec.js";
|
|
@@ -194,10 +194,35 @@ const PATH_ITEM = { kind: "field", name: "__oqx_path", expr: { kind: "ident", na
|
|
|
194
194
|
// so it rides through id/path injection, keyset paging, and distinct as an
|
|
195
195
|
// ordinary record field and is peeled off at the end.
|
|
196
196
|
const VALUE_KEY = "__oqx_value";
|
|
197
|
+
// Rows as values (spec/surface §1.4, 1.2). The engine hands back store rows
|
|
198
|
+
// wherever the projection made a row a VALUE — a nested `collect { }` /
|
|
199
|
+
// `first { }` / `single { }` with an empty projection ("the row itself",
|
|
200
|
+
// spec/oqx §12), a `values` projection of `$value`/`$self`, a field bound to a
|
|
201
|
+
// row — and a store row is not a wire shape (column names, `attrs` as a JSON
|
|
202
|
+
// string, the `__path` join column, Buffers). Walk the projected value and
|
|
203
|
+
// render every tagged row as `{ id, path }`; everything else passes through
|
|
204
|
+
// untouched (plain records and arrays are descended, scalars kept). Hits are
|
|
205
|
+
// records the engine built from the injected id/path items, never rows.
|
|
206
|
+
function renderValue(v) {
|
|
207
|
+
if (v === null || typeof v !== "object")
|
|
208
|
+
return v;
|
|
209
|
+
const ref = rowRef(v);
|
|
210
|
+
if (ref)
|
|
211
|
+
return ref;
|
|
212
|
+
if (Array.isArray(v))
|
|
213
|
+
return v.map(renderValue);
|
|
214
|
+
const proto = Object.getPrototypeOf(v);
|
|
215
|
+
if (proto !== Object.prototype && proto !== null)
|
|
216
|
+
return v; // Buffer, Map, … — not a record
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const [k, x] of Object.entries(v))
|
|
219
|
+
out[k] = renderValue(x);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
197
222
|
function toHit(row) {
|
|
198
223
|
const o = row;
|
|
199
224
|
const { __oqx_id, __oqx_path, ...rest } = o;
|
|
200
|
-
return { id: String(__oqx_id), path: String(__oqx_path ?? ""), ...rest };
|
|
225
|
+
return { id: String(__oqx_id), path: String(__oqx_path ?? ""), ...renderValue(rest) };
|
|
201
226
|
}
|
|
202
227
|
// Top-level `select distinct`: dedup hits by their USER projection (every field
|
|
203
228
|
// except the injected id/path), keeping the first (so the retained hit carries a
|
|
@@ -19,9 +19,102 @@ export interface Frag {
|
|
|
19
19
|
sql: string;
|
|
20
20
|
params: unknown[];
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* What a translated operand denotes, for the comparison matrix (spec/surface §1
|
|
24
|
+
* decline (a)):
|
|
25
|
+
*
|
|
26
|
+
* text a string literal/binding, a text column or intrinsic, `.lower()`/`.upper()`
|
|
27
|
+
* int an integer-typed intrinsic column (blocks `$ordinal`, `$depth`)
|
|
28
|
+
* num a number literal/binding
|
|
29
|
+
* bool a boolean literal/binding
|
|
30
|
+
* null a null (or undefined) literal/binding
|
|
31
|
+
* json a JSON read: an `attrs` path or a bare attribute name on blocks/nodes (json_extract)
|
|
32
|
+
* prop a document property read: a bare key on docs or `doc.<k>` (the scalar-in-scope subquery)
|
|
33
|
+
*/
|
|
34
|
+
export type OperandKind = "text" | "int" | "num" | "bool" | "null" | "json" | "prop";
|
|
35
|
+
/** A value-position fragment with the kind of value it denotes, plus what the
|
|
36
|
+
* typed comparison (`typedComparison`) needs to re-shape it: the JSON column +
|
|
37
|
+
* path behind a `json` read, the document alias + key behind a `prop` read, and
|
|
38
|
+
* the constant behind a literal/binding (`value`; `bool`/`num`/`null`/text
|
|
39
|
+
* constants only — a column operand has none). */
|
|
40
|
+
export interface Operand extends Frag {
|
|
41
|
+
kind: OperandKind;
|
|
42
|
+
json?: {
|
|
43
|
+
col: string;
|
|
44
|
+
path: string;
|
|
45
|
+
};
|
|
46
|
+
prop?: {
|
|
47
|
+
docAlias: string;
|
|
48
|
+
key: string;
|
|
49
|
+
};
|
|
50
|
+
value?: unknown;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
|
|
54
|
+
* `context.ts` resolves to something other than a scalar field or property: the
|
|
55
|
+
* self alias, the reach-through handles, the relations, the property source
|
|
56
|
+
* handles and (blocks/nodes) the whole `attrs` object. A comparison naming one of
|
|
57
|
+
* these is declined (spec/surface §1 decline (b)) — read as a property it would
|
|
58
|
+
* be `NULL`, where in memory the value is a row, an array or an object.
|
|
59
|
+
* `translate.test.ts` asserts this set against the store context.
|
|
60
|
+
*/
|
|
61
|
+
export declare const NON_PROPERTY_NAMES: Readonly<Record<Target, ReadonlySet<string>>>;
|
|
62
|
+
/** docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
|
|
63
|
+
* pushable, so the residual raises it (and the planner keeps the whole query
|
|
64
|
+
* unplanned when one is residual: spec/surface §1 decline (c)). */
|
|
65
|
+
export declare const RESERVED_DOC_BASENAMES: ReadonlySet<string>;
|
|
22
66
|
/** Translate an expression used as a VALUE (comparison operand, method receiver,
|
|
23
|
-
* function argument) to a SQL scalar. Returns null if not
|
|
67
|
+
* function argument) to a SQL scalar with its operand kind. Returns null if not
|
|
68
|
+
* faithfully translatable. */
|
|
69
|
+
export declare function translateOperand(e: Expr, ctx: TranslateCtx): Operand | null;
|
|
70
|
+
/** `translateOperand` without the kind — the plain fragment. */
|
|
24
71
|
export declare function translateValue(e: Expr, ctx: TranslateCtx): Frag | null;
|
|
72
|
+
/**
|
|
73
|
+
* The comparison rule (spec/surface §1, decline (a)), stated positively: a
|
|
74
|
+
* comparison is pushed only when its two operand kinds are provably compared
|
|
75
|
+
* the same way by SQLite and by the in-memory `equals`/`compare`.
|
|
76
|
+
*
|
|
77
|
+
* Equality (`==`, `!=`) pushes PLAINLY (`IS` / `IS NOT`) iff
|
|
78
|
+
* • one operand is text — SQLite's typed `IS` and strict `===` agree that a
|
|
79
|
+
* string equals nothing but an equal string (`5 IS '5'` is false);
|
|
80
|
+
* • or one operand is null and the other is NOT a property read — the same
|
|
81
|
+
* absence both ways, except that a list-valued or nested property has no
|
|
82
|
+
* scalar row (SQL NULL) where in memory the value is an array or object;
|
|
83
|
+
* • or both operands are numeric (int or num) — an exact integer comparison.
|
|
84
|
+
* Relational (`<`, `<=`, `>`, `>=`) pushes plainly iff both operands are text
|
|
85
|
+
* or both are numeric.
|
|
86
|
+
* TYPED pushes (the 1.2 patch, `typedComparison`): a bool literal/binding
|
|
87
|
+
* against a json or prop read (`==`, `!=` only) and a num literal/binding
|
|
88
|
+
* against a json or prop read (every op) push with the stored type tested
|
|
89
|
+
* first — `json_type(x) = 'true'`, `json_type(x) IN ('integer','real') AND
|
|
90
|
+
* json_extract(x) <op> ?`, `p.type = 'bool' AND p.val_bool = ?`, `p.type =
|
|
91
|
+
* 'number' AND p.val_num <op> ?` — wrapped `(…) IS 1` (or `IS NOT 1` for
|
|
92
|
+
* `!=`), so an absent or differently typed value is unequal and never
|
|
93
|
+
* ordered, exactly as in memory.
|
|
94
|
+
* Every other pair declines: SQLite sees JSON `true` and `1`, `val_bool` and
|
|
95
|
+
* `val_num` alike (`$ordinal == checked`, `true == 1`), orders every
|
|
96
|
+
* integer before every text (`$ordinal < "3"`, `level < "x"`, `$path > 5`),
|
|
97
|
+
* and two JSON or property reads carry no type at plan time
|
|
98
|
+
* (`attrs.a == attrs.b`, `era == stages`).
|
|
99
|
+
*
|
|
100
|
+
* The resulting grid (P = pushed plainly, T = typed push, D = declined;
|
|
101
|
+
* symmetric) for `==` / `!=`:
|
|
102
|
+
*
|
|
103
|
+
* text int num bool null json prop
|
|
104
|
+
* text P P P P P P P
|
|
105
|
+
* int P P P D P D D
|
|
106
|
+
* num P P P D P T T
|
|
107
|
+
* bool P D D D P T T
|
|
108
|
+
* null P P P P P P D
|
|
109
|
+
* json P D T T P D D
|
|
110
|
+
* prop P D T T D D D
|
|
111
|
+
*
|
|
112
|
+
* and for `<` `<=` `>` `>=` text×text, int×int, int×num, num×num push plainly
|
|
113
|
+
* and num×json, num×prop push typed.
|
|
114
|
+
*/
|
|
115
|
+
export declare function comparable(op: string, a: OperandKind, b: OperandKind): boolean;
|
|
116
|
+
/** The complement of `comparable` — kept for readers of the earlier decline-list form. */
|
|
117
|
+
export declare function declinedPair(a: OperandKind, b: OperandKind, op?: string): boolean;
|
|
25
118
|
/** Translate an expression used as a boolean PREDICATE to a SQL boolean, or null
|
|
26
119
|
* if it cannot be pushed faithfully. Only positive, AND-safe forms are handled. */
|
|
27
120
|
export declare function translatePredicate(e: Expr, ctx: TranslateCtx): Frag | null;
|