@omgbase/core 0.21.0 → 0.24.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.
Files changed (39) hide show
  1. package/dist/src/core/ids.d.ts +1 -1
  2. package/dist/src/core/read/document.d.ts +4 -1
  3. package/dist/src/core/read/document.js +18 -6
  4. package/dist/src/core/store/archive.d.ts +196 -0
  5. package/dist/src/core/store/archive.js +606 -0
  6. package/dist/src/core/store/gc.d.ts +25 -4
  7. package/dist/src/core/store/gc.js +43 -28
  8. package/dist/src/core/store/rebuild.d.ts +2 -0
  9. package/dist/src/core/store/rebuild.js +8 -2
  10. package/dist/src/core/store/retention.d.ts +138 -0
  11. package/dist/src/core/store/retention.js +409 -0
  12. package/dist/src/core/store/schema.d.ts +3 -2
  13. package/dist/src/core/store/schema.js +30 -4
  14. package/dist/src/core/store/size.d.ts +21 -2
  15. package/dist/src/core/store/size.js +24 -3
  16. package/dist/src/core/store/store.d.ts +1 -0
  17. package/dist/src/core/store/store.js +53 -1
  18. package/dist/src/graph/history.d.ts +2 -0
  19. package/dist/src/graph/history.js +8 -4
  20. package/dist/src/index.d.ts +3 -1
  21. package/dist/src/index.js +3 -0
  22. package/dist/src/mcp/errors.d.ts +1 -1
  23. package/dist/src/mcp/errors.js +4 -0
  24. package/dist/src/mcp/reference.d.ts +1 -1
  25. package/dist/src/mcp/reference.js +49 -11
  26. package/dist/src/mcp/server.js +2 -2
  27. package/dist/src/mutate/select.js +5 -1
  28. package/dist/src/oqx-js/context.js +39 -8
  29. package/dist/src/oqx-js/dates.d.ts +105 -0
  30. package/dist/src/oqx-js/dates.js +427 -0
  31. package/dist/src/oqx-js/names.js +14 -3
  32. package/dist/src/oqx-js/planner.js +21 -2
  33. package/dist/src/oqx-js/run.d.ts +1 -0
  34. package/dist/src/oqx-js/run.js +82 -2
  35. package/dist/src/oqx-js/sql/translate.js +12 -5
  36. package/dist/src/oqx-js/store-index.js +1 -1
  37. package/dist/src/spec-versions.d.ts +4 -4
  38. package/dist/src/spec-versions.js +3 -3
  39. package/package.json +2 -2
@@ -165,6 +165,14 @@ function kindOfPath(on, path) {
165
165
  cur = seg;
166
166
  continue;
167
167
  }
168
+ // At a group scope `rows` yields the grouped rows (their kind); a key is a
169
+ // value the language built, not a row of any kind.
170
+ if (typeof cur === "object") {
171
+ if (seg !== "rows" || i !== 0)
172
+ return null;
173
+ cur = cur.group;
174
+ continue;
175
+ }
168
176
  if (seg === "$it" && i === 0)
169
177
  continue;
170
178
  const next = HANDLES[cur][seg] ?? RELATIONS[cur][seg];
@@ -172,7 +180,7 @@ function kindOfPath(on, path) {
172
180
  return null;
173
181
  cur = next;
174
182
  }
175
- return cur === "root" ? null : cur;
183
+ return cur === "root" || typeof cur === "object" ? null : cur;
176
184
  }
177
185
  /** The kind of the frontier rows of a walk: the seeds' kind when every destination yields rows of that same kind, else unknown. */
