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..5398acc --- /dev/null +++ b/.agents/skills/verify-interface/features/README.md @@ -0,0 +1,43 @@ +# Feature map + +One file per feature. `covers:` lists the source globs it owns, `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. + +Every journey runs a scenario inside `ajs module test` with `@antelopejs/mongodb` against a MongoDB container, then reads the database back with `mongosh`. The scenarios live in `scenarios/`, the runner in `runner.ts`. + +| Feature | What the user does | Journeys | +|---|---|---| +| [autodate](autodate.md) | Creation and update timestamps are set on write. | autodate | +| [custom-modifiers](custom-modifiers.md) | A developer writes a one-way, two-way, container or events-only modifier. | one-way-modifier, two-way-modifier, container-modifier, modifier-events | +| [declarations](declarations.md) | The packed type declarations match the return types the docs list. | model-signatures, modifier-signatures | +| [encryption](encryption.md) | A field is stored encrypted and read back decrypted. | encryption-at-rest, encryption-dates, encryption-keys | +| [hashing](hashing.md) | A field is stored hashed and tested against a candidate. | hash-storage, hash-verify, hash-limits, hash-upgrade, hash-digest | +| [localization](localization.md) | A field keeps one text per locale with a fallback. | localize, localize-wildcard, localized-storage | +| [models](models.md) | A developer reads and writes rows through a typed model. | crud, conversion, validation, get-model, model-factory, extend-model | +| [parameter-decoration](parameter-decoration.md) | A controller gets a model injected per route, instance or property. | fixed-instance, per-request-instance, property-decoration, crud-controller | +| [registration](registration.md) | A module registers its schema, fills fixtures and opens tables from their classes. | register-schema, fixtures, fixture-race, table-access | +| [smoke](smoke.md) | The fixed journeys every verdict runs. | register-schema, crud | +| [tables](tables.md) | A developer describes a table with Field, Index and Relation, and subclasses it. | index-options, group-flags, field-types, relation-metadata, inheritance, table-with | + +## Not driven + +| Documented behaviour | Why no journey drives it | +|---|---| +| The minor-bump rule of the interface kind | Fails at the current head, not at a change: `campaign/integration` exports more than the published 0.1.12 and `package.json` is still 0.1.12. Add it to `smoke` in the PR that bumps the version. | +| A model "stays on the declaring module's database even when another module calls `GetModel`" (docs/4, GetModel) | Needs two modules with two databases behind one provider; one container serves one database here. | +| `testHash` and `testHashAsync` spend one scrypt run whatever the row holds, and the time of a refusal does not tell the row's format (docs/3, HashModifier) | Driven (`hashing/hash-cost`) by ratios against one async run, retried up to three times, not by absolute times. | +| A digest field's test spends no scrypt run (docs/3) | Driven (`hashing/hash-cost`), by the same ratios. | +| A stored scrypt string with `ln` of at least 16 × `r` refuses every candidate (docs/3) | Node's own scrypt cannot compute such a hash, so no correct candidate exists to refuse; the boundary just below it is driven. | +| Encrypted fields keep whole-second dates only (docs/3) | Driven (`encryption-dates`). | +| `Fixture` generator for a class that does not extend the decorated one throws "does not extend" | Not reachable through the public API; the generator is wrapped by the decorator. | +| Queries that read a modified field through `model.table` (`unlockrequest`, locale fallback in AQL) | The docs only say `model.table` returns AQL results; they describe no query behaviour of the modifiers. | +| `@Model` on a method of a controller outside a request | Docs state the property is unset outside a request (driven in `property-decoration`); no other claim. | +| Updating one modified field beside others keeps them (docs/3 salt reuse, docs/4 `_internal`) | Driven by `models/partial-update-keeps-modified-fields`, which FAILS on purpose: known finding AntelopeJS/interface-database-decorators#80. The verdict stays FAIL for `models` until #80 is fixed. | +| `toDatabase` "has no prototype" (docs/4) | The result is a plain object with `Object.prototype`; the journey checks it is not an instance of the table class. The wording is loose, noted in the hand-back. | +| Database-driver behaviours: a driver that cannot enforce a unique secondary index refuses the schema (docs/2) | Belongs to the database module (rethinkdb); this repository only passes `unique` to the provider (driven through MongoDB in `index-options`). | +| `CROSS_INSTANCE` indexes and `between()` on a real driver beyond the schema definition | The cross-instance index is checked in the definition and in MongoDB's index list; query speed is not observable. | +| Without `crossInstance`, the `@antelopejs/mongodb` and `@antelopejs/rethinkdb` drivers log a warning once per index (docs/2) | A log line of the database module, not of this package; the index definition that triggers it is driven. | +| Call `RegisterSchema` in the declaring module so the binding follows that module's database (docs/2) | Needs two modules on two databases; one container serves one database here. | +| `unlockrequest` shows database expressions the encoded value of a custom two-way modifier (docs/3) | Needs an AQL expression over a modified field; the docs describe no result a journey could compare beyond the read-back it drives. | +| The first codec message wording (docs/4 Validation) | Driven in `validation`: the first entry that has a message is used and `invalid ` otherwise. An entry without a message is read as reporting none, which matches the sentence. | +| `X.with(...)` keeps the fields and indexes decorated on `X` (docs/2 Inheritance, applied to the class `with` returns) | Driven by `tables/table-with-keeps-fields`, which FAILS on purpose: known finding AntelopeJS/interface-database-decorators#81. The `tables` verdict stays FAIL until #81 is fixed. | +| A raw stored empty text in the query path (`unlockrequest`) | The docs promise the fallback for texts the modifier wrote (an empty text is stored as no text); a hand-written `""` in the database is not described. Driven for the written case in `localized-query`. | diff --git a/.agents/skills/verify-interface/features/autodate.md b/.agents/skills/verify-interface/features/autodate.md new file mode 100644 index 0000000..5fdac08 --- /dev/null +++ b/.agents/skills/verify-interface/features/autodate.md @@ -0,0 +1,32 @@ +--- +covers: + - src/modifiers/autodate.ts +docs: + - docs/3.table-modifiers.md +journeys: + - autodate + - autodate-object-update +--- + +# Auto dates + +`CreationTime` and `UpdateTime`. + +## Sub-features + +- `autodate-object-update`: the update form that carries the primary key. +- `autodate`: insert, update and the stored dates. + +## How to get to it (user POV) + +- A table marks `Date` fields `@CreationTime()` and `@UpdateTime()`; no mixin. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" autodate +``` + +## Gotchas + +- The update path waits 60 ms so the refreshed time differs. diff --git a/.agents/skills/verify-interface/features/custom-modifiers.md b/.agents/skills/verify-interface/features/custom-modifiers.md new file mode 100644 index 0000000..ba6e499 --- /dev/null +++ b/.agents/skills/verify-interface/features/custom-modifiers.md @@ -0,0 +1,46 @@ +--- +covers: + - src/modifiers/common.ts + - src/modifiers/index.ts + - src/modifiers/internal/** +docs: + - docs/3.table-modifiers.md +journeys: + - one-way-modifier + - two-way-modifier + - container-modifier + - container-query + - metadata-class + - legacy-metadata + - modifier-helpers + - modifier-events +--- + +# Custom modifiers + +`Modifier`, `OneWayModifier`, `TwoWayModifier`, `ContainerModifier`, `attachModifier` and the modifier events. + +## Sub-features + +- `one-way-modifier`: transform on write; a second one-way modifier on a field is refused. +- `two-way-modifier`: transform on write, unlock on read. +- `modifier-helpers`: the exported helpers of the modifier machinery. +- `container-query`: the key used in a query. +- `metadata-class`: `inherit` and `upgrade` of a published `MetadataClass`. +- `legacy-metadata`: metadata stored by an older copy of the package. +- `container-modifier`: values by key and the wildcard. +- `modifier-events`: event order for insert, update and reads; events-only modifiers add no accessor. + +## How to get to it (user POV) + +- A developer extends a modifier class and registers it with `attachModifier`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" custom-modifiers +``` + +## Gotchas + +- Scenarios define the modifiers in plain JavaScript with the same method names as the docs. diff --git a/.agents/skills/verify-interface/features/declarations.md b/.agents/skills/verify-interface/features/declarations.md new file mode 100644 index 0000000..4c3f0b2 --- /dev/null +++ b/.agents/skills/verify-interface/features/declarations.md @@ -0,0 +1,45 @@ +--- +covers: + - src/index.ts + - src/common.ts + - src/model.ts + - src/table.ts + - src/relation.ts + - src/database.ts + - src/schema.ts + - src/modifiers/*.ts +docs: + - docs/4.data-models.md +journeys: + - docs-examples + - model-signatures + - modifier-signatures + - rejected-calls + - packed-subpaths +--- + +# Declarations + +The packed `.d.ts` files. + +## Sub-features + +- `docs-examples`: every complete TypeScript example of docs/ and README.md (blocks not marked `fragment`) compiles against the packed declarations. +- `model-signatures`: the return types of the model methods and `RegisterSchema`, the row aliases, `getBy` keys, `@Model` without an instance, async fixtures, `toDatabase` of plain data, and `X.with(...)` on a decorated class. +- `packed-subpaths`: the packed package, not the checkout, serves every subpath the skill file imports. +- `rejected-calls`: every wrong call listed in the journey is refused by tsc. +- `modifier-signatures`: the hash and localization mixin methods. + +## How to get to it (user POV) + +- A consumer compiles against `dist/index.d.ts` in strict mode. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" declarations +``` + +## Gotchas + +- Needs no database. diff --git a/.agents/skills/verify-interface/features/encryption.md b/.agents/skills/verify-interface/features/encryption.md new file mode 100644 index 0000000..a579d33 --- /dev/null +++ b/.agents/skills/verify-interface/features/encryption.md @@ -0,0 +1,34 @@ +--- +covers: + - src/modifiers/encryption.ts +docs: + - docs/3.table-modifiers.md +journeys: + - encryption-at-rest + - encryption-dates + - encryption-keys +--- + +# Encryption + +`EncryptionModifier` and `@Encrypted`. + +## Sub-features + +- `encryption-at-rest`: base64 ciphertext, iv and auth tag; only the key opens it. +- `encryption-dates`: a `Date` returns with whole-second precision. +- `encryption-keys`: the key is a UTF-8 string, so a hex key is refused. + +## How to get to it (user POV) + +- A table extends `Table.with(EncryptionModifier)` and marks fields `@Encrypted`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" encryption +``` + +## Gotchas + +- The scenario decrypts the stored triple with Node's crypto alone. diff --git a/.agents/skills/verify-interface/features/hashing.md b/.agents/skills/verify-interface/features/hashing.md new file mode 100644 index 0000000..f013517 --- /dev/null +++ b/.agents/skills/verify-interface/features/hashing.md @@ -0,0 +1,48 @@ +--- +covers: + - src/modifiers/hash.ts +docs: + - docs/3.table-modifiers.md +journeys: + - hash-storage + - hash-verify + - hash-limits + - hash-upgrade + - hash-bounds + - hash-memory + - hash-sync-async + - hash-cost + - hash-outdated + - hash-digest +--- + +# Hashing + +`HashModifier` and `@Hashed`. + +## Sub-features + +- `hash-storage`: the scrypt format and parameters, checked with an independent scrypt. +- `hash-verify`: `testHash`, `testHashAsync`, `isHashOutdated`. +- `hash-bounds`: both edges of the `ln`, `r`, `p`, salt and key ranges. +- `hash-memory`: the 128 MiB limit at, under and over the line. +- `hash-sync-async`: `testHash` equals `testHashAsync` on current, legacy, low-cost, junk, empty and digest rows. +- `hash-cost`: one scrypt run per test, sync and async, whatever the row holds, none for a digest, and async off the loop. The sync call is timed against one async run, so a `testHash` that skipped the run on an empty or junk row fails. Ratios and tick counts only, retried up to three times. +- `hash-outdated`: `isHashOutdated` for a changed `p`, `r` and `ln`. +- `hash-limits`: the limits a stored scrypt string must stay inside. +- `hash-upgrade`: older formats verify, are flagged and are rewritten. +- `hash-digest`: `algorithm` digests with the row's shared salt. + +## How to get to it (user POV) + +- A table extends `Table.with(HashModifier)` and marks fields `@Hashed`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" hashing +``` + +## Gotchas + +- Each scrypt run takes about 32 MiB and blocks the loop; the journeys take several seconds. diff --git a/.agents/skills/verify-interface/features/localization.md b/.agents/skills/verify-interface/features/localization.md new file mode 100644 index 0000000..76f6631 --- /dev/null +++ b/.agents/skills/verify-interface/features/localization.md @@ -0,0 +1,38 @@ +--- +covers: + - src/modifiers/localization.ts +docs: + - docs/3.table-modifiers.md +journeys: + - localize + - localized-query + - localize-wildcard + - localize-wildcard-replace + - localized-storage +--- + +# Localization + +`LocalizationModifier` and `@Localized`. + +## Sub-features + +- `localize`: switching locale, fallback, restricting fields. +- `localized-query`: `unlockrequest` with a fallback locale. +- `localize-wildcard-replace`: a wildcard write replaces what was stored. +- `localize-wildcard`: the `"*"` key. +- `localized-storage`: one value per locale in MongoDB. + +## How to get to it (user POV) + +- A table extends `Table.with(LocalizationModifier)` and marks fields `@Localized`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" localization +``` + +## Gotchas + +- An empty text counts as no text, so the fallback answers for it. diff --git a/.agents/skills/verify-interface/features/models.md b/.agents/skills/verify-interface/features/models.md new file mode 100644 index 0000000..da4bc5c --- /dev/null +++ b/.agents/skills/verify-interface/features/models.md @@ -0,0 +1,51 @@ +--- +covers: + - src/model.ts +docs: + - docs/4.data-models.md + - docs/1.introduction.md +journeys: + - crud + - conversion + - validation + - get-model + - plain-data + - compound-get-by + - get-by-keys + - autodate-object-update + - partial-update-keeps-modified-fields + - model-factory + - extend-model +--- + +# Models + +`BasicDataModel`, its CRUD methods, conversions, validation and `GetModel`. + +## Sub-features + +- `crud`: ids, counts and read-backs. +- `conversion`: the three static conversions save nothing; reads convert. +- `validation`: codecs, opt-in `validate`, partial patches, and a refusal that is an `Error`. +- `get-model`: one cached model per class and instance id; instances stay apart. +- `autodate-object-update`: the update form that carries the primary key, as in [autodate](autodate.md). +- `plain-data`: `toPlainData` output. +- `compound-get-by`: a three-field compound index, with text keys and with number, boolean and value proxy keys. +- `get-by-keys`: a number, a boolean and a value proxy key, as `Table.getAll` takes them. +- `partial-update-keeps-modified-fields`: known finding #80, fails until fixed. +- `model-factory`: the table name comes from the argument or `RegisterTable`. +- `extend-model`: a model subclass adds query methods. + +## How to get to it (user POV) + +- A module builds a model class from a table class and gets it with `GetModel`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" models +``` + +## Gotchas + +- `update` with a patch of one modified field erases the other modified fields of the row (bug, issue draft; not pinned). diff --git a/.agents/skills/verify-interface/features/parameter-decoration.md b/.agents/skills/verify-interface/features/parameter-decoration.md new file mode 100644 index 0000000..3cab970 --- /dev/null +++ b/.agents/skills/verify-interface/features/parameter-decoration.md @@ -0,0 +1,40 @@ +--- +covers: + - src/model.ts +docs: + - docs/5.parameter-decoration.md +journeys: + - fixed-instance + - per-request-instance + - custom-parameter-provider + - property-decoration + - crud-controller + - decorated-before-binding +--- + +# Parameter decoration + +`@Model` on a controller parameter or property. + +## Sub-features + +- `fixed-instance`: an instance id. +- `per-request-instance`: a callback picks the instance, or `undefined` for the default. +- `custom-parameter-provider`: the docs' custom decorator example. +- `property-decoration`: a property is set while a request is handled. +- `crud-controller`: `@Model` beside `@Parameter` and `@RawBody` in a CRUD controller. +- `decorated-before-binding`: a controller decorated before `RegisterSchema` runs, as a module declares it at import. + +## How to get to it (user POV) + +- A controller method declares `@Model(UserModel, "main")` on a parameter. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" parameter-decoration +``` + +## Gotchas + +- The journeys also load `@antelopejs/api` on a free port picked per run. diff --git a/.agents/skills/verify-interface/features/registration.md b/.agents/skills/verify-interface/features/registration.md new file mode 100644 index 0000000..68c589b --- /dev/null +++ b/.agents/skills/verify-interface/features/registration.md @@ -0,0 +1,52 @@ +--- +covers: + - src/database.ts + - src/schema.ts + - src/internal/bound-schema.ts + - src/table.ts +docs: + - docs/2.table-definitions.md + - docs/1.introduction.md +journeys: + - register-schema + - fixtures + - fixture-race + - table-access + - fixture-not-empty + - fixture-once + - fixture-mixed-tables + - quick-start + - fixture-ids-mixed + - table-foreign-instance +--- + +# Registration + +`RegisterSchema`, `Fixture` and `Table.table()`. + +## Sub-features + +- `register-schema`: provisioning, a table without any `@Field` included, binding and the unknown-schema error. +- `fixtures`: fixtures fill an empty table once, a table without any `@Field` included, with generated ids and the insert conversion. +- `fixture-race`: two starts write each row once; a subclass gets its parent's fixture. +- `fixture-once`: the generator runs once over several `RegisterSchema` calls. +- `fixture-mixed-tables`: only the empty table of a schema gets its fixture. +- `quick-start`: the docs/1 block, compiled and run as written. +- `fixture-not-empty`: fixtures only fill an empty table. +- `fixture-ids-mixed`: ids kept and positions for the rest. +- `table-foreign-instance`: a held instance of another schema is used as is. +- `table-access`: `Table.table` for the default, a named, the cross-instance and a held instance, and its two errors. + +## How to get to it (user POV) + +- A module calls `await RegisterSchema(id)` during initialization. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" registration +``` + +## Gotchas + +- A stored fixture row without an id is keyed `:fixture:`. diff --git a/.agents/skills/verify-interface/features/smoke.md b/.agents/skills/verify-interface/features/smoke.md new file mode 100644 index 0000000..edec47f --- /dev/null +++ b/.agents/skills/verify-interface/features/smoke.md @@ -0,0 +1,33 @@ +--- +covers: +docs: + - README.md +journeys: + - register-schema + - crud +--- + +# Smoke + +The fixed journeys every verdict runs, whatever the diff touches: provision a schema from decorated tables, write and read rows through a model in a live MongoDB,. + +## Sub-features + +- `register-schema`: as in [registration](registration.md). +- `crud`: as in [models](models.md). + +## How to get to it (user POV) + +- A module that imports `@antelopejs/interface-database-decorators`, declares a table and calls `RegisterSchema`, in a project with a database module. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" smoke +``` + +## Gotchas + +- The smoke feature covers no glob, so it never marks a file as mapped. +- The pack's `minor-bump` journey is not in this feature. It fails at the current head: `campaign/integration` already adds and changes exports over the published 0.1.12 while `package.json` still says 0.1.12, so the next release must be at least a minor. Add `minor-bump` to `smoke` in the PR that bumps the version. +- `every-function` is not part of this feature: the package exports decorators, classes and plain functions, and no interface proxy a stub provider could answer. The scenarios below it drive the real functions against MongoDB instead. diff --git a/.agents/skills/verify-interface/features/tables.md b/.agents/skills/verify-interface/features/tables.md new file mode 100644 index 0000000..5557565 --- /dev/null +++ b/.agents/skills/verify-interface/features/tables.md @@ -0,0 +1,52 @@ +--- +covers: + - src/table.ts + - src/relation.ts + - src/common.ts +docs: + - docs/2.table-definitions.md +journeys: + - index-options + - group-flags + - field-types + - relation-metadata + - forward-relation + - inheritance + - table-with + - table-with-subclass + - table-with-keeps-fields + - subclass-modifiers + - combine-modifiers +--- + +# Tables + +A table class with `Field`, `Index`, `Relation`, subclasses and `Table.with`. + +## Sub-features + +- `index-options`: plain, compound, cross-instance and unique indexes reach the provider and MongoDB. +- `group-flags`: `unique` and `crossInstance` on one field apply to the whole group. +- `field-types`: every field type goes through unchanged, in either decorator order. +- `forward-relation`: the thunk allows forward references. +- `relation-metadata`: `Relation` records links and enforces none. +- `inheritance`: a subclass copies its parent and never writes into it. +- `combine-modifiers`: three modifiers on one table. +- `table-with-subclass`: the result of `with` extends the class it was called on. +- `table-with-keeps-fields`: known finding #81, fails until fixed. +- `subclass-modifiers`: modifier and event lists are copied per subclass. +- `table-with`: mixin methods reach the subclass only. + +## How to get to it (user POV) + +- A module declares `Table` subclasses and registers them. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" tables +``` + +## Gotchas + +- MongoDB creates indexes after the schema call returns, so the index read-back waits 2.5 seconds. 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/runner.ts b/.agents/skills/verify-interface/runner.ts new file mode 100644 index 0000000..f82f370 --- /dev/null +++ b/.agents/skills/verify-interface/runner.ts @@ -0,0 +1,149 @@ +import { mkdirSync, symlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; + +import { equal } from "@antelopejs/tooling-configs/verify"; +import type { Check, Journey, JourneyContext } from "@antelopejs/tooling-configs/verify"; + +/** The Mongo database every scenario uses, dropped before each journey by the service's `reset`. */ +export const DATABASE = "verify"; + +/** One native read-back: a `mongosh` expression evaluated against the verify database, and the JSON it must print. */ +export interface Stored { + name: string; + expression: string; + expected: string; +} + +/** What a scenario must print, and what the database itself must hold afterwards. */ +export interface Scenario { + title: string; + /** Statements run inside a mocha test after the prelude; `return` the observations the docs promise. */ + body: string; + /** The JSON the body returns. */ + printed: string; + stored?: Stored[]; + /** Also loads `@antelopejs/api` on a free port, exposed to the body as `process.env.VERIFY_API_PORT`. */ + api?: boolean; + /** Runs before the test with the module folder; the environment it returns is added to the run. */ + prepare?: (context: JourneyContext, folder: string) => Promise<{ [name: string]: string }>; +} + +/** The helpers every scenario shares: the packed decorators, the database interface, and small helpers. */ +const PRELUDE = ` +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const dist = process.env.VERIFY_REPO + "/dist/index.js"; +const d = require(dist); +const iface = require(require.resolve("@antelopejs/interface-database", { paths: [dist] })); +const decorate = (cls, props) => { for (const [key, list] of Object.entries(props)) for (const apply of list) apply(cls.prototype, key); return cls; }; +const caught = (fn) => { try { fn(); return null; } catch (error) { return error.message; } }; +const rejection = async (promise) => { try { await promise; return null; } catch (error) { return { name: error.name, message: error.message }; } }; +const mongoDriver = () => require(require.resolve("mongodb", { paths: [process.env.VERIFY_MODULE_CACHE + "/@antelopejs/mongodb"] })); +const indexNames = async (collection) => { const client = new (mongoDriver().MongoClient)(process.env.VERIFY_MONGO_URL); try { await client.connect(); return (await client.db("${DATABASE}").collection(collection).indexes()).map((index) => index.name).sort(); } finally { await client.close(); } }; +const untilIndexed = async (collection, names) => { for (let attempt = 0; attempt < 120; attempt += 1) { const have = await indexNames(collection); if (names.every((name) => have.includes(name))) return have; await new Promise((resolve) => setTimeout(resolve, 500)); } return null; }; +const refusedDuplicate = async (model, make) => { for (let attempt = 0; attempt < 60; attempt += 1) { try { const [id] = await model.insert(make(attempt)); await model.delete(id); } catch (error) { return error; } await new Promise((resolve) => setTimeout(resolve, 500)); } return null; }; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const rawTable = (schemaId, table, instance) => iface.Schema.get(schemaId).instance(instance).table(table); +`; + +const TAIL = ` +describe("scenario", function () { + it("runs", async function () { + this.timeout(120000); + const out = await main(); + console.log("RESULT " + JSON.stringify(out, (key, value) => (value === undefined ? null : value))); + }); +}); +`; + +const TEST_CONFIG = ` +const { defineConfig } = require(require.resolve("@antelopejs/interface-core/config", { paths: [process.env.VERIFY_REPO] })); +const modules = { + local: { source: { type: "local", path: "." } }, + mongodb: { source: { type: "package", package: "@antelopejs/mongodb", version: "1.5.0" }, config: { url: process.env.VERIFY_MONGO_URL, database: "${DATABASE}" } }, +}; +if (process.env.VERIFY_API_PORT) { + modules.api = { + source: { type: "package", package: "@antelopejs/api", version: "1.6.0" }, + config: { servers: [{ protocol: "http", host: "127.0.0.1", port: Number(process.env.VERIFY_API_PORT) }], publicBaseUrl: "http://127.0.0.1:" + process.env.VERIFY_API_PORT }, + }; +} +module.exports = defineConfig({ name: "verify-scenario", cacheFolder: process.env.VERIFY_MODULE_CACHE, modules, test: { folder: "." } }); +`; + +const MODULE_MANIFEST = JSON.stringify({ + name: "verify-scenario", + version: "1.0.0", + main: "index.js", + antelopeJs: { test: "./antelope.test.js", standalone: true }, +}); + +const MODULE_ENTRY = "exports.construct = () => {};\nexports.start = () => {};\nexports.stop = () => {};\nexports.destroy = () => {};\n"; + +/** Asks the kernel for a free loopback port. */ +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 && "port" in address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +/** Reads `mongosh` output as one compact JSON line. */ +function compact(text: string): string { + return text.trim().split("\n").at(-1) ?? ""; +} + +/** Runs a scenario in a real `ajs module test` with the packed decorators and a MongoDB module wired to the Docker service, then reads the database back through `mongosh`: the second view. */ +export function scenario({ title, body, printed, stored = [], api = false, prepare }: Scenario): Journey { + return { + title, + entryPoint: "a module test run by `ajs module test` with the decorators, @antelopejs/mongodb and a MongoDB container", + async run(context: JourneyContext) { + const mongo = context.service("mongo"); + const folder = join(context.work, "module"); + mkdirSync(folder, { recursive: true }); + symlinkSync(join(context.repo, "node_modules"), join(folder, "node_modules"), "dir"); + context.edit("write the module manifest", join(folder, "package.json"), `${MODULE_MANIFEST}\n`); + context.edit("write the module entry", join(folder, "index.js"), MODULE_ENTRY); + context.edit("write the test configuration", join(folder, "antelope.test.js"), TEST_CONFIG); + context.edit("write the scenario", join(folder, "scenario.test.js"), `${PRELUDE}\nasync function main() {\n${body}\n}\n${TAIL}`); + const cache = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "antelopejs-verify", "ids-modules"); + const apiPort = api ? String(await freePort()) : ""; + const extra = prepare === undefined ? {} : await prepare(context, folder); + const env = { + VERIFY_REPO: context.repo, + VERIFY_MODULE_CACHE: cache, + VERIFY_MONGO_URL: `mongodb://${mongo.host}:${mongo.port}`, + VERIFY_API_PORT: apiPort, + ...extra, + }; + const ran = await context.command("run the scenario", join(context.repo, "node_modules", ".bin", "ajs"), ["module", "test", folder, "-f", join(folder, "scenario.test.js")], { + cwd: folder, + env, + timeoutMs: 300_000, + }); + const line = ran.stdout.split("\n").find((entry) => entry.startsWith("RESULT ")); + context.expect(ran.folder, [ + { name: "the scenario ended cleanly", pass: ran.exitCode === 0, detail: `${ran.stdout.slice(-600)}${ran.stderr.slice(-300)}` }, + equal("the scenario printed what the docs promise", line?.slice("RESULT ".length) ?? "", printed), + ]); + for (const read of stored) { + const expression = `print(JSON.stringify(${read.expression.replaceAll("$DB", `db.getSiblingDB("${DATABASE}")`)}))`; + let result = await context.native(read.name, "mongo", ["mongosh", "--quiet", "--eval", expression]); + for (let again = 0; again < 20 && compact(result.stdout) !== read.expected; again += 1) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + result = await context.native(read.name, "mongo", ["mongosh", "--quiet", "--eval", expression]); + } + const checks: Check[] = [equal(`MongoDB holds ${read.name}`, compact(result.stdout), read.expected)]; + context.expect(result.folder, checks); + } + }, + }; +} diff --git a/.agents/skills/verify-interface/scenarios/custom.ts b/.agents/skills/verify-interface/scenarios/custom.ts new file mode 100644 index 0000000..0c44a69 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/custom.ts @@ -0,0 +1,244 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +const PRELUDE = ` + const { MakePropertyDecorator } = require(require.resolve("@antelopejs/interface-core/decorators", { paths: [dist] })); +`; + +const ONE_WAY = `${PRELUDE} + class PrefixHashModifier extends d.OneWayModifier { + autolock = true; + lock(_locked, value) { if (value === undefined) return undefined; return crypto.createHash(this.options.algorithm || "sha256").update(JSON.stringify(value)).digest("hex"); } + [d.MixinSymbol] = class {}; + } + const PrefixHashed = MakePropertyDecorator((target, propertyKey, options) => { d.attachModifier(target.constructor, PrefixHashModifier, propertyKey, options || {}); }); + class Doc extends d.Table {} + decorate(Doc, { title: [d.Field("string")], digest: [PrefixHashed(), d.Field("string")], strong: [PrefixHashed({ algorithm: "sha512" }), d.Field("string")] }); + d.RegisterTable("docs", "oneway")(Doc); + await d.RegisterSchema("oneway"); + const model = d.GetModel(d.BasicDataModel(Doc)); + const [id] = await model.insert({ title: "t", digest: "value", strong: "value" }); + const raw = await rawTable("oneway", "docs").get(id); + const loaded = await model.get(id); + return { + tested: [d.testValue(loaded, "digest", "value"), d.testValue(loaded, "digest", "other"), d.testValue(loaded, "strong", "value")], + stored: [raw._internal.data.digest === crypto.createHash("sha256").update(JSON.stringify("value")).digest("hex"), raw._internal.data.strong === crypto.createHash("sha512").update(JSON.stringify("value")).digest("hex")], + afterOneWay: caught(() => { class T extends d.Table.with(d.HashModifier, d.EncryptionModifier) {} decorate(T, { pin: [d.Hashed(), d.Encrypted({ secretKey: "12345678901234567890123456789012" })] }); }), + beforeOneWay: caught(() => { class T extends d.Table.with(d.HashModifier, d.EncryptionModifier) {} decorate(T, { pin: [d.Encrypted({ secretKey: "12345678901234567890123456789012" }), d.Hashed()] }); }), + secondOneWay: caught(() => { class Twice extends d.Table {} decorate(Twice, { f: [PrefixHashed(), PrefixHashed()] }); }), + }; +`; + +const TWO_WAY = `${PRELUDE} + class Base64Modifier extends d.TwoWayModifier { + autolock = true; + autounlock = true; + lock(_locked, value) { if (value === undefined) return undefined; return (this.options.prefix || "b64-") + Buffer.from(JSON.stringify(value)).toString("base64"); } + unlock(locked) { if (typeof locked !== "string") return undefined; const prefix = this.options.prefix || "b64-"; return JSON.parse(Buffer.from(locked.substring(prefix.length), "base64").toString("utf8")); } + unlockrequest(data) { return data; } + [d.MixinSymbol] = class {}; + } + const Base64Encoded = MakePropertyDecorator((target, propertyKey, options) => { d.attachModifier(target.constructor, Base64Modifier, propertyKey, options || {}); }); + class Doc extends d.Table {} + decorate(Doc, { title: [d.Field("string")], body: [Base64Encoded(), d.Field("any")], tagged: [Base64Encoded({ prefix: "x:" }), d.Field("any")] }); + d.RegisterTable("docs", "twoway")(Doc); + await d.RegisterSchema("twoway"); + const model = d.GetModel(d.BasicDataModel(Doc)); + const [id] = await model.insert({ title: "t", body: { a: [1, 2] }, tagged: "plain" }); + await rawTable("twoway", "docs").insert({ _id: "odd", title: "odd", _internal: { meta: {}, data: { body: 42 } } }).run(); + const read = await model.get(id); + const odd = await model.get("odd"); + const raw = await rawTable("twoway", "docs").get(id); + return { read: [read.body, read.tagged], storedBody: raw._internal.data.body, storedTagged: raw._internal.data.tagged, notAString: odd.body ?? "undefined" }; +`; + +const CONTAINER = `${PRELUDE} + class PickModifier extends d.ContainerModifier { + [d.MixinSymbol] = class { pick(key) { d.unlock(this, PickModifier, undefined, key); return this; } }; + } + const Picked = MakePropertyDecorator((target, propertyKey, options) => { d.attachModifier(target.constructor, PickModifier, propertyKey, options || {}); }); + class Doc extends d.Table.with(PickModifier) {} + decorate(Doc, { values: [Picked(), d.Field("any")] }); + const doc = new Doc(); + doc.pick("a").values = 1; + doc.pick("b").values = 2; + const both = doc.pick("*").values; + doc.pick("a").values = 3; + const afterOverwrite = doc.pick("*").values; + Object.assign(doc.pick("*"), { values: { z: 26 } }); + const replaced = doc.pick("*").values; + return { both, afterOverwrite, replaced, byKey: [doc.pick("z").values, doc.pick("missing").values ?? "undefined", doc.pick("constructor").values ?? "undefined"] }; +`; + +const CONTAINER_QUERY = `${PRELUDE} + class PickModifier extends d.ContainerModifier { + [d.MixinSymbol] = class {}; + } + const Picked = MakePropertyDecorator((target, propertyKey, options) => { d.attachModifier(target.constructor, PickModifier, propertyKey, options || {}); }); + class Doc extends d.Table.with(PickModifier) {} + decorate(Doc, { values: [Picked(), d.Field("any")] }); + d.RegisterTable("docs", "ctquery")(Doc); + await d.RegisterSchema("ctquery"); + const seed = (id, values) => rawTable("ctquery", "docs").insert({ _id: id, _internal: { meta: {}, data: { values } } }).run(); + await seed("one", { a: 1, b: 2 }); + await seed("two", { a: 2, b: 1 }); + await seed("only-b", { b: 1 }); + const table = rawTable("ctquery", "docs"); + const where = async (key, value) => (await table.filter((row) => d.unlockrequest(Doc, row, "values", [{ modifier: PickModifier, args: [key] }]).eq(value))).map((row) => row._id).sort(); + return { a1: await where("a", 1), a2: await where("a", 2), b1: await where("b", 1), b2: await where("b", 2), c1: await where("c", 1) }; +`; + +const METADATA = `${PRELUDE} + const share = { key: Symbol("shared") }; + class Shared { constructor() { this.list = []; } } + Shared.key = share.key; + class Copied { constructor() { this.list = []; } static inherit(parent) { const copy = new Copied(); copy.list = [...parent.list]; return copy; } } + Copied.key = Symbol("copied"); + class A {} + class B extends A {} + d.getMetadata(A, Shared).list.push("x"); + d.getMetadata(A, Copied).list.push("x"); + const sharedB = d.getMetadata(B, Shared); + const copiedB = d.getMetadata(B, Copied); + copiedB.list.push("y"); + const upgraded = { upgrade: (stored) => (stored.version ? stored : Object.assign(new Copied(), stored, { version: 2 })) }; + class Aged { constructor() { this.list = []; } static upgrade(stored) { return Object.hasOwn(stored, "version") ? stored : Object.assign(new Aged(), stored, { version: 2 }); } } + Aged.key = Symbol("aged"); + class Old {} + Reflect.defineMetadata(Aged.key, { list: ["legacy"] }, Old); + const first = d.getMetadata(Old, Aged); + const second = d.getMetadata(Old, Aged); + class Current {} + d.RegisterTable("currents", "meta")(Current); + const current = d.getMetadata(Current, d.DatumStaticMetadata); + const currentAgain = d.getMetadata(Current, d.DatumStaticMetadata); + return { + identity: [current === currentAgain, d.DatumStaticMetadata.upgrade(current) === current, Aged.upgrade(first) === first], + shared: [sharedB === d.getMetadata(A, Shared), sharedB.list], + copied: [copiedB === d.getMetadata(A, Copied), copiedB.list, d.getMetadata(A, Copied).list], + upgrade: [first instanceof Aged, first.version, first.list, first === second], + }; +`; + +const LEGACY_METADATA = ` + class Old {} + Reflect.defineMetadata(d.DatumStaticMetadata.key, { schemaName: "legacy", tableName: "olds", indexes: {}, crossInstanceIndexes: new Set(), fields: {}, primary: "_id" }, Old); + const upgraded = d.getMetadata(Old, d.DatumStaticMetadata); + const again = d.getMetadata(Old, d.DatumStaticMetadata); + d.Field("string")(Old.prototype, "code"); + d.Index({ unique: true })(Old.prototype, "code"); + d.RegisterTable("olds", "legacy")(Old); + await d.RegisterSchema("legacy"); + const model = d.GetModel(d.BasicDataModel(Old)); + await model.insert({ code: "A" }); + const refused = await refusedDuplicate(model, (attempt) => ({ code: "A" })); + return { + upgraded: [upgraded instanceof d.DatumStaticMetadata, upgraded.uniqueIndexes instanceof Set, upgraded === again, upgraded.tableName], + definition: iface.Schema.get("legacy").definition.olds.indexes, + uniqueEnforced: refused && refused.name, + }; +`; + +const EVENTS = `${PRELUDE} + const log = []; + const shape = (args) => args.length + ":" + args.map((value) => (value !== null && typeof value === "object" ? Object.keys(value).filter((key) => key !== "_instance").sort().join("+") : String(value))).join(";"); + class Spy extends d.Modifier { + fromPlainData(object, field, ...args) { log.push(["fromPlainData", field, this instanceof Spy, object.constructor.name, shape(args)]); } + toPlainData(object, field, ...args) { log.push(["toPlainData", field, this instanceof Spy, object.constructor.name, shape(args)]); } + fromDatabase(object, field, ...args) { log.push(["fromDatabase", field, this instanceof Spy, object.constructor.name, shape(args)]); } + toDatabase(object, field, ...args) { log.push(["toDatabase", field, this instanceof Spy, object.constructor.name, shape(args)]); args[0].handled = "by-handler"; } + insert(object, field, ...args) { log.push(["insert", field, this instanceof Spy, object.constructor.name, shape(args)]); } + update(object, field, ...args) { log.push(["update", field, this instanceof Spy, object.constructor.name, shape(args)]); } + [d.MixinSymbol] = class {}; + } + const Spied = MakePropertyDecorator((target, propertyKey, options) => { d.attachModifier(target.constructor, Spy, propertyKey, options || {}); }); + class Doc extends d.Table {} + decorate(Doc, { note: [Spied(), d.Field("string")] }); + d.RegisterTable("docs", "events")(Doc); + await d.RegisterSchema("events"); + const model = d.GetModel(d.BasicDataModel(Doc)); + const phase = (_name, entries) => entries.map((entry) => entry.join("/")); + const mark = () => log.splice(0); + const [id] = await model.insert({ note: "n" }); + const onInsert = phase("insert", mark()); + const batchIds = await model.insert([{ note: "x" }, { note: "y" }]); + const onBatch = phase("batch", mark()); + const batchStored = (await Promise.all(batchIds.map((batchId) => rawTable("events", "docs").get(batchId)))).map((row) => [row.note, row.handled]); + await model.update(id, { note: "m" }); + const onUpdate = phase("update", mark()); + await model.get(id); + const onRead = phase("read", mark()); + await model.getAll(); + const onReadAll = phase("readAll", mark()); + const instance = await model.get(id); + mark(); + d.toPlainData(instance); + const onPlain = phase("plain", mark()); + const storedByHandler = (await rawTable("events", "docs").get(id)).handled ?? "undefined"; + const plainAccessor = Object.getOwnPropertyDescriptor(Doc.prototype, "note"); + return { onInsert, onBatch, batchStored, onUpdate, onRead, onReadAll, onPlain, storedByHandler, eventsOnlyKeepPlainValue: plainAccessor === undefined }; +`; + +const HELPERS = ` + class Profile extends d.Table.with(d.HashModifier, d.LocalizationModifier) {} + decorate(Profile, { + password: [d.Hashed(), d.Field("string")], + title: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + summary: [d.Localized(), d.Field("string")], + plain: [d.Field("string")], + }); + d.RegisterTable("profiles", "helpers")(Profile); + class Other extends d.Table {} + d.RegisterTable("others", "helpers")(Other); + const row = new Profile(); + const names = { hashed: d.getModifiedFields(row, d.HashModifier), localized: d.getModifiedFields(row, d.LocalizationModifier), none: d.getModifiedFields(row, d.EncryptionModifier) }; + row.title = "floating"; + const floatingRead = row.title; + const floatingStored = Object.keys(d.toDatabase(row)).sort(); + d.unlock(row, d.LocalizationModifier, ["title"], "fr"); + const afterUnlock = [row.title, row.localize("fr").title, row.localize("en").title ?? "undefined"]; + const stored = d.toDatabase(row); + const lockedKeys = Object.keys(stored._internal.data).sort(); + row.localize("fr").summary = "résumé"; + d.unlock(row, d.LocalizationModifier, ["title"], "en"); + const restricted = [row.title ?? "undefined", row.summary ?? "undefined"]; + const tables = d.getTablesForSchema("helpers"); + await d.RegisterSchema("helpers"); + const bound = d.getMetadata(Profile, d.DatumStaticMetadata); + class Early extends Profile {} + const earlyBefore = d.getMetadata(Early, d.DatumStaticMetadata); + const copiedArrays = [earlyBefore.indexes !== bound.indexes, earlyBefore.fields !== bound.fields, earlyBefore.primary, bound.schema !== undefined, earlyBefore.schema === undefined]; + const parentMeta = d.getMetadata(Profile, d.DatumStaticMetadata); + class Child extends Profile {} + const own = d.getMetadata(Child, d.DatumStaticMetadata, false); + class Child2 extends Profile {} + const inherited = d.getMetadata(Child2, d.DatumStaticMetadata); + return { + names, + floatingRead, + floatingStored, + afterUnlock, + lockedKeys, + restricted, + copiedArrays, + tables: [Object.keys(tables).sort(), tables.profiles === Profile, d.getTablesForSchema("nothing-here") ?? "undefined"], + metadata: [parentMeta.tableName, own.tableName ?? "undefined", inherited.tableName, own === inherited, inherited === parentMeta, d.getMetadata(Profile, d.DatumStaticMetadata) === parentMeta], + }; +`; + +export const CUSTOM_JOURNEYS = { + "one-way-modifier": scenario({ title: "a custom OneWayModifier transforms a value when it is written", body: ONE_WAY, printed: "{\"tested\":[true,false,true],\"stored\":[true,true],\"afterOneWay\":\"Adding Modifier EncryptionModifier on T.pin after a One-Way Modifier, please review your ordering.\",\"beforeOneWay\":null,\"secondOneWay\":\"Attempted adding Modifier PrefixHashModifier on Twice.f but it already has a One-Way Modifier\"}" }), + "two-way-modifier": scenario({ + title: "a custom TwoWayModifier stores a transformed value and unlocks it on read", + body: TWO_WAY, + printed: "{\"read\":[{\"a\":[1,2]},\"plain\"],\"storedBody\":\"b64-eyJhIjpbMSwyXX0=\",\"storedTagged\":\"x:InBsYWluIg==\",\"notAString\":\"undefined\"}", + stored: [{ name: "the stored encodings", expression: "$DB.getCollection('twoway__docs').find({ title: 't' }).toArray().map((r) => [r._internal.data.body, r._internal.data.tagged])", expected: "[[\"b64-eyJhIjpbMSwyXX0=\",\"x:InBsYWluIg==\"]]" }], + }), + "modifier-helpers": scenario({ title: "getModifiedFields, unlock, lock, floating values, getMetadata and getTablesForSchema do what their TSDoc says", body: HELPERS, printed: "{\"names\":{\"hashed\":[\"password\"],\"localized\":[\"title\",\"summary\"],\"none\":[]},\"floatingRead\":\"floating\",\"floatingStored\":[\"_internal\"],\"afterUnlock\":[\"floating\",\"floating\",\"undefined\"],\"lockedKeys\":[\"title\"],\"restricted\":[\"undefined\",\"r\u00e9sum\u00e9\"],\"copiedArrays\":[true,true,\"_id\",true,true],\"tables\":[[\"others\",\"profiles\"],true,\"undefined\"],\"metadata\":[\"profiles\",\"undefined\",\"profiles\",false,false,true]}" }), + "container-query": scenario({ title: "ContainerModifier.unlockrequest uses its key, so a query reads one entry of the stored record", body: CONTAINER_QUERY, printed: "{\"a1\":[\"one\"],\"a2\":[\"two\"],\"b1\":[\"only-b\",\"two\"],\"b2\":[\"one\"],\"c1\":[]}" }), + "metadata-class": scenario({ title: "a metadata class without inherit is shared with the subclass, one with inherit is copied, and upgrade runs on stored metadata", body: METADATA, printed: "{\"identity\":[true,true,true],\"shared\":[true,[\"x\"]],\"copied\":[false,[\"x\",\"y\"],[\"x\"]],\"upgrade\":[true,2,[\"legacy\"],true]}" }), + "legacy-metadata": scenario({ title: "metadata stored without uniqueIndexes is upgraded and unique indexes still register", body: LEGACY_METADATA, printed: "{\"upgraded\":[true,true,true,\"olds\"],\"definition\":{\"code\":{\"fields\":null,\"unique\":true}},\"uniqueEnforced\":\"DuplicateKeyError\"}" }), + "container-modifier": scenario({ title: "a ContainerModifier keeps values by key and treats the wildcard key as the whole record", body: CONTAINER, printed: "{\"both\":{\"a\":1,\"b\":2},\"afterOverwrite\":{\"a\":3,\"b\":2},\"replaced\":{\"z\":26},\"byKey\":[26,\"undefined\",\"undefined\"]}" }), + "modifier-events": scenario({ title: "a modifier's event methods run for inserts, updates and conversions, and an events-only modifier leaves the field plain", body: EVENTS, printed: "{\"onInsert\":[\"fromPlainData/note/true/Doc/1:note\",\"insert/note/true/Doc/0:\",\"toDatabase/note/true/Doc/1:_internal+note\"],\"onBatch\":[\"fromPlainData/note/true/Doc/1:note\",\"insert/note/true/Doc/0:\",\"toDatabase/note/true/Doc/1:_internal+note\",\"fromPlainData/note/true/Doc/1:note\",\"insert/note/true/Doc/0:\",\"toDatabase/note/true/Doc/1:_internal+note\"],\"batchStored\":[[\"x\",\"by-handler\"],[\"y\",\"by-handler\"]],\"onUpdate\":[\"fromPlainData/note/true/Doc/1:note\",\"update/note/true/Doc/0:\",\"toDatabase/note/true/Doc/1:_internal+note\"],\"onRead\":[\"fromDatabase/note/true/Doc/1:_id+_internal+handled+note\"],\"onReadAll\":[\"fromDatabase/note/true/Doc/1:_id+_internal+handled+note\",\"fromDatabase/note/true/Doc/1:_id+_internal+handled+note\",\"fromDatabase/note/true/Doc/1:_id+_internal+handled+note\"],\"onPlain\":[\"toPlainData/note/true/Doc/1:_id+handled+note\"],\"storedByHandler\":\"by-handler\",\"eventsOnlyKeepPlainValue\":true}" }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/declarations.ts b/.agents/skills/verify-interface/scenarios/declarations.ts new file mode 100644 index 0000000..8823ca2 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/declarations.ts @@ -0,0 +1,361 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { equal } from "@antelopejs/tooling-configs/verify"; +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +/** The probe header: `Assert>` fails to compile unless A and B are the same type. */ +const PROBE_HEADER = ` +type Identical = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; +type CanCall = F extends (...args: Args) => unknown ? true : false; +`; + +const MODEL_SIGNATURES = ` +class User extends Api.Table { declare name: string; } +const Model = Api.BasicDataModel(User, "users"); +type Instance = InstanceType; +type _Get = Assert, PromiseLike>>; +type _GetBy = Assert, PromiseLike>>; +type _GetAll = Assert, PromiseLike>>; +type _GetByNumberKey = Assert>; +type _GetByBooleanKey = Assert>; +type _GetByCompoundKeys = Assert>; +type _GetByProxyKey = Assert]>>; +type _IndexKeyIsGetAllKey = Assert["getAll"]>[0], unknown[]>>>; +type _Insert = Assert, Promise>>; +type _Delete = Assert, Promise>>; +type _Update = Assert>, number>>; +type _FromDatabase = Assert, User | undefined>>; +type _ToDatabase = Assert, Api.StoredRow>>; +type _Validate = Assert, Api.ValidationResult>>; +type _ModelWithoutInstance = Assert>; +Api.Fixture(async () => [{ _id: "a" }])(User); +Api.Fixture(async () => ({ _id: "b" }))(User); +class Greeter extends Api.Table { declare name: string; greet(): string { return this.name; } } +const GreeterModel = Api.BasicDataModel(Greeter, "greeters"); +const greeterRow = GreeterModel.toDatabase({ name: "plain data" }); +type _ToDatabasePlain = Assert>>; +class Admin extends Greeter.with(Api.HashModifier) {} +type _WithKeepsFields = Assert>; +type _WithKeepsMethods = Assert, string>>; +type _ValidationResultShape = Assert>; +type _PlainRowShape = Assert, { _id?: string; name?: string }>>; +type _StoredRowField = Assert["name"], string | undefined>>; +type _StoredRowId = Assert["_id"], string | undefined>>; +type _StoredRowInternal = Assert ? true : false, true>>; +type _StoredRowLocked = Assert["_internal"]>, "meta" | "data">>; +type _Register = Assert Promise>>; +type _Fixture = Assert, { a: 1 } | { a: 1 }[] | undefined>>; +type _PrimaryKey = Assert>; +type _UpdateById = Assert>; +type _UpdateByIdOptions = Assert>; +type _UpdateByObject = Assert>; +type _UpdateByObjectOptions = Assert>; +type _UpdateNoKey = Assert, false>>; +type _UpdateNoData = Assert, false>>; +type _UpdateBadId = Assert, false>>; +type _UpdateBadField = Assert, false>>; +type _UpdateBadOptions = Assert, false>>; +type _GetParams = Assert, [id: string]>>; +type _DeleteParams = Assert, [id: string]>>; +type _InsertOptions = Assert[1], Api.ValidateOptions | undefined>>; +type _ValidateParams = Assert, [obj: unknown, options?: Api.ValidateInputOptions]>>; +type _ValidateOptions = Assert>; +type _GetModelInstance = Assert[1], Parameters[0] extends infer T ? Extract : never>>; +declare const u: User; +const plain = Api.toPlainData(u); +type _ToPlain = Assert>>; +const modified = Api.getModifiedFields(u, Api.HashModifier); +type _Modified = Assert>; +type _Tables = Assert>[string]>, Api.Table>>; +type _TablesMaybe = Assert ? true : false, true>>; +const meta = Api.getMetadata(User, Api.DatumStaticMetadata); +type _Meta = Assert>; +const built = Api.fromPlainData({ name: "x" }, User); +type _FromPlain = Assert>; +const read = Api.fromDatabase({ name: "x" }, User); +type _FromDb = Assert>; +const stored = Api.toDatabase(u); +type _ToDb = Assert>; +class Options extends Api.Table.with(Api.EncryptionModifier, Api.HashModifier, Api.LocalizationModifier) { + @Api.Index({ group: "g", crossInstance: true, unique: true }) @Api.Field("string") declare a: string; + @Api.Relation({ to: () => User, toField: "name", many: true }) @Api.Field("string") declare b: string; + @Api.Encrypted({ secretKey: "k", algorithm: "aes-256-gcm", ivSize: 16 }) @Api.Field("string") declare c: string; + @Api.Hashed({ algorithm: "sha256" }) @Api.Field("string") declare d: string; + @Api.Localized({ fallbackLocale: "en" }) @Api.Field("string") declare e: string; +} +type _Table = Assert[0], Api.TableTarget | undefined>>; +class Custom { + static readonly schemaName = "manual"; + readonly table: Db.Table; + constructor(readonly database: Db.SchemaInstance) { + this.table = database.table("things"); + } + static fromDatabase(obj: any): object | undefined { + return obj ? obj : undefined; + } + static fromPlainData(obj: object): object { + return obj; + } +} +const custom = Api.GetModel(Custom); +type _CustomModel = Assert>; +`; + +const MODIFIER_SIGNATURES = ` +class Hashed extends Api.Table.with(Api.HashModifier) { declare password: string; } +class Localized extends Api.Table.with(Api.LocalizationModifier) { declare name: string; } +declare const hashed: Hashed; +const tested = hashed.testHash("password", "x"); +const testedAsync = hashed.testHashAsync("password", "x"); +const outdated = hashed.isHashOutdated("password"); +type _TestHash = Assert>; +type _TestHashAsync = Assert>>; +type _Outdated = Assert>; +declare const localized: Localized; +const switched = localized.localize("en"); +type _Localize = Assert>; +const testedNumber = hashed.testHash("password", "x"); +const accepted = [hashed.testHashAsync("password", "x"), localized.localize("en", ["name"]), localized.localize("fr", [])]; +type _FallbackParams = Assert, [TableClass: Api.Constructible, field: string]>>; +type _Fallback = Assert, string | undefined>>; +`; + +/** Statements that must each fail to compile: the journey passes only when tsc reports an error on every one of them, so a parameter type that gets looser stops being reported. */ +const REJECTED = [ + 'void m.update({ name: "x" });', + 'void m.update("1");', + 'void m.update("1", { name: 5 });', + 'void m.update({ _id: 1, name: "x" });', + 'void m.update("1", { name: "x" }, { validate: "yes" });', + 'void m.insert({ name: 5 });', + 'void m.insert({ name: "x" }, { validate: 1 });', + "void m.get(5);", + "void m.delete(5);", + 'void m.getBy("nope", "x");', + 'void Model.validate({}, { partial: "yes" });', + 'void Model.fromPlainData({ name: 5 });', + "void User.table(5);", + "void Api.RegisterSchema(5);", + "void Api.GetModel(Model, 5);", + 'void hashed.testHash("password", 5);', + 'void hashed.testHash("nope", "x");', + 'void hashed.testHashAsync("password", 5);', + 'void hashed.testHashAsync("nope", "x");', + 'void hashed.isHashOutdated("nope");', + 'void localized.localize("en", ["nope"]);', + "void localized.localize(5);", + 'void Api.LocalizationModifier.fallbackLocaleOf(5, "name");', + 'Api.Index({ unique: "yes" });', + "Api.Index({ group: 5 });", + 'Api.Index({ crossInstance: "yes" });', + "Api.Relation({});", + 'Api.Relation({ to: () => User, many: "yes" });', + 'Api.Relation({ to: () => User, toField: 5 });', + 'Api.RegisterTable("only-a-name");', + "Api.Encrypted({});", + 'Api.Encrypted({ secretKey: "k", ivSize: "16" });', + "Api.Hashed({ algorithm: 5 });", + "Api.Localized({ fallbackLocale: 5 });", + "Api.Fixture(5);", + "void Api.BasicDataModel(5);", + "void Api.GetModel(5);", + 'Api.Index({ crossInstance: 1 });', + 'Api.RegisterTable(5, "schema");', + 'Api.RegisterTable("table", 5);', + "Api.Encrypted({ secretKey: 5 });", + 'Api.Encrypted({ secretKey: "k", algorithm: 5 });', + "Api.Relation({ to: User });", + "Api.Relation({ to: () => Date });", + "Api.Localized({ fallbackLocale: true });", + "Api.Hashed({ algorithm: true });", + "Api.Model(Model, 5);", + 'void Api.getMetadata(User, "key");', +]; + +const REJECT_PRELUDE = ` +class User extends Api.Table { declare name: string; } +const Model = Api.BasicDataModel(User, "users"); +declare const m: InstanceType; +class Hashed extends Api.Table.with(Api.HashModifier) { declare password: string; } +class Localized extends Api.Table.with(Api.LocalizationModifier) { declare name: string; } +declare const hashed: Hashed; +declare const localized: Localized; +`; + +function rejected(title: string, statements: string[]): Journey { + return { + title, + entryPoint: "a TypeScript consumer compiling wrong calls against the packed declarations", + async run(context) { + const file = `${context.work}/reject.ts`; + const head = `import * as Api from "${context.repo}/dist/index";\n${PROBE_HEADER}\n${REJECT_PRELUDE}`; + const firstLine = head.split("\n").length + 1; + context.edit("write the rejecting probe", file, `${head}\n${statements.join("\n")}\n`); + const compiled = await context.command("compile the rejecting probe", "node", [ + `${context.repo}/node_modules/typescript/bin/tsc`, + "--noEmit", + "--strict", + "--skipLibCheck", + "--experimentalDecorators", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + `${context.repo}/node_modules/@types`, + "--types", + "node", + file, + ]); + const failing = new Set([...compiled.stdout.matchAll(/reject\.ts\((\d+),/g)].map((match) => Number(match[1]))); + const accepted = statements.filter((_, index) => !failing.has(firstLine + index)); + context.expect(compiled.folder, [ + { name: "tsc refuses every wrong call", pass: accepted.length === 0, detail: accepted.join(" | ") }, + ]); + }, + }; +} + +function declared(title: string, probe: string): Journey { + return { + title, + entryPoint: "a TypeScript consumer compiling against the packed declarations", + async run(context) { + const file = `${context.work}/probe.ts`; + context.edit("write the signature probe", file, `import * as Api from "${context.repo}/dist/index";\nimport type * as Db from "${context.repo}/node_modules/@antelopejs/interface-database";\n${PROBE_HEADER}\n${probe}`); + const compiled = await context.command("compile the signature probe", "node", [ + `${context.repo}/node_modules/typescript/bin/tsc`, + "--noEmit", + "--strict", + "--skipLibCheck", + "--experimentalDecorators", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + `${context.repo}/node_modules/@types`, + "--types", + "node", + file, + ]); + context.expect(compiled.folder, [ + { name: "every export has the documented signature", pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 600) }, + ]); + }, + }; +} + +const PACKED_CHECK = ` +const fs = require("node:fs"); +const path = require("node:path"); +const pack = process.argv[2]; +const consumer = process.argv[3]; +const skill = fs.readFileSync(path.join(pack, "skills", "database-decorators-interface", "SKILL.md"), "utf8"); +const name = "@antelopejs/interface-database-decorators"; +const result = { files: ["docs/1.introduction.md", "docs/5.parameter-decoration.md", "skills/database-decorators-interface/SKILL.md", "dist/index.js"].filter((file) => !fs.existsSync(path.join(pack, file))), imports: {} }; +const wanted = new Map(); +for (const match of skill.matchAll(/import \\{([^}]*)\\} from "(@antelopejs\\/interface-database-decorators[^"]*)"/g)) { + const names = match[1].split(",").map((entry) => entry.trim()).filter(Boolean); + wanted.set(match[2], [...(wanted.get(match[2]) ?? []), ...names]); +} +const requireFrom = require("node:module").createRequire(path.join(consumer, "index.js")); +for (const [specifier, names] of wanted) { + try { + const loaded = requireFrom(specifier); + result.imports[specifier] = names.filter((entry) => loaded[entry] === undefined); + } catch (error) { + result.imports[specifier] = "ERR " + error.code; + } +} +console.log("RESULT " + JSON.stringify({ subpaths: [...wanted.keys()].sort(), files: result.files, imports: result.imports })); +`; + +/** The folder `new` packed and extracted: the nearest ancestor of the journey folder that holds `pack/package`. */ +function packedPackage(work: string): string { + let folder = work; + while (!existsSync(join(folder, "pack", "package", "package.json"))) { + const parent = dirname(folder); + if (parent === folder) throw new Error(`no packed package above ${work}`); + folder = parent; + } + return join(folder, "pack", "package"); +} + +const packedSubpaths: Journey = { + title: "the packed package ships its docs and skill, and every subpath its skill file imports from exports the names the skill shows", + entryPoint: "a consumer resolving the packed tarball's exports, as npm would publish them", + async run(context) { + const pack = packedPackage(context.work); + const consumer = join(context.work, "consumer"); + const scope = join(consumer, "node_modules", "@antelopejs"); + mkdirSync(scope, { recursive: true }); + symlinkSync(pack, join(scope, "interface-database-decorators"), "dir"); + if (!existsSync(join(pack, "node_modules"))) symlinkSync(join(context.repo, "node_modules"), join(pack, "node_modules"), "dir"); + const script = join(context.work, "packed-check.cjs"); + context.edit("write the packed check", script, PACKED_CHECK); + const ran = await context.command("load every subpath of the skill's imports", "node", [script, pack, consumer]); + const line = ran.stdout.split("\n").find((entry) => entry.startsWith("RESULT ")); + context.expect(ran.folder, [ + { name: "the check ended cleanly", pass: ran.exitCode === 0, detail: ran.stderr.slice(-300) }, + equal("the packed package carries what the skill imports", line?.slice("RESULT ".length) ?? "", "{\"subpaths\":[\"@antelopejs/interface-database-decorators/common\",\"@antelopejs/interface-database-decorators/database\",\"@antelopejs/interface-database-decorators/model\",\"@antelopejs/interface-database-decorators/modifiers/autodate\",\"@antelopejs/interface-database-decorators/modifiers/common\",\"@antelopejs/interface-database-decorators/modifiers/encryption\",\"@antelopejs/interface-database-decorators/modifiers/hash\",\"@antelopejs/interface-database-decorators/modifiers/localization\",\"@antelopejs/interface-database-decorators/relation\",\"@antelopejs/interface-database-decorators/schema\",\"@antelopejs/interface-database-decorators/table\"],\"files\":[],\"imports\":{\"@antelopejs/interface-database-decorators/table\":[],\"@antelopejs/interface-database-decorators/schema\":[],\"@antelopejs/interface-database-decorators/database\":[],\"@antelopejs/interface-database-decorators/model\":[],\"@antelopejs/interface-database-decorators/relation\":[],\"@antelopejs/interface-database-decorators/common\":[],\"@antelopejs/interface-database-decorators/modifiers/autodate\":[],\"@antelopejs/interface-database-decorators/modifiers/encryption\":[],\"@antelopejs/interface-database-decorators/modifiers/hash\":[],\"@antelopejs/interface-database-decorators/modifiers/localization\":[],\"@antelopejs/interface-database-decorators/modifiers/common\":[]}}"), + ]); + }, +}; + +/** Every complete TypeScript example of the docs and README, as a reader copies it; `fragment` blocks are partial by design and skipped. */ +function docExamples(repo: string): { name: string; source: string }[] { + const pages = [...readdirSync(join(repo, "docs")).filter((page) => page.endsWith(".md")).sort().map((page) => join("docs", page)), "README.md"]; + const examples: { name: string; source: string }[] = []; + for (const page of pages) { + const text = readFileSync(join(repo, page), "utf8"); + for (const match of text.matchAll(/^```(?:typescript|ts)([^\n]*)\n([\s\S]*?)^```/gm)) { + if (match[1].includes("fragment")) continue; + const line = text.slice(0, match.index).split("\n").length; + examples.push({ name: `${page.replace(/[/.]/g, "_")}_L${line}`, source: `${match[2]}\nexport {};\n` }); + } + } + return examples; +} + +const docsExamples: Journey = { + title: "every complete TypeScript example in the docs and README compiles against the packed declarations", + entryPoint: "a TypeScript consumer pasting each documented example into a module that depends on the packed package", + async run(context) { + const pack = packedPackage(context.work); + const folder = join(context.work, "examples"); + mkdirSync(folder, { recursive: true }); + symlinkSync(join(context.repo, "node_modules"), join(folder, "node_modules"), "dir"); + const examples = docExamples(context.repo); + for (const example of examples) context.edit(`write ${example.name}`, join(folder, `${example.name}.ts`), example.source); + const tsconfig = { + compilerOptions: { + strict: true, noImplicitAny: false, skipLibCheck: true, experimentalDecorators: true, noEmit: true, + target: "es2022", module: "esnext", moduleResolution: "bundler", types: ["node"], + paths: { "@antelopejs/interface-database-decorators": [`${pack}/dist/index`], "@antelopejs/interface-database-decorators/*": [`${pack}/dist/*`] }, + }, + files: examples.map((example) => `${example.name}.ts`), + }; + context.edit("write the consumer tsconfig", join(folder, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); + const compiled = await context.command("compile every documented example", "node", [`${context.repo}/node_modules/typescript/bin/tsc`, "-p", folder], { cwd: folder }); + const failing = [...new Set([...compiled.stdout.matchAll(/^([\w.-]+)\.ts\(/gm)].map((match) => match[1]))]; + context.expect(compiled.folder, [ + { name: "the docs hold complete examples to compile", pass: examples.length >= 20, detail: examples.map((example) => example.name).join(" ") }, + { name: "every documented example compiles", pass: compiled.exitCode === 0 && failing.length === 0, detail: `${failing.join(" ")}\n${compiled.stdout.slice(0, 1500)}` }, + ]); + }, +}; + +export const DECLARATION_JOURNEYS = { + "docs-examples": docsExamples, + "model-signatures": declared("the model methods and RegisterSchema declare the return types the docs table lists", MODEL_SIGNATURES), + "packed-subpaths": packedSubpaths, + "rejected-calls": rejected("calls with a wrong parameter type or a missing key are refused by the declarations", REJECTED), + "modifier-signatures": declared("the hash and localization mixins declare the return types the docs describe", MODIFIER_SIGNATURES), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/decoration.ts b/.agents/skills/verify-interface/scenarios/decoration.ts new file mode 100644 index 0000000..2822b4a --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/decoration.ts @@ -0,0 +1,144 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +/** The helpers the HTTP scenarios share: a controller class whose routes are decorated by hand, and a fetch that answers the status and body. */ +const HTTP = ` + const api = require(require.resolve("@antelopejs/interface-api", { paths: [dist] })); + const base = "http://127.0.0.1:" + process.env.VERIFY_API_PORT; + const route = (Controller, method, verb, path, handler, params) => { + Controller.prototype[method] = handler; + params.forEach((apply, index) => apply(Controller.prototype, method, index)); + api[verb](path)(Controller.prototype, method, Object.getOwnPropertyDescriptor(Controller.prototype, method)); + }; + const call = async (path, init) => { const response = await fetch(base + path, init); const text = await response.text(); return { status: response.status, body: text }; }; + class Note extends d.Table {} + decorate(Note, { title: [d.Field("string")] }); + d.RegisterTable("notes", "http")(Note); + await d.RegisterSchema("http"); + const NoteModel = d.BasicDataModel(Note); +`; + +const FIXED = `${HTTP} + const seen = []; + class Fixed extends api.Controller("/fixed") {} + route(Fixed, "list", "Get", "/", async (model) => { seen.push(model); return (await model.getAll()).map((row) => row.title); }, [d.Model(NoteModel, "main")]); + const seenDefault = []; + route(Fixed, "plain", "Get", "/plain", async (model) => { seenDefault.push(model); return (await model.getAll()).map((row) => row.title); }, [d.Model(NoteModel)]); + await d.GetModel(NoteModel, "main").insert({ title: "in main" }); + await d.GetModel(NoteModel).insert({ title: "in default" }); + const plain = await call("/fixed/plain"); + await call("/fixed/plain"); + const first = await call("/fixed"); + const second = await call("/fixed"); + return { plain, defaultIsGetModel: [seenDefault.length, seenDefault[0] === d.GetModel(NoteModel), seenDefault[0] === seenDefault[1], seenDefault[0] === d.GetModel(NoteModel, "main")], first, second, sameModelEveryRequest: seen.length === 2 && seen[0] === seen[1], isTheCachedModel: seen[0] === d.GetModel(NoteModel, "main") }; +`; + +const PER_REQUEST = `${HTTP} + class Tenants extends api.Controller("/tenants") {} + route(Tenants, "list", "Get", "/:tenantId/notes", async (tenantId, model) => ({ tenantId, titles: (await model.getAll()).map((row) => row.title) }), [api.Parameter("tenantId"), d.Model(NoteModel, (ctx) => "tenant-" + ctx.routeParameters.tenantId)]); + class Defaulted extends api.Controller("/defaulted") {} + route(Defaulted, "list", "Get", "/", async (model) => (await model.getAll()).map((row) => row.title), [d.Model(NoteModel, () => undefined)]); + await d.GetModel(NoteModel, "tenant-a").insert({ title: "alpha" }); + await d.GetModel(NoteModel, "tenant-b").insert({ title: "beta" }); + await d.GetModel(NoteModel).insert({ title: "default instance" }); + return { a: await call("/tenants/a/notes"), b: await call("/tenants/b/notes"), none: await call("/tenants/c/notes"), defaulted: await call("/defaulted") }; +`; + +const PROPERTY = `${HTTP} + class Props extends api.Controller("/props") {} + d.Model(NoteModel, "main")(Props.prototype, "notes"); + route(Props, "list", "Get", "/", async function () { return (await this.notes.getAll()).map((row) => row.title); }, []); + route(Props, "one", "Get", "/:id", async function (id) { const row = await this.notes.get(id); return row ? row.title : "missing"; }, [api.Parameter("id")]); + route(Props, "same", "Get", "/same/model", async function () { return this.notes === d.GetModel(NoteModel, "main"); }, []); + const [id] = await d.GetModel(NoteModel, "main").insert({ title: "stored" }); + return { list: await call("/props"), one: await call("/props/" + id), missing: await call("/props/none"), sameAsGetModel: await call("/props/same/model"), outsideARequest: new Props().notes === undefined }; +`; + +const CRUD_CONTROLLER = `${HTTP} + class Notes extends api.Controller("/notes") {} + route(Notes, "list", "Get", "/", async (model) => (await model.getAll()).map((row) => row.title), [d.Model(NoteModel, "main")]); + route(Notes, "get", "Get", "/:id", async (id, model) => { const row = await model.get(id); return row ? row.title : "missing"; }, [api.Parameter("id"), d.Model(NoteModel, "main")]); + route(Notes, "create", "Post", "/", async (body, model) => model.insert(JSON.parse(body.toString())), [api.RawBody(), d.Model(NoteModel, "main")]); + route(Notes, "update", "Put", "/:id", async (id, body, model) => model.update(id, JSON.parse(body.toString())), [api.Parameter("id"), api.RawBody(), d.Model(NoteModel, "main")]); + route(Notes, "remove", "Delete", "/:id", async (id, model) => model.delete(id), [api.Parameter("id"), d.Model(NoteModel, "main")]); + const created = await call("/notes", { method: "POST", body: JSON.stringify({ title: "first" }) }); + const id = JSON.parse(created.body)[0]; + const updated = await call("/notes/" + id, { method: "PUT", body: JSON.stringify({ title: "renamed" }) }); + const read = await call("/notes/" + id); + const removed = await call("/notes/" + id, { method: "DELETE" }); + const gone = await call("/notes/" + id); + return { created: [created.status, typeof id], updated, read, removed, gone }; +`; + +const CUSTOM_PROVIDER = `${HTTP} + const { MakeParameterAndPropertyDecorator } = require(require.resolve("@antelopejs/interface-core/decorators", { paths: [dist] })); + const CustomModel = MakeParameterAndPropertyDecorator((target, key, index, modelClass, resolver) => { + api.SetParameterProvider(target, key, index, (ctx) => d.GetModel(modelClass, resolver(ctx))); + }); + class Custom extends api.Controller("/custom") {} + route(Custom, "list", "Get", "/:tenant", async (model) => (await model.getAll()).map((row) => row.title), [CustomModel(NoteModel, (ctx) => "c-" + ctx.routeParameters.tenant)]); + await d.GetModel(NoteModel, "c-x").insert({ title: "custom x" }); + return { x: await call("/custom/x"), y: await call("/custom/y") }; +`; + +const EARLY = ` + const api = require(require.resolve("@antelopejs/interface-api", { paths: [dist] })); + const base = "http://127.0.0.1:" + process.env.VERIFY_API_PORT; + const route = (Controller, method, verb, path, handler, params) => { + Controller.prototype[method] = handler; + params.forEach((apply, index) => apply(Controller.prototype, method, index)); + api[verb](path)(Controller.prototype, method, Object.getOwnPropertyDescriptor(Controller.prototype, method)); + }; + const call = async (path, init) => { const response = await fetch(base + path, init); const text = await response.text(); return { status: response.status, body: text }; }; + class Note extends d.Table {} + decorate(Note, { title: [d.Field("string")] }); + d.RegisterTable("notes", "early")(Note); + const NoteModel = d.BasicDataModel(Note); + class Early extends api.Controller("/early") {} + const decorated = caught(() => route(Early, "list", "Get", "/", async (model) => (await model.getAll()).map((row) => row.title), [d.Model(NoteModel, "main")])); + class Props extends api.Controller("/early-props") {} + const decoratedProperty = caught(() => d.Model(NoteModel)(Props.prototype, "notes")); + route(Props, "list", "Get", "/", async function () { return (await this.notes.getAll()).map((row) => row.title); }, []); + await d.RegisterSchema("early"); + await d.GetModel(NoteModel, "main").insert({ title: "after binding" }); + await d.GetModel(NoteModel).insert({ title: "default after binding" }); + return { decorated, decoratedProperty, list: await call("/early"), props: await call("/early-props") }; +`; + +export const DECORATION_JOURNEYS = { + "decorated-before-binding": scenario({ + title: "@Model on a controller declared before RegisterSchema binds the table resolves the model per request, not when the decorator runs", + body: EARLY, + printed: "{\"decorated\":null,\"decoratedProperty\":null,\"list\":{\"status\":200,\"body\":\"[\\\"after binding\\\"]\"},\"props\":{\"status\":200,\"body\":\"[\\\"default after binding\\\"]\"}}", + api: true, + }), + "fixed-instance": scenario({ + title: "@Model with an instance id injects the cached model of that instance into a controller method", + body: FIXED, + printed: "{\"plain\":{\"status\":200,\"body\":\"[\\\"in default\\\"]\"},\"defaultIsGetModel\":[2,true,true,false],\"first\":{\"status\":200,\"body\":\"[\\\"in main\\\"]\"},\"second\":{\"status\":200,\"body\":\"[\\\"in main\\\"]\"},\"sameModelEveryRequest\":true,\"isTheCachedModel\":true}", + api: true, + stored: [{ name: "the notes by instance", expression: "$DB.getCollection('http__notes').find().sort({ title: 1 }).toArray().map((r) => [r.title, r._instance])", expected: "[[\"in default\",null],[\"in main\",\"main\"]]" }], + }), + "per-request-instance": scenario({ + title: "@Model with a callback picks the instance from each request, or the default instance", + body: PER_REQUEST, + printed: "{\"a\":{\"status\":200,\"body\":\"{\\\"tenantId\\\":\\\"a\\\",\\\"titles\\\":[\\\"alpha\\\"]}\"},\"b\":{\"status\":200,\"body\":\"{\\\"tenantId\\\":\\\"b\\\",\\\"titles\\\":[\\\"beta\\\"]}\"},\"none\":{\"status\":200,\"body\":\"{\\\"tenantId\\\":\\\"c\\\",\\\"titles\\\":[]}\"},\"defaulted\":{\"status\":200,\"body\":\"[\\\"default instance\\\"]\"}}", + api: true, + stored: [{ name: "the notes by instance", expression: "$DB.getCollection('http__notes').find().sort({ title: 1 }).toArray().map((r) => [r.title, r._instance])", expected: "[[\"alpha\",\"tenant-a\"],[\"beta\",\"tenant-b\"],[\"default instance\",null]]" }], + }), + "custom-parameter-provider": scenario({ title: "a custom decorator can register its own parameter provider that resolves a model with GetModel", body: CUSTOM_PROVIDER, printed: "{\"x\":{\"status\":200,\"body\":\"[\\\"custom x\\\"]\"},\"y\":{\"status\":200,\"body\":\"[]\"}}", api: true }), + "property-decoration": scenario({ + title: "@Model on a property gives the controller its model while a request is handled", + body: PROPERTY, + printed: "{\"list\":{\"status\":200,\"body\":\"[\\\"stored\\\"]\"},\"one\":{\"status\":200,\"body\":\"stored\"},\"missing\":{\"status\":200,\"body\":\"missing\"},\"sameAsGetModel\":{\"status\":200,\"body\":\"true\"},\"outsideARequest\":true}", + api: true, + }), + "crud-controller": scenario({ + title: "@Model combines with the API decorators in a CRUD controller", + body: CRUD_CONTROLLER, + printed: "{\"created\":[200,\"string\"],\"updated\":{\"status\":200,\"body\":\"1\"},\"read\":{\"status\":200,\"body\":\"renamed\"},\"removed\":{\"status\":200,\"body\":\"1\"},\"gone\":{\"status\":200,\"body\":\"missing\"}}", + api: true, + stored: [{ name: "the notes left", expression: "$DB.getCollection('http__notes').find().toArray().length", expected: "0" }], + }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/encryption.ts b/.agents/skills/verify-interface/scenarios/encryption.ts new file mode 100644 index 0000000..0388603 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/encryption.ts @@ -0,0 +1,115 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +const KEY = "12345678901234567890123456789012"; + +/** Decrypts a stored `[ciphertext, iv, authTag]` triple with Node's crypto alone: the scenario's second view of the cipher. */ +const DECRYPT = ` + const decrypt = (triple, algorithm = "aes-256-gcm", key = "${KEY}") => { + const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(triple[1], "base64"), triple[2] ? { authTagLength: 16 } : undefined); + if (triple[2]) decipher.setAuthTag(Buffer.from(triple[2], "base64")); + return Buffer.concat([decipher.update(Buffer.from(triple[0], "base64")), decipher.final()]).toString("utf8"); + }; +`; + +const AT_REST = `${DECRYPT} + class Vault extends d.Table.with(d.EncryptionModifier) {} + decorate(Vault, { + owner: [d.Field("string")], + card: [d.Encrypted({ secretKey: "${KEY}", algorithm: "aes-256-gcm", ivSize: 16 }), d.Field("string")], + personal: [d.Encrypted({ secretKey: "${KEY}" }), d.Field("any")], + legacy: [d.Encrypted({ secretKey: "${KEY}", algorithm: "aes-256-cbc" }), d.Field("string")], + short: [d.Encrypted({ secretKey: "${KEY}", ivSize: 12 }), d.Field("string")], + aead: [d.Encrypted({ secretKey: "${KEY}", algorithm: "chacha20-poly1305", ivSize: 12 }), d.Field("string")], + }); + d.RegisterTable("vaults", "enc")(Vault); + await d.RegisterSchema("enc"); + const model = d.GetModel(d.BasicDataModel(Vault)); + const personal = { ssn: "078-05-1120", birthDate: "1990-01-01" }; + const [id] = await model.insert({ owner: "Ann", card: "4242 4242 4242 4242", personal, legacy: "old cipher", short: "twelve", aead: "hello" }); + const [again] = await model.insert({ owner: "Bea", card: "4242 4242 4242 4242" }); + const read = await model.get(id); + const raw = await rawTable("enc", "vaults").get(id); + const rawAgain = await rawTable("enc", "vaults").get(again); + const data = raw._internal.data; + const shape = (triple) => [Array.isArray(triple), triple.length, typeof triple[2]]; + return { + read: [read.card, read.personal, read.legacy, read.short, read.aead], + topLevelHoldsNoCiphertext: raw.card === undefined && raw.personal === undefined, + gcmShape: shape(data.card), + cbcShape: [data.legacy.length, data.legacy[2] ?? null], + ivBytes: [Buffer.from(data.card[1], "base64").length, Buffer.from(data.short[1], "base64").length, Buffer.from(data.personal[1], "base64").length, Buffer.from(data.legacy[1], "base64").length], + aeadShape: [data.aead.length, typeof data.aead[2], Buffer.from(data.aead[2], "base64").length, Buffer.from(data.aead[1], "base64").length], + aeadDecrypt: decrypt(data.aead, "chacha20-poly1305"), + aeadTagChecked: (() => { const bad = [...data.aead]; bad[2] = Buffer.alloc(16).toString("base64"); try { decrypt(bad, "chacha20-poly1305"); return "decrypted"; } catch { return "refused"; } })(), + plaintextAbsent: !JSON.stringify(raw).includes("4242") && !JSON.stringify(raw).includes("078-05-1120"), + independentDecrypt: [decrypt(data.card), decrypt(data.personal), decrypt(data.legacy, "aes-256-cbc")], + sameValueTwice: [data.card[1] !== rawAgain._internal.data.card[1], data.card[0] !== rawAgain._internal.data.card[0]], + }; +`; + +const DATES = `${DECRYPT} + class Event extends d.Table.with(d.EncryptionModifier) {} + decorate(Event, { when: [d.Encrypted({ secretKey: "${KEY}" }), d.Field("any")], plainObject: [d.Encrypted({ secretKey: "${KEY}" }), d.Field("any")] }); + d.RegisterTable("events", "encdate")(Event); + await d.RegisterSchema("encdate"); + const model = d.GetModel(d.BasicDataModel(Event)); + const moment = new Date("2026-01-02T03:04:05.678Z"); + const [nestedId] = await model.insert({ when: { inside: { at: moment }, list: [moment, "text", [moment]] }, plainObject: 1 }); + const nested = (await model.get(nestedId)).when; + const [id] = await model.insert({ when: moment, plainObject: { type: "Date", value: "not a date", extra: 1 } }); + const read = await model.get(id); + const raw = await rawTable("encdate", "events").get(id); + const [taggedId] = await model.insert({ when: moment, plainObject: { type: "Date", value: moment.toUTCString(), extra: 1 } }); + const tagged = (await model.get(taggedId)).plainObject; + const [twoKeyId] = await model.insert({ when: moment, plainObject: { type: "Date", value: "x" } }); + const twoKey = (await model.get(twoKeyId)).plainObject; + return { + taggedKept: [tagged instanceof Date, tagged], + twoKeyKept: [twoKey instanceof Date, twoKey], + isDate: read.when instanceof Date, + iso: read.when.toISOString(), + storedText: JSON.parse(decrypt(raw._internal.data.when)), + lookalikeKept: read.plainObject, + nested: [nested.inside.at instanceof Date, nested.inside.at.toISOString(), nested.list[0] instanceof Date, nested.list[1], nested.list[2][0] instanceof Date, nested.list[2][0].toISOString()], + }; +`; + +const KEYS = ` + class Strict extends d.Table.with(d.EncryptionModifier) {} + const goodKey = "${KEY}"; + const hexKey = Buffer.from(goodKey).toString("hex"); + class Hex extends d.Table.with(d.EncryptionModifier) {} + decorate(Strict, { value: [d.Encrypted({ secretKey: goodKey }), d.Field("string")] }); + decorate(Hex, { value: [d.Encrypted({ secretKey: hexKey }), d.Field("string")] }); + d.RegisterTable("strict", "enckey")(Strict); + d.RegisterTable("hex", "enckey")(Hex); + await d.RegisterSchema("enckey"); + const strict = d.GetModel(d.BasicDataModel(Strict)); + const hex = d.GetModel(d.BasicDataModel(Hex)); + const [id] = await strict.insert({ value: "kept" }); + return { + hexKeyBytes: Buffer.byteLength(hexKey), + hexRefused: await rejection(hex.insert({ value: "refused" })), + goodStored: (await strict.get(id)).value, + hexStored: (await hex.getAll()).length, + }; +`; + +export const ENCRYPTION_JOURNEYS = { + "encryption-at-rest": scenario({ + title: "an encrypted field is stored as base64 ciphertext, iv and auth tag that only the key opens", + body: AT_REST, + printed: "{\"read\":[\"4242 4242 4242 4242\",{\"ssn\":\"078-05-1120\",\"birthDate\":\"1990-01-01\"},\"old cipher\",\"twelve\",\"hello\"],\"topLevelHoldsNoCiphertext\":true,\"gcmShape\":[true,3,\"string\"],\"cbcShape\":[3,null],\"ivBytes\":[16,12,16,16],\"aeadShape\":[3,\"string\",16,12],\"aeadDecrypt\":\"\\\"hello\\\"\",\"aeadTagChecked\":\"refused\",\"plaintextAbsent\":true,\"independentDecrypt\":[\"\\\"4242 4242 4242 4242\\\"\",\"{\\\"ssn\\\":\\\"078-05-1120\\\",\\\"birthDate\\\":\\\"1990-01-01\\\"}\",\"\\\"old cipher\\\"\"],\"sameValueTwice\":[true,true]}", + stored: [ + { + name: "the stored triples", + expression: "$DB.getCollection('enc__vaults').find({ owner: 'Ann' }).toArray().map((r) => [Object.keys(r._internal.data).sort(), r._internal.data.card.length, r._internal.data.legacy.length, r.card === undefined])", + expected: "[[[\"aead\",\"card\",\"legacy\",\"personal\",\"short\"],3,3,true]]", + }, + ], + }), + "encryption-dates": scenario({ title: "an encrypted Date comes back as a Date with whole-second precision", body: DATES, printed: "{\"taggedKept\":[false,{\"type\":\"Date\",\"value\":\"Fri, 02 Jan 2026 03:04:05 GMT\",\"extra\":1}],\"twoKeyKept\":[false,{\"type\":\"Date\",\"value\":\"x\"}],\"isDate\":true,\"iso\":\"2026-01-02T03:04:05.000Z\",\"storedText\":{\"value\":\"Fri, 02 Jan 2026 03:04:05 GMT\",\"type\":\"Date\"},\"lookalikeKept\":{\"type\":\"Date\",\"value\":\"not a date\",\"extra\":1},\"nested\":[true,\"2026-01-02T03:04:05.000Z\",true,\"text\",true,\"2026-01-02T03:04:05.000Z\"]}" }), + "encryption-keys": scenario({ title: "the key goes to the cipher as a UTF-8 string, so a hex-encoded key is refused", body: KEYS, printed: "{\"hexKeyBytes\":64,\"hexRefused\":{\"name\":\"RangeError\",\"message\":\"Invalid key length\"},\"goodStored\":\"kept\",\"hexStored\":0}" }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/hashing.ts b/.agents/skills/verify-interface/scenarios/hashing.ts new file mode 100644 index 0000000..b051ee0 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/hashing.ts @@ -0,0 +1,282 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +/** Independent scrypt, digest and parsing helpers, so the scenario checks the stored hash without the library. */ +const HASH_HELPERS = ` + const CURRENT = "$" + "scrypt" + "$ln=15,r=8,p=3$"; + const b64 = (buffer) => buffer.toString("base64").replace(/=+$/, ""); + const scryptOf = (value, salt, keylen, ln, r, p) => crypto.scryptSync(JSON.stringify(value), salt, keylen, { N: 2 ** ln, r, p, maxmem: 1024 * 1024 * 1024 }); + const scryptString = (value, ln, r, p, saltBytes = 16, keyBytes = 32) => { const salt = Buffer.alloc(saltBytes, 7); return "$" + "scrypt" + "$ln=" + ln + ",r=" + r + ",p=" + p + "$" + b64(salt) + "$" + b64(scryptOf(value, salt, keyBytes, ln, r, p)); }; + const digest = (algorithm, salt, value) => crypto.createHash(algorithm).update(salt).update(JSON.stringify(value)).digest("base64"); + class Account extends d.Table.with(d.HashModifier) {} + decorate(Account, { name: [d.Field("string")], password: [d.Hashed(), d.Field("string")], pin: [d.Hashed({ algorithm: "sha256" }), d.Field("string")], token: [d.Hashed({ algorithm: "sha256" }), d.Field("string")], long: [d.Hashed({ algorithm: "sha512" }), d.Field("string")], legacy: [d.Hashed({ algorithm: "md5" }), d.Field("string")], one: [d.Hashed({ algorithm: "sha1" }), d.Field("string")], mid: [d.Hashed({ algorithm: "sha384" }), d.Field("string")] }); + d.RegisterTable("accounts", "hash")(Account); + await d.RegisterSchema("hash"); + const Model = d.BasicDataModel(Account); + const accounts = d.GetModel(Model); + const rawRow = async (id) => rawTable("hash", "accounts").get(id); +`; + +const STORAGE = `${HASH_HELPERS} + const [id] = await accounts.insert({ name: "Ann", password: "secret" }); + const [second] = await accounts.insert({ name: "Bea", password: "secret" }); + const raw = await rawRow(id); + const stored = raw._internal.data.password; + const match = /^\\$scrypt\\$ln=15,r=8,p=3\\$([A-Za-z0-9+/]+)\\$([A-Za-z0-9+/]+)$/.exec(stored); + const salt = Buffer.from(match[1], "base64"); + const key = Buffer.from(match[2], "base64"); + const expected = scryptOf("secret", salt, 32, 15, 8, 3); + const loaded = await accounts.get(id); + d.lock(loaded, d.HashModifier, ["password"]); + const kept = d.toDatabase(loaded)._internal.data.password; + loaded.password = stored; + const undef = Model.fromPlainData({ _id: "y", password: undefined }); + return { + format: [Boolean(match), salt.length, key.length, match[1].includes("="), match[2].includes("=")], + hashesTheJsonText: Buffer.compare(key, expected) === 0, + plaintextAbsent: !JSON.stringify(raw).includes("secret"), + freshSaltEachWrite: raw._internal.data.password !== (await rawRow(second))._internal.data.password, + lockKeepsTheHash: kept === stored, + assigningTheStoredHashKeepsIt: d.toDatabase(loaded)._internal.data.password === stored, + undefinedStoresNothing: d.toDatabase(undef)._internal === undefined || d.toDatabase(undef)._internal.data.password === undefined, + }; +`; + +const VERIFY = `${HASH_HELPERS} + const [id] = await accounts.insert({ name: "Ann", password: "secret" }); + const row = await accounts.get(id); + const asyncRight = await row.testHashAsync("password", "secret"); + const asyncWrong = await row.testHashAsync("password", "Secret"); + const syncRight = row.testHash("password", "secret"); + const syncWrong = row.testHash("password", "nope"); + const notHashedAsync = await rejection(row.testHashAsync("name", "Ann")); + let notHashedOutdated = null; + try { row.isHashOutdated("name"); } catch (error) { notHashedOutdated = error.message; } + const [bare] = await accounts.insert({ name: "Nobody" }); + const bareRow = await accounts.get(bare); + return { + asyncRight, asyncWrong, syncRight: [typeof syncRight, syncRight], syncWrong, + fresh: row.isHashOutdated("password"), + notHashedAsync: notHashedAsync && notHashedAsync.message, + notHashedOutdated, + nothingStored: [bareRow.isHashOutdated("password"), await bareRow.testHashAsync("password", "x")], + }; +`; + +const LIMITS = `${HASH_HELPERS} + const seed = async (id, password, extra = {}) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta: {}, data: { password } }, ...extra }).run(); + const cases = { + cheap: [10, 8, 1], boundaryMemory: [17, 8, 1], tooMuchMemory: [18, 8, 1], + parallelBoundary: [8, 8, 16], parallelTooMany: [8, 8, 17], costBelow16TimesR: [15, 1, 1], + }; + const verdicts = {}; + for (const [name, [ln, r, p]] of Object.entries(cases)) { + await seed(name, scryptString("pw", ln, r, p)); + const row = await accounts.get(name); + verdicts[name] = [await row.testHashAsync("password", "pw"), await row.testHashAsync("password", "other")]; + } + const sizes = { shortSalt: scryptString("pw", 8, 8, 1, 8, 32), longKey: scryptString("pw", 8, 8, 1, 16, 64), tooLongKey: scryptString("pw", 8, 8, 1, 16, 80), shortKey: scryptString("pw", 8, 8, 1, 16, 8), threeDigitP: scryptString("pw", 4, 1, 100) }; + for (const [name, stored] of Object.entries(sizes)) { + await seed(name, stored); + verdicts[name] = await (await accounts.get(name)).testHashAsync("password", "pw"); + } + return verdicts; +`; + +const UPGRADE = `${HASH_HELPERS} + const seed = async (id, data, meta = {}) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta, data } }).run(); + await seed("legacy", { password: digest("sha256", "old-salt", "hunter2") }, { HashModifier: { salt: "old-salt" } }); + await seed("lowcost", { password: scryptString("hunter2", 10, 8, 1) }); + const legacy = await accounts.get("legacy"); + const lowcost = await accounts.get("lowcost"); + const before = [await legacy.testHashAsync("password", "hunter2"), legacy.isHashOutdated("password"), await lowcost.testHashAsync("password", "hunter2"), lowcost.isHashOutdated("password")]; + const wrongLegacy = await legacy.testHashAsync("password", "wrong"); + if (await legacy.testHashAsync("password", "hunter2")) { if (legacy.isHashOutdated("password")) { legacy.password = "hunter2"; await accounts.update(legacy); } } + const rewritten = await rawRow("legacy"); + const afterRewrite = [rewritten._internal.data.password.startsWith(CURRENT), (await accounts.get("legacy")).isHashOutdated("password"), await (await accounts.get("legacy")).testHashAsync("password", "hunter2")]; + const lowBefore = (await rawRow("lowcost"))._internal.data.password; + await accounts.update("lowcost", { name: "renamed" }); + const keptByNameUpdate = (await rawRow("lowcost"))._internal.data.password === lowBefore; + await accounts.update({ _id: "lowcost", name: "renamed again" }); + const keptByObjectUpdate = (await rawRow("lowcost"))._internal.data.password === lowBefore; + await accounts.update("lowcost", { password: "hunter2" }); + const viaUpdate = (await rawRow("lowcost"))._internal.data.password.startsWith(CURRENT); + return { before, wrongLegacy, afterRewrite, keptByNameUpdate, keptByObjectUpdate, viaUpdate }; +`; + +const DIGEST = `${HASH_HELPERS} + const [id] = await accounts.insert({ name: "Ann", pin: "1234", token: "abc", long: "abc", legacy: "abc", one: "abc", mid: "abc" }); + const raw = await rawRow(id); + const salt = raw._internal.meta.HashModifier.salt; + const others = [raw._internal.data.one === digest("sha1", salt, "abc"), raw._internal.data.mid === digest("sha384", salt, "abc")]; + const algorithms = [raw._internal.data.long === digest("sha512", salt, "abc"), raw._internal.data.legacy === digest("md5", salt, "abc"), raw._internal.data.long === digest("sha256", salt, "abc"), raw._internal.data.long.length, raw._internal.data.legacy.length]; + const checked = await accounts.get(id); + const algorithmTests = [checked.testHash("long", "abc"), checked.testHash("legacy", "abc"), checked.testHash("long", "abd")]; + await rawTable("hash", "accounts").insert({ _id: "wrong-digest", name: "w", _internal: { meta: { HashModifier: { salt } }, data: { long: digest("sha256", salt, "abc") } } }).run(); + const wrongDigest = (await accounts.get("wrong-digest")).testHash("long", "abc"); + const row = await accounts.get(id); + const [other] = await accounts.insert({ name: "Bea", pin: "1234" }); + const otherSalt = (await rawRow(other))._internal.meta.HashModifier.salt; + const again = await accounts.get(id); + again.pin = "9999"; + const rewritten = d.toDatabase(again); + const holdsScrypt = Model.fromDatabase({ _id: "h", name: "h", _internal: { meta: { HashModifier: { salt } }, data: { pin: scryptString("1234", 10, 8, 1) } } }); + const sha512 = Model.fromDatabase({ _id: "s", name: "s", _internal: { meta: { HashModifier: { salt } }, data: { pin: crypto.createHash("sha512").update(salt).update(JSON.stringify("1234")).digest("base64") } } }); + return { + algorithms, others, algorithmTests, wrongDigest, + storedDigest: raw._internal.data.pin === digest("sha256", salt, "1234"), + sharedSalt: raw._internal.data.token === digest("sha256", salt, "abc"), + saltIsText: typeof salt === "string" && salt.length > 0, + freshSaltPerRow: salt !== otherSalt, + saltReusedOnLaterWrite: [rewritten._internal.meta.HashModifier.salt === salt, rewritten._internal.data.pin === digest("sha256", salt, "9999")], + testDigest: [row.testHash("pin", "1234"), row.testHash("pin", "0000"), await row.testHashAsync("pin", "1234")], + digestNeverOutdated: row.isHashOutdated("pin"), + scryptOnDigestField: [holdsScrypt.testHash("pin", "1234"), holdsScrypt.isHashOutdated("pin")], + otherDigestRefused: sha512.testHash("pin", "1234"), + }; +`; + + +const BOUNDS = `${HASH_HELPERS} + const seed = async (id, password) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta: {}, data: { password } } }).run(); + const attempt = async (id, stored) => { + await seed(id, stored); + const row = await accounts.get(id); + try { return [await row.testHashAsync("password", "pw"), await row.testHashAsync("password", "other")]; } catch (error) { return "threw " + error.name; } + }; + const fake = (ln, r, p) => "$" + "scrypt" + "$ln=" + ln + ",r=" + r + ",p=" + p + "$" + b64(Buffer.alloc(16, 1)) + "$" + b64(Buffer.alloc(32, 2)); + return { + r1: await attempt("r1", scryptString("pw", 4, 1, 1)), + r10: await attempt("r10", scryptString("pw", 4, 10, 1)), + r99: await attempt("r99", scryptString("pw", 4, 99, 1)), + r100: await attempt("r100", scryptString("pw", 4, 100, 1)), + r0: await attempt("r0", fake(4, 0, 1)), + ln0: await attempt("ln0", fake(0, 8, 1)), + p0: await attempt("p0", fake(4, 8, 0)), + p99: await attempt("p99", scryptString("pw", 4, 8, 99)), + ln16r1: await attempt("ln16r1", fake(16, 1, 1)), + salt65: await attempt("salt65", scryptString("pw", 4, 8, 1, 65, 32)), + ln1: await attempt("ln1", scryptString("pw", 1, 8, 1)), + ln2: await attempt("ln2", scryptString("pw", 2, 8, 1)), + ln99: await attempt("ln99", fake(99, 8, 1)), + ln100: await attempt("ln100", fake(100, 8, 1)), + p1: await attempt("p1", scryptString("pw", 4, 8, 1)), + p16: await attempt("p16", scryptString("pw", 4, 8, 16)), + salt16: await attempt("salt16", scryptString("pw", 4, 8, 1, 16, 32)), + salt64: await attempt("salt64", scryptString("pw", 4, 8, 1, 64, 32)), + salt80: await attempt("salt80", scryptString("pw", 4, 8, 1, 80, 32)), + salt15: await attempt("salt15", scryptString("pw", 4, 8, 1, 15, 32)), + key16: await attempt("key16", scryptString("pw", 4, 8, 1, 16, 16)), + key64: await attempt("key64", scryptString("pw", 4, 8, 1, 16, 64)), + key15: await attempt("key15", scryptString("pw", 4, 8, 1, 16, 15)), + key65: await attempt("key65", scryptString("pw", 4, 8, 1, 16, 65)), + }; +`; + +const MEMORY = `${HASH_HELPERS} + const seed = async (id, password) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta: {}, data: { password } } }).run(); + const attempt = async (id, stored) => { + await seed(id, stored); + const row = await accounts.get(id); + try { return [await row.testHashAsync("password", "pw"), await row.testHashAsync("password", "other")]; } catch (error) { return "threw " + error.name; } + }; + return { + exactR64: await attempt("a", scryptString("pw", 14, 64, 1)), + overR65: await attempt("b", scryptString("pw", 14, 65, 1)), + exactR2: await attempt("c", scryptString("pw", 19, 2, 1)), + underR3: await attempt("d", scryptString("pw", 18, 3, 1)), + overR5: await attempt("e", scryptString("pw", 18, 5, 1)), + overR9: await attempt("f", scryptString("pw", 17, 9, 1)), + }; +`; + +const SYNC_ASYNC = `${HASH_HELPERS} + const seedRow = async (id, data, meta = {}) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta, data } }).run(); + await seedRow("legacy", { password: digest("sha256", "old-salt", "hunter2") }, { HashModifier: { salt: "old-salt" } }); + await seedRow("lowcost", { password: scryptString("hunter2", 10, 8, 1) }); + await seedRow("junk", { password: "plain text" }); + await seedRow("empty", {}); + await seedRow("digest", { pin: digest("sha256", "pin-salt", "hunter2") }, { HashModifier: { salt: "pin-salt" } }); + await seedRow("scryptOnDigest", { pin: scryptString("hunter2", 10, 8, 1) }); + const [fresh] = await accounts.insert({ name: "fresh", password: "hunter2", pin: "hunter2" }); + const matrix = {}; + for (const [id, field] of [[fresh, "password"], [fresh, "pin"], ["legacy", "password"], ["lowcost", "password"], ["junk", "password"], ["empty", "password"], ["digest", "pin"], ["scryptOnDigest", "pin"]]) { + const row = await accounts.get(id); + const answers = []; + for (const candidate of ["hunter2", "wrong", ""]) { + const sync = row.testHash(field, candidate); + const asynchronous = await row.testHashAsync(field, candidate); + answers.push([sync, asynchronous, sync === asynchronous]); + } + matrix[(id === fresh ? "fresh" : id) + ":" + field] = answers; + } + return matrix; +`; + +const COST = `${HASH_HELPERS} + const seedRow = async (id, data, meta = {}) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta, data } }).run(); + await seedRow("junk", { password: "plain text" }); + await seedRow("empty", {}); + await seedRow("digest", { pin: digest("sha256", "pin-salt", "hunter2") }, { HashModifier: { salt: "pin-salt" } }); + const [fresh] = await accounts.insert({ name: "fresh", password: "hunter2" }); + const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)]; + const timed = async (id, field) => { const row = await accounts.get(id); const runs = []; for (let i = 0; i < 3; i += 1) { const start = process.hrtime.bigint(); await row.testHashAsync(field, "wrong"); runs.push(Number(process.hrtime.bigint() - start) / 1e6); } return median(runs); }; + const timedSync = async (id, field) => { const row = await accounts.get(id); const runs = []; for (let i = 0; i < 3; i += 1) { const start = process.hrtime.bigint(); const answer = row.testHash(field, "wrong"); runs.push(answer === false ? Number(process.hrtime.bigint() - start) / 1e6 : -1); } return median(runs); }; + const allPass = (value) => Object.values(value).every((entry) => (Array.isArray(entry) ? entry.every(Boolean) : Boolean(entry))); + const measure = async () => { + const hashed = await timed(fresh, "password"); + const empty = await timed("empty", "password"); + const junk = await timed("junk", "password"); + const digest = await timed("digest", "pin"); + const syncHashed = await timedSync(fresh, "password"); + const syncEmpty = await timedSync("empty", "password"); + const syncJunk = await timedSync("junk", "password"); + const syncDigest = await timedSync("digest", "pin"); + return { emptyVsHashed: empty / hashed >= 0.5, junkVsHashed: junk / hashed >= 0.5, digestCheaper: digest / hashed < 0.5, syncSpendsOneRun: [syncHashed / hashed >= 0.5, syncEmpty / hashed >= 0.5, syncJunk / hashed >= 0.5], syncDigestCheaper: syncDigest / hashed < 0.5 }; + }; + let cost = await measure(); + for (let attempt = 0; attempt < 2 && !allPass(cost); attempt += 1) cost = await measure(); + const row = await accounts.get(fresh); + const loopTicks = async (work) => { let ticks = 0; const timer = setInterval(() => { ticks += 1; }, 1); try { await work(); } finally { clearInterval(timer); } return ticks; }; + let asyncTicks = 0; + for (let attempt = 0; attempt < 3 && asyncTicks <= 2; attempt += 1) asyncTicks = await loopTicks(() => row.testHashAsync("password", "hunter2")); + return { ...cost, asyncKeepsLoopRunning: asyncTicks > 2 }; +`; + +const OUTDATED = `${HASH_HELPERS} + const seed = async (id, password) => rawTable("hash", "accounts").insert({ _id: id, name: id, _internal: { meta: {}, data: { password } } }).run(); + const probe = async (id, stored) => { await seed(id, stored); const row = await accounts.get(id); return [await row.testHashAsync("password", "pw"), row.isHashOutdated("password")]; }; + const [fresh] = await accounts.insert({ name: "fresh", password: "pw" }); + const freshRow = await accounts.get(fresh); + return { + fresh: [await freshRow.testHashAsync("password", "pw"), freshRow.isHashOutdated("password")], + sameCost: await probe("same", scryptString("pw", 15, 8, 3)), + lowerP: await probe("p1", scryptString("pw", 15, 8, 1)), + higherR: await probe("r9", scryptString("pw", 15, 9, 3)), + lowerLn: await probe("ln14", scryptString("pw", 14, 8, 3)), + notScrypt: await probe("junk", "plain text"), + }; +`; + +export const HASH_JOURNEYS = { + "hash-storage": scenario({ + title: "a hashed field stores a salted scrypt hash of the JSON-encoded value", + body: STORAGE, + printed: "{\"format\":[true,16,32,false,false],\"hashesTheJsonText\":true,\"plaintextAbsent\":true,\"freshSaltEachWrite\":true,\"lockKeepsTheHash\":true,\"assigningTheStoredHashKeepsIt\":true,\"undefinedStoresNothing\":true}", + stored: [{ name: "the stored hash", expression: "$DB.getCollection('hash__accounts').find({ name: 'Ann' }).toArray().map((r) => [Object.keys(r._internal.data), /^\\$scrypt\\$ln=15,r=8,p=3\\$[A-Za-z0-9+/]{22}\\$[A-Za-z0-9+/]{43}$/.test(r._internal.data.password)])", expected: "[[[\"password\"],true]]" }], + }), + "hash-verify": scenario({ title: "testHash, testHashAsync and isHashOutdated answer for a hashed field and refuse another", body: VERIFY, printed: "{\"asyncRight\":true,\"asyncWrong\":false,\"syncRight\":[\"boolean\",true],\"syncWrong\":false,\"fresh\":false,\"notHashedAsync\":\"Field name is not hashed\",\"notHashedOutdated\":\"Field name is not hashed\",\"nothingStored\":[false,false]}" }), + "hash-limits": scenario({ title: "a stored scrypt string verifies only inside the documented limits", body: LIMITS, printed: "{\"cheap\":[true,false],\"boundaryMemory\":[true,false],\"tooMuchMemory\":[false,false],\"parallelBoundary\":[true,false],\"parallelTooMany\":[false,false],\"costBelow16TimesR\":[true,false],\"shortSalt\":false,\"longKey\":true,\"tooLongKey\":false,\"shortKey\":false,\"threeDigitP\":false}" }), + "hash-upgrade": scenario({ + title: "older hash formats still verify, are flagged, and are rewritten by assigning the tested value", + body: UPGRADE, + printed: "{\"before\":[true,true,true,true],\"wrongLegacy\":false,\"afterRewrite\":[true,false,true],\"keptByNameUpdate\":true,\"keptByObjectUpdate\":true,\"viaUpdate\":true}", + }), + "hash-bounds": scenario({ title: "a stored scrypt string verifies at each edge of the documented ranges and is refused just outside", body: BOUNDS, printed: "{\"r1\":[true,false],\"r10\":[true,false],\"r99\":[true,false],\"r100\":[false,false],\"r0\":[false,false],\"ln0\":[false,false],\"p0\":[false,false],\"p99\":[false,false],\"ln16r1\":[false,false],\"salt65\":[false,false],\"ln1\":[true,false],\"ln2\":[true,false],\"ln99\":[false,false],\"ln100\":[false,false],\"p1\":[true,false],\"p16\":[true,false],\"salt16\":[true,false],\"salt64\":[true,false],\"salt80\":[false,false],\"salt15\":[false,false],\"key16\":[true,false],\"key64\":[true,false],\"key15\":[false,false],\"key65\":[false,false]}" }), + "hash-memory": scenario({ title: "a stored scrypt string verifies up to 128 MiB of memory (128 x N x r) and is refused above it", body: MEMORY, printed: "{\"exactR64\":[true,false],\"overR65\":[false,false],\"exactR2\":[true,false],\"underR3\":[true,false],\"overR5\":[false,false],\"overR9\":[false,false]}" }), + "hash-sync-async": scenario({ title: "testHash answers exactly what testHashAsync answers, for every kind of stored value", body: SYNC_ASYNC, printed: "{\"fresh:password\":[[true,true,true],[false,false,true],[false,false,true]],\"fresh:pin\":[[true,true,true],[false,false,true],[false,false,true]],\"legacy:password\":[[true,true,true],[false,false,true],[false,false,true]],\"lowcost:password\":[[true,true,true],[false,false,true],[false,false,true]],\"junk:password\":[[false,false,true],[false,false,true],[false,false,true]],\"empty:password\":[[false,false,true],[false,false,true],[false,false,true]],\"digest:pin\":[[true,true,true],[false,false,true],[false,false,true]],\"scryptOnDigest:pin\":[[true,true,true],[false,false,true],[false,false,true]]}" }), + "hash-cost": scenario({ title: "a test spends one scrypt run whatever the row holds, a digest field spends none, testHashAsync leaves the event loop running and testHash spends the scrypt run inside the call, none for a digest", body: COST, printed: "{\"emptyVsHashed\":true,\"junkVsHashed\":true,\"digestCheaper\":true,\"syncSpendsOneRun\":[true,true,true],\"syncDigestCheaper\":true,\"asyncKeepsLoopRunning\":true}" }), + "hash-outdated": scenario({ title: "isHashOutdated flags any scrypt string whose ln, r or p differs from the current cost", body: OUTDATED, printed: "{\"fresh\":[true,false],\"sameCost\":[true,false],\"lowerP\":[true,true],\"higherR\":[true,true],\"lowerLn\":[true,true],\"notScrypt\":[false,true]}" }), + "hash-digest": scenario({ title: "a field with an algorithm stores a salted digest with the row's shared salt", body: DIGEST, printed: "{\"algorithms\":[true,true,false,88,24],\"others\":[true,true],\"algorithmTests\":[true,true,false],\"wrongDigest\":false,\"storedDigest\":true,\"sharedSalt\":true,\"saltIsText\":true,\"freshSaltPerRow\":true,\"saltReusedOnLaterWrite\":[true,true],\"testDigest\":[true,false,true],\"digestNeverOutdated\":false,\"scryptOnDigestField\":[true,false],\"otherDigestRefused\":false}" }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/localization.ts b/.agents/skills/verify-interface/scenarios/localization.ts new file mode 100644 index 0000000..4f2a741 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/localization.ts @@ -0,0 +1,204 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +const PRODUCT = ` + class Product extends d.Table.with(d.LocalizationModifier) {} + decorate(Product, { + price: [d.Field("number")], + name: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + description: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + bare: [d.Localized(), d.Field("string")], + summary: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + }); + d.RegisterTable("products", "loc")(Product); + await d.RegisterSchema("loc"); +`; + +const LOCALIZE = `${PRODUCT} + const product = new Product(); + product._id = "prod-123"; + product.price = 99.99; + product.localize("en").name = "Premium Headphones"; + product.localize("fr").name = "Casque Premium"; + product.localize("es").name = "Auriculares Premium"; + const spanish = product.localize("es"); + const spanishNow = spanish.name; + const german = product.localize("de"); + const germanNow = german.name; + const sameObject = [spanish === product, german === product, spanish === german]; + const readNow = spanish.name; + product.localize("en").description = "Loud"; + product.localize("fr").description = ""; + const frenchEmpty = product.localize("fr").description; + product.localize("fr").bare = "sans repli"; + const noFallback = [product.localize("fr").bare, product.localize("de").bare ?? "undefined"]; + product.localize("en").summary = "short"; + product.localize("fr").summary = "court"; + const restricted = product.localize("en").localize("fr", ["name"]); + const restrictedReads = [restricted.name, restricted.summary]; + const untouched = product.localize("en").localize("fr", []); + const emptyListReads = [untouched.name, untouched.summary]; + return { + emptyListReads, + spanish: spanishNow, + germanFallsBack: germanNow, + sameObject, + readNow, + frenchEmpty, + noFallback, + restrictedReads, + fallbackLocaleOf: [d.LocalizationModifier.fallbackLocaleOf(Product, "name"), d.LocalizationModifier.fallbackLocaleOf(Product, "bare") ?? "undefined", d.LocalizationModifier.fallbackLocaleOf(Product, "price") ?? "undefined", d.LocalizationModifier.fallbackLocaleOf(Product, "missing") ?? "undefined"], + }; +`; + +const WILDCARD = `${PRODUCT} + const product = new Product(); + product.localize("en").name = "Premium Headphones"; + product.localize("fr").name = "Casque Premium"; + const all = product.localize("*").name; + Object.assign(product.localize("*"), { name: { en: "Premium Headphones", fr: "Casque Premium", de: "Premium-Kopfhoerer", es: "" } }); + const replaced = product.localize("*").name; + const german = product.localize("de").name; + const model = d.GetModel(d.BasicDataModel(Product)); + const written = new Product(); + written.localize("en").name = "Hello"; + written.localize("fr").name = "Bonjour"; + const [id] = await model.insert(written); + const loaded = await model.get(id); + return { all, replaced, german, stored: [loaded.localize("fr").name, loaded.localize("it").name, loaded.localize("*").name] }; +`; + +const WILDCARD_REPLACE = `${PRODUCT} + const model = d.GetModel(d.BasicDataModel(Product)); + const product = new Product(); + product._id = "wild"; + product.price = 3; + product.localize("en").name = "Hello"; + product.localize("fr").name = "Bonjour"; + const before = product.localize("*").name; + Object.assign(product.localize("*"), { name: { de: "Hallo" } }); + const afterWrite = product.localize("*").name; + const fallbacks = [product.localize("fr").name ?? "undefined", product.localize("en").name ?? "undefined", product.localize("de").name]; + await model.insert(product); + const loaded = await model.get("wild"); + return { before, afterWrite, fallbacks, read: loaded.localize("*").name, readFr: loaded.localize("fr").name ?? "undefined" }; +`; + +const STORED = `${PRODUCT} + const model = d.GetModel(d.BasicDataModel(Product)); + const first = new Product(); + first.price = 5; + first.localize("en").name = "Hello"; + first.localize("fr").name = "Bonjour"; + first.localize("de").name = ""; + const [id] = await model.insert(first); + const alone = new Product(); + alone.price = 6; + alone.localize("fr").name = "Seulement"; + const [second] = await model.insert(alone); + const only = await model.get(second); + const raw = await rawTable("loc", "products").get(id); + await rawTable("loc", "products").insert({ _id: "raw-empty", price: 7, _internal: { meta: {}, data: { name: { en: "Hello", fr: "" }, bare: { fr: "" } } } }).run(); + const rawRow = await model.get("raw-empty"); + const fromRaw = [rawRow.localize("fr").name, rawRow.localize("fr").bare ?? "undefined", rawRow.localize("en").name]; + return { fromRaw, rawKeys: Object.keys(raw._internal.data).sort(), raw: raw._internal.data.name, onlyFrench: [only.localize("fr").name, only.localize("en").name ?? "undefined"] }; +`; + +const AUTO = ` + class Article extends d.Table {} + decorate(Article, { title: [d.Field("string")], creationDate: [d.CreationTime(), d.Field("date")], updateDate: [d.UpdateTime(), d.Field("date")], born: [d.CreationTime(), d.Field("date")], touched: [d.UpdateTime(), d.Field("date")] }); + d.RegisterTable("articles", "auto")(Article); + await d.RegisterSchema("auto"); + const model = d.GetModel(d.BasicDataModel(Article)); + const before = Date.now(); + const [id] = await model.insert({ title: "One", creationDate: new Date(0), updateDate: new Date(0) }); + const inserted = await rawTable("auto", "articles").get(id); + await sleep(60); + await model.update(id, { title: "Two", creationDate: new Date(0) }); + const updated = await rawTable("auto", "articles").get(id); + const batch = await model.insert([{ title: "B1", creationDate: new Date(0) }, { title: "B2" }, { title: "B3", updateDate: new Date(0) }]); + const batchRows = await Promise.all(batch.map((batchId) => rawTable("auto", "articles").get(batchId))); + const after = Date.now(); + const stamp = (value) => new Date(value).getTime(); + return { + batchStamped: batchRows.map((row) => [row.title, stamp(row.creationDate) >= before, stamp(row.updateDate) >= before, stamp(row.born) >= before, stamp(row.touched) >= before]), + insertSetsBoth: [stamp(inserted.creationDate) >= before, stamp(inserted.updateDate) >= before, stamp(inserted.creationDate) <= after], + creationKeptOnUpdate: stamp(updated.creationDate) === stamp(inserted.creationDate), + updateRefreshed: stamp(updated.updateDate) >= stamp(inserted.updateDate) + 50, + secondFieldsToo: [stamp(inserted.born) >= before, stamp(inserted.touched) >= before, stamp(updated.born) === stamp(inserted.born), stamp(updated.touched) >= stamp(inserted.touched) + 50], + noMixinNeeded: Object.getPrototypeOf(Article.prototype) === d.Table.prototype, + }; +`; + +const QUERY = `${PRODUCT} + const seed = (id, name, bare) => rawTable("loc", "products").insert({ _id: id, price: 1, _internal: { meta: {}, data: bare ? { name, bare } : { name } } }).run(); + await seed("only-en", { en: "Hello" }, { en: "Hello" }); + await seed("has-de", { en: "Hi", de: "Hallo" }, { en: "Hi", de: "Hallo" }); + const written = new Product(); + written._id = "empty-de"; + written.localize("en").name = "Hello"; + written.localize("de").name = ""; + await d.GetModel(d.BasicDataModel(Product)).insert(written); + await seed("none", { fr: "Salut" }); + const table = rawTable("loc", "products"); + const askedFor = async (locale, text) => (await table.filter((row) => d.unlockrequest(Product, row, "name", [{ modifier: d.LocalizationModifier, args: [locale] }]).eq(text))).map((row) => row._id).sort(); + const noFallback = async (locale, text) => (await table.filter((row) => d.unlockrequest(Product, row, "bare", [{ modifier: d.LocalizationModifier, args: [locale] }]).eq(text))).map((row) => row._id).sort(); + return { + germanHello: await askedFor("de", "Hello"), + germanHallo: await askedFor("de", "Hallo"), + englishHello: await askedFor("en", "Hello"), + frenchSalut: await askedFor("fr", "Salut"), + frenchHello: await askedFor("fr", "Hello"), + noFallbackGerman: await noFallback("de", "Hello"), + noFallbackEnglish: await noFallback("en", "Hello"), + noFallbackOwnText: await noFallback("de", "Hallo"), + }; +`; + +const AUTO_OBJECT = ` + class Article extends d.Table {} + decorate(Article, { title: [d.Field("string")], creationDate: [d.CreationTime(), d.Field("date")], updateDate: [d.UpdateTime(), d.Field("date")] }); + d.RegisterTable("articles", "autoobj")(Article); + await d.RegisterSchema("autoobj"); + const model = d.GetModel(d.BasicDataModel(Article)); + const [id] = await model.insert({ title: "One" }); + const inserted = await rawTable("autoobj", "articles").get(id); + await sleep(60); + await model.update({ _id: id, title: "Two", creationDate: new Date(0) }); + const updated = await rawTable("autoobj", "articles").get(id); + const stamp = (value) => new Date(value).getTime(); + return { + title: updated.title, + updateRefreshed: stamp(updated.updateDate) >= stamp(inserted.updateDate) + 50, + creationKept: stamp(updated.creationDate) === stamp(inserted.creationDate), + }; +`; + +export const LOCALIZATION_JOURNEYS = { + localize: scenario({ title: "localize switches the locale of an instance and falls back when a translation is missing or empty", body: LOCALIZE, printed: "{\"emptyListReads\":[\"Premium Headphones\",\"short\"],\"spanish\":\"Auriculares Premium\",\"germanFallsBack\":\"Premium Headphones\",\"sameObject\":[true,true,true],\"readNow\":\"Premium Headphones\",\"frenchEmpty\":\"Loud\",\"noFallback\":[\"sans repli\",\"undefined\"],\"restrictedReads\":[\"Casque Premium\",\"short\"],\"fallbackLocaleOf\":[\"en\",\"undefined\",\"undefined\",\"undefined\"]}" }), + "localized-query": scenario({ title: "a query through unlockrequest answers the requested locale and falls back to the fallback locale, an empty text included", body: QUERY, printed: "{\"germanHello\":[\"empty-de\",\"only-en\"],\"germanHallo\":[\"has-de\"],\"englishHello\":[\"empty-de\",\"only-en\"],\"frenchSalut\":[\"none\"],\"frenchHello\":[\"empty-de\",\"only-en\"],\"noFallbackGerman\":[],\"noFallbackEnglish\":[\"only-en\"],\"noFallbackOwnText\":[\"has-de\"]}" }), + "localize-wildcard-replace": scenario({ + title: "a wildcard write replaces every stored translation", + body: WILDCARD_REPLACE, + printed: "{\"before\":{\"en\":\"Hello\",\"fr\":\"Bonjour\"},\"afterWrite\":{\"de\":\"Hallo\"},\"fallbacks\":[\"undefined\",\"undefined\",\"Hallo\"],\"read\":{\"de\":\"Hallo\"},\"readFr\":\"undefined\"}", + stored: [{ name: "the stored translations", expression: "$DB.getCollection('loc__products').find({ _id: 'wild' }).toArray().map((r) => r._internal.data.name)", expected: "[{\"de\":\"Hallo\"}]" }], + }), + "localize-wildcard": scenario({ title: "the wildcard key reads and writes every translation at once", body: WILDCARD, printed: "{\"all\":{\"en\":\"Premium Headphones\",\"fr\":\"Casque Premium\"},\"replaced\":{\"en\":\"Premium Headphones\",\"fr\":\"Casque Premium\",\"de\":\"Premium-Kopfhoerer\"},\"german\":\"Premium-Kopfhoerer\",\"stored\":[\"Bonjour\",\"Hello\",{\"en\":\"Hello\",\"fr\":\"Bonjour\"}]}" }), + "localized-storage": scenario({ + title: "a localized field is stored as one value per locale", + body: STORED, + printed: "{\"fromRaw\":[\"Hello\",\"undefined\",\"Hello\"],\"rawKeys\":[\"name\"],\"raw\":{\"en\":\"Hello\",\"fr\":\"Bonjour\"},\"onlyFrench\":[\"Seulement\",\"undefined\"]}", + stored: [{ name: "the stored translations", expression: "$DB.getCollection('loc__products').find().sort({ price: 1 }).toArray().map((r) => r._internal.data.name)", expected: "[{\"en\":\"Hello\",\"fr\":\"Bonjour\"},{\"fr\":\"Seulement\"},{\"en\":\"Hello\",\"fr\":\"\"}]" }], + }), +} satisfies { [id: string]: Journey }; + +export const AUTODATE_JOURNEYS = { + "autodate-object-update": scenario({ title: "update with the primary key inside the object refreshes UpdateTime and keeps CreationTime", body: AUTO_OBJECT, printed: "{\"title\":\"Two\",\"updateRefreshed\":true,\"creationKept\":true}" }), + autodate: scenario({ + title: "CreationTime is set on insert and kept on update, UpdateTime is set on insert and refreshed on update", + body: AUTO, + printed: "{\"batchStamped\":[[\"B1\",true,true,true,true],[\"B2\",true,true,true,true],[\"B3\",true,true,true,true]],\"insertSetsBoth\":[true,true,true],\"creationKeptOnUpdate\":true,\"updateRefreshed\":true,\"secondFieldsToo\":[true,true,true,true],\"noMixinNeeded\":true}", + stored: [{ name: "the article dates", expression: "$DB.getCollection('auto__articles').find().sort({ title: 1 }).toArray().map((r) => [r.title, r.creationDate instanceof Date, r.updateDate instanceof Date, r.born instanceof Date, r.touched instanceof Date])", expected: "[[\"B1\",true,true,true,true],[\"B2\",true,true,true,true],[\"B3\",true,true,true,true],[\"Two\",true,true,true,true]]" }], + }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/models.ts b/.agents/skills/verify-interface/scenarios/models.ts new file mode 100644 index 0000000..055769f --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/models.ts @@ -0,0 +1,366 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +const CRUD = ` + class User extends d.Table {} + decorate(User, { email: [d.Field("string"), d.Index()], name: [d.Field("string")], role: [d.Field("string"), d.Index()] }); + d.RegisterTable("users", "crud")(User); + await d.RegisterSchema("crud"); + const users = d.GetModel(d.BasicDataModel(User)); + const single = await users.insert({ email: "alice@example.com", name: "Alice", role: "admin" }); + const batch = await users.insert([{ email: "bob@example.com", name: "Bob", role: "user" }, { email: "carol@example.com", name: "Carol", role: "user" }]); + const got = await users.get(single[0]); + const missing = await users.get("no-such-id"); + const admins = (await users.getBy("role", "admin")).map((row) => row.name); + const byEmail = (await users.getBy("email", "bob@example.com")).map((row) => row.name); + const everyone = (await users.getAll()).map((row) => row.name).sort(); + const instances = [got instanceof User, (await users.getBy("role", "admin")).every((row) => row instanceof User), (await users.getBy("email", "bob@example.com")).every((row) => row instanceof User), (await users.getAll()).every((row) => row instanceof User), (await users.getAll()).length]; + const updatedById = await users.update(single[0], { name: "Alice Liddell" }); + const updatedByObject = await users.update({ _id: batch[0], name: "Robert" }); + const updatedMissing = await users.update("no-such-id", { name: "Nobody" }); + const noKey = await rejection(users.update({ name: "No key" })); + const deleted = await users.delete(batch[1]); + const deletedMissing = await users.delete("no-such-id"); + return { + insertedIds: [single.length, batch.length, [...single, ...batch].every((id) => typeof id === "string")], + got: [got instanceof User, got._id === single[0], got.name], + missing: missing ?? "undefined", + instances, + admins, byEmail, everyone, + updatedById, updatedByObject, updatedMissing, noKey, + deleted, deletedMissing, + left: (await users.getAll()).map((row) => row.name).sort(), + }; +`; + +const KEY = "12345678901234567890123456789012"; + +const CONVERSION = ` + class Account extends d.Table.with(d.EncryptionModifier) {} + decorate(Account, { name: [d.Field("string"), d.Index()], secret: [d.Encrypted({ secretKey: "${KEY}" }), d.Field("string")] }); + d.RegisterTable("accounts", "cnv")(Account); + await d.RegisterSchema("cnv"); + const Model = d.BasicDataModel(Account); + const fromPlain = Model.fromPlainData({ _id: "a1", name: "Ann", secret: "s3cret" }); + const sameInstance = Model.fromPlainData(fromPlain) === fromPlain; + const stored = Model.toDatabase(fromPlain); + const fromPlainDirect = Model.toDatabase({ _id: "a2", name: "Bea" }); + const plainWithSecret = Model.toDatabase({ _id: "a4", name: "Eve", secret: "plain" }); + const record = { _id: "a3", name: "Cy" }; + const copy = Model.fromDatabase(record); + const empty = Model.fromDatabase({}); + const afterConversions = await rawTable("cnv", "accounts").count(); + const model = d.GetModel(Model); + const [id] = await model.insert({ name: "Dee", secret: "hidden" }); + const read = await model.get(id); + const direct = await model.table.get(id); + const viaGetAll = await model.getAll(); + const viaGetBy = await model.getBy("name", "Dee"); + return { + plainLocked: [plainWithSecret.secret === undefined, Object.keys(plainWithSecret._internal?.data ?? {})], + fromPlain: [fromPlain instanceof Account, fromPlain.secret, fromPlain.name], + sameInstance, + storedShape: [stored instanceof Account, Object.keys(stored).sort(), stored._internal.data.secret.length, stored.secret === undefined], + plainDataShape: [fromPlainDirect instanceof Account, Object.keys(fromPlainDirect).sort()], + nullIsUndefined: [Model.fromDatabase(null) === undefined, Model.fromDatabase(undefined) === undefined], + emptyObjectIsAnInstance: empty instanceof Account, + copyNotSame: [copy !== record, copy instanceof Account, record instanceof Account, copy.name], + afterConversions, + read: [read instanceof Account, read.secret], + direct: [direct instanceof Account, direct.name, direct._internal !== undefined], + getAllConverted: viaGetAll.every((row) => row instanceof Account), + getByConverted: [viaGetBy.length, viaGetBy[0] instanceof Account, viaGetBy[0].secret, viaGetAll[0].secret, read.secret], + }; +`; + +const STR = `{ _tag: "codec", name: "string", is: (u) => typeof u === "string", encode: (a) => a, decode: (u) => (typeof u === "string" ? { _tag: "Right", right: u } : { _tag: "Left", left: [{ message: "expected a string" }, { message: "second message" }] }) }`; +const NUM = `{ _tag: "codec", name: "number", is: (u) => typeof u === "number", encode: (a) => a, decode: (u) => (typeof u === "number" ? { _tag: "Right", right: u } : { _tag: "Left", left: [] }) }`; + +const VALIDATION = ` + class Person extends d.Table {} + decorate(Person, { name: [d.Field(${STR})], age: [d.Field(${NUM})], nick: [d.Field("string")], extra: [] }); + d.RegisterTable("people", "val")(Person); + await d.RegisterSchema("val"); + const Model = d.BasicDataModel(Person); + const people = d.GetModel(Model); + const errorOf = async (promise) => { try { await promise; return null; } catch (error) { return { isError: error instanceof Error, message: error.message, errors: error.errors }; } }; + const valid = Model.validate({ name: "Alice", age: 30 }); + const invalid = Model.validate({ name: 42, age: "old" }); + const notAnObject = Model.validate(null); + const partialOk = Model.validate({ name: "x" }, { partial: true }); + const partialEmpty = Model.validate({}, { partial: true }); + const partialBad = Model.validate({ age: "old" }, { partial: true }); + const tokenSkipped = Model.validate({ name: "x", age: 1, nick: 99, extra: {} }); + const optIn = await people.insert({ name: 42, age: "anything" }); + const refused = await errorOf(people.insert({ name: 42, age: "old" }, { validate: true })); + const batchRefused = await errorOf(people.insert([{ name: "ok", age: 1 }, { name: 1, age: 2 }, { name: "fine", age: "x" }], { validate: true })); + const accepted = await people.insert({ name: "Alice", age: 30 }, { validate: true }); + const patched = await people.update(accepted[0], { name: "Bob" }, { validate: true }); + const patchRefused = await errorOf(people.update(accepted[0], { age: "old" }, { validate: true })); + const objectPatchRefused = await errorOf(people.update({ _id: accepted[0], name: 7 }, { validate: true })); + const unvalidatedPatch = await people.update(accepted[0], { age: "old" }); + const presentUndefined = Model.validate({ age: undefined }, { partial: true }); + const absentAge = Model.validate({ name: "x" }, { partial: true }); + const missingRequired = await errorOf(people.insert({ name: "Alice" }, { validate: true })); + const missingInArray = await errorOf(people.insert([{ name: "Ok", age: 1 }, { name: "No age" }], { validate: true })); + const missingStored = (await people.getAll()).map((row) => row.name).sort(); + const trim = { _tag: "codec", name: "trim", is: (u) => typeof u === "string", encode: (a) => a, decode: (u) => (typeof u === "string" ? { _tag: "Right", right: u.trim() } : { _tag: "Left", left: [{ message: "not text" }] }) }; + class Label extends d.Table {} + decorate(Label, { label: [d.Field(trim)] }); + d.RegisterTable("labels", "val")(Label); + await d.RegisterSchema("val"); + const labels = d.GetModel(d.BasicDataModel(Label)); + const [padded] = await labels.insert({ label: " padded " }, { validate: true }); + const [batchA, batchB] = await labels.insert([{ label: " a " }, { label: " b " }], { validate: true }); + const afterInsert = (await rawTable("val", "labels").get(padded)).label; + await labels.update(padded, { label: " again " }, { validate: true }); + const [unvalidated] = await labels.insert({ label: 42 }, { validate: false }); + await labels.update(unvalidated, { label: 43 }, { validate: false }); + const storedLabels = await Promise.all([padded, batchA, batchB, unvalidated].map(async (labelId) => (await rawTable("val", "labels").get(labelId)).label)); + const hole = { _tag: "codec", name: "hole", is: () => false, encode: (a) => a, decode: () => ({ _tag: "Left", left: [{}, { message: "after a hole" }, { message: "last" }] }) }; + const silent = { _tag: "codec", name: "silent", is: () => false, encode: (a) => a, decode: () => ({ _tag: "Left", left: [{}, { message: "" }] }) }; + class Odd extends d.Table {} + decorate(Odd, { a: [d.Field(hole)], b: [d.Field(silent)] }); + const oddMessages = d.BasicDataModel(Odd, "odd").validate({}); + return { valid, invalid, notAnObject, partialOk, partialEmpty, partialBad, tokenSkipped, optIn: optIn.length, refused, batchRefused, patched, patchRefused, objectPatchRefused, unvalidatedPatch, oddMessages, missingRequired, missingInArray, missingStored, afterInsert, storedLabels, presentUndefined, absentAge }; +`; + +const GET_MODEL = ` + class Item extends d.Table {} + decorate(Item, { name: [d.Field("string")] }); + d.RegisterTable("items", "gm")(Item); + await d.RegisterSchema("gm"); + const Model = d.BasicDataModel(Item); + const first = d.GetModel(Model); + const again = d.GetModel(Model); + const tenant = d.GetModel(Model, "t1"); + const tenantAgain = d.GetModel(Model, "t1"); + const other = d.GetModel(Model, "t2"); + const everywhere = d.GetModel(Model, iface.CROSS_INSTANCE); + await first.insert({ name: "default" }); + await tenant.insert({ name: "tenant one" }); + await other.insert({ name: "tenant two" }); + const OtherModel = d.BasicDataModel(Item); + class Custom { constructor(database) { this.database = database; this.table = database.table("things"); } } + Custom.schemaName = "manual"; + new iface.Schema("manual", { things: { fields: {}, indexes: {} } }); + const custom = d.GetModel(Custom); + class Lost { constructor(database) { this.database = database; } } + Lost.schemaName = "absent-schema"; + return { + cache: [first === again, tenant === tenantAgain, first !== tenant, tenant !== other, everywhere !== first, first instanceof Model, d.GetModel(OtherModel) !== first], + defaultSees: (await first.getAll()).map((row) => row.name), + tenantSees: (await tenant.getAll()).map((row) => row.name), + otherSees: (await other.getAll()).map((row) => row.name), + everywhereSees: (await everywhere.getAll()).map((row) => row.name).sort(), + customSchema: [custom instanceof Custom, d.GetModel(Custom) === custom], + lost: caught(() => d.GetModel(Lost)), + statics: [Model.schemaName, Model.tableClass === Item], + }; +`; + +const FACTORY = ` + class Plain extends d.Table {} + decorate(Plain, { name: [d.Field("string")] }); + const missingName = caught(() => d.BasicDataModel(Plain)); + new iface.Schema("fac", { explicit: { fields: { name: "string" }, indexes: {} } }); + const Explicit = d.BasicDataModel(Plain, "explicit"); + const model = new Explicit(iface.Schema.get("fac").instance()); + const [id] = await model.insert({ name: "named by argument" }); + class Registered extends d.Table {} + d.RegisterTable("registered", "fac")(Registered); + const Derived = d.BasicDataModel(Registered); + const Overridden = d.BasicDataModel(Registered, "explicit"); + return { missingName, explicit: (await model.get(id)).name, schemaOfPlain: Explicit.schemaName ?? "undefined", schemaOfRegistered: Derived.schemaName, overriddenTable: await new Overridden(iface.Schema.get("fac").instance()).getAll().then((rows) => rows.length) }; +`; + +const EXTEND = ` + class User extends d.Table {} + decorate(User, { email: [d.Field("string"), d.Index()], name: [d.Field("string")], status: [d.Field("string"), d.Index()] }); + d.RegisterTable("users", "ext")(User); + await d.RegisterSchema("ext"); + const UserModelBase = d.BasicDataModel(User); + class CustomUserModel extends UserModelBase { + async findActive() { + return this.table.filter((u) => u.key("status").eq("active")).then((rows) => rows.map((row) => CustomUserModel.fromDatabase(row)).filter((user) => user !== undefined)); + } + async register(email, name) { + const existing = await this.getBy("email", email); + if (existing.length > 0) throw new Error("Email already in use"); + return this.insert({ email, name, status: "active" }); + } + } + const model = d.GetModel(CustomUserModel); + await model.register("a@example.com", "Ada"); + const duplicate = await rejection(model.register("a@example.com", "Again")); + await model.insert({ email: "z@example.com", name: "Zed", status: "suspended" }); + const active = await model.findActive(); + return { isModel: model instanceof CustomUserModel, duplicate, active: active.map((user) => [user instanceof User, user.name]) }; +`; + +const COMPOUND = ` + class Event extends d.Table {} + decorate(Event, { + kind: [d.Field("string"), d.Index({ group: "triple" })], + day: [d.Field("string"), d.Index({ group: "triple" })], + room: [d.Field("string"), d.Index({ group: "triple" })], + title: [d.Field("string")], + }); + d.RegisterTable("events", "tri")(Event); + class Seat extends d.Table {} + decorate(Seat, { + row: [d.Field("number"), d.Index({ group: "spot" })], + col: [d.Field("number"), d.Index({ group: "spot" })], + open: [d.Field("boolean"), d.Index({ group: "spot" })], + label: [d.Field("string")], + }); + d.RegisterTable("seats", "tri")(Seat); + await d.RegisterSchema("tri"); + const seats = d.GetModel(d.BasicDataModel(Seat)); + await seats.insert([{ row: 1, col: 2, open: true, label: "1-2" }, { row: 2, col: 1, open: true, label: "2-1" }, { row: 1, col: 2, open: false, label: "1-2 shut" }]); + const model = d.GetModel(d.BasicDataModel(Event)); + await model.insert([ + { kind: "talk", day: "mon", room: "A", title: "one" }, + { kind: "talk", day: "mon", room: "B", title: "two" }, + { kind: "talk", day: "tue", room: "A", title: "three" }, + { kind: "demo", day: "mon", room: "A", title: "four" }, + ]); + await sleep(2500); + const titles = async (...keys) => (await model.getBy("triple", ...keys)).map((row) => row.title).sort(); + const labels = async (...keys) => (await seats.getBy("spot", ...keys)).map((row) => row.label).sort(); + const constant = (value) => iface.ValueProxy.constant(value); + return { + exact: await titles("talk", "mon", "A"), other: await titles("talk", "mon", "B"), none: await titles("talk", "tue", "B"), order: await titles("mon", "talk", "A"), + numbers: await labels(1, 2, true), numbersAsText: await labels("1", "2", true), shut: await labels(1, 2, false), proxies: await labels(constant(2), constant(1), constant(true)), + }; +`; + +const GET_BY_KEYS = ` + class Player extends d.Table {} + decorate(Player, { name: [d.Field("string"), d.Index()], rank: [d.Field("number"), d.Index()], active: [d.Field("boolean"), d.Index()] }); + d.RegisterTable("players", "keys")(Player); + await d.RegisterSchema("keys"); + const model = d.GetModel(d.BasicDataModel(Player)); + await model.insert([{ name: "Ann", rank: 2, active: true }, { name: "Bob", rank: 12, active: false }, { name: "Cid", rank: 2, active: false }]); + await untilIndexed("keys__players", ["name", "rank", "active"]); + const names = async (index, ...keys) => (await model.getBy(index, ...keys)).map((row) => [row instanceof Player, row.name]).sort(); + return { + number: await names("rank", 2), + numberIsNotText: await names("rank", "2"), + boolean: await names("active", false), + proxy: await names("name", iface.ValueProxy.constant("Bob")), + numberProxy: await names("rank", iface.ValueProxy.constant(12)), + }; +`; + +const PARTIAL = ` + const secretKey = "${KEY}"; + class UserProfile extends d.Table.with(d.EncryptionModifier, d.HashModifier, d.LocalizationModifier) {} + decorate(UserProfile, { + privateInfo: [d.Encrypted({ secretKey }), d.Field("any")], + password: [d.Hashed(), d.Field("string")], + bio: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + }); + d.RegisterTable("profiles", "partial")(UserProfile); + await d.RegisterSchema("partial"); + const model = d.GetModel(d.BasicDataModel(UserProfile)); + const profile = new UserProfile(); + profile.privateInfo = { address: "1 Main St", phoneNumber: "555" }; + profile.password = "old-password"; + profile.localize("en").bio = "Hello"; + const [id] = await model.insert(profile); + const before = Object.keys((await rawTable("partial", "profiles").get(id))._internal.data).sort(); + await model.update(id, { password: "new-password" }); + const row = await model.get(id); + const after = Object.keys((await rawTable("partial", "profiles").get(id))._internal.data).sort(); + return { + before, + after, + passwordReplaced: [await row.testHashAsync("password", "new-password"), await row.testHashAsync("password", "old-password")], + privateInfo: row.privateInfo ?? "lost", + bio: row.localize("en").bio ?? "lost", + }; +`; + +const PLAIN_DATA = ` + class Account extends d.Table.with(d.EncryptionModifier, d.HashModifier, d.LocalizationModifier) {} + decorate(Account, { + name: [d.Field("string"), d.Index()], + secret: [d.Encrypted({ secretKey: "${KEY}" }), d.Field("any")], + password: [d.Hashed(), d.Field("string")], + title: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + }); + d.RegisterTable("accounts", "plain")(Account); + await d.RegisterSchema("plain"); + const model = d.GetModel(d.BasicDataModel(Account)); + const account = new Account(); + account._id = "a1"; + account.name = "Ann"; + account.secret = { token: "t" }; + account.password = "pw"; + account.localize("en").title = "Hello"; + const [id] = await model.insert(account); + const loaded = await model.get(id); + const everyPath = [await model.get(id), (await model.getBy("name", "Ann"))[0], (await model.getAll())[0]]; + const pathResults = []; + for (const row of everyPath) pathResults.push([row instanceof Account, row.name, row.secret, await row.testHashAsync("password", "pw"), row.localize("en").title]); + const plain = d.toPlainData(loaded); + const unlocked = d.toPlainData(loaded.localize("en")); + const events = []; + return { + pathResults, + keys: Object.keys(plain).filter((key) => key !== "_instance").sort(), + noInternal: !("_internal" in plain), + name: plain.name, + secret: plain.secret, + passwordIsHash: typeof plain.password === "string" && plain.password !== "pw" && plain.password.length > 20, + lockedTitle: plain.title ?? "undefined", + unlockedTitle: unlocked.title, + json: JSON.parse(JSON.stringify(loaded)).secret, + stillLocked: Object.keys(loaded._internal.data).sort(), + notAnInstance: !(plain instanceof Account), + }; +`; + +export const MODEL_JOURNEYS = { + crud: scenario({ + title: "a model inserts, reads, updates and deletes rows and answers ids and counts", + body: CRUD, + printed: "{\"insertedIds\":[1,2,true],\"got\":[true,true,\"Alice\"],\"missing\":\"undefined\",\"instances\":[true,true,true,true,3],\"admins\":[\"Alice\"],\"byEmail\":[\"Bob\"],\"everyone\":[\"Alice\",\"Bob\",\"Carol\"],\"updatedById\":1,\"updatedByObject\":1,\"updatedMissing\":0,\"noKey\":{\"name\":\"AssertionError\",\"message\":\"Missing primary key in object update.\"},\"deleted\":1,\"deletedMissing\":0,\"left\":[\"Alice Liddell\",\"Robert\"]}", + stored: [{ name: "the rows left", expression: "$DB.getCollection('crud__users').find().sort({ email: 1 }).toArray().map((r) => [r.email, r.name, r.role])", expected: "[[\"alice@example.com\",\"Alice Liddell\",\"admin\"],[\"bob@example.com\",\"Robert\",\"user\"]]" }], + }), + conversion: scenario({ + title: "fromPlainData, fromDatabase and toDatabase convert without saving, and reads convert their rows", + body: CONVERSION, + printed: "{\"plainLocked\":[true,[\"secret\"]],\"fromPlain\":[true,\"s3cret\",\"Ann\"],\"sameInstance\":true,\"storedShape\":[false,[\"_id\",\"_internal\",\"name\"],3,true],\"plainDataShape\":[false,[\"_id\",\"name\"]],\"nullIsUndefined\":[true,true],\"emptyObjectIsAnInstance\":true,\"copyNotSame\":[true,true,false,\"Cy\"],\"afterConversions\":0,\"read\":[true,\"hidden\"],\"direct\":[false,\"Dee\",true],\"getAllConverted\":true,\"getByConverted\":[1,true,\"hidden\",\"hidden\",\"hidden\"]}", + stored: [{ name: "the one saved account", expression: "$DB.getCollection('cnv__accounts').find().toArray().map((r) => [r.name, Object.keys(r._internal.data), r._internal.data.secret.length])", expected: "[[\"Dee\",[\"secret\"],3]]" }], + }), + validation: scenario({ + title: "validate checks the io-ts codecs, and insert and update refuse invalid input only when asked", + body: VALIDATION, + printed: "{\"valid\":{\"ok\":true},\"invalid\":{\"ok\":false,\"errors\":[{\"field\":\"name\",\"message\":\"expected a string\"},{\"field\":\"age\",\"message\":\"invalid age\"}]},\"notAnObject\":{\"ok\":false,\"errors\":[{\"field\":\"name\",\"message\":\"expected a string\"},{\"field\":\"age\",\"message\":\"invalid age\"}]},\"partialOk\":{\"ok\":true},\"partialEmpty\":{\"ok\":true},\"partialBad\":{\"ok\":false,\"errors\":[{\"field\":\"age\",\"message\":\"invalid age\"}]},\"tokenSkipped\":{\"ok\":true},\"optIn\":1,\"refused\":{\"isError\":true,\"message\":\"validation failed: name (expected a string), age (invalid age)\",\"errors\":[{\"field\":\"name\",\"message\":\"expected a string\"},{\"field\":\"age\",\"message\":\"invalid age\"}]},\"batchRefused\":{\"isError\":true,\"message\":\"validation failed: name (expected a string), age (invalid age)\",\"errors\":[{\"field\":\"name\",\"message\":\"expected a string\"},{\"field\":\"age\",\"message\":\"invalid age\"}]},\"patched\":1,\"patchRefused\":{\"isError\":true,\"message\":\"validation failed: age (invalid age)\",\"errors\":[{\"field\":\"age\",\"message\":\"invalid age\"}]},\"objectPatchRefused\":{\"isError\":true,\"message\":\"validation failed: name (expected a string)\",\"errors\":[{\"field\":\"name\",\"message\":\"expected a string\"}]},\"unvalidatedPatch\":1,\"oddMessages\":{\"ok\":false,\"errors\":[{\"field\":\"a\",\"message\":\"after a hole\"},{\"field\":\"b\",\"message\":\"invalid b\"}]},\"missingRequired\":{\"isError\":true,\"message\":\"validation failed: age (invalid age)\",\"errors\":[{\"field\":\"age\",\"message\":\"invalid age\"}]},\"missingInArray\":{\"isError\":true,\"message\":\"validation failed: age (invalid age)\",\"errors\":[{\"field\":\"age\",\"message\":\"invalid age\"}]},\"missingStored\":[42,\"Bob\"],\"afterInsert\":\" padded \",\"storedLabels\":[\" again \",\" a \",\" b \",43],\"presentUndefined\":{\"ok\":false,\"errors\":[{\"field\":\"age\",\"message\":\"invalid age\"}]},\"absentAge\":{\"ok\":true}}", + stored: [{ name: "the people", expression: "$DB.getCollection('val__people').find().sort({ name: 1 }).toArray().map((r) => [r.name, r.age])", expected: "[[42,\"anything\"],[\"Bob\",\"old\"]]" }], + }), + "get-model": scenario({ + title: "GetModel caches one model per class and instance id, and keeps each instance's rows apart", + body: GET_MODEL, + printed: "{\"cache\":[true,true,true,true,true,true,true],\"defaultSees\":[\"default\"],\"tenantSees\":[\"tenant one\"],\"otherSees\":[\"tenant two\"],\"everywhereSees\":[\"default\",\"tenant one\",\"tenant two\"],\"customSchema\":[true,true],\"lost\":\"Schema not found for 'absent-schema'\",\"statics\":[\"gm\",true]}", + stored: [{ name: "the rows by instance", expression: "$DB.getCollection('gm__items').find().sort({ name: 1 }).toArray().map((r) => [r.name, r._instance])", expected: "[[\"default\",null],[\"tenant one\",\"t1\"],[\"tenant two\",\"t2\"]]" }], + }), + "plain-data": scenario({ title: "toPlainData gives unlocked values without _internal, and leaves a still-locked field undefined", body: PLAIN_DATA, printed: "{\"pathResults\":[[true,\"Ann\",{\"token\":\"t\"},true,\"Hello\"],[true,\"Ann\",{\"token\":\"t\"},true,\"Hello\"],[true,\"Ann\",{\"token\":\"t\"},true,\"Hello\"]],\"keys\":[\"_id\",\"name\",\"password\",\"secret\",\"title\"],\"noInternal\":true,\"name\":\"Ann\",\"secret\":{\"token\":\"t\"},\"passwordIsHash\":true,\"lockedTitle\":\"undefined\",\"unlockedTitle\":\"Hello\",\"json\":{\"token\":\"t\"},\"stillLocked\":[\"password\",\"secret\",\"title\"],\"notAnInstance\":true}" }), + "compound-get-by": scenario({ title: "getBy matches a compound index of three fields by its keys in field order, with text, number, boolean and value proxy keys", body: COMPOUND, printed: "{\"exact\":[\"one\"],\"other\":[\"two\"],\"none\":[],\"order\":[],\"numbers\":[\"1-2\"],\"numbersAsText\":[],\"shut\":[\"1-2 shut\"],\"proxies\":[\"2-1\"]}" }), + "get-by-keys": scenario({ title: "getBy matches a number, a boolean and a value proxy key as the index holds it, and a text key does not match a number", body: GET_BY_KEYS, printed: "{\"number\":[[true,\"Ann\"],[true,\"Cid\"]],\"numberIsNotText\":[],\"boolean\":[[true,\"Bob\"],[true,\"Cid\"]],\"proxy\":[[true,\"Bob\"]],\"numberProxy\":[[true,\"Bob\"]]}" }), + "partial-update-keeps-modified-fields": scenario({ + title: "KNOWN FINDING AntelopeJS/interface-database-decorators#80, fails until fixed: update with one field keeps the row's other encrypted, hashed and localized fields", + body: PARTIAL, + printed: "{\"before\":[\"bio\",\"password\",\"privateInfo\"],\"after\":[\"bio\",\"password\",\"privateInfo\"],\"passwordReplaced\":[true,false],\"privateInfo\":{\"address\":\"1 Main St\",\"phoneNumber\":\"555\"},\"bio\":\"Hello\"}", + }), + "model-factory": scenario({ title: "BasicDataModel takes its table name from the argument or from RegisterTable", body: FACTORY, printed: "{\"missingName\":\"tableName must be provided or set via @RegisterTable\",\"explicit\":\"named by argument\",\"schemaOfPlain\":\"undefined\",\"schemaOfRegistered\":\"fac\",\"overriddenTable\":1}" }), + "extend-model": scenario({ + title: "a subclass of a model adds query methods that use the model's table and conversions", + body: EXTEND, + printed: "{\"isModel\":true,\"duplicate\":{\"name\":\"Error\",\"message\":\"Email already in use\"},\"active\":[[true,\"Ada\"]]}", + stored: [{ name: "the users", expression: "$DB.getCollection('ext__users').find().sort({ email: 1 }).toArray().map((r) => [r.email, r.status])", expected: "[[\"a@example.com\",\"active\"],[\"z@example.com\",\"suspended\"]]" }], + }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/registration.ts b/.agents/skills/verify-interface/scenarios/registration.ts new file mode 100644 index 0000000..804b300 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/registration.ts @@ -0,0 +1,294 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +const REGISTER = ` + class Item extends d.Table {} + decorate(Item, { name: [d.Field("string"), d.Index()] }); + d.RegisterTable("items", "reg")(Item); + class Box extends d.Table {} + decorate(Box, { label: [d.Field("string")] }); + d.RegisterTable("boxes", "reg")(Box); + class Bare extends d.Table {} + d.RegisterTable("bare", "reg")(Bare); + class Early extends d.Table {} + d.RegisterTable("early", "preexisting")(Early); + const noTables = await rejection(d.RegisterSchema("nothing-registered")); + const beforeBinding = caught(() => d.GetModel(d.BasicDataModel(Item))); + const returned = await d.RegisterSchema("reg"); + const schema = iface.Schema.get("reg"); + const afterBinding = d.GetModel(d.BasicDataModel(Item)) !== undefined; + const again = await d.RegisterSchema("reg"); + new iface.Schema("preexisting", { early: { fields: {}, indexes: {} } }); + const viaSchemaName = d.GetModel(d.BasicDataModel(Early)) !== undefined; + await d.GetModel(d.BasicDataModel(Item)).insert({ name: "one" }); + await d.GetModel(d.BasicDataModel(Box)).insert({ label: "red" }); + const [bareId] = await d.GetModel(d.BasicDataModel(Bare)).insert({}); + return { + noTables: noTables && noTables.message, + beforeBinding, + returned: returned ?? "undefined", + again: again ?? "undefined", + tables: Object.keys(schema.definition), + bareDefinition: schema.definition.bare, + bareRow: (await d.GetModel(d.BasicDataModel(Bare)).get(bareId)) instanceof Bare, + afterBinding, + viaSchemaName, + tableNameIsNotASchema: iface.Schema.get("items") === undefined, + }; +`; + +const FIXTURES = ` + const roles = [{ _id: "admin", name: "Administrator" }, { _id: "user", name: "Standard User" }]; + class Role extends d.Table {} + decorate(Role, { name: [d.Field("string")] }); + d.Fixture(() => roles)(Role); + d.RegisterTable("roles", "fx")(Role); + class Setting extends d.Table {} + decorate(Setting, { key: [d.Field("string")], value: [d.Field("string")] }); + d.Fixture(async () => ({ key: "theme", value: "dark" }))(Setting); + d.RegisterTable("settings", "fx")(Setting); + class Plan extends d.Table {} + decorate(Plan, { label: [d.Field("string")] }); + d.Fixture(async () => [{ label: "free" }, { label: "pro" }, { label: "team" }])(Plan); + d.RegisterTable("plans", "fx")(Plan); + class Nothing extends d.Table {} + d.Fixture(() => undefined)(Nothing); + d.RegisterTable("nothing", "fx")(Nothing); + class Marker extends d.Table {} + d.Fixture(() => [{ _id: "marker-a" }, { _id: "marker-b" }])(Marker); + d.RegisterTable("markers", "fx")(Marker); + class Loose extends d.Table {} + decorate(Loose, { name: [d.Field("string")] }); + d.Fixture(() => [{ _id: "loose", name: "never inserted" }])(Loose); + class Stamped extends d.Table.with(d.HashModifier) {} + decorate(Stamped, { secret: [d.Hashed(), d.Field("string")], created: [d.CreationTime(), d.Field("date")] }); + d.Fixture(() => [{ _id: "s1", secret: "hunter2" }, { _id: "s2", secret: "hunter3" }])(Stamped); + d.RegisterTable("stamped", "fx")(Stamped); + await d.RegisterSchema("fx"); + const count = (name) => rawTable("fx", name).count(); + const first = { roles: await count("roles"), settings: await count("settings"), plans: await count("plans"), nothing: await count("nothing"), markers: await count("markers") }; + await rawTable("fx", "roles").get("user").delete(); + await d.RegisterSchema("fx"); + const afterSecondStart = await count("roles"); + const ids = (await rawTable("fx", "plans")).map((row) => row._id).sort(); + const stampedRows = [await rawTable("fx", "stamped").get("s1"), await rawTable("fx", "stamped").get("s2")]; + const stamped = stampedRows[0]; + return { + first, + afterSecondStart, + plans: ids, + unregisteredClassProvisioned: Object.keys(iface.Schema.get("fx").definition).includes("loose"), + hashedAtInsert: stampedRows.map((row) => row._internal.data.secret.startsWith("$" + "scrypt" + "$ln=15,r=8,p=3$") && !row._internal.data.secret.includes("hunter")), + insertEventFired: stamped.created !== undefined, + }; +`; + +const FIXTURE_RACE = ` + class Seed extends d.Table {} + decorate(Seed, { name: [d.Field("string")] }); + d.Fixture(() => [{ name: "a" }, { name: "b" }])(Seed); + d.RegisterTable("seeds", "race")(Seed); + class Child extends Seed {} + d.RegisterTable("kids", "race")(Child); + let seen = null; + class Spy extends d.Table {} + decorate(Spy, { name: [d.Field("string")] }); + d.Fixture((cls) => { seen = cls.name; return [{ name: "spy" }]; })(Spy); + class SpyChild extends Spy {} + d.RegisterTable("spies", "race")(SpyChild); + await Promise.all([d.RegisterSchema("race"), d.RegisterSchema("race")]); + const rows = (await rawTable("race", "seeds")).map((row) => row._id).sort(); + const kids = (await rawTable("race", "kids")).map((row) => row._id).sort(); + return { rows, kids, generatorReceived: seen }; +`; + +const TABLE_ACCESS = ` + class Item extends d.Table {} + decorate(Item, { name: [d.Field("string")] }); + d.RegisterTable("items", "acc")(Item); + class Nameless extends d.Table {} + class Ghost extends d.Table {} + d.RegisterTable("ghosts", "never-registered")(Ghost); + const unbound = caught(() => Ghost.table()); + const nameless = caught(() => Nameless.table()); + const beforeRegister = caught(() => Item.table()); + await d.RegisterSchema("acc"); + await Item.table().insert({ name: "default" }).run(); + await Item.table("tenant-1").insert({ name: "tenant" }).run(); + await Item.table(iface.CROSS_INSTANCE).count(); + const held = iface.Schema.get("acc").instance("held"); + await Item.table(held).insert({ name: "held" }).run(); + return { + unbound, + nameless, + beforeRegister, + defaultCount: await Item.table().count(), + tenantCount: await Item.table("tenant-1").count(), + crossCount: await Item.table(iface.CROSS_INSTANCE).count(), + heldCount: await Item.table(held).count(), + }; +`; + +const FIXTURE_MIXED = ` + class Mixed extends d.Table {} + decorate(Mixed, { name: [d.Field("string")] }); + d.Fixture(() => [{ _id: "keep", name: "a" }, { name: "b" }, { name: "c" }])(Mixed); + d.RegisterTable("mixed", "mix")(Mixed); + class Single extends d.Table {} + decorate(Single, { name: [d.Field("string")] }); + d.Fixture(() => ({ name: "only" }))(Single); + d.RegisterTable("single", "mix")(Single); + class Last extends d.Table {} + decorate(Last, { name: [d.Field("string")] }); + d.Fixture(() => [{ name: "x" }, { _id: "named", name: "y" }])(Last); + d.RegisterTable("last", "mix")(Last); + await d.RegisterSchema("mix"); + const ids = async (table) => (await rawTable("mix", table)).map((row) => row._id).sort(); + return { mixed: await ids("mixed"), last: await ids("last"), single: await ids("single") }; +`; + +const FOREIGN = ` + class Item extends d.Table {} + decorate(Item, { name: [d.Field("string")] }); + d.RegisterTable("items", "home")(Item); + await d.RegisterSchema("home"); + new iface.Schema("away", { items: { fields: { name: "string" }, indexes: {} } }); + const away = iface.Schema.get("away").instance("away-1"); + await Item.table(away).insert({ _id: "foreign", name: "stored away" }).run(); + await Item.table().insert({ _id: "local", name: "stored home" }).run(); + return { homeCount: await Item.table().count(), awayCount: await Item.table(away).count() }; +`; + +const FIXTURE_ONCE = ` + let calls = 0; + class Counted extends d.Table {} + decorate(Counted, { name: [d.Field("string")] }); + d.Fixture(() => { calls += 1; return [{ name: "once" }]; })(Counted); + d.RegisterTable("counted", "once")(Counted); + await d.RegisterSchema("once"); + const afterFirst = [calls, (await rawTable("once", "counted")).length]; + await d.RegisterSchema("once"); + await d.RegisterSchema("once"); + return { afterFirst, afterThree: [calls, (await rawTable("once", "counted")).length] }; +`; + +const FIXTURE_MIXED_TABLES = ` + class Filled extends d.Table {} + decorate(Filled, { name: [d.Field("string")] }); + d.RegisterTable("filled", "twotables")(Filled); + await d.RegisterSchema("twotables"); + await d.GetModel(d.BasicDataModel(Filled)).insert({ _id: "mine", name: "mine" }); + class Fixtured extends d.Table {} + decorate(Fixtured, { name: [d.Field("string")] }); + d.Fixture(() => [{ name: "seed" }])(Fixtured); + d.RegisterTable("empties", "twotables")(Fixtured); + class FilledFixtured extends d.Table {} + decorate(FilledFixtured, { name: [d.Field("string")] }); + d.Fixture(() => [{ _id: "fixture-for-filled", name: "must not appear" }])(FilledFixtured); + d.RegisterTable("filled", "twotables")(FilledFixtured); + await d.RegisterSchema("twotables"); + const ids = async (table) => (await rawTable("twotables", table)).map((row) => row._id).sort(); + return { filled: await ids("filled"), empties: await ids("empties") }; +`; + +const FIXTURE_NOT_EMPTY = ` + class Plain extends d.Table {} + decorate(Plain, { name: [d.Field("string")] }); + d.RegisterTable("shared", "notempty")(Plain); + await d.RegisterSchema("notempty"); + await d.GetModel(d.BasicDataModel(Plain)).insert({ _id: "unrelated", name: "already here" }); + class Seeded extends d.Table {} + decorate(Seeded, { name: [d.Field("string")] }); + d.Fixture(() => [{ _id: "fixture-a", name: "a" }, { _id: "fixture-b", name: "b" }])(Seeded); + d.RegisterTable("shared", "notempty")(Seeded); + await d.RegisterSchema("notempty"); + const ids = (await rawTable("notempty", "shared")).map((row) => row._id).sort(); + await rawTable("notempty", "shared").get("unrelated").delete().run(); + await d.RegisterSchema("notempty"); + const afterEmptied = (await rawTable("notempty", "shared")).map((row) => row._id).sort(); + return { whileNotEmpty: ids, afterEmptied }; +`; + +export const REGISTRATION_JOURNEYS = { + "register-schema": scenario({ + title: "RegisterSchema provisions the registered tables, one without any @Field included, binds the classes and refuses an unknown schema", + body: REGISTER, + printed: "{\"noTables\":\"No tables registered for schema 'nothing-registered'\",\"beforeBinding\":\"Item is not bound to a schema: call RegisterSchema(\\\"reg\\\") in the module that declares it\",\"returned\":\"undefined\",\"again\":\"undefined\",\"tables\":[\"items\",\"boxes\",\"bare\"],\"bareDefinition\":{\"fields\":{},\"indexes\":{}},\"bareRow\":true,\"afterBinding\":true,\"viaSchemaName\":true,\"tableNameIsNotASchema\":true}", + stored: [{ name: "the collections of the schema", expression: "$DB.getCollectionNames().filter((n) => n.startsWith('reg__')).sort()", expected: "[\"reg__bare\",\"reg__boxes\",\"reg__items\"]" }], + }), + fixtures: scenario({ + title: "fixtures fill an empty table once, with generated ids and the same conversion as an insert", + body: FIXTURES, + printed: "{\"first\":{\"roles\":2,\"settings\":1,\"plans\":3,\"nothing\":0,\"markers\":2},\"afterSecondStart\":1,\"plans\":[\"plans:fixture:0\",\"plans:fixture:1\",\"plans:fixture:2\"],\"unregisteredClassProvisioned\":false,\"hashedAtInsert\":[true,true],\"insertEventFired\":true}", + stored: [ + { name: "the plan rows", expression: "$DB.getCollection('fx__plans').find({}, { name: 0 }).sort({ _id: 1 }).toArray().map((r) => [r._id, r.label])", expected: "[[\"plans:fixture:0\",\"free\"],[\"plans:fixture:1\",\"pro\"],[\"plans:fixture:2\",\"team\"]]" }, + { name: "the stamped row", expression: "$DB.getCollection('fx__stamped').find().sort({ _id: 1 }).toArray().map((r) => [r._id, r._internal.data.secret.startsWith('$' + 'scrypt' + '$ln=15,r=8,p=3$'), r._internal.data.secret.includes('hunter'), r.created !== undefined])", expected: "[[\"s1\",true,false,true],[\"s2\",true,false,true]]" }, + ], + }), + "fixture-race": scenario({ + title: "two starts on one empty table write each fixture row once, and a subclass gets its parent's fixture", + body: FIXTURE_RACE, + printed: "{\"rows\":[\"seeds:fixture:0\",\"seeds:fixture:1\"],\"kids\":[\"kids:fixture:0\",\"kids:fixture:1\"],\"generatorReceived\":\"SpyChild\"}", + stored: [{ name: "the seed rows", expression: "$DB.getCollection('race__seeds').find().sort({ _id: 1 }).toArray().map((r) => [r._id, r.name])", expected: "[[\"seeds:fixture:0\",\"a\"],[\"seeds:fixture:1\",\"b\"]]" }], + }), + "fixture-once": scenario({ title: "a fixture generator runs once, on the first RegisterSchema that finds its table empty", body: FIXTURE_ONCE, printed: "{\"afterFirst\":[1,1],\"afterThree\":[1,1]}" }), + "fixture-mixed-tables": scenario({ title: "in a schema with a filled table and an empty one, only the empty table gets its fixture", body: FIXTURE_MIXED_TABLES, printed: "{\"filled\":[\"mine\"],\"empties\":[\"empties:fixture:0\"]}" }), + "quick-start": scenario({ + title: "the docs/1 quick start, compiled as written, saves and reads back a note", + body: `const quick = require(process.env.VERIFY_QUICKSTART);\n return { title: await quick.tryNote() };`, + printed: "{\"title\":\"Learn models\"}", + async prepare(context, folder) { + const page = readFileSync(join(context.repo, "docs", "1.introduction.md"), "utf8"); + const block: string | undefined = /```ts docs-check\n([\s\S]*?)```/.exec(page)?.[1]; + if (block === undefined) throw new Error("docs/1 has no docs-check block"); + const source = join(folder, "quick-start.ts"); + context.edit("write the quick start as the docs show it", source, block.replace('"@antelopejs/interface-database-decorators"', JSON.stringify(join(context.repo, "dist", "index")))); + const compiled = await context.command("compile the quick start", "node", [ + join(context.repo, "node_modules", "typescript", "bin", "tsc"), + "--strict", + "--skipLibCheck", + "--experimentalDecorators", + "--emitDecoratorMetadata", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + join(context.repo, "node_modules", "@types"), + "--types", + "node", + source, + ]); + context.expect(compiled.folder, [{ name: "the docs/1 block compiles", pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 400) }], { critical: true }); + return { VERIFY_QUICKSTART: join(folder, "quick-start.js") }; + }, + }), + "fixture-not-empty": scenario({ title: "a table that already holds a row gets no fixture rows, and an emptied table gets them", body: FIXTURE_NOT_EMPTY, printed: "{\"whileNotEmpty\":[\"unrelated\"],\"afterEmptied\":[\"fixture-a\",\"fixture-b\"]}" }), + "fixture-ids-mixed": scenario({ + title: "a fixture mixing rows with and without an id keeps the ids it has and numbers the others by position", + body: FIXTURE_MIXED, + printed: "{\"mixed\":[\"keep\",\"mixed:fixture:1\",\"mixed:fixture:2\"],\"last\":[\"last:fixture:0\",\"named\"],\"single\":[\"single:fixture:0\"]}", + stored: [{ name: "the mixed ids", expression: "$DB.getCollection('mix__mixed').find().sort({ _id: 1 }).toArray().map((r) => r._id)", expected: "[\"keep\",\"mixed:fixture:1\",\"mixed:fixture:2\"]" }], + }), + "table-foreign-instance": scenario({ + title: "Table.table with a schema instance takes the table from that instance as is, even of another schema", + body: FOREIGN, + printed: "{\"homeCount\":1,\"awayCount\":1}", + stored: [ + { name: "the home rows", expression: "$DB.getCollection('home__items').find().toArray().map((r) => r._id)", expected: "[\"local\"]" }, + { name: "the away rows", expression: "$DB.getCollection('away__items').find().toArray().map((r) => [r._id, r._instance])", expected: "[[\"foreign\",\"away-1\"]]" }, + ], + }), + "table-access": scenario({ + title: "Table.table opens the bound table for the default instance, an instance id, all instances or a held instance", + body: TABLE_ACCESS, + printed: "{\"unbound\":\"Ghost is not bound to a schema: call RegisterSchema(\\\"never-registered\\\") in the module that declares it\",\"nameless\":\"Nameless has no table name: decorate it with @RegisterTable\",\"beforeRegister\":\"Item is not bound to a schema: call RegisterSchema(\\\"acc\\\") in the module that declares it\",\"defaultCount\":1,\"tenantCount\":1,\"crossCount\":3,\"heldCount\":1}", + stored: [{ name: "the rows by instance", expression: "$DB.getCollection('acc__items').find().sort({ name: 1 }).toArray().map((r) => [r.name, r._instance])", expected: "[[\"default\",null],[\"held\",\"held\"],[\"tenant\",\"tenant-1\"]]" }], + }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/scenarios/tables.ts b/.agents/skills/verify-interface/scenarios/tables.ts new file mode 100644 index 0000000..971e372 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios/tables.ts @@ -0,0 +1,279 @@ +import type { Journey } from "@antelopejs/tooling-configs/verify"; + +import { scenario } from "../runner.ts"; + +const INDEX_OPTIONS = ` + class User extends d.Table {} + decorate(User, { + email: [d.Field("string"), d.Index({ unique: true })], + first: [d.Field("string"), d.Index({ group: "name", crossInstance: true })], + last: [d.Field("string"), d.Index({ group: "name" })], + age: [d.Index(), d.Field("number")], + note: [], + }); + d.RegisterTable("users", "idx")(User); + await d.RegisterSchema("idx"); + const definition = iface.Schema.get("idx").definition; + const Model = d.BasicDataModel(User); + const model = d.GetModel(Model); + const other = d.GetModel(Model, "tenant-2"); + await model.insert({ email: "a@x", first: "Ada", last: "Lovelace", age: 36 }); + const refused = await refusedDuplicate(model, (attempt) => ({ email: "a@x", first: "Bob", last: "Brown", age: 40 + attempt })); + const duplicate = refused && { name: refused.name, message: refused.message }; + const recognised = [iface.IsDuplicateKeyError(refused), iface.IsDuplicateKeyError(new Error("other"))]; + const otherInstance = await other.insert({ email: "a@x", first: "Ada", last: "Lovelace", age: 36 }); + const groupRows = await model.getBy("name", "Ada", "Lovelace"); + const singleRows = await model.getBy("age", 36); + const converted = [groupRows.every((row) => row instanceof User), singleRows.every((row) => row instanceof User), groupRows.length, singleRows.length]; + const byGroup = groupRows.map((row) => row.email); + const bySingle = singleRows.map((row) => row.first); + const between = (await rawTable("idx", "users").between("age", 30, 38)).map((row) => row.first); + const symbolKey = caught(() => d.Index()({}, Symbol("k"))); + const built = await untilIndexed("idx__users", ["email__i", "name__i", "age__i", "name"]); + return { converted, indexed: built !== null, definition: definition.users, duplicate, recognised, otherInstanceStored: otherInstance.length, byGroup, bySingle, between, symbolKey }; +`; + +const GROUP_FLAGS = ` + class Pair extends d.Table {} + decorate(Pair, { + a: [d.Field("string"), d.Index({ group: "ab" })], + b: [d.Field("string"), d.Index({ group: "ab", unique: true })], + c: [d.Field("string"), d.Index({ group: "cd", crossInstance: true })], + e: [d.Field("string"), d.Index({ group: "cd" })], + f: [d.Field("string"), d.Index({ group: "ef" })], + g: [d.Field("string"), d.Index({ group: "ef", crossInstance: true })], + h: [d.Field("string"), d.Index({ group: "ef" })], + i: [d.Field("string"), d.Index({ group: "ij", unique: true })], + j: [d.Field("string"), d.Index({ group: "ij" })], + k: [d.Field("string"), d.Index({ group: "plain" })], + l: [d.Field("string"), d.Index({ group: "plain" })], + }); + d.RegisterTable("pairs", "grp")(Pair); + await d.RegisterSchema("grp"); + const model = d.GetModel(d.BasicDataModel(Pair)); + await model.insert({ a: "1", b: "x", c: "p", e: "q", i: "first" }); + const refusedKey = await refusedDuplicate(model, (attempt) => ({ a: "1", b: "x", c: "r" + attempt, e: "s", i: "refused" + attempt })); + const sameKey = refusedKey && { name: refusedKey.name, message: refusedKey.message }; + const otherSecond = await model.insert({ a: "1", b: "y", c: "p", e: "q", i: "other" }); + return { indexes: iface.Schema.get("grp").definition.pairs.indexes, sameKey, otherSecond: otherSecond.length }; +`; + +const FIELD_TYPES = ` + const codec = { _tag: "codec", name: "Positive", decode: (u) => (typeof u === "number" && u > 0 ? { _tag: "Right", right: u } : { _tag: "Left", left: [{ message: "not positive" }] }), encode: (a) => a, is: (u) => typeof u === "number" }; + class Order extends d.Table {} + decorate(Order, { + id: [d.Field("string")], + tags: [d.Field(["string"])], + address: [d.Field({ street: "string", geo: { lat: "number", lng: "number" } })], + total: [d.Field(codec), d.Index()], + lateIndex: [d.Index(), d.Field("number")], + slugOnly: [d.Index()], + stamped: [d.CreationTime()], + plain: [], + }); + d.RegisterTable("orders", "fld")(Order); + await d.RegisterSchema("fld"); + const fields = iface.Schema.get("fld").definition.orders.fields; + return { + names: Object.keys(fields), + id: fields.id, + tags: fields.tags, + address: fields.address, + codecIsTheSameObject: fields.total === codec, + lateIndexType: fields.lateIndex, + indexes: Object.keys(iface.Schema.get("fld").definition.orders.indexes), + leftOut: ["slugOnly", "stamped", "plain"].map((name) => name in fields), + }; +`; + +const FORWARD_RELATION = ` + class Early extends d.Table {} + decorate(Early, { laterId: [d.Field("string"), d.Relation({ to: () => Later })] }); + class Later extends d.Table {} + const relation = d.getMetadata(Early, d.RelationStaticMetadata).relations.laterId; + return { resolves: relation.to() === Later, toField: relation.toField ?? "undefined" }; +`; + +const RELATION = ` + class Author extends d.Table {} + decorate(Author, { name: [d.Field("string")] }); + class Post extends d.Table {} + decorate(Post, { + authorId: [d.Index(), d.Field("string"), d.Relation({ to: () => Author })], + tagIds: [d.Relation({ to: () => Author, toField: "name", many: true }), d.Field(["string"])], + title: [d.Field("string")], + }); + d.RegisterTable("authors", "rel")(Author); + d.RegisterTable("posts", "rel")(Post); + await d.RegisterSchema("rel"); + const metadata = d.getMetadata(Post, d.RelationStaticMetadata).relations; + const posts = d.GetModel(d.BasicDataModel(Post)); + const ids = await posts.insert({ authorId: "nobody", tagIds: ["ghost"], title: "Dangling" }); + const stored = await posts.get(ids[0]); + const definition = iface.Schema.get("rel").definition.posts; + class Child extends Post {} + decorate(Child, { editorId: [d.Field("string"), d.Relation({ to: () => Author })] }); + return { + relationFields: Object.keys(metadata), + authorTarget: metadata.authorId.to() === Author, + authorToField: metadata.authorId.toField ?? null, + tagMany: metadata.tagIds.many, + tagToField: metadata.tagIds.toField, + dangling: [stored.authorId, stored.tagIds], + fieldsKept: Object.keys(definition.fields), + childRelations: Object.keys(d.getMetadata(Child, d.RelationStaticMetadata).relations), + parentRelations: Object.keys(d.getMetadata(Post, d.RelationStaticMetadata).relations), + }; +`; + +const INHERITANCE = ` + class Cross extends d.Table {} + decorate(Cross, { a: [d.Field("string"), d.Index({ group: "ab" })], b: [d.Field("string"), d.Index({ group: "ab", crossInstance: true, unique: true })], c: [d.Field("string"), d.Index({ group: "c" })] }); + d.RegisterTable("crosses", "inh")(Cross); + class Base extends d.Table {} + decorate(Base, { createdAt: [d.Field("date")] }); + d.RegisterTable("people", "inh")(Base); + class Person extends Base {} + decorate(Person, { email: [d.Field("string"), d.Index({ unique: true })] }); + class Employee extends Person {} + decorate(Employee, { badge: [d.Field("string"), d.Index()] }); + class Sub extends Cross {} + decorate(Sub, { extra: [d.Field("string")] }); + d.RegisterTable("subs", "inh")(Sub); + class Contractor extends Base {} + decorate(Contractor, { rate: [d.Field("number")] }); + const meta = (cls) => { const m = d.getMetadata(cls, d.DatumStaticMetadata); return { table: m.tableName ?? null, schema: m.schemaName ?? null, fields: Object.keys(m.fields), indexes: Object.keys(m.indexes), unique: [...m.uniqueIndexes], cross: [...m.crossInstanceIndexes] }; }; + await d.RegisterSchema("inh"); + const built = iface.Schema.get("inh").definition; + class Late extends Cross {} + const late = [d.getMetadata(Late, d.DatumStaticMetadata).schema === undefined, d.getMetadata(Cross, d.DatumStaticMetadata).schema !== undefined, d.getMetadata(Late, d.DatumStaticMetadata).tableName]; + return { late, built: [built.subs.indexes.ab, built.crosses.indexes.ab], base: meta(Base), person: meta(Person), employee: meta(Employee), contractor: meta(Contractor), cross: meta(Cross), sub: meta(Sub) }; +`; + +const WITH_SUBCLASS = ` + class User extends d.Table { + greet() { return "hi " + this.name; } + } + decorate(User, { name: [d.Field("string")] }); + class Admin extends User.with(d.HashModifier) {} + decorate(Admin, { password: [d.Hashed(), d.Field("string")] }); + d.RegisterTable("admins", "withsub")(Admin); + await d.RegisterSchema("withsub"); + const admin = new Admin(); + admin.name = "Root"; + const model = d.GetModel(d.BasicDataModel(Admin)); + const [id] = await model.insert({ name: "Root", password: "pw" }); + const row = await model.get(id); + return { + isUser: [admin instanceof User, admin instanceof d.Table, Object.getPrototypeOf(User.with(d.HashModifier)) === User], + methods: [admin.greet(), typeof admin.testHash, typeof admin.testHashAsync, typeof admin.isHashOutdated, typeof User.prototype.testHash], + stored: [row.greet(), await row.testHashAsync("password", "pw")], + }; +`; + +const WITH_FIELDS = ` + class User extends d.Table {} + decorate(User, { name: [d.Field("string")], email: [d.Field("string"), d.Index()] }); + class Admin extends User.with(d.HashModifier) {} + decorate(Admin, { password: [d.Hashed(), d.Field("string")] }); + d.RegisterTable("admins", "withfields")(Admin); + await d.RegisterSchema("withfields"); + const definition = iface.Schema.get("withfields").definition.admins; + return { fields: Object.keys(d.getMetadata(Admin, d.DatumStaticMetadata).fields).sort(), built: Object.keys(definition.fields).sort(), indexes: Object.keys(definition.indexes) }; +`; + +const SUBCLASS_MODIFIERS = ` + class Parent extends d.Table.with(d.HashModifier) {} + decorate(Parent, { name: [d.Field("string")], born: [d.CreationTime(), d.Field("date")] }); + d.RegisterTable("parents", "submod")(Parent); + class Child extends Parent {} + decorate(Child, { extra: [d.CreationTime(), d.Field("date")], secret: [d.Hashed(), d.Field("string")] }); + d.RegisterTable("children", "submod")(Child); + await d.RegisterSchema("submod"); + const parentRow = new Parent(); + const childRow = new Child(); + const parents = d.GetModel(d.BasicDataModel(Parent)); + const children = d.GetModel(d.BasicDataModel(Child)); + const [parentId] = await parents.insert({ name: "p" }); + const [childId] = await children.insert({ name: "c", secret: "s" }); + const parentStored = await rawTable("submod", "parents").get(parentId); + const childStored = await rawTable("submod", "children").get(childId); + const meta = d.getMetadata(Parent, d.DatumStaticMetadata); + class Fixtured extends Parent {} + d.Fixture(() => [{ name: "only the child" }])(Fixtured); + return { + parentModified: [d.getModifiedFields(parentRow, d.HashModifier), d.getModifiedFields(parentRow, d.AutoDateModifier)], + childModified: [d.getModifiedFields(childRow, d.HashModifier), d.getModifiedFields(childRow, d.AutoDateModifier)], + parentStored: [Object.keys(parentStored).filter((key) => ["born", "extra"].includes(key)).sort(), parentStored._internal === undefined || Object.keys(parentStored._internal.data ?? {})], + childStored: [Object.keys(childStored).filter((key) => ["born", "extra"].includes(key)).sort(), Object.keys(childStored._internal.data)], + parentFixture: meta.generator === undefined, + childSeesParentFields: Object.keys(d.getMetadata(Child, d.DatumStaticMetadata).fields), + }; +`; + +const WITH = ` + class Plain extends d.Table {} + class Hashing extends d.Table.with(d.HashModifier) {} + class Both extends d.Table.with(d.HashModifier, d.LocalizationModifier) {} + const has = (cls, name) => typeof new cls()[name] === "function"; + const sub = d.Table.with(d.HashModifier); + return { + plainHasTestHash: has(Plain, "testHash"), + hashingHas: [has(Hashing, "testHash"), has(Hashing, "testHashAsync"), has(Hashing, "isHashOutdated"), has(Hashing, "localize")], + bothHas: [has(Both, "testHash"), has(Both, "localize")], + isTableSubclass: new Hashing() instanceof d.Table, + tableItselfUntouched: typeof d.Table.prototype.testHash, + subIsASubclass: sub.prototype instanceof d.Table, + }; +`; + +const COMBINE = ` + const secretKey = "12345678901234567890123456789012"; + class UserProfile extends d.Table.with(d.EncryptionModifier, d.HashModifier, d.LocalizationModifier) {} + decorate(UserProfile, { + privateInfo: [d.Encrypted({ secretKey }), d.Field("any")], + password: [d.Hashed(), d.Field("string")], + bio: [d.Localized({ fallbackLocale: "en" }), d.Field("string")], + }); + d.RegisterTable("profiles", "combo")(UserProfile); + await d.RegisterSchema("combo"); + const model = d.GetModel(d.BasicDataModel(UserProfile)); + const profile = new UserProfile(); + profile.privateInfo = { address: "1 Main St", phoneNumber: "555" }; + profile.password = "pw"; + profile.localize("en").bio = "Hello"; + profile.localize("fr").bio = "Bonjour"; + const [id] = await model.insert(profile); + const row = await model.get(id); + const raw = await rawTable("combo", "profiles").get(id); + return { + stored: Object.keys(raw._internal.data).sort(), + privateInfo: row.privateInfo, + password: [await row.testHashAsync("password", "pw"), await row.testHashAsync("password", "no")], + bio: [row.localize("fr").bio, row.localize("de").bio], + mixins: [typeof row.testHash, typeof row.localize], + }; +`; + +export const TABLE_JOURNEYS = { + "index-options": scenario({ + title: "Field and Index reach the database as fields and plain, compound, cross-instance and unique indexes", + body: INDEX_OPTIONS, + printed: "{\"converted\":[true,true,1,1],\"indexed\":true,\"definition\":{\"fields\":{\"email\":\"string\",\"first\":\"string\",\"last\":\"string\",\"age\":\"number\"},\"indexes\":{\"email\":{\"fields\":null,\"unique\":true},\"name\":{\"fields\":[\"first\",\"last\"],\"crossInstance\":true},\"age\":{\"fields\":null}}},\"duplicate\":{\"name\":\"DuplicateKeyError\",\"message\":\"Duplicate key on index \\\"email\\\" (email)\"},\"recognised\":[true,false],\"otherInstanceStored\":1,\"byGroup\":[\"a@x\"],\"bySingle\":[\"Ada\"],\"between\":[\"Ada\"],\"symbolKey\":\"@Index on Symbol(k): a database index names a field, never a symbol\"}", + stored: [{ name: "the indexes of the users collection", expression: "$DB.getCollection('idx__users').getIndexes().map((i) => [i.name, i.unique === true]).sort()", expected: "[[\"_id_\",false],[\"age__i\",false],[\"email__i\",true],[\"name\",false],[\"name__i\",false]]" }], + }), + "group-flags": scenario({ title: "unique and crossInstance on any field of a group apply to the whole group", body: GROUP_FLAGS, printed: "{\"indexes\":{\"ab\":{\"fields\":[\"a\",\"b\"],\"unique\":true},\"cd\":{\"fields\":[\"c\",\"e\"],\"crossInstance\":true},\"ef\":{\"fields\":[\"f\",\"g\",\"h\"],\"crossInstance\":true},\"ij\":{\"fields\":[\"i\",\"j\"],\"unique\":true},\"plain\":{\"fields\":[\"k\",\"l\"]}},\"sameKey\":{\"name\":\"DuplicateKeyError\",\"message\":\"Duplicate key on index \\\"ab\\\" (a, b)\"},\"otherSecond\":1}" }), + "forward-relation": scenario({ title: "a Relation thunk is not called while the decorator runs, so it can name a class declared later", body: FORWARD_RELATION, printed: "{\"resolves\":true,\"toField\":\"undefined\"}" }), + "field-types": scenario({ title: "Field hands every type to the database unchanged and combines with Index in either order", body: FIELD_TYPES, printed: "{\"names\":[\"id\",\"tags\",\"address\",\"total\",\"lateIndex\"],\"id\":\"string\",\"tags\":[\"string\"],\"address\":{\"street\":\"string\",\"geo\":{\"lat\":\"number\",\"lng\":\"number\"}},\"codecIsTheSameObject\":true,\"lateIndexType\":\"number\",\"indexes\":[\"total\",\"lateIndex\",\"slugOnly\"],\"leftOut\":[false,false,false]}" }), + "relation-metadata": scenario({ title: "Relation records links without enforcing them", body: RELATION, printed: "{\"relationFields\":[\"authorId\",\"tagIds\"],\"authorTarget\":true,\"authorToField\":null,\"tagMany\":true,\"tagToField\":\"name\",\"dangling\":[\"nobody\",[\"ghost\"]],\"fieldsKept\":[\"authorId\",\"tagIds\",\"title\"],\"childRelations\":[\"authorId\",\"tagIds\",\"editorId\"],\"parentRelations\":[\"authorId\",\"tagIds\"]}" }), + inheritance: scenario({ title: "a table subclass starts with a copy of its parent and never changes it", body: INHERITANCE, printed: "{\"late\":[true,true,\"crosses\"],\"built\":[{\"fields\":[\"a\",\"b\"],\"crossInstance\":true,\"unique\":true},{\"fields\":[\"a\",\"b\"],\"crossInstance\":true,\"unique\":true}],\"base\":{\"table\":\"people\",\"schema\":\"inh\",\"fields\":[\"createdAt\"],\"indexes\":[],\"unique\":[],\"cross\":[]},\"person\":{\"table\":\"people\",\"schema\":\"inh\",\"fields\":[\"createdAt\",\"email\"],\"indexes\":[\"email\"],\"unique\":[\"email\"],\"cross\":[]},\"employee\":{\"table\":\"people\",\"schema\":\"inh\",\"fields\":[\"createdAt\",\"email\",\"badge\"],\"indexes\":[\"email\",\"badge\"],\"unique\":[\"email\"],\"cross\":[]},\"contractor\":{\"table\":\"people\",\"schema\":\"inh\",\"fields\":[\"createdAt\",\"rate\"],\"indexes\":[],\"unique\":[],\"cross\":[]},\"cross\":{\"table\":\"crosses\",\"schema\":\"inh\",\"fields\":[\"a\",\"b\",\"c\"],\"indexes\":[\"ab\",\"c\"],\"unique\":[\"ab\"],\"cross\":[\"ab\"]},\"sub\":{\"table\":\"subs\",\"schema\":\"inh\",\"fields\":[\"a\",\"b\",\"c\",\"extra\"],\"indexes\":[\"ab\",\"c\"],\"unique\":[\"ab\"],\"cross\":[\"ab\"]}}" }), + "combine-modifiers": scenario({ title: "Table.with combines the encryption, hashing and localization modifiers on one table", body: COMBINE, printed: "{\"stored\":[\"bio\",\"password\",\"privateInfo\"],\"privateInfo\":{\"address\":\"1 Main St\",\"phoneNumber\":\"555\"},\"password\":[true,false],\"bio\":[\"Bonjour\",\"Hello\"],\"mixins\":[\"function\",\"function\"]}" }), + "table-with-subclass": scenario({ title: "X.with(...) returns a subclass of X that keeps X's fields and methods and gains the mixin's", body: WITH_SUBCLASS, printed: "{\"isUser\":[true,true,true],\"methods\":[\"hi Root\",\"function\",\"function\",\"function\",\"undefined\"],\"stored\":[\"hi Root\",true]}" }), + "table-with-keeps-fields": scenario({ + title: "KNOWN FINDING AntelopeJS/interface-database-decorators#81, fails until fixed: the subclass X.with(...) returns keeps the fields and indexes decorated on X", + body: WITH_FIELDS, + printed: "{\"fields\":[\"email\",\"name\",\"password\"],\"built\":[\"email\",\"name\",\"password\"],\"indexes\":[\"email\"]}", + }), + "subclass-modifiers": scenario({ title: "a subclass's modifier and event lists never reach its parent", body: SUBCLASS_MODIFIERS, printed: "{\"parentModified\":[[],[]],\"childModified\":[[\"secret\"],[]],\"parentStored\":[[\"born\"],[]],\"childStored\":[[\"born\",\"extra\"],[\"secret\"]],\"parentFixture\":true,\"childSeesParentFields\":[\"name\",\"born\",\"extra\",\"secret\"]}" }), + "table-with": scenario({ title: "Table.with adds the mixin methods of the modifiers to a subclass", body: WITH, printed: "{\"plainHasTestHash\":false,\"hashingHas\":[true,true,true,false],\"bothHas\":[true,true],\"isTableSubclass\":true,\"tableItselfUntouched\":\"undefined\",\"subIsASubclass\":true}" }), +} satisfies { [id: string]: Journey }; diff --git a/.agents/skills/verify-interface/seeded-breaks/autodate.patch b/.agents/skills/verify-interface/seeded-breaks/autodate.patch new file mode 100644 index 0000000..e08ed4e --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/autodate.patch @@ -0,0 +1,13 @@ +diff --git a/src/modifiers/autodate.ts b/src/modifiers/autodate.ts +index 3be1f82..e6a5c58 100644 +--- a/src/modifiers/autodate.ts ++++ b/src/modifiers/autodate.ts +@@ -25,7 +25,7 @@ export class AutoDateModifier extends Modifier { + if (this.options.type === "updated") { + object[field] = new Date(); + } else { +- delete object[field]; ++ object[field] = new Date(); + } + } + } diff --git a/.agents/skills/verify-interface/seeded-breaks/custom-modifiers.patch b/.agents/skills/verify-interface/seeded-breaks/custom-modifiers.patch new file mode 100644 index 0000000..5b3e4e9 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/custom-modifiers.patch @@ -0,0 +1,13 @@ +diff --git a/src/modifiers/common.ts b/src/modifiers/common.ts +index 638798b..7eb97df 100644 +--- a/src/modifiers/common.ts ++++ b/src/modifiers/common.ts +@@ -127,7 +127,7 @@ export class ContainerModifier< + if (key === "*") { + return locked_value; + } +- return Object.hasOwn(locked_value, key) ? locked_value[key] : undefined; ++ return locked_value[key]; + } + public override unlockrequest( + // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the container holds the field's values by key, typed by the table class, not by the modifier diff --git a/.agents/skills/verify-interface/seeded-breaks/declarations.patch b/.agents/skills/verify-interface/seeded-breaks/declarations.patch new file mode 100644 index 0000000..df1f15b --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/declarations.patch @@ -0,0 +1,12 @@ +diff --git a/src/common.ts b/src/common.ts +index 2cdd7a1..d097554 100644 +--- a/src/common.ts ++++ b/src/common.ts +@@ -40,7 +40,6 @@ export type PlainData = { + */ + export type DatumGeneratorOutput = + | Row +- | Row[] + | undefined; + /** + * A fixture generator of a table class, called with the class registered into diff --git a/.agents/skills/verify-interface/seeded-breaks/encryption.patch b/.agents/skills/verify-interface/seeded-breaks/encryption.patch new file mode 100644 index 0000000..b74593f --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/encryption.patch @@ -0,0 +1,13 @@ +diff --git a/src/modifiers/encryption.ts b/src/modifiers/encryption.ts +index b569dc7..4e86667 100644 +--- a/src/modifiers/encryption.ts ++++ b/src/modifiers/encryption.ts +@@ -95,7 +95,7 @@ export class EncryptionModifier extends TwoWayModifier< + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- overrides OneWayModifier.lock; a modified field's value has its table class's type, which the shared modifier chain cannot name + value: unknown, + ): LockedType { +- const iv = randomBytes(this.options.ivSize || 16); ++ const iv = randomBytes(16); + const cipher = createCipheriv( + this.options.algorithm || "aes-256-gcm", + this.options.secretKey, diff --git a/.agents/skills/verify-interface/seeded-breaks/hashing.patch b/.agents/skills/verify-interface/seeded-breaks/hashing.patch new file mode 100644 index 0000000..5cacd2a --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/hashing.patch @@ -0,0 +1,13 @@ +diff --git a/src/modifiers/hash.ts b/src/modifiers/hash.ts +index f23cce8..05ca063 100644 +--- a/src/modifiers/hash.ts ++++ b/src/modifiers/hash.ts +@@ -52,7 +52,7 @@ type HashCheck = { + }; + + const SCRYPT_COST: ScryptCost = { ln: 15, r: 8, p: 3 }; +-const SALT_BYTES = 16; ++const SALT_BYTES = 8; + const KEY_BYTES = 32; + const DECOY_SALT = Buffer.alloc(SALT_BYTES); + const LEGACY_DIGEST = "sha256"; diff --git a/.agents/skills/verify-interface/seeded-breaks/localization.patch b/.agents/skills/verify-interface/seeded-breaks/localization.patch new file mode 100644 index 0000000..7e772c6 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/localization.patch @@ -0,0 +1,13 @@ +diff --git a/src/modifiers/localization.ts b/src/modifiers/localization.ts +index c672436..b4d4ec0 100644 +--- a/src/modifiers/localization.ts ++++ b/src/modifiers/localization.ts +@@ -24,7 +24,7 @@ const EMPTY_TEXT = ""; + + function withoutEmptyTexts(values: Record): Record { + return Object.fromEntries( +- Object.entries(values).filter(([, value]) => value !== EMPTY_TEXT), ++ Object.entries(values).filter(([, value]) => value !== undefined), + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/models.patch b/.agents/skills/verify-interface/seeded-breaks/models.patch new file mode 100644 index 0000000..f0fbf8e --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/models.patch @@ -0,0 +1,13 @@ +diff --git a/src/model.ts b/src/model.ts +index 3f26f32..8d1e42c 100644 +--- a/src/model.ts ++++ b/src/model.ts +@@ -140,7 +140,7 @@ export function BasicDataModel( + * @returns Table class instance + */ + public static fromDatabase(obj: any) { +- return obj ? fromDatabase(obj, dataType) : undefined; ++ return obj && Object.keys(obj).length > 0 ? fromDatabase(obj, dataType) : undefined; + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/parameter-decoration.patch b/.agents/skills/verify-interface/seeded-breaks/parameter-decoration.patch new file mode 100644 index 0000000..971f48c --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/parameter-decoration.patch @@ -0,0 +1,13 @@ +diff --git a/src/model.ts b/src/model.ts +index 3f26f32..41aa96d 100644 +--- a/src/model.ts ++++ b/src/model.ts +@@ -419,7 +419,7 @@ export const Model = MakeParameterAndPropertyDecorator( + ) => { + if (isInstanceIdProvider(instanceIdOrCallback)) { + SetParameterProvider(target, key, index, (ctx: RequestContext) => { +- return GetModel(cl, instanceIdOrCallback(ctx)); ++ return GetModel(cl); + }); + } else { + SetParameterProvider(target, key, index, () => diff --git a/.agents/skills/verify-interface/seeded-breaks/registration.patch b/.agents/skills/verify-interface/seeded-breaks/registration.patch new file mode 100644 index 0000000..ca13947 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/registration.patch @@ -0,0 +1,13 @@ +diff --git a/src/database.ts b/src/database.ts +index 93cbfa4..5201029 100644 +--- a/src/database.ts ++++ b/src/database.ts +@@ -122,7 +122,7 @@ function withStableId( + position: number, + ): StoredRow
{ + return row._id === undefined +- ? { ...row, _id: `${tableName}:fixture:${position}` } ++ ? { ...row, _id: `${tableName}:fixture:${position + 1}` } + : row; + } + 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..2be8a13 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/smoke.patch @@ -0,0 +1,13 @@ +diff --git a/src/model.ts b/src/model.ts +index 3f26f32..2b6395b 100644 +--- a/src/model.ts ++++ b/src/model.ts +@@ -298,7 +298,7 @@ export function BasicDataModel( + * @returns Delete result + */ + public delete(id: string) { +- return this.table.get(id).delete().run(); ++ return this.table.get(id).run(); + } + }; + return Model; diff --git a/.agents/skills/verify-interface/seeded-breaks/tables.patch b/.agents/skills/verify-interface/seeded-breaks/tables.patch new file mode 100644 index 0000000..1707dd6 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/tables.patch @@ -0,0 +1,13 @@ +diff --git a/src/table.ts b/src/table.ts +index 89445f4..78cf036 100644 +--- a/src/table.ts ++++ b/src/table.ts +@@ -220,7 +220,7 @@ export const Index = MakePropertyDecorator( + const key = propertyKey; + metadata.addIndex( + key, +- options?.group || key, ++ key, + options?.crossInstance, + options?.unique, + ); 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..4e68584 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/contract.patch @@ -0,0 +1,11 @@ +diff --git a/src/index.ts b/src/index.ts +index ef4a9de..48fc1c8 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -4,6 +4,5 @@ export * from "./common"; + export * from "./database"; + export * from "./model"; + export * from "./modifiers"; +-export * from "./relation"; + export * from "./schema"; + export * from "./table"; 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..2074bb7 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/data.patch @@ -0,0 +1,13 @@ +diff --git a/src/model.ts b/src/model.ts +index 3f26f32..fac3b3a 100644 +--- a/src/model.ts ++++ b/src/model.ts +@@ -298,7 +298,7 @@ export function BasicDataModel( + * @returns Delete result + */ + public delete(id: string) { +- return this.table.get(id).delete().run(); ++ return this.table.delete().run(); + } + }; + return Model; 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..fc16397 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,26 @@ +diff --git a/src/modifiers/hash.ts b/src/modifiers/hash.ts +index f23cce8..4e4b4e2 100644 +--- a/src/modifiers/hash.ts ++++ b/src/modifiers/hash.ts +@@ -51,7 +51,7 @@ type HashCheck = { + verdict: (derived: Buffer | undefined) => boolean; + }; + +-const SCRYPT_COST: ScryptCost = { ln: 15, r: 8, p: 3 }; ++const SCRYPT_COST: ScryptCost = { ln: 14, r: 8, p: 3 }; + const SALT_BYTES = 16; + const KEY_BYTES = 32; + const DECOY_SALT = Buffer.alloc(SALT_BYTES); +diff --git a/src/modifiers/encryption.ts b/src/modifiers/encryption.ts +index b569dc7..cf8f055 100644 +--- a/src/modifiers/encryption.ts ++++ b/src/modifiers/encryption.ts +@@ -113,7 +113,7 @@ export class EncryptionModifier extends TwoWayModifier< + : undefined; + } catch {} + +- return [encrypted, iv.toString("base64"), authTag]; ++ return [encrypted, iv.toString("hex"), authTag]; + } + + readonly autounlock = true; 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..93a7031 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/publish.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 8b1d78f..50284a7 100644 +--- a/package.json ++++ b/package.json +@@ -115,7 +115,7 @@ + "./package.json": "./package.json" + }, + "publishConfig": { +- "access": "public", ++ "access": "restricted", + "provenance": true + }, + "scripts": { 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..ddd0f87 --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,53 @@ +import type { VerifyConfig } from "@antelopejs/tooling-configs/verify"; + +import { CUSTOM_JOURNEYS } from "./scenarios/custom.ts"; +import { DECLARATION_JOURNEYS } from "./scenarios/declarations.ts"; +import { DECORATION_JOURNEYS } from "./scenarios/decoration.ts"; +import { ENCRYPTION_JOURNEYS } from "./scenarios/encryption.ts"; +import { HASH_JOURNEYS } from "./scenarios/hashing.ts"; +import { AUTODATE_JOURNEYS, LOCALIZATION_JOURNEYS } from "./scenarios/localization.ts"; +import { MODEL_JOURNEYS } from "./scenarios/models.ts"; +import { REGISTRATION_JOURNEYS } from "./scenarios/registration.ts"; +import { TABLE_JOURNEYS } from "./scenarios/tables.ts"; + +const config: VerifyConfig = { + services: { + mongo: { + image: "mongo:7", + port: 27017, + ready: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"], + reset: ["mongosh", "--quiet", "--eval", 'db.getSiblingDB("verify").dropDatabase()'], + }, + }, + doors: { + data: { + globs: ["src/model.ts", "src/database.ts"], + funnels: [], + containments: ["models/crud"], + why: "model.insert, update and delete and RegisterSchema's fixtures write and remove rows in the consumer's database", + }, + format: { + globs: ["src/modifiers/encryption.ts", "src/modifiers/hash.ts", "src/modifiers/localization.ts", "src/modifiers/internal/records.ts"], + funnels: [], + containments: ["encryption/encryption-at-rest", "hashing/hash-storage"], + why: "the encrypted [ciphertext, iv, authTag] triple, the scrypt string with its ln, r and p cost, salt and key, the digest and salt and the per-locale record are what rows persist as", + }, + contract: { + globs: ["src/index.ts", "package.json"], + funnels: [], + containments: [], + why: "the exports and subpaths are what every module declaring tables imports", + }, + outbound: null, + exposure: null, + 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-database-decorators on npm", + }, + }, + journeys: { ...TABLE_JOURNEYS, ...REGISTRATION_JOURNEYS, ...MODEL_JOURNEYS, ...DECORATION_JOURNEYS, ...ENCRYPTION_JOURNEYS, ...HASH_JOURNEYS, ...LOCALIZATION_JOURNEYS, ...AUTODATE_JOURNEYS, ...CUSTOM_JOURNEYS, ...DECLARATION_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 a803ecc..a7e021d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,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 diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index 28ccc14..c11d190 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -144,7 +144,7 @@ class Chef extends Table.with( to the whole group. - `@Fixture` data is only inserted by `RegisterSchema` when the table is empty (count === 0). - `GetModel` caches by model class + instanceId; the same pair always returns the same instance. -- `RegisterSchema` binds each registered table class to the `Schema` it builds; call it in the module that declares the tables. `User.table()`, `User.table(instanceId)` and `User.table(db)` then return the class's AQL table, and `GetModel` builds `BasicDataModel` models on that same schema, even when another module (bound to another database) calls them. An unbound class throws rather than looking a schema up by id. +- `RegisterSchema` binds each registered table class to the `Schema` it builds; call it in the module that declares the tables. `User.table()`, `User.table(instanceId)` and `User.table(db)` then return the class's AQL table, and `GetModel` builds `BasicDataModel` models on that same schema, even when another module (bound to another database) calls them. An unbound class whose schema name has a schema uses that schema; one whose schema name has none throws ` is not bound to a schema`. - Serialization of modified instances to plain JSON goes through `toPlainData` (auto-attached as `toJSON`). Deeper reference: this package's `docs/` chapters — Introduction, Table Definitions, Table Modifiers,