Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/22301-verify-observe-writes-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/verify': minor
---

feat(verify): the handle gains a door that observes the writes the engine receives, a hook's refused write included

Clause-②: yes (widening)

An app's tests can see what a hook handed the engine through the in-process handle, so a suite no longer wraps the booted engine's write methods in a spy to find a refused write, to wait for a fire-and-forget hook, or to stage a refusal.

- **`stack.observeWrites(fn)`** runs `fn` and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (`ObservedWrite`) carries `object`, `operation`, the payload (`data`), the update or delete selection (`where`), `multi`, the execution context the write carried (`context`), and the engine's answer (`outcome`): `pending`, then `resolved` with its result, or `refused` with the error the engine threw, unchanged, and that error's own `code`.
- **What it is.** One global middleware on the engine's own chain (`registerMiddleware`), added on the first call. It passes every operation on unchanged and decides nothing. So it sits after the write gates the boot registered. Every write those gates let through is recorded, from any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and so is every refusal the engine raises past them: a declared validation rule (`VALIDATION_FAILED`), a hook's throw, a unique conflict (`DUPLICATE_RECORD`). A write a gate refuses, the permission check among them, never reaches it and leaves no entry. Its refusal still reaches whoever made the write.
- **An outcome is the engine's answer to that call, not a statement about stored rows.** A write an `after*` hook refuses was refused after the driver wrote, and its row can stay. Read rows back with `stack.rows`.
- **The window is time, not cause.** A write the stack makes for its own reasons while `fn` runs is recorded too, so filter by `object`.
- **An `async: true` hook.** Its write reaches the engine after the write that fired it has returned. `fn` is handed the live observation (`WriteObservation`), and `observation.settled(match)` resolves with the first write `match` accepts once the engine has answered it. It rejects after `timeoutMs` (default 2000), naming every write observed, or when the observation closes first. A hook that writes nothing leaves nothing to observe.
- **Staging a refusal.** Arrange one the engine makes itself: seed the row that already holds a unique value the hook will write, or set up the state a declared validation rule refuses. The handle has no switch that refuses a write in the engine's place.
- A `fn` that is not a function, a `match` that is not a function, and a `timeoutMs` that is not a positive number are refused with `code: 'INVALID_REQUEST'`, `status: 400`. A rejection of `fn` is rethrown unchanged once the observation has closed. No error code is added.
- New exported types: `ObservedWrite`, `ObservedWriteOutcome`, `WriteObservation`.
1 change: 1 addition & 0 deletions packages/qa/dogfood/test/rls-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function fakeStack(opts: FakeOpts): VerifyStack {
automation: undefined as never,
seed: undefined as never,
rows: undefined as never,
observeWrites: undefined as never,
metadata: undefined as never,
tenancy: undefined as never,
};
Expand Down
34 changes: 30 additions & 4 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ await stack.stop();

Every `VerifyStack` carries it; nothing extra to boot. Each method is a thin
facade over a door the kernel wired at boot — the ObjectQL engine's own write,
dry-run and read calls, the runtime's `/automation` and `/actions` routes
driven in-process, the automation engine's condition evaluator, the
`SchemaRegistry`, the `tenancy` service — with **zero
dry-run and read calls and its middleware chain, the runtime's `/automation`
and `/actions` routes driven in-process, the automation engine's condition
evaluator, the `SchemaRegistry`, the `tenancy` service — with **zero
re-implemented semantics**: the handle assembles no execution context, orders
no hooks, evaluates no permission. What the engine does is what you assert on.

Expand Down Expand Up @@ -137,6 +137,16 @@ expect(await stack.automation.evaluateCondition(
const [acc] = await stack.seed('crm_account', [{ name: 'Globex' }]);
const mine = await stack.rows('crm_opportunity', { crm_account: acc.id }, { as: rep });

// What a hook handed the engine: every write the engine receives while the
// function runs, and what it answered. A hook's write the engine refused
// leaves no row; here it is, refused, with the engine's own error.
const writes = await stack.observeWrites(async (observation) => {
await stack.hooks.run('crm_lead', 'insert', { last_name: 'Ada', company: 'Globex' }, { as: rep });
// An `async: true` hook writes after the triggering write has returned.
await observation.settled((w) => w.object === 'crm_task');
});
const refused = writes.filter((w) => w.outcome.status === 'refused');

// What the boot actually holds.
stack.metadata.object('crm_opportunity')?.fields; // system columns injected
stack.metadata.items('permission'); // the registry's singular names
Expand Down Expand Up @@ -164,6 +174,22 @@ await stack.stop();
touched, with `INVALID_REQUEST` / `400` (a caller named twice, or a
`hooks.updateWhere` the engine would write by id). Assert on `code` (and
`status` / `statusCode`), never on a message alone.
- `observeWrites(fn)` records each insert, update and delete the engine
receives while `fn` runs (`object`, `operation`, `data`, `where`, `multi`,
`context`) and what the engine answered (`pending`, then `resolved` with its
result or `refused` with its error and that error's own `code`). It is one
middleware on the engine's own chain that passes everything on unchanged,
so it sits after the write gates the boot registered: a write a gate refuses
(the permission check among them) never reaches it and leaves no entry,
while every refusal past the gates (a validation rule, a hook's throw, a
unique conflict) is recorded. An outcome is the engine's answer to that call,
not a statement about stored rows: read those back with `rows`. The window is
time, not cause, so filter by `object`. `observation.settled(match)` waits
for a write that lands later, such as an `async: true` hook's (default
timeout 2000 ms). To stage a refusal of a hook's write, arrange one the
engine makes itself: seed the row that already holds a unique value, or set
up the state a declared validation rule refuses. The handle has no switch
that refuses a write in the engine's place.
- Many files, one boot: `bootStackOnce(config, opts?)` memoises `bootStack` per
`(config, opts)` object identity for the life of the process. Share it from
one module, under vitest `isolate: false`, and never `stop()` a stack other
Expand Down Expand Up @@ -221,7 +247,7 @@ run" must never read like "nothing to find".

- `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`, plus the handle:
`hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` /
`automation.evaluateCondition` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`).
`automation.evaluateCondition` / `seed` / `rows` / `observeWrites` / `metadata` / `tenancy` / `contextFor`).
- `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process.
- `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object.
- `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary.
Expand Down
Loading
Loading