@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
@@ -23,6 +23,7 @@ import { sanitizeFtsQuery } from "../search/fts-query.js";
23
23
  import { cosineBytes } from "../core/vec.js";
24
24
  import { ctxHashHex } from "../search/embeddings.js";
25
25
  import { makeBlockContextResolver } from "../search/tasks.js";
26
+ import { dateFunction, dateMethod } from "./dates.js";
26
27
  const TARGET = Symbol("oqx.target");
27
28
  const ROOT_ROW = Symbol("oqx.rootRow");
28
29
  const PROP_SOURCE = Symbol("oqx.propSource");
@@ -44,7 +45,9 @@ function tagAll(rows, t) {
44
45
  // (`children`, `blocks`, `out_links`, `doc`, `frontmatter`, …) always followed:
45
46
  // a bare name that is an engine attribute reads the engine's value, never the
46
47
  // author's. The attributes: every row's `id`/`path`; docs' and blocks' `raw`/
47
- // `hash`/`updated_at`; docs' `title` (the computed title) and `tags` (authored
48
+ // `hash`/`updated_at`/`created_at` (4.10: the commit `ts` of the thing's first
49
+ // revision / earliest `block_changes` row — "first observed by omgbase", not a
50
+ // filesystem time); docs' `title` (the computed title) and `tags` (authored
48
51
  // tags ∪ body hashtags) and `format`; a foreign key named for what it is
49
52
  // (`doc_id`, `block_id`, `via_id`, `dst_id`, `dst_path`, `uri`), a column its
50
53
  // column (`type`, `text`, `depth`, `ordinal`, `kind`, `predicate`, `field`).
@@ -59,14 +62,16 @@ function tagAll(rows, t) {
59
62
  // target name — `docs`, `links`, … — is a loud error of its own (root-row.ts).
60
63
  // `revisions` on a document is a RELATION (its revision list, §1.2, 4.7) in
61
64
  // `rel.docs` below, and names.ts's `RELATIONS.docs` knows it as one.
62
- const DOC_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "title", "tags", "format"]);
63
- const BLOCK_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "doc_id", "type", "text", "attrs", "depth", "ordinal"]);
65
+ const DOC_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "created_at", "title", "tags", "format"]);
66
+ const BLOCK_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "created_at", "doc_id", "type", "text", "attrs", "depth", "ordinal"]);
64
67
  const NODE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "kind", "name", "value", "attrs"]);
65
68
  const EDGE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "via_id", "dst_id", "dst_path", "uri", "field", "dst_kind", "predicate", "provenance", "anchor"]);
66
69
  // The history target (spec/surface §1.2, 4.7): a revision row's own columns
67
70
  // under the names `docs_history` already uses (`rev`, `seq`, `commit`, `origin`,
68
- // `actor`, `hash`), the commit's `ts` and `reason`, and `is_current`.
69
- const REVISION_ATTRIBUTES = new Set(["id", "path", "rev", "seq", "doc_id", "commit", "ts", "origin", "actor", "reason", "hash", "is_current"]);
71
+ // `actor`, `hash`), the commit's `ts` and `reason`, `is_current`, and (4.9)
72
+ // `archived` — the revision's bodies live in an archive segment (spec/store §7
73
+ // "Retention"; `docs_read { at }` reads through it when attached).
74
+ const REVISION_ATTRIBUTES = new Set(["id", "path", "rev", "seq", "doc_id", "commit", "ts", "origin", "actor", "reason", "hash", "is_current", "archived"]);
70
75
  /** The engine attributes per kind (spec/surface §1.2 table). Exported so the
71
76
  * translator's tests pin the two against each other. */