178
186
  function kindOfFrontier(seed, f, readDepth, byDepth) {
@@ -194,13 +202,16 @@ export function kindOfScope(steps) {
194
202
  for (const step of steps) {
195
203
  const e = step.expr;
196
204
  if (e.kind === "follow")
197
- cur = kindOfFrontier(cur === "root" ? null : cur, e, readDepth, byDepth);
205
+ cur = kindOfFrontier(cur === "root" || typeof cur === "object" ? null : cur, e, readDepth, byDepth);
206
+ // A `group by` step (oqx 0.24): the rows at this depth become groups of them.
207
+ else if (e.kind === "groupBy")
208
+ cur = { group: cur === "root" || typeof cur === "object" ? null : cur };
198
209
  else
199
210
  cur = kindOfExpr(e, cur, readDepth, byDepth);
200
211
  byDepth.set(step.depth, cur);
201
212
  readDepth = step.depth;
202
213
  }
203
- return cur === "root" ? null : cur;
214
+ return cur === "root" || typeof cur === "object" ? null : cur;
204
215
  }
205
216
  /** Judge a dotted name read on rows of `target`; null = known. */
206
217
  function judge(store, repoId, target, path) {
@@ -91,6 +91,15 @@ function subMayRaise(s, target) {
91
91
  return true;
92
92
  if (s.follow && followMayRaise(s.follow, target))
93
93
  return true;
94
+ // A grouped block (oqx 0.24) evaluates its keys, `having` and its aggregates
95
+ // (`sum` over a string raises) per group.
96
+ if (s.groupBy?.keys.some((k) => exprMayRaise(k.expr, target, false)))
97
+ return true;
98
+ if (s.having && whereMayRaise(s.having, target, false))
99
+ return true;
100
+ // A flattened block (oqx 0.25) evaluates each `flatten` expression per row.
101
+ if (s.flatten.some((f) => exprMayRaise(f.expr, target, false)))
102
+ return true;
94
103
  if (s.limit && exprMayRaise(s.limit, target, false))
95
104
  return true;
96
105
  if (s.offset && exprMayRaise(s.offset, target, false))
@@ -133,6 +142,9 @@ function exprMayRaise(e, target, root) {
133
142
  // a record is an eval error only the data decides, so with any interpolation
134
143
  // it may raise.
135
144
  case "template": return e.parts.some((p) => typeof p !== "string");
145
+ // An aggregate (oqx 0.24) raises on a non-number under `sum`/`avg`, which
146
+ // only the data decides; a grouped query is never planned anyway (below).
147
+ case "aggregate": return true;
136
148
  case "lit":
137
149
  case "binding": return false;
138
150
  }
@@ -148,8 +160,15 @@ export class SQLiteQueryPlanner {
148
160
  this.ctxOpts = ctxOpts;
149
161
  }
150
162
  plan(query, params) {
151
- // Only simple top-level scans: no follow, no `from E` re-projection.
152
- if (query.follow || query.from.length > 0)
163
+ // Only simple top-level scans: no follow, no `from E` re-projection, no
164
+ // `group by` (oqx 0.24), no `flatten` (oqx 0.25). A grouped or flattened
165
+ // query is declined whole: its `where` conjuncts could be pushed (the fan-out
166
+ // and the grouping happen after them, so the residual would flatten and
167
+ // group the produced rows correctly), but a pushed conjunct that empties the
168
+ // scan would hide an eval error a `flatten` expression, an aggregate or
169
+ // `having` raises in memory (`x!`, `sum` over a string) — decline (c)
170
+ // extended to the post-where clauses. SQL pushdown of either is a later round.
171
+ if (query.follow || query.from.length > 0 || query.groupBy || query.flatten.length > 0)
153
172
  return null;
154
173
  const target = rootTarget(query.source);
155
174
  if (!target)
@@ -72,6 +72,7 @@ export interface OqxOptions {
72
72
  }
73
73
  export declare function rootPathLiterals(e: Expr): Expr;
74
74
  export declare function checkRootSpellings(q: Query): void;
75
+ export declare function foldDates(e: Expr): Expr;
75
76
  export declare function collectSemanticPhrases(source: string): string[];
76
77
  export declare function reservedAlias(items: readonly SelectItem[]): FilterInvalid | null;
77
78
  export declare const BARE_NEEDS_SELECT_MESSAGE = "`bare` needs a `select` \u2014 without a projection a hit is only its envelope";
@@ -9,6 +9,7 @@ import { makeStoreContext, rowRef } from "./context.js";
9
9
  import { SQLiteQueryPlanner } from "./planner.js";
10
10
  import { REPO_REMOVED_MESSAGE, ROOTS, bareTargetMessage, pastRootMessage, unknownTargetMessage } from "./root-row.js";
11
11
  import { checkNames } from "./names.js";
12
+ import { DATE_FUNCTIONS, DATE_METHODS, dateFunction, dateMethod } from "./dates.js";
12
13
  import { FilterInvalid } from "../search/cel/parser.js";
13
14
  import { float32ToBlob } from "../core/vec.js";
14
15
  import { encodeCursor as encodeKeyset, decodePathCursor } from "../core/cursor.js";
@@ -91,8 +92,32 @@ export function checkRootSpellings(q) {
91
92
  },
92
93
  });
93
94
  }
95
+ // Dates over literals fold to literals (spec/surface §1.3 "Dates and
96
+ // durations", 4.8): `date("today")`, `dur("30 days")`, and a date method whose
97
+ // receiver and arguments are literals (`date("today").minus(dur("30 days"))`,
98
+ // folded inside-out since `transform` maps children first) become the string /
99
+ // number / null they evaluate to — by the SAME functions the context runs, so
100
+ // nothing changes in meaning — before the planner sees the tree. So `where
101
+ // updated_at < date("today")` is a text-vs-text comparison the translator
102
+ // pushes down (a call is otherwise an unconditional decline), and a named point
103
+ // is resolved ONCE per query. A call with the wrong arity is left in place: it
104
+ // raises when evaluated, and an empty source never evaluates it (spec/oqx §11).
105
+ const isLit = (e) => e.kind === "lit";
106
+ export function foldDates(e) {
107
+ if (e.kind !== "call" || !e.args.every(isLit))
108
+ return e;
109
+ const args = e.args.map((a) => a.value);
110
+ if (e.recv === null) {
111
+ if (!Object.hasOwn(DATE_FUNCTIONS, e.name) || DATE_FUNCTIONS[e.name] !== args.length)
112
+ return e;
113
+ return build.lit(dateFunction(e.name, args).value);
114
+ }
115
+ if (!isLit(e.recv) || !Object.hasOwn(DATE_METHODS, e.name) || DATE_METHODS[e.name] !== args.length)
116
+ return e;
117
+ return build.lit(dateMethod(e.name, e.recv.value, args).value);
118
+ }
94
119
  function rewriteQuery(q) {
95
- return transform(q, (e) => (e.kind === "call" && e.recv === null && ROW_FNS.has(e.name) ? { ...e, recv: build.ident("$self") } : rootPathLiterals(e)));
120
+ return transform(q, (e) => (e.kind === "call" && e.recv === null && ROW_FNS.has(e.name) ? { ...e, recv: build.ident("$self") } : foldDates(rootPathLiterals(e))));
96
121
  }
97
122
  // A top-level `limit`/`offset` on the collect path is applied by the runner (see
98
123
  // oqxRunInner), so it must be a plain number literal here — there is no row to
@@ -347,6 +372,33 @@ function runResolved(store, repoId, raw, opts) {
347
372
  const res = engine.run(parsed, []);
348
373
  return { hits: [], truncated: false, cursor: null, consumer: "none", none: res.consumer === "none" ? res.none : true };
349
374
  }
375
+ // A grouped query (oqx 0.24, SEMANTICS §24) yields GROUPS, not store rows:
376
+ // the envelope does not apply — there is no `id`/`path` to inject and no
377
+ // keyset to page by — so a hit is the group's projection as the query wrote
378
+ // it (`{ layer, n }`), exactly what `bare` yields for a row query; `bare` is
379
+ // accepted and changes nothing. Rows that surface as values (`rows collect {
380
+ // }` with an empty projection, `collect(doc)`) render as `{ id, path }` refs
381
+ // like every row-as-value. Paging is by position: the query's own
382
+ // `limit`/`offset` define the set, the tool's `limit` takes the first page,
383
+ // `truncated` says whether more groups follow, the cursor is always null (as
384
+ // under a custom `order by`). `distinct` cannot occur (a parse error).
385
+ if (parsed.groupBy)
386
+ return runGrouped(engine, parsed, consumer, opts);
387
+ // A flattened query (oqx 0.25, SEMANTICS §25) yields the parent rows, one per
388
+ // element: a hit is still a store row, so the envelope is injected as usual
389
+ // and repeats per element (Dataview's `TABLE … FLATTEN`). What changes is
390
+ // uniqueness — `(path, id)` no longer names one hit — so the keyset cursor
391
+ // cannot land inside a document's elements: a flattened query pages
392
+ // positionally with a null cursor, as a grouped query and a custom `order by`
393
+ // do (a keyset extended with the flatten ordinal is a later round). A bound
394
+ // name of `id` or `path` would shadow the envelope's own reads (the name
395
+ // shadows a same-named row property on the flattened row): refused, like a
396
+ // projection that collides with the envelope.
397
+ const flattened = parsed.flatten.length > 0;
398
+ for (const f of parsed.flatten) {
399
+ if (ENVELOPE_KEYS.has(f.name))
400
+ throw new FilterInvalid(`flatten \`as ${f.name}\` collides with the hit envelope — a flattened row is still the row, whose \`${f.name}\` the envelope reads; bind another name`, "OQX");
401
+ }
350
402
  // collect / first / single: inject id + path so every hit carries them. A
351
403
  // top-level `select distinct` is applied HERE, not in the engine: the injected
352
404
  // id/path are unique per row and would defeat the engine's projection dedup, so
@@ -400,7 +452,7 @@ function runResolved(store, repoId, raw, opts) {
400
452
  const limit = constBound(topLimit, "limit");
401
453
  if (offset || limit != null)
402
454
  rows = rows.slice(offset, limit == null ? undefined : offset + limit);
403
- const custom = !!parsed.orderBy && parsed.orderBy.length > 0;
455
+ const custom = (!!parsed.orderBy && parsed.orderBy.length > 0) || flattened;
404
456
  const cap = opts.limit ?? 50;
405
457
  let page = rows;
406
458
  if (!custom && opts.cursor) {
@@ -417,6 +469,34 @@ function runResolved(store, repoId, raw, opts) {
417
469
  return { hits: [], truncated, cursor, consumer: "collect", values: page.map((s) => s.proj[VALUE_KEY]) };
418
470
  return { hits: page.map(render), truncated, cursor, consumer: "collect" };
419
471
  }
472
+ // The collect/first/single path of a grouped query (see `runResolved`).
473
+ function runGrouped(engine, parsed, consumer, opts) {
474
+ const topValues = parsed.values;
475
+ // A `values` projection rides as a record under VALUE_KEY and is peeled off
476
+ // the page, as on the row path.
477
+ const select = topValues ? [{ ...parsed.select[0], name: VALUE_KEY }] : parsed.select;
478
+ const { limit: topLimit, offset: topOffset } = parsed;
479
+ const base = consumer === "collect" ? { ...parsed, limit: null, offset: null } : parsed;
480
+ const res = engine.run({ ...base, values: false, select }, []);
481
+ const project = (row) => renderValue(row);
482
+ if (consumer === "first" || consumer === "single") {
483
+ const row = res.consumer === "first" || res.consumer === "single" ? res.row : null;
484
+ if (topValues)
485
+ return { hits: [], truncated: false, cursor: null, consumer, values: row == null ? [] : [project(row)[VALUE_KEY]] };
486
+ return { hits: row == null ? [] : [project(row)], truncated: false, cursor: null, consumer };
487
+ }
488
+ let groups = (res.consumer === "collect" ? res.rows : []).map(project);
489
+ const offset = constBound(topOffset, "offset") ?? 0;
490
+ const limit = constBound(topLimit, "limit");
491
+ if (offset || limit != null)
492
+ groups = groups.slice(offset, limit == null ? undefined : offset + limit);
493
+ const cap = opts.limit ?? 50;
494
+ const truncated = groups.length > cap;
495
+ const page = groups.slice(0, cap);
496
+ if (topValues)
497
+ return { hits: [], truncated, cursor: null, consumer: "collect", values: page.map((g) => g[VALUE_KEY]) };
498
+ return { hits: page, truncated, cursor: null, consumer: "collect" };
499
+ }
420
500
  export async function oqxRunAsync(store, repoId, source, opts = {}, embedQuery) {
421
501
  const phrases = collectSemanticPhrases(source);
422
502
  if (phrases.length === 0)
@@ -60,14 +60,16 @@ export const NON_PROPERTY_NAMES = {
60
60
  };
61
61
  // ---- engine attributes ------------------------------------------------------
62
62
  // The engine attributes (spec/surface §1.2, 3.0: bare, snake_case, winning on
63
- // collision — `id`, `path`, `raw`, `hash`, `updated_at` on docs and blocks,
64
- // `title`/`tags` on docs, `id`/`path` on nodes and edges) → a param-free SQL
63
+ // collision — `id`, `path`, `raw`, `hash`, `updated_at`, `created_at` on docs
64
+ // and blocks, `title`/`tags` on docs, `id`/`path` on nodes and edges) → a param-free SQL
65
65
  // scalar, per target, with its kind. The structural columns and foreign keys are
66
66
  // translated in `translateOperand`. Anything not mapped (`raw`, reconstructed or
67
67
  // a blob; `title`, computed; `tags`, the union of the authored list and the
68
68
  // computed hashtags) returns null → residual, and the caller must NOT fall
69
69
  // through to a property read of the same name (the attribute wins in memory).
70
- // `updated_at` is a correlated subquery. `path` (and the edge field `dst_path`)
70
+ // `updated_at` and `created_at` (4.10) are correlated subqueries over the
71
+ // document's revisions (`current_rev` / `seq = 1`); a block's are residual, as
72
+ // before. `path` (and the edge field `dst_path`)
71
73
  // is the REFERENCE form on the surface (spec/surface §1 "Paths", 2.0) — `'/' ||
72
74
  // <storage column>` in SQL — so every comparison, `startsWith`, `contains`,
73
75
  // `lower()`… sees exactly the string the in-memory attribute yields;
@@ -79,8 +81,8 @@ export function pathColumn(col) {
79
81
  /** The identity/bytes/time attributes per kind — the names `attributeSql` owns
80
82
  * (the structural columns are handled per target in `translateOperand`). */
81
83
  export const ROW_ATTRIBUTES = {
82
- docs: new Set(["id", "path", "raw", "hash", "updated_at", "title", "tags"]),
83
- blocks: new Set(["id", "path", "raw", "hash", "updated_at"]),
84
+ docs: new Set(["id", "path", "raw", "hash", "updated_at", "created_at", "title", "tags"]),
85
+ blocks: new Set(["id", "path", "raw", "hash", "updated_at", "created_at"]),
84
86
  nodes: new Set(["id", "path"]),
85
87
  links: new Set(["id", "path"]),
86
88
  };
@@ -93,6 +95,7 @@ function attributeSql(name, ctx) {
93
95
  case "path": return pathColumn(`${doc}.path`);
94
96
  case "hash": return text(`lower(hex(${self}.file_hash))`);
95
97
  case "updated_at": return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${self}.current_rev)`);
98
+ case "created_at": return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = ${self}.doc_id AND r.seq = 1)`);
96
99
  }
97
100
  else if (target === "blocks")
98
101
  switch (name) {
@@ -300,6 +303,8 @@ export function translateOperand(e, ctx) {
300
303
  return text(`lower(hex(${doc}.file_hash))`);
301
304
  if (k === "updated_at")
302
305
  return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${doc}.current_rev)`);
306
+ if (k === "created_at")
307
+ return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = ${doc}.doc_id AND r.seq = 1)`);
303
308
  if (k === "format")
304
309
  return text(`${doc}.format`);
305
310
  if (k.startsWith("$"))
@@ -334,6 +339,8 @@ export function translateOperand(e, ctx) {
334
339
  return probe("lower(hex(dd.file_hash))");
335
340
  if (k === "updated_at")
336
341
  return probe("(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = dd.current_rev)");
342
+ if (k === "created_at")
343
+ return probe("(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = dd.doc_id AND r.seq = 1)");
337
344
  if (k === "format")
338
345
  return probe("dd.format");
339
346
  if (k.startsWith("$"))
@@ -79,7 +79,7 @@ function probeFor(target, path) {
79
79
  // relation / handle (`out_links`, `data`, `frontmatter`, …), not the `format` column
80
80
  // (not indexed), not an OQX `$` name, and of the engine attributes only
81
81
  // `title` (the computed `$title` row, a scalar property the author's `title:`
82
- // never reaches — `data.title` does): `raw`, `hash`, `updated_at` and `tags`
82
+ // never reaches — `data.title` does): `raw`, `hash`, `updated_at`, `created_at` and `tags`
83
83
  // (authored ∪ hashtags) have no index and are never property probes.
84
84
  if (key === "title")
85
85
  return { kind: "property", key: "$title", source: "computed" };
@@ -1,14 +1,14 @@
1
1
  export declare const SPEC_VERSIONS: {
2
- readonly oqx: "0.23";
2
+ readonly oqx: "0.25";
3
3
  readonly format: "0.2";
4
4
  readonly reconcile: "2.3";
5
- readonly store: "14.2";
5
+ readonly store: "15.0";
6
6
  readonly properties: "1.1";
7
7
  readonly graph: "1.1";
8
8
  readonly search: "2.0";
9
9
  readonly mutate: "3.5";
10
10
  readonly sync: "2.2";
11
- readonly surface: "4.7";
12
- readonly cli: "4.5";
11
+ readonly surface: "4.10";
12
+ readonly cli: "4.6";
13
13
  };
14
14
  export type SpecName = keyof typeof SPEC_VERSIONS;
@@ -13,13 +13,13 @@ export const SPEC_VERSIONS = {
13
13
  oqx: LANGUAGE_VERSION,
14
14
  format: "0.2",
15
15
  reconcile: "2.3",
16
- store: "14.2",
16
+ store: "15.0",
17
17
  properties: "1.1",
18
18
  graph: "1.1",
19
19
  search: "2.0",
20
20
  mutate: "3.5",
21
21
  sync: "2.2",
22
- surface: "4.7",
23
- cli: "4.5",
22
+ surface: "4.10",
23
+ cli: "4.6",
24
24
  };
25
25
  //# sourceMappingURL=spec-versions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/core",
3
- "version": "0.21.0",
3
+ "version": "0.24.0",
4
4
  "description": "Open Markdown Graph Base — embedded engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@modelcontextprotocol/sdk": "^1.30.0",
58
- "@omgbase/oqx": "^0.23.0",
58
+ "@omgbase/oqx": "^0.25.0",
59
59
  "better-sqlite3": "^13.0.3",
60
60
  "mdast-util-from-markdown": "^2.0.2",
61
61
  "mdast-util-frontmatter": "^2.0.1",