diff --git a/.agents/skills/formal-field-access/SKILL.md b/.agents/skills/formal-field-access/SKILL.md new file mode 100644 index 0000000..b59591b --- /dev/null +++ b/.agents/skills/formal-field-access/SKILL.md @@ -0,0 +1,110 @@ +--- +name: formal-field-access +description: Bounded exhaustive check of field exposure and write gating in @antelopejs/interface-data-api (AccessMode, @Access overrides, @Listable, @Computed, @Joined, derived controllers), run against the real decorators, ReadProperties and WriteProperties. Use when a change touches src/metadata.ts, src/components.ts, src/index.ts, src/internal/read-properties.ts, src/internal/properties.ts, src/internal/meta-records.ts or src/internal/prototypes.ts, or when a change to access modes, descriptors, pluck or metadata inheritance needs proof that no field leaks and no body key bypasses its access. +--- + +# Formal check: field exposure and write gating + +A module author declares a controller class over a table and marks each field with `@Access(mode, overrides?)`, `@Listable`, `@Computed` or `@Joined`. Per action (`get`, `list`, `new`, `edit`, or a custom one), a field's effective mode decides which fields a response holds and which body keys reach the database: `ReadProperties` builds the response from `meta.readable[action]`, `WriteProperties` builds the row from `meta.writable[action]`, both rebuilt by `DataAPIMeta.recomputeAccess`. The `exposure` door of `verify.config.ts` names the harm: a write-only field that leaks exposes a secret. + +No verifier runs on this TypeScript, so the check enumerates every controller declaration within the bounds below and runs the real code on each: `cases.ts` lists the declarations, `build.ts` turns each into real classes (`class extends DataController(Row, {}, Controller(...))`, the accessors, then each decorator in the order TypeScript applies them, bottom first, with the descriptor `__decorate` would pass), and `rules.ts` runs `Query.ReadProperties` and `Query.WriteProperties` for every action and compares them with an oracle built from the decorators' TSDoc. `field-access.ts` holds the record of findings. `ts-loader.mjs` lets Node run the `src` TypeScript as it is. + +## Run it + +From the repository root, with Node 24 and `pnpm install` done: + +```sh +node .agents/skills/formal-field-access/check.mjs # CI bounds +node .agents/skills/formal-field-access/check.mjs --full # full bounds +node .agents/skills/formal-field-access/check.mjs --show # also print every violating case with what the check saw +``` + +| Configuration | Classes and fields | CI cases | Full cases | +| --- | --- | --- | --- | +| `single` | one controller `C`, field `a` in each of 5 declarations | 3,413 | 32,384 | +| `pair` | `C` with fields `a` and `b` (`b` defaulted or a setter) | 1,820 | 16,562 | +| `derived` | `P`, then `A extends P`, then `B extends P`, field `a` | 600 (1,800 classes) | 5,460 (16,380 classes) | + +One run of each on a shared 16-core machine: CI 6.8 s, full 34 s. Every class is read for 7 actions with and without `onlyList`, and written for 7 actions, 2 or 3 bodies, as a new row and over a stored row. + +- Exit 0 means every rule that held still holds, and each finding still breaks on the recorded number of cases with the recorded shortest case. +- Exit 1 means a rule changed. `BROKEN` names a rule that held and now breaks. `CHANGED` names a finding whose case count or shortest case changed. `HOLDS` names a finding that no longer breaks. Each line carries the shortest case as source and, below it, what the check saw. +- Exit 2 means the check could not run, or the arguments were wrong. + +The lines that name a changed rule match `^(?:finding|BROKEN|CHANGED|HOLDS) +(\w+)`. A rule that still holds prints as lowercase `holds A, B (N cases, configuration)`. + +## When to run it + +Run `--full` when a change touches a covered file (see the coverage record) or this folder. A changed rule (exit 1) makes the Verdict FAIL. The recorded findings fail it only when the diff changes what the covered files do, which `antelopejs-verify formal` decides by comparing their JavaScript on the base and on HEAD (`verify-interface` verdict rule 5; the entry is `formal-field-access` in `.agents/skills/verify-interface/verify.config.ts`, which runs `--full` on HEAD and the CI bounds on the base). + +## The rules + +The oracle reads the decorators as their TSDoc states them. `@Access` sets the mode and replaces the override table. `@Computed` and `@Joined` make the field read-only and keep the override table, which no doc mentions. `@Listable` adds the field to a list mode with the database names it selects, plus itself and its local key once joined (`listedNames`). `@Access` and `@Listable` hand the framework the accessor's descriptor (`setDescriptor`, "the known field descriptor"); `@Computed` and `@Joined` are property decorators and see none, so an accessor they alone decorate is read and written as a plain property. A child's declarations come after its parent's. The effective mode of an action is its override, else the mode; `_default` and no action mean the mode. + +| Rule | Plain sentence | Harm when it breaks | `single` | `pair` | `derived` | +| --- | --- | --- | --- | --- | --- | +| `ReadNoLeak` | `ReadProperties` answers no field, and runs no getter, without read access for the action; with `onlyList`, none that no `@Listable` names. | A write-only secret, or a private field, is in a response. | holds | holds | finding F2 (244, 2,840) | +| `ReadComplete` | Every field with read access is answered with the row value, or with the known getter's value. | A client misses a field, or gets another class's getter. | finding F3 (24, 32) | holds | findings F2, F3 (202, 2,146) | +| `WriteGated` | `WriteProperties` stores nothing but the stored row (or the class defaults of decorated fields) plus the body keys with write access, only those present on edit, and runs no other setter. | A body sets a read-only or private column. | holds | holds | finding F2 (100, 1,240) | +| `WriteComplete` | Every writable body key is stored (`undefined` when absent on new), or the known setter runs with it. | A write is silently dropped, or another class's setter runs. | holds | holds | finding F2 (184, 1,958) | +| `ComputedReadOnly` | A `@Computed` or `@Joined` field is never stored from the body and its setter never runs, whatever `@Access` says ("The value is read-only"). | A column the table never declares is persisted and hidden by the query-time value. | finding F1 (492, 4,338) | finding F1 (200, 3,440) | finding F1 (60, 1,164) | +| `ListPluck` | `meta.pluck[mode]` is exactly the union of the database names the fields listable in that mode select. | A list fetches, or drops, a column no declaration names. | holds | holds | finding F2 (16, 288) | + +The numbers in brackets are the violating cases under the CI and the full bounds, which `check.mjs` records per configuration with the shortest case. + +## Findings at the sync commit + +- **F1, a computed or joined field becomes writable (`ComputedReadOnly`).** `setComputed` (metadata.ts:312) and `setJoined` (line 357) set `field.mode = ReadOnly` and nothing else. Two sequences defeat it. `@Access(AccessMode.ReadWrite) @Computed(expr) declare a`: decorators run bottom-up, so `setMode` runs last and `a` is read-write; docs/11.computed.md and docs/10.joined.md warn "do not combine the two". `@Computed(expr) @Access(AccessMode.ReadOnly, { new: AccessMode.ReadWrite }) declare a`: `setComputed` runs last but leaves `modeOverrides`, so `a` is writable on `new`; no doc mentions it (62 of the 492 `single` CI cases, per the `fix-derived-clears-overrides` row below). In both, `WriteProperties("new")` and `("edit")` store `body.a` under `a`, and a setter on `a` runs. The stored value is hidden at read time by the computed or joined value. +- **F2, a derived controller rewrites the fields of its base and its siblings (`ReadNoLeak`, `ReadComplete`, `WriteGated`, `WriteComplete`, `ListPluck`).** `inherit` copies the parent's `FieldData` objects by reference (`merge(parent.fields, this.fields)`, metadata.ts:152, meta-records.ts:6-15), and `field(name)` (line 170-175) returns that shared object, so a child's decorator mutates the parent's field. `P { @Access(AccessMode.WriteOnly) declare a } A extends P { @Access(AccessMode.ReadWrite) declare a } B extends P {}`: `B`, defined after `A` with no declaration of its own, inherits `a` as read-write and answers it on every action, though `P` and `B` declare it write-only. `P { @Access(AccessMode.ReadOnly) get a() } A extends P { @Access(AccessMode.ReadOnly) get a() }`: `P.ReadProperties` answers `get:A:a`, the derived class's getter run on the base instance, because `setDescriptor` replaced the shared `desc`. `B`'s `@Listable` rebuilds its pluck from fields `A` changed. The `fix-inherit-copies-fields` row below turns four rules back to holding. +- **F3, a getter that `@Listable` hands over after `@Computed` or `@Joined` never runs (`ReadComplete`).** `setListable` and `setDescriptor` do not call `recomputeAccess`, so `@Listable() @Computed(expr) get a()` keeps `a` in `readable.props`, built when `setComputed` ran without a descriptor, and answers the row value; `@Computed(expr) @Listable() get a()` runs the getter. The `fix-listable-recomputes-access` row confirms the cause. + +## Real bug or check error + +The check runs the published code: `Query.ReadProperties` and `Query.WriteProperties` from `src/components.ts`, the decorators from `src/metadata.ts`, `DataController` from `src/index.ts`, with `GetDataControllerMeta` reading the metadata as the routes do. The routes answer what `ReadProperties` returns, minus `_internal` and with foreign relations reshaped (`Validation.ClearInternal`), plus `_id` on `new`. A violation is a check error only when the oracle in `promiseOf` (rules.ts) misreads a decorator's TSDoc, or when the case builds a class TypeScript would refuse: a child redeclaring an accessor as a property, or the reverse. Replay a shortest case by writing its class in a scratch test under `src/tests` and asserting on the route's answer or on the row `WriteProperties` returns. A getter answers `get::`, so the class whose accessor ran is in the value. Run `--show` to list every violating case with what the check saw. + +## Changing the check + +- For an intended code change, edit the oracle step in `promiseOf` that maps to that decorator, then run both bounds. A fix that clears the override table in `setComputed` and `setJoined` also changes the `Computed` and `Joined` steps, as the `fix-derived-clears-overrides` row shows. +- Never weaken or drop a rule to make the check pass. A fixed finding leaves `FINDINGS` in `field-access.ts`, for both modes, once a replay confirms the fix. A new finding goes into `FINDINGS` with its case count and shortest case for each mode, and into this file with its sequence. +- When you change the alphabet or the depths in `cases.ts`, rerun the scratch changes below. The CI bounds must still catch each one. +- After you confirm that the oracle and the coverage record describe the code at a newer commit, move the sync commit forward. Do it only then. + +## Coverage record + +| Check part | Code | +| --- | --- | +| alphabet and oracle of `@Access` | `Access` (metadata.ts:525-537), `setDescriptor` (433-436), `setMode` (231-241), `GetEffectiveMode` (93-99), `AccessMode` (78-82) | +| oracle of `@Listable` and `ListPluck` | `Listable` (550-562), `setListable` (250-263), `recomputeListable` (177-179), `listedNames` and `rebuildPluck` (meta-records.ts:80-94, 26-45) | +| oracle of `@Computed` and `@Joined` | `Computed` (643-647), `setComputed` (305-318), `Joined` (613-627), `setJoined` (330-375), `resolveTableReference` for a table name in the same schema (table-reference.ts:56-72) | +| the known descriptor, `readable` and `writable` | `onField` (507-518), `recomputeAccess` (181-223), `field` (170-175) | +| `derived` configuration | `inherit` (150-168), `merge` (meta-records.ts:6-15), `DataController` (index.ts:54-91), `GetDataControllerMeta` (metadata.ts:735-739), `classOf` and `prototypeOf` (prototypes.ts:26-41) | +| reads | `Query.ReadProperties` (components.ts:347-355), `ReadProperties` (read-properties.ts:11-39), `fieldsOf` (prototypes.ts:14-19); read from the code, not driven: the routes pass `get` (index.ts:180-185) and `list` (251) and never `onlyList` | +| writes | `Query.WriteProperties` (components.ts:367-381), `WriteProperties`, `writableInBody`, `writeTargetDefaults` (properties.ts:12-66); read from the code, not driven: the routes pass `new` (index.ts:279) and `edit` with the stored row (309-315) | + +The sync commit is `a91c1c0` on AntelopeJS/interface-data-api `feat/verify-interface-skill` (PR #91). + +## Bounds and what is left out + +The full alphabet is `@Access` with each mode and each of 8 override tables (none, `get`, `new`, `edit`, `custom`, `_default`, two actions), `@Computed`, `@Joined` on the other field or on the undeclared key `k`, and `@Listable` with `true`, `false`, `[other]` and `(true, "detailed")`: 30 decorators. The core alphabet has 9: one of each, two override tables. `single` CI runs every sequence of up to 2 of the 30 on `declare a` and on `get a() / set a(v)`, up to 1 on the defaulted property, the getter and the setter, and every sequence of 3 of the core 9 on the first two. `pair` CI runs up to 2 of the core 9 on `a` and up to 1 on `b`. `derived` CI runs up to 1 on `P`, up to 1 on `A`, and none, `@Access(ReadOnly)` or `@Listable()` on `B`. Full raises `single` to 3 of the 30 on `declare a`, 3 of the core 9 and 2 of the 30 on the accessor, 2 of the 30 on the others, and `b` and `A` to 2. Bodies send every field plus `k`, nothing, and (`pair`) `a` alone; rows hold every field plus `k`. + +The check leaves out: + +- the routes and the database. From `src/index.ts` the check runs only `DataController`, and from `src/components.ts` only `Query.ReadProperties` and `Query.WriteProperties`. Both files stay in `covers` so that a diff to them reruns the check, but the rest of them is not driven here: which action each route passes, `Validation.Lock` and `Unlock`, the get guard, and `ListPage`, which applies `pluck` (so a database field a getter's `@Listable([...])` names is answered when it has read access). The `verify-interface` features `access`, `modifiers`, `guards` and `listing` drive those through HTTP. Foreign, joined and computed values, the rest of `Validation`, modifiers, and `dbName`, which nothing in `src` sets, are left out too. +- `@Mandatory`, `@Sortable`, `@Validator`, `@Foreign` and `@Filter`, which create the field and record its descriptor without changing access. +- a class default on a property no decorator names: `writeTargetDefaults` stores defaults only for keys in `meta.fields`, and the oracle follows (`decorated`). +- getters and setters that read other fields (the `verify-interface` journey `listing/pluck-modes` drives one), actions named like `Object.prototype` keys, three levels of inheritance, and more than two siblings. + +## Confirm the check can fail + +Apply one change at a time to `src`, run `check.mjs` with the CI bounds, then `git checkout -- src`. These ran at the sync commit; every row exited 1 and the unchanged tree exits 0. + +| Scratch change in the code | CI bounds | +| --- | --- | +| `drop-onlylist-skip`: `ReadProperties` ignores `onlyList` for props (read-properties.ts:23-25) | `BROKEN ReadNoLeak` 977 (`single`), 1,476 (`pair`); `CHANGED ReadNoLeak` 408 (`derived`) | +| `read-default-action`: `ReadProperties` reads `meta.readable._default` whatever the action (line 18-19) | `BROKEN ReadNoLeak` 316, shortest `@Access(ReadOnly, { get: WriteOnly }) get a()` answered on `get`; `CHANGED ReadComplete` 389 | +| `drop-writable-in-body`: edit writes every writable field (properties.ts:47-49) | `BROKEN WriteGated` 2,477 (`single`), 1,328 (`pair`), `CHANGED` 384 (`derived`): an empty edit body writes `a = undefined` and runs setters | +| `writable-on-readonly`: `recomputeAccess` fills `writable` from the read bit (metadata.ts:218) | `BROKEN WriteGated`, `BROKEN WriteComplete`, `CHANGED ComputedReadOnly` in every configuration | +| `computed-keeps-mode`: `setComputed` no longer sets `ReadOnly` (line 312) | `BROKEN WriteGated` 116 (`single`), 60 (`pair`), `BROKEN ReadComplete` 308 (`pair`), `CHANGED` on six rules | +| Fix candidate `fix-inherit-copies-fields`: `inherit` copies each parent field and its `listable` table | `HOLDS ReadNoLeak`, `HOLDS WriteGated`, `HOLDS WriteComplete`, `HOLDS ListPluck` (`derived`); `CHANGED ReadComplete` 28, all F3 | +| Fix candidate `fix-derived-clears-overrides`: `setComputed` clears `modeOverrides` | `CHANGED ComputedReadOnly` 430, 180, 54; `BROKEN ReadNoLeak` 12 and `WriteComplete` 62 (`single`), from the oracle keeping overrides | +| Fix candidate `fix-listable-recomputes-access`: `setListable` calls `recomputeAccess` | `HOLDS ReadComplete` (`single`); `CHANGED ReadComplete` 196 (`derived`) | diff --git a/.agents/skills/formal-field-access/build.ts b/.agents/skills/formal-field-access/build.ts new file mode 100644 index 0000000..7dbd992 --- /dev/null +++ b/.agents/skills/formal-field-access/build.ts @@ -0,0 +1,144 @@ +import { Controller } from "@antelopejs/interface-api"; +import type { Class, DecoratorTarget } from "@antelopejs/interface-core/decorators"; +import { RegisterTable, Table } from "@antelopejs/interface-database-decorators"; + +import { DataController, GetDataControllerMeta } from "../../../src/index.ts"; +import { Access, Computed, type DataAPIMeta, Joined, Listable } from "../../../src/metadata.ts"; +import { type Case, type ClassDecl, classNames, type Declaration, type Op } from "./cases.ts"; + +const SCHEMA = "formal-field-access"; +const JOINED_TABLE = "formal-field-access-other"; + +class Row extends Table {} +RegisterTable("formal-field-access-rows", SCHEMA)(Row); + +class Other extends Table {} +RegisterTable(JOINED_TABLE, SCHEMA)(Other); + +/** One accessor call the check's getters and setters record: which class's accessor ran, on which field, with what. */ +export interface AccessorCall { + owner: string; + field: string; + kind: "get" | "set"; + value?: string; +} + +/** A controller class the check built, with its metadata, the name it prints under and what it declares. */ +export interface Built { + name: string; + cls: Class; + meta: DataAPIMeta; + decl: ClassDecl; +} + +/** A getter answers `get::`; the class is the one whose prototype holds the accessor. */ +export function getterValue(owner: string, field: string): string { + return `get:${owner}:${field}`; +} + +export function usesGetter(declared: Declaration): boolean { + return declared === "getter" || declared === "accessor"; +} + +export function usesSetter(declared: Declaration): boolean { + return declared === "setter" || declared === "accessor"; +} + +/** The class defaults of a declaration: `name = "default:"` for each defaulted property. */ +export function defaultsOf(decl: ClassDecl): Map { + const defaults = new Map(); + for (const [name, fieldDecl] of Object.entries(decl)) { + if (fieldDecl.declared === "defaulted") defaults.set(name, `default:${name}`); + } + return defaults; +} + +function defineAccessors( + prototype: DecoratorTarget, + owner: string, + field: string, + declared: Declaration, + log: AccessorCall[], +) { + if (!usesGetter(declared) && !usesSetter(declared)) return; + const descriptor: PropertyDescriptor = { configurable: true, enumerable: false }; + if (usesGetter(declared)) { + descriptor.get = () => { + log.push({ owner, field, kind: "get" }); + return getterValue(owner, field); + }; + } + if (usesSetter(declared)) { + descriptor.set = (value: string | undefined) => { + log.push({ owner, field, kind: "set", value }); + }; + } + Object.defineProperty(prototype, field, descriptor); +} + +/** + * Applies one decorator as TypeScript's `__decorate` does: a property passes + * no descriptor, an accessor passes its own property descriptor. + */ +function decorate(op: Op, prototype: DecoratorTarget, field: string, descriptor: PropertyDescriptor | undefined) { + switch (op.decorator) { + case "Access": { + const decorator = Access(op.mode, op.overrides); + if (descriptor) decorator(prototype, field, descriptor); + else decorator(prototype, field); + return; + } + case "Computed": + Computed(() => 0)(prototype, field); + return; + case "Joined": + Joined({ table: JOINED_TABLE, localKey: op.localKey, remoteField: "value" })(prototype, field); + return; + case "Listable": { + const decorator = Listable(op.required, op.mode); + if (descriptor) decorator(prototype, field, descriptor); + else decorator(prototype, field); + } + } +} + +/** + * Declares one controller class as a module author would: it extends + * `DataController(Row, {}, Controller(location[, parent]))`, defines its + * accessors, then runs each field's decorators bottom-up. The metadata is read + * through `GetDataControllerMeta`, as the routes read it. + */ +function declareClass(name: string, decl: ClassDecl, log: AccessorCall[], parent?: Built): Built { + const base = parent + ? DataController(Row, {}, Controller(`/${name}`, parent.cls)) + : DataController(Row, {}, Controller(`/${name}`)); + const defaults = Object.fromEntries(defaultsOf(decl)); + const cls = class extends base { + constructor() { + super(); + Object.assign(this, defaults); + } + }; + Object.defineProperty(cls, "name", { value: name }); + for (const [field, fieldDecl] of Object.entries(decl)) { + defineAccessors(cls.prototype, name, field, fieldDecl.declared, log); + const descriptor = Object.getOwnPropertyDescriptor(cls.prototype, field); + for (const op of fieldDecl.ops) decorate(op, cls.prototype, field, descriptor); + } + return { name, cls, meta: GetDataControllerMeta(new cls()), decl }; +} + +/** The classes of a case in definition order, and the accessor log they share. */ +export interface BuiltCase { + classes: Built[]; + log: AccessorCall[]; +} + +/** Builds the classes of a case in definition order, sharing one accessor log. */ +export function build(testCase: Case): BuiltCase { + const log: AccessorCall[] = []; + const [parentName, ...childNames] = classNames(testCase); + const parent = declareClass(parentName, testCase.parent, log); + const children = testCase.children.map((decl, index) => declareClass(childNames[index], decl, log, parent)); + return { classes: [parent, ...children], log }; +} diff --git a/.agents/skills/formal-field-access/cases.ts b/.agents/skills/formal-field-access/cases.ts new file mode 100644 index 0000000..1c3ac55 --- /dev/null +++ b/.agents/skills/formal-field-access/cases.ts @@ -0,0 +1,249 @@ +import { AccessMode } from "../../../src/metadata.ts"; + +/** A decorator a controller writes on a field, with the arguments the check varies. */ +export type Op = + | { decorator: "Access"; mode: AccessMode; overrides?: Record } + | { decorator: "Computed" } + | { decorator: "Joined"; localKey: string } + | { decorator: "Listable"; required: boolean | string[]; mode?: string }; + +/** + * How a controller class declares a field: `declare a`, `a = "default:a"`, + * `get a()`, `set a(value)`, or both accessors. + */ +export type Declaration = "property" | "defaulted" | "getter" | "setter" | "accessor"; + +/** A field as one class declares it, its decorators in the order they run: the bottom one first. */ +export interface FieldDecl { + declared: Declaration; + ops: Op[]; +} + +/** The fields of one controller class, by name. */ +export type ClassDecl = Record; + +/** + * One case: a parent controller, then the derived controllers defined after + * it in order, each extending the parent. A case with no child is one + * controller on its own. + */ +export interface Case { + parent: ClassDecl; + children: ClassDecl[]; +} + +export type Configuration = "single" | "pair" | "derived"; +export type Mode = "ci" | "full"; + +export const CONFIGURATIONS: Configuration[] = ["single", "pair", "derived"]; + +/** The database key no field declares: the row holds it, a body sends it, `@Listable([...])` and `@Joined` may name it. */ +export const DB_ONLY_KEY = "k"; + +const { ReadOnly, WriteOnly, ReadWrite } = AccessMode; +const MODES = [ReadOnly, WriteOnly, ReadWrite]; +const OVERRIDES: (Record | undefined)[] = [ + undefined, + { get: ReadOnly }, + { get: WriteOnly }, + { new: ReadWrite }, + { edit: ReadWrite }, + { custom: ReadWrite }, + { _default: WriteOnly }, + { get: WriteOnly, new: ReadWrite }, +]; +const ALL_DECLARATIONS: Declaration[] = ["property", "defaulted", "getter", "setter", "accessor"]; +const MAIN_DECLARATIONS: Declaration[] = ["property", "accessor"]; + +function access(mode: AccessMode, overrides?: Record): Op { + return { decorator: "Access", mode, overrides }; +} + +/** Every decorator the check writes on a field, next to `other` (a second field or {@link DB_ONLY_KEY}). */ +function fullAlphabet(other: string): Op[] { + return [ + ...MODES.flatMap((mode) => OVERRIDES.map((overrides) => access(mode, overrides))), + { decorator: "Computed" }, + { decorator: "Joined", localKey: other }, + { decorator: "Listable", required: true }, + { decorator: "Listable", required: false }, + { decorator: "Listable", required: [other] }, + { decorator: "Listable", required: true, mode: "detailed" }, + ]; +} + +/** The alphabet of the longer sequences: each decorator once, plus the two override tables the docs show. */ +function coreAlphabet(other: string): Op[] { + return [ + access(ReadOnly), + access(WriteOnly), + access(ReadWrite), + access(ReadOnly, { new: ReadWrite }), + access(ReadWrite, { edit: ReadOnly }), + { decorator: "Computed" }, + { decorator: "Joined", localKey: other }, + { decorator: "Listable", required: true }, + { decorator: "Listable", required: [other] }, + ]; +} + +/** Every sequence of `min` to `max` symbols of `alphabet`, shortest first. */ +function sequences(alphabet: Op[], min: number, max: number): Op[][] { + let layer: Op[][] = [[]]; + const all: Op[][] = min === 0 ? [[]] : []; + for (let length = 1; length <= max; length++) { + layer = layer.flatMap((sequence) => alphabet.map((op) => [...sequence, op])); + if (length >= min) all.push(...layer); + } + return all; +} + +function field(declared: Declaration, ops: Op[]): FieldDecl { + return { declared, ops }; +} + +function singleCases(mode: Mode): Case[] { + const alphabet = fullAlphabet(DB_ONLY_KEY); + const cases: Case[] = []; + const add = (declarations: Declaration[], ops: Op[][]) => { + for (const declared of declarations) { + for (const sequence of ops) cases.push({ parent: { a: field(declared, sequence) }, children: [] }); + } + }; + const others = ALL_DECLARATIONS.filter((declared) => !MAIN_DECLARATIONS.includes(declared)); + if (mode === "ci") { + add(MAIN_DECLARATIONS, sequences(alphabet, 0, 2)); + add(others, sequences(alphabet, 0, 1)); + add(MAIN_DECLARATIONS, sequences(coreAlphabet(DB_ONLY_KEY), 3, 3)); + } else { + add(["property"], sequences(alphabet, 0, 3)); + add(["accessor"], sequences(coreAlphabet(DB_ONLY_KEY), 3, 3)); + add([...others, "accessor"], sequences(alphabet, 0, 2)); + } + return cases; +} + +function pairCases(mode: Mode): Case[] { + const first = sequences(coreAlphabet("b"), 0, 2); + const second = sequences(coreAlphabet("a"), 0, mode === "ci" ? 1 : 2); + const cases: Case[] = []; + for (const declaredA of ["property"] as const) { + for (const declaredB of ["defaulted", "setter"] as const) { + for (const opsA of first) { + for (const opsB of second) { + cases.push({ parent: { a: field(declaredA, opsA), b: field(declaredB, opsB) }, children: [] }); + } + } + } + } + return cases; +} + +function derivedCases(mode: Mode): Case[] { + const alphabet = coreAlphabet(DB_ONLY_KEY); + const parents = sequences(alphabet, 0, 1); + const firstChildren = sequences(alphabet, 0, mode === "ci" ? 1 : 2); + const siblings: Op[][] = [[], [access(ReadOnly)], [{ decorator: "Listable", required: true }]]; + const cases: Case[] = []; + for (const declared of MAIN_DECLARATIONS) { + for (const opsP of parents) { + for (const opsA of firstChildren) { + for (const opsB of siblings) { + cases.push({ + parent: { a: field(declared, opsP) }, + children: [{ a: field(declared, opsA) }, { a: field(declared, opsB) }], + }); + } + } + } + } + return cases; +} + +/** The cases of a configuration within a mode's bounds, in a fixed order. */ +export function casesOf(configuration: Configuration, mode: Mode): Case[] { + switch (configuration) { + case "single": + return singleCases(mode); + case "pair": + return pairCases(mode); + case "derived": + return derivedCases(mode); + } +} + +/** The class names a case prints and builds under: `C` alone, else `P` and its children `A`, `B`, … */ +export function classNames(testCase: Case): string[] { + if (testCase.children.length === 0) return ["C"]; + return ["P", ...testCase.children.map((_, index) => String.fromCharCode(65 + index))]; +} + +function printMode(mode: AccessMode): string { + return `AccessMode.${AccessMode[mode]}`; +} + +function printOverrides(overrides: Record): string { + const entries = Object.entries(overrides).map(([action, mode]) => `${action}: ${printMode(mode)}`); + return `{ ${entries.join(", ")} }`; +} + +/** A decorator as a developer writes it. */ +export function printOp(op: Op): string { + switch (op.decorator) { + case "Access": + return op.overrides + ? `@Access(${printMode(op.mode)}, ${printOverrides(op.overrides)})` + : `@Access(${printMode(op.mode)})`; + case "Computed": + return "@Computed(expr)"; + case "Joined": + return `@Joined({ table, localKey: ${JSON.stringify(op.localKey)}, remoteField })`; + case "Listable": { + const args = [JSON.stringify(op.required), ...(op.mode ? [JSON.stringify(op.mode)] : [])]; + return `@Listable(${op.required === true && !op.mode ? "" : args.join(", ")})`; + } + } +} + +function printDeclaration(name: string, declared: Declaration): string { + switch (declared) { + case "property": + return `declare ${name}`; + case "defaulted": + return `${name} = "default:${name}"`; + case "getter": + return `get ${name}()`; + case "setter": + return `set ${name}(v)`; + case "accessor": + return `get ${name}() / set ${name}(v)`; + } +} + +/** A field with its decorators top to bottom, as a developer writes them. */ +function printField(name: string, decl: FieldDecl): string { + const decorators = [...decl.ops].reverse().map(printOp); + return [...decorators, printDeclaration(name, decl.declared)].join(" "); +} + +function printClass(name: string, decl: ClassDecl, parent?: string): string { + const head = parent ? `${name} extends ${parent}` : name; + const fields = Object.entries(decl).map(([field, fieldDecl]) => printField(field, fieldDecl)); + return `${head} { ${fields.join("; ")} }`; +} + +/** A case as source, one class after the other in definition order. */ +export function printCase(testCase: Case): string { + const [parent, ...children] = classNames(testCase); + return [ + printClass(parent, testCase.parent), + ...testCase.children.map((child, index) => printClass(children[index], child, parent)), + ].join(" "); +} + +/** How many decorators a case writes, over every class: the first measure of its size. */ +export function opCount(testCase: Case): number { + return [testCase.parent, ...testCase.children] + .flatMap((decl) => Object.values(decl)) + .reduce((count, decl) => count + decl.ops.length, 0); +} diff --git a/.agents/skills/formal-field-access/check.mjs b/.agents/skills/formal-field-access/check.mjs new file mode 100644 index 0000000..e110398 --- /dev/null +++ b/.agents/skills/formal-field-access/check.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Checks field exposure and write gating of @antelopejs/interface-data-api: + * `field-access.ts` declares every controller within its bounds through the + * real decorators and runs the real `ReadProperties` and `WriteProperties` + * on each. Node needs module hooks to run the TypeScript sources, so this + * file starts a second Node with them and exits as it exits: 0 when every + * rule matches the record in SKILL.md, 1 when a rule changed, 2 when the + * check could not run. + */ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const run = spawnSync( + process.execPath, + [ + "--disable-warning=ExperimentalWarning", + "--import", + pathToFileURL(path.join(dir, "ts-loader.mjs")).href, + path.join(dir, "field-access.ts"), + ...process.argv.slice(2), + ], + { stdio: "inherit" }, +); +process.exit(run.status ?? 2); diff --git a/.agents/skills/formal-field-access/field-access.ts b/.agents/skills/formal-field-access/field-access.ts new file mode 100644 index 0000000..60afa0c --- /dev/null +++ b/.agents/skills/formal-field-access/field-access.ts @@ -0,0 +1,185 @@ +import { build } from "./build.ts"; +import { type Case, casesOf, CONFIGURATIONS, type Configuration, type Mode, opCount, printCase } from "./cases.ts"; +import { checkCase, type Rule, RULES } from "./rules.ts"; + +/** A recorded finding: how many cases break the rule, and the shortest of them as {@link printCase} writes it. */ +interface Recorded { + cases: number; + shortest: string; +} + +type Record_ = Partial>; + +/** The findings of each configuration under each mode's bounds. */ +const FINDINGS: Record> = { + ci: { + single: { + ReadComplete: { cases: 24, shortest: "C { @Listable() @Computed(expr) get a() / set a(v) }" }, + ComputedReadOnly: { cases: 492, shortest: "C { @Access(AccessMode.WriteOnly) @Computed(expr) declare a }" }, + }, + pair: { + ComputedReadOnly: { cases: 200, shortest: "C { @Access(AccessMode.WriteOnly) @Computed(expr) declare a; set b(v) }" }, + }, + derived: { + ReadNoLeak: { + cases: 244, + shortest: "P { @Computed(expr) declare a } A extends P { declare a } B extends P { @Listable() declare a }", + }, + ReadComplete: { + cases: 202, + shortest: "P { @Computed(expr) declare a } A extends P { @Access(AccessMode.WriteOnly) declare a } B extends P { declare a }", + }, + WriteGated: { + cases: 100, + shortest: "P { @Listable() declare a } A extends P { @Access(AccessMode.WriteOnly) declare a } B extends P { declare a }", + }, + WriteComplete: { + cases: 184, + shortest: "P { @Access(AccessMode.WriteOnly) declare a } A extends P { @Computed(expr) declare a } B extends P { declare a }", + }, + ComputedReadOnly: { + cases: 60, + shortest: "P { @Computed(expr) declare a } A extends P { @Access(AccessMode.WriteOnly) declare a } B extends P { declare a }", + }, + ListPluck: { + cases: 16, + shortest: 'P { @Listable() declare a } A extends P { @Joined({ table, localKey: "k", remoteField }) declare a } B extends P { @Listable() declare a }', + }, + }, + }, + full: { + single: { + ReadComplete: { cases: 32, shortest: "C { @Listable() @Computed(expr) get a() }" }, + ComputedReadOnly: { cases: 4338, shortest: "C { @Access(AccessMode.WriteOnly) @Computed(expr) get a() }" }, + }, + pair: { + ComputedReadOnly: { + cases: 3440, + shortest: "C { declare a; @Access(AccessMode.WriteOnly) @Computed(expr) set b(v) }", + }, + }, + derived: { + ReadNoLeak: { + cases: 2840, + shortest: "P { @Computed(expr) declare a } A extends P { declare a } B extends P { @Listable() declare a }", + }, + ReadComplete: { + cases: 2146, + shortest: "P { @Computed(expr) declare a } A extends P { @Access(AccessMode.WriteOnly) declare a } B extends P { declare a }", + }, + WriteGated: { + cases: 1240, + shortest: "P { @Listable() declare a } A extends P { @Access(AccessMode.WriteOnly) declare a } B extends P { declare a }", + }, + WriteComplete: { + cases: 1958, + shortest: "P { @Access(AccessMode.WriteOnly) declare a } A extends P { @Computed(expr) declare a } B extends P { declare a }", + }, + ComputedReadOnly: { + cases: 1164, + shortest: "P { declare a } A extends P { @Access(AccessMode.WriteOnly) @Computed(expr) declare a } B extends P { declare a }", + }, + ListPluck: { + cases: 288, + shortest: 'P { @Listable() declare a } A extends P { @Joined({ table, localKey: "k", remoteField }) declare a } B extends P { @Listable() declare a }', + }, + }, + }, +}; + +const EXIT = { ok: 0, changed: 1, tool: 2 }; + +/** What one rule's failing cases look like in one configuration. */ +interface Broken { + cases: Set; + shortest: Case; + detail: string; +} + +function shorter(candidate: Case, current: Case): boolean { + const byOps = opCount(candidate) - opCount(current); + if (byOps !== 0) return byOps < 0; + return printCase(candidate).length < printCase(current).length; +} + +async function brokenRules(cases: Case[], show: boolean): Promise> { + const broken = new Map(); + for (const testCase of cases) { + const { classes, log } = build(testCase); + const violations = await checkCase(testCase, classes, log); + if (violations.length === 0) continue; + const printed = printCase(testCase); + if (show) console.log(` ${printed}\n${violations.map((violation) => ` ${violation.rule}: ${violation.detail}`).join("\n")}`); + for (const violation of violations) { + const entry = broken.get(violation.rule); + if (!entry) { + broken.set(violation.rule, { cases: new Set([printed]), shortest: testCase, detail: violation.detail }); + } else { + entry.cases.add(printed); + if (shorter(testCase, entry.shortest)) { + entry.shortest = testCase; + entry.detail = violation.detail; + } + } + } + } + return broken; +} + +function count(n: number): string { + return n.toLocaleString("en-US"); +} + +/** Prints one configuration's result against its record; true when a rule changed. */ +function report(configuration: Configuration, broken: Map, recorded: Record_, total: number): boolean { + let changed = false; + const holding: Rule[] = []; + for (const rule of RULES) { + const now = broken.get(rule); + const record = Object.hasOwn(recorded, rule) ? recorded[rule] : undefined; + if (!now && !record) { + holding.push(rule); + continue; + } + if (now && !record) { + console.log(`BROKEN ${rule}: ${count(now.cases.size)} cases, shortest ${printCase(now.shortest)} (${configuration})\n ${now.detail}`); + changed = true; + } else if (!now && record) { + console.log(`HOLDS ${rule}: no case breaks it (recorded ${count(record.cases)}, ${configuration})`); + changed = true; + } else if (now && record) { + const shortest = printCase(now.shortest); + if (now.cases.size !== record.cases || shortest !== record.shortest) { + console.log(`CHANGED ${rule}: ${count(now.cases.size)} cases (recorded ${count(record.cases)}), shortest ${shortest} (${configuration})\n ${now.detail}`); + changed = true; + } else { + console.log(`finding ${rule}: ${count(now.cases.size)} cases, shortest ${shortest} (${configuration})`); + } + } + } + if (holding.length > 0) console.log(`holds ${holding.join(", ")} (${count(total)} cases, ${configuration})`); + return changed; +} + +async function main(args: string[]): Promise { + if (args.some((arg) => arg !== "--full" && arg !== "--show")) { + console.error("usage: node check.mjs [--full] [--show]"); + return EXIT.tool; + } + const mode: Mode = args.includes("--full") ? "full" : "ci"; + const show = args.includes("--show"); + let changed = false; + for (const configuration of CONFIGURATIONS) { + const cases = casesOf(configuration, mode); + const broken = await brokenRules(cases, show); + if (report(configuration, broken, FINDINGS[mode][configuration], cases.length)) changed = true; + } + return changed ? EXIT.changed : EXIT.ok; +} + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + console.error(error); + process.exitCode = EXIT.tool; +} diff --git a/.agents/skills/formal-field-access/package.json b/.agents/skills/formal-field-access/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/.agents/skills/formal-field-access/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.agents/skills/formal-field-access/rules.ts b/.agents/skills/formal-field-access/rules.ts new file mode 100644 index 0000000..9fb6537 --- /dev/null +++ b/.agents/skills/formal-field-access/rules.ts @@ -0,0 +1,360 @@ +import { Query } from "../../../src/components.ts"; +import { AccessMode, type RowFields } from "../../../src/metadata.ts"; +import { type AccessorCall, type Built, defaultsOf, getterValue, usesGetter, usesSetter } from "./build.ts"; +import { type Case, type ClassDecl, classNames, DB_ONLY_KEY, type Declaration, type Op } from "./cases.ts"; + +export type Rule = + | "ReadNoLeak" + | "ReadComplete" + | "WriteGated" + | "WriteComplete" + | "ComputedReadOnly" + | "ListPluck"; + +export const RULES: Rule[] = [ + "ReadNoLeak", + "ReadComplete", + "WriteGated", + "WriteComplete", + "ComputedReadOnly", + "ListPluck", +]; + +/** One rule a class broke, and what the check saw, in the code's words. */ +export interface Violation { + rule: Rule; + detail: string; +} + +/** + * What a field's declarations promise, read as their TSDoc states it. + * `@Access` sets the mode and replaces the override table. `@Computed` and + * `@Joined` make the field read-only, and keep the override table, which no + * doc mentions. `@Listable` adds the field to a list mode with the database + * names it selects, plus itself and its local key once it is joined. + * `@Access` and `@Listable` hand the framework the accessor's descriptor + * (`setDescriptor`, "the known field descriptor"); `@Computed` and `@Joined` + * are property decorators and see none, so an accessor they alone decorate + * stays unknown, and the field is read and written as a plain property. + */ +interface FieldPromise { + declared: Declaration; + mode?: AccessMode; + overrides?: Record; + derived: boolean; + listable: boolean; + /** Some decorator names the field, so `WriteProperties` stores its class default on creation. */ + decorated: boolean; + /** The class whose accessor the framework knows: the last to write `@Access` or `@Listable` on the field. */ + accessorOwner?: string; + joinedKey?: string; + selects: Map>; +} + +/** One class of a case, under the name it was built with. */ +interface Declared { + name: string; + decl: ClassDecl; +} + +/** A decorator with the class that wrote it. */ +interface Written { + owner: string; + op: Op; +} + +/** The actions the routes pass, a custom one, the default action by name, and none. */ +const ACTIONS: (string | undefined)[] = ["get", "list", "new", "edit", "custom", "_default", undefined]; + +function promiseOf(name: string, declared: Declaration, ops: Written[]): FieldPromise { + const promise: FieldPromise = { + declared, + derived: false, + listable: false, + decorated: ops.length > 0, + selects: new Map(), + }; + for (const { owner, op } of ops) { + switch (op.decorator) { + case "Access": + promise.mode = op.mode; + promise.overrides = op.overrides; + promise.accessorOwner = owner; + break; + case "Computed": + promise.derived = true; + promise.mode = AccessMode.ReadOnly; + break; + case "Joined": + promise.derived = true; + promise.mode = AccessMode.ReadOnly; + promise.joinedKey = op.localKey; + for (const names of promise.selects.values()) { + if (names.size > 0) names.add(name).add(op.localKey); + } + break; + case "Listable": { + promise.listable = true; + promise.accessorOwner = owner; + const base = op.required === true ? [name] : op.required === false ? [] : op.required; + const names = new Set(base); + if (names.size > 0 && promise.joinedKey) names.add(name).add(promise.joinedKey); + promise.selects.set(op.mode ?? "list", names); + } + } + } + return promise; +} + +/** The promises of a class: its parent's declarations, then its own, per field. */ +function promisesOf(chain: Declared[]): Map { + const promises = new Map(); + const names = new Set(chain.flatMap(({ decl }) => Object.keys(decl))); + for (const name of names) { + const own = chain.flatMap(({ name: owner, decl }) => + Object.hasOwn(decl, name) ? [{ owner, field: decl[name] }] : [], + ); + const ops = own.flatMap(({ owner, field }) => field.ops.map((op) => ({ owner, op }))); + promises.set(name, promiseOf(name, own[0].field.declared, ops)); + } + return promises; +} + +function effective(promise: FieldPromise, action: string | undefined): AccessMode | undefined { + if (action === undefined || action === "_default") return promise.mode; + const overrides = promise.overrides; + if (overrides && Object.hasOwn(overrides, action)) return overrides[action]; + return promise.mode; +} + +function canRead(promise: FieldPromise, action: string | undefined): boolean { + return ((effective(promise, action) ?? 0) & AccessMode.ReadOnly) !== 0; +} + +function canWrite(promise: FieldPromise, action: string | undefined): boolean { + return ((effective(promise, action) ?? 0) & AccessMode.WriteOnly) !== 0; +} + +/** The class whose setter the framework runs for the field, if it knows one. */ +function knownSetter(promise: FieldPromise | undefined): string | undefined { + return promise && usesSetter(promise.declared) ? promise.accessorOwner : undefined; +} + +function json(value: string | string[] | undefined): string { + return value === undefined ? "undefined" : JSON.stringify(value); +} + +function label(action: string | undefined, extra?: string): string { + const parts = [action === undefined ? "no action" : json(action), ...(extra ? [extra] : [])]; + return parts.join(", "); +} + +/** A row or body: `:` for each field and for the key no field declares. */ +function valuesOf(names: string[], prefix: string): Map { + return new Map([...names, DB_ONLY_KEY].map((name) => [name, `${prefix}:${name}`])); +} + +/** The bodies a write receives: every field, no field, and, with several fields, the first alone. */ +function bodiesOf(names: string[]): Map[] { + const bodies = [valuesOf(names, "body"), new Map()]; + if (names.length > 1) bodies.push(valuesOf(names.slice(0, 1), "body")); + return bodies; +} + +/** One class under check: the instance the routes would hold, its promises, and the accessor log it writes to. */ +interface Observed { + built: Built; + instance: object; + promises: Map; + names: string[]; + log: AccessorCall[]; +} + +async function checkReads({ built, instance, promises, names, log }: Observed): Promise { + const violations: Violation[] = []; + const add = (rule: Rule, detail: string) => violations.push({ rule, detail }); + for (const action of ACTIONS) { + for (const onlyList of [false, true]) { + const row = valuesOf(names, "row"); + const where = `${built.name}.ReadProperties(${label(action, onlyList ? "onlyList" : undefined)})`; + log.length = 0; + const answered = await Query.ReadProperties(instance, built.meta, Object.fromEntries(row), action, onlyList); + const readable = names.filter((name) => { + const promise = promises.get(name); + return promise !== undefined && canRead(promise, action) && (!onlyList || promise.listable); + }); + for (const key of Object.keys(answered)) { + if (!readable.includes(key)) add("ReadNoLeak", `${where} answers ${key}`); + } + for (const call of log) { + if (call.kind === "get" && !readable.includes(call.field)) { + add("ReadNoLeak", `${where} runs the getter of ${call.field}`); + } + } + for (const name of readable) { + const promise = promises.get(name); + const owner = promise?.accessorOwner; + const expected = + promise && owner !== undefined && usesGetter(promise.declared) ? getterValue(owner, name) : row.get(name); + if (!Object.hasOwn(answered, name)) add("ReadComplete", `${where} lacks ${name}`); + else if (answered[name] !== expected) { + add("ReadComplete", `${where} answers ${name} as ${JSON.stringify(answered[name])}, not ${json(expected)}`); + } + } + } + } + return violations; +} + +/** One write the check ran, with what the promises expect of it. */ +interface Write { + where: string; + result: RowFields; + /** The row the write starts from: the stored row, or the class defaults of the decorated fields. */ + start: Map; + body: Map; + /** The fields the promises let this write store. */ + written: string[]; + log: AccessorCall[]; +} + +function gatedViolations({ where, result, start, body, written, log }: Write, promises: Map) { + const violations: Violation[] = []; + for (const [key, value] of Object.entries(result)) { + if (start.has(key) && start.get(key) === value) continue; + if (written.includes(key) && knownSetter(promises.get(key)) === undefined && value === body.get(key)) continue; + violations.push({ rule: "WriteGated", detail: `${where} writes ${key} = ${JSON.stringify(value)}` }); + } + for (const key of start.keys()) { + if (!Object.hasOwn(result, key)) violations.push({ rule: "WriteGated", detail: `${where} drops ${key}` }); + } + for (const call of log) { + if (call.kind === "set" && !written.includes(call.field)) { + violations.push({ rule: "WriteGated", detail: `${where} runs the setter of ${call.field}` }); + } + } + return violations; +} + +function completeViolations({ where, result, body, written, log }: Write, promises: Map) { + const violations: Violation[] = []; + for (const name of written) { + const owner = knownSetter(promises.get(name)); + if (owner !== undefined) { + const ran = log.some( + (call) => call.kind === "set" && call.field === name && call.owner === owner && call.value === body.get(name), + ); + if (!ran) { + violations.push({ + rule: "WriteComplete", + detail: `${where} does not run ${owner}'s setter of ${name} with ${json(body.get(name))}`, + }); + } + } else if (!Object.hasOwn(result, name) || result[name] !== body.get(name)) { + violations.push({ + rule: "WriteComplete", + detail: `${where} writes ${name} as ${JSON.stringify(result[name])}, not ${json(body.get(name))}`, + }); + } + } + return violations; +} + +function derivedViolations({ where, result, start, log }: Write, promises: Map) { + const violations: Violation[] = []; + for (const [name, promise] of promises) { + if (!promise.derived) continue; + const untouched = start.has(name) + ? Object.hasOwn(result, name) && result[name] === start.get(name) + : !Object.hasOwn(result, name); + if (!untouched) { + violations.push({ + rule: "ComputedReadOnly", + detail: `${where} writes the computed or joined field ${name} = ${JSON.stringify(result[name])}`, + }); + } + if (log.some((call) => call.kind === "set" && call.field === name)) { + violations.push({ + rule: "ComputedReadOnly", + detail: `${where} runs the setter of the computed or joined field ${name}`, + }); + } + } + return violations; +} + +async function checkWrite( + { built, instance, promises, names, log }: Observed, + action: string | undefined, + body: Map, + stored: Map | undefined, +): Promise { + const how = `${stored ? "edit" : "new"} with ${json([...body.keys()])}`; + const where = `${built.name}.WriteProperties(${label(action, how)})`; + log.length = 0; + const result = await Query.WriteProperties( + instance, + built.meta, + Object.fromEntries(body), + action, + stored ? Object.fromEntries(stored) : undefined, + ); + const written = names.filter((name) => { + const promise = promises.get(name); + return promise !== undefined && canWrite(promise, action) && (stored === undefined || body.has(name)); + }); + const defaults = [...defaultsOf(built.decl)].filter(([name]) => promises.get(name)?.decorated); + const write: Write = { where, result, start: stored ?? new Map(defaults), body, written, log }; + return [ + ...gatedViolations(write, promises), + ...completeViolations(write, promises), + ...derivedViolations(write, promises), + ]; +} + +function checkPluck({ built, promises }: Observed): Violation[] { + const violations: Violation[] = []; + const expected = new Map>(); + for (const promise of promises.values()) { + for (const [mode, names] of promise.selects) { + const set = expected.get(mode) ?? new Set(); + for (const name of names) set.add(name); + expected.set(mode, set); + } + } + const modes = new Set([...expected.keys(), ...Object.keys(built.meta.pluck)]); + for (const mode of modes) { + const got = Object.hasOwn(built.meta.pluck, mode) ? [...built.meta.pluck[mode]].sort() : undefined; + const want = expected.has(mode) ? [...(expected.get(mode) ?? [])].sort() : undefined; + if (json(got) !== json(want)) { + violations.push({ rule: "ListPluck", detail: `${built.name}.pluck.${mode} is ${json(got)}, not ${json(want)}` }); + } + } + return violations; +} + +/** + * Every rule a case breaks, over every class the case declares: each class + * is read and written for every action, as the routes do, and compared with + * what its own declarations and its parent's promise. + */ +export async function checkCase(testCase: Case, classes: Built[], log: AccessorCall[]): Promise { + const violations: Violation[] = []; + const names = classNames(testCase); + for (const [index, built] of classes.entries()) { + const parent = { name: names[0], decl: testCase.parent }; + const chain = index === 0 ? [parent] : [parent, { name: names[index], decl: testCase.children[index - 1] }]; + const promises = promisesOf(chain); + const observed: Observed = { built, instance: new built.cls(), promises, names: [...promises.keys()], log }; + violations.push(...(await checkReads(observed))); + for (const action of ACTIONS) { + for (const body of bodiesOf(observed.names)) { + for (const stored of [undefined, valuesOf(observed.names, "stored")]) { + violations.push(...(await checkWrite(observed, action, body, stored))); + } + } + } + violations.push(...checkPluck(observed)); + } + return violations; +} diff --git a/.agents/skills/formal-field-access/ts-loader.mjs b/.agents/skills/formal-field-access/ts-loader.mjs new file mode 100644 index 0000000..ff59979 --- /dev/null +++ b/.agents/skills/formal-field-access/ts-loader.mjs @@ -0,0 +1,30 @@ +/** + * Node module hooks that run this repository's TypeScript sources as they + * are: a relative import with no extension resolves to its `.ts` file, and + * each `.ts` file is transformed with Node's own TypeScript support, enums + * and namespaces included. `check.mjs` passes this file to `--import`. + */ +import { existsSync, readFileSync } from "node:fs"; +import { registerHooks, stripTypeScriptTypes } from "node:module"; +import { fileURLToPath } from "node:url"; + +const HAS_SCRIPT_EXTENSION = /\.[cm]?[jt]s$/; + +registerHooks({ + resolve(specifier, context, next) { + const fromTypeScript = context.parentURL?.endsWith(".ts") ?? false; + if (fromTypeScript && specifier.startsWith(".") && !HAS_SCRIPT_EXTENSION.test(specifier)) { + const file = fileURLToPath(new URL(`${specifier}.ts`, context.parentURL)); + if (existsSync(file)) return next(`${specifier}.ts`, context); + } + return next(specifier, context); + }, + load(url, context, next) { + if (!url.endsWith(".ts")) return next(url, context); + const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), "utf8"), { + mode: "transform", + sourceUrl: url, + }); + return { format: "module", source, shortCircuit: true }; + }, +}); diff --git a/.agents/skills/formal-field-access/tsconfig.json b/.agents/skills/formal-field-access/tsconfig.json new file mode 100644 index 0000000..58178e2 --- /dev/null +++ b/.agents/skills/formal-field-access/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../..", + "types": ["node"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "allowJs": true, + "checkJs": true, + "composite": false, + "noEmit": true + }, + "include": ["*.ts", "*.mjs"] +} diff --git a/.agents/skills/verify-interface/SKILL.md b/.agents/skills/verify-interface/SKILL.md new file mode 100644 index 0000000..70db01a --- /dev/null +++ b/.agents/skills/verify-interface/SKILL.md @@ -0,0 +1,137 @@ +--- +name: verify-interface +description: "Drive this AntelopeJS interface the way a module author does: pack it, load it in `ajs project dev` next to a stub provider wired through ImplementInterface and a consumer, call every declared function through core, and check the minor-bump rule against the published version. Use to prove a change to the interface works before merge, to verify a PR, or to check that a seeded break is caught." +--- + +# Verify interface + +The user of an interface is a module author: one module implements it, others import it and call its functions through core. Every deterministic step is an `antelopejs-verify` command: you choose which journeys to run and judge what they show. Never replace a command with a shell recipe. When a journey you need does not exist, add it to `verify.config.ts`, name it in its feature file, then run it. + +`antelopejs-sync` writes this file, `package.json` and `tsconfig.json` here, and keeps them in sync. Everything else in `.agents/skills/verify-interface/` is the repository's own layer, which sync never touches. + +## Launch + +```bash +pnpm exec antelopejs-verify new --base origin/main --title "" +``` + +`new` prints the run folder; pass it as `` below. `new` runs the setup (`pnpm run build` by default), then packs the interface with `pnpm pack` and extracts it, so journeys load what npm would publish, `files` and `exports` included. Every `services` entry of `verify.config.ts` then starts in its own Docker container on a free loopback port, as for a module. Every journey gets its own `HOME` and scratch folder, `CI=1`, `AJS_NO_UPDATE_CHECK=1`, `NO_COLOR=1` and a free port from the kernel, so two runs never share an instance. Run it from the repository root after installing dependencies. + +## Doctor + +```bash +pnpm exec antelopejs-verify doctor +``` + +Doctor checks that HEAD has not moved since `new`, `ajs` comes from the repository's `@antelopejs/core`, the build is newer than `src/`, what `new` prepared is there, and no process the run recorded is still alive. Do not drive a run that fails doctor. + +## Drive + +```bash +pnpm exec antelopejs-verify plan --base origin/main +pnpm exec antelopejs-verify journey [journey...] +``` + +`plan` maps the diff to features and lists unmapped surfaces. `journey` runs a feature's journeys, or the ones named. The pack ships these journeys for this kind; a feature file names them next to the repository's own: + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module. The consumer walks the loaded interface, and every function the `.d.ts` files declare is called through core: + - an async proxy answers the stub's echo, and the stub's call log shows the same arguments (the second view); + - a registering proxy registers and unregisters, and the stub logs both; + - an event proxy delivers the emitted arguments to a registered handler; + - a plain function runs the `interface.calls` hook of `verify.config.ts` and must return its `expect`; one only providers call is listed in `interface.providerSide` with why. + A declared function with none of these fails the journey. +- `minor-bump`: reads the declared functions of the packed head and of the published version (`npm pack @`). An added, changed or removed function needs a release of at least the next minor. The release is the head version when it is already bumped, otherwise what changelogen makes of the change title (`new --title`) or the commit subjects since the base. + +## Evidence + +Each step writes `/evidence////` with `command.txt`, `stdout.txt`, `stderr.txt`, `exit-code.txt`, `pid.txt` for background processes, `tree.txt`, `http.json` for every request, and `checks.json`. `result.json` holds a journey's result and `feature.json` the merged results of the feature. Read the evidence before you judge a PASS: a check proves only what it asserts. + +## Cleanup + +```bash +pnpm exec antelopejs-verify clean +``` + +`clean` stops what the run started, by recorded PID and container name, and removes everything but `evidence/` and `run.json`. Run it after every run, failed ones included. + +## The repository's own layer + +- `verify.config.ts` default-exports a `VerifyConfig` from `@antelopejs/tooling-configs/verify`: + - `doors`: one entry per door kind (`data`, `format`, `contract`, `outbound`, `exposure`, `publish`), each `null` when it cannot apply or `{ globs, funnels, containments, why }`. `containments` names the `feature/journey` journeys that contain a Data or Format door. Each must fail with that kind's seeded door applied and pass without it: write the journey so one of its checks reads the state the door corrupts. + - `journeys`: the repository's journeys by id, each `{ title, entryPoint, run(context) }`, using the check helpers the same import exports. + - `env`, `setup` (commands replacing the build), `userFacing` (globs replacing the kind's default) and `formal` (formal checks: `name`, `covers`, `command`, `violation`, and optionally `behaviour` and `baseCommand`). +- `services`: image, port, readiness command and a `reset` command run before every journey, as for a module. `new` starts them in Docker on free ports; a journey reads them with `context.service(name)` and `context.native(...)`. +- `interface.provide(api, services)` runs in the stub provider after the generic stubs attach, for example to hand a client pointed at `services` to `internal.SetClient`. +- `interface.calls[":"]` calls a plain exported function from the consumer and states the value it must return. +- `interface.providerSide[":"]` names a function only providers call, with the reason. +- `features/.md`: frontmatter lists `covers:` (source globs), `docs:` (pages a verdict judges) and `journeys:`, then the sections Sub-features, How to get to it (user POV), Driving it with antelopejs-verify, and Gotchas. `features/smoke.md` covers nothing and runs on every verdict; here it runs `every-function`. +- `seeded-breaks/.patch`: one minimal product bug per feature that its journeys must catch. +- `seeded-doors/.patch`: one change per door kind that applies. A Data or Format door's patch must break what its containment journeys check. + +```bash +pnpm exec antelopejs-verify map +``` + +`map` fails on a user-facing file no feature covers, a feature without journeys, docs or seeded break, an unknown journey, an applicable door kind without its seeded door, and a Data or Format door whose `containments` name no journey a feature registers. `containments` (below) then runs those journeys against the seeded doors. + +## Verdict rules + +1. **Run every feature whose `covers:` globs the diff touches, plus `smoke`.** `plan` prints that list as `run`. +2. **An unmapped surface fails the verdict.** A changed user-facing file no feature covers needs a feature entry, its journeys and its seeded break in the same PR. +3. **Every registered journey of a feature in `run` must have run and passed.** Run whole features. A journey that could not reach the product is NOT VERIFIED, and so is the verdict, whatever else passed: never PASS and never PASS+NOTES. +4. **Docs.** Judge every `docs:` page of a touched feature against what the journeys showed. A stale page fails the verdict. +5. **Formal checks.** `pnpm exec antelopejs-verify formal ` runs every formal check of `verify.config.ts` that covers the diff, or whose own `files` the diff changes, and records it under `evidence/formal//`. For each one it decides, without judgement, whether the diff changed what the covered code does. `emit` (the default) compares the structure of the covered files' JavaScript on the base and on HEAD: node kinds, names and literal values, leaving out types, comments, formatting, parentheses and `export` keywords. Each use of an imported binding stands for its origin (the resolved module and the imported name), so an import retargeted to another module or alias counts as changed; a function declaration refers to the top-level variables and classes of its own file by file and name, so moving it next to a different variable of the same name counts as changed. Exports are keyed by the origin they export, so a re-export or an `export { f as run }` retargeted to another binding counts as changed. Each file must keep its top-level statements and its side-effect imports in order; function declarations, which are hoisted, may move between covered files with their imports. So a type-only edit, a reformat or a moved function declaration counts as unchanged, while reordering statements or moving any other statement counts as changed. `emit` judges behaviour, not the public surface: exports are the api-diff's job. `probe` runs the check's `behaviour.command` with a worktree of each commit and compares what it prints. Then: + - a covered check that did not run, or exits non-zero (a tool error, or a check that no longer matches its record), fails the verdict; + - recorded findings fail the verdict only when the covered behaviour changed; when it did not, the verdict reports them as known findings; + - when the covered behaviour changed, or the diff changed the check's own `files`, `formal` also runs the check on the base (`baseCommand` when set). A rule the base broke counts as fixed only when HEAD reports it passing (`passing`); a rule the base broke that HEAD does not report at all fails the verdict. A change that leaves no violated rule passes. + + The decision never reads the check's output, so a model whose rule names stay the same still fails on a changed behaviour. It is conservative: a local rename or an extracted helper counts as changed, and code outside `covers` is invisible to it. `--all` also runs the checks that do not cover the diff, without gating. +6. **One-way doors.** The section comes right after the result line and is mandatory. `pnpm exec antelopejs-verify doors` prints the `One-way-door:` line of the diff. Every file the path rules flag needs a door row or a false positive with its reason. A Data or Format row names a `containments` journey that passed in this run, and that journey is proven by `pnpm exec antelopejs-verify containments`: it fails with the kind's seeded door applied. A containment that passes with its door contains nothing, so a Data or Format door without one that fails with its door is not contained. +7. **Result.** FAIL if any rule fails. NOT VERIFIED when any journey of a required feature is NOT VERIFIED, for example a SaaS module run without its sandbox keys, however many other journeys passed. PASS+NOTES when everything required ran and passed but the judgement lists something no journey reached or something the owner should look at. PASS otherwise. + +Write your judgement to `/evidence/judgement.json`: + +```json +{ + "doors": { + "none": "why the diff opens no door", + "rows": [], + "falsePositives": [{ "kind": "publish", "location": "package.json", "reason": "only devDependencies changed" }] + }, + "docs": [{ "page": "README.md", "matches": true, "note": "" }], + "notVerified": ["What no journey reached, and why"], + "attention": ["What the owner should look at that the journeys cannot judge"], + "verifier": "the fresh thread that judged this run, not the author" +} +``` + +A door row is `{ "kind", "location", "irreversible", "containment", "containmentJourney" }`. Then render and publish: + +```bash +pnpm exec antelopejs-verify verdict --pr +pnpm exec antelopejs-verify publish --pr +``` + +`verdict` writes `evidence/verdict.md` and exits 1 unless the result is PASS or PASS+NOTES. `publish` commits the evidence to the orphan branch `verify-evidence` under `pr-//` from a temporary worktree, posts the verdict as a PR comment, sets commit status `verify` on the head SHA (`success` for PASS and PASS+NOTES, `error` for NOT VERIFIED, `failure` for FAIL) and reads it back. It prints the error GitHub answers when the token cannot write statuses. A new push voids the status. Post a verdict only from a thread that did not write the change. + +## Trust + +The skill is not Trusted yet. It becomes Trusted when it catches every seeded break, flags every seeded door, fails every Data and Format containment with its door, passes a shadow run of 10 consecutive agent PRs, and its last maintain pass is newer than the last change to the repository or its `@antelopejs` dependencies and at most 30 days old. + +## Seeded breaks and doors + +```bash +pnpm exec antelopejs-verify seeded +pnpm exec antelopejs-verify seeded-doors +pnpm exec antelopejs-verify containments +``` + +`seeded` runs each feature's journeys in a scratch worktree of HEAD without the patch, as the control, and with `seeded-breaks/.patch` applied. It prints `CAUGHT` only when a journey that passes in the control fails one of its checks with the patch. Journeys failing in the control and failures without a failing check do not count. `seeded-doors` prints `FLAGGED` when the path rules flag each patch with its own kind. `containments` runs every Data and Format containment journey in a scratch worktree of HEAD without the door, as the control, and with `seeded-doors/.patch` applied. It prints `CONTAINED` only when the journey passes in the control and fails one of its checks with the door. `MISSED` means it passes with its door, `UNPROVEN` that it fails in the control or ends without a failing check, and `NO-DOOR` that the kind has no seeded door; the command exits 1 on anything but `CONTAINED`. + +## Gotchas + +- A function the consumer awaits forever, such as a getter no provider resolved, fails after 15 seconds with a `Timeout` value. +- `minor-bump` needs the npm registry. Without it the journey is not verified, never passed. +- A containment journey of a Data or Format door must check what that door's seeded patch changes, for example the arguments the stub provider logged. `containments` reports one that still passes with its door as `MISSED`. +- Runs live under the system temp folder (`TMPDIR`), and the shared caches under `~/.cache/antelopejs-verify`. +- A repository that outgrows this skill runs `antelopejs-sync --verify custom` and keeps its own `.agents/skills/verify-/`. diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md new file mode 100644 index 0000000..7de261e --- /dev/null +++ b/.agents/skills/verify-interface/features/README.md @@ -0,0 +1,47 @@ +# Feature map + +One file per feature. `covers:` lists every source file that computes something one of the feature's journeys checks, `docs:` the pages a verdict judges, `journeys:` the journeys it runs, and `seeded-breaks/.patch` the break its journeys must catch. `pnpm exec antelopejs-verify map` checks that every user-facing file is covered. + +`plan` selects a feature only through `covers:`, so a file a journey depends on but its feature does not list is a break no verdict runs. A file goes under every feature whose journeys check its behaviour, not only the feature named after it. `src/index.ts` (the routes pick the action, lock and unlock modifiers, and run the guard), `src/components.ts` (the route parameters and the `Query` and `Validation` steps) and `src/metadata.ts` (every decorator) serve every feature, so every feature lists them. When a journey starts checking a new file, add that file to the journey's feature. + +| Feature | What the user does | Journeys | +|---|---|---| +| [crud](crud.md) | A client calls the five default routes of a data controller. | crud-lifecycle, crud-errors, crud-subset, new-row-shape, edit-setters, edit-answers-foreign, unstorable-body | +| [route-options](route-options.md) | A route is customized with `WithOptions`. | option-overrides, list-unknown, issue-90-edit-index, no-mandatory-strings, no-pluck-strings, no-model-500, option-beats-query | +| [access](access.md) | `@Access` decides what each route reads and writes. | access-modes, listable-alone, read-overrides | +| [validation](validation.md) | `@Mandatory`, `@Validator` and `@Optional` check the body. | mandatory-and-validators, parsed-values, error-codes, empty-forms, mandatory-before-validators, holey-body | +| [listing](listing.md) | A client pages, sorts and narrows a list. | pluck-modes, pagination, sorting, issue-92-getter-mode | +| [foreign](foreign.md) | A key field answers the record it points to. | foreign-resolve, foreign-raw, issue-93-multi-foreign, no-foreign-strings, foreign-declaration-throws, foreign-unplucked | +| [filters](filters.md) | A list request filters rows. | default-filter, custom-filters, filter-details, route-filters-list-only | +| [modifiers](modifiers.md) | A per-request key selects a modifier's value. | localized-key | +| [joined](joined.md) | A remote field is flattened onto the row. | joined-read, joined-access | +| [computed](computed.md) | A row answers a database-computed value. | computed-read, computed-access | +| [guards](guards.md) | A module guards a read, amends a body or adds a route. | get-guard, guard-unlocked, amend-body, custom-route, guard-semantics | +| [metadata](metadata.md) | A module author reads metadata and declares controllers. | effective-modes, declaration-errors, exports-runtime, consumer-compiles, derived-own-model, foreign-target-meta | +| [smoke](smoke.md) | The fixed journey every verdict runs. | every-function, smoke-roundtrip | + +## Known issues, driven on purpose + +Three journeys fail on head and cite their issue: `issue-90-edit-index` ([#90](https://github.com/AntelopeJS/interface-data-api/issues/90)), `issue-92-getter-mode` ([#92](https://github.com/AntelopeJS/interface-data-api/issues/92)) and `issue-93-multi-foreign` ([#93](https://github.com/AntelopeJS/interface-data-api/issues/93)). A verdict that runs their features is FAIL until the issue is fixed; the fix turns the journey green. + +## Not driven + +Every documented behaviour of the README, `docs/` and the TSDoc has a journey above, or a row here with the reason. + +| Behaviour | Why no journey drives it | +|---|---| +| `Unregistered Database Table` (docs/2) | Needs a registered schema that lacks the table class; `@RegisterTable` registers the table. | +| Class metadata and `schema` option name different schemas, the declaration throws (docs/7, docs/10) | Needs a table class whose metadata names a second schema; declaration-time only, no route reaches it. | +| `@Joined` `remoteIndex` (docs/10) | Needs an indexed non-key column on the remote table. | +| `Query.List` and `Query.ListPage` composed by a custom route, deferred lookup order, bare `count()` skips lookups, `changes()` (docs/10) | Advanced composition by internal helpers; the default list route is driven for the same joined and computed fields. | +| `useIndex`: an indexed equality filter reads through the index (docs/8) | Not observable from an answer; only the query plan differs. | +| `noIndex` changes where the sort sits, the provider decides how it runs (docs/6) | Both settings answer the same rows; the sort is checked for both. | +| `@Localized` sort on `fallbackLocale`, never indexed (docs/6) | The fallback is configured in the modifier, outside this package. | +| `@Hashed`, `@Encrypted` and `EncryptionModifier.unlockrequest` in filters (docs/8, docs/9) | Modifiers of `@antelopejs/interface-database-decorators`; only `@ModifierKey` is this package's. | +| Clearing a mandatory field on edit with `[]` (docs/5) | Same check as `""` and `null`, which are driven. | +| `@Optional` clears what `@Mandatory` set (docs/5) | Only absence on `new` is driven. | +| A wrapped `new` or `edit` receives `Date` or `undefined` values (docs/5) | Needs a route that builds a body with those; the bigint refusal is driven. | +| `500` when the database operation fails or the controller holds no model (docs/3) | Needs a broken database or a controller without `@ModelReference`. | +| An async uniqueness check followed by an insert is not unique under concurrency (docs/5) | Advice about the application's own validator, not package behaviour. | +| `DataControllerCallback` generic wrappers, `GetGuard` `this` typing (docs/3) | Type-level; the docs-check snippets in CI compile them. | +| Install commands, implementations table, compatibility table, contributing (README) | Prose; the compatibility table is checked by the docs action. | diff --git a/.agents/skills/verify-interface/features/access.md b/.agents/skills/verify-interface/features/access.md new file mode 100644 index 0000000..e0f808e --- /dev/null +++ b/.agents/skills/verify-interface/features/access.md @@ -0,0 +1,40 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/properties.ts + - src/internal/read-properties.ts +docs: + - docs/4.access-rights.md +journeys: + - access-modes + - listable-alone + - read-overrides +--- + +# access + +`@Access` decides which fields each route reads and writes, per action. + +## Sub-features + +- `access-modes`: a write-only field is never answered and is stored, a field with no `@Access` is never answered and never written, a read-only field is answered and ignored in a body, and a per-action override (`role` writable on `new` and read-only on `edit`, `age` the other way) decides what `new` and `edit` store; MongoDB shows each case. +- `listable-alone`: a field that is only `@Listable` never appears in a response, and a route that skips the mandatory check still runs the validators. +- `read-overrides`: `@Access(mode, { get, list })` hides a field on one action and grants it on the other, in both directions (readable by default and write-only on an action, write-only by default and read-only on an action), `new` writes a write-only field, and `GetEffectiveMode` answers what the routes apply. + +## How to get to it (user POV) + +- `@Access(AccessMode.WriteOnly)` on a password, then `POST /users/new` and `GET /users/get`; `@Access(AccessMode.ReadWrite, { edit: AccessMode.ReadOnly })` on a role, then `PUT /users/edit`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" access +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- A refused write is not an error: the route ignores the value and answers 200. diff --git a/.agents/skills/verify-interface/features/computed.md b/.agents/skills/verify-interface/features/computed.md new file mode 100644 index 0000000..4b12635 --- /dev/null +++ b/.agents/skills/verify-interface/features/computed.md @@ -0,0 +1,38 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/computed.ts + - src/internal/list.ts +docs: + - docs/11.computed.md +journeys: + - computed-read + - computed-access +--- + +# computed + +A row answers a value the database computes. + +## Sub-features + +- `computed-read`: `@Computed` merges a per-row `count()` subquery and a row-local expression; `default` turns the null aggregate of an empty set into 0, the list sorts and pages on the aggregate with the same total on every page, filters on it, and the field is read-only. +- `computed-access`: an `@Access` written above `@Computed` replaces its read-only mode, on get and on list. + +## How to get to it (user POV) + +- `GET /groups/list?sortKey=devices_count&sortDirection=desc&limit=2` on a controller whose `devices_count` counts the devices of each group. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" computed +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- The aggregate form needs a driver that turns a per-row subquery into a correlated lookup; the journey runs on `@antelopejs/mongodb`. diff --git a/.agents/skills/verify-interface/features/crud.md b/.agents/skills/verify-interface/features/crud.md new file mode 100644 index 0000000..e350715 --- /dev/null +++ b/.agents/skills/verify-interface/features/crud.md @@ -0,0 +1,56 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/controller-types.ts + - src/internal/extractors.ts + - src/internal/guards.ts + - src/internal/properties.ts + - src/internal/tables.ts + - src/internal/validation.ts +docs: + - README.md + - docs/1.introduction.md + - docs/2.data-controllers.md + - docs/3.routes.md +journeys: + - crud-lifecycle + - crud-errors + - crud-subset + - new-row-shape + - edit-setters + - edit-answers-foreign + - unstorable-body +--- + +# crud + +A client calls the five default routes of a data controller and the table follows. + +## Sub-features + +- `crud-lifecycle`: `new`, `get`, `edit` and `delete` on the `users` controller change exactly the rows they name: the write-only password is stored but never answered, the age allowed on `new` is stored, an edit keeps the fields it was not sent, and a delete of two ids and one that does not exist leaves every other row; MongoDB shows each write. +- `crud-errors`: `Missing id.` (400), `Not Found` (404), `Invalid JSON body.` and `The request body must be a JSON object.` (400) on every route that reads them, a delete of an unknown id answers 200, and no refused request writes a row. +- `crud-subset`: a controller that declares only `get`, `list` and `new` answers 404 on `edit` and `delete`; `WithOptions(Edit, options, "patch")` serves under `patch` and not under its key; a controller derived with `Controller("/staff", UserAPI)` reads through the inherited model reference. +- `new-row-shape`: `new` answers its `_id` on a controller whose `_id` has no `@Access`, starts from the defaults the controller class declares, and runs every writable setter, with `undefined` for a field absent from the body. +- `edit-setters`: `edit` runs the setters of the fields present in the body and no other, and the class defaults do not overwrite stored fields. +- `edit-answers-foreign`: `edit` answers the stored record with its foreign fields resolved. +- `unstorable-body`: a route that hands `Validation.ParseBody` a parsed body holding a `bigint` answers 400 and writes nothing. + +## How to get to it (user POV) + +- `GET /users/get?id=`, `GET /users/list`, `POST /users/new`, `PUT /users/edit?id=`, `DELETE /users/delete?id=` on a controller declared with `DataController(Table, DefaultRoutes.All, Controller("/users"))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" crud +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- The delete route answers an empty body, not `0`, when nothing matched; the journey checks the status only. +- `Unregistered Database Table` is not driven: see the README table. diff --git a/.agents/skills/verify-interface/features/filters.md b/.agents/skills/verify-interface/features/filters.md new file mode 100644 index 0000000..ee47d2e --- /dev/null +++ b/.agents/skills/verify-interface/features/filters.md @@ -0,0 +1,44 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/default-filter.ts + - src/internal/filters.ts + - src/internal/list.ts + - src/internal/meta-records.ts +docs: + - docs/8.filters.md +journeys: + - default-filter + - custom-filters + - filter-details + - route-filters-list-only +--- + +# filters + +A list request filters rows through `@Filter` query parameters. + +## Sub-features + +- `default-filter`: `filter_=` and `:` with `eq`, `ne`, `gt`, `ge`, `lt`, `le`, several fields at once, the `eq:` prefix for a value that holds a colon, a parameter that names no filter is ignored, and an unknown operator answers the 400 message that lists the operators. +- `custom-filters`: a custom function parses its own value (numbers with `gt`/`ge`/`lt`, the same field twice is an AND, `Invalid number` is a 400), a domain filter escapes its value and refuses other operators, and a mandatory route filter wins on its field while the request filters on the other fields still apply. +- `filter-details`: an indexed field filtered twice applies both filters, a filter function receives the controller as `context.this`, a custom filter with `useIndex` unset runs its function, and the default filter compares strings. +- `route-filters-list-only`: a route's forced `filters` constrain the list and not `get`. + +## How to get to it (user POV) + +- `GET /users/list?filter_status=active&filter_role=ne:admin`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" filters +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- Whether an equality filter reads through the index is not observable from the answers: see the README table. diff --git a/.agents/skills/verify-interface/features/foreign.md b/.agents/skills/verify-interface/features/foreign.md new file mode 100644 index 0000000..5512cf7 --- /dev/null +++ b/.agents/skills/verify-interface/features/foreign.md @@ -0,0 +1,49 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/foreign.ts + - src/internal/list.ts + - src/internal/table-reference.ts + - src/internal/tables.ts + - src/internal/validation.ts +docs: + - docs/7.foreign-keys.md +journeys: + - foreign-resolve + - foreign-raw + - issue-93-multi-foreign + - no-foreign-strings + - foreign-declaration-throws + - foreign-unplucked +--- + +# foreign + +A field that holds a key of another table answers the record it points to. + +## Sub-features + +- `foreign-resolve`: `@Foreign` answers the referenced record in place of the key: with `pluck`, through a secondary `index`, by table name, across schemas, and as an array (`multi`); a key that matches nothing answers `null`. +- `foreign-raw`: the `noForeign` route option answers the raw keys of single references on a get and on a list. +- `issue-93-multi-foreign`: fails on purpose until [#93](https://github.com/AntelopeJS/interface-data-api/issues/93) is fixed: a multi reference answers `null` for a missing element and raw ids with `noForeign`. +- `no-foreign-strings`: the Get `noForeign` option skips resolving for any non-empty string, `"false"` included, and not for `""` or when absent; the List option is a boolean. +- `foreign-declaration-throws`: `@Foreign` throws at declaration for a schema mismatch between the class and the `schema` parameter, and for a string table name with no class registered in that schema. +- `foreign-unplucked`: a foreign key without `pluck` answers the whole target row, including fields the target controller's `@Access` hides. + +## How to get to it (user POV) + +- `GET /orders/get?id=o1` on a controller whose `userId` field is declared `@Foreign(User, undefined, false, ["_id", "name", "email"])`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" foreign +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- A missing element of a multi reference answers `{}` and `noForeign` spreads its ids into character-index objects: [#93](https://github.com/AntelopeJS/interface-data-api/issues/93), driven by `issue-93-multi-foreign`. diff --git a/.agents/skills/verify-interface/features/guards.md b/.agents/skills/verify-interface/features/guards.md new file mode 100644 index 0000000..afe9e29 --- /dev/null +++ b/.agents/skills/verify-interface/features/guards.md @@ -0,0 +1,43 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/validation.ts +docs: + - docs/3.routes.md +journeys: + - get-guard + - guard-unlocked + - amend-body + - custom-route + - guard-semantics +--- + +# guards + +A module guards a read, amends a body or adds a route beyond CRUD. + +## Sub-features + +- `get-guard`: `DefaultRoutes.WithGetGuard` denies a read by throwing (the stored discriminator never appears in the answer), a missing row answers 404 without the guard, and a plain `get` route is not guarded. +- `guard-unlocked`: a `WithGetGuard` guard on `/articles/translated` reads the localized `title` unlocked with the request's `x-content-language`: the English read passes it, a German read finds no title and answers the guard's 404, and the plain `get` answers both. +- `amend-body`: a route that wraps `new`, parses the body with `Validation.ParseBody` and stamps a field stores the stamped value, while the plain `new` stores what was sent. +- `custom-route`: a route declared with `func`, `args` and `method` is served next to the defaults. +- `guard-semantics`: `WithGetGuard` allows the read on a normal return even of `false`, runs with the controller as `this` and the parsed Get parameters, runs for a stored row, and is skipped for a missing one (404). + +## How to get to it (user POV) + +- `GET /documents/invoice?id=x2` on a route built with `DefaultRoutes.WithGetGuard`; `POST /documents/stamped`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" guards +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- The guard-throws-`HTTPResult` and `assert` forms answer the same; the journey uses `assert`. diff --git a/.agents/skills/verify-interface/features/joined.md b/.agents/skills/verify-interface/features/joined.md new file mode 100644 index 0000000..70db677 --- /dev/null +++ b/.agents/skills/verify-interface/features/joined.md @@ -0,0 +1,41 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/deferred-joined-stream.ts + - src/internal/joined.ts + - src/internal/list.ts + - src/internal/table-reference.ts + - src/internal/tables.ts +docs: + - docs/10.joined.md +journeys: + - joined-read + - joined-access +--- + +# joined + +A list or get answers one field of another table at the top level of the row. + +## Sub-features + +- `joined-read`: `@Joined` flattens a remote field onto the row (same schema and across schemas), a missing remote row joins as `null`, the list sorts and filters on the joined value in the database, and the field is read-only: an edit that sends it stores nothing. +- `joined-access`: an `@Access` written above `@Joined` replaces its read-only mode. + +## How to get to it (user POV) + +- `GET /books/list?sortKey=authorName` on a controller whose `authorName` is `@Joined({ table: Author, localKey: "authorId", remoteField: "name" })`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" joined +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- The order of the deferred lookups of `Query.ListPage` is not driven: see the README table. diff --git a/.agents/skills/verify-interface/features/listing.md b/.agents/skills/verify-interface/features/listing.md new file mode 100644 index 0000000..bdf6063 --- /dev/null +++ b/.agents/skills/verify-interface/features/listing.md @@ -0,0 +1,44 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/extractors.ts + - src/internal/list.ts + - src/internal/meta-records.ts + - src/internal/read-properties.ts +docs: + - docs/6.listable.md +journeys: + - pluck-modes + - pagination + - sorting + - issue-92-getter-mode +--- + +# listing + +A client pages, sorts and narrows the rows of a table through `GET /resource/list`. + +## Sub-features + +- `pluck-modes`: the default list mode answers the listable fields with read access, a `detailed` mode answers its own fields and does not inherit the default ones, a field can be left out of a mode, a field that is not listable is still read by `get`, and a getter lists the database fields it reads. +- `pagination`: a list answers `results`, `total`, `offset` and `limit`; `limit` and `offset` page the rows, the last page is short, the default is 10 and a route without `maxPage` caps `limit` at 100. +- `sorting`: `sortKey` and `sortDirection` sort on indexed and non-indexed `@Sortable` fields, any other direction sorts ascending, and a field without `@Sortable` answers `Field is not sortable.`. +- `issue-92-getter-mode`: fails on purpose until [#92](https://github.com/AntelopeJS/interface-data-api/issues/92) is fixed: a getter that a list mode does not declare is left out of that mode. + +## How to get to it (user POV) + +- `GET /users/list?limit=2&offset=1&sortKey=name&sortDirection=desc`, `GET /users/detailed`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" listing +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- `/people/brief` lists a getter that only the default mode declares: [#92](https://github.com/AntelopeJS/interface-data-api/issues/92), driven by `issue-92-getter-mode`. diff --git a/.agents/skills/verify-interface/features/metadata.md b/.agents/skills/verify-interface/features/metadata.md new file mode 100644 index 0000000..42abfc7 --- /dev/null +++ b/.agents/skills/verify-interface/features/metadata.md @@ -0,0 +1,51 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/field-types.ts + - src/internal/filters.ts + - src/internal/foreign.ts + - src/internal/meta-records.ts + - src/internal/tables.ts + - src/internal/validation.ts +docs: + - docs/4.access-rights.md + - docs/2.data-controllers.md +journeys: + - effective-modes + - declaration-errors + - exports-runtime + - consumer-compiles + - derived-own-model + - foreign-target-meta +--- + +# metadata + +A module author reads field metadata and declares controllers with the decorators. + +## Sub-features + +- `effective-modes`: `GetEffectiveMode` answers the mode each action applies for a read-write field with an edit override, a read-only field with a new override, a write-only field and a read-only field. +- `declaration-errors`: `DataController` throws `Non-existent Database Schema` for a table class whose schema is not registered. +- `exports-runtime`: the packed `metadata` entry exports `AccessMode` as 1, 2 and 3 and `FilterValuesOf`, which reads a single filter and a list of filters as a list. +- `consumer-compiles`: a module written from the docs, with every decorator and route helper, compiles in strict mode against the packed declarations. +- `derived-own-model`: a controller derived from another that declares its own `@ModelReference` reads its own model; one that declares none inherits the parent's. +- `foreign-target-meta`: `@Foreign` given the target controller's metadata resolves the target's `@Joined` fields. + +## How to get to it (user POV) + +- `GetEffectiveMode(meta.fields.role, "edit")` from `@antelopejs/interface-data-api/metadata`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" metadata +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- `Unregistered Database Table` is not driven: it needs a registered schema without the table class, and `@RegisterTable` registers the table. diff --git a/.agents/skills/verify-interface/features/modifiers.md b/.agents/skills/verify-interface/features/modifiers.md new file mode 100644 index 0000000..0c22478 --- /dev/null +++ b/.agents/skills/verify-interface/features/modifiers.md @@ -0,0 +1,37 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/list.ts + - src/internal/prototypes.ts + - src/internal/validation.ts +docs: + - docs/9.modifiers.md +journeys: + - localized-key +--- + +# modifiers + +A modifier that needs a per-request value takes it from a controller field. + +## Sub-features + +- `localized-key`: `@ModifierKey(LocalizationModifier)` on a field read from the `x-content-language` header selects the language a write stores and a read answers: English on create, French on edit, both kept in MongoDB, an edit without the title keeps both, and a list sorts on the title in the request locale, ascending and descending. + +## How to get to it (user POV) + +- `POST /articles/new` with `x-content-language: en`, then `PUT /articles/edit?id=` with `fr`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" modifiers +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- The sort on a fallback locale is not driven: the modifier that defines it is configured outside this package. diff --git a/.agents/skills/verify-interface/features/route-options.md b/.agents/skills/verify-interface/features/route-options.md new file mode 100644 index 0000000..4cee771 --- /dev/null +++ b/.agents/skills/verify-interface/features/route-options.md @@ -0,0 +1,49 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/extractors.ts + - src/internal/list.ts + - src/internal/tables.ts +docs: + - docs/3.routes.md +journeys: + - option-overrides + - list-unknown + - issue-90-edit-index + - no-mandatory-strings + - no-pluck-strings + - no-model-500 + - option-beats-query +--- + +# route-options + +A route is customized with `DefaultRoutes.WithOptions`; its options win over the query string. + +## Sub-features + +- `option-overrides`: `maxPage` caps the `limit` of a query and is the page size without one, `noMandatory` as a route option lets a mandatory field be cleared while the plain route refuses it, any non-empty string (`"false"` included) turns the option on, `index` reads through a secondary index, `noPluck` still answers only readable fields, and a `pluckMode` no field declares answers `No fields found for pluckMode 'nothing'`. +- `list-unknown`: a list route with `noPluck` answers 200 for a `pluckMode` no field declares, and a URL no route matches answers 404. +- `issue-90-edit-index`: fails on purpose until [#90](https://github.com/AntelopeJS/interface-data-api/issues/90) is fixed: an `edit` with the `index` option updates the row the index found. +- `no-mandatory-strings`: `noMandatory` skips the mandatory check for any non-empty string, `"false"` included, on `edit` and on `new`, and not for `""` or when absent. +- `no-pluck-strings`: `noPluck` skips the pluck-mode check only when true. +- `no-model-500`: a controller whose model reference holds no model answers 500. +- `option-beats-query`: a route option beats a query parameter of the same name, nested `WithOptions` keep the inner options and endpoint, and the key name is not served when an endpoint renames it. + +## How to get to it (user POV) + +- `DefaultRoutes.WithOptions(DefaultRoutes.List, { maxPage: 3 })` under a key of the definition object, then `GET /users/capped?limit=50`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" route-options +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- A query parameter named like a route option is ignored; `noMandatory` is not read from the query at all. diff --git a/.agents/skills/verify-interface/features/smoke.md b/.agents/skills/verify-interface/features/smoke.md new file mode 100644 index 0000000..43e2425 --- /dev/null +++ b/.agents/skills/verify-interface/features/smoke.md @@ -0,0 +1,34 @@ +--- +covers: +docs: + - README.md +journeys: + - every-function + - smoke-roundtrip +--- + +# smoke + +The fixed journey every verdict runs: the exports answer through core, and one row goes through the four write and read routes. + +## Sub-features + +- `every-function`: the packed interface loads in `ajs project dev` next to a stub provider and a consumer; the pack walks the declared exports and the `interface.calls` hooks of `verify.config.ts` call each plain function through core and state what it must return (the seventeen functions of the package entry: `DataController`, `GetDataControllerMeta`, `RegisterDataController`, `DefaultRoutes.WithOptions`, `DefaultRoutes.WithGetGuard` and the ten route functions). The package has no proxy, so the stub provider has nothing to implement. +- `smoke-roundtrip`: a user is created through `POST /users/new`, read through `GET /users/get`, edited through `PUT /users/edit`, deleted through `DELETE /users/delete` and gone on the next read, with the consumer, `@antelopejs/api` and `@antelopejs/mongodb` running in `ajs project dev`. + +## How to get to it (user POV) + +- A module imports `@antelopejs/interface-data-api` and declares one data controller; the project loads an API module and a database module. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" smoke +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- The smoke feature covers no glob, so it never marks a file as mapped. +- `every-function` covers the hooks of `verify.config.ts`; a new export of the package entry needs a hook there, or the journey fails on it. diff --git a/.agents/skills/verify-interface/features/validation.md b/.agents/skills/verify-interface/features/validation.md new file mode 100644 index 0000000..925161f --- /dev/null +++ b/.agents/skills/verify-interface/features/validation.md @@ -0,0 +1,46 @@ +--- +covers: + - src/index.ts + - src/components.ts + - src/metadata.ts + - src/internal/guards.ts + - src/internal/validation.ts +docs: + - docs/5.validators.md +journeys: + - mandatory-and-validators + - parsed-values + - error-codes + - empty-forms + - mandatory-before-validators + - holey-body +--- + +# validation + +`@Mandatory`, `@Validator` and `@Optional` check the body of `new` and `edit`. + +## Sub-features + +- `mandatory-and-validators`: a body missing mandatory fields answers `{ code: "missing_mandatory_fields", fields }` with every field, the mandatory check runs before the validators, invalid values answer `{ code: "invalid_field_types", fields }` with every failing field (a sync regex, a range and an async validator), a refused write stores nothing, an edit keeps a mandatory field absent from the body and refuses one cleared with `""` or `null`. +- `parsed-values`: a validator that returns a zod-style parse result makes `new` and `edit` store the parsed `Date`, and a failed parse answers the invalid-types body. +- `error-codes`: the packed `components` entry exports `Validation.MISSING_MANDATORY_FIELDS` and `Validation.INVALID_FIELD_TYPES` with the codes the bodies carry. +- `empty-forms`: `edit` accepts a mandatory field absent and refuses it cleared with `null`, `""` or `[]`; `{}` is not a clearing value. +- `mandatory-before-validators`: the mandatory check answers before any validator, on `new` and on `edit`, with bodies that trip both. +- `holey-body`: a parsed body with an `undefined` array element answers 400 and writes nothing. + +## How to get to it (user POV) + +- `POST /users/new` with a body that lacks `email`, or holds `age: 500`, or `nickname: "taken"`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation +``` + +Each journey compiles the consumer in `fixture/shop.ts.txt`, starts `ajs project dev` with `@antelopejs/api` and `@antelopejs/mongodb` against a Docker MongoDB, drives the routes with curl and reads the writes back with `mongosh`. + +## Gotchas + +- Clearing a mandatory field with `[]` on `edit` is documented and not driven: see the README table. diff --git a/.agents/skills/verify-interface/fixture/shop.ts.txt b/.agents/skills/verify-interface/fixture/shop.ts.txt new file mode 100644 index 0000000..93a4171 --- /dev/null +++ b/.agents/skills/verify-interface/fixture/shop.ts.txt @@ -0,0 +1,899 @@ +/** + * The consumer module of the verification journeys: data controllers over a small shop schema, written the way + * the docs of this package show them. A journey compiles it against the packed declarations and loads it in + * `ajs project dev` next to `@antelopejs/api` and `@antelopejs/mongodb`. + * + * @packageDocumentation + */ +import { Context, Controller, Parameter } from "@antelopejs/interface-api"; +import type { RequestContext } from "@antelopejs/interface-api"; +import { assert } from "@antelopejs/interface-api-util"; +import { + BasicDataModel, + Field, + Index, + LocalizationModifier, + Localized, + Model, + RegisterSchema, + RegisterTable, + Relation, + Table, +} from "@antelopejs/interface-database-decorators"; +import { DataController, DefaultRoutes, GetDataControllerMeta, RegisterDataController } from "@antelopejs/interface-data-api"; +import type { DataControllerInstance } from "@antelopejs/interface-data-api"; +import { Parameters, Validation } from "@antelopejs/interface-data-api/components"; +import { + Access, + AccessMode, + Computed, + Filter, + Foreign, + GetEffectiveMode, + Joined, + Listable, + Mandatory, + ModelReference, + ModifierKey, + Optional, + Sortable, + Validator, +} from "@antelopejs/interface-data-api/metadata"; +import type { FilterFunction } from "@antelopejs/interface-data-api/metadata"; +import { z } from "zod"; + +const SCHEMA = "verify"; +const AUTH_SCHEMA = "verify_auth"; + +const COMPARISONS = ["eq", "ne", "gt", "ge", "lt", "le"] as const; +type Comparison = (typeof COMPARISONS)[number]; + +function isComparison(mode: string): mode is Comparison { + return COMPARISONS.some((comparison) => comparison === mode); +} + +/** The numeric filter of docs/8.filters.md. */ +const numberFilter: FilterFunction> = (_context, proxy, _key, raw, mode) => { + const value = Number(raw); + assert(raw.trim() !== "" && Number.isFinite(value), 400, "Invalid number"); + assert(isComparison(mode), 400, "Invalid comparison"); + return proxy[mode](value); +}; + +/** The email-domain filter of docs/8.filters.md: equality only, with the domain escaped. */ +const emailDomainFilter: FilterFunction> = (_context, proxy, _key, raw, mode) => { + assert(mode === "eq", 400, "Only equality is supported for email domains"); + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return proxy.match(`@${escaped}$`); +} + +const EMAIL = /^[\w](\.?[\w-]+)*@(?:[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$/; +const startsAtSchema = z.coerce.date(); + +@RegisterTable("accounts", AUTH_SCHEMA) +class Account extends Table { + declare _id: string; + + @Field("string") + declare login: string; +} +class AccountModel extends BasicDataModel(Account, "accounts") {} + +@RegisterTable("users", SCHEMA) +class User extends Table { + declare _id: string; + + @Index() + @Field("string") + declare email: string; + + @Field("string") + declare name: string; + + @Field("string") + declare password: string; + + @Field("string") + declare role: string; + + @Field("number") + declare age: number; + + @Field("string") + declare status: string; + + @Field("string") + declare createdAt: string; + + @Field("string") + declare biography: string; + + @Field("string") + declare internalNotes: string; + + @Field("string") + declare nickname: string; + + @Field("string") + declare notes: string; + + @Field("string") + declare secretNote: string; + + @Field("date") + declare startsAt: Date; +} +class UserModel extends BasicDataModel(User, "users") {} + +const userDeclarationErrors = (): string[] => { + const messages: string[] = []; + try { + DataController(class Bogus {}, {}, Controller("/bogus")); + messages.push("declared"); + } catch (error) { + messages.push(error instanceof Error ? error.message : "thrown"); + } + try { + class Unheld extends Author {} + DataController(Unheld, {}, Controller("/unheld")); + messages.push("declared"); + } catch (error) { + messages.push(error instanceof Error ? error.message : "thrown"); + } + return messages; +}; + +const foreignDeclarationErrors = (): string[] => { + const messages: string[] = []; + try { + class Mismatch extends DataController(Author, {}, Controller("/mismatch")) { + @Access(AccessMode.ReadOnly) + @Foreign(Account, undefined, false, undefined, SCHEMA) + declare owner: Account; + } + messages.push(Mismatch.name ? "declared" : "declared"); + } catch (error) { + messages.push(error instanceof Error ? error.message : "thrown"); + } + try { + class Ghost extends DataController(Author, {}, Controller("/ghost")) { + @Access(AccessMode.ReadOnly) + @Foreign("ghost", undefined, false, undefined, AUTH_SCHEMA) + declare owner: Account; + } + messages.push(Ghost.name ? "declared" : "declared"); + } catch (error) { + messages.push(error instanceof Error ? error.message : "thrown"); + } + return messages; +}; + +const UserExtras = { + quickEdit: DefaultRoutes.WithOptions(DefaultRoutes.Edit, { noMandatory: "true" }), + falseSkip: DefaultRoutes.WithOptions(DefaultRoutes.Edit, { noMandatory: "false" }), + emptySkip: DefaultRoutes.WithOptions(DefaultRoutes.Edit, { noMandatory: "" }), + newSkip: DefaultRoutes.WithOptions(DefaultRoutes.New, { noMandatory: "true" }), + newSkipFalse: DefaultRoutes.WithOptions(DefaultRoutes.New, { noMandatory: "false" }), + newSkipEmpty: DefaultRoutes.WithOptions(DefaultRoutes.New, { noMandatory: "" }), + detailed: DefaultRoutes.WithOptions(DefaultRoutes.List, { pluckMode: "detailed" }), + minimal: DefaultRoutes.WithOptions(DefaultRoutes.List, { pluckMode: "minimal" }), + capped: DefaultRoutes.WithOptions(DefaultRoutes.List, { maxPage: 3 }), + activeOnly: DefaultRoutes.WithOptions(DefaultRoutes.List, { filters: { status: ["active", "eq"] } }), + unplucked: DefaultRoutes.WithOptions(DefaultRoutes.List, { noPluck: true }), + missingMode: DefaultRoutes.WithOptions(DefaultRoutes.List, { pluckMode: "nothing" }), + byEmail: DefaultRoutes.WithOptions(DefaultRoutes.Get, { index: "email" }), + search: { + func: async () => ({ results: [] }), + args: [Context(), Parameters.List()], + method: "get", + }, + modes: { + func: function (this: DataControllerInstance) { + const meta = GetDataControllerMeta(this); + const answer: Record> = {}; + for (const field of ["role", "age", "password", "_id"]) { + answer[field] = {}; + for (const action of ["get", "list", "new", "edit"]) { + answer[field][action] = GetEffectiveMode(meta.fields[field], action); + } + } + return answer; + }, + args: [Context()], + method: "get", + }, + declarations: { + func: userDeclarationErrors, + args: [], + method: "get", + }, +}; + +@RegisterDataController() +class _UserAPI extends DataController(User, { ...DefaultRoutes.All, ...UserExtras }, Controller("/users")) { + @ModelReference() + @Model(UserModel) + declare userModel: UserModel; + + @Listable() + @Listable(true, "minimal") + @Access(AccessMode.ReadOnly) + @Sortable() + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Mandatory("new", "edit") + @Validator((value) => typeof value === "string" && EMAIL.test(value)) + @Filter(emailDomainFilter, false) + declare email: string; + + @Listable() + @Listable(true, "detailed") + @Listable(false, "minimal") + @Access(AccessMode.ReadWrite) + @Mandatory("new") + @Sortable({ noIndex: true }) + @Filter() + declare name: string; + + @Access(AccessMode.WriteOnly) + @Mandatory("new") + declare password: string; + + @Listable() + @Access(AccessMode.ReadWrite, { edit: AccessMode.ReadOnly }) + @Filter() + declare role: string; + + @Listable() + @Access(AccessMode.ReadOnly, { new: AccessMode.ReadWrite }) + @Validator((value) => typeof value === "number" && value >= 0 && value <= 120) + @Sortable({ noIndex: true }) + @Filter(numberFilter, false) + declare age: number; + + @Listable() + @Access(AccessMode.ReadWrite) + @Mandatory("edit") + @Filter() + declare status: string; + + @Listable() + @Access(AccessMode.ReadOnly) + declare createdAt: string; + + @Access(AccessMode.ReadOnly) + declare biography: string; + + @Listable(true, "detailed") + @Access(AccessMode.ReadOnly) + declare internalNotes: string; + + @Access(AccessMode.ReadWrite) + @Validator(async (value) => typeof value === "string" && value !== "taken") + declare nickname: string; + + @Access(AccessMode.ReadWrite) + @Optional() + declare notes: string; + + @Access(AccessMode.ReadWrite) + @Validator((value) => startsAtSchema.safeParseAsync(value)) + declare startsAt: Date; + + @Listable() + declare secretNote: string; +} + +@RegisterDataController() +class _PeopleAPI extends DataController( + User, + { get: DefaultRoutes.Get, list: DefaultRoutes.List, brief: DefaultRoutes.WithOptions(DefaultRoutes.List, { pluckMode: "brief" }) }, + Controller("/people"), +) { + @ModelReference() + @Model(UserModel) + declare userModel: UserModel; + + @Listable() + @Listable(true, "brief") + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadOnly) + declare name: string; + + @Access(AccessMode.ReadOnly) + declare email: string; + + @Listable(["name", "email"]) + @Access(AccessMode.ReadOnly) + get fullName(): string { + return `${this.name} <${this.email}>`; + } +} + +@RegisterDataController() +class _StaffAPI extends DataController(User, { get: DefaultRoutes.Get }, Controller("/staff", _UserAPI)) { + @Access(AccessMode.ReadOnly) + declare name: string; +} + +@RegisterTable("order_items", SCHEMA) +class OrderItem extends Table { + declare _id: string; + + @Field("string") + declare name: string; + + @Field("number") + declare price: number; +} +class OrderItemModel extends BasicDataModel(OrderItem, "order_items") {} + +@RegisterTable("orders", SCHEMA) +class Order extends Table { + declare _id: string; + + @Field("string") + @Relation({ to: () => User }) + declare userId: string; + + @Field("string") + declare userEmail: string; + + @Field("string") + declare ownerLogin: string; + + @Field("string") + declare tagged: string; + + @Field(["string"]) + @Relation({ to: () => OrderItem, many: true }) + declare items: string[]; + + @Field("string") + declare byEmail: string; + + @Field(["string"]) + declare itemsAll: string[]; + + @Field(["string"]) + declare labels: string[]; + + @Field("number") + declare total: number; +} +class OrderModel extends BasicDataModel(Order, "orders") {} + +@RegisterDataController() +class _OrderAPI extends DataController( + Order, + { ...DefaultRoutes.All, raw: DefaultRoutes.WithOptions(DefaultRoutes.Get, { noForeign: "true" }), rawList: DefaultRoutes.WithOptions(DefaultRoutes.List, { noForeign: true }), rawFalse: DefaultRoutes.WithOptions(DefaultRoutes.Get, { noForeign: "false" }), rawEmpty: DefaultRoutes.WithOptions(DefaultRoutes.Get, { noForeign: "" }), listForeignFalse: DefaultRoutes.WithOptions(DefaultRoutes.List, { noForeign: false }) }, + Controller("/orders"), +) { + @ModelReference() + @Model(OrderModel) + declare orderModel: OrderModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign(User, undefined, false, ["_id", "name", "email"]) + declare userId: User; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign(User, "email", false, ["name"]) + declare userEmail: User; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign(Account, undefined, false, undefined, AUTH_SCHEMA) + declare ownerLogin: Account; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign("users", undefined, false, ["name"]) + declare tagged: User; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign(OrderItem, undefined, true, ["name", "price"]) + declare items: OrderItem[]; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign(User, "email") + declare byEmail: User; + + @Listable() + @Access(AccessMode.ReadWrite) + @Foreign(OrderItem, undefined, true) + declare itemsAll: OrderItem[]; + + @Listable() + @Access(AccessMode.ReadWrite) + declare labels: string[]; + + @Listable() + @Access(AccessMode.ReadWrite) + declare total: number; +} + +@RegisterTable("authors", SCHEMA) +class Author extends Table { + declare _id: string; + + @Field("string") + declare name: string; +} + +class AuthorModel extends BasicDataModel(Author, "authors") {} + +@RegisterDataController() +class _AuthorAPI extends DataController( + Author, + { + get: DefaultRoutes.Get, + list: DefaultRoutes.List, + new: DefaultRoutes.New, + quick: DefaultRoutes.WithOptions(DefaultRoutes.Edit, { noMandatory: "true" }, "patch"), + }, + Controller("/authors"), +) { + @ModelReference() + @Model(AuthorModel) + declare authorModel: AuthorModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Mandatory("new", "edit") + declare name: string; +} + +@RegisterTable("books", SCHEMA) +class Book extends Table { + declare _id: string; + + @Field("string") + @Relation({ to: () => Author }) + declare authorId: string; + + @Field("string") + declare title: string; + + @Field("string") + declare accountId: string; +} +class BookModel extends BasicDataModel(Book, "books") {} + +@RegisterDataController() +class _BookAPI extends DataController(Book, DefaultRoutes.All, Controller("/books")) { + @ModelReference() + @Model(BookModel) + declare bookModel: BookModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + declare authorId: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Sortable({ noIndex: true }) + declare title: string; + + @Listable() + @Sortable() + @Filter() + @Joined({ table: Author, localKey: "authorId", remoteField: "name" }) + declare authorName: string; + + @Listable() + @Joined({ table: "accounts", localKey: "accountId", remoteField: "login", schema: AUTH_SCHEMA }) + declare accountLogin: string; +} + +@RegisterTable("groups", SCHEMA) +class Group extends Table { + declare _id: string; + + @Field("string") + declare name: string; +} +class GroupModel extends BasicDataModel(Group, "groups") {} + +@RegisterTable("devices", SCHEMA) +class Device extends Table { + declare _id: string; + + @Index() + @Field("string") + @Relation({ to: () => Group }) + declare group_id: string; +} + +@RegisterDataController() +class _GroupAPI extends DataController(Group, DefaultRoutes.All, Controller("/groups")) { + @ModelReference() + @Model(GroupModel) + declare groupModel: GroupModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Sortable({ noIndex: true }) + declare name: string; + + @Listable() + @Computed((row, db) => db.table("devices").filter((device) => device.key("group_id").eq(row.key("_id"))).count(), { default: 0 }) + @Sortable() + @Filter(numberFilter, false) + declare devices_count: number; + + @Listable() + @Computed((row) => row.key("name").concat("!")) + declare display_name: string; +} + +@RegisterTable("articles", SCHEMA) +class Article extends Table.with(LocalizationModifier) { + declare _id: string; + + @Localized() + @Field("string") + declare title: string; +} +class ArticleModel extends BasicDataModel(Article, "articles") {} + +/** The get guard of docs/3.routes.md on a modifier-held field: an article answers only in a language it has a title in. */ +const translatedGet = DefaultRoutes.WithGetGuard(async function (_ctx, current) { + assert(typeof current.title === "string", 404, "Not Found"); +}); + +@RegisterDataController() +class _ArticleAPI extends DataController(Article, { ...DefaultRoutes.All, translated: translatedGet }, Controller("/articles")) { + @ModelReference() + @Model(ArticleModel) + declare articleModel: ArticleModel; + + @Parameter("x-content-language", "header") + @ModifierKey(LocalizationModifier) + declare language: string; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Sortable({ noIndex: true }) + declare title: string; +} + +@RegisterTable("documents", SCHEMA) +class DocumentRow extends Table { + declare _id: string; + + @Field("string") + declare title: string; + + @Field("string") + declare documentType: string; + + @Field("string") + declare source: string; +} +class DocumentModel extends BasicDataModel(DocumentRow, "documents") {} + +const invoiceGet = DefaultRoutes.WithGetGuard(async function (_ctx, current) { + assert(current.documentType === "invoice", 404, "Not Found"); +}); + +const stampedNew = { + ...DefaultRoutes.New, + func: function (this: DataControllerInstance, ctx: RequestContext, params: Parameters.NewParameters, body: Buffer) { + const parsed = Validation.ParseBody(body); + parsed.source = "import"; + return DefaultRoutes.New.func.call(this, ctx, params, parsed); + }, +}; + +const unstorableNew = { + ...DefaultRoutes.New, + func: function (this: DataControllerInstance, ctx: RequestContext, params: Parameters.NewParameters, body: Buffer) { + const parsed = Validation.ParseBody(body); + Reflect.set(parsed, "title", () => 1); + return DefaultRoutes.New.func.call(this, ctx, params, parsed); + }, +}; + +@RegisterDataController() +class _DocumentAPI extends DataController( + DocumentRow, + { ...DefaultRoutes.All, unstorable: unstorableNew, invoice: DefaultRoutes.WithOptions(invoiceGet, { noForeign: "true" }), stamped: stampedNew }, + Controller("/documents"), +) { + @ModelReference() + @Model(DocumentModel) + declare documentModel: DocumentModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadWrite) + declare title: string; + + @Listable() + @Access(AccessMode.ReadWrite) + declare source: string; +} + +@RegisterTable("probes", SCHEMA) +class ProbeRow extends Table { + declare _id: string; + + @Index() + @Field("string") + declare kind: string; + + @Field("string") + declare title: string; + + @Field("string") + declare origin: string; + + @Field("string") + declare trace: string; + + @Index() + @Field("string") + declare tag: string; + + @Field("number") + declare score: number; +} +class ProbeModel extends BasicDataModel(ProbeRow, "probes") {} + +const lenientGuard = DefaultRoutes.WithGetGuard(function (this: DataControllerInstance) { + return Promise.resolve(false) as unknown as Promise; +}); + +const ownedGuard = DefaultRoutes.WithGetGuard(function (this: DataControllerInstance) { + assert(this instanceof _ProbeAPI, 500, "guard without the controller"); +}); + +const paramsGuard = DefaultRoutes.WithGetGuard(function (_ctx, _current, params) { + assert(params.id === "p1", 500, "guard without the parsed parameters"); +}); + +const alwaysThrowsGuard = DefaultRoutes.WithGetGuard(function () { + assert(false, 500, "the guard ran"); +}); + +const titleThisFilter: FilterFunction> = (context, proxy, _key, raw) => + proxy.eq(context.this instanceof _ProbeAPI ? raw : "__no_controller__"); + +const upperTagFilter: FilterFunction> = (_context, proxy, _key, raw) => proxy.eq(raw.toUpperCase()); + +@RegisterDataController() +class _ProbeAPI extends DataController( + ProbeRow, + { + ...DefaultRoutes.All, + lenient: lenientGuard, + owned: ownedGuard, + withParams: paramsGuard, + alwaysThrows: alwaysThrowsGuard, + plain: DefaultRoutes.WithOptions(DefaultRoutes.List, { pluckMode: "nothing", noPluck: true }), + plainFalse: DefaultRoutes.WithOptions(DefaultRoutes.List, { pluckMode: "nothing", noPluck: false }), + slugEdit: DefaultRoutes.WithOptions(DefaultRoutes.Edit, { index: "tag" }), + }, + Controller("/probes"), +) { + @ModelReference() + @Model(ProbeModel) + declare probeModel: ProbeModel; + + @Listable() + @Access(AccessMode.ReadWrite) + @Filter() + declare kind: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Filter(titleThisFilter, false) + declare title: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Filter(upperTagFilter) + declare tag: string; + + @Listable() + @Access(AccessMode.ReadWrite) + @Filter() + declare score: number; + + @Listable() + @Access(AccessMode.ReadOnly) + origin = "class-default"; + + @Listable() + @Access(AccessMode.ReadOnly) + declare trace: string; + + @Access(AccessMode.WriteOnly) + set mark(value: string | undefined) { + const row: unknown = Reflect.get(this, "table"); + if (typeof row === "object" && row !== null) Reflect.set(row, "trace", value === undefined ? "setter-ran-for-absent" : `mark:${value}`); + } +} + +const overrideModes = { + func: function (this: DataControllerInstance) { + const meta = GetDataControllerMeta(this); + const answer: Record> = {}; + for (const field of ["email", "name", "notes", "nickname"]) { + answer[field] = {}; + for (const action of ["get", "list", "new", "edit"]) answer[field][action] = GetEffectiveMode(meta.fields[field], action); + } + return answer; + }, + args: [Context()], + method: "get", +}; + +@RegisterDataController() +class _OverridesAPI extends DataController(User, { get: DefaultRoutes.Get, list: DefaultRoutes.List, new: DefaultRoutes.New, edit: DefaultRoutes.Edit, modes: overrideModes }, Controller("/overrides")) { + @ModelReference() + @Model(UserModel) + declare userModel: UserModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Listable() + @Access(AccessMode.ReadOnly, { get: AccessMode.WriteOnly }) + declare email: string; + + @Listable() + @Access(AccessMode.ReadOnly, { list: AccessMode.WriteOnly }) + declare name: string; + + @Listable() + @Access(AccessMode.WriteOnly, { get: AccessMode.ReadOnly }) + declare notes: string; + + @Listable() + @Access(AccessMode.WriteOnly, { list: AccessMode.ReadOnly }) + declare nickname: string; +} + +@RegisterDataController() +class _OwnModelAPI extends DataController(Author, { get: DefaultRoutes.Get }, Controller("/own-model", _UserAPI)) { + @ModelReference() + @Model(AuthorModel) + declare ownAuthorModel: AuthorModel; + + @Access(AccessMode.ReadOnly) + declare name: string; +} + +@RegisterDataController() +class _NoModelAPI extends DataController(Author, { get: DefaultRoutes.Get }, Controller("/no-model")) { + @ModelReference() + declare missingModel: AuthorModel; + + @Access(AccessMode.ReadOnly) + declare name: string; +} + +@RegisterTable("shelves", SCHEMA) +class Shelf extends Table { + declare _id: string; + + @Field("string") + @Relation({ to: () => Book }) + declare bookId: string; +} +class ShelfModel extends BasicDataModel(Shelf, "shelves") {} + +@RegisterDataController() +class _ShelfAPI extends DataController(Shelf, { get: DefaultRoutes.Get }, Controller("/shelves")) { + @ModelReference() + @Model(ShelfModel) + declare shelfModel: ShelfModel; + + @Access(AccessMode.ReadOnly) + declare _id: string; + + @Access(AccessMode.ReadOnly) + @Foreign(Book, undefined, false, ["title", "authorName"], undefined, GetDataControllerMeta(Object.create(_BookAPI.prototype))) + declare bookId: Book; +} + +@RegisterDataController() +class _OptionRoutesAPI extends DataController( + User, + { + nested: DefaultRoutes.WithOptions(DefaultRoutes.WithOptions(DefaultRoutes.List, { maxPage: 2 }), { pluckMode: "minimal" }), + sorted: DefaultRoutes.WithOptions(DefaultRoutes.List, { sortKey: "name", sortDirection: "desc" }), + nestedEdit: DefaultRoutes.WithOptions(DefaultRoutes.WithOptions(DefaultRoutes.Edit, {}, "patch2"), { noMandatory: "true" }), + foreignDeclarations: { func: foreignDeclarationErrors, args: [], method: "get" }, + }, + Controller("/option-routes", _UserAPI), +) {} + +@RegisterDataController() +class _JoinedAccessAPI extends DataController(Book, { get: DefaultRoutes.Get }, Controller("/joined-access")) { + @ModelReference() + @Model(BookModel) + declare joinedAccessModel: BookModel; + + @Access(AccessMode.ReadOnly) + declare title: string; + + @Access(AccessMode.WriteOnly) + @Joined({ table: Author, localKey: "authorId", remoteField: "name" }) + declare authorName: string; +} + +@RegisterDataController() +class _ComputedAccessAPI extends DataController(Group, { get: DefaultRoutes.Get, list: DefaultRoutes.List }, Controller("/computed-access")) { + @ModelReference() + @Model(GroupModel) + declare computedAccessModel: GroupModel; + + @Listable() + @Access(AccessMode.ReadOnly) + declare name: string; + + @Listable() + @Access(AccessMode.WriteOnly) + @Computed((row, db) => db.table("devices").filter((device) => device.key("group_id").eq(row.key("_id"))).count(), { default: 0 }) + declare devices_count: number; +} + +const holeyNew = { + ...DefaultRoutes.New, + func: function (this: DataControllerInstance, ctx: RequestContext, params: Parameters.NewParameters, body: Buffer) { + const parsed = Validation.ParseBody(body); + Reflect.set(parsed, "title", [undefined]); + return DefaultRoutes.New.func.call(this, ctx, params, parsed); + }, +}; + +@RegisterDataController() +class _HoleyDocsAPI extends DataController(DocumentRow, { holey: holeyNew }, Controller("/holey-docs")) { + @ModelReference() + @Model(DocumentModel) + declare holeyDocumentModel: DocumentModel; + + @Access(AccessMode.ReadWrite) + declare title: string; +} + +export async function construct(): Promise { + await RegisterSchema(AUTH_SCHEMA); + await RegisterSchema(SCHEMA); +} diff --git a/.agents/skills/verify-interface/harness.ts b/.agents/skills/verify-interface/harness.ts new file mode 100644 index 0000000..982483e --- /dev/null +++ b/.agents/skills/verify-interface/harness.ts @@ -0,0 +1,232 @@ +import { createServer } from "node:net"; +import { readFileSync, realpathSync } from "node:fs"; +import { join } from "node:path"; + +import { z } from "zod"; + +import { equal, exitIs, interrupted } from "@antelopejs/tooling-configs/verify"; +import type { App, Check, CommandResult, Journey, JourneyContext } from "@antelopejs/tooling-configs/verify"; + +/** The `@antelopejs/mongodb` release the package's own tests run against. */ +const MONGODB_VERSION = "1.4.4"; +const READY_POLLS = 60; +const READY_MS = 500; +const INTERFACES = [ + "@antelopejs/interface-api", + "@antelopejs/interface-api-util", + "@antelopejs/interface-core", + "@antelopejs/interface-database", + "@antelopejs/interface-database-decorators", +]; +const SHOP_SOURCE = join(import.meta.dirname, "fixture", "shop.ts.txt"); + +/** A JSON value, as the harness checks take it. */ +export type Json = Exclude[1], undefined>; + +/** What an HTTP request to the running API answered. */ +export interface Answer { + folder: string; + status: number; + body: string; + json: Json | undefined; +} + +/** The running shop: its base URL and the helpers journeys drive it with. */ +export interface Shop { + context: JourneyContext; + app: App; + base: string; + request(name: string, method: string, path: string, options?: { body?: string; headers?: string[] }): Promise; + mongo(name: string, script: string): Promise; +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address instanceof Object ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +const JSON_VALUE = z.json(); +const JSON_OBJECT = z.record(z.string(), z.json()); + +function parseJson(text: string): Json | undefined { + try { + return JSON_VALUE.parse(JSON.parse(text)); + } catch { + return undefined; + } +} + +/** The script that links the packed interface and the repository's interface copies into the shop module. */ +const LINK_SCRIPT = ` +const fs = require("node:fs"); +const path = require("node:path"); +const [, , shop, packed, repo, ...names] = process.argv; +const link = (name, target) => { + const at = path.join(shop, "node_modules", name); + fs.mkdirSync(path.dirname(at), { recursive: true }); + fs.symlinkSync(target, at, "dir"); +}; +link("@antelopejs/interface-data-api", packed); +for (const name of [...names, "zod"]) link(name, fs.realpathSync(path.join(repo, "node_modules", name))); +`; + +async function compileShop(context: JourneyContext, project: string): Promise { + const shop = join(project, "shop"); + const packed = realpathSync(join(project, "driver", "node_modules", "@antelopejs", "interface-data-api")); + context.edit("write the shop module manifest", join(shop, "package.json"), `${JSON.stringify({ + name: "verify-shop", + version: "1.0.0", + main: "shop.js", + dependencies: Object.fromEntries(INTERFACES.map((name) => [name, "*"])), + }, null, 2)}\n`); + context.edit("write the shop module source", join(shop, "shop.ts"), readFileSync(SHOP_SOURCE, "utf8")); + context.edit("write the link script", join(context.work, "link.cjs"), LINK_SCRIPT); + const linked = await context.command("link the packed interface", "node", [join(context.work, "link.cjs"), shop, packed, context.repo, ...INTERFACES]); + context.expect(linked.folder, [exitIs(linked, 0)], { critical: true }); + const compiled = await context.command("compile the shop against the packed declarations", "node", [ + join(context.repo, "node_modules", "typescript", "bin", "tsc"), + "--strict", "--skipLibCheck", "--experimentalDecorators", "--emitDecoratorMetadata", "--esModuleInterop", + "--target", "es2022", "--module", "commonjs", "--moduleResolution", "node", + "--typeRoots", join(context.repo, "node_modules", "@types"), "--types", "node", + "--outDir", shop, join(shop, "shop.ts"), + ], { timeoutMs: 180_000 }); + context.expect(compiled.folder, [{ name: "the shop compiles against the packed declarations", pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 600) }], { critical: true }); +} + +function rewriteConfig(context: JourneyContext, project: string, apiPort: number): void { + const mongo = context.service("mongo"); + const overrides = { "@antelopejs/interface-api": "api", "@antelopejs/interface-database": "mongodb" }; + const config = { + name: "verify-interface-data-api", + cacheFolder: join(context.repo, ".antelope", "cache"), + modules: { + "verify-stub": { source: { type: "local", path: "./stub" }, importOverrides: overrides }, + "verify-driver": { source: { type: "local", path: "./driver" }, importOverrides: overrides }, + mongodb: { + source: { type: "package", package: "@antelopejs/mongodb", version: MONGODB_VERSION }, + config: { url: `mongodb://${mongo.host}:${mongo.port}`, database: "verify" }, + }, + api: { + source: { type: "local", path: realpathSync(join(context.repo, "node_modules", "@antelopejs", "api")) }, + config: { publicBaseUrl: `http://127.0.0.1:${apiPort}`, servers: [{ protocol: "http", host: "127.0.0.1", port: apiPort }] }, + }, + "verify-shop": { source: { type: "local", path: "./shop" }, importOverrides: overrides }, + }, + }; + context.edit("wire the shop, the API and MongoDB into the project", join(project, "antelope.config.ts"), `export default ${JSON.stringify(config, null, 2)};\n`); +} + +/** Runs one request with curl, so the evidence holds the command, and returns its status and body. */ +async function curl(context: JourneyContext, base: string, name: string, call: { method: string; path: string; body?: string; headers?: string[] }): Promise { + const { method, path, ...options } = call; + const args = ["-sS", "-X", method, "-H", "content-type: application/json", "-w", "\\n%{http_code}"]; + for (const header of options.headers ?? []) args.push("-H", header); + if (options.body !== undefined) args.push("--data-binary", options.body); + const ran = await context.command(name, "curl", [...args, `${base}${path}`], { timeoutMs: 30_000 }); + const cut = ran.stdout.lastIndexOf("\n"); + const body = ran.stdout.slice(0, cut); + return { folder: ran.folder, status: Number(ran.stdout.slice(cut + 1)), body, json: parseJson(body) }; +} + +/** Compiles the consumer module against the packed declarations and stops. */ +export async function compileOnly(context: JourneyContext): Promise { + await compileShop(context, context.fixture()); +} + +/** Compiles the shop, starts `ajs project dev` with the API and MongoDB, waits until the routes answer, runs `use`, then stops the project by SIGINT. */ +export async function withShop(context: JourneyContext, use: (shop: Shop) => Promise): Promise { + const project = context.fixture(); + await compileShop(context, project); + const apiPort = await freePort(); + rewriteConfig(context, project, apiPort); + const app = await context.dev("ajs project dev", project); + const base = `http://127.0.0.1:${apiPort}`; + const shop: Shop = { + context, + app, + base, + request: (name, method, path, options) => curl(context, base, name, { method, path, ...options }), + mongo: (name, script) => context.native(name, "mongo", ["mongosh", "--quiet", "--eval", script]), + }; + try { + let probe = await curl(context, base, "wait for the routes", { method: "GET", path: "/users/list" }); + for (let poll = 0; poll < READY_POLLS && probe.status !== 200; poll += 1) { + await new Promise((resolve) => setTimeout(resolve, READY_MS)); + probe = await curl(context, base, "wait for the routes", { method: "GET", path: "/users/list" }); + } + context.expect(probe.folder, [{ name: "the data controllers answer once the project is up", pass: probe.status === 200, detail: base }], { critical: true }); + await use(shop); + } finally { + const stopped = await context.stop("SIGINT", app); + context.expect(stopped.folder, [interrupted(stopped)]); + } +} + +/** A check that `answer` has the HTTP `status`. */ +export function status(answer: Answer, expected: number, label = `answers ${expected}`): Check { + return { name: label, pass: answer.status === expected, detail: `status ${answer.status}: ${answer.body.slice(0, 300)}` }; +} + + +/** The value at `key` of a JSON object, or null when the key is absent. */ +export function pick(value: Json | undefined, key: string): Json { + const object = JSON_OBJECT.safeParse(value); + return object.success ? (object.data[key] ?? null) : null; +} + +/** The keys of a JSON object, sorted. */ +export function keysOf(value: Json | undefined): string[] { + const object = JSON_OBJECT.safeParse(value); + return object.success ? Object.keys(object.data).sort((a, b) => a.localeCompare(b)) : []; +} + +/** The rows of a list answer. */ +export function results(answer: Answer): Json[] { + const found = pick(answer.json, "results"); + return Array.isArray(found) ? found : []; +} + +/** A JSON string value as text, or the JSON of any other value. */ +export function text(value: Json | undefined): string { + const parsed = z.string().safeParse(value); + return parsed.success ? parsed.data : JSON.stringify(value ?? null); +} + +/** The `_id` of each row of a list answer, sorted. */ +export function ids(answer: Answer): string[] { + return results(answer).map((row) => text(pick(row, "_id"))).sort((a, b) => a.localeCompare(b)); +} + +/** One check per key: the answer's JSON object holds `expected[key]` at `key`. */ +export function holds(answer: Answer, expected: { [key: string]: Json }): Check[] { + return Object.entries(expected).map(([key, value]) => equal(`${key} in the answer`, pick(answer.json, key), value)); +} + +/** Reads rows of a collection back through mongosh, the second view of every write: `filter` and `projection` are mongosh source. */ +export async function stored(shop: Shop, name: string, collection: string, filter = "{}", projection = "{}"): Promise<{ folder: string; rows: Json[] }> { + const read = await shop.mongo(name, `JSON.stringify(db.getSiblingDB("verify").${collection}.find(${filter}, ${projection}).sort({ _id: 1 }).toArray())`); + const rows = parseJson(read.stdout.trim()); + return { folder: read.folder, rows: Array.isArray(rows) ? rows : [] }; +} + +/** Inserts documents with mongosh before a request: `script` is mongosh source. */ +export async function seed(shop: Shop, script: string): Promise { + const run = await shop.mongo("seed MongoDB", `const d = db.getSiblingDB("verify"); ${script}`); + shop.context.expect(run.folder, [exitIs(run, 0)], { critical: true }); +} + +/** A journey that drives the shop module over HTTP and reads the writes back in MongoDB. */ +export function shopJourney(title: string, run: (shop: Shop) => Promise): Journey { + return { + title, + entryPoint: "HTTP requests to the shop module's data controllers in `ajs project dev`, read back in MongoDB", + run: (context) => withShop(context, run), + }; +} diff --git a/.agents/skills/verify-interface/journeys-probe.ts b/.agents/skills/verify-interface/journeys-probe.ts new file mode 100644 index 0000000..9b1739c --- /dev/null +++ b/.agents/skills/verify-interface/journeys-probe.ts @@ -0,0 +1,353 @@ +import { equal } from "@antelopejs/tooling-configs/verify"; + +import { holds, ids, keysOf, pick, results, seed, shopJourney, status, stored, text } from "./harness.ts"; +import { BOOKS, DOCUMENTS, GROUPS, ORDERS, OVERRIDES, PROBES, USERS } from "./seeds.ts"; + +const guardSemantics = shopJourney("a get guard allows on a normal return, runs with the controller and the parsed parameters, and is skipped for a missing row", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const lenient = await shop.request("a guard that returns false", "GET", "/probes/lenient?id=p1"); + context.expect(lenient.folder, [status(lenient, 200, "a normal return allows the read, even a return value of false")]); + const owned = await shop.request("a guard that reads this", "GET", "/probes/owned?id=p1"); + context.expect(owned.folder, [status(owned, 200, "the guard runs with the controller as this")]); + const withParams = await shop.request("a guard that reads params", "GET", "/probes/withParams?id=p1"); + context.expect(withParams.folder, [status(withParams, 200, "the guard receives the parsed Get parameters")]); + const other = await shop.request("the same guard on another row", "GET", "/probes/withParams?id=p2"); + context.expect(other.folder, [status(other, 500, "the guard sees the id of the request")]); + const ran = await shop.request("a guard that always throws, on a stored row", "GET", "/probes/alwaysThrows?id=p1"); + context.expect(ran.folder, [status(ran, 500, "the guard runs for a stored row")]); + const missing = await shop.request("the same guard on a missing row", "GET", "/probes/alwaysThrows?id=nope"); + context.expect(missing.folder, [status(missing, 404, "a missing record skips the guard"), equal("the answer is the route's own", missing.body, "Not Found")]); +}); + +const newRowContents = shopJourney("new answers _id when get does not read it, starts from the class defaults and runs every writable setter", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const absent = await shop.request("new without the setter field", "POST", "/probes/new", { body: '{"kind":"c","title":"Three"}' }); + const id = text(pick(absent.json, "_id")); + context.expect(absent.folder, [status(absent, 200), equal("new answers an _id though no @Access reads it", id.length > 0, true)]); + const getBody = await shop.request("get the created row", "GET", `/probes/get?id=${id}`); + context.expect(getBody.folder, [equal("get does not read _id here", keysOf(getBody.json).includes("_id"), false)]); + const row = await stored(shop, "read the created row", "verify__probes", '{ kind: "c" }'); + context.expect(row.folder, [ + equal("the row starts from the class default of a read-only field", pick(row.rows[0] ?? null, "origin"), "class-default"), + equal("the setter ran with undefined for the absent field", pick(row.rows[0] ?? null, "trace"), "setter-ran-for-absent"), + equal("the row is the one new answered", pick(row.rows[0] ?? null, "_id"), id), + ]); + const given = await shop.request("new with the setter field", "POST", "/probes/new", { body: '{"kind":"d","title":"Four","mark":"x"}' }); + context.expect(given.folder, [status(given, 200)]); + const second = await stored(shop, "read the second created row", "verify__probes", '{ kind: "d" }'); + context.expect(second.folder, [equal("the setter ran with the sent value", pick(second.rows[0] ?? null, "trace"), "mark:x")]); +}); + +const editSetters = shopJourney("edit runs the setters of the fields present in the body and no other", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const without = await shop.request("edit without the setter field", "PUT", "/probes/edit?id=p1", { body: '{"title":"One2"}' }); + context.expect(without.folder, [status(without, 200)]); + const kept = await stored(shop, "read p1", "verify__probes", '{ _id: "p1" }'); + context.expect(kept.folder, [equal("a setter absent from the body does not run", pick(kept.rows[0] ?? null, "trace"), "seed"), equal("the title was written", pick(kept.rows[0] ?? null, "title"), "One2")]); + const withField = await shop.request("edit with the setter field", "PUT", "/probes/edit?id=p1", { body: '{"mark":"y"}' }); + context.expect(withField.folder, [status(withField, 200)]); + const ran = await stored(shop, "read p1 again", "verify__probes", '{ _id: "p1" }'); + context.expect(ran.folder, [equal("a setter present in the body runs with its value", pick(ran.rows[0] ?? null, "trace"), "mark:y"), equal("the class default does not overwrite a stored field on edit", pick(ran.rows[0] ?? null, "origin"), "seed")]); +}); + +const filterDetails = shopJourney("filters on an indexed field, with the controller as this, with no index for a custom filter, and with string values", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const twice = await shop.request("an indexed field filtered twice", "GET", "/probes/list?filter_kind=a&filter_kind=ne:a"); + context.expect(twice.folder, [status(twice, 200), equal("both filters apply: an indexed equality uses its index only when it is the only filter", pick(twice.json, "total"), 0)]); + const once = await shop.request("an indexed field filtered once", "GET", "/probes/list?filter_kind=a"); + context.expect(once.folder, [equal("a single indexed equality returns its row", pick(once.json, "total"), 1)]); + const byThis = await shop.request("a filter that reads this", "GET", "/probes/list?filter_title=Two"); + context.expect(byThis.folder, [equal("the filter context holds the controller as this", pick(byThis.json, "total"), 1)]); + const custom = await shop.request("a custom filter on an indexed field", "GET", "/probes/list?filter_tag=a"); + context.expect(custom.folder, [equal("a custom filter with useIndex unset runs its function, not an index lookup on the raw value", pick(custom.json, "total"), 1)]); + const strings = await shop.request("the default filter on a number", "GET", "/probes/list?filter_score=5"); + context.expect(strings.folder, [status(strings, 200), equal("filter values stay strings, so a stored number does not match", pick(strings.json, "total"), 0)]); +}); + +const listUnknown = shopJourney("a list route with noPluck answers for a mode no field declares, and an unknown route answers 404", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const plain = await shop.request("noPluck with a mode nobody declares", "GET", "/probes/plain"); + context.expect(plain.folder, [status(plain, 200, "noPluck skips the pluck-mode check"), equal("every row is listed", results(plain).length, 2)]); + const unknown = await shop.request("a URL no route matches", "GET", "/users/nothing"); + context.expect(unknown.folder, [status(unknown, 404)]); +}); + +const listableAlone = shopJourney("a field that is only @Listable is never answered", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const list = await shop.request("list", "GET", "/users/list"); + context.expect(list.folder, [equal("@Listable alone grants no access", results(list).some((row) => keysOf(row).includes("secretNote")), false)]); +}); + +const editAnswersForeign = shopJourney("edit answers the stored record with its foreign fields resolved", async (shop) => { + const { context } = shop; + await seed(shop, ORDERS); + const edited = await shop.request("edit an order", "PUT", "/orders/edit?id=o1", { body: '{"total":100}' }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { total: 100, userId: { _id: "u1", name: "Alice", email: "alice@example.com" } })]); +}); + +const readOverrides = shopJourney("a per-action read override hides or grants a field on get and on list, and GetEffectiveMode agrees", async (shop) => { + const { context } = shop; + await seed(shop, OVERRIDES); + const got = await shop.request("get", "GET", "/overrides/get?id=u1"); + const gotKeys = keysOf(got.json); + context.expect(got.folder, [ + status(got, 200), + equal("a get override to write-only hides the field on get", gotKeys.includes("email"), false), + equal("a field readable by default and write-only on list is read on get", pick(got.json, "name"), "Alice"), + equal("a write-only field with a get override to read-only is read on get", pick(got.json, "notes"), "note1"), + equal("a write-only field without a get override stays out of get", gotKeys.includes("nickname"), false), + ]); + const listed = await shop.request("list", "GET", "/overrides/list"); + const first = results(listed).find((row) => pick(row, "_id") === "u1") ?? null; + const listedKeys = keysOf(first); + context.expect(listed.folder, [ + status(listed, 200), + equal("a list override to write-only hides the field on list", listedKeys.includes("name"), false), + equal("a field readable by default and write-only on get is listed", pick(first, "email"), "alice@example.com"), + equal("a write-only field with a list override to read-only is listed", pick(first, "nickname"), "al"), + equal("a write-only field without a list override stays out of list", listedKeys.includes("notes"), false), + ]); + const modes = await shop.request("effective modes", "GET", "/overrides/modes"); + context.expect(modes.folder, [ + ...holds(modes, { + email: { get: 2, list: 1, new: 1, edit: 1 }, + name: { get: 1, list: 2, new: 1, edit: 1 }, + notes: { get: 1, list: 2, new: 2, edit: 2 }, + nickname: { get: 2, list: 1, new: 2, edit: 2 }, + }), + ]); + const created = await shop.request("new reads its own overrides", "POST", "/overrides/new", { body: '{"notes":"fresh","nickname":"nn"}' }); + context.expect(created.folder, [status(created, 200)]); + const row = await stored(shop, "read the created row", "verify__users", '{ notes: "fresh" }'); + context.expect(row.folder, [equal("a write-only field is written on new", pick(row.rows[0] ?? null, "nickname"), "nn")]); +}); + +const issue90 = shopJourney("fails on purpose until #90 is fixed: edit with the index option updates the row the index found", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const edited = await shop.request("edit p1 by its tag", "PUT", "/probes/slugEdit?id=A", { body: '{"title":"New"}' }); + context.expect(edited.folder, [status(edited, 200), equal("#90: the response shows the new title", pick(edited.json, "title"), "New")]); + const row = await stored(shop, "read p1", "verify__probes", '{ _id: "p1" }'); + context.expect(row.folder, [equal("#90: the row found through the index holds the new title", pick(row.rows[0] ?? null, "title"), "New")]); +}); + +const issue92 = shopJourney("fails on purpose until #92 is fixed: a getter that a list mode does not declare is left out of it", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const brief = await shop.request("people in the brief mode", "GET", "/people/brief"); + context.expect(brief.folder, [status(brief, 200), equal("#92: the brief mode lists only _id", keysOf(results(brief)[0] ?? null), ["_id"])]); +}); + +const issue93 = shopJourney("fails on purpose until #93 is fixed: a multi reference answers null for a missing element and raw ids with noForeign", async (shop) => { + const { context } = shop; + await seed(shop, `${ORDERS}\nd.verify__orders.insertOne({ _id: "o3", items: [], itemsAll: ["i1", "ghost"], labels: [], total: 1 });`); + const resolved = await shop.request("an order with a missing element", "GET", "/orders/get?id=o3"); + const items = pick(resolved.json, "itemsAll"); + context.expect(resolved.folder, [status(resolved, 200), equal("#93: the missing element is null", Array.isArray(items) ? items[1] : "not an array", null)]); + const raw = await shop.request("the same order with noForeign", "GET", "/orders/raw?id=o3"); + context.expect(raw.folder, [equal("#93: noForeign answers the raw ids of a multi reference", pick(raw.json, "itemsAll"), ["i1", "ghost"])]); +}); + +const noForeignStrings = shopJourney("the Get noForeign option skips resolving for any non-empty string, false included, and the List one is a boolean", async (shop) => { + const { context } = shop; + await seed(shop, ORDERS); + const cases: [string, string, string | { _id: string; name: string; email: string }][] = [ + ["noForeign absent", "/orders/get?id=o1", { _id: "u1", name: "Alice", email: "alice@example.com" }], + ["noForeign true", "/orders/raw?id=o1", "u1"], + ["noForeign the string false", "/orders/rawFalse?id=o1", "u1"], + ["noForeign the empty string", "/orders/rawEmpty?id=o1", { _id: "u1", name: "Alice", email: "alice@example.com" }], + ]; + for (const [name, path, expected] of cases) { + const answer = await shop.request(name, "GET", path); + context.expect(answer.folder, [status(answer, 200), equal(`${name}: userId`, pick(answer.json, "userId"), expected)]); + } + const listTrue = await shop.request("list noForeign true", "GET", "/orders/rawList"); + context.expect(listTrue.folder, [equal("list noForeign true answers the raw key", pick(results(listTrue)[0] ?? null, "userId"), "u1")]); + const listFalse = await shop.request("list noForeign false", "GET", "/orders/listForeignFalse"); + context.expect(listFalse.folder, [equal("list noForeign false resolves", pick(results(listFalse)[0] ?? null, "userId"), { _id: "u1", name: "Alice", email: "alice@example.com" })]); +}); + +const noMandatoryStrings = shopJourney("the noMandatory option skips the mandatory check for any non-empty string, false included, on edit and on new", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const edits: [string, string, number][] = [ + ["edit with noMandatory absent", "/users/edit?id=u1", 400], + ["edit with noMandatory true", "/users/quickEdit?id=u1", 200], + ["edit with noMandatory the string false", "/users/falseSkip?id=u2", 200], + ["edit with noMandatory the empty string", "/users/emptySkip?id=u3", 400], + ]; + for (const [name, path, code] of edits) { + const answer = await shop.request(name, "PUT", path, { body: '{"status":null}' }); + context.expect(answer.folder, [status(answer, code)]); + } + const creates: [string, string, number][] = [ + ["new with noMandatory absent", "/users/new", 400], + ["new with noMandatory true", "/users/newSkip", 200], + ["new with noMandatory the string false", "/users/newSkipFalse", 200], + ["new with noMandatory the empty string", "/users/newSkipEmpty", 400], + ]; + for (const [name, path, code] of creates) { + const answer = await shop.request(name, "POST", path, { body: '{"name":"Zed"}' }); + context.expect(answer.folder, [status(answer, code)]); + } + const rows = await stored(shop, "read the users", "verify__users"); + context.expect(rows.folder, [equal("only the two skipped creates wrote a row", rows.rows.length, 7)]); +}); + +const noPluckStrings = shopJourney("the noPluck option skips the pluck-mode check only when it is true", async (shop) => { + const { context } = shop; + await seed(shop, PROBES); + const on = await shop.request("noPluck true", "GET", "/probes/plain"); + context.expect(on.folder, [status(on, 200)]); + const off = await shop.request("noPluck false", "GET", "/probes/plainFalse"); + context.expect(off.folder, [status(off, 400), equal("the mode is checked", off.body, "No fields found for pluckMode 'nothing'")]); +}); + +const derivedOwnModel = shopJourney("a derived controller that declares its own model reference reads its own model", async (shop) => { + const { context } = shop; + await seed(shop, BOOKS); + const own = await shop.request("derived controller with its own model", "GET", "/own-model/get?id=au1"); + context.expect(own.folder, [status(own, 200, "the derived controller reads its own model"), equal("the author's name", pick(own.json, "name"), "Zola")]); + const inherited = await shop.request("derived controller without one", "GET", "/staff/get?id=au1"); + context.expect(inherited.folder, [status(inherited, 404, "the inheriting controller reads the users model, where the author is not")]); +}); + +const foreignTargetMeta = shopJourney("a foreign key given the target controller's metadata resolves the target's joined fields", async (shop) => { + const { context } = shop; + await seed(shop, `${BOOKS}\nd.verify__shelves.insertMany([{ _id: "s1", bookId: "b1" }]);`); + const shelf = await shop.request("shelf with its book", "GET", "/shelves/get?id=s1"); + context.expect(shelf.folder, [status(shelf, 200), equal("the book's joined author name is resolved", pick(shelf.json, "bookId"), { title: "Nana", authorName: "Zola" })]); +}); + +const noModelAnswers500 = shopJourney("a controller whose model reference holds no model answers 500", async (shop) => { + const { context } = shop; + await seed(shop, BOOKS); + const answer = await shop.request("get on a controller with no model", "GET", "/no-model/get?id=au1"); + context.expect(answer.folder, [status(answer, 500, "a controller that holds no model answers 500")]); +}); + +const optionBeatsQuery = shopJourney("a route option beats a query parameter of the same name, and nested options keep the inner ones and the endpoint", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const sorted = await shop.request("route sort against query sort", "GET", "/option-routes/sorted?sortKey=_id&sortDirection=asc"); + context.expect(sorted.folder, [equal("the route options win over the same-named query parameters", results(sorted).map((row) => pick(row, "name")), ["Erin", "Dave", "Carol", "Bob", "Alice"])]); + const nested = await shop.request("nested options on a list", "GET", "/option-routes/nested?limit=50"); + context.expect(nested.folder, [equal("the inner maxPage survives the outer options", pick(nested.json, "limit"), 2), equal("the outer pluckMode applies", keysOf(results(nested)[0] ?? null), ["_id"])]); + const edited = await shop.request("nested options keep the inner endpoint", "PUT", "/option-routes/patch2?id=u1", { body: '{"status":null}' }); + context.expect(edited.folder, [status(edited, 200), equal("the outer noMandatory applies", pick(edited.json, "status"), null)]); + const byKey = await shop.request("the key name is not served", "PUT", "/option-routes/nestedEdit?id=u1", { body: '{"status":null}' }); + context.expect(byKey.folder, [status(byKey, 404)]); +}); + +const foreignDeclarationThrows = shopJourney("a foreign key declaration throws for a schema mismatch and for a string table with no class", async (shop) => { + const { context } = shop; + const declared = await shop.request("declare the foreign keys", "GET", "/option-routes/foreignDeclarations"); + const messages = Array.isArray(declared.json) ? declared.json.map((message) => JSON.stringify(message)) : []; + context.expect(declared.folder, [ + status(declared, 200), + equal("a class registered in one schema with a schema parameter naming another throws", (messages[0] ?? "").includes("Schema mismatch"), true), + equal("a string table name with no registered class in that schema throws", (messages[1] ?? "").includes("declared"), false), + ]); +}); + +const routeFiltersListOnly = shopJourney("a route's forced filters constrain the list and not get", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const listed = await shop.request("list with the forced filter", "GET", "/users/activeOnly"); + context.expect(listed.folder, [equal("the forced filter keeps active rows", ids(listed), ["u1", "u3", "u4", "u5"])]); + const inactive = await shop.request("get an inactive row", "GET", "/users/get?id=u2"); + context.expect(inactive.folder, [status(inactive, 200, "route filters do not restrict get"), ...holds(inactive, { name: "Bob" })]); +}); + +const emptyForms = shopJourney("edit refuses a mandatory field cleared with null, an empty string or an empty array, and accepts it absent", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const missing = { code: "missing_mandatory_fields", fields: ["email"] }; + const absent = await shop.request("email absent", "PUT", "/users/edit?id=u1", { body: '{"name":"Alice2"}' }); + context.expect(absent.folder, [status(absent, 200, "an absent mandatory field keeps its value")]); + for (const [name, body] of [["null", '{"email":null}'], ["an empty string", '{"email":""}'], ["an empty array", '{"email":[]}']] as const) { + const cleared = await shop.request(`email cleared with ${name}`, "PUT", "/users/edit?id=u1", { body }); + context.expect(cleared.folder, [status(cleared, 400), equal(`${name} counts as empty`, cleared.json, missing)]); + } + const object = await shop.request("email set to an empty object", "PUT", "/users/edit?id=u1", { body: '{"email":{}}' }); + context.expect(object.folder, [status(object, 400), equal("an empty object is not a clearing value, so the validator refuses it", object.json, { code: "invalid_field_types", fields: ["email"] })]); + const row = await stored(shop, "read u1", "verify__users", '{ _id: "u1" }'); + context.expect(row.folder, [equal("no refused edit changed the email", pick(row.rows[0] ?? null, "email"), "alice@example.com")]); +}); + +const mandatoryBeforeValidators = shopJourney("the mandatory check answers before any validator, on new and on edit", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const created = await shop.request("new with a missing mandatory field and an invalid value", "POST", "/users/new", { body: '{"name":"Zed","age":500}' }); + context.expect(created.folder, [status(created, 400), equal("new answers the mandatory code", created.json, { code: "missing_mandatory_fields", fields: ["email", "password"] })]); + const edited = await shop.request("edit clearing a mandatory field with an invalid value", "PUT", "/users/edit?id=u1", { body: '{"email":null,"nickname":"taken"}' }); + context.expect(edited.folder, [status(edited, 400), equal("edit answers the mandatory code", edited.json, { code: "missing_mandatory_fields", fields: ["email"] })]); +}); + +const holeyBody = shopJourney("a parsed body that holds an undefined array element answers 400", async (shop) => { + const { context } = shop; + await seed(shop, DOCUMENTS); + const holey = await shop.request("new through a route that sets [undefined]", "POST", "/holey-docs/holey", { body: '{"title":"x"}' }); + context.expect(holey.folder, [status(holey, 400, "an undefined array element answers 400")]); + const rows = await stored(shop, "count the documents", "verify__documents"); + context.expect(rows.folder, [equal("nothing was written", rows.rows.length, 2)]); +}); + +const joinedAccess = shopJourney("an @Access written above @Joined replaces its read-only mode", async (shop) => { + const { context } = shop; + await seed(shop, BOOKS); + const got = await shop.request("get a book", "GET", "/joined-access/get?id=b1"); + context.expect(got.folder, [status(got, 200), ...holds(got, { title: "Nana" }), equal("the later write-only @Access hides the joined field", keysOf(got.json).includes("authorName"), false)]); +}); + +const computedAccess = shopJourney("an @Access written above @Computed replaces its read-only mode", async (shop) => { + const { context } = shop; + await seed(shop, GROUPS); + const got = await shop.request("get a group", "GET", "/computed-access/get?id=g1"); + context.expect(got.folder, [status(got, 200), ...holds(got, { name: "Alpha" }), equal("the later write-only @Access hides the computed field on get", keysOf(got.json).includes("devices_count"), false)]); + const listed = await shop.request("list the groups", "GET", "/computed-access/list"); + context.expect(listed.folder, [equal("and on list", results(listed).some((row) => keysOf(row).includes("devices_count")), false)]); +}); + +const foreignUnplucked = shopJourney("a foreign key without pluck answers the whole target row, whatever the target controller's @Access says", async (shop) => { + const { context } = shop; + await seed(shop, ORDERS); + const one = await shop.request("order o1", "GET", "/orders/get?id=o1"); + const target = pick(one.json, "byEmail"); + context.expect(one.folder, [status(one, 200), equal("the target's name", pick(target, "name"), "Bob"), equal("the target's stored password is not hidden by the users controller's @Access", pick(target, "password"), "p2")]); +}); + +export const PROBE_JOURNEYS = { + "guard-semantics": guardSemantics, + "new-row-shape": newRowContents, + "edit-setters": editSetters, + "filter-details": filterDetails, + "list-unknown": listUnknown, + "listable-alone": listableAlone, + "edit-answers-foreign": editAnswersForeign, + "read-overrides": readOverrides, + "no-foreign-strings": noForeignStrings, + "no-mandatory-strings": noMandatoryStrings, + "no-pluck-strings": noPluckStrings, + "derived-own-model": derivedOwnModel, + "foreign-target-meta": foreignTargetMeta, + "no-model-500": noModelAnswers500, + "option-beats-query": optionBeatsQuery, + "foreign-declaration-throws": foreignDeclarationThrows, + "route-filters-list-only": routeFiltersListOnly, + "empty-forms": emptyForms, + "mandatory-before-validators": mandatoryBeforeValidators, + "holey-body": holeyBody, + "joined-access": joinedAccess, + "computed-access": computedAccess, + "foreign-unplucked": foreignUnplucked, + "issue-90-edit-index": issue90, + "issue-92-getter-mode": issue92, + "issue-93-multi-foreign": issue93, +}; diff --git a/.agents/skills/verify-interface/journeys-read.ts b/.agents/skills/verify-interface/journeys-read.ts new file mode 100644 index 0000000..a140110 --- /dev/null +++ b/.agents/skills/verify-interface/journeys-read.ts @@ -0,0 +1,296 @@ +import { equal, exitIs } from "@antelopejs/tooling-configs/verify"; +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { compileOnly, holds, ids, keysOf, pick, results, seed, shopJourney, status, stored, text } from "./harness.ts"; +import { BOOKS, DOCUMENTS, GROUPS, ORDERS, USERS } from "./seeds.ts"; + +const LOCALE = "x-content-language"; +const UNKNOWN_COMPARISON = 'Unknown filter comparison "bogus": use one of eq, ne, gt, ge, lt, le, or start a value that contains a colon with "eq:".'; + +const pluckModes = shopJourney("each pluck mode lists its own fields, and a getter lists the database fields it reads", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const list = await shop.request("default mode", "GET", "/users/list"); + context.expect(list.folder, [equal("the default mode lists the @Listable fields that have read access", keysOf(results(list)[0] ?? null), ["_id", "age", "createdAt", "email", "name", "role", "status"])]); + const detailed = await shop.request("detailed mode", "GET", "/users/detailed"); + const detailedKeys = keysOf(results(detailed)[0] ?? null); + context.expect(detailed.folder, [ + equal("the detailed mode lists name and internalNotes", [detailedKeys.includes("name"), detailedKeys.includes("internalNotes")], [true, true]), + equal("the detailed mode does not inherit the default mode", [detailedKeys.includes("email"), detailedKeys.includes("role")], [false, false]), + ]); + const minimal = await shop.request("minimal mode", "GET", "/users/minimal"); + context.expect(minimal.folder, [equal("a field can be left out of a mode", keysOf(results(minimal)[0] ?? null), ["_id"])]); + const get = await shop.request("get", "GET", "/users/get?id=u1"); + context.expect(get.folder, [equal("a field that is not listable is still read by get", pick(get.json, "biography"), "bio1")]); + const people = await shop.request("people list", "GET", "/people/list"); + context.expect(people.folder, [equal("a getter lists the fields it reads", pick(results(people)[0] ?? null, "fullName"), "Alice ")]); +}); + +const pagination = shopJourney("a list answers one page with the total, offset and limit it used", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const first = await shop.request("default page", "GET", "/users/list"); + context.expect(first.folder, [equal("the default page", [pick(first.json, "total"), pick(first.json, "offset"), pick(first.json, "limit"), results(first).length], [5, 0, 10, 5])]); + const middle = await shop.request("limit 2 offset 1", "GET", "/users/list?limit=2&offset=1&sortKey=_id"); + context.expect(middle.folder, [equal("the page skips the offset and holds the limit", ids(middle), ["u2", "u3"]), equal("the envelope", [pick(middle.json, "total"), pick(middle.json, "offset"), pick(middle.json, "limit")], [5, 1, 2])]); + const tail = await shop.request("limit 2 offset 4", "GET", "/users/list?limit=2&offset=4&sortKey=_id"); + context.expect(tail.folder, [equal("the last page is short", ids(tail), ["u5"]), equal("the total stays the same", pick(tail.json, "total"), 5)]); + const capped = await shop.request("limit 500", "GET", "/users/list?limit=500"); + context.expect(capped.folder, [equal("a list route without maxPage caps the limit at 100", pick(capped.json, "limit"), 100)]); +}); + +const sorting = shopJourney("a list sorts on a @Sortable field in the database and refuses any other", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const byName = await shop.request("name descending", "GET", "/users/list?sortKey=name&sortDirection=desc"); + context.expect(byName.folder, [equal("rows come back in descending name order", results(byName).map((row) => pick(row, "name")), ["Erin", "Dave", "Carol", "Bob", "Alice"])]); + const bogus = await shop.request("any other direction", "GET", "/users/list?sortKey=name&sortDirection=bogus"); + context.expect(bogus.folder, [equal("any other direction sorts ascending", results(bogus).map((row) => pick(row, "name")), ["Alice", "Bob", "Carol", "Dave", "Erin"])]); + const byAge = await shop.request("age ascending, not indexed", "GET", "/users/list?sortKey=age"); + context.expect(byAge.folder, [equal("a noIndex field sorts too", results(byAge).map((row) => pick(row, "age")), [18, 22, 30, 40, 65])]); + const byId = await shop.request("id descending, indexed", "GET", "/users/list?sortKey=_id&sortDirection=desc"); + context.expect(byId.folder, [equal("an indexed field sorts", results(byId).map((row) => pick(row, "_id")), ["u5", "u4", "u3", "u2", "u1"])]); + const refused = await shop.request("a field that is not sortable", "GET", "/users/list?sortKey=email"); + context.expect(refused.folder, [status(refused, 400), equal("the answer names the problem", refused.body, "Field is not sortable.")]); +}); + +const foreignResolve = shopJourney("a foreign key field answers the referenced record, plucked, by index, across schemas and as an array", async (shop) => { + const { context } = shop; + await seed(shop, ORDERS); + const one = await shop.request("order o1", "GET", "/orders/get?id=o1"); + context.expect(one.folder, [ + status(one, 200), + ...holds(one, { + userId: { _id: "u1", name: "Alice", email: "alice@example.com" }, + userEmail: { name: "Bob" }, + ownerLogin: { _id: "a1", login: "alice-login" }, + tagged: { name: "Bob" }, + items: [{ name: "Widget", price: 9.99 }, { name: "Gadget", price: 24.99 }], + itemsAll: [{ _id: "i1", name: "Widget", price: 9.99 }], + total: 99, + }), + ]); + context.expect(one.folder, [equal("an index without pluck resolves the whole record", pick(pick(one.json, "byEmail"), "name"), "Bob")]); + const missing = await shop.request("order o2", "GET", "/orders/get?id=o2"); + context.expect(missing.folder, [ + status(missing, 200), + ...holds(missing, { userId: null, userEmail: null, ownerLogin: null, tagged: { name: "Alice" }, items: [], total: 5 }), + ]); + const list = await shop.request("order list", "GET", "/orders/list"); + context.expect(list.folder, [equal("the list resolves the same fields", pick(results(list)[0] ?? null, "userId"), { _id: "u1", name: "Alice", email: "alice@example.com" })]); +}); + +const foreignRaw = shopJourney("noForeign answers the raw keys on a get and on a list", async (shop) => { + const { context } = shop; + await seed(shop, ORDERS); + const raw = await shop.request("raw get", "GET", "/orders/raw?id=o1"); + context.expect(raw.folder, [status(raw, 200), ...holds(raw, { userId: "u1", userEmail: "bob@gmail.com", ownerLogin: "a1", tagged: "u2", total: 99 })]); + const rawList = await shop.request("raw list", "GET", "/orders/rawList"); + const first = results(rawList)[0] ?? null; + context.expect(rawList.folder, [equal("the list route returns raw keys", [pick(first, "userId"), pick(first, "ownerLogin")], ["u1", "a1"])]); +}); + +const defaultFilter = shopJourney("the default filter compares with six operators and refuses any other", async (shop) => { + const { context } = shop; + await seed(shop, `${USERS}\nd.verify__users.insertOne({ _id: "u6", email: "colon@example.com", name: "a:b", password: "p6", role: "user", age: 50, status: "active" });`); + const cases: [string, string, string[]][] = [ + ["equal by default", "filter_status=inactive", ["u2"]], + ["not equal", "filter_role=ne:admin", ["u2", "u3", "u4", "u5", "u6"]], + ["several fields", "filter_status=active&filter_role=ne:admin", ["u3", "u4", "u5", "u6"]], + ["greater than", "filter_name=gt:Dave", ["u5", "u6"]], + ["greater or equal", "filter_name=ge:Dave", ["u4", "u5", "u6"]], + ["less than", "filter_name=lt:Bob", ["u1"]], + ["less or equal", "filter_name=le:Bob", ["u1", "u2"]], + ["the eq prefix keeps a colon in the value", "filter_name=eq:a:b", ["u6"]], + ["a field that is not a filter is ignored", "filter_nothing=1", ["u1", "u2", "u3", "u4", "u5", "u6"]], + ]; + for (const [name, query, expected] of cases) { + const answer = await shop.request(name, "GET", `/users/list?${query}`); + context.expect(answer.folder, [status(answer, 200), equal(`${name}: the rows`, ids(answer), expected)]); + } + const refused = await shop.request("an operator the table does not hold", "GET", "/users/list?filter_status=bogus:x"); + context.expect(refused.folder, [status(refused, 400), equal("the answer names the operator", refused.body, UNKNOWN_COMPARISON)]); +}); + +const customFilters = shopJourney("custom filter functions parse their own values, and a route can force a filter", async (shop) => { + const { context } = shop; + await seed(shop, `${USERS}\nd.verify__users.insertOne({ _id: "u7", email: "z@gmailxcom", name: "Zed", password: "p7", role: "user", age: 70, status: "active" });`); + const older = await shop.request("numeric filter", "GET", "/users/list?filter_age=gt:30"); + context.expect(older.folder, [equal("a number compares as a number", ids(older), ["u2", "u4", "u7"])]); + const range = await shop.request("one field filtered twice", "GET", "/users/list?filter_age=ge:18&filter_age=lt:65"); + context.expect(range.folder, [equal("both filters apply (AND)", ids(range), ["u1", "u3", "u4", "u5"])]); + const invalid = await shop.request("an invalid number", "GET", "/users/list?filter_age=abc"); + context.expect(invalid.folder, [status(invalid, 400), equal("the filter answers its message", invalid.body, "Invalid number")]); + const domain = await shop.request("domain filter", "GET", "/users/list?filter_email=gmail.com"); + context.expect(domain.folder, [equal("the domain is escaped, so the dot is literal", ids(domain), ["u2", "u3"])]); + const other = await shop.request("domain filter with another operator", "GET", "/users/list?filter_email=ne:gmail.com"); + context.expect(other.folder, [status(other, 400), equal("the filter refuses what it does not support", other.body, "Only equality is supported for email domains")]); + const forced = await shop.request("route filter", "GET", "/users/activeOnly?filter_status=inactive&filter_role=user"); + context.expect(forced.folder, [equal("the route filter wins on its field and the request filter on the others", ids(forced), ["u3", "u4", "u5", "u7"])]); +}); + +const localizedKey = shopJourney("a modifier key read from a header selects the language a write stores and a read answers", async (shop) => { + const { context } = shop; + const created = await shop.request("create in English", "POST", "/articles/new", { body: '{"title":"Hello"}', headers: [`${LOCALE}: en`] }); + const id = text(pick(created.json, "_id")); + context.expect(created.folder, [status(created, 200), ...holds(created, { title: "Hello" })]); + const french = await shop.request("edit in French", "PUT", `/articles/edit?id=${id}`, { body: '{"title":"Bonjour"}', headers: [`${LOCALE}: fr`] }); + context.expect(french.folder, [status(french, 200), ...holds(french, { title: "Bonjour" })]); + const english = await shop.request("read in English", "GET", `/articles/get?id=${id}`, { headers: [`${LOCALE}: en`] }); + context.expect(english.folder, [...holds(english, { title: "Hello" })]); + const kept = await shop.request("edit without the title", "PUT", `/articles/edit?id=${id}`, { body: "{}", headers: [`${LOCALE}: fr`] }); + context.expect(kept.folder, [status(kept, 200), ...holds(kept, { title: "Bonjour" })]); + const afterKept = await shop.request("read in English again", "GET", `/articles/get?id=${id}`, { headers: [`${LOCALE}: en`] }); + context.expect(afterKept.folder, [...holds(afterKept, { title: "Hello" })]); + const row = await stored(shop, "read the article back", "verify__articles", `{ _id: "${id}" }`); + const stored_title = JSON.stringify(row.rows[0] ?? null); + context.expect(row.folder, [equal("MongoDB holds both languages", [stored_title.includes("Hello"), stored_title.includes("Bonjour")], [true, true])]); + await shop.request("create Alpha in English", "POST", "/articles/new", { body: '{"title":"Alpha"}', headers: [`${LOCALE}: en`] }); + const sorted = await shop.request("sort on the English title", "GET", "/articles/list?sortKey=title", { headers: [`${LOCALE}: en`] }); + context.expect(sorted.folder, [equal("rows sort on the value in the request's locale", results(sorted).map((item) => pick(item, "title")), ["Alpha", "Hello"])]); + const descending = await shop.request("sort on the English title, descending", "GET", "/articles/list?sortKey=title&sortDirection=desc", { headers: [`${LOCALE}: en`] }); + context.expect(descending.folder, [equal("a sort on a modifier-held field follows sortDirection", results(descending).map((item) => pick(item, "title")), ["Hello", "Alpha"])]); +}); + +const joinedRead = shopJourney("a joined field flattens one remote field onto the row and sorts and filters on it in the database", async (shop) => { + const { context } = shop; + await seed(shop, BOOKS); + const one = await shop.request("get b1", "GET", "/books/get?id=b1"); + context.expect(one.folder, [status(one, 200), ...holds(one, { authorName: "Zola", accountLogin: "alice-login", title: "Nana" })]); + const list = await shop.request("list", "GET", "/books/list"); + context.expect(list.folder, [equal("a missing remote row joins as null", results(list).map((row) => [pick(row, "authorName"), pick(row, "accountLogin")]), [["Zola", "alice-login"], ["Austen", null], [null, null]])]); + const sorted = await shop.request("sort on the joined field", "GET", "/books/list?sortKey=authorName"); + context.expect(sorted.folder, [equal("the join applies before the sort", results(sorted).map((row) => pick(row, "authorName")), [null, "Austen", "Zola"])]); + const filtered = await shop.request("filter on the joined field", "GET", "/books/list?filter_authorName=Zola"); + context.expect(filtered.folder, [equal("the join applies before the filter", [ids(filtered), pick(filtered.json, "total")], [["b1"], 1])]); + const edited = await shop.request("edit a joined field", "PUT", "/books/edit?id=b1", { body: JSON.stringify({ authorName: "Hacked", title: "Nana 2" }) }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { authorName: "Zola", title: "Nana 2" })]); + const row = await stored(shop, "read b1 back", "verify__books", '{ _id: "b1" }'); + context.expect(row.folder, [equal("a joined field is never stored", keysOf(row.rows[0] ?? null).includes("authorName"), false), equal("the local key is untouched", pick(row.rows[0] ?? null, "authorId"), "au1")]); +}); + +const computedRead = shopJourney("a computed field is merged from the database where a step needs it", async (shop) => { + const { context } = shop; + await seed(shop, GROUPS); + const one = await shop.request("get g2", "GET", "/groups/get?id=g2"); + context.expect(one.folder, [status(one, 200), ...holds(one, { devices_count: 0, display_name: "Beta!" })]); + const first = await shop.request("first page by count", "GET", "/groups/list?sortKey=devices_count&sortDirection=desc&limit=2"); + context.expect(first.folder, [equal("the first page", results(first).map((row) => [pick(row, "name"), pick(row, "devices_count")]), [["Alpha", 3], ["Gamma", 1]]), equal("the total", [pick(first.json, "total"), pick(first.json, "limit")], [3, 2])]); + const second = await shop.request("second page by count", "GET", "/groups/list?sortKey=devices_count&sortDirection=desc&limit=2&offset=2"); + context.expect(second.folder, [equal("the second page", results(second).map((row) => [pick(row, "name"), pick(row, "devices_count")]), [["Beta", 0]]), equal("the total is the same on every page", pick(second.json, "total"), 3)]); + const empty = await shop.request("filter on a count of zero", "GET", "/groups/list?filter_devices_count=eq:0"); + context.expect(empty.folder, [equal("the default turns a null aggregate into 0", ids(empty), ["g2"])]); + const edited = await shop.request("edit computed fields", "PUT", "/groups/edit?id=g1", { body: JSON.stringify({ devices_count: 99, display_name: "x", name: "Alpha2" }) }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { devices_count: 3, display_name: "Alpha2!", name: "Alpha2" })]); + const row = await stored(shop, "read g1 back", "verify__groups", '{ _id: "g1" }'); + context.expect(row.folder, [equal("a computed field is never stored", [keysOf(row.rows[0] ?? null).includes("devices_count"), keysOf(row.rows[0] ?? null).includes("display_name")], [false, false])]); +}); + +const getGuard = shopJourney("a get guard denies by throwing and applies to its own route only", async (shop) => { + const { context } = shop; + await seed(shop, DOCUMENTS); + const allowed = await shop.request("invoice through the guarded route", "GET", "/documents/invoice?id=x1"); + context.expect(allowed.folder, [status(allowed, 200), equal("the guarded row", allowed.json, { _id: "x1", title: "Bill" })]); + const denied = await shop.request("memo through the guarded route", "GET", "/documents/invoice?id=x2"); + context.expect(denied.folder, [status(denied, 404), equal("the guard's error is the answer", denied.body, "Not Found")]); + const missing = await shop.request("missing row through the guarded route", "GET", "/documents/invoice?id=zzz"); + context.expect(missing.folder, [status(missing, 404)]); + const plain = await shop.request("memo through the plain route", "GET", "/documents/get?id=x2"); + context.expect(plain.folder, [status(plain, 200), ...holds(plain, { title: "Memo" })]); +}); + +const guardUnlocked = shopJourney("a get guard reads the loaded model unlocked with the request's modifier key", async (shop) => { + const { context } = shop; + const created = await shop.request("create in English", "POST", "/articles/new", { body: '{"title":"Hello"}', headers: [`${LOCALE}: en`] }); + const id = text(pick(created.json, "_id")); + context.expect(created.folder, [status(created, 200)]); + const english = await shop.request("read through the guarded route in English", "GET", `/articles/translated?id=${id}`, { headers: [`${LOCALE}: en`] }); + context.expect(english.folder, [status(english, 200, "the guard sees the title unlocked in English"), ...holds(english, { title: "Hello" })]); + const german = await shop.request("read through the guarded route in German", "GET", `/articles/translated?id=${id}`, { headers: [`${LOCALE}: de`] }); + context.expect(german.folder, [status(german, 404, "the guard sees no German title"), equal("the guard's error is the answer", german.body, "Not Found")]); + const plain = await shop.request("read through the plain route in German", "GET", `/articles/get?id=${id}`, { headers: [`${LOCALE}: de`] }); + context.expect(plain.folder, [status(plain, 200, "the plain route is not guarded")]); +}); + +const amendBody = shopJourney("a route that wraps new can amend the parsed body before it is written", async (shop) => { + const { context } = shop; + const stamped = await shop.request("new through the stamping route", "POST", "/documents/stamped", { body: '{"title":"Imp","source":"sent"}' }); + const stampedId = text(pick(stamped.json, "_id")); + const plain = await shop.request("new through the plain route", "POST", "/documents/new", { body: '{"title":"Plain","source":"sent"}' }); + const plainId = text(pick(plain.json, "_id")); + context.expect(stamped.folder, [status(stamped, 200), ...holds(stamped, { source: "import", title: "Imp" })]); + const rows = await stored(shop, "read both back", "verify__documents"); + const source = (id: string) => pick(rows.rows.find((row) => pick(row, "_id") === id) ?? null, "source"); + context.expect(rows.folder, [equal("the amended field is written", source(stampedId), "import"), equal("the plain route writes what was sent", source(plainId), "sent")]); +}); + +const customRoute: Journey = shopJourney("a custom route declared with func, args and method is served next to the defaults", async (shop) => { + const { context } = shop; + const search = await shop.request("custom search route", "GET", "/users/search"); + context.expect(search.folder, [status(search, 200), equal("the custom route answers", search.json, { results: [] })]); + const list = await shop.request("default route beside it", "GET", "/users/list"); + context.expect(list.folder, [status(list, 200)]); +}); + +const effectiveModes = shopJourney("GetEffectiveMode answers the mode each action applies", async (shop) => { + const { context } = shop; + const modes = await shop.request("read the effective modes", "GET", "/users/modes"); + context.expect(modes.folder, [ + status(modes, 200), + ...holds(modes, { + role: { get: 3, list: 3, new: 3, edit: 1 }, + age: { get: 1, list: 1, new: 3, edit: 1 }, + password: { get: 2, list: 2, new: 2, edit: 2 }, + _id: { get: 1, list: 1, new: 1, edit: 1 }, + }), + ]); +}); + +const declarationErrors = shopJourney("DataController refuses a table class whose schema is not registered", async (shop) => { + const { context } = shop; + const declared = await shop.request("declare a controller on an unregistered table", "GET", "/users/declarations"); + context.expect(declared.folder, [status(declared, 200), equal("the declarations throw", declared.json, ["Non-existent Database Schema", "Unregistered Database Table"])]); +}); + +const exportsRuntime: Journey = { + title: "the packed metadata entry exports the enum and the filter reader with their documented values", + entryPoint: "the packed `metadata` entry required from a consumer", + async run(context) { + const loaded = await context.command("read the exports", "node", [ + "-e", + `const m = require(process.argv[1]); +console.log(JSON.stringify([m.AccessMode.ReadOnly, m.AccessMode.WriteOnly, m.AccessMode.ReadWrite, m.FilterValuesOf(["a", "eq"]), m.FilterValuesOf([["a", "eq"], ["b", "ne"]])]));`, + `${context.repo}/dist/metadata.js`, + ]); + context.expect(loaded.folder, [exitIs(loaded, 0), equal("the enum values and both filter shapes", loaded.stdout.trim(), '[1,2,3,[["a","eq"]],[["a","eq"],["b","ne"]]]')]); + }, +}; + +const consumerCompiles: Journey = { + title: "a consumer written from the docs compiles against the packed declarations", + entryPoint: "tsc on a module that declares controllers with every decorator the docs show, against the packed types", + async run(context) { + await compileOnly(context); + }, +}; + +export const READ_JOURNEYS = { + "pluck-modes": pluckModes, + pagination, + sorting, + "foreign-resolve": foreignResolve, + "foreign-raw": foreignRaw, + "default-filter": defaultFilter, + "custom-filters": customFilters, + "localized-key": localizedKey, + "joined-read": joinedRead, + "computed-read": computedRead, + "get-guard": getGuard, + "guard-unlocked": guardUnlocked, + "amend-body": amendBody, + "custom-route": customRoute, + "effective-modes": effectiveModes, + "declaration-errors": declarationErrors, + "exports-runtime": exportsRuntime, + "consumer-compiles": consumerCompiles, +}; diff --git a/.agents/skills/verify-interface/journeys-write.ts b/.agents/skills/verify-interface/journeys-write.ts new file mode 100644 index 0000000..1c4d14e --- /dev/null +++ b/.agents/skills/verify-interface/journeys-write.ts @@ -0,0 +1,250 @@ +import { equal, exitIs } from "@antelopejs/tooling-configs/verify"; +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { holds, keysOf, pick, results, seed, shopJourney, status, stored, text } from "./harness.ts"; +import { USERS } from "./seeds.ts"; + +const NEW_USER = { + name: "Zed", + email: "zed@example.com", + password: "pw1234", + role: "admin", + age: 33, + createdAt: "x", + biography: "b", + status: "active", + nickname: "zz", + secretNote: "leak", + notes: "n", +}; + +const MISSING = "missing_mandatory_fields"; +const INVALID = "invalid_field_types"; + +/** The ids left in the users collection, sorted. */ +const USER_IDS = '{ _id: 1 }'; + +const smokeRoundtrip = shopJourney("a user created, read, edited and deleted through the routes", async (shop) => { + const { context } = shop; + const created = await shop.request("create", "POST", "/users/new", { body: JSON.stringify({ name: "Ann", email: "ann@example.com", password: "pw1234" }) }); + const id = text(pick(created.json, "_id")); + context.expect(created.folder, [status(created, 200), equal("the created row has an id", id.length > 0, true)]); + const read = await shop.request("read", "GET", `/users/get?id=${id}`); + context.expect(read.folder, [status(read, 200), ...holds(read, { name: "Ann", email: "ann@example.com" })]); + const edited = await shop.request("edit", "PUT", `/users/edit?id=${id}`, { body: JSON.stringify({ name: "Anna" }) }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { name: "Anna", email: "ann@example.com" })]); + const deleted = await shop.request("delete", "DELETE", `/users/delete?id=${id}`); + context.expect(deleted.folder, [status(deleted, 200), equal("one record deleted", deleted.json, 1)]); + const gone = await shop.request("read again", "GET", `/users/get?id=${id}`); + context.expect(gone.folder, [status(gone, 404)]); +}); + +const crudLifecycle = shopJourney("new, get, edit and delete change exactly the rows they name, and MongoDB shows it", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const created = await shop.request("create Zed", "POST", "/users/new", { body: JSON.stringify(NEW_USER) }); + const id = text(pick(created.json, "_id")); + context.expect(created.folder, [ + status(created, 200), + ...holds(created, { name: "Zed", email: "zed@example.com", status: "active" }), + equal("the answer leaves out the write-only password", keysOf(created.json).includes("password"), false), + ]); + const row = await stored(shop, "read Zed back", "verify__users", `{ _id: "${id}" }`); + context.expect(row.folder, [ + equal("the row holds the written password", pick(row.rows[0] ?? null, "password"), "pw1234"), + equal("the row holds the age allowed on new", pick(row.rows[0] ?? null, "age"), 33), + equal("the row holds the email", pick(row.rows[0] ?? null, "email"), "zed@example.com"), + ]); + const read = await shop.request("get u1", "GET", "/users/get?id=u1"); + context.expect(read.folder, [status(read, 200), ...holds(read, { _id: "u1", name: "Alice", email: "alice@example.com", biography: "bio1" })]); + const edited = await shop.request("edit u1", "PUT", "/users/edit?id=u1", { body: JSON.stringify({ name: "Updated" }) }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { _id: "u1", name: "Updated", email: "alice@example.com", status: "active" })]); + const after = await stored(shop, "read u1 back", "verify__users", '{ _id: "u1" }'); + context.expect(after.folder, [ + equal("the edit changed the name", pick(after.rows[0] ?? null, "name"), "Updated"), + equal("the edit kept the email it was not sent", pick(after.rows[0] ?? null, "email"), "alice@example.com"), + equal("the edit kept the password it was not sent", pick(after.rows[0] ?? null, "password"), "p1"), + ]); + const deleted = await shop.request("delete u4, u5 and a missing id", "DELETE", "/users/delete?id=u4&id=u5&id=ghost"); + context.expect(deleted.folder, [status(deleted, 200), equal("the body counts the records deleted", deleted.json, 2)]); + const left = await shop.mongo("list the ids left", `JSON.stringify(db.getSiblingDB("verify").verify__users.find({}, ${USER_IDS}).sort({ _id: 1 }).toArray().map((row) => row._id))`); + context.expect(left.folder, [equal("only the named rows are gone", left.stdout.trim(), JSON.stringify(["u1", "u2", "u3", id].sort((a, b) => a.localeCompare(b))))]); +}); + +const crudErrors = shopJourney("a missing id, an unknown record and a bad body answer the documented errors", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const cases: [string, string, string, number, string, string?][] = [ + ["get without id", "GET", "/users/get", 400, "Missing id."], + ["get unknown", "GET", "/users/get?id=nope", 404, "Not Found"], + ["edit without id", "PUT", "/users/edit", 400, "Missing id.", '{"name":"x"}'], + ["edit unknown", "PUT", "/users/edit?id=nope", 404, "Not Found", '{"name":"x"}'], + ["edit with a body that is not JSON", "PUT", "/users/edit?id=u1", 400, "Invalid JSON body.", "nope"], + ["delete without id", "DELETE", "/users/delete", 400, "Missing id."], + ["new with a body that is not JSON", "POST", "/users/new", 400, "Invalid JSON body.", "not json"], + ["new with a JSON array", "POST", "/users/new", 400, "The request body must be a JSON object.", "[1,2]"], + ]; + for (const [name, method, path, code, text, body] of cases) { + const answer = await shop.request(name, method, path, { body }); + context.expect(answer.folder, [status(answer, code), equal(`${name} answers its message`, answer.body, text)]); + } + const unknownDelete = await shop.request("delete an unknown id", "DELETE", "/users/delete?id=ghost"); + context.expect(unknownDelete.folder, [status(unknownDelete, 200)]); + const invalid = await shop.request("quickEdit with an invalid email", "PUT", "/users/quickEdit?id=u1", { body: '{"email":"not-an-email"}' }); + context.expect(invalid.folder, [status(invalid, 400), equal("a route that skips the mandatory check still runs the validators", invalid.json, { code: INVALID, fields: ["email"] })]); + const rows = await stored(shop, "count the rows", "verify__users"); + context.expect(rows.folder, [equal("no failed request wrote anything", rows.rows.length, 5)]); +}); + +const crudSubset = shopJourney("a controller serves only the routes it declares, under the endpoint name it is given", async (shop) => { + const { context } = shop; + await seed(shop, `${USERS}\nd.verify__authors.insertMany([{ _id: "au1", name: "Zola" }]);`); + const served = await shop.request("get an author", "GET", "/authors/get?id=au1"); + context.expect(served.folder, [status(served, 200), ...holds(served, { name: "Zola" })]); + const created = await shop.request("create an author", "POST", "/authors/new", { body: JSON.stringify({ name: "Austen" }) }); + context.expect(created.folder, [status(created, 200)]); + for (const [name, method, path] of [["edit is not declared", "PUT", "/authors/edit?id=au1"], ["delete is not declared", "DELETE", "/authors/delete?id=au1"], ["the renamed route has no key name", "PUT", "/authors/quick?id=au1"]]) { + const answer = await shop.request(name, method, path, { body: '{"name":"x"}' }); + context.expect(answer.folder, [status(answer, 404)]); + } + const renamed = await shop.request("edit through the renamed endpoint", "PUT", "/authors/patch?id=au1", { body: JSON.stringify({ name: "Renamed" }) }); + context.expect(renamed.folder, [status(renamed, 200), ...holds(renamed, { name: "Renamed" })]); + const author = await stored(shop, "read the author back", "verify__authors", '{ _id: "au1" }'); + context.expect(author.folder, [equal("the renamed endpoint wrote the name", pick(author.rows[0] ?? null, "name"), "Renamed")]); + const inherited = await shop.request("get through the derived controller", "GET", "/staff/get?id=u1"); + context.expect(inherited.folder, [status(inherited, 200), ...holds(inherited, { name: "Alice" })]); +}); + +const optionOverrides = shopJourney("route options override the query string and switch checks off", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const capped = await shop.request("capped list with a larger limit", "GET", "/users/capped?limit=50"); + context.expect(capped.folder, [equal("maxPage caps the limit", pick(capped.json, "limit"), 3), equal("three rows", results(capped).length, 3), equal("the total counts every row", pick(capped.json, "total"), 5)]); + const cappedDefault = await shop.request("capped list without limit", "GET", "/users/capped"); + context.expect(cappedDefault.folder, [equal("maxPage is the page size without a limit", pick(cappedDefault.json, "limit"), 3), equal("three rows", results(cappedDefault).length, 3)]); + const refused = await shop.request("edit clears a mandatory field", "PUT", "/users/edit?id=u2", { body: '{"status":null}' }); + context.expect(refused.folder, [status(refused, 400), equal("the mandatory check answers its code", pick(refused.json, "code"), MISSING)]); + const skipped = await shop.request("quickEdit clears a mandatory field", "PUT", "/users/quickEdit?id=u2", { body: '{"status":null}' }); + context.expect(skipped.folder, [status(skipped, 200), equal("noMandatory skips the check", pick(skipped.json, "status"), null)]); + const row = await stored(shop, "read u2 back", "verify__users", '{ _id: "u2" }'); + context.expect(row.folder, [equal("the cleared status is stored", pick(row.rows[0] ?? null, "status"), null)]); + const falseString = await shop.request("a route option set to the string false", "PUT", "/users/falseSkip?id=u3", { body: '{"status":null}' }); + context.expect(falseString.folder, [status(falseString, 200), equal("any non-empty string turns noMandatory on, false included", pick(falseString.json, "status"), null)]); + const byIndex = await shop.request("get through the email index", "GET", "/users/byEmail?id=bob@gmail.com"); + context.expect(byIndex.folder, [status(byIndex, 200), ...holds(byIndex, { _id: "u2", name: "Bob" })]); + const unplucked = await shop.request("list with noPluck", "GET", "/users/unplucked"); + context.expect(unplucked.folder, [status(unplucked, 200), equal("noPluck still reads the readable fields", keysOf(results(unplucked)[0] ?? null).includes("biography"), true), equal("noPluck keeps the write-only password out", keysOf(results(unplucked)[0] ?? null).includes("password"), false)]); + const missingMode = await shop.request("list in a mode no field declares", "GET", "/users/missingMode"); + context.expect(missingMode.folder, [status(missingMode, 400), equal("the answer names the mode", missingMode.body, "No fields found for pluckMode 'nothing'")]); +}); + +const accessModes = shopJourney("access modes decide what a response holds and what a write stores, per action", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const read = await shop.request("get u1", "GET", "/users/get?id=u1"); + const readKeys = keysOf(read.json); + context.expect(read.folder, [ + equal("a write-only field is never read", readKeys.includes("password"), false), + equal("a field without @Access is never read", readKeys.includes("secretNote"), false), + equal("a read-only field is read", pick(read.json, "createdAt"), "2020"), + equal("a read-write field is read", pick(read.json, "name"), "Alice"), + ]); + const list = await shop.request("list", "GET", "/users/list"); + context.expect(list.folder, [equal("a list never holds the write-only field", results(list).some((row) => keysOf(row).includes("password")), false)]); + const created = await shop.request("create with read-only, write-only and unguarded fields", "POST", "/users/new", { body: JSON.stringify({ name: "Zed", email: "zed@example.com", password: "pw1234", role: "admin", age: 33, createdAt: "x", biography: "b", secretNote: "leak" }) }); + const id = text(pick(created.json, "_id")); + const row = await stored(shop, "read the new row back", "verify__users", `{ _id: "${id}" }`); + const written = row.rows[0] ?? null; + context.expect(row.folder, [ + equal("a write-only field is stored", pick(written, "password"), "pw1234"), + equal("an override lets role and age be written on new", [pick(written, "role"), pick(written, "age")], ["admin", 33]), + equal("a read-only field is not written", pick(written, "createdAt"), null), + equal("a field without @Access is not written", pick(written, "secretNote"), null), + ]); + const edited = await shop.request("edit read-only and overridden fields", "PUT", "/users/edit?id=u1", { body: JSON.stringify({ name: "Alice2", role: "hacked", age: 99, createdAt: "zzz" }) }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { name: "Alice2", role: "admin", age: 30, createdAt: "2020" })]); + const after = await stored(shop, "read u1 back", "verify__users", '{ _id: "u1" }'); + const kept = after.rows[0] ?? null; + context.expect(after.folder, [ + equal("edit wrote the read-write name", pick(kept, "name"), "Alice2"), + equal("edit ignored the role overridden to read-only", pick(kept, "role"), "admin"), + equal("edit ignored the read-only age", pick(kept, "age"), 30), + equal("edit ignored the read-only createdAt", pick(kept, "createdAt"), "2020"), + ]); +}); + +const mandatoryAndValidators = shopJourney("mandatory fields and validators answer 400 bodies with a code and every field that failed", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const missing = await shop.request("new without mandatory fields", "POST", "/users/new", { body: '{"name":"Zed"}' }); + context.expect(missing.folder, [status(missing, 400), equal("the body names the code and every missing field", missing.json, { code: MISSING, fields: ["email", "password"] })]); + const empty = await shop.request("new with an empty object", "POST", "/users/new", { body: "{}" }); + context.expect(empty.folder, [status(empty, 400), equal("the mandatory check runs before the validators", pick(empty.json, "code"), MISSING)]); + const invalid = await shop.request("new with invalid values", "POST", "/users/new", { body: JSON.stringify({ name: "Zed", email: "not-an-email", password: "pw1234", age: 500, nickname: "taken" }) }); + context.expect(invalid.folder, [status(invalid, 400), equal("the body names the code and every invalid field", invalid.json, { code: INVALID, fields: ["email", "age", "nickname"] })]); + const nothing = await stored(shop, "count the users", "verify__users"); + context.expect(nothing.folder, [equal("a refused write stores nothing", nothing.rows.length, 5)]); + const absent = await shop.request("edit sends only the name", "PUT", "/users/edit?id=u1", { body: '{"name":"Alice2"}' }); + context.expect(absent.folder, [status(absent, 200), equal("an absent mandatory field keeps its value", pick(absent.json, "email"), "alice@example.com")]); + for (const [name, body] of [["an empty string", '{"email":""}'], ["null", '{"email":null}']] as const) { + const cleared = await shop.request(`edit clears a mandatory field with ${name}`, "PUT", "/users/edit?id=u1", { body }); + context.expect(cleared.folder, [status(cleared, 400), equal("the body names the field", cleared.json, { code: MISSING, fields: ["email"] })]); + } + const taken = await shop.request("edit with a value the async validator refuses", "PUT", "/users/edit?id=u1", { body: '{"nickname":"taken"}' }); + context.expect(taken.folder, [status(taken, 400), equal("the async validator answers its code", taken.json, { code: INVALID, fields: ["nickname"] })]); + const valid = await shop.request("edit with a valid email", "PUT", "/users/edit?id=u1", { body: '{"email":"alice@corp.io"}' }); + context.expect(valid.folder, [status(valid, 200), ...holds(valid, { email: "alice@corp.io" })]); + const optional = await shop.request("new without the optional field", "POST", "/users/new", { body: JSON.stringify({ name: "Zed", email: "zed@example.com", password: "pw1234" }) }); + context.expect(optional.folder, [status(optional, 200)]); +}); + +const parsedValues = shopJourney("a validator that returns a parse result makes the route write the parsed value", async (shop) => { + const { context } = shop; + await seed(shop, USERS); + const created = await shop.request("new with a date string", "POST", "/users/new", { body: JSON.stringify({ name: "Zed", email: "zed@example.com", password: "pw1234", startsAt: "2026-01-15" }) }); + const id = text(pick(created.json, "_id")); + context.expect(created.folder, [status(created, 200), ...holds(created, { startsAt: "2026-01-15T00:00:00.000Z" })]); + const asDate = await shop.mongo("count date-typed values", `db.getSiblingDB("verify").verify__users.countDocuments({ _id: "${id}", startsAt: { $type: "date" } })`); + context.expect(asDate.folder, [equal("new stored a Date, not the string", asDate.stdout.trim(), "1")]); + const edited = await shop.request("edit with another date string", "PUT", "/users/edit?id=u1", { body: JSON.stringify({ startsAt: "2027-02-03" }) }); + context.expect(edited.folder, [status(edited, 200), ...holds(edited, { startsAt: "2027-02-03T00:00:00.000Z" })]); + const editedDate = await shop.mongo("count date-typed values on u1", 'db.getSiblingDB("verify").verify__users.countDocuments({ _id: "u1", startsAt: { $type: "date" } })'); + context.expect(editedDate.folder, [equal("edit stored a Date, not the string", editedDate.stdout.trim(), "1")]); + const refused = await shop.request("new with a value the schema refuses", "POST", "/users/new", { body: JSON.stringify({ name: "Zed", email: "zed2@example.com", password: "pw1234", startsAt: "not a date" }) }); + context.expect(refused.folder, [status(refused, 400), equal("the parse failure answers its code", refused.json, { code: INVALID, fields: ["startsAt"] })]); +}); + +const unstorableBody = shopJourney("a parsed body that holds a value no database stores answers 400", async (shop) => { + const { context } = shop; + await seed(shop, `d.verify__documents.insertMany([{ _id: "x1", title: "Bill", documentType: "invoice" }]);`); + const refused = await shop.request("new through a route that sets a bigint", "POST", "/documents/unstorable", { body: '{"title":"x"}' }); + context.expect(refused.folder, [status(refused, 400)]); + const rows = await stored(shop, "count the documents", "verify__documents"); + context.expect(rows.folder, [equal("nothing was written", rows.rows.length, 1)]); +}); + +const errorCodes: Journey = { + title: "the packed components export the codes the 400 bodies carry", + entryPoint: "the packed `components` entry required from a consumer", + async run(context) { + const loaded = await context.command("read the exported codes", "node", [ + "-e", + 'const { Validation } = require(process.argv[1]); console.log(JSON.stringify([Validation.MISSING_MANDATORY_FIELDS, Validation.INVALID_FIELD_TYPES]));', + `${context.repo}/dist/components.js`, + ]); + context.expect(loaded.folder, [exitIs(loaded, 0), equal("the exported codes are the ones the bodies carry", loaded.stdout.trim(), JSON.stringify([MISSING, INVALID]))]); + }, +}; + +export const WRITE_JOURNEYS = { + "smoke-roundtrip": smokeRoundtrip, + "crud-lifecycle": crudLifecycle, + "crud-errors": crudErrors, + "crud-subset": crudSubset, + "option-overrides": optionOverrides, + "access-modes": accessModes, + "mandatory-and-validators": mandatoryAndValidators, + "parsed-values": parsedValues, + "unstorable-body": unstorableBody, + "error-codes": errorCodes, +}; diff --git a/.agents/skills/verify-interface/package.json b/.agents/skills/verify-interface/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/.agents/skills/verify-interface/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.agents/skills/verify-interface/seeded-breaks/access.patch b/.agents/skills/verify-interface/seeded-breaks/access.patch new file mode 100644 index 0000000..b0c0432 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/access.patch @@ -0,0 +1,14 @@ +diff --git a/src/internal/properties.ts b/src/internal/properties.ts +index c4a526e..c8ef08d 100644 +--- a/src/internal/properties.ts ++++ b/src/internal/properties.ts +@@ -42,8 +42,7 @@ export function WriteProperties( + action?: string, + existingDBData?: Parameters[4], + ) { +- const actionWritable = +- meta.writable[action ?? "_default"] ?? meta.writable._default; ++ const actionWritable = meta.writable._default; + const writable = existingDBData + ? writableInBody(actionWritable, bodyData) + : actionWritable; diff --git a/.agents/skills/verify-interface/seeded-breaks/computed.patch b/.agents/skills/verify-interface/seeded-breaks/computed.patch new file mode 100644 index 0000000..cfd832f --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/computed.patch @@ -0,0 +1,13 @@ +diff --git a/src/internal/computed.ts b/src/internal/computed.ts +index 450b46b..85b2563 100644 +--- a/src/internal/computed.ts ++++ b/src/internal/computed.ts +@@ -47,7 +47,7 @@ function buildComputedMappers( + const applyDefaults: RowMapper = (row) => { + const merged: Record> = {}; + for (const [name, computed] of defaulted) { +- merged[name] = row.key(name).default(computed.default); ++ merged[name] = row.key(name); + } + return row.merge(merged).cast(); + }; diff --git a/.agents/skills/verify-interface/seeded-breaks/crud.patch b/.agents/skills/verify-interface/seeded-breaks/crud.patch new file mode 100644 index 0000000..1a7d2f0 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/crud.patch @@ -0,0 +1,13 @@ +diff --git a/src/index.ts b/src/index.ts +index f38741d..523652e 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -173,7 +173,7 @@ export namespace DefaultRoutes { + } + + const dbResult = model.constructor.fromDatabase(await query); +- assert(dbResult, 404, "Not Found"); ++ assert(dbResult, 500, "Not Found"); + Validation.Unlock(controller, meta, dbResult); + if (guard && reqCtx) await guard.call(controller, reqCtx, dbResult, params); + diff --git a/.agents/skills/verify-interface/seeded-breaks/filters.patch b/.agents/skills/verify-interface/seeded-breaks/filters.patch new file mode 100644 index 0000000..d8fdb83 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/filters.patch @@ -0,0 +1,13 @@ +diff --git a/src/internal/default-filter.ts b/src/internal/default-filter.ts +index 5ee6110..f098b22 100644 +--- a/src/internal/default-filter.ts ++++ b/src/internal/default-filter.ts +@@ -23,7 +23,7 @@ const DEFAULT_FILTER_OPERATORS: DefaultFilterOperators = { + eq: (proxy, value) => proxy.eq(value), + ne: (proxy, value) => proxy.ne(value), + gt: (proxy, value) => proxy.gt(value), +- ge: (proxy, value) => proxy.ge(value), ++ ge: (proxy, value) => proxy.gt(value), + lt: (proxy, value) => proxy.lt(value), + le: (proxy, value) => proxy.le(value), + }; diff --git a/.agents/skills/verify-interface/seeded-breaks/foreign.patch b/.agents/skills/verify-interface/seeded-breaks/foreign.patch new file mode 100644 index 0000000..1816edc --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/foreign.patch @@ -0,0 +1,12 @@ +diff --git a/src/internal/foreign.ts b/src/internal/foreign.ts +index 24dc0d4..4e36bbd 100644 +--- a/src/internal/foreign.ts ++++ b/src/internal/foreign.ts +@@ -105,7 +105,6 @@ export function Foreign( + let foreignObject: Datum = Get( + remote, + obj.key(name).cast(), +- index, + ); + if (targetJoined && targetJoined.length > 0) { + foreignObject = applyForeignJoinedDatum( diff --git a/.agents/skills/verify-interface/seeded-breaks/guards.patch b/.agents/skills/verify-interface/seeded-breaks/guards.patch new file mode 100644 index 0000000..0a752fd --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/guards.patch @@ -0,0 +1,13 @@ +diff --git a/src/index.ts b/src/index.ts +index f38741d..f154c73 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -175,7 +175,7 @@ export namespace DefaultRoutes { + const dbResult = model.constructor.fromDatabase(await query); + assert(dbResult, 404, "Not Found"); + Validation.Unlock(controller, meta, dbResult); +- if (guard && reqCtx) await guard.call(controller, reqCtx, dbResult, params); ++ if (guard && reqCtx) void guard; + + const results = await Query.ReadProperties( + controller, diff --git a/.agents/skills/verify-interface/seeded-breaks/joined.patch b/.agents/skills/verify-interface/seeded-breaks/joined.patch new file mode 100644 index 0000000..8ea1985 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/joined.patch @@ -0,0 +1,12 @@ +diff --git a/src/internal/joined.ts b/src/internal/joined.ts +index 7d10b81..2a8f86c 100644 +--- a/src/internal/joined.ts ++++ b/src/internal/joined.ts +@@ -83,7 +83,6 @@ export function splitJoinedFields( + addAll(split.filter, names); + } else if ( + hasComputedSort || +- (sortField && names.includes(sortField)) || + meta.fields[group.localKey]?.foreign + ) { + addAll(split.sort, names); diff --git a/.agents/skills/verify-interface/seeded-breaks/listing.patch b/.agents/skills/verify-interface/seeded-breaks/listing.patch new file mode 100644 index 0000000..65a222e --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/listing.patch @@ -0,0 +1,13 @@ +diff --git a/src/internal/list.ts b/src/internal/list.ts +index 50f85d9..8934696 100644 +--- a/src/internal/list.ts ++++ b/src/internal/list.ts +@@ -316,7 +316,7 @@ export function ListPage( + query: Stream, + options: ListPageOptions, + ): Stream { +- let page = query.slice(options.offset, options.limit); ++ let page = query.slice(options.offset, options.limit + 1); + const computed = pageComputedFields(meta, options); + const joined = pageJoinedFields(options, computed.size > 0); + if (joined.size > 0) page = Joined(db, meta, page, joined); diff --git a/.agents/skills/verify-interface/seeded-breaks/metadata.patch b/.agents/skills/verify-interface/seeded-breaks/metadata.patch new file mode 100644 index 0000000..70c4d74 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/metadata.patch @@ -0,0 +1,13 @@ +diff --git a/src/metadata.ts b/src/metadata.ts +index e7a8bc2..245e24f 100644 +--- a/src/metadata.ts ++++ b/src/metadata.ts +@@ -95,7 +95,7 @@ export function GetEffectiveMode( + action?: string, + ): AccessMode | undefined { + if (!action || action === DEFAULT_ACTION) return field.mode; +- return field.modeOverrides?.[action] ?? field.mode; ++ return field.mode; + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/modifiers.patch b/.agents/skills/verify-interface/seeded-breaks/modifiers.patch new file mode 100644 index 0000000..6bc6256 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/modifiers.patch @@ -0,0 +1,13 @@ +diff --git a/src/internal/validation.ts b/src/internal/validation.ts +index 29bfdb8..06d0370 100644 +--- a/src/internal/validation.ts ++++ b/src/internal/validation.ts +@@ -74,7 +74,7 @@ export function Unlock( + for (const [modifier, field] of meta.modifierKeys.entries()) { + const keyed: KeyedModifier = modifier; + const key = fieldsOf(obj)[field]; +- unlock(dbData, keyed, undefined, key); ++ unlock(dbData, keyed, undefined, undefined); + for (const [foreign, fieldData] of Object.entries(meta.fields)) { + const related = dbData[foreign]; + if (fieldData.foreign && isObject(related)) { diff --git a/.agents/skills/verify-interface/seeded-breaks/route-options.patch b/.agents/skills/verify-interface/seeded-breaks/route-options.patch new file mode 100644 index 0000000..b8c930a --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/route-options.patch @@ -0,0 +1,13 @@ +diff --git a/src/components.ts b/src/components.ts +index 96c65d8..4097423 100644 +--- a/src/components.ts ++++ b/src/components.ts +@@ -167,7 +167,7 @@ export namespace Parameters { + "Field is not sortable.", + ); + params.limit = params.limit +- ? Math.min(params.limit, params.maxPage ?? 100) ++ ? Math.min(params.limit, 100) + : params.maxPage; + + return params; diff --git a/.agents/skills/verify-interface/seeded-breaks/smoke.patch b/.agents/skills/verify-interface/seeded-breaks/smoke.patch new file mode 100644 index 0000000..1f28e05 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/smoke.patch @@ -0,0 +1,13 @@ +diff --git a/src/index.ts b/src/index.ts +index f38741d..aadb575 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -335,7 +335,7 @@ export namespace DefaultRoutes { + const query = Query.Delete(model.table, params.id); + + const dbResult = await query; +- return dbResult; ++ return dbResult && 0; + } + + Object.defineProperty(getRow, "name", { value: "get" }); diff --git a/.agents/skills/verify-interface/seeded-breaks/validation.patch b/.agents/skills/verify-interface/seeded-breaks/validation.patch new file mode 100644 index 0000000..6ea628a --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation.patch @@ -0,0 +1,11 @@ +diff --git a/src/components.ts b/src/components.ts +--- a/src/components.ts ++++ b/src/components.ts +@@ -571,7 +571,6 @@ + invalid.push(name); + } + } +- rejectFields(INVALID_FIELD_TYPES, invalid); + return parsed; + } + diff --git a/.agents/skills/verify-interface/seeded-doors/contract.patch b/.agents/skills/verify-interface/seeded-doors/contract.patch new file mode 100644 index 0000000..b4d0e56 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/contract.patch @@ -0,0 +1,13 @@ +diff --git a/src/index.ts b/src/index.ts +index f38741d..a4cc1d7 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -90,7 +90,7 @@ export function DataController< + return c as Class & TableHolder> & Base; + } + +-export const RegisterDataController = ++export const RegisterController = + (): ClassDecorator => (target) => { + const meta = GetMetadata(target, DataAPIMeta); + const prototype = prototypeOf(target); diff --git a/.agents/skills/verify-interface/seeded-doors/data.patch b/.agents/skills/verify-interface/seeded-doors/data.patch new file mode 100644 index 0000000..b096782 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/data.patch @@ -0,0 +1,13 @@ +diff --git a/src/components.ts b/src/components.ts +index 96c65d8..bf4e5aa 100644 +--- a/src/components.ts ++++ b/src/components.ts +@@ -472,7 +472,7 @@ export namespace Query { + + export function Delete(table: Table, id: string | string[]) { + return Array.isArray(id) +- ? table.getAll(id).delete() ++ ? table.delete() + : table.get(id).delete(); + } + } diff --git a/.agents/skills/verify-interface/seeded-doors/exposure.patch b/.agents/skills/verify-interface/seeded-doors/exposure.patch new file mode 100644 index 0000000..06b3163 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/exposure.patch @@ -0,0 +1,13 @@ +diff --git a/src/metadata.ts b/src/metadata.ts +index e7a8bc2..a4fd8e0 100644 +--- a/src/metadata.ts ++++ b/src/metadata.ts +@@ -212,7 +212,7 @@ export class DataAPIMeta { + continue; + } + +- if (effectiveMode & AccessMode.ReadOnly) { ++ if (effectiveMode) { + readable[field.desc?.get ? "getters" : "props"].push([key, field]); + } + if (effectiveMode & AccessMode.WriteOnly) { diff --git a/.agents/skills/verify-interface/seeded-doors/format.patch b/.agents/skills/verify-interface/seeded-doors/format.patch new file mode 100644 index 0000000..a1cc3d0 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,13 @@ +diff --git a/src/components.ts b/src/components.ts +index 96c65d8..b06c6c3 100644 +--- a/src/components.ts ++++ b/src/components.ts +@@ -502,7 +502,7 @@ export namespace Validation { + }; + + /** The `code` of a 400 naming the mandatory fields a body lacks. */ +- export const MISSING_MANDATORY_FIELDS = "missing_mandatory_fields"; ++ export const MISSING_MANDATORY_FIELDS = "missing_fields"; + /** The `code` of a 400 naming the fields whose validator refused the value. */ + export const INVALID_FIELD_TYPES = "invalid_field_types"; + diff --git a/.agents/skills/verify-interface/seeded-doors/publish.patch b/.agents/skills/verify-interface/seeded-doors/publish.patch new file mode 100644 index 0000000..ed719fe --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/publish.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 0725569..cfb49e3 100644 +--- a/package.json ++++ b/package.json +@@ -48,7 +48,7 @@ + "./package.json": "./package.json" + }, + "publishConfig": { +- "access": "public", ++ "access": "restricted", + "provenance": true + }, + "scripts": { diff --git a/.agents/skills/verify-interface/seeds.ts b/.agents/skills/verify-interface/seeds.ts new file mode 100644 index 0000000..cd56ec9 --- /dev/null +++ b/.agents/skills/verify-interface/seeds.ts @@ -0,0 +1,48 @@ +/** Five users for the list, sort and filter journeys. `password` is stored so a leak would show. */ +export const USERS = `d.verify__users.insertMany([ + { _id: "u1", email: "alice@example.com", name: "Alice", password: "p1", role: "admin", age: 30, status: "active", createdAt: "2020", biography: "bio1", internalNotes: "n1", nickname: "al", secretNote: "hidden" }, + { _id: "u2", email: "bob@gmail.com", name: "Bob", password: "p2", role: "user", age: 65, status: "inactive", createdAt: "2021", biography: "bio2", internalNotes: "n2" }, + { _id: "u3", email: "carol@gmail.com", name: "Carol", password: "p3", role: "user", age: 18, status: "active", createdAt: "2022", biography: "bio3", internalNotes: "n3" }, + { _id: "u4", email: "dave@example.com", name: "Dave", password: "p4", role: "user", age: 40, status: "active", createdAt: "2023", biography: "bio4", internalNotes: "n4" }, + { _id: "u5", email: "erin@example.com", name: "Erin", password: "p5", role: "user", age: 22, status: "active", createdAt: "2024", biography: "bio5", internalNotes: "n5" }, +]);`; + +/** Users, order items, an account in the second schema, and two orders: one whose references resolve, one whose references do not. */ +export const ORDERS = `d.verify__users.insertMany([ + { _id: "u1", email: "alice@example.com", name: "Alice", password: "p1" }, + { _id: "u2", email: "bob@gmail.com", name: "Bob", password: "p2" }, +]); +d.verify__order_items.insertMany([{ _id: "i1", name: "Widget", price: 9.99 }, { _id: "i2", name: "Gadget", price: 24.99 }]); +d.verify_auth__accounts.insertMany([{ _id: "a1", login: "alice-login" }]); +d.verify__orders.insertMany([ + { _id: "o1", userId: "u1", userEmail: "bob@gmail.com", ownerLogin: "a1", tagged: "u2", byEmail: "bob@gmail.com", items: ["i1", "i2"], itemsAll: ["i1"], total: 99 }, + { _id: "o2", userId: "nobody", userEmail: "nobody@x.io", ownerLogin: "zz", tagged: "u1", items: [], itemsAll: [], total: 5 }, +]);`; + +/** Two authors, three books (one with no author), and one account for the cross-schema join. */ +export const BOOKS = `d.verify__authors.insertMany([{ _id: "au1", name: "Zola" }, { _id: "au2", name: "Austen" }]); +d.verify_auth__accounts.insertMany([{ _id: "a1", login: "alice-login" }]); +d.verify__books.insertMany([ + { _id: "b1", authorId: "au1", title: "Nana", accountId: "a1" }, + { _id: "b2", authorId: "au2", title: "Emma", accountId: "zz" }, + { _id: "b3", authorId: "nobody", title: "Orphan" }, +]);`; + +/** Three groups, four devices: Alpha has three, Beta none, Gamma one. */ +export const GROUPS = `d.verify__groups.insertMany([{ _id: "g1", name: "Alpha" }, { _id: "g2", name: "Beta" }, { _id: "g3", name: "Gamma" }]); +d.verify__devices.insertMany([{ _id: "d1", group_id: "g1" }, { _id: "d2", group_id: "g1" }, { _id: "d3", group_id: "g1" }, { _id: "d4", group_id: "g3" }]);`; + +/** An invoice and a memo in the table the guarded route reads. */ +export const DOCUMENTS = `d.verify__documents.insertMany([{ _id: "x1", title: "Bill", documentType: "invoice" }, { _id: "x2", title: "Memo", documentType: "memo" }]);`; + +/** Two probe rows: a kind, a title, an upper-case tag, a numeric score, and a trace the setter overwrites. */ +export const PROBES = `d.verify__probes.insertMany([ + { _id: "p1", kind: "a", title: "One", origin: "seed", trace: "seed", tag: "A", score: 5 }, + { _id: "p2", kind: "b", title: "Two", origin: "seed", trace: "seed", tag: "B", score: 7 }, +]);`; + +/** Two users whose notes and nickname are stored, for the per-action read overrides. */ +export const OVERRIDES = `d.verify__users.insertMany([ + { _id: "u1", email: "alice@example.com", name: "Alice", password: "p1", notes: "note1", nickname: "al" }, + { _id: "u2", email: "bob@gmail.com", name: "Bob", password: "p2", notes: "note2", nickname: "bo" }, +]);`; diff --git a/.agents/skills/verify-interface/tsconfig.json b/.agents/skills/verify-interface/tsconfig.json new file mode 100644 index 0000000..8d4c934 --- /dev/null +++ b/.agents/skills/verify-interface/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../..", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "composite": false, + "noEmit": true + }, + "include": [ + "**/*.ts" + ] +} diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts new file mode 100644 index 0000000..4b17b19 --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,133 @@ +import { Controller } from "@antelopejs/interface-api"; +import type { VerifyConfig } from "@antelopejs/tooling-configs/verify"; + +import type * as DataApi from "../../../src/index.ts"; +import { PROBE_JOURNEYS } from "./journeys-probe.ts"; +import { READ_JOURNEYS } from "./journeys-read.ts"; +import { WRITE_JOURNEYS } from "./journeys-write.ts"; + +const FAILS_ON_AN_UNREGISTERED_SCHEMA = "Non-existent Database Schema"; + +/** The route functions of `DefaultRoutes`: each is named after its endpoint, and `All` holds the same functions. */ +function routeFunctions(api: typeof DataApi) { + const { Get, List, New, Edit, Delete, All } = api.DefaultRoutes; + return { + names: [Get.func.name, List.func.name, New.func.name, Edit.func.name, Delete.func.name], + all: [All.get.func === Get.func, All.list.func === List.func, All.new.func === New.func, All.edit.func === Edit.func, All.delete.func === Delete.func], + }; +} + +const config: VerifyConfig = { + doors: { + data: { + globs: ["src/index.ts", "src/components.ts", "src/internal/tables.ts", "src/internal/properties.ts"], + funnels: [], + containments: ["crud/crud-lifecycle"], + why: "the new, edit and delete routes write and remove rows in the consumer's database; a delete that removes a row nobody named cannot be undone", + }, + format: { + globs: ["src/components.ts", "src/internal/validation.ts"], + funnels: [], + containments: ["validation/mandatory-and-validators"], + why: "the 400 bodies (code and fields) are what clients parse", + }, + contract: { + globs: ["src/index.ts", "src/metadata.ts", "src/components.ts", "package.json"], + funnels: [], + containments: [], + why: "the decorators, DataController and DefaultRoutes are what every module serving a table imports", + }, + outbound: null, + exposure: { + globs: ["src/internal/read-properties.ts", "src/metadata.ts"], + funnels: [], + containments: [], + why: "the access modes decide which fields a response holds; a write-only field that leaks exposes a secret", + }, + publish: { + globs: ["package.json", ".release-it.json", "changelogen.config.json", ".github/workflows/release.yml"], + funnels: [], + containments: [], + why: "the package name and release workflow claim @antelopejs/interface-data-api on npm", + }, + }, + formal: [ + { + name: "formal-field-access", + covers: [ + "src/metadata.ts", + "src/components.ts", + "src/index.ts", + "src/internal/read-properties.ts", + "src/internal/properties.ts", + "src/internal/meta-records.ts", + "src/internal/prototypes.ts", + ], + files: [".agents/skills/formal-field-access/**"], + command: ["node", ".agents/skills/formal-field-access/check.mjs", "--full"], + baseCommand: ["node", ".agents/skills/formal-field-access/check.mjs"], + violation: /^(?:finding|BROKEN|CHANGED|HOLDS) +(\w+)/gm, + passing: /(?<=^holds +(?:\w+, )*)(\w+)(?=,| \()/gm, + behaviour: { kind: "emit" }, + }, + ], + services: { + mongo: { + image: "mongo:7", + port: 27017, + ready: ["mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 }).ok"], + reset: ["mongosh", "--quiet", "--eval", 'db.getSiblingDB("verify").dropDatabase(); db.getSiblingDB("verify_auth").dropDatabase()'], + }, + }, + interface: { + calls: { + ".:DataController": { + call: (api) => { + try { + api.DataController(class Bogus {}, {}, Controller("/bogus")); + return Promise.resolve("declared"); + } catch (error) { + return Promise.resolve(error instanceof Error ? error.message : "thrown"); + } + }, + expect: FAILS_ON_AN_UNREGISTERED_SCHEMA, + }, + ".:GetDataControllerMeta": { + call: (api) => Promise.resolve(api.GetDataControllerMeta({}).setMode.name), + expect: "setMode", + }, + ".:RegisterDataController": { call: (api) => Promise.resolve(api.RegisterDataController().length), expect: 1 }, + ".:DefaultRoutes.WithOptions": { + call: (api) => { + const { Get, WithOptions } = api.DefaultRoutes; + const renamed = WithOptions(Get, { noForeign: "true" }, "raw"); + const merged = WithOptions(WithOptions(Get, { noForeign: "true" }), { index: "email" }); + return Promise.resolve({ endpoint: renamed.endpoint ?? null, options: renamed.options ?? null, wrapsGet: renamed.callback === Get, merged: merged.options ?? null }); + }, + expect: { endpoint: "raw", options: { noForeign: "true" }, wrapsGet: true, merged: { noForeign: "true", index: "email" } }, + }, + ".:DefaultRoutes.WithGetGuard": { + call: (api) => { + const guarded = api.DefaultRoutes.WithGetGuard(() => undefined); + return Promise.resolve({ method: guarded.method, args: guarded.args === api.DefaultRoutes.Get.args, ownFunc: guarded.func !== api.DefaultRoutes.Get.func }); + }, + expect: { method: "get", args: true, ownFunc: true }, + }, + ...Object.fromEntries( + ["Get", "List", "New", "Edit", "Delete"].map((route) => [ + `.:DefaultRoutes.${route}.func`, + { call: (api: typeof DataApi) => Promise.resolve(routeFunctions(api).names), expect: ["get", "list", "new", "edit", "delete"] }, + ]), + ), + ...Object.fromEntries( + ["get", "list", "new", "edit", "delete"].map((route) => [ + `.:DefaultRoutes.All.${route}.func`, + { call: (api: typeof DataApi) => Promise.resolve(routeFunctions(api).all), expect: [true, true, true, true, true] }, + ]), + ), + }, + }, + journeys: { ...WRITE_JOURNEYS, ...READ_JOURNEYS, ...PROBE_JOURNEYS }, +}; + +export default config; diff --git a/.claude/skills/verify-interface b/.claude/skills/verify-interface new file mode 120000 index 0000000..c7df9cd --- /dev/null +++ b/.claude/skills/verify-interface @@ -0,0 +1 @@ +../../.agents/skills/verify-interface \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0764a75..78674ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ The release commit writes `CHANGELOG.md`. Upgrade notes for that version follow Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Prove a change works with the verification skill in [`.agents/skills/verify-interface/SKILL.md`](.agents/skills/verify-interface/SKILL.md) before you call it done. + The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. ### Comments