72
77
  export const ATTRIBUTES = {
@@ -78,7 +83,7 @@ export const ATTRIBUTES = {
78
83
  // `revisions` table carries no `repo_id`, so the repository guard joins `docs`.
79
84
  // `is_current` is `docs_history`'s `isCurrent`: the revision is the document's
80
85
  // `current_rev`.
81
- const REVISION_COLS = "r.rev_id, r.doc_id, r.seq, r.rendered_hash, r.path, r.commit_id, c.ts, c.origin, c.actor, c.reason, (d.current_rev = r.rev_id) AS is_current";
86
+ const REVISION_COLS = "r.rev_id, r.doc_id, r.seq, r.rendered_hash, r.path, r.commit_id, c.ts, c.origin, c.actor, c.reason, (d.current_rev = r.rev_id) AS is_current, (r.archive IS NOT NULL) AS archived";
82
87
  const REVISION_FROM = "revisions r JOIN docs d ON d.doc_id = r.doc_id JOIN commits c ON c.commit_id = r.commit_id";
83
88
  /** The source handles of a document (§1.2): `data` is the merged authored view
84
89
  * (frontmatter + inline), the other two one source each. */
@@ -461,6 +466,12 @@ export function makeStoreContext(store, repoId, opts = {}) {
461
466
  case "path": return referencePath(String(row.path));
462
467
  case "hash": return row.file_hash == null ? null : hex(row.file_hash);
463
468
  case "updated_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?`, row.current_rev) ?? null;
469
+ // `created_at` (4.10): the commit `ts` of the document's first revision
470
+ // (`seq = 1`, the row `revisions first { ts values }` reads) — when
471
+ // omgbase first observed the document, not a filesystem time. The
472
+ // retention squash (spec/store §7.1, 15.0) keeps every revision and
473
+ // commit row, so the value survives archiving.
474
+ case "created_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = ? AND r.seq = 1`, row.doc_id) ?? null;
464
475
  // `raw`: the exact file bytes (spec/store §6.1, frontmatter included).
465
476
  case "raw": return docsRead(store, String(row.doc_id))?.raw ?? null;
466
477
  // `title`: the computed title (spec/properties §3.3 — the authored
@@ -484,6 +495,13 @@ export function makeStoreContext(store, repoId, opts = {}) {
484
495
  case "raw": return row.raw_hash == null ? null : scalar(`SELECT bytes FROM blobs WHERE hash = ?`, row.raw_hash)?.toString("utf8") ?? null;
485
496
  case "hash": return row.raw_hash == null ? null : hex(row.raw_hash);
486
497
  case "updated_at": return scalar(`SELECT MAX(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?`, row.block_id) ?? null;
498
+ // `created_at` (4.10): the EARLIEST commit `ts` among the block's
499
+ // `block_changes` — the same join as `updated_at` under MIN: the commit
500
+ // that first wrote the id (`inserted`, or the split/merge lineage row
501
+ // that minted it). Store 14.2 keeps that row and the 15.0 squash keeps
502
+ // each block's earliest row (spec/store §7.1), so it survives archiving;
503
+ // a resurrected block keeps its original creation.
504
+ case "created_at": return scalar(`SELECT MIN(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?`, row.block_id) ?? null;
487
505
  case "doc_id": return row.doc_id;
488
506
  case "type": return row.type;
489
507
  case "text": return row.text;
@@ -537,6 +555,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
537
555
  case "reason": return row.reason ?? null;
538
556
  case "hash": return hex(row.rendered_hash);
539
557
  case "is_current": return row.is_current === 1 || row.is_current === true;
558
+ case "archived": return row.archived === 1 || row.archived === true;
540
559
  }
541
560
  return undefined;
542
561
  };
@@ -888,6 +907,13 @@ export function makeStoreContext(store, repoId, opts = {}) {
888
907
  }
889
908
  if (name === "refs")
890
909
  return { handled: true, value: refs(args[0]) };
910
+ // `date(x)` / `dur(x)` (spec/surface §1.3 "Dates and durations", 4.8):
911
+ // pure over their argument except the named points, which read the host
912
+ // clock (pinned by the spec clock seam). run.ts folds a literal argument
913
+ // before planning, so the arm here serves the non-literal ones.
914
+ const dated = dateFunction(name, args);
915
+ if (dated.handled)
916
+ return dated;
891
917
  if (name === "range") {
892
918
  const s = args[0];
893
919
  if (semantics.isRange(s))
@@ -898,12 +924,17 @@ export function makeStoreContext(store, repoId, opts = {}) {
898
924
  const fn = semantics.BUILTIN_FUNCTIONS[name];
899
925
  return fn ? { handled: true, value: fn(args) } : { handled: false };
900
926
  },
901
- // Domain methods first; fall back to oqx-js builtin methods
902
- // (contains/startsWith/endsWith/matches/size/lower/upper).
927
+ // Domain methods first, then the date methods (`plus`/`minus`/`until`/the
928
+ // fields/`format` on a date string, `dur_text`/`dur_iso` on a number —
929
+ // §1.3 4.8; total: any other receiver is absent); fall back to oqx-js
930
+ // builtin methods (contains/startsWith/endsWith/matches/size/lower/upper).
903
931
  callMethod(name, recv, args) {
904
932
  const r = method(name, recv, args);
905
933
  if (r.handled)
906
934
  return r;
935
+ const dated = dateMethod(name, recv, args);
936
+ if (dated.handled)
937
+ return dated;
907
938
  const fn = semantics.BUILTIN_METHODS[name];
908
939
  return fn ? { handled: true, value: fn(recv, args) } : { handled: false };
909
940
  },
@@ -0,0 +1,105 @@
1
+ import type { CallResult } from "@omgbase/oqx";
2
+ /** A parsed date: the instant, the offset it was WRITTEN in (minutes east of
3
+ * UTC; `0` for `Z` and for a date-only value), whether it is date-only, and
4
+ * whether it carried a fraction of a second as written (kept on render). */
5
+ export interface DateValue {
6
+ ms: number;
7
+ offset: number;
8
+ dateOnly: boolean;
9
+ fraction: boolean;
10
+ }
11
+ /** Parse an ISO-8601 date / datetime string; `null` for anything else (a
12
+ * non-string, a malformed string, an out-of-range field such as month 13 or
13
+ * hour 24). */
14
+ export declare function parseDate(x: unknown): DateValue | null;
15
+ interface Fields {
16
+ year: number;
17
+ month: number;
18
+ day: number;
19
+ hour: number;
20
+ minute: number;
21
+ second: number;
22
+ millis: number;
23
+ weekday: number;
24
+ }
25
+ /** The fields of the value in the offset it was written in (`weekday` 1 = Monday … 7 = Sunday). */
26
+ export declare function fieldsOf(d: DateValue): Fields;
27
+ /** The canonical string: `YYYY-MM-DD` for a date-only value; otherwise
28
+ * `YYYY-MM-DDThh:mm:ss` + `.fff` when the value carries a fraction (as written,
29
+ * or because arithmetic produced one) + `Z` for UTC or the written `±hh:mm`. */
30
+ export declare function renderDate(d: DateValue): string | null;
31
+ /** `date(x)` on a string that is not a named point: the canonical form, or null. */
32
+ export declare function canonicalDate(x: unknown): string | null;
33
+ /** The named points `date()` resolves against the clock. */
34
+ export declare const NAMED_POINTS: ReadonlySet<string>;
35
+ /** Resolve a named point at the instant `nowMs` (UTC): `now` is the instant
36
+ * with milliseconds (the form the store stamps commits in); the others are
37
+ * date-only — `sow`/`eow` the Monday/Sunday of the current week, `som`/`eom`
38
+ * the first/last day of the month, `soy`/`eoy` of the year. Null for a name
39
+ * that is not a point. */
40
+ export declare function namedPoint(name: string, nowMs: number): DateValue | null;
41
+ /** `date(x)`: a named point against the clock, else the canonical ISO form, else null. */
42
+ export declare function dateOf(x: unknown, nowMs?: number): string | null;
43
+ /** A parsed duration: the calendar counts `years` and `months` kept apart from
44
+ * the fixed-length `seconds`, so `plus` can be calendar-aware while `dur()`
45
+ * applies the 365-day / 30-day conventions. */
46
+ export interface Duration {
47
+ years: number;
48
+ months: number;
49
+ seconds: number;
50
+ }
51
+ /** Days per month / year under the fixed conventions `dur()` applies. */
52
+ export declare const MONTH_SECONDS: number;
53
+ export declare const YEAR_SECONDS: number;
54
+ /** Parse a duration string: Dataview's `<number> <unit>` pairs, each pair
55
+ * separated from the next by spaces, a comma, or nothing (`"1h30m"`, `"2 days,
56
+ * 3 hours"`; a trailing separator is malformed), units case-insensitive:
57
+ * y|yr|yrs|year|years, mo|mos|month|months, w|wk|wks|week|weeks, d|day|days,
58
+ * h|hr|hrs|hour|hours, m|min|mins|minute|minutes, s|sec|secs|second|seconds —
59
+ * or an ISO-8601 duration (`P1DT2H`, `PT90M`, `P2W`). Null when it is neither. */
60
+ export declare function parseDuration(x: unknown): Duration | null;
61
+ /** A duration's length in seconds under the conventions: a year is 365 days, a month 30. */
62
+ export declare function durationSeconds(d: Duration): number;
63
+ /** `dur(x)`: a number is that many seconds; a string is parsed; else null. */
64
+ export declare function durOf(x: unknown): number | null;
65
+ /** Shift a date by a whole number of calendar `months` (the month field moves
66
+ * and the day is clamped to the new month's length) and then by `seconds`. A
67
+ * date-only value stays date-only when the seconds are a whole number of days;
68
+ * otherwise it becomes a datetime in its offset (UTC for a date-only receiver). */
69
+ export declare function shiftDate(d: DateValue, months: number, seconds: number): DateValue;
70
+ /** A duration as `plus` applies it: the WHOLE years and months are calendar
71
+ * months; a fractional year or month count is not calendar — its fraction is
72
+ * 365-day / 30-day seconds — and joins the fixed-length seconds. */
73
+ export declare function calendarParts(dur: Duration): {
74
+ months: number;
75
+ seconds: number;
76
+ };
77
+ /** `d.plus(x)` (`sign` 1) / `d.minus(x)` (`sign` -1). */
78
+ export declare function plusMinus(recv: unknown, arg: unknown, sign: 1 | -1): string | null;
79
+ /** `a.until(b)`: seconds from `a` to `b` (negative when `b` is earlier), by instant. */
80
+ export declare function until(a: unknown, b: unknown): number | null;
81
+ /** `d.format(pattern)`: `yyyy` year, `MM` month, `dd` day, `HH` hour, `mm`
82
+ * minute, `ss` second (two digits each), `MMM` the English month abbreviation
83
+ * (`Jan`), `EEE` the English weekday abbreviation (`Mon`); every other
84
+ * character is literal. Fields are read in the written offset; a date-only
85
+ * value has `00` time fields. */
86
+ export declare function formatDate(recv: unknown, pattern: unknown): string | null;
87
+ /** `n.dur_text()`: `"2d 3h"`, `"1h 30m"`, `"45s"` — days, hours, minutes,
88
+ * seconds, zero parts omitted, `"0s"` for zero, a leading `-` for a negative
89
+ * duration, a fractional second kept (`"1.5s"`). Null for a non-number. */
90
+ export declare function durText(n: unknown): string | null;
91
+ /** `n.dur_iso()`: the ISO-8601 duration `P[nD][T[nH][nM][nS]]` — `PT1H30M`,
92
+ * `P2DT3H`, `PT45S`, `PT0S` for zero, a leading `-` for a negative duration.
93
+ * Null for a non-number. */
94
+ export declare function durIso(n: unknown): string | null;
95
+ /** The free functions and their arity. */
96
+ export declare const DATE_FUNCTIONS: Readonly<Record<string, number>>;
97
+ /** The methods and their arity: on a date string, and (`dur_text`/`dur_iso`) on a number. */
98
+ export declare const DATE_METHODS: Readonly<Record<string, number>>;
99
+ /** `callFunction` arm: `date(x)` / `dur(x)`; `{ handled: false }` for any other name. */
100
+ export declare function dateFunction(name: string, args: unknown[], nowMs?: number): CallResult;
101
+ /** `callMethod` arm: the date methods on a string receiver and the duration
102
+ * renderers on a number; a receiver of any other kind yields absent (the
103
+ * methods are total). `{ handled: false }` for any other name. */
104
+ export declare function dateMethod(name: string, recv: unknown, args: unknown[]): CallResult;
105
+ export {};
@@ -0,0 +1,427 @@
1
+ // Dates and durations for the query binding (spec/surface §1.3 "Dates and
2
+ // durations", 4.8): the Dataview-parity function set, served as omgbase HOST
3
+ // functions through `@omgbase/oqx`'s `callFunction` / `callMethod` seam.
4
+ //
5
+ // Two value conventions and no new value kinds:
6
+ // • a DATE is an ISO-8601 string — `YYYY-MM-DD` (date-only) or
7
+ // `YYYY-MM-DDThh:mm:ss[.fff](Z|±hh:mm)` — exactly what the store writes for
8
+ // `updated_at`/`ts` and what OQX already orders chronologically by code
9
+ // point (spec/oqx SEMANTICS §6);
10
+ // • a DURATION is a number of seconds.
11
+ // Arithmetic between them is METHODS (`d.plus(s)`, `a.until(b)`), never the
12
+ // `+`/`-` operators: `+` with a string operand concatenates (SEMANTICS §7), so
13
+ // `"2026-01-05" + 86400` is `"2026-01-0586400"` and must stay so.
14
+ //
15
+ // Everything is total: a value that is not a date (or a duration) yields
16
+ // absent, so a `where` over mixed data filters quietly. Only a wrong ARITY is
17
+ // an error (`filter_invalid`, the surface's eval error). The one impure input
18
+ // — the named points `today`, `now`, `sow`… — reads the HOST clock, UTC, through
19
+ // `Date.now()`, which the spec clock seam (`core/clock.ts`, `OMGBASE_SPEC_CLOCK`)
20
+ // pins; the language itself has no clock (the fixtures run under the corpus
21
+ // clock, so `date("today")` is `2026-09-27` there).
22
+ //
23
+ // The pure half (parse / render / shift / until / fields / format / the duration
24
+ // grammar) is exported for the unit tests and the Rust twin's reading; the two
25
+ // `CallResult` arms at the bottom are what context.ts and run.ts's constant fold
26
+ // call.
27
+ import { FilterInvalid } from "../search/cel/parser.js";
28
+ const DAY_MS = 86_400_000;
29
+ // `YYYY-MM-DD`, optionally `T`/`t`/space + `hh:mm[:ss[.fff…]]` + optional offset
30
+ // (`Z`, `±hh:mm`, `±hhmm`); a time without an offset is UTC.
31
+ const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?(Z|z|[+-]\d{2}:?\d{2})?)?$/;
32
+ function daysInMonth(year, month) {
33
+ // month 1..12; day 0 of the next month is the last day of this one.
34
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
35
+ }
36
+ /** Parse an ISO-8601 date / datetime string; `null` for anything else (a
37
+ * non-string, a malformed string, an out-of-range field such as month 13 or
38
+ * hour 24). */
39
+ export function parseDate(x) {
40
+ if (typeof x !== "string")
41
+ return null;
42
+ const m = ISO_RE.exec(x.trim());
43
+ if (!m)
44
+ return null;
45
+ const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
46
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month))
47
+ return null;
48
+ if (m[4] === undefined) {
49
+ return { ms: Date.UTC(year, month - 1, day), offset: 0, dateOnly: true, fraction: false };
50
+ }
51
+ const hour = Number(m[4]), minute = Number(m[5]), second = m[6] === undefined ? 0 : Number(m[6]);
52
+ if (hour > 23 || minute > 59 || second > 59)
53
+ return null;
54
+ const fraction = m[7] !== undefined;
55
+ const millis = fraction ? Number(m[7].padEnd(3, "0").slice(0, 3)) : 0;
56
+ let offset = 0;
57
+ const z = m[8];
58
+ if (z !== undefined && z !== "Z" && z !== "z") {
59
+ const sign = z[0] === "-" ? -1 : 1;
60
+ const digits = z.slice(1).replace(":", "");
61
+ const oh = Number(digits.slice(0, 2)), om = Number(digits.slice(2, 4));
62
+ if (oh > 23 || om > 59)
63
+ return null;
64
+ offset = sign * (oh * 60 + om);
65
+ }
66
+ const local = Date.UTC(year, month - 1, day, hour, minute, second, millis);
67
+ return { ms: local - offset * 60_000, offset, dateOnly: false, fraction };
68
+ }
69
+ /** The fields of the value in the offset it was written in (`weekday` 1 = Monday … 7 = Sunday). */
70
+ export function fieldsOf(d) {
71
+ const t = new Date(d.ms + d.offset * 60_000);
72
+ return {
73
+ year: t.getUTCFullYear(), month: t.getUTCMonth() + 1, day: t.getUTCDate(),
74
+ hour: t.getUTCHours(), minute: t.getUTCMinutes(), second: t.getUTCSeconds(), millis: t.getUTCMilliseconds(),
75
+ weekday: ((t.getUTCDay() + 6) % 7) + 1,
76
+ };
77
+ }
78
+ const pad = (n, w) => String(n).padStart(w, "0");
79
+ /** The canonical string: `YYYY-MM-DD` for a date-only value; otherwise
80
+ * `YYYY-MM-DDThh:mm:ss` + `.fff` when the value carries a fraction (as written,
81
+ * or because arithmetic produced one) + `Z` for UTC or the written `±hh:mm`. */
82
+ export function renderDate(d) {
83
+ if (!Number.isFinite(d.ms))
84
+ return null;
85
+ const f = fieldsOf(d);
86
+ const ymd = `${pad(f.year, 4)}-${pad(f.month, 2)}-${pad(f.day, 2)}`;
87
+ if (d.dateOnly)
88
+ return ymd;
89
+ let s = `${ymd}T${pad(f.hour, 2)}:${pad(f.minute, 2)}:${pad(f.second, 2)}`;
90
+ if (d.fraction || f.millis !== 0)
91
+ s += `.${pad(f.millis, 3)}`;
92
+ if (d.offset === 0)
93
+ return `${s}Z`;
94
+ const a = Math.abs(d.offset);
95
+ return `${s}${d.offset < 0 ? "-" : "+"}${pad(Math.floor(a / 60), 2)}:${pad(a % 60, 2)}`;
96
+ }
97
+ /** `date(x)` on a string that is not a named point: the canonical form, or null. */
98
+ export function canonicalDate(x) {
99
+ const d = parseDate(x);
100
+ return d === null ? null : renderDate(d);
101
+ }
102
+ // ---- named points (the host clock, UTC) --------------------------------------------
103
+ /** The named points `date()` resolves against the clock. */
104
+ export const NAMED_POINTS = new Set(["now", "today", "yesterday", "tomorrow", "sow", "eow", "som", "eom", "soy", "eoy"]);
105
+ const dateOnly = (ms) => ({ ms, offset: 0, dateOnly: true, fraction: false });
106
+ /** Resolve a named point at the instant `nowMs` (UTC): `now` is the instant
107
+ * with milliseconds (the form the store stamps commits in); the others are
108
+ * date-only — `sow`/`eow` the Monday/Sunday of the current week, `som`/`eom`
109
+ * the first/last day of the month, `soy`/`eoy` of the year. Null for a name
110
+ * that is not a point. */
111
+ export function namedPoint(name, nowMs) {
112
+ const t = new Date(nowMs);
113
+ const y = t.getUTCFullYear(), mo = t.getUTCMonth(), d = t.getUTCDate();
114
+ const today = Date.UTC(y, mo, d);
115
+ switch (name) {
116
+ case "now": return { ms: nowMs, offset: 0, dateOnly: false, fraction: true };
117
+ case "today": return dateOnly(today);
118
+ case "yesterday": return dateOnly(today - DAY_MS);
119
+ case "tomorrow": return dateOnly(today + DAY_MS);
120
+ case "sow": return dateOnly(today - ((t.getUTCDay() + 6) % 7) * DAY_MS);
121
+ case "eow": return dateOnly(today + (6 - (t.getUTCDay() + 6) % 7) * DAY_MS);
122
+ case "som": return dateOnly(Date.UTC(y, mo, 1));
123
+ case "eom": return dateOnly(Date.UTC(y, mo + 1, 0));
124
+ case "soy": return dateOnly(Date.UTC(y, 0, 1));
125
+ case "eoy": return dateOnly(Date.UTC(y, 11, 31));
126
+ default: return null;
127
+ }
128
+ }
129
+ /** `date(x)`: a named point against the clock, else the canonical ISO form, else null. */
130
+ export function dateOf(x, nowMs = Date.now()) {
131
+ if (typeof x !== "string")
132
+ return null;
133
+ const key = x.trim().toLowerCase();
134
+ if (NAMED_POINTS.has(key))
135
+ return renderDate(namedPoint(key, nowMs));
136
+ return canonicalDate(x);
137
+ }
138
+ const UNIT_SECONDS = {
139
+ w: 604_800, wk: 604_800, wks: 604_800, week: 604_800, weeks: 604_800,
140
+ d: 86_400, day: 86_400, days: 86_400,
141
+ h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600,
142
+ m: 60, min: 60, mins: 60, minute: 60, minutes: 60,
143
+ s: 1, sec: 1, secs: 1, second: 1, seconds: 1,
144
+ };
145
+ const UNIT_YEARS = new Set(["y", "yr", "yrs", "year", "years"]);
146
+ const UNIT_MONTHS = new Set(["mo", "mos", "month", "months"]);
147
+ /** Days per month / year under the fixed conventions `dur()` applies. */
148
+ export const MONTH_SECONDS = 30 * 86_400;
149
+ export const YEAR_SECONDS = 365 * 86_400;
150
+ const NUM = String.raw `(\d+(?:\.\d+)?)`;
151
+ const ISO_DUR_RE = new RegExp(`^([+-])?P(?:${NUM}Y)?(?:${NUM}M)?(?:${NUM}W)?(?:${NUM}D)?(?:T(?:${NUM}H)?(?:${NUM}M)?(?:${NUM}S)?)?$`, "i");
152
+ const PAIR_RE = /^(-?\d+(?:\.\d+)?)\s*([a-zA-Z]+)/;
153
+ /** Parse a duration string: Dataview's `<number> <unit>` pairs, each pair
154
+ * separated from the next by spaces, a comma, or nothing (`"1h30m"`, `"2 days,
155
+ * 3 hours"`; a trailing separator is malformed), units case-insensitive:
156
+ * y|yr|yrs|year|years, mo|mos|month|months, w|wk|wks|week|weeks, d|day|days,
157
+ * h|hr|hrs|hour|hours, m|min|mins|minute|minutes, s|sec|secs|second|seconds —
158
+ * or an ISO-8601 duration (`P1DT2H`, `PT90M`, `P2W`). Null when it is neither. */
159
+ export function parseDuration(x) {
160
+ if (typeof x !== "string")
161
+ return null;
162
+ const s = x.trim();
163
+ if (s === "")
164
+ return null;
165
+ const iso = ISO_DUR_RE.exec(s);
166
+ if (iso) {
167
+ const [, sign, y, mo, w, d, h, mi, sec] = iso;
168
+ if ([y, mo, w, d, h, mi, sec].every((v) => v === undefined))
169
+ return null;
170
+ const n = (v) => (v === undefined ? 0 : Number(v));
171
+ const k = sign === "-" ? -1 : 1;
172
+ return {
173
+ years: k * n(y),
174
+ months: k * n(mo),
175
+ seconds: k * (n(w) * 604_800 + n(d) * 86_400 + n(h) * 3600 + n(mi) * 60 + n(sec)),
176
+ };
177
+ }
178
+ let rest = s;
179
+ let years = 0, months = 0, seconds = 0;
180
+ for (;;) {
181
+ const m = PAIR_RE.exec(rest);
182
+ if (!m)
183
+ return null;
184
+ const n = Number(m[1]);
185
+ const unit = m[2].toLowerCase();
186
+ if (Object.hasOwn(UNIT_SECONDS, unit))
187
+ seconds += n * UNIT_SECONDS[unit];
188
+ else if (UNIT_MONTHS.has(unit))
189
+ months += n;
190
+ else if (UNIT_YEARS.has(unit))
191
+ years += n;
192
+ else
193
+ return null;
194
+ rest = rest.slice(m[0].length);
195
+ if (rest === "")
196
+ break;
197
+ // the separator: spaces, one comma among them, or none at all (`1h30m`)
198
+ rest = rest.replace(/^\s*,?\s*/, "");
199
+ if (rest === "")
200
+ return null;
201
+ }
202
+ return { years, months, seconds };
203
+ }
204
+ /** A duration's length in seconds under the conventions: a year is 365 days, a month 30. */
205
+ export function durationSeconds(d) {
206
+ return d.years * YEAR_SECONDS + d.months * MONTH_SECONDS + d.seconds;
207
+ }
208
+ /** `dur(x)`: a number is that many seconds; a string is parsed; else null. */
209
+ export function durOf(x) {
210
+ if (typeof x === "number")
211
+ return Number.isFinite(x) ? x : null;
212
+ const d = parseDuration(x);
213
+ return d === null ? null : durationSeconds(d);
214
+ }
215
+ // ---- arithmetic -------------------------------------------------------------------
216
+ /** Shift a date by a whole number of calendar `months` (the month field moves
217
+ * and the day is clamped to the new month's length) and then by `seconds`. A
218
+ * date-only value stays date-only when the seconds are a whole number of days;
219
+ * otherwise it becomes a datetime in its offset (UTC for a date-only receiver). */
220
+ export function shiftDate(d, months, seconds) {
221
+ let ms = d.ms;
222
+ if (months !== 0) {
223
+ const f = fieldsOf(d);
224
+ const total = f.year * 12 + (f.month - 1) + months;
225
+ const year = Math.floor(total / 12), month = ((total % 12) + 12) % 12 + 1;
226
+ const day = Math.min(f.day, daysInMonth(year, month));
227
+ ms = Date.UTC(year, month - 1, day, f.hour, f.minute, f.second, f.millis) - d.offset * 60_000;
228
+ }
229
+ const deltaMs = Math.round(seconds * 1000);
230
+ ms += deltaMs;
231
+ const dateOnly = d.dateOnly && deltaMs % DAY_MS === 0;
232
+ return { ms, offset: d.offset, dateOnly, fraction: d.fraction };
233
+ }
234
+ /** The argument of `plus`/`minus` as a duration: a number of seconds (never
235
+ * calendar) or a duration string (months/years calendar-aware). Null otherwise. */
236
+ function durationArg(x) {
237
+ if (typeof x === "number")
238
+ return Number.isFinite(x) ? { years: 0, months: 0, seconds: x } : null;
239
+ return parseDuration(x);
240
+ }
241
+ /** A duration as `plus` applies it: the WHOLE years and months are calendar
242
+ * months; a fractional year or month count is not calendar — its fraction is
243
+ * 365-day / 30-day seconds — and joins the fixed-length seconds. */
244
+ export function calendarParts(dur) {
245
+ const wy = Math.trunc(dur.years), wm = Math.trunc(dur.months);
246
+ return {
247
+ months: wy * 12 + wm,
248
+ seconds: dur.seconds + (dur.years - wy) * YEAR_SECONDS + (dur.months - wm) * MONTH_SECONDS,
249
+ };
250
+ }
251
+ /** `d.plus(x)` (`sign` 1) / `d.minus(x)` (`sign` -1). */
252
+ export function plusMinus(recv, arg, sign) {
253
+ const d = parseDate(recv);
254
+ const dur = durationArg(arg);
255
+ if (d === null || dur === null)
256
+ return null;
257
+ const { months, seconds } = calendarParts(dur);
258
+ return renderDate(shiftDate(d, sign * months, sign * seconds));
259
+ }
260
+ /** `a.until(b)`: seconds from `a` to `b` (negative when `b` is earlier), by instant. */
261
+ export function until(a, b) {
262
+ const da = parseDate(a), db = parseDate(b);
263
+ if (da === null || db === null)
264
+ return null;
265
+ return (db.ms - da.ms) / 1000;
266
+ }
267
+ // ---- format -------------------------------------------------------------------------
268
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
269
+ const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
270
+ /** The `format` tokens, longest first (a scan takes the longest token at each position). */
271
+ const FORMAT_TOKENS = ["yyyy", "MMM", "EEE", "MM", "dd", "HH", "mm", "ss"];
272
+ /** `d.format(pattern)`: `yyyy` year, `MM` month, `dd` day, `HH` hour, `mm`
273
+ * minute, `ss` second (two digits each), `MMM` the English month abbreviation
274
+ * (`Jan`), `EEE` the English weekday abbreviation (`Mon`); every other
275
+ * character is literal. Fields are read in the written offset; a date-only
276
+ * value has `00` time fields. */
277
+ export function formatDate(recv, pattern) {
278
+ const d = parseDate(recv);
279
+ if (d === null || typeof pattern !== "string")
280
+ return null;
281
+ const f = fieldsOf(d);
282
+ let out = "";
283
+ let i = 0;
284
+ while (i < pattern.length) {
285
+ const tok = FORMAT_TOKENS.find((t) => pattern.startsWith(t, i));
286
+ if (tok === undefined) {
287
+ out += pattern[i];
288
+ i++;
289
+ continue;
290
+ }
291
+ switch (tok) {
292
+ case "yyyy":
293
+ out += pad(f.year, 4);
294
+ break;
295
+ case "MMM":
296
+ out += MONTHS[f.month - 1];
297
+ break;
298
+ case "EEE":
299
+ out += WEEKDAYS[f.weekday - 1];
300
+ break;
301
+ case "MM":
302
+ out += pad(f.month, 2);
303
+ break;
304
+ case "dd":
305
+ out += pad(f.day, 2);
306
+ break;
307
+ case "HH":
308
+ out += pad(f.hour, 2);
309
+ break;
310
+ case "mm":
311
+ out += pad(f.minute, 2);
312
+ break;
313
+ case "ss":
314
+ out += pad(f.second, 2);
315
+ break;
316
+ }
317
+ i += tok.length;
318
+ }
319
+ return out;
320
+ }
321
+ // ---- duration rendering -----------------------------------------------------------------
322
+ function splitSeconds(n) {
323
+ const neg = n < 0;
324
+ const a = Math.round(Math.abs(n) * 1000) / 1000;
325
+ const d = Math.floor(a / 86_400);
326
+ const h = Math.floor((a % 86_400) / 3600);
327
+ const m = Math.floor((a % 3600) / 60);
328
+ const s = Math.round((a % 60) * 1000) / 1000;
329
+ return { neg, d, h, m, s };
330
+ }
331
+ /** `n.dur_text()`: `"2d 3h"`, `"1h 30m"`, `"45s"` — days, hours, minutes,
332
+ * seconds, zero parts omitted, `"0s"` for zero, a leading `-` for a negative
333
+ * duration, a fractional second kept (`"1.5s"`). Null for a non-number. */
334
+ export function durText(n) {
335
+ if (typeof n !== "number" || !Number.isFinite(n))
336
+ return null;
337
+ const { neg, d, h, m, s } = splitSeconds(n);
338
+ const parts = [];
339
+ if (d)
340
+ parts.push(`${d}d`);
341
+ if (h)
342
+ parts.push(`${h}h`);
343
+ if (m)
344
+ parts.push(`${m}m`);
345
+ if (s)
346
+ parts.push(`${s}s`);
347
+ if (parts.length === 0)
348
+ return "0s";
349
+ return (neg ? "-" : "") + parts.join(" ");
350
+ }
351
+ /** `n.dur_iso()`: the ISO-8601 duration `P[nD][T[nH][nM][nS]]` — `PT1H30M`,
352
+ * `P2DT3H`, `PT45S`, `PT0S` for zero, a leading `-` for a negative duration.
353
+ * Null for a non-number. */
354
+ export function durIso(n) {
355
+ if (typeof n !== "number" || !Number.isFinite(n))
356
+ return null;
357
+ const { neg, d, h, m, s } = splitSeconds(n);
358
+ let out = "P";
359
+ if (d)
360
+ out += `${d}D`;
361
+ if (h || m || s) {
362
+ out += "T";
363
+ if (h)
364
+ out += `${h}H`;
365
+ if (m)
366
+ out += `${m}M`;
367
+ if (s)
368
+ out += `${s}S`;
369
+ }
370
+ if (out === "P")
371
+ out = "PT0S";
372
+ return (neg ? "-" : "") + out;
373
+ }
374
+ // ---- the OQX arms ---------------------------------------------------------------------
375
+ /** The free functions and their arity. */
376
+ export const DATE_FUNCTIONS = { date: 1, dur: 1 };
377
+ /** The methods and their arity: on a date string, and (`dur_text`/`dur_iso`) on a number. */
378
+ export const DATE_METHODS = {
379
+ plus: 1, minus: 1, until: 1, format: 1,
380
+ year: 0, month: 0, day: 0, weekday: 0, hour: 0, minute: 0, second: 0, date: 0,
381
+ dur_text: 0, dur_iso: 0,
382
+ };
383
+ function arity(spelling, want, got) {
384
+ if (got !== want)
385
+ throw new FilterInvalid(`${spelling} takes exactly ${want} argument${want === 1 ? "" : "s"}, got ${got}`, "OQX");
386
+ }
387
+ /** `callFunction` arm: `date(x)` / `dur(x)`; `{ handled: false }` for any other name. */
388
+ export function dateFunction(name, args, nowMs = Date.now()) {
389
+ if (!Object.hasOwn(DATE_FUNCTIONS, name))
390
+ return { handled: false };
391
+ arity(`${name}()`, DATE_FUNCTIONS[name], args.length);
392
+ return { handled: true, value: name === "date" ? dateOf(args[0], nowMs) : durOf(args[0]) };
393
+ }
394
+ /** `callMethod` arm: the date methods on a string receiver and the duration
395
+ * renderers on a number; a receiver of any other kind yields absent (the
396
+ * methods are total). `{ handled: false }` for any other name. */
397
+ export function dateMethod(name, recv, args) {
398
+ if (!Object.hasOwn(DATE_METHODS, name))
399
+ return { handled: false };
400
+ arity(`.${name}()`, DATE_METHODS[name], args.length);
401
+ const field = (k) => {
402
+ const d = parseDate(recv);
403
+ return d === null ? null : fieldsOf(d)[k];
404
+ };
405
+ switch (name) {
406
+ case "plus": return { handled: true, value: plusMinus(recv, args[0], 1) };
407
+ case "minus": return { handled: true, value: plusMinus(recv, args[0], -1) };
408
+ case "until": return { handled: true, value: until(recv, args[0]) };
409
+ case "format": return { handled: true, value: formatDate(recv, args[0]) };
410
+ case "year":
411
+ case "month":
412
+ case "day":
413
+ case "weekday":
414
+ case "hour":
415
+ case "minute":
416
+ case "second":
417
+ return { handled: true, value: field(name) };
418
+ case "date": {
419
+ const d = parseDate(recv);
420
+ return { handled: true, value: d === null ? null : renderDate({ ...d, dateOnly: true }) };
421
+ }
422
+ case "dur_text": return { handled: true, value: durText(recv) };
423
+ case "dur_iso": return { handled: true, value: durIso(recv) };
424
+ default: return { handled: false };
425
+ }
426
+ }
427
+ //# sourceMappingURL=dates.js.map