jsonq

July 10, 2026 · View on GitHub

Status: RATIFIED 2026-07-10. The loop treats this as frozen ground truth. Pairs with MANIFESTO.md: manifesto = public intent; this = engineering record. Do not re-litigate locked rows without explicit human amendment to this file.

1. What this project is

jsonq is a small, readable, fully type-safe query engine for in-memory JSON arrays, written in pure TypeScript with zero runtime dependencies. You build a query fluently, the compiler verifies it (keys, operators, value types, result shapes), and execute() runs it in one pass over the pipeline — without ever mutating the source data.

The primary value is the TYPE LAYER: an invalid query must not compile. The runtime is deliberately simple; the engineering interest is API design, generics, and type inference.

This project is also the first dogfood build of the loop-engineering template: it is built fully autonomously by a test-gated agent loop. Frictions found in the template are recorded in loop/TEMPLATE-FEEDBACK.md (by the human observer, not the loop).

2. The wedge

We compete with ad-hoc data.filter(x => ...).sort(...) spaghetti and untyped query helpers — not with lodash, not with a database.

The empty space: a query over a typed array should be as verifiable as the array's type itself. Existing helpers either give up type safety (string paths, any values) or give up the fluent query shape (raw array methods).

3. Locked decisions

DecisionLock
Product scopeLibrary: in-memory JSON array query engine. No IO, no persistence, no server.
Primary languageTypeScript, strict mode. Zero runtime dependencies.
DistributionBuildable to dist/ (tsc). npm publish is a HUMAN decision after M1, not a loop task.
ArchitectureImmutable op-list core: a Query<T> = source ref + frozen list of pending ops; every fluent call returns a NEW Query; execute() runs the pipeline once, in call order.
API styleFluent, synchronous, immutable. String-key operator API with full type constraints, plus a typed predicate overload as the escape hatch.
Type-safety barFULL: invalid key, operator/value-type mismatch, or wrong aggregate key must be a COMPILE ERROR, locked by compile-time tests.
Reliability barEvery line tested: 100% line + function coverage; type-level tests (positive and @ts-expect-error negative) run in the typecheck gate.
Test runnerbun test (with coverage threshold in bunfig.toml); typecheck = tsc --noEmit.
Size budget~400-600 source lines (excluding tests). Comprehension time is the real metric; no code-golf.
BeachheadA learning/dogfood project first; production-quality discipline anyway.

4. Principles

  1. Invalid queries do not compile. The type layer is the product; the compiler is the first test suite.
  2. Plans are visible. explain() shows the op pipeline; ops apply in call order, no hidden reordering, no magic.
  3. The engine never mutates. Source array and rows are never written to; every fluent call returns a new Query; execute() returns a new array.
  4. Conscious omission. Strength comes from the refusals list (section 8). Small enough to read in one sitting.
  5. Errors are not hidden. The few runtime errors that can exist throw loudly with clear messages; nothing returns silent garbage.

5. Architecture

src/types.ts       type machinery: OperatorFor<V>, KeysOfType<T,V>, aggregate
                   spec inference. Compile-time only (erased at runtime).
src/ops.ts         GUARDED CORE: compare(), deepEqual(), predicate evaluation,
                   aggregate computations. Wrong answers are born here, so this
                   file gets the strictest tests and decision records.
src/query.ts       fluent builder: Query<T> op-list, GroupedQuery, pipeline
                   executor, explain().
src/index.ts       public surface: query(), agg, and public types.
src/type-tests.ts  compile-time assertions (positive cases + @ts-expect-error
                   negatives). Checked by `tsc --noEmit`; contains no runtime
                   test and never ships.

Trust boundaries

BoundaryModulesGate
Guarded coresrc/ops.ts100% coverage, every semantic in section 7 pinned by a test, decisions recorded in PROGRESS
Open edgessrc/query.ts, src/index.ts, src/types.tsstandard gate (still 100% coverage; types locked by type-tests)

Honesty discipline: ops.ts stays small because comparison/equality/aggregation is genuinely minimal — never by sweeping complexity into query.ts.

6. Public API (the contract)

function query<T extends object>(source: readonly T[]): Query<T>;

interface Query<T> {
  // filtering
  where<K extends keyof T & string>(key: K, op: OperatorFor<T[K]>, value: ...): Query<T>;
  //   value type: for "in" -> readonly T[K][]; for every other op -> T[K]
  where(predicate: (row: T) => boolean): Query<T>;   // typed escape hatch

  // ordering / slicing
  sort(key: SortableKey<T>, direction?: "asc" | "desc"): Query<T>;
  limit(n: number): Query<T>;

  // shaping
  select<K extends keyof T & string>(...keys: K[]): Query<Pick<T, K>>;
  groupBy<K extends keyof T & string>(key: K): GroupedQuery<T, K>;

  // aggregates (ungrouped)
  count(): number;
  sum(key: KeysOfType<T, number>): number;
  avg(key: KeysOfType<T, number>): number;
  min(key: KeysOfType<T, number>): number;
  max(key: KeysOfType<T, number>): number;

  // terminals
  execute(): T[];
  explain(): readonly OpDescription[];
}

