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..ddca188 --- /dev/null +++ b/.agents/skills/verify-interface/features/README.md @@ -0,0 +1,18 @@ +# 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. + +| Feature | What the user does | Journeys | +|---|---|---| +| [smoke](smoke.md) | The fixed journey every verdict runs, whatever the diff touches: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call everything it declares. | every-function | +| [calls](calls.md) | A backend starts, reads, lists, cancels and re-methods a subscription through the five `InterfaceFunction`s, each with an idempotency key positionally and an optional trailing `provider`. | calls-through-core | +| [signatures](signatures.md) | The compile-time contract of the calls: which argument comes where, which are optional, what they resolve to. | signatures | +| [field-types](field-types.md) | The data a backend reads and writes: `Subscription`, `SubscriptionRequest`, `SubscriptionEvent` and the `JsonObject` that `vendorData` is limited to. | field-types | +| [validation-fields](validation-fields.md) | `ValidateSubscriptionRequest` enforces the portable request fields so every implementation refuses the same shapes. | validate-fields | +| [validation-order](validation-order.md) | The error names the first rule broken, so the order of the rules is part of the contract. | validate-order | +| [validation-interval](validation-interval.md) | A period is `intervalCount` times `interval`; the validator refuses what is not a calendar unit and a positive whole count. | validate-interval | +| [validation-dates](validation-dates.md) | Trials and billing anchors defer the first charge; they are mutually exclusive, must be finite, and with `now` must be strictly in the future. | validate-dates | +| [statuses](statuses.md) | The four normalised statuses and why a subscription ended; only `canceled` is terminal. | status-vocabulary | +| [vocabularies](vocabularies.md) | The other strings consumers branch on and providers store: intervals, cancellation timings, event types, the metadata key marking subscription charges, and the one-year period cap. | wire-names | +| [errors](errors.md) | What a consumer catches when a call did not happen: not found, invalid request, invalid state, first period declined. | error-classes | +| [conformance](conformance.md) | The provider conformance suite ships in the package so any module that lists the interface in `antelopeJs.implements` runs it. | suite-helpers, suite-defaults, suite-files | diff --git a/.agents/skills/verify-interface/features/calls.md b/.agents/skills/verify-interface/features/calls.md new file mode 100644 index 0000000..2adf7fb --- /dev/null +++ b/.agents/skills/verify-interface/features/calls.md @@ -0,0 +1,33 @@ +--- +covers: + - src/functions.ts + - src/index.ts +docs: + - README.md + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - calls-through-core +--- + +# Calls + +A backend starts, reads, lists, cancels and re-methods a subscription through the five `InterfaceFunction`s, each with an idempotency key positionally and an optional trailing `provider`. + +## Sub-features + +- `calls-through-core`: `CreateSubscription`, `GetSubscription`, `ListSubscriptions`, `CancelSubscription` and `UpdateSubscriptionPaymentMethod` are called through core by a consumer module with the arguments a backend passes (the request, the idempotency key positionally, the cancellation timing, the optional trailing `provider` both given and left out); the stub provider must receive the same arguments in the same order. + +## How to get to it (user POV) + +- `await CreateSubscription(request, `subscribe-${accountId}`)` and `await CancelSubscription(id, `cancel-now-${id}`, "now")` in a module next to a billing provider. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" calls +``` + +## Gotchas + +- The stub provider echoes its arguments, so this feature proves what reaches a provider, not what a provider answers. Provider behaviour belongs to its conformance suite. diff --git a/.agents/skills/verify-interface/features/conformance.md b/.agents/skills/verify-interface/features/conformance.md new file mode 100644 index 0000000..992008f --- /dev/null +++ b/.agents/skills/verify-interface/features/conformance.md @@ -0,0 +1,35 @@ +--- +covers: + - src/tests/** +docs: + - README.md + - docs/4.implementing.md +journeys: + - suite-helpers + - suite-defaults + - suite-files +--- + +# Conformance + +The provider conformance suite ships in the package so any module that lists the interface in `antelopeJs.implements` runs it. + +## Sub-features + +- `suite-helpers`: the shipped `tests/support` module of the packed interface, loaded by a provider author through the package's own `exports`, reads `ANTELOPE_PAYMENT_TEST_METHOD`, `ANTELOPE_SUBSCRIPTIONS_TEST_METHOD_DECLINES_LATER`, `ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS` and `ANTELOPE_PAYMENT_TEST_CURRENCY` as README.md states, with every variable set. +- `suite-defaults`: the same module with no variable set: no test method, events not observable and the currency `EUR`; epoch seconds are refused as a time, milliseconds accepted. +- `suite-files`: `dist/tests` holds the two test files and the support module, and `package.json` exports `./tests/*` and ships `dist`. + +## How to get to it (user POV) + +- A provider author runs `ajs module test` in their module with the `ANTELOPE_PAYMENT_TEST_*` variables from the README table. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" conformance +``` + +## Gotchas + +- The suite itself needs a real provider, so these journeys cover its helpers and packaging, not a provider's verdict. diff --git a/.agents/skills/verify-interface/features/errors.md b/.agents/skills/verify-interface/features/errors.md new file mode 100644 index 0000000..987d284 --- /dev/null +++ b/.agents/skills/verify-interface/features/errors.md @@ -0,0 +1,30 @@ +--- +covers: + - src/errors.ts +docs: + - docs/2.subscriptions.md +journeys: + - error-classes +--- + +# Errors + +What a consumer catches when a call did not happen: not found, invalid request, invalid state, first period declined. + +## Sub-features + +- `error-classes`: the five error classes, built the way docs/2.subscriptions.md says (`new InvalidSubscriptionStateError(message, status?, providerCode?)`, `SubscriptionPaymentError(payment, providerCode?)`): each has its own `name`, is a `SubscriptionError` and an `Error`, siblings are not each other, the provider code is kept verbatim, the state error carries the status, the payment error carries the declined `Payment` and the message `The first period could not be charged: payment is `, and the not-found message is `No such subscription: `. + +## How to get to it (user POV) + +- `catch (error) { if (error instanceof SubscriptionPaymentError) show(error.payment.failure) }`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" errors +``` + +## Gotchas + +- A failed renewal is a `past_due` status, not an error; only a declined first period rejects. diff --git a/.agents/skills/verify-interface/features/field-types.md b/.agents/skills/verify-interface/features/field-types.md new file mode 100644 index 0000000..70bbeb4 --- /dev/null +++ b/.agents/skills/verify-interface/features/field-types.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - field-types +--- + +# Field types + +The data a backend reads and writes: `Subscription`, `SubscriptionRequest`, `SubscriptionEvent` and the `JsonObject` that `vendorData` is limited to. + +## Sub-features + +- `field-types`: a TypeScript consumer compiling against the packed declarations: the fields of `Subscription`, `SubscriptionRequest` and `SubscriptionEvent` that are required and optional as docs/ state, the literal unions of status, interval, end reason, timing and event type, no `vendorData` in a request, and what `JsonObject` accepts (`undefined` values, nested objects) and refuses (`Date`, `Buffer`, functions, read-only arrays, interface-typed objects). + +## How to get to it (user POV) + +- A TypeScript backend reading `subscription.status`, `subscription.endedAt` and `event.payment`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" field-types +``` + +## Gotchas + +- Types only: the probe cannot see a runtime value. diff --git a/.agents/skills/verify-interface/features/signatures.md b/.agents/skills/verify-interface/features/signatures.md new file mode 100644 index 0000000..7b8c98f --- /dev/null +++ b/.agents/skills/verify-interface/features/signatures.md @@ -0,0 +1,31 @@ +--- +covers: + - src/functions.ts +docs: + - docs/2.subscriptions.md + - docs/4.implementing.md +journeys: + - signatures +--- + +# Signatures + +The compile-time contract of the calls: which argument comes where, which are optional, what they resolve to. + +## Sub-features + +- `signatures`: a TypeScript consumer compiling against the packed declarations: each call takes the arguments docs/2.subscriptions.md shows in order and returns `Promise` (`Promise` for the list), `ValidateSubscriptionRequest(request, now?)` returns `void`, `isTerminalSubscriptionStatus` takes a status, and an event handler takes one `SubscriptionEvent`. + +## How to get to it (user POV) + +- A TypeScript backend module importing the calls and the event handler type. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" signatures +``` + +## Gotchas + +- The probe compiles with `--strict` against `dist/index.d.ts`; it reads types only and needs no provider. diff --git a/.agents/skills/verify-interface/features/smoke.md b/.agents/skills/verify-interface/features/smoke.md new file mode 100644 index 0000000..fb63e47 --- /dev/null +++ b/.agents/skills/verify-interface/features/smoke.md @@ -0,0 +1,30 @@ +--- +covers: +docs: + - README.md + - docs/1.introduction.md +journeys: + - every-function +--- + +# Smoke + +The fixed journey every verdict runs, whatever the diff touches: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call everything it declares. + +## Sub-features + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module; every declared call, the event proxy and the two plain helpers (`ValidateSubscriptionRequest` refuses a zero amount with `InvalidSubscriptionRequestError`, `isTerminalSubscriptionStatus` answers true for `canceled` and false for `past_due`) answer through core, and the stub provider logs each call. + +## How to get to it (user POV) + +- A project with a billing module and a module that imports `@antelopejs/interface-subscriptions`. + +## 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. diff --git a/.agents/skills/verify-interface/features/statuses.md b/.agents/skills/verify-interface/features/statuses.md new file mode 100644 index 0000000..9fdb79f --- /dev/null +++ b/.agents/skills/verify-interface/features/statuses.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - README.md +journeys: + - status-vocabulary +--- + +# Statuses + +The four normalised statuses and why a subscription ended; only `canceled` is terminal. This journey contains the Format door of `src/types.ts`. + +## Sub-features + +- `status-vocabulary`: the packed `SUBSCRIPTION_STATUSES` lists trialing, active, past_due and canceled in lifecycle order, `SUBSCRIPTION_END_REASONS` lists requested and payment_failed, both are frozen so a consumer cannot extend them, and `isTerminalSubscriptionStatus` is true for `canceled` only. + +## How to get to it (user POV) + +- `if (isTerminalSubscriptionStatus(subscription.status)) stopBilling()` and `switch (subscription.status)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" statuses +``` + +## Gotchas + +- The strings are stored by providers and consumers, so a rename is a data migration. That is why the door exists. diff --git a/.agents/skills/verify-interface/features/validation-dates.md b/.agents/skills/verify-interface/features/validation-dates.md new file mode 100644 index 0000000..76b1e68 --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-dates.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-dates +--- + +# Validation dates + +Trials and billing anchors defer the first charge; they are mutually exclusive, must be finite, and with `now` must be strictly in the future. + +## Sub-features + +- `validate-dates`: with a fixed `now`, `ValidateSubscriptionRequest` accepts a trial end or billing anchor one millisecond ahead and refuses one equal to `now`, behind it, or in epoch seconds; a trial together with an anchor is refused before any date; without `now` a past date passes while `NaN` and infinities are refused; it returns `undefined` on success. + +## How to get to it (user POV) + +- `ValidateSubscriptionRequest({ ...request, trialEnd: Date.now() + 14 * DAY }, Date.now())`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-dates +``` + +## Gotchas + +- `now` is Unix epoch milliseconds, not seconds. diff --git a/.agents/skills/verify-interface/features/validation-fields.md b/.agents/skills/verify-interface/features/validation-fields.md new file mode 100644 index 0000000..08e61dd --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-fields.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-fields +--- + +# Validation fields + +`ValidateSubscriptionRequest` enforces the portable request fields so every implementation refuses the same shapes. + +## Sub-features + +- `validate-fields`: the packed `ValidateSubscriptionRequest` accepts the README request and refuses, with `InvalidSubscriptionRequestError` and the message naming the rule, an empty or missing reference, customer or payment method, an amount that is zero, negative, fractional, `NaN`, a string or missing, and a currency that is not three uppercase letters; it checks the format only, so `ZZZ` is accepted. + +## How to get to it (user POV) + +- `ValidateSubscriptionRequest(request, Date.now())` before a request reaches a provider. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-fields +``` + +## Gotchas + +- The error must be an `InvalidSubscriptionRequestError`, a `SubscriptionError` and an `Error`. diff --git a/.agents/skills/verify-interface/features/validation-interval.md b/.agents/skills/verify-interface/features/validation-interval.md new file mode 100644 index 0000000..a37146d --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-interval.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-interval +--- + +# Validation interval + +A period is `intervalCount` times `interval`; the validator refuses what is not a calendar unit and a positive whole count. + +## Sub-features + +- `validate-interval`: `ValidateSubscriptionRequest` accepts `day`, `week`, `month` and `year`, an omitted interval count and any positive whole one, and refuses an unknown or differently cased interval and a zero, negative, fractional, `NaN`, text or `null` count. + +## How to get to it (user POV) + +- `{ interval: "month", intervalCount: 3 }` for a quarterly plan. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-interval +``` + +## Gotchas + +- An omitted `intervalCount` means 1 and is accepted; an explicit `null` is not. diff --git a/.agents/skills/verify-interface/features/validation-order.md b/.agents/skills/verify-interface/features/validation-order.md new file mode 100644 index 0000000..500ebb1 --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-order.md @@ -0,0 +1,30 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md +journeys: + - validate-order +--- + +# Validation order + +The error names the first rule broken, so the order of the rules is part of the contract. + +## Sub-features + +- `validate-order`: a request that breaks every rule is repaired one rule at a time; `ValidateSubscriptionRequest` must name reference, customer, payment method, amount, currency, interval, interval count, then the trial and anchor conflict, then the date, as docs/4.implementing.md lists. + +## How to get to it (user POV) + +- A form that shows the first problem `ValidateSubscriptionRequest` reports. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-order +``` + +## Gotchas + +- Each step of the journey repairs exactly one rule, so a swapped pair shows as one wrong message. diff --git a/.agents/skills/verify-interface/features/vocabularies.md b/.agents/skills/verify-interface/features/vocabularies.md new file mode 100644 index 0000000..b52547d --- /dev/null +++ b/.agents/skills/verify-interface/features/vocabularies.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - wire-names +--- + +# Vocabularies + +The other strings consumers branch on and providers store: intervals, cancellation timings, event types, the metadata key marking subscription charges, and the one-year period cap. This journey contains the Format door of `src/types.ts`. + +## Sub-features + +- `wire-names`: the packed `SUBSCRIPTION_INTERVALS`, `CANCELLATION_TIMINGS` and `SUBSCRIPTION_EVENT_TYPES` hold the strings docs/ names in the documented order and are frozen, `SUBSCRIPTION_METADATA_KEY` is `antelopeSubscription` and `MAX_PORTABLE_PERIOD_DAYS` is 366. + +## How to get to it (user POV) + +- `if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) return;` in a payment webhook route. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" vocabularies +``` + +## Gotchas + +- The metadata key is how a payment webhook tells a subscription charge from a one-off payment. diff --git a/.agents/skills/verify-interface/journey-kit.ts b/.agents/skills/verify-interface/journey-kit.ts new file mode 100644 index 0000000..a8d32d5 --- /dev/null +++ b/.agents/skills/verify-interface/journey-kit.ts @@ -0,0 +1,126 @@ +import { equal, interrupted } from "@antelopejs/tooling-configs/verify"; +import type { CallRequest, Journey, JourneyContext } from "@antelopejs/tooling-configs/verify"; + +/** Printed by a packed-interface script: a JSON value the journey compares with the docs. */ +export type Printed = string | number | boolean | null | Printed[] | { [key: string]: Printed }; + +/** The request every validation scenario starts from: the README quick start's monthly EUR 19 plan. */ +export const BASE = `{ reference: "acct_1", customer: "cus_1", paymentMethod: "pm_1", amount: { value: 1900, currency: "EUR" }, interval: "month" }`; + +/** A fixed clock, so a date is in the future or the past whatever day the journey runs. */ +export const NOW = 1_800_000_000_000; + +export const PRELUDE = ` +const c = require(process.argv[1]); +const base = ${BASE}; +const NOW = ${NOW}; +const verdict = (request, now) => { + try { + c.ValidateSubscriptionRequest(request, now); + return "accepted"; + } catch (error) { + return [error.name, error.message, error instanceof c.InvalidSubscriptionRequestError, error instanceof c.SubscriptionError, error instanceof Error]; + } +}; +const refused = (request, now) => { + const result = verdict(request, now); + return Array.isArray(result) ? result[1] : result; +}; +`; + +/** Runs a script against the packed `dist/index.js` and checks the JSON it prints against what the docs state. */ +export function packed(title: string, script: string, expected: Printed, label: string): Journey { + return { + title, + entryPoint: "the packed interface required from a consumer", + async run(context: JourneyContext) { + const loaded = await context.command(label, "node", ["-e", `${PRELUDE}\n${script}`, `${context.repo}/dist/index.js`], { + cwd: context.repo, + }); + context.expect(loaded.folder, [ + { name: `${label} ran`, pass: loaded.exitCode === 0, detail: loaded.stderr.slice(-300) }, + equal(`${label} answers as docs/ states`, loaded.stdout.trim(), JSON.stringify(expected)), + ]); + }, + }; +} + +/** The header of a type probe: `Assert>` fails to compile when A and B are not assignable both ways. */ +export const PROBE_HEADER = ` +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Accepts = [Value] extends [Target] ? true : false; +type Not = T extends true ? false : true; +type Assert = T; +`; + +/** Compiles a probe against the packed declarations in a consumer's strict mode. */ +export function declared(title: string, probe: string, label: 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 type probe", file, `import type * as Api from "${context.repo}/dist/index";\n${PROBE_HEADER}\n${probe}`); + const compiled = await context.command("compile the type probe", "node", [ + `${context.repo}/node_modules/typescript/bin/tsc`, + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + `${context.repo}/node_modules/@types`, + "--types", + "node", + file, + ]); + context.expect(compiled.folder, [{ name: label, pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 600) }]); + }, + }; +} + +/** One consumer call through core: the member to call, its arguments, and the proxy path the stub provider must log. */ +export interface Call { + name: string; + args: NonNullable; +} + +export const REQUEST = { reference: "acct_1", customer: "cus_1", paymentMethod: "pm_1", amount: { value: 1900, currency: "EUR" }, interval: "month" }; + +/** Calls each member of the loaded interface through core with the arguments a backend passes, and checks the stub provider received them in order. */ +export function throughCore(title: string, calls: Call[]): Journey { + return { + title, + entryPoint: "a consumer module calling the packed interface in `ajs project dev`", + async run(context) { + const app = await context.dev("ajs project dev", context.fixture()); + for (const { name, args } of calls) { + const result = await context.call(`call ${name}`, app, { path: [name], args }); + context.expect(result.folder, [ + { name: `${name} resolved through core`, pass: result.ok, detail: JSON.stringify(result.value) }, + equal(`${name} reached the provider with its arguments in order`, result.value, { stub: `.:${name}`, args }), + ]); + } + const stopped = await context.stop("SIGINT", app); + context.expect(stopped.folder, [interrupted(stopped)]); + }, + }; +} + +export function suite(title: string, script: string, expected: Printed, label: string, env: { [name: string]: string } = {}): Journey { + return { + title, + entryPoint: "the conformance suite a provider author imports from the packed interface", + async run(context: JourneyContext) { + const loaded = await context.command(label, "node", ["-e", script], { cwd: context.repo, env }); + context.expect(loaded.folder, [ + { name: `${label} ran`, pass: loaded.exitCode === 0, detail: loaded.stderr.slice(-300) }, + equal(`${label} answers as README.md states`, loaded.stdout.trim(), JSON.stringify(expected)), + ]); + }, + }; +} 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/probes.ts b/.agents/skills/verify-interface/probes.ts new file mode 100644 index 0000000..0f11923 --- /dev/null +++ b/.agents/skills/verify-interface/probes.ts @@ -0,0 +1,52 @@ +export const SIGNATURES = ` +type Req = Api.SubscriptionRequest; +type Sub = Api.Subscription; +type _Create = Assert, [request: Req, idempotencyKey: string, provider?: string]>>; +type _CreateOut = Assert, Promise>>; +type _Get = Assert, [id: string, provider?: string]>>; +type _GetOut = Assert, Promise>>; +type _List = Assert, [customer: string, provider?: string]>>; +type _ListOut = Assert, Promise>>; +type _Cancel = Assert, [id: string, idempotencyKey: string, timing: Api.CancellationTiming, provider?: string]>>; +type _CancelOut = Assert, Promise>>; +type _Update = Assert, [id: string, paymentMethod: string, idempotencyKey: string, provider?: string]>>; +type _UpdateOut = Assert, Promise>>; +type _Validate = Assert void>>; +type _Terminal = Assert boolean>>; +type Handler = Parameters[1]; +type _Handler = Assert>; +type _HandlerOut = Assert, void | Promise>>; +type _HandlerIn = Assert, [event: Api.SubscriptionEvent]>>; +`; + +export const FIELD_TYPES = ` +type Sub = Api.Subscription; +type Req = Api.SubscriptionRequest; +type Required_ = Pick extends Required> ? true : false; +type _Required = Assert>; +type _OptionalTrial = Assert>>; +type _OptionalEnded = Assert>>; +type _OptionalReason = Assert>>; +type _OptionalPayment = Assert>>; +type _OptionalMetadata = Assert>>; +type _OptionalVendor = Assert>>; +type _Status = Assert>; +type _Interval = Assert>; +type _Reason = Assert, "requested" | "payment_failed">>; +type _Times = Assert>; +type _Timing = Assert>; +type _RequestRequired = Assert>; +type _RequestOptional = Assert, Required_, Required_, Required_, Required_], [false, false, false, false, false]>>; +type _NoVendorInRequest = Assert>; +type _VendorReadonly = Assert]: NonNullable[K] }, NonNullable> extends true ? false : true>>; +type _Event = Assert>; +type _EventPayment = Assert>>; +type _EventSubscription = Assert>; +type _Json = Assert>; +type _NoDate = Assert>>; +type _NoBuffer = Assert>>; +type _NoFunction = Assert void }>>>; +type _NoReadonlyArray = Assert>>; +interface Declared { name: string } +type _NoInterface = Assert>>; +`; diff --git a/.agents/skills/verify-interface/seeded-breaks/calls.patch b/.agents/skills/verify-interface/seeded-breaks/calls.patch new file mode 100644 index 0000000..b1db9e1 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/calls.patch @@ -0,0 +1,33 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..907c48d 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -84,15 +84,19 @@ export const ListSubscriptions = + * @throws SubscriptionNotFoundError if no subscription has that identifier + * @throws InvalidSubscriptionStateError if the subscription has already ended + */ +-export const CancelSubscription = +- InterfaceFunction< +- ( +- id: string, +- idempotencyKey: string, +- timing: CancellationTiming, +- provider?: string, +- ) => Promise +- >(); ++const Cancel = InterfaceFunction< ++ ( ++ id: string, ++ idempotencyKey: string, ++ timing: CancellationTiming, ++ provider?: string, ++ ) => Promise ++>(); ++export const CancelSubscription = (( ++ id: string, ++ idempotencyKey: string, ++ timing: CancellationTiming, ++) => Cancel(id, idempotencyKey, timing)) as typeof Cancel; + + /** + * Charges a different saved method from the next attempt onwards. diff --git a/.agents/skills/verify-interface/seeded-breaks/conformance.patch b/.agents/skills/verify-interface/seeded-breaks/conformance.patch new file mode 100644 index 0000000..883f0e8 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/conformance.patch @@ -0,0 +1,13 @@ +diff --git a/src/tests/support.ts b/src/tests/support.ts +index b97fbaa..75cd4f1 100644 +--- a/src/tests/support.ts ++++ b/src/tests/support.ts +@@ -73,7 +73,7 @@ export const EVENTS_OBSERVABLE = Boolean( + process.env.ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS, + ); + +-const CURRENCY = process.env.ANTELOPE_PAYMENT_TEST_CURRENCY || "EUR"; ++const CURRENCY = process.env.ANTELOPE_PAYMENT_TEST_CURRENCY || "USD"; + + export const CHARGEABLE = "requires ANTELOPE_PAYMENT_TEST_METHOD"; + export const DECLINES_LATER = diff --git a/.agents/skills/verify-interface/seeded-breaks/errors.patch b/.agents/skills/verify-interface/seeded-breaks/errors.patch new file mode 100644 index 0000000..85c3178 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/errors.patch @@ -0,0 +1,13 @@ +diff --git a/src/errors.ts b/src/errors.ts +index c168617..ef13628 100644 +--- a/src/errors.ts ++++ b/src/errors.ts +@@ -27,7 +27,7 @@ export class SubscriptionError extends Error { + */ + export class SubscriptionNotFoundError extends SubscriptionError { + constructor(id: string, providerCode?: string) { +- super(`No such subscription: ${id}`, providerCode); ++ super(`Subscription not found: ${id}`, providerCode); + this.name = "SubscriptionNotFoundError"; + } + } diff --git a/.agents/skills/verify-interface/seeded-breaks/field-types.patch b/.agents/skills/verify-interface/seeded-breaks/field-types.patch new file mode 100644 index 0000000..5e7fd62 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/field-types.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..9e3b2ec 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -158,7 +158,7 @@ export interface Subscription { + */ + currentPeriodEnd: number; + /** Unix epoch milliseconds. Present when the subscription began with a trial. */ +- trialEnd?: number; ++ trialEnd: number; + /** The subscription ends at `currentPeriodEnd` instead of renewing. */ + cancelAtPeriodEnd: boolean; + /** Unix epoch milliseconds. Present exactly when `status` is `canceled`. */ diff --git a/.agents/skills/verify-interface/seeded-breaks/signatures.patch b/.agents/skills/verify-interface/seeded-breaks/signatures.patch new file mode 100644 index 0000000..e21faa6 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/signatures.patch @@ -0,0 +1,13 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..cab8dff 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -66,7 +66,7 @@ export const GetSubscription = + */ + export const ListSubscriptions = + InterfaceFunction< +- (customer: string, provider?: string) => Promise ++ (customer: string, provider?: string) => Promise + >(); + + /** 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..1663643 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/smoke.patch @@ -0,0 +1,18 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..8628767 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -54,8 +54,11 @@ export const CreateSubscription = + * @returns The subscription + * @throws SubscriptionNotFoundError if no subscription has that identifier + */ +-export const GetSubscription = +- InterfaceFunction<(id: string, provider?: string) => Promise>(); ++export const GetSubscription = ( ++ id: string, ++ provider?: string, ++): Promise => ++ Promise.reject(new Error(`No provider for ${id} ${provider}`)); + + /** + * Lists a customer's subscriptions, ended ones included. diff --git a/.agents/skills/verify-interface/seeded-breaks/statuses.patch b/.agents/skills/verify-interface/seeded-breaks/statuses.patch new file mode 100644 index 0000000..399bb90 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/statuses.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..79fc22b 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -88,7 +88,7 @@ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number]; + export function isTerminalSubscriptionStatus( + status: SubscriptionStatus, + ): boolean { +- return status === "canceled"; ++ return status === "canceled" || status === "past_due"; + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch b/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch new file mode 100644 index 0000000..0801467 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..3f34eff 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -10,7 +10,7 @@ function positiveInteger(value: number | undefined): boolean { + function validTime(value: number | undefined, now?: number): boolean { + return ( + value === undefined || +- (Number.isFinite(value) && (now === undefined || value > now)) ++ (Number.isFinite(value) && (now === undefined || value >= now)) + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch b/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch new file mode 100644 index 0000000..5ddac63 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..1d0ae72 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -25,7 +25,7 @@ const RULES: Rule[] = [ + ? undefined + : "amount must be a positive integer of minor units", + (request) => +- /^[A-Z]{3}$/.test(request.amount?.currency ?? "") ++ /^[A-Za-z]{3}$/.test(request.amount?.currency ?? "") + ? undefined + : "amount.currency must be an uppercase ISO-4217 code", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch b/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch new file mode 100644 index 0000000..4bd1d07 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..5c014fa 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -34,7 +34,7 @@ const RULES: Rule[] = [ + : `interval must be one of ${SUBSCRIPTION_INTERVALS.join(", ")}`, + (request) => + request.intervalCount === undefined || +- positiveInteger(request.intervalCount) ++ request.intervalCount > 0 + ? undefined + : "intervalCount must be a positive integer", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-order.patch b/.agents/skills/verify-interface/seeded-breaks/validation-order.patch new file mode 100644 index 0000000..75873d2 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-order.patch @@ -0,0 +1,15 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..012a3b4 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -15,9 +15,9 @@ function validTime(value: number | undefined, now?: number): boolean { + } + + const RULES: Rule[] = [ ++ (request) => (request.customer ? undefined : "customer is required"), + (request) => + request.reference ? undefined : "reference must be a non-empty string", +- (request) => (request.customer ? undefined : "customer is required"), + (request) => + request.paymentMethod ? undefined : "paymentMethod is required", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch b/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch new file mode 100644 index 0000000..79dc38c --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..1ed43c2 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -50,7 +50,7 @@ export type SubscriptionInterval = (typeof SUBSCRIPTION_INTERVALS)[number]; + * Providers cap the period differently. A request for a longer one may be + * refused with {@link InvalidSubscriptionRequestError}. + */ +-export const MAX_PORTABLE_PERIOD_DAYS = 366; ++export const MAX_PORTABLE_PERIOD_DAYS = 31; + + /** + * Every value {@link SubscriptionStatus} can take, in lifecycle order. 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..f991702 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/contract.patch @@ -0,0 +1,9 @@ +diff --git a/src/index.ts b/src/index.ts +index c8fedcc..373e570 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1,4 +1,3 @@ + export * from "./errors"; + export * from "./functions"; + export * from "./types"; +-export * from "./validation"; 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..e933e27 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,19 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..b6b5482 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -58,7 +58,7 @@ export const MAX_PORTABLE_PERIOD_DAYS = 366; + export const SUBSCRIPTION_STATUSES = Object.freeze([ + "trialing", + "active", +- "past_due", ++ "pastdue", + "canceled", + ] as const); + +@@ -295,4 +295,4 @@ export type SubscriptionEventHandler = ( + * `payment.*` events reach your payment webhook route as well. Use this key to + * tell them from one-off payments. + */ +-export const SUBSCRIPTION_METADATA_KEY = "antelopeSubscription"; ++export const SUBSCRIPTION_METADATA_KEY = "subscription"; 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..32e4fcd --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/publish.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 19f16e9..1d084cc 100644 +--- a/package.json ++++ b/package.json +@@ -43,7 +43,7 @@ + "./package.json": "./package.json" + }, + "publishConfig": { +- "access": "public", ++ "access": "restricted", + "provenance": true + }, + "scripts": { diff --git a/.agents/skills/verify-interface/suite-scripts.ts b/.agents/skills/verify-interface/suite-scripts.ts new file mode 100644 index 0000000..30c8857 --- /dev/null +++ b/.agents/skills/verify-interface/suite-scripts.ts @@ -0,0 +1,33 @@ +export const CONFORMANCE_SCRIPT = ` +globalThis.after = () => undefined; +const own = (name) => require(require.resolve(name, { paths: [process.cwd()] })); +const support = own("@antelopejs/interface-subscriptions/tests/support"); +const seconds = (() => { try { support.assertEpochMillis(Math.floor(Date.now() / 1000), "when"); return "accepted"; } catch (error) { return error.name; } })(); +const millis = (() => { try { support.assertEpochMillis(Date.now(), "when"); return "accepted"; } catch (error) { return error.name; } })(); +const whole = support.inWholeSeconds(60_000) % 1000; +console.log(JSON.stringify({ + method: support.TEST_METHOD ?? null, + declines: support.TEST_METHOD_DECLINES_LATER ?? null, + events: support.EVENTS_OBSERVABLE, + currency: support.amount(5).currency, + value: support.amount(5).value, + absent: support.ABSENT_ID.startsWith("antelope-absent-"), + seconds, + millis, + whole, + day: support.DAY_MS, + trialDays: support.TRIAL_DAYS, + keyDiffers: support.key() !== support.key(), + referenceDiffers: support.reference() !== support.reference(), +})); +`; + +export const CONFORMANCE_FILES = ` +const fs = require("node:fs"); +const path = require("node:path"); +const dist = path.join(process.cwd(), "dist", "tests"); +const files = fs.readdirSync(dist).filter((name) => name.endsWith(".js")).sort(); +const manifest = require(path.join(process.cwd(), "package.json")); +console.log(JSON.stringify({ files, exported: manifest.exports["./tests/*"], shipped: manifest.files })); +`; + 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/validation-cases.ts b/.agents/skills/verify-interface/validation-cases.ts new file mode 100644 index 0000000..00a40b0 --- /dev/null +++ b/.agents/skills/verify-interface/validation-cases.ts @@ -0,0 +1,170 @@ +import type { Printed } from "./journey-kit.ts"; + +export const FIELD_CASES = ` +const cases = { + base: verdict(base), + referenceEmpty: refused({ ...base, reference: "" }), + referenceMissing: refused({ ...base, reference: undefined }), + customerEmpty: refused({ ...base, customer: "" }), + methodEmpty: refused({ ...base, paymentMethod: "" }), + amountZero: refused({ ...base, amount: { value: 0, currency: "EUR" } }), + amountNegative: refused({ ...base, amount: { value: -5, currency: "EUR" } }), + amountFraction: refused({ ...base, amount: { value: 19.5, currency: "EUR" } }), + amountNaN: refused({ ...base, amount: { value: NaN, currency: "EUR" } }), + amountText: refused({ ...base, amount: { value: "1900", currency: "EUR" } }), + amountMissing: refused({ ...base, amount: undefined }), + currencyLower: refused({ ...base, amount: { value: 1900, currency: "eur" } }), + currencyLong: refused({ ...base, amount: { value: 1900, currency: "EURO" } }), + currencyShort: refused({ ...base, amount: { value: 1900, currency: "EU" } }), + currencyEmpty: refused({ ...base, amount: { value: 1900, currency: "" } }), + currencyUnlisted: refused({ ...base, amount: { value: 1, currency: "ZZZ" } }), + smallestAmount: refused({ ...base, amount: { value: 1, currency: "JPY" } }), + errorKind: verdict({ ...base, reference: "" }), +}; +console.log(JSON.stringify(cases)); +`; + +export const FIELD_EXPECTED: Printed = { + base: "accepted", + referenceEmpty: "reference must be a non-empty string", + referenceMissing: "reference must be a non-empty string", + customerEmpty: "customer is required", + methodEmpty: "paymentMethod is required", + amountZero: "amount must be a positive integer of minor units", + amountNegative: "amount must be a positive integer of minor units", + amountFraction: "amount must be a positive integer of minor units", + amountNaN: "amount must be a positive integer of minor units", + amountText: "amount must be a positive integer of minor units", + amountMissing: "amount must be a positive integer of minor units", + currencyLower: "amount.currency must be an uppercase ISO-4217 code", + currencyLong: "amount.currency must be an uppercase ISO-4217 code", + currencyShort: "amount.currency must be an uppercase ISO-4217 code", + currencyEmpty: "amount.currency must be an uppercase ISO-4217 code", + currencyUnlisted: "accepted", + smallestAmount: "accepted", + errorKind: ["InvalidSubscriptionRequestError", "reference must be a non-empty string", true, true, true], +}; + +export const ORDER_CASES = ` +const broken = { reference: "", customer: "", paymentMethod: "", amount: { value: 0, currency: "x" }, interval: "x", intervalCount: 0, trialEnd: 1, billingAnchor: 2 }; +const order = []; +let request = broken; +for (const key of ["reference", "customer", "paymentMethod", "amount", "currency", "interval", "intervalCount", "trialEnd"]) { + order.push(refused(request, NOW)); + if (key === "reference") request = { ...request, reference: "r" }; + if (key === "customer") request = { ...request, customer: "c" }; + if (key === "paymentMethod") request = { ...request, paymentMethod: "m" }; + if (key === "amount") request = { ...request, amount: { value: 1, currency: "x" } }; + if (key === "currency") request = { ...request, amount: { value: 1, currency: "EUR" } }; + if (key === "interval") request = { ...request, interval: "day" }; + if (key === "intervalCount") request = { ...request, intervalCount: 1 }; + if (key === "trialEnd") request = { ...request, billingAnchor: undefined }; +} +order.push(refused(request, NOW)); +console.log(JSON.stringify(order)); +`; + +export const ORDER_EXPECTED: Printed = [ + "reference must be a non-empty string", + "customer is required", + "paymentMethod is required", + "amount must be a positive integer of minor units", + "amount.currency must be an uppercase ISO-4217 code", + "interval must be one of day, week, month, year", + "intervalCount must be a positive integer", + "trialEnd and billingAnchor are mutually exclusive", + "trialEnd must be a finite timestamp in the future", +]; + +export const INTERVAL_CASES = ` +const cases = { + day: refused({ ...base, interval: "day" }), + week: refused({ ...base, interval: "week" }), + month: refused({ ...base, interval: "month" }), + year: refused({ ...base, interval: "year" }), + fortnight: refused({ ...base, interval: "fortnight" }), + capitalised: refused({ ...base, interval: "Month" }), + empty: refused({ ...base, interval: "" }), + missing: refused({ ...base, interval: undefined }), + countOmitted: refused({ ...base, intervalCount: undefined }), + countOne: refused({ ...base, intervalCount: 1 }), + countTwelve: refused({ ...base, intervalCount: 12 }), + countZero: refused({ ...base, intervalCount: 0 }), + countNegative: refused({ ...base, intervalCount: -1 }), + countFraction: refused({ ...base, intervalCount: 1.5 }), + countNaN: refused({ ...base, intervalCount: NaN }), + countText: refused({ ...base, intervalCount: "2" }), + countNull: refused({ ...base, intervalCount: null }), + vocabulary: [...c.SUBSCRIPTION_INTERVALS], +}; +console.log(JSON.stringify(cases)); +`; + +export const INTERVAL_EXPECTED: Printed = { + day: "accepted", + week: "accepted", + month: "accepted", + year: "accepted", + fortnight: "interval must be one of day, week, month, year", + capitalised: "interval must be one of day, week, month, year", + empty: "interval must be one of day, week, month, year", + missing: "interval must be one of day, week, month, year", + countOmitted: "accepted", + countOne: "accepted", + countTwelve: "accepted", + countZero: "intervalCount must be a positive integer", + countNegative: "intervalCount must be a positive integer", + countFraction: "intervalCount must be a positive integer", + countNaN: "intervalCount must be a positive integer", + countText: "intervalCount must be a positive integer", + countNull: "intervalCount must be a positive integer", + vocabulary: ["day", "week", "month", "year"], +}; + +export const DATE_CASES = ` +const later = NOW + 86_400_000; +const cases = { + trialFuture: refused({ ...base, trialEnd: later }, NOW), + trialNow: refused({ ...base, trialEnd: NOW }, NOW), + trialOneMsLater: refused({ ...base, trialEnd: NOW + 1 }, NOW), + trialOneMsEarlier: refused({ ...base, trialEnd: NOW - 1 }, NOW), + anchorFuture: refused({ ...base, billingAnchor: later }, NOW), + anchorNow: refused({ ...base, billingAnchor: NOW }, NOW), + anchorPast: refused({ ...base, billingAnchor: NOW - 86_400_000 }, NOW), + trialInSeconds: refused({ ...base, trialEnd: Math.floor(NOW / 1000) + 86_400 }, NOW), + bothFuture: refused({ ...base, trialEnd: later, billingAnchor: later }, NOW), + bothWithoutClock: refused({ ...base, trialEnd: later, billingAnchor: later }), + bothPast: refused({ ...base, trialEnd: 1, billingAnchor: 2 }, NOW), + pastWithoutClock: refused({ ...base, trialEnd: 1 }), + anchorPastWithoutClock: refused({ ...base, billingAnchor: 1 }), + trialNaN: refused({ ...base, trialEnd: NaN }), + trialInfinite: refused({ ...base, trialEnd: Infinity }), + anchorNaN: refused({ ...base, billingAnchor: NaN }, NOW), + anchorInfinite: refused({ ...base, billingAnchor: -Infinity }), + neither: refused(base, NOW), + returned: c.ValidateSubscriptionRequest(base, NOW) === undefined, +}; +console.log(JSON.stringify(cases)); +`; + +export const DATE_EXPECTED: Printed = { + trialFuture: "accepted", + trialNow: "trialEnd must be a finite timestamp in the future", + trialOneMsLater: "accepted", + trialOneMsEarlier: "trialEnd must be a finite timestamp in the future", + anchorFuture: "accepted", + anchorNow: "billingAnchor must be a finite timestamp in the future", + anchorPast: "billingAnchor must be a finite timestamp in the future", + trialInSeconds: "trialEnd must be a finite timestamp in the future", + bothFuture: "trialEnd and billingAnchor are mutually exclusive", + bothWithoutClock: "trialEnd and billingAnchor are mutually exclusive", + bothPast: "trialEnd and billingAnchor are mutually exclusive", + pastWithoutClock: "accepted", + anchorPastWithoutClock: "accepted", + trialNaN: "trialEnd must be a finite timestamp in the future", + trialInfinite: "trialEnd must be a finite timestamp in the future", + anchorNaN: "billingAnchor must be a finite timestamp in the future", + anchorInfinite: "billingAnchor must be a finite timestamp in the future", + neither: "accepted", + returned: true, +}; diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts new file mode 100644 index 0000000..7fc5d04 --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,214 @@ +import type { InterfaceHooks, VerifyConfig } from "@antelopejs/tooling-configs/verify"; + +import type * as SubscriptionsInterface from "../../../src/index.ts"; +import { declared, packed, REQUEST, suite, throughCore } from "./journey-kit.ts"; +import { DATE_CASES, DATE_EXPECTED, FIELD_CASES, FIELD_EXPECTED, INTERVAL_CASES, INTERVAL_EXPECTED, ORDER_CASES, ORDER_EXPECTED } from "./validation-cases.ts"; +import { ERROR_EXPECTED, ERROR_SCRIPT, STATUS_EXPECTED, STATUS_SCRIPT, VOCABULARY_EXPECTED, VOCABULARY_SCRIPT } from "./vocabulary-cases.ts"; +import { FIELD_TYPES, SIGNATURES } from "./probes.ts"; +import { CONFORMANCE_FILES, CONFORMANCE_SCRIPT } from "./suite-scripts.ts"; + +const EUR_19 = { value: 1900, currency: "EUR" }; + +function errorFacts(error: SubscriptionsInterface.SubscriptionError, api: typeof SubscriptionsInterface): [string, string, string | null, boolean] { + return [error.name, error.message, error.providerCode ?? null, error instanceof api.SubscriptionError]; +} + +const CALLS: NonNullable["calls"]> = { + ".:ValidateSubscriptionRequest": { + async call(api) { + try { + api.ValidateSubscriptionRequest({ + reference: "acct_1", + customer: "cus_1", + paymentMethod: "pm_1", + amount: { value: 0, currency: "EUR" }, + interval: "month", + }); + return "accepted"; + } catch (error) { + return error instanceof Error ? error.name : "thrown"; + } + }, + expect: "InvalidSubscriptionRequestError", + }, + ".:SubscriptionError": { + call: (api) => Promise.resolve(errorFacts(new api.SubscriptionError("could not call", "network"), api)), + expect: ["SubscriptionError", "could not call", "network", true], + }, + ".:SubscriptionNotFoundError": { + call: (api) => Promise.resolve(errorFacts(new api.SubscriptionNotFoundError("sub_1", "missing"), api)), + expect: ["SubscriptionNotFoundError", "No such subscription: sub_1", "missing", true], + }, + ".:InvalidSubscriptionRequestError": { + call: (api) => Promise.resolve(errorFacts(new api.InvalidSubscriptionRequestError("bad amount", "invalid"), api)), + expect: ["InvalidSubscriptionRequestError", "bad amount", "invalid", true], + }, + ".:InvalidSubscriptionStateError": { + call: (api) => Promise.resolve(errorFacts(new api.InvalidSubscriptionStateError("ended", "canceled", "state"), api)), + expect: ["InvalidSubscriptionStateError", "ended", "state", true], + }, + ".:SubscriptionPaymentError": { + call: (api) => + Promise.resolve( + errorFacts(new api.SubscriptionPaymentError({ + id: "pay_1", + status: "failed", + amount: EUR_19, + amountCaptured: { value: 0, currency: "EUR" }, + amountRefunded: { value: 0, currency: "EUR" }, + reference: "acct_1", + createdAt: 0, + }, "declined"), api), + ), + expect: ["SubscriptionPaymentError", "The first period could not be charged: payment pay_1 is failed", "declined", true], + }, + ".:isTerminalSubscriptionStatus": { + call: (api) => Promise.resolve([api.isTerminalSubscriptionStatus("canceled"), api.isTerminalSubscriptionStatus("past_due")]), + expect: [true, false], + }, +}; + +const config: VerifyConfig = { + doors: { + data: null, + format: { + globs: ["src/types.ts"], + funnels: [], + containments: ["statuses/status-vocabulary", "vocabularies/wire-names"], + why: "the status, end reason, interval, timing and event strings and the antelopeSubscription metadata key are what providers store in their subscription rows and payment metadata, and what consumers branch on", + }, + contract: { + globs: ["src/index.ts", "src/functions.ts", "package.json"], + funnels: [], + containments: [], + why: "the five calls, the event proxy, the validator, the error classes and the vocabularies are what every billing module and provider 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-subscriptions on npm", + }, + }, + interface: { + calls: CALLS, + }, + journeys: { + "calls-through-core": throughCore("the five calls reach the provider with their arguments in order", [ + { name: "CreateSubscription", args: [REQUEST, "subscribe-acct_1"] }, + { name: "CreateSubscription", args: [REQUEST, "subscribe-acct_1", "eu-account"] }, + { name: "GetSubscription", args: ["sub_1"] }, + { name: "GetSubscription", args: ["sub_1", "eu-account"] }, + { name: "ListSubscriptions", args: ["cus_1"] }, + { name: "ListSubscriptions", args: ["cus_1", "eu-account"] }, + { name: "CancelSubscription", args: ["sub_1", "cancel-period-end-sub_1", "period_end"] }, + { name: "CancelSubscription", args: ["sub_1", "cancel-now-sub_1", "now", "eu-account"] }, + { name: "UpdateSubscriptionPaymentMethod", args: ["sub_1", "pm_2", "switch-sub_1"] }, + { name: "UpdateSubscriptionPaymentMethod", args: ["sub_1", "pm_2", "switch-sub_1", "eu-account"] }, + ]), + signatures: declared("every call and the validator have the documented signatures", SIGNATURES, "every export has the documented signature"), + "field-types": declared("Subscription, SubscriptionRequest, SubscriptionEvent and JsonObject have the documented shapes", FIELD_TYPES, "every type has the documented shape"), + "validate-fields": packed( + "ValidateSubscriptionRequest refuses an empty reference, customer or method, a non-positive amount and a malformed currency", + FIELD_CASES, + FIELD_EXPECTED, + "field rules", + ), + "validate-order": packed( + "ValidateSubscriptionRequest names the first rule broken, in the order docs/4.implementing.md lists", + ORDER_CASES, + ORDER_EXPECTED, + "rule order", + ), + "validate-interval": packed( + "ValidateSubscriptionRequest accepts day, week, month and year and a positive whole interval count", + INTERVAL_CASES, + INTERVAL_EXPECTED, + "interval rules", + ), + "validate-dates": packed( + "ValidateSubscriptionRequest refuses both a trial and an anchor, and past or non-finite dates", + DATE_CASES, + DATE_EXPECTED, + "date rules", + ), + "status-vocabulary": packed( + "the four statuses and two end reasons are frozen and only canceled is terminal", + STATUS_SCRIPT, + STATUS_EXPECTED, + "status vocabulary", + ), + "wire-names": packed( + "intervals, timings, event types and the metadata key are the strings docs/ names", + VOCABULARY_SCRIPT, + VOCABULARY_EXPECTED, + "wire names", + ), + "error-classes": packed( + "the error classes carry the names, messages, codes, status and declined payment docs/2.subscriptions.md lists", + ERROR_SCRIPT, + ERROR_EXPECTED, + "error classes", + ), + "suite-helpers": suite( + "the shipped conformance helpers read the environment variables README.md lists", + CONFORMANCE_SCRIPT, + { + method: "tok_ok", + declines: "tok_later", + events: true, + currency: "JPY", + value: 5, + absent: true, + seconds: "AssertionError", + millis: "accepted", + whole: 0, + day: 86_400_000, + trialDays: 14, + keyDiffers: true, + referenceDiffers: true, + }, + "suite helpers with every variable set", + { + ANTELOPE_PAYMENT_TEST_METHOD: "tok_ok", + ANTELOPE_SUBSCRIPTIONS_TEST_METHOD_DECLINES_LATER: "tok_later", + ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS: "1", + ANTELOPE_PAYMENT_TEST_CURRENCY: "JPY", + }, + ), + "suite-defaults": suite( + "without the variables the suite defaults to EUR and skips what needs a provider token", + CONFORMANCE_SCRIPT, + { + method: null, + declines: null, + events: false, + currency: "EUR", + value: 5, + absent: true, + seconds: "AssertionError", + millis: "accepted", + whole: 0, + day: 86_400_000, + trialDays: 14, + keyDiffers: true, + referenceDiffers: true, + }, + "suite helpers with no variable set", + ), + "suite-files": suite( + "the package ships dist/tests and exports it as ./tests/*", + CONFORMANCE_FILES, + { + files: ["subscriptions-changes.test.js", "subscriptions.test.js", "support.js"], + exported: { types: "./dist/tests/*.d.ts", default: "./dist/tests/*.js" }, + shipped: ["dist", "docs", "skills"], + }, + "shipped suite files", + ), + }, +}; + +export default config; diff --git a/.agents/skills/verify-interface/vocabulary-cases.ts b/.agents/skills/verify-interface/vocabulary-cases.ts new file mode 100644 index 0000000..fc7cda9 --- /dev/null +++ b/.agents/skills/verify-interface/vocabulary-cases.ts @@ -0,0 +1,105 @@ +import type { Printed } from "./journey-kit.ts"; + +export const STATUS_SCRIPT = ` +const mutation = (list) => { try { list.push("invented"); return "pushed"; } catch (error) { return error.name; } }; +console.log(JSON.stringify({ + statuses: [...c.SUBSCRIPTION_STATUSES], + terminal: c.SUBSCRIPTION_STATUSES.map((status) => [status, c.isTerminalSubscriptionStatus(status)]), + reasons: [...c.SUBSCRIPTION_END_REASONS], + frozen: [Object.isFrozen(c.SUBSCRIPTION_STATUSES), Object.isFrozen(c.SUBSCRIPTION_END_REASONS)], + push: [mutation(c.SUBSCRIPTION_STATUSES), mutation(c.SUBSCRIPTION_END_REASONS)], + afterPush: [...c.SUBSCRIPTION_STATUSES].length, + unknown: c.isTerminalSubscriptionStatus("deleted"), +})); +`; + +export const STATUS_EXPECTED: Printed = { + statuses: ["trialing", "active", "past_due", "canceled"], + terminal: [ + ["trialing", false], + ["active", false], + ["past_due", false], + ["canceled", true], + ], + reasons: ["requested", "payment_failed"], + frozen: [true, true], + push: ["TypeError", "TypeError"], + afterPush: 4, + unknown: false, +}; + +export const VOCABULARY_SCRIPT = ` +const mutation = (list) => { try { list.push("invented"); return "pushed"; } catch (error) { return error.name; } }; +console.log(JSON.stringify({ + intervals: [...c.SUBSCRIPTION_INTERVALS], + timings: [...c.CANCELLATION_TIMINGS], + events: [...c.SUBSCRIPTION_EVENT_TYPES], + metadataKey: c.SUBSCRIPTION_METADATA_KEY, + maxPeriodDays: c.MAX_PORTABLE_PERIOD_DAYS, + frozen: [c.SUBSCRIPTION_INTERVALS, c.CANCELLATION_TIMINGS, c.SUBSCRIPTION_EVENT_TYPES].map(Object.isFrozen), + push: [c.SUBSCRIPTION_INTERVALS, c.CANCELLATION_TIMINGS, c.SUBSCRIPTION_EVENT_TYPES].map(mutation), +})); +`; + +export const VOCABULARY_EXPECTED: Printed = { + intervals: ["day", "week", "month", "year"], + timings: ["now", "period_end"], + events: ["subscription.created", "subscription.payment_succeeded", "subscription.payment_failed", "subscription.canceled"], + metadataKey: "antelopeSubscription", + maxPeriodDays: 366, + frozen: [true, true, true], + push: ["TypeError", "TypeError", "TypeError"], +}; + +export const ERROR_SCRIPT = ` +const declined = { id: "pay_1", status: "failed", amount: { value: 1900, currency: "EUR" } }; +const describe = (error) => ({ + name: error.name, + message: error.message, + providerCode: error.providerCode ?? null, + base: error instanceof c.SubscriptionError, + error: error instanceof Error, +}); +const missing = new c.SubscriptionNotFoundError("sub_1", "resource_missing"); +const invalid = new c.InvalidSubscriptionRequestError("amount must be positive", "parameter_invalid"); +const state = new c.InvalidSubscriptionStateError("already ended", "canceled", "state_conflict"); +const stateBare = new c.InvalidSubscriptionStateError("already ended"); +const payment = new c.SubscriptionPaymentError(declined, "card_declined"); +const paymentBare = new c.SubscriptionPaymentError({ ...declined, status: "canceled" }); +const root = new c.SubscriptionError("could not call", "network"); +console.log(JSON.stringify({ + base: describe(root), + missing: describe(missing), + missingBare: new c.SubscriptionNotFoundError("sub_2").providerCode ?? null, + invalid: describe(invalid), + siblings: [missing instanceof c.InvalidSubscriptionRequestError, invalid instanceof c.SubscriptionNotFoundError, state instanceof c.SubscriptionPaymentError], + state: { ...describe(state), status: state.status }, + stateBare: { status: stateBare.status ?? null, providerCode: stateBare.providerCode ?? null }, + payment: { ...describe(payment), carried: payment.payment === declined, status: payment.payment.status }, + paymentBare: { message: paymentBare.message, providerCode: paymentBare.providerCode ?? null }, +})); +`; + +export const described = (name: string, message: string, providerCode: string | null) => ({ + name, + message, + providerCode, + base: true, + error: true, +}); + +export const ERROR_EXPECTED: Printed = { + base: described("SubscriptionError", "could not call", "network"), + missing: described("SubscriptionNotFoundError", "No such subscription: sub_1", "resource_missing"), + missingBare: null, + invalid: described("InvalidSubscriptionRequestError", "amount must be positive", "parameter_invalid"), + siblings: [false, false, false], + state: { ...described("InvalidSubscriptionStateError", "already ended", "state_conflict"), status: "canceled" }, + stateBare: { status: null, providerCode: null }, + payment: { + ...described("SubscriptionPaymentError", "The first period could not be charged: payment pay_1 is failed", "card_declined"), + carried: true, + status: "failed", + }, + paymentBare: { message: "The first period could not be charged: payment pay_1 is canceled", providerCode: null }, +}; 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index affb25a..9ee1268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,15 @@ permissions: jobs: ci: - uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1 + uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1-campaign with: docs-files: | README.md docs docs-typescript-project: docs/tsconfig.json + renovate-sync: false permissions: - contents: write - actions: write + contents: read checks: if: always() @@ -29,3 +29,10 @@ jobs: steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') run: exit 1 + renovate-sync: + if: >- + github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + actions: write + uses: AntelopeJS/.github/.github/workflows/ci-renovate-sync.yml@v1 diff --git a/AGENTS.md b/AGENTS.md index 6469976..e5e19e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,9 @@ The AntelopeJS interface for recurring billing of a saved payment method, publis ## Guardrails -Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +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. @@ -44,9 +46,9 @@ Hold every test you write or keep to this checklist: ### Cleanup proof -A behaviour-preserving cleanup PR shows `antelopejs-api-diff ` passing. Published declarations and built runtime exports stay unchanged; pass `--allow-added ` (repeatable, e.g. `'**/internal/**'`) to let added entries under that glob pass when their module is `@internal`, and copy its "Added (allowed)" list. A comment-only PR shows `antelopejs-comment-proof ` passing instead of a verifier. Both compare committed HEAD. +A behaviour-preserving cleanup PR shows `antelopejs-api-diff ` passing. Published declarations, built runtime exports and the names and signatures a DMS frontend module auto-imports stay unchanged; pass `--allow-added ` (repeatable, e.g. `'**/internal/**'`) to let added entries under that glob pass when their module is `@internal`, and copy its "Added (allowed)" list. Every export under a directory that `dms.frontend.build.ts` auto-imports is published, `@internal` or not; a helper that must stay private goes under `app/build/`. A comment-only PR shows `antelopejs-comment-proof ` passing instead of a verifier. Both compare committed HEAD. -Copy counts from the proof's base and HEAD table. Never type counts by hand. Keep the command and the committed head beside the copied output. The table counts a lint directive once per rule it names, inline directives included. +Copy the proof's whole output into the PR body, down to its closing `` line, which stamps the command, base, committed head and a digest. Never type counts by hand. List the lint findings a PR leaves in place by pasting the block `antelopejs-body-check --findings` prints and replacing each ``. `antelopejs-body-check ` fails an edited or stale proof block, proof output outside one, and a findings line that is not a live diagnostic at HEAD; counts in prose are not checked, so leave them out. The table counts a lint directive once per rule it names, inline directives included. Pass `--probe ` to api-diff when changing receivers, positional calls, or modules that no published entry reaches. The same captured script runs against both worktree roots, supplied as `argv[2]`, and prints JSON observations. Matching metadata alone does not prove those behaviours. @@ -58,11 +60,7 @@ Verify a boundary against the imports a developer or agent would plausibly write ### Suppression budget -`guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. - -### Debt - -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, run `pnpm run ratchet:update` and commit the lowered file. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. +`guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. ### What enforces what @@ -76,25 +74,34 @@ Verify a boundary against the imports a developer or agent would plausibly write | Tests import the code under test from local source, not their own package name | `antelopejs/no-self-import-in-tests` | | No assertion that restates a constant | `antelopejs/no-constant-value-assertion` | | No test whose only check is "is a function" | `antelopejs/no-typeof-function-only-test` | +| String-keyed object tables are read through own keys only | `antelopejs/no-inherited-key-lookup` | +| A type guard checks each member it narrows to; keys with `Object.hasOwn` | `antelopejs/no-presence-only-type-guard` | +| A type guard checks the nested elements and values it claims | review; no tool | | Params ≤ 5, function ≤ 120 lines, file ≤ 500 lines, depth ≤ 4, complexity ≤ 20 | `eslint/max-*`, `eslint/complexity` | -| Cleanup preserves declarations and built runtime export metadata | `antelopejs-api-diff ` | +| Cleanup preserves declarations, runtime exports and DMS auto-imports | `antelopejs-api-diff ` | | Comment-only changes preserve JavaScript emit | `antelopejs-comment-proof ` | -| PR-body counts come from tracked code files | api-diff and comment-proof tables | +| PR-body proof tables are copied unedited from the current head | `antelopejs-body-check ` | +| PR-body findings-left lines are live diagnostics at HEAD | `antelopejs-body-check ` | | Receiver, positional and unpublished-module behaviour | `antelopejs-api-diff --probe ` | | Directive and `SAFETY:` reasons are true of the code | review; no tool | | Evasion-only and patched-internal findings go under Attention | review; no tool | | No-upward rules reject same-tier peers | review and import probes | | No `@ts-ignore` | `typescript/ban-ts-comment` | +| No once-used type parameter standing in for `unknown` | `typescript/no-unnecessary-type-parameters` | +| No `any` passed, assigned or returned where a type is declared | `typescript/no-unsafe-{argument,assignment,return}` | | Files ≤ 20,000 bytes, tests included | `antelopejs-check-size` (pre-commit) | | Suppressions stay within `guardrails.suppressions.json` | `antelopejs-suppressions` (pre-commit, checks) | +| No lint config switches off a rule to stay under the budget | `antelopejs-suppressions` (pre-commit, checks) | | No added directive disables the `SAFETY:`, comment or test rules | `antelopejs-suppressions` (pre-commit, checks) | | An added suppression gives a specific reason | `antelopejs-suppressions` (pre-commit, checks) | +| A directive in a file a change touches suppresses something and gives a reason | `antelopejs-suppressions` (pre-commit, checks) | | A block disable is closed in its file | `antelopejs-suppressions` (pre-commit, checks) | | Types check | `tsc` (pre-push) | | No dead files, exports, or dependencies | knip | | Managed files match the pack | `antelopejs-sync --check` | +| A CI gate reads `needs.` only of jobs in its `needs` | `antelopejs-sync --check` | | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | +| A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | -| Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/README.md b/README.md index 7d4a7a4..c9ca5b4 100644 --- a/README.md +++ b/README.md @@ -139,28 +139,35 @@ off-session before `CreateSubscription` resolves. Adapt those amounts to your billing policy. Register an idempotent handler for subsequent lifecycle events: ```ts -import { SubscriptionEvents } from "@antelopejs/interface-subscriptions"; +import { + SubscriptionEvents, + type Subscription, +} from "@antelopejs/interface-subscriptions"; + +declare function grantOrRevokeAccess(subscription: Subscription): Promise; SubscriptionEvents.register("billing", async (event) => { await grantOrRevokeAccess(event.subscription); }); ``` -`grantOrRevokeAccess` stands for your application's access policy. Deduplicate +`grantOrRevokeAccess` stands for your application's access policy, and its `declare` line only gives it a type. Deduplicate events by `event.id` as described in [Events](./docs/3.events.md). ## Compatibility - + + +| Package | Supported | Tested with | +| ------------------------------- | ---------------- | ----------- | +| `@antelopejs/interface-core` | `>=0.0.5 <1.0.0` | `0.1.1` | +| `@antelopejs/interface-payment` | `>=0.0.2 <1.0.0` | `0.0.2` | -| Package | Range | -| ------------------------------- | ---------------- | -| `@antelopejs/interface-core` | `>=0.0.5 <1.0.0` | -| `@antelopejs/interface-payment` | `>=0.0.2 <1.0.0` | + These are the peer requirements in `package.json`. Development uses the same payment-interface semver range. Check your implementing module's requirements -too. `@antelopejs/core` 1.16.0 is a development test runner, not a runtime peer. +too. `@antelopejs/core` is a development test runner, not a runtime peer. ## Conventions diff --git a/docs/2.subscriptions.md b/docs/2.subscriptions.md index 723beb8..7eb0154 100644 --- a/docs/2.subscriptions.md +++ b/docs/2.subscriptions.md @@ -20,17 +20,26 @@ taken from a payer who is there. That first payment is an ordinary one-off; the subscription starts after it, and its first period is a separate charge. ```ts -const subscription = await CreateSubscription( - { - reference: accountId, - customer, - paymentMethod: method.id, - amount: { value: 1900, currency: "EUR" }, - interval: "month", - intervalCount: 1, - }, - `subscribe-${accountId}`, -); +import type { PaymentMethod } from "@antelopejs/interface-payment"; +import { CreateSubscription } from "@antelopejs/interface-subscriptions"; + +export async function subscribe( + accountId: string, + customer: string, + method: PaymentMethod, +) { + return await CreateSubscription( + { + reference: accountId, + customer, + paymentMethod: method.id, + amount: { value: 1900, currency: "EUR" }, + interval: "month", + intervalCount: 1, + }, + `subscribe-${accountId}`, + ); +} ``` Without a trial or a billing anchor, the first period is charged off-session @@ -102,11 +111,22 @@ If the saved method will not work again — an expired card — take a new manda on-session and switch to it: ```ts -await UpdateSubscriptionPaymentMethod( - subscription.id, - newMethodId, - idempotencyKey, -); +import { + UpdateSubscriptionPaymentMethod, + type Subscription, +} from "@antelopejs/interface-subscriptions"; + +export async function switchMethod( + subscription: Subscription, + newMethodId: string, + idempotencyKey: string, +) { + await UpdateSubscriptionPaymentMethod( + subscription.id, + newMethodId, + idempotencyKey, + ); +} ``` Nothing is charged by the switch. The next scheduled attempt uses the new method. @@ -114,8 +134,12 @@ Nothing is charged by the switch. The next scheduled attempt uses the new method ## Cancelling ```ts -await CancelSubscription(id, `cancel-period-end-${id}`, "period_end"); // run out the paid period -await CancelSubscription(id, `cancel-now-${id}`, "now"); // end immediately +import { CancelSubscription } from "@antelopejs/interface-subscriptions"; + +export async function cancel(id: string) { + await CancelSubscription(id, `cancel-period-end-${id}`, "period_end"); // run out the paid period + await CancelSubscription(id, `cancel-now-${id}`, "now"); // end immediately +} ``` Use distinct keys for distinct operations. Reuse a key only when retrying the diff --git a/docs/3.events.md b/docs/3.events.md index f046c4c..95d4a6c 100644 --- a/docs/3.events.md +++ b/docs/3.events.md @@ -24,7 +24,13 @@ event: the caller made those changes and already knows. ## Receiving them ```ts -import { SubscriptionEvents } from "@antelopejs/interface-subscriptions"; +import { + SubscriptionEvents, + type SubscriptionEvent, +} from "@antelopejs/interface-subscriptions"; + +declare function alreadyHandled(eventId: string): Promise; +declare function applyToAccount(event: SubscriptionEvent): Promise; SubscriptionEvents.register("billing", async (event) => { if (await alreadyHandled(event.id)) return; @@ -68,9 +74,17 @@ Every subscription charge is an ordinary payment-interface payment, so its the subscription's identifier: ```ts -const event = await VerifyWebhook(request); -if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) { - return; // a subscription charge: handled by the subscription event +import { + VerifyWebhook, + type WebhookRequest, +} from "@antelopejs/interface-payment"; +import { SUBSCRIPTION_METADATA_KEY } from "@antelopejs/interface-subscriptions"; + +export async function handlePaymentWebhook(request: WebhookRequest) { + const event = await VerifyWebhook(request); + if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) { + return; // a subscription charge: handled by the subscription event + } } ``` diff --git a/docs/4.implementing.md b/docs/4.implementing.md index afa2d44..bfb45a7 100644 --- a/docs/4.implementing.md +++ b/docs/4.implementing.md @@ -61,7 +61,7 @@ own: that would put a mandatory database and scheduler into a payment module. The calls are declared directly as the public names, so the whole module is the declaration: -```ts +```ts fragment import * as subscriptions from "@antelopejs/interface-subscriptions"; import { ImplementInterface } from "@antelopejs/interface-core"; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index c3e8775..fff0882 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", "noEmit": true, "composite": false, "incremental": false, diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json deleted file mode 100644 index 8286b65..0000000 --- a/guardrails.ratchet.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "rules": { - "anti-slop/require-safety-comment-for-type-assertion": 1 - }, - "oversizeFiles": {} -} diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 6936c18..4618231 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,6 +1,7 @@ { "rules": {}, - "tsExpectError": 0, + "tsExpectError": 1, "tsIgnore": 0, - "safety": 0 + "safety": 0, + "config": {} } diff --git a/lefthook.yml b/lefthook.yml index ff792b5..779c267 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,9 +8,6 @@ pre-commit: run: pnpm exec oxlint --no-error-on-unmatched-pattern {staged_files} antelopejs-size: run: pnpm exec antelopejs-check-size --staged {staged_files} - antelopejs-ratchet: - glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" - run: pnpm exec antelopejs-ratchet --staged {staged_files} antelopejs-suppressions: glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" run: pnpm exec antelopejs-suppressions --staged diff --git a/oxlint.config.ts b/oxlint.config.ts index fbd8c24..b654d03 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "oxlint"; -import { antelopePreset } from "@antelopejs/tooling-configs/oxc/lint"; +import { + ANTELOPE_IGNORE_PATTERNS, + antelopePreset, +} from "@antelopejs/tooling-configs/oxc/lint"; export default defineConfig({ extends: [antelopePreset()], + ignorePatterns: ANTELOPE_IGNORE_PATTERNS, options: { typeAware: true }, }); diff --git a/package.json b/package.json index bfb6731..19f16e9 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,12 @@ ], "main": "dist/index.js", "types": "dist/index.d.ts", + "imports": { + "#built": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -53,9 +59,7 @@ "prepare": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && command -v lefthook >/dev/null 2>&1; then lefthook install; fi", "checks": "antelopejs-checks", "size": "antelopejs-check-size", - "ratchet": "antelopejs-ratchet", "sync": "antelopejs-sync", - "ratchet:update": "antelopejs-ratchet --update", "sync:check": "antelopejs-sync --check", "typecheck": "tsc --noEmit", "suppressions": "antelopejs-suppressions", @@ -65,7 +69,7 @@ "@antelopejs/core": "1.16.0", "@antelopejs/interface-core": ">=0.0.5 <1.0.0", "@antelopejs/interface-payment": ">=0.0.2 <1.0.0", - "@antelopejs/tooling-configs": "^0.2.11", + "@antelopejs/tooling-configs": "^0.2.17", "@types/mocha": "^10.0.10", "@types/node": "^22.19.15", "eslint-plugin-perfectionist": "^5.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e28c00..a87b292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: '>=0.0.2 <1.0.0' version: 0.0.2(@antelopejs/interface-core@0.1.1) '@antelopejs/tooling-configs': - specifier: ^0.2.11 - version: 0.2.11(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003)) + specifier: ^0.2.17 + version: 0.2.17(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003)) '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -75,8 +75,8 @@ packages: peerDependencies: '@antelopejs/interface-core': '>=0.0.3 <1.0.0' - '@antelopejs/tooling-configs@0.2.11': - resolution: {integrity: sha512-11reej4rSQuPEQqQgef60SyQZ8XGWlUSshD4RXLXPBpyFGVQmOqagKmj1zTg8QAktzwxxokhk/8JrIyChIv3LA==} + '@antelopejs/tooling-configs@0.2.17': + resolution: {integrity: sha512-8fwh7xQPzazP/l/ivEwRKpfN8qShV9+PRizpI9cBewe2TwoRPu4n95w6sYSlygiRmm0HxLJYZFhmSPRgtiNo1Q==} engines: {node: '>=22.18.0'} hasBin: true peerDependencies: @@ -2511,7 +2511,7 @@ snapshots: dependencies: '@antelopejs/interface-core': 0.1.1 - '@antelopejs/tooling-configs@0.2.11(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003))': + '@antelopejs/tooling-configs@0.2.17(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003))': dependencies: '@oxlint/plugins': 1.81.0 dts-bundle-generator: 9.5.1 diff --git a/src/tests/subscriptions-changes.test.ts b/src/tests/subscriptions-changes.test.ts index 7c65326..4d5a834 100644 --- a/src/tests/subscriptions-changes.test.ts +++ b/src/tests/subscriptions-changes.test.ts @@ -221,8 +221,9 @@ describe(`requests outside the contract (${CHARGEABLE})`, function () { "a fractional amount": () => ({ amount: amount(10.5) }), "an interval count of zero": () => ({ intervalCount: 0 }), "a fractional interval count": () => ({ intervalCount: 1.5 }), - "an interval outside the vocabulary": () => ({ - interval: "fortnight" as SubscriptionRequest["interval"], + "an interval outside the vocabulary": (): Partial => ({ + // @ts-expect-error "fortnight" is outside the interval vocabulary, which CreateSubscription must refuse with InvalidSubscriptionRequestError + interval: "fortnight", }), "a trial that has already ended": (now) => ({ trialEnd: now - DAY_MS }), "a billing anchor in the past": (now) => ({ diff --git a/src/tests/support.ts b/src/tests/support.ts index a72b439..b97fbaa 100644 --- a/src/tests/support.ts +++ b/src/tests/support.ts @@ -270,7 +270,7 @@ export async function rejectsWith( ): Promise { const error: unknown = await work.then( () => undefined, - (reason) => reason, + (reason: unknown) => reason, ); assert.ok( error instanceof expected, diff --git a/test/contract.test.js b/test/contract.test.js index 4daacce..bf3f9c7 100644 --- a/test/contract.test.js +++ b/test/contract.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const { RegisteringProxy } = require("@antelopejs/interface-core"); -const subscriptions = require("../dist/index.js"); +const subscriptions = require("#built"); /** * This package is a contract: the calls are `InterfaceFunction` declarations diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..d5528ec --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "composite": false, + "declaration": false, + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["*.js"] +} diff --git a/tests/json-object.types.ts b/tests/json-object.types.ts index 28f1b32..e8beefd 100644 --- a/tests/json-object.types.ts +++ b/tests/json-object.types.ts @@ -3,21 +3,21 @@ import type { JsonObject, Subscription } from "../src/index"; type Accepts = [Value] extends [Target] ? true : false; type Not = Flag extends true ? false : true; +type Proof = Flag; + interface InterfaceTyped { name: string; } -declare function assertType(): Flag; - -assertType>(); -assertType>(); -assertType< - Accepts ->(); -assertType, { phase: null }>>(); -assertType>>(); -assertType>>(); -assertType string }>>>(); -assertType>>(); -assertType>>(); -assertType>>(); +export type Proofs = [ + Proof>, + Proof>, + Proof>, + Proof, { phase: null }>>, + Proof>>, + Proof>>, + Proof string }>>>, + Proof>>, + Proof>>, + Proof>>, +];