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
12 changes: 12 additions & 0 deletions .changeset/22588-nested-refusal-sentence-zero-row-total.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/runtime": patch
"@objectstack/metadata-protocol": patch
---

fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588)

Clause-②: no

- **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame.
- **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged.
- **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size.
15 changes: 14 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13276,7 +13276,20 @@ export class ObjectStackProtocolImplementation implements
// reproduced by it, so for those we skip the count and fall back to a
// page-local estimate (a full page implies there may be more) rather than
// reporting a wrong total.
const pageLimit = typeof options.limit === 'number' && options.limit > 0 ? options.limit : undefined;
//
// [#22588] `limit: 0` IS a limit. It asks for no rows (objectstack#6485,
// the drivers' pagination conformance), so `records.length` is 0 and was
// reported as the total: `?$top=0` answered `total: 0` while `?$top=1`
// answered the true count, and the Console's footer probe — which sends
// exactly `$top: 0` to read the total and nothing else — showed `0` on
// every list page. Gated `> 0`, a zero-row page fell into the "no limit,
// the full set is the total" arm, which is true of every limit except
// this one. So `>= 0`: a zero-row request is a paged request like any
// other, and the three arms below keep their meaning for it — the count
// runs (`total` is the filtered count, whatever the page size), `$count=false`
// still omits `total` instead of reporting the page's 0, and `search`
// still reports its page-local estimate rather than a count.
const pageLimit = typeof options.limit === 'number' && options.limit >= 0 ? options.limit : undefined;
const pageOffset = typeof options.offset === 'number' && options.offset > 0 ? options.offset : 0;
let total: number | undefined = records.length;
let hasMore = false;
Expand Down
166 changes: 166 additions & 0 deletions packages/metadata-protocol/src/protocol.zero-limit-total.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22588 item 2 — a list's `total` is the filtered count whatever the page size,
* `$top=0` included.
*
* ## What was wrong
*
* `findData` decided "is this a paged request?" with `limit > 0`. `limit: 0`
* asks for NO rows (objectstack#6485 — every driver's pagination conformance
* pins it), so the gate sent a zero-row request into the "no limit, the whole
* result set came back, so its length IS the total" arm — the one arm that is
* false for exactly this limit. Measured over HTTP on a booted stack at
* `3d0eeefa` (five rows, two of them in stage `b`):
*
* GET /data/mz_task?top=0 total 0 ← the defect
* GET /data/mz_task?top=1 total 5
* GET /data/mz_task?top=0&stage=b total 0 ← the defect
* GET /data/mz_task?top=1&stage=b total 2
* GET /data/mz_task?top=0&$count=false total 0 ← the opt-out ignored too
*
* The Console's list footer sends exactly `$top: 0` to read the total and
* nothing else, so every list page showed a record count of `0`.
*
* ## What this suite pins
*
* 1. `top=0` and `top=1` report the SAME `total`, and `top=0` still returns no
* rows — the engine is asked for `limit: 0`, never for the whole set.
* 2. CONTROL — a filter moves both alike. A fix that reported the UNFILTERED
* count for a zero-row page would pass (1) and fail here.
* 3. The two neighbouring arms keep their meaning for a zero-row page:
* `$count=false` still issues no COUNT and omits `total` (it no longer
* reports the page's `0` as if it were a count), and `search` still issues
* no COUNT — `engine.count()` cannot reproduce a search, which is why that
* arm reports a page-local estimate at every page size.
* 4. Absent a limit nothing moves: no COUNT, `total` is the set's length.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';

const SCHEMA = {
name: 'mz_task',
nameField: 'name',
fields: {
name: { name: 'name', type: 'text' },
stage: { name: 'stage', type: 'text' },
},
};

const ROWS = [
{ id: 't0', name: 't0', stage: 'a' },
{ id: 't1', name: 't1', stage: 'a' },
{ id: 't2', name: 't2', stage: 'a' },
{ id: 't3', name: 't3', stage: 'b' },
{ id: 't4', name: 't4', stage: 'b' },
];

/**
* Equality-only `where` — the implicit field filter `?stage=b` lowers to.
* Anything else (a combinator, an operator object) is REFUSED rather than
* answered silently wrong.
*/
function matches(row: Record<string, unknown>, where: unknown): boolean {
if (where == null) return true;
if (typeof where !== 'object' || Array.isArray(where)) {
throw new Error(`test double: unexpected where shape ${JSON.stringify(where)}`);
}
return Object.entries(where as Record<string, unknown>).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`test double: combinator '${k}' is not implemented`);
if (v !== null && typeof v === 'object') throw new Error(`test double: operator on '${k}' is not implemented`);
return row[k] === v;
});
}