interface GroupedQuery<T, K extends keyof T> {
  execute(): Map<T[K], T[]>;
  aggregate<S extends AggSpec<T>>(spec: S): Array<{ key: T[K] } & AggResult<S>>;
}

// aggregate spec constructors (the `agg` namespace)
agg.count(): AggCount;
agg.sum<T>(key: KeysOfType<T, number>): ...;   // likewise avg / min / max

Operator set (v1, complete)

OperatorAllowed on T[K]Semantics
==, !=any JSON valueDEEP STRUCTURAL, TYPE-SENSITIVE equality: 1 != "1"; objects compared by structure (key order irrelevant); arrays element-wise and order-sensitive
<, <=, >, >=number | string onlyJS relational semantics; NaN comparisons are false
inany JSON valuevalue is readonly T[K][]; membership via the same deep equality

OperatorFor<V> enforces the "allowed on" column at compile time: where("active", ">", true) and where("name", ">", 5) MUST NOT compile.

7. Pinned runtime semantics (each row is a required test)

BehaviorPin
Pipeline orderOps apply in CALL order. limit(10).where(...) truncates first, filters second. explain() reflects exactly this order.
ImmutabilitySource array and its rows are never mutated. execute() returns a NEW array whose elements are the ORIGINAL row references (no deep copy — documented). Each fluent call returns a NEW Query; the receiver is unchanged (branching two queries off one shared prefix must work).
SortStable. Repeated .sort() calls compose as tie-breakers: FIRST call is the primary key (SQL ORDER BY a, b). Only number | string keys are sortable (SortableKey<T>). null/undefined values sort LAST regardless of direction.
selectProjects each row to a new object with exactly the named keys; result element type is Pick<T, K>. Selecting the same key twice is harmless. A later where on a selected-away key must not compile.
groupByGroups by SameValueZero on the raw key value (native Map key semantics). Key values that are objects therefore group by reference — a documented limitation, not deep equality. execute() returns Map<T[K], T[]> in first-seen group order; rows keep pipeline order within a group.
aggregateaggregate(spec) result rows appear in first-seen group order; each row is { key } & { [name]: number } with names and result types inferred from the spec.
Empty-set aggregatescount() -> 0, sum() -> 0. avg/min/max over an EMPTY set THROW RangeError whose message names the aggregate and the key. (Grouped aggregates never see an empty group by construction, so this bites only ungrouped.)
limitlimit(n) keeps the first n rows at its pipeline position. Non-integer or negative n throws TypeError at CALL time (fail fast, not at execute). limit(0) is valid and yields an empty result.
explainReturns plain, serializable op descriptions (discriminated by kind), one per fluent call, in call order. No hidden ops.
Runtime errorsONLY the two above (limit TypeError, empty-set RangeError). Everything else that could be wrong is a compile error instead.

8. Deliberately rejected (v1 — the loop may not reopen these)

  • Dot-path / nested field access (where("address.city", ...)) — template-literal-type machinery would blow the size and comprehension budget. Top-level keys only.
  • contains / regex / fuzzy operators — the operator set in section 6 is closed.
  • offset / paginationlimit only.
  • Date support — JSON has no Date; rows are JSON values.
  • Lazy / streaming / iterator evaluationexecute() materializes once; the op-list is the only laziness.
  • Deep-freezing results — immutability is a discipline of the engine, not a runtime cost.
  • Indexes / query optimization / reordering — the pipeline is literal; O(n) per op is documented, not fixed.
  • Async API — in-memory arrays are synchronous.
  • Multi-key sort varargs (sort("a", "b")) — chained .sort() tie-breakers cover it.
  • npm publish — human decision after M1.

9. Testing strategy

  • TDD per loop discipline: failing test first, then implementation.
  • Runtime tests (bun test): every pinned semantic in section 7, every operator row in section 6, immutability proofs (source and rows unchanged after execute), branching-query proof, stable-sort proof, empty/edge cases. Coverage threshold 100% line + function (bunfig.toml).
  • Type-level tests (src/type-tests.ts, checked by tsc --noEmit in the gate):
    • positive: inferred result types (select -> Pick, aggregate -> named numbers, groupBy().execute() -> Map<T[K], T[]>) asserted with an Expect<Equal<...>> helper;
    • negative: every "MUST NOT compile" example in sections 6-7 locked with @ts-expect-error.
  • Subjective bar: "readable in one sitting" judged at milestone close by the fresh-context judge agent (.claude/agents/judge.md); verdict recorded in loop/PROGRESS.md.

10. Open questions

  • npm publish + package name — RESOLVED 2026-07-10 by the maintainer: the package is @open-spek/jsonq (the unscoped jsonq is taken on npm; the scope keeps the project name intact and ties it to the open-spek organization, github.com/open-spek/jsonq). publishConfig.access: public is set; publish timing remains the maintainer's call.

11. References

  • LibreDB (~/projects/libredb/libredb-database) — the reference loop-engineered build; its deep-equality and honest-omission decisions are deliberately echoed here.
  • Loop-engineering template (~/projects/open-spek/loop, github.com/open-spek/loop) — the machinery this project dogfoods.