Skip to content

Commit 79d5dac

Browse files
fix(objectql): temporal-comparand and text-operator filter refusals state the prescription inside the client message cap (#27336)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27173 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:查询过滤里写了日期字段读不懂的值(如 `"last_30_days"`、年份超范围、不存在的日子、非 ISO 写法、解析后超范围的相对日期占位符),或对数字/布尔等字段用了文本操作符(`$contains` 等)时,`400 INVALID_FILTER` 报错现在先说"错在哪",紧接着说"该怎么写",最后才讲"没执行"和后果。只动了 objectql 里这两处报错的句子顺序和措辞。 - **为什么改**:客户端只读得到报错前 500 个字符。原文案把"该怎么写"放在最后,测得 306 个样例里 172 个连补救句的开头都被截掉,281 个补救不完整;开发者(或 AI)只知道错了,不知道改成什么。 - **风险与回滚**:低。500 字符上限、截断方式、错误码与状态码都没变,被拒绝的过滤集合也没变;只是同一条报错里句子换了顺序,`datetime` 的补救句改为先给相对日期占位符、年份范围补救句缩短。回滚即 revert。 - **先看**:`packages/objectql/src/temporal-comparand-door.ts`(六处 throw 与 `REMEDY` / `YEAR_CLASS`);`packages/objectql/src/text-operator-declared-type-door.ts`(一处 throw);`packages/objectql/src/filter-refusal-prescription-cap.test.ts`(新 pin)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/filter-refusal-prescription-cap.test.ts`,预期 31/31 通过;把这两个 producer 换回 `main` 版本再跑,31 条全部失败。 - **需要你做的**:无,席位落地。 ## What changes Two `INVALID_FILTER` / 400 producers on `where`, `aggregations[i].filter` and `having` printed their prescription last, after the consequence and "NOT applied". A client reads at most 500 characters (`CLIENT_MESSAGE_MAX` in `@objectstack/types`, cut with an ellipsis), so the prescription was often cut. Each refusal is now ordered: the verdict (what was written, where, and why it is not a value of its kind), then the prescription, then "NOT applied" and the consequence. The cap, the truncation, the code, the status and the set of refused filters are unchanged, as the triage ruled (6465970858): "⛔ The same rule as #27098: the prescription goes first, at the producer, and the cap and envelope stay unchanged." - **`temporal-comparand-door.ts`**: all six throw sites (junk, year class, misread class on `where` and `having`; the resolved-placeholder refusal on both) move the remedy ahead of "NOT applied". The verdict clause shortens to "so it is not a KIND value" on the year and misread arms (the junk arm keeps "which is not a KIND value this platform can interpret"), and the placeholder arms drop the repeated "the years a KIND value may name". `REMEDY.datetime` now names the relative-date placeholder first, as its own doc comment always said it did, and is shorter; the two year-class remedies are shorter. A module section, "Prescription first, inside the client cap", records the rule. - **`text-operator-declared-type-door.ts`**: "Compare FIELD with an operator its declared type can answer …" now follows "A TYPE value is never a string on any backend." The no-match-rule explanation moves after "NOT applied". - **`engine-aggregate-having-temporal-door.test.ts`**: the case that read the remedy after "NOT applied" now reads it between the verdict and "NOT applied", on both `having` and its `where` twin, and still requires them equal. ### Measured: remedy position in the message (306 messages, matrix below) Matrix: three name sizes (object/field 1/1, 12/12, 25/31 characters) × every refusal class × `where` and `aggregations[0].filter` (flat and inside a nested `$and` / `$or`) and `having`. "Start past cap" = the remedy's first character at or past 499; "remedy cut" = the remedy is not whole in what the client reads. Before = `origin/main` `4d3a077f`, after = this head. | Producer arm | n | length before → after | remedy start before → after | start past cap | remedy cut | |:--|--:|:--|:--|:--|:--| | temporal: junk (where/filter) | 48 | 392–759 → 392–686 | 319–499 → 143–323 | 1 → 0 | 34 → 1 | | temporal: junk (having) | 12 | 379–742 → 379–669 | 306–482 → 156–332 | 0 → 0 | 8 → 1 | | temporal: year range (where/filter) | 36 | 523–703 → 422–638 | 382–600 → 181–328 | 16 → 0 | 36 → 0 | | temporal: year range (having) | 9 | 511–714 → 410–649 | 370–611 → 194–337 | 3 → 0 | 9 → 0 | | temporal: misread (where/filter) | 60 | 473–903 → 445–802 | 328–643 → 156–355 | 27 → 0 | 56 → 2 | | temporal: misread (having) | 15 | 450–913 → 422–812 | 333–653 → 169–364 | 6 → 0 | 12 → 2 | | temporal: resolved placeholder (where/filter) | 24 | 557–826 → 497–725 | 468–652 → 235–383 | 20 → 0 | 24 → 4 | | temporal: resolved placeholder (having) | 12 | 536–863 → 476–762 | 463–689 → 262–418 | 9 → 0 | 12 → 3 | | text-operator door (5 types × 3 operators) | 90 | 694–839 → 693–838 | 502–617 → 152–267 | 90 → 0 | 90 → 0 | | **all** | 306 | | | **172 → 0** | **281 → 13** | By name size, remedy cut before → after: 1/1 87 → 0, 12/12 96 → 0, 25/31 98 → 13. The 13 left are all on the 25/31-character names; in each the remedy starts inside the cap and only its tail (the literal-value alternative after a placeholder's own fix, or the last alternative of the `datetime` list behind a 60-character preview) is cut. ## Tests - `packages/objectql/src/filter-refusal-prescription-cap.test.ts` (new, 31 cases): every refusal arm of both producers, read the way a client reads it, through `mapDataError` (`@objectstack/types`). Each case asserts status 400, code `INVALID_FILTER`, that the opening and closing words of the prescription are in the bounded client text, and that they sit before "NOT applied". Fixtures use a 12-character object and field and paths through an aggregation filter with a nested `$and` / `$or`. - Ablation, once: both producers restored to the `4d3a077f` blobs (hash-checked on disk), pin run: **31 of 31 failed**; restored to `HEAD` (hash-checked, `git diff HEAD` empty), 31 of 31 pass. - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2` at `0aa08456`: 479 of 479 files, 8935 tests passed. - `@objectstack/rest`, the 46 test files that name a temporal or text operator or `INVALID_FILTER` (after building the closure): 46 of 46 files, 1051 passed, 289 skipped (the PostgreSQL-gated legs). - After merging `origin/main` (`f2afe56d`; the incoming diff touches one objectql test file): the pin, the having/where temporal door, text-operator door and resolved-token suites, 5 files, 132 of 132. - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `eslint --no-inline-config` on the four touched files: exit 0. ## Acceptance notes - Not touched, by the card: the `$null` non-boolean sentence (`rest-4xx-message-truncation.test.ts` pins that truncation on purpose). - The card's two unlocated producers, measured with this branch's `tsx` against the source: `no-operator-object-door.ts` json and address variants keep the whole remedy inside the cap (remedy start 275–310, end 431–466 for a 15-character field); the single-valued lookup variant on `aggregations[i].filter` / `having` starts its route at 329–336 but its trailing `$in` example ends at 510–517, so the example is cut. Reported to the seat as a finding, not changed here. `having-filter.ts`'s cross-column class refusal carries no prescription sentence to move; left as is. ## Verification Derived gate families at head `f2afe56d` (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, change set 5 files, 252 changed lines vs `origin/main` `ebfadb02`), each run once and its exit code captured before any pipe. `--ran` reconciliation: **59 derived, 58 run, 1 NOT MEASURED, 0 unrun**. - 58 exit 0. - `pnpm check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET ("Run `pnpm build` first": it needs every published package's `dist`, and only the rest closure was built here). NOT MEASURED locally; CI runs it. <details><summary>Every recorded command and exit code</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-engine-split-ratio.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> Every other family CI runs (the required lint, typecheck, test and build jobs) is read from this PR's check runs, not re-run here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Cb7UcFADbm6v5Aw1fh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8eeb3b3 commit 79d5dac

5 files changed

Lines changed: 213 additions & 39 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): temporal-comparand and text-operator filter refusals state what to write inside the 500-character client cap
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`POST /api/v1/data/OBJECT/query` refuses a comparand a temporal field cannot read (`{ "created_date": { "$gte": "last_30_days" } }`, a year outside the field's range, an impossible day, a non-ISO spelling, a relative-date placeholder resolved outside the years) and a text operator aimed at a field that never holds a string (`{ "amount": { "$contains": "5" } }`) with `400 INVALID_FILTER`, in `where`, `aggregations[i].filter` and `having`. A client reads at most 500 characters of that message, and these refusals printed the prescription last, after the consequence, so it was often cut off. Each refusal now reads: what was written and why it is not a value of its kind, then what to write instead, then "NOT applied" and what the value would have done. The `datetime` prescription now names the relative-date placeholder first (`"{30_days_ago}"`), and the year-range prescriptions are shorter. The cap, the truncation, the code and the status are unchanged, and so is the set of filters refused.

‎packages/objectql/src/engine-aggregate-having-temporal-door.test.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,20 +285,25 @@ describe('[#20263] having — a comparand its column cannot read is refused befo
285285
"`having` on 'first_opened' (min(opened_at), a datetime column) compares against \"last_30_days\" at "
286286
+ 'having.first_opened.$lt, which is not a datetime value this platform can interpret.'],
287287
];
288-
const after = (message: string, marker: string): string => {
289-
expect(message).toContain(marker);
290-
return message.slice(message.indexOf(marker) + marker.length);
288+
// [#27173] The remedy sits between the verdict and "NOT applied", so it
289+
// lands inside the 500 characters a client reads.
290+
const between = (message: string, start: string, end: string): string => {
291+
expect(message).toContain(start);
292+
const from = message.indexOf(start) + start.length;
293+
expect(message.indexOf(end, from)).toBeGreaterThan(from);
294+
return message.slice(from, message.indexOf(end, from));
291295
};
296+
const VERDICT_END = 'this platform can interpret. ';
292297
for (const [having, where, firstSentence] of CASES) {
293298
// INVALID_FILTER / 400 on both paths, empty or populated, no read.
294299
const message = await expectHavingRefusal(() => having);
295300
expect(message.startsWith(`aggregate('${OBJECT}'): ${firstSentence} `)).toBe(true);
296-
const remedy = after(message, 'The `having` was NOT applied. ');
301+
const remedy = between(message, VERDICT_END, ' The `having` was NOT applied.');
297302
expect(remedy).toContain('"{30_days_ago}"');
298303
const twin = await whereTwinOf(where);
299304
expect(twin.err?.code).toBe('INVALID_FILTER');
300305
expect(twin.err?.status).toBe(400);
301-
expect(remedy).toBe(after(twin.err!.message, 'The filter was NOT applied. '));
306+
expect(remedy).toBe(between(twin.err!.message, VERDICT_END, ' The filter was NOT applied.'));
302307
}
303308
});
304309
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27173] Two `INVALID_FILTER` producers — the temporal-comparand door and
5+
* the text-operator declared-type door — state what to write instead INSIDE
6+
* the 500 characters a client reads.
7+
*
8+
* The cap stays: `mapDataError` (`@objectstack/types`) cuts a 4xx message at
9+
* `CLIENT_MESSAGE_MAX` with `…`, and that is the general bound on what reaches
10+
* a client. So the producer orders each refusal verdict, prescription, then
11+
* consequence, and this file reads every arm the way a client does — through
12+
* `mapDataError` — and requires the whole prescription in what survives the
13+
* cut, ahead of "NOT applied". Run against the two producers as they stood on
14+
* `origin/main` `4d3a077f`, all 31 cases here go red: every arm printed its
15+
* prescription last, after "NOT applied" and the consequence.
16+
*
17+
* The fixtures are long on purpose — a 12-character object and field, a path
18+
* through an aggregation's filter and a nested `$and` / `$or` — so the pin
19+
* holds with margin, not only on a one-letter field.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { resolveFilterTokens } from '@objectstack/core';
24+
import { mapDataError } from '@objectstack/types';
25+
import {
26+
assertTemporalComparandsInterpretable,
27+
assertHavingTemporalComparandsInterpretable,
28+
assertResolvedTemporalTokensInRange,
29+
assertHavingResolvedTemporalTokensInRange,
30+
} from './temporal-comparand-door.js';
31+
import { assertTextOperatorTargetsAreStringCapable } from './text-operator-declared-type-door.js';
32+
33+
const OBJECT = 'support_case';
34+
const FIELD = 'created_date';
35+
const ROOTS = ['where', 'aggregations[0].filter'] as const;
36+
37+
/** The refusal a client reads: status, code, and the bounded `error` text. */
38+
function clientReads(fn: () => void): { status: number; code: unknown; text: string; full: string } {
39+
let thrown: unknown;
40+
try { fn(); } catch (e) { thrown = e; }
41+
expect(thrown, 'the door refused').toBeInstanceOf(Error);
42+
const { status, body } = mapDataError(thrown);
43+
return { status, code: body.code, text: String(body.error), full: (thrown as Error).message };
44+
}
45+
46+
/**
47+
* The prescription is whole in what the client reads, and precedes the
48+
* "NOT applied" sentence — the consequence after it is what a cut may take.
49+
*/
50+
function expectPrescriptionInside(fn: () => void, prescription: readonly string[]): void {
51+
const read = clientReads(fn);
52+
expect(read.status).toBe(400);
53+
expect(read.code).toBe('INVALID_FILTER');
54+
const notApplied = read.full.indexOf(' was NOT applied');
55+
expect(notApplied, read.full).toBeGreaterThan(0);
56+
for (const words of prescription) {
57+
expect(read.text, `client text misses ${JSON.stringify(words)}`).toContain(words);
58+
expect(read.full.indexOf(words), `${JSON.stringify(words)} sits after "NOT applied"`).toBeLessThan(notApplied);
59+
}
60+
}
61+
62+
/** The opening and the closing words of each remedy sentence, per class. */
63+
const DATETIME_REMEDY = ['Write a relative-date placeholder such as "{30_days_ago}"', 'or epoch milliseconds.'];
64+
const DATE_REMEDY = ['Write a "YYYY-MM-DD" calendar day that exists', '"{current_month_start}".'];
65+
const TIME_REMEDY = ['Write an "HH:MM" / "HH:MM:SS" wall clock', 'ADR-0053 D-C1).'];
66+
const DATETIME_YEARS_REMEDY = ['Write an ISO-8601 instant, epoch milliseconds or a Date', 'in the years 1000 to 9999.'];
67+
const DATE_YEARS_REMEDY = ['Write a "YYYY-MM-DD" day, epoch milliseconds or a Date', 'in the years 0001 to 9999.'];
68+
const TOKEN_REMEDY = ['Use a relative-date placeholder whose offset lands inside those years.'];
69+
70+
type Kind = 'date' | 'datetime' | 'time';
71+
const WHERE_CASES: ReadonlyArray<readonly [string, Kind, unknown, readonly string[]]> = [
72+
['a preset name (junk) on a datetime', 'datetime', 'last_30_days', DATETIME_REMEDY],
73+
['a 60-character junk string on a datetime', 'datetime', 'x'.repeat(80), DATETIME_REMEDY],
74+
['junk on a date', 'date', 'last_30_days', DATE_REMEDY],
75+
['junk on a time', 'time', 'noon', TIME_REMEDY],
76+
['year 10000 on a datetime', 'datetime', '+010000-01-01T00:00:00.000Z', DATETIME_YEARS_REMEDY],
77+
['year 999 on a datetime (before the floor)', 'datetime', '0999-01-01T00:00:00.000Z', DATETIME_YEARS_REMEDY],
78+
['year 10000 on a date', 'date', 253402300800000, DATE_YEARS_REMEDY],
79+
['an impossible day on a date', 'date', '2026-02-30', DATE_REMEDY],
80+
['an impossible day on a datetime', 'datetime', '2026-02-30T00:00:00Z', DATETIME_REMEDY],
81+
['a slashed day on a datetime (not ISO)', 'datetime', '07/15/2026', DATETIME_REMEDY],
82+
['an instant outside the four-digit years on a time', 'time', '+010000-01-01T00:00:00.000Z', TIME_REMEDY],
83+
];
84+
85+
describe('[#27173] the temporal-comparand door: the prescription lands inside the client cap', () => {
86+
for (const [name, kind, value, prescription] of WHERE_CASES) {
87+
it(`where / aggregations[i].filter — ${name}`, () => {
88+
const schema = { fields: { [FIELD]: { type: kind } } };
89+
for (const root of ROOTS) {
90+
expectPrescriptionInside(() => assertTemporalComparandsInterpretable(
91+
OBJECT, 'aggregate', schema, { $and: [{ x: 1 }, { $or: [{ [FIELD]: { $in: [value] } }] }] }, root,
92+
), prescription);
93+
}
94+
});
95+
it(`having — ${name}`, () => {
96+
const classes = new Map([[FIELD, kind]]);
97+
const query = { aggregations: [{ alias: FIELD, function: 'max', field: 'opened_on_src' }] };
98+
expectPrescriptionInside(() => assertHavingTemporalComparandsInterpretable(
99+
OBJECT, { [FIELD]: { $gt: value } }, classes as never, query,
100+
), prescription);
101+
});
102+
}
103+
104+
const TOKENS: ReadonlyArray<readonly [Kind, string, readonly string[]]> = [
105+
['datetime', '{8000_years_from_now}', [...TOKEN_REMEDY, ...DATETIME_YEARS_REMEDY]],
106+
['datetime', '{1977_years_ago}', [...TOKEN_REMEDY, ...DATETIME_YEARS_REMEDY]],
107+
['date', '{8000_years_from_now}', [...TOKEN_REMEDY, ...DATE_YEARS_REMEDY]],
108+
['time', '{8000_years_from_now}', TIME_REMEDY],
109+
];
110+
for (const [kind, token, prescription] of TOKENS) {
111+
it(`a ${token} placeholder resolved outside a ${kind} column's years — where, aggregations[i].filter, having`, () => {
112+
const schema = { fields: { [FIELD]: { type: kind } } };
113+
const written = { [FIELD]: { $gt: token } };
114+
const resolved = resolveFilterTokens(written, {});
115+
for (const root of ROOTS) {
116+
expectPrescriptionInside(() => assertResolvedTemporalTokensInRange(
117+
OBJECT, 'aggregate', schema, written, resolved, root,
118+
), prescription);
119+
}
120+
const classes = new Map([[FIELD, kind]]);
121+
const query = { groupBy: [{ field: 'opened_on_src', alias: FIELD, dateGranularity: 'day' }] };
122+
expectPrescriptionInside(() => assertHavingResolvedTemporalTokensInRange(
123+
OBJECT, written, resolved, classes as never, query,
124+
), prescription);
125+
});
126+
}
127+
});
128+
129+
describe('[#27173] the text-operator declared-type door: the prescription lands inside the client cap', () => {
130+
for (const type of ['number', 'currency', 'boolean', 'datetime', 'json']) {
131+
it(`${type} under $notContains — where and aggregations[i].filter`, () => {
132+
const field = 'contract_value';
133+
const schema = { fields: { [field]: { type } } };
134+
for (const root of ROOTS) {
135+
expectPrescriptionInside(() => assertTextOperatorTargetsAreStringCapable(
136+
OBJECT, 'aggregate', schema, { $and: [{ x: 1 }, { $or: [{ [field]: { $notContains: '5' } }] }] }, root,
137+
), [
138+
`Compare '${field}' with an operator its declared type can answer`,
139+
'or aim the text operator at a text-valued field.',
140+
]);
141+
}
142+
});
143+
}
144+
});