/**
* An engine double that honours `limit` AS WRITTEN — `limit: 0` is no rows,
* the drivers' declared contract — and counts over the same `where`.
*/
function makeProtocol() {
const find = vi.fn(async (_object: string, opts: any = {}) => {
const hits = ROWS.filter((r) => matches(r, opts.where));
const offset = typeof opts.offset === 'number' ? opts.offset : 0;
const sliced = hits.slice(offset);
return opts.limit !== undefined ? sliced.slice(0, opts.limit) : sliced;
});
const count = vi.fn(async (_object: string, opts: any = {}) => ROWS.filter((r) => matches(r, opts.where)).length);
const engine = {
registry: { getObject: (n: string) => (n === SCHEMA.name ? SCHEMA : undefined) },
find,
count,
aggregate: vi.fn(async () => [] as unknown[]),
};
return { p: new ObjectStackProtocolImplementation(engine as any), find, count };
}

async function list(query: Record<string, unknown>) {
const h = makeProtocol();
const result: any = await h.p.findData({ object: SCHEMA.name, query } as never);
return { result, ...h };
}

describe('[#22588] a zero-row page reports the filtered total', () => {
it('top=0 and top=1 report the same total, and top=0 returns no rows', async () => {
const zero = await list({ top: '0' });
const one = await list({ top: '1' });

expect(zero.result.total).toBe(5);
expect(zero.result.total).toBe(one.result.total);
expect(zero.result.records).toEqual([]);
// The engine was asked for NO rows — the fix counts, it never widens
// the read to the whole set to measure it.
expect((zero.find.mock.calls[0] as unknown[])[1]).toMatchObject({ limit: 0 });
expect(zero.result.hasMore).toBe(true);
});

it('every spelling of the zero window reaches the same answer', async () => {
for (const query of [{ $top: '0' }, { limit: '0' }, { top: 0 }]) {
const { result, count } = await list(query);
expect(result.total, JSON.stringify(query)).toBe(5);
expect(count, JSON.stringify(query)).toHaveBeenCalledTimes(1);
}
});

it('CONTROL — a filter changes both alike', async () => {
const zero = await list({ top: '0', stage: 'b' });
const one = await list({ top: '1', stage: 'b' });

expect(zero.result.total).toBe(2);
expect(one.result.total).toBe(2);
expect(zero.result.records).toEqual([]);
expect(one.result.records).toHaveLength(1);
});

it('a zero-row page past an offset still reports the whole filtered total', async () => {
const { result } = await list({ top: '0', skip: '2' });

expect(result.total).toBe(5);
expect(result.records).toEqual([]);
expect(result.hasMore).toBe(true);
});
});

describe('[#22588] the neighbouring arms keep their meaning for a zero-row page', () => {
it('$count=false still issues no COUNT — and omits total instead of reporting the page\'s 0', async () => {
const { result, count } = await list({ $top: '0', $count: 'false' });

expect(count).not.toHaveBeenCalled();
expect('total' in result).toBe(false);
});

it('search still issues no COUNT — engine.count() cannot reproduce a search', async () => {
const { result, count } = await list({ $top: '0', $search: 't' });

expect(count).not.toHaveBeenCalled();
expect(result.records).toEqual([]);
});

it('absent a limit nothing moves: no COUNT, total is the set\'s length', async () => {
const { result, count } = await list({ stage: 'b' });

expect(count).not.toHaveBeenCalled();
expect(result.total).toBe(2);
expect(result.hasMore).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22588] Every `/actions` invocation route answers a hook's refusal in the
* hook's own words — enumerated from the route ledger, not hand-listed.
*
* ## What was measured broken
*
* `POST /api/v1/actions/mz_task/mz_reopen/ID` on a booted stack at `3d0eeefa`:
* a script action whose `ctx.api` write a sandboxed `beforeUpdate` hook refused
* answered `400 VALIDATION_ERROR` with
* `hook 'mz_guard_reopen' threw: Error: A finished task cannot be reopened. …`.
* The hotclm browser pass saw the same text in a toast. The producer was the
* sandbox's VM hop, repaired in `sandbox/quickjs-runner.ts`; the door now reads
* its sentence through `sandboxBusinessMessage`, the read `/data` makes.
*
* ## Why the ledger, and what the enumeration buys
*
* The rows are read from {@link ROUTE_LEDGER} — the dispatcher's audited list of
* what it serves — so a new `/actions` row is driven here the day it is
* ledgered, or fails the completeness case until someone says why it cannot
* run an action body. `_activation` is the one such row today.
*
* Each row is driven through `HttpDispatcher.dispatch()` at a concrete path
* built from the row itself, and the refusal each one meets is produced by the
* REAL runner at both levels (a hook body refusing under an action body's
* `ctx.api` write) — the shape that reached the wire, never a hand-built error.
* `executeAction` being called is asserted per row, so a row answered by some
* earlier refusal (a missing declaration, the anonymous floor) cannot pass.
*/

import { describe, it, expect, vi } from 'vitest';

import { HttpDispatcher } from '../http-dispatcher.js';
import { ROUTE_LEDGER } from '../route-ledger.js';
import { QuickJSScriptRunner } from '../sandbox/quickjs-runner.js';

const REFUSAL = 'A finished task cannot be reopened. Create a follow-up task instead.';
const OBJECT = 'mz_task';
const ACTION = 'mz_reopen';
const RECORD = 't1';

/**
* `/actions` rows that do not invoke an action, with the reason. Closed: a new
* row is driven below unless it is named here.
*/
const NOT_AN_INVOCATION: ReadonlyMap<string, string> = new Map([
['POST /actions/_activation/:object/:action',
'ADR-0126 §8 activation door — writes one sys_metadata_activation row and runs no action body, '
+ 'so no hook can refuse inside it'],
]);

const ACTION_ROWS = ROUTE_LEDGER.filter((row) => row.domain === '/actions');
const INVOCATION_ROWS = ACTION_ROWS.filter((row) => !NOT_AN_INVOCATION.has(row.route));

/** `POST /actions/:object/:action/:recordId` → `/actions/mz_task/mz_reopen/t1`; literals stay. */
function concretePath(route: string): string {
const [, pattern] = route.split(' ');
return pattern
.replace(':object', OBJECT)
.replace(':action', ACTION)
.replace(':recordId', RECORD);
}

const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000, actionTimeoutMs: 10_000 });

/**
* What an action body's handler throws when its `ctx.api` write is refused by
* a sandboxed hook — both levels run by the real runner.
*/
async function nestedRefusal(): Promise<unknown> {
const hookRefusal = () => runner.runScript(
{ language: 'js', source: `throw new Error(${JSON.stringify(REFUSAL)});` },
{ input: {} } as any,
{ origin: { kind: 'hook', name: 'mz_guard_reopen' } },
).then(() => { throw new Error('the guard hook resolved'); }, (e: unknown) => e);
return runner.runScript(
{
language: 'js',
source: `await ctx.api.object('${OBJECT}').update({ id: '${RECORD}', done: false });`,
capabilities: ['api.write'],
},
{ input: {}, api: { object: () => ({ update: async () => { throw await hookRefusal(); } }) } } as any,
{ origin: { kind: 'action', name: ACTION } },
).then(() => { throw new Error('the action body resolved'); }, (e: unknown) => e);
}

function makeDispatcher() {
const declared = { name: ACTION, objectName: OBJECT, type: 'script', target: ACTION };
const objectDef = { name: OBJECT, actions: [declared] };
const executeAction = vi.fn(async () => { throw await nestedRefusal(); });
const ql: any = {
executeAction,
getSchema: (n: string) => (n === OBJECT ? objectDef : undefined),
registry: { getObject: (n: string) => (n === OBJECT ? objectDef : undefined), getItem: () => undefined },
find: vi.fn(async () => [{ id: RECORD, done: true }]),
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
};
// The object-less rows resolve their declaration here: a global action of the same name.
const global = (type: string, name: string) =>
type === 'action' ? { name, type: 'script', target: name, objectName: 'global' } : null;
const metadata: any = {
load: vi.fn(async (type: string, name: string) => global(type, name)),
loadDiagnosed: vi.fn(async (type: string, name: string) => ({ data: global(type, name), degraded: false, errors: [] })),
listObjects: vi.fn(async () => [objectDef]),
getObject: vi.fn(async (n: string) => (n === OBJECT ? objectDef : undefined)),
};
// `/actions` answers an anonymous caller 401 before addressing anything.
const auth: any = { api: { getSession: async () => ({ user: { id: 'u1' } }) } };
const kernel: any = {
context: {
getService: (n: string) =>
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'auth' ? auth : null,
},
};
return { dispatcher: new HttpDispatcher(kernel) as any, executeAction };
}

describe('[#22588] the /actions ledger rows, each driven with a real nested hook refusal', () => {
it('the ledger has invocation rows to drive, and every exemption names a real row', () => {
expect(INVOCATION_ROWS.length).toBeGreaterThanOrEqual(4);
for (const route of NOT_AN_INVOCATION.keys()) {
expect(ACTION_ROWS.map((r) => r.route), `stale exemption: ${route}`).toContain(route);
}
});

for (const row of INVOCATION_ROWS) {
it(`${row.route} answers the hook's sentence, not its debug wrapper`, async () => {
const { dispatcher, executeAction } = makeDispatcher();

const res = await dispatcher.dispatch('POST', concretePath(row.route), {}, {}, {
request: { headers: {} },
});

// Anti-vacuity: the action body really ran and really refused.
expect(executeAction).toHaveBeenCalled();
expect(res.response.status).toBe(400);
expect(res.response.body.success).toBe(false);
expect(res.response.body.error.code).toBe('VALIDATION_ERROR');
// The wording IS the defect here, so the sentence is asserted whole.
expect(res.response.body.error.message).toBe(REFUSAL);
expect(JSON.stringify(res.response.body)).not.toMatch(/threw:|hook '/);
}, 60_000);
}
});
Loading
Loading