‎packages/objectql/src/temporal-comparand-door.ts‎

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@
216216
* expanded-year form (`+010026-10-01`, `-000001-10-01`), so the range reads
217217
* the year it names on every host.
218218
*
219+
* ## [#27173] Prescription first, inside the client cap
220+
*
221+
* A client reads at most 500 characters of a refusal (`CLIENT_MESSAGE_MAX`,
222+
* `@objectstack/types`, cut with `…`), and the cap stays. So every refusal
223+
* here is ordered: what was written and why it is not a value of its kind,
224+
* then what to write instead, then "NOT applied" and what the value would
225+
* have done past this door. The consequence is the part a cut may take; the
226+
* prescription is not. `filter-refusal-prescription-cap.test.ts`
227+
* pins it per arm, the same rule #27098 set for the retired operators.
228+
*
219229
* @see `@objectstack/core`'s `temporal-comparand.ts` — the value-half predicate,
220230
* shared with the analytics raw-SQL decline so one rule cannot exist twice.
221231
* @see https://github.com/objectstack-ai/objectstack/issues/8690
@@ -484,12 +494,15 @@ function preview(value: unknown): string {
484494
* preset name (`last_30_days`) that the console would have lowered to
485495
* `{30_days_ago}`, so the fix a caller needs is almost always "wrap it as the
486496
* token the resolver knows", not "compute an instant yourself".
497+
*
498+
* [#27173] Each one is short, and printed right after the verdict, so it lands
499+
* inside the 500 characters a client reads — see "Prescription first" above.
487500
*/
488501
const REMEDY: Record<TemporalComparandKind, string> = {
489502
datetime:
490-
'Write an ISO-8601 instant ("2026-07-15T00:00:00.000Z") on a calendar day that exists, '
491-
+ 'a bare "YYYY-MM-DD" (read as midnight UTC), epoch milliseconds as a number, or a '
492-
+ 'relative-date placeholder the resolver knows, e.g. "{30_days_ago}" / "{current_month_start}".',
503+
'Write a relative-date placeholder such as "{30_days_ago}", an ISO-8601 instant '
504+
+ '("2026-07-15T00:00:00.000Z") on a day that exists, a bare "YYYY-MM-DD" (midnight UTC) or '
505+
+ 'epoch milliseconds.',
493506
date:
494507
'Write a "YYYY-MM-DD" calendar day that exists, or a relative-date placeholder the resolver '
495508
+ 'knows, e.g. "{30_days_ago}" / "{current_month_start}".',
@@ -530,8 +543,8 @@ const YEAR_CLASS: Record<'date' | 'datetime', YearClass> = {
530543
date: {
531544
year: 'whose calendar day falls outside the years 0001 to 9999',
532545
...misorders('does not sort as a day'),
533-
remedy: 'Write a "YYYY-MM-DD" calendar day in the years 0001 to 9999, or an epoch-millisecond '
534-
+ 'number or Date whose UTC calendar day falls in those years.',
546+
remedy: 'Write a "YYYY-MM-DD" day, epoch milliseconds or a Date whose UTC day falls in the years '
547+
+ '0001 to 9999.',
535548
},
536549
datetime: {
537550
year: DATETIME_YEARS,
@@ -703,13 +716,13 @@ export function assertTemporalComparandsInterpretable(
703716
// does: its text orders as no day or instant does, so it answers the WRONG
704717
// rows (or, on PostgreSQL, a database error) — or [#20280], for a `datetime`
705718
// in 0001..0999, it names a year MySQL's `DATETIME` does not document.
719+
// [#27173] Each arm: the verdict, the prescription, then the consequence.
706720
const yearClass = yearClassOf(hit);
707721
if (yearClass) {
708722
throw invalidFilterError(
709723
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field `
710-
+ `against ${preview(hit.value)} at ${hit.path}, ${yearClass.year}, the years a ${hit.kind} `
711-
+ `value may name, so it is not a ${hit.kind} value this platform can interpret. `
712-
+ `${yearClass.where} The filter was NOT applied. ${yearClass.remedy}`,
724+
+ `against ${preview(hit.value)} at ${hit.path}, ${yearClass.year}, so it is not a ${hit.kind} `
725+
+ `value. ${yearClass.remedy} The filter was NOT applied. ${yearClass.where}`,
713726
);
714727
}
715728
// [#20549] A comparand the rule reads as another value than it names.
@@ -718,16 +731,15 @@ export function assertTemporalComparandsInterpretable(
718731
throw invalidFilterError(
719732
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field `
720733
+ `against ${preview(hit.value)} at ${hit.path}, ${misread.why}, so it is not a ${hit.kind} `
721-
+ `value this platform can interpret. ${misread.where} The filter was NOT applied. `
722-
+ REMEDY[hit.kind],
734+
+ `value. ${REMEDY[hit.kind]} The filter was NOT applied. ${misread.where}`,
723735
);
724736
}
725737
throw invalidFilterError(
726738
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} `
727739
+ `field against ${preview(hit.value)} at ${hit.path}, which is not a ${hit.kind} value `
728-
+ 'this platform can interpret. It would reach the driver as written, compare false for '
729-
+ 'EVERY row, and return 200 with an empty result — indistinguishable from "there is no '
730-
+ `data". The filter was NOT applied. ${REMEDY[hit.kind]}`,
740+
+ `this platform can interpret. ${REMEDY[hit.kind]} The filter was NOT applied. As written `
741+
+ 'it would reach the driver, compare false for EVERY row, and return 200 with an empty '
742+
+ 'result — indistinguishable from "there is no data".',
731743
);
732744
}
733745

@@ -784,29 +796,29 @@ export function assertHavingTemporalComparandsInterpretable(
784796
if (!hit) return;
785797
const column = havingColumnPhrase(hit, query);
786798
// The year class, in its own words, as on `where` (#20240, #20264, #20280).
799+
// [#27173] The verdict, the prescription, then the consequence, as on `where`.
787800
const yearClass = yearClassOf(hit);
788801
if (yearClass) {
789802
throw invalidFilterError(
790803
`aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, `
791-
+ `${yearClass.year}, the years a ${hit.kind} value may name, so it is not a ${hit.kind} `
792-
+ `value this platform can interpret. ${yearClass.having} The \`having\` was NOT applied. `
793-
+ yearClass.remedy,
804+
+ `${yearClass.year}, so it is not a ${hit.kind} value. ${yearClass.remedy} The \`having\` was `
805+
+ `NOT applied. ${yearClass.having}`,
794806
);
795807
}
796808
// [#20549] The misread classes, in their own words, as on `where`.
797809
const misread = misreadClassOf(hit);
798810
if (misread) {
799811
throw invalidFilterError(
800812
`aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, `
801-
+ `${misread.why}, so it is not a ${hit.kind} value this platform can interpret. `
802-
+ `${misread.having} The \`having\` was NOT applied. ${REMEDY[hit.kind]}`,
813+
+ `${misread.why}, so it is not a ${hit.kind} value. ${REMEDY[hit.kind]} The \`having\` was `
814+
+ `NOT applied. ${misread.having}`,
803815
);
804816
}
805817
throw invalidFilterError(
806818
`aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, `
807-
+ `which is not a ${hit.kind} value this platform can interpret. Compared with each group as `
808-
+ 'written, it would keep no group or every group, a 200 indistinguishable from a real answer. '
809-
+ `The \`having\` was NOT applied. ${REMEDY[hit.kind]}`,
819+
+ `which is not a ${hit.kind} value this platform can interpret. ${REMEDY[hit.kind]} The `
820+
+ '`having` was NOT applied. Compared with each group as written, it would keep no group or '
821+
+ 'every group, a 200 indistinguishable from a real answer.',
810822
);
811823
}
812824

@@ -847,7 +859,7 @@ function judgeResolvedToken(
847859

848860
/** [#20844] The placeholder, where it sits, and what it resolved to — for the message. */
849861
function resolvedTokenPhrase(hit: ResolvedTokenOutsideYears): string {
850-
return `${preview(hit.token)} at ${hit.path}, a relative-date placeholder that resolved to `
862+
return `${preview(hit.token)} at ${hit.path}, a relative-date placeholder resolved to `
851863
+ `${preview(hit.value)} (the year ${resolvedYearOf(hit.value)})`;
852864
}
853865

@@ -866,7 +878,7 @@ function resolvedTokenWords(hit: ResolvedTokenOutsideYears): { cls: string; wher
866878
}
867879
const yearClass = yearClassOutside(hit.kind, hit.value);
868880
return {
869-
cls: `${yearClass.year}, the years a ${hit.kind} value may name`,
881+
cls: yearClass.year,
870882
where: yearClass.where,
871883
having: yearClass.having,
872884
remedy: `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`,
@@ -899,8 +911,8 @@ export function assertResolvedTemporalTokensInRange(
899911
const words = resolvedTokenWords(hit);
900912
throw invalidFilterError(
901913
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field against `
902-
+ `${resolvedTokenPhrase(hit)}, ${words.cls}, so it is not a ${hit.kind} value this platform can `
903-
+ `interpret. ${words.where} The filter was NOT applied. ${words.remedy}`,
914+
+ `${resolvedTokenPhrase(hit)}, ${words.cls}. ${words.remedy} The filter was NOT applied. `
915+
+ words.where,
904916
);
905917
}
906918

@@ -921,7 +933,6 @@ export function assertHavingResolvedTemporalTokensInRange(
921933
const words = resolvedTokenWords(hit);
922934
throw invalidFilterError(
923935
`aggregate('${object}'): ${havingColumnPhrase(hit, query)} compares against ${resolvedTokenPhrase(hit)}, `
924-
+ `${words.cls}, so it is not a ${hit.kind} value this platform can interpret. ${words.having} `
925-
+ `The \`having\` was NOT applied. ${words.remedy}`,
936+
+ `${words.cls}. ${words.remedy} The \`having\` was NOT applied. ${words.having}`,
926937
);
927938
}

0 commit comments

Comments
 (0)