Skip to content

Commit 90ba78d

Browse files
committed
Merge origin/main into claude/issue-20873-aggregation-filter-array-membership
Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
2 parents e9d4728 + 7fa67da commit 90ba78d

14 files changed

Lines changed: 719 additions & 97 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
A dataset's date dimension reads the bucket key `@objectstack/core`'s `bucketDateKey` writes, with its year in four digits, and a draft preview keys a row the way the same dataset does once published.
6+
7+
- **Dimension labels (`queryDataset`).** A date dimension's grouped key is labelled as written. The year key `0050` was labelled `1970` (read as epoch seconds, because the year check admitted only 1000..9999), and a month or day key lost its padding (`0050-06` became `50-06`, `0050-06-15` became `50-06-15`). A raw date value is relabelled with the year in four digits too. A year from 1000 to 9999 is labelled as before.
8+
- **Draft preview (`queryDataset` with `previewDrafts`).** Drafted seed rows are keyed by `bucketDateKey` itself, the key the published path's grouping writes. For 0050-06-15 the preview answered `50`, `50-Q2`, `50-06` and `50-06-15`; it now answers `0050`, `0050-Q2`, `0050-06` and `0050-06-15`. A `week` bucket is now the ISO week label (`2026-W25`), no longer the Monday's date (`2026-06-15`), so a weekly `compareTo` in the preview merges each comparison row onto its week, as the published path does. An epoch-milliseconds value is bucketed by its instant (it was the empty bucket), and a `Date` in 0001..0999 by its own year (a `Date` in 0050 keyed `1950`).
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
fix(formula,plugin-security): the refusal of a field-to-field comparison across comparison classes now leads with its remedy, so the remedy reaches REST callers (#20869)
7+
8+
Clause-②: no
9+
10+
A row-level policy that compares two fields of no shared comparison class (text against a number, or any field against a file field, a formula field, or a field that holds a list or an object) is refused with `INVALID_FILTER` / 400. The REST door keeps a 4xx message under 500 characters by cutting it to its first 499 characters plus an ellipsis. Both messages for this refusal put the remedy last, so the remedy was always cut off, and a caller read the diagnosis but never the fix:
11+
12+
- The record matcher's message (`@objectstack/formula`, raised by the RLS write check on an insert or update through `/data`) was 972 characters, with the remedy starting at character 825.
13+
- The explain engine's message (`@objectstack/plugin-security`, answered by `GET` / `POST /api/v1/security/explain`) put the remedy after the policy names and the diagnostic. Those have no length limit, so the message was 601 characters with a short policy name and longer with longer names.
14+
15+
Both messages now start with the remedy. It is the same sentence as before and has only moved:
16+
17+
- The record matcher's message is 494 characters and reaches the wire whole. In order it says: the remedy; that the two columns share no class, and which classes exist; why the comparison is refused; and why the columns are not named. It still names no column, operator or policy; the server log names them.
18+
- The explain engine's message starts with the remedy, then names the policy and both columns, then gives the reason. Whatever the names' length, the remedy sits in the first 125 characters. With long names the REST door may cut the reason at the end.
19+
20+
Unchanged: the error code (`INVALID_FILTER`), the status (400), which comparisons are refused, the refusal a find answers with (driver-sql's read refusal, 383 characters, which already reached the wire whole), and every other refusal.

‎packages/formula/src/matches-filter-cross-field-class.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,31 @@ describe('matchesFilterCondition — a field compared with a field of no shared
161161
expect(crossFieldClassRefusalCarriedBy(new Error('x'))).toBeNull();
162162
});
163163

164+
it('leads with its remedy and fits the REST client-message bound whole, however long the column names', () => {
165+
// The REST door cuts a 4xx message of 500 characters or more to 499 plus an
166+
// ellipsis (`CLIENT_MESSAGE_MAX`, `@objectstack/rest`): it keeps the HEAD.
167+
const remedy =
168+
'In a row-level policy, compare a field only with a field of the same class, or fix the declaration of ' +
169+
'the one that is declared with the wrong type.';
170+
const long = (stem: string) => `${stem}_${'x'.repeat(120)}`;
171+
const longFields = { [long('stage')]: { type: 'text' }, [long('amount')]: { type: 'number' } };
172+
const shortErr = refusalOf({ status: { $ne: { $field: 'amount' } } })!;
173+
let longErr: WireBearingError | null = null;
174+
try {
175+
matchesFilterCondition({}, { [long('stage')]: { $ne: { $field: long('amount') } } } as never, { fields: longFields });
176+
} catch (e) {
177+
longErr = e as WireBearingError;
178+
}
179+
expect({ code: longErr?.code, status: longErr?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
180+
// It names no column, so its length does not depend on theirs.
181+
expect(longErr?.message).toBe(shortErr.message);
182+
expect(shortErr.message.startsWith(remedy)).toBe(true);
183+
expect(shortErr.message.length).toBeLessThan(500);
184+
// After the remedy: what is refused, why, and why the columns are withheld.
185+
const at = (s: string) => shortErr.message.indexOf(s);
186+
expect([at('share no class'), at('so it is refused'), at('withheld')].every((i, n, a) => i > remedy.length && (n === 0 || i > a[n - 1]))).toBe(true);
187+
});
188+
164189
it('findCrossFieldClassRefusal answers null for a filter whose comparisons all compare', () => {
165190
expect(findCrossFieldClassRefusal({ $and: [{ status: { $eq: { $field: 'title' } } }, { amount: { $lt: { $field: 'budget' } } }] }, FIELDS)).toBeNull();
166191
expect(findCrossFieldClassRefusal({ amount: { $lt: { $field: 'status' } } }, FIELDS)).toMatchObject({

‎packages/formula/src/matches-filter.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -507,19 +507,23 @@ const CROSS_FIELD_CLASS_REFUSAL = Symbol.for('objectstack.formula.crossFieldClas
507507
* — driver-sql withholds the same comparison's columns on the read for the
508508
* same reason (#7929). The columns, the operator and both declarations travel
509509
* on the error for the server log ({@link crossFieldClassRefusalCarriedBy}).
510+
*
511+
* The remedy leads, and the whole message stays under the REST door's client
512+
* message bound (`CLIENT_MESSAGE_MAX` in `@objectstack/rest`: a 4xx message of
513+
* 500 characters or more is cut to 499 plus an ellipsis). The bound cuts the
514+
* TAIL, so a remedy written last never reached the wire. The order is: the
515+
* remedy; what is refused (two columns with no shared class, and the classes);
516+
* why it is refused; why the columns are withheld. The text is fixed, so its
517+
* length is too — a sentence added here must be paid for by a shorter one.
510518
*/
511519
function crossFieldClassError(refusal: CrossFieldClassRefusal): Error {
512520
const err = new Error(
513-
'A field-to-field comparison ({ "$field": … }) in this filter compares two columns that share no ' +
514-
'comparison class. Two columns are compared only within one class — a number with a number, text ' +
515-
'with text, a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day ' +
516-
'with a time of day — and a file field, a formula field, or a column that holds a list or an object ' +
517-
'has no class at all, so the platform defines no answer for this comparison. It is refused rather ' +
518-
'than evaluated: across classes SQL and this evaluator answer differently, and the read path refuses ' +
519-
'the same comparison, so an answer here would give one access policy two meanings. The columns and ' +
520-
'the operator are withheld from this message because the filter may be an access policy the caller ' +
521-
'did not write; the server log names them. In a row-level policy, compare a field only with a field ' +
522-
'of the same class, or fix the declaration of the one that is declared with the wrong type.',
521+
'In a row-level policy, compare a field only with a field of the same class, or fix the declaration ' +
522+
'of the one that is declared with the wrong type. This filter compares two columns that share no ' +
523+
'class (number, text, boolean, date, datetime, time; file, formula, list and object fields have ' +
524+
'none). SQL and this evaluator answer it differently, so it is refused, as on the read path. The ' +
525+
'columns and operator are withheld, as the caller may not have written the policy; the server log ' +
526+
'names them.',
523527
) as Error & { code?: string; status?: number };
524528
err.code = StandardErrorCode.enum.INVALID_FILTER;
525529
err.status = 400;

‎packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,26 @@ const ROW = { id: 'r1', status: 'open', title: 'x', amount: 5 };
180180

181181
const rlsRecordOf = (d: ExplainDecision) => d.layers.find((l) => l.layer === 'rls')?.record;
182182

183+
/**
184+
* The remedy explain's refusal leads with. It comes before the policy names and
185+
* the diagnostic, which have no length bound, because the REST door keeps only
186+
* a long message's first 499 characters.
187+
*/
188+
const REMEDY =
189+
'Compare a field only with a field of the same class, or fix the declaration of the one that is declared with ' +
190+
'the wrong type.';
191+
183192
/**
184193
* Explain's answer is the find's refusal: the same envelope, no decision and so
185-
* no record verdict, and a message that names the policy and both columns.
194+
* no record verdict, and a message that leads with the remedy and names the
195+
* policy and both columns.
186196
*/
187197
async function expectExplainRefuses(p: Promise<unknown>, columns: [string, string]): Promise<void> {
188198
const r = await refusalOf(p);
189199
expect(r).not.toBe('answered');
190200
if (r === 'answered') return;
191201
expect({ code: r.code, status: r.status }).toEqual(INVALID);
202+
expect(r.message.startsWith(`${REMEDY} `), r.message).toBe(true);
192203
expect(r.message).toContain(`'${POLICY}'`);
193204
for (const column of columns) expect(r.message).toContain(`"${column}"`);
194205
}

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,14 @@ function refusedPolicyNamesOf(
923923
* same caller for the same object publishes the same predicate: `readFilter`
924924
* without a `recordId`, the `rls` layer's `rowFilter` with one. So naming the
925925
* policy and its two columns here discloses nothing that report does not.
926+
*
927+
* The remedy leads, BEFORE the subject. The REST door bounds a 4xx message
928+
* (`CLIENT_MESSAGE_MAX` in `@objectstack/rest`: 500 characters or more is cut
929+
* to 499 plus an ellipsis), and the subject and the diagnostic have no length
930+
* bound: object, field and policy names declare no maximum, and the subject
931+
* lists every refused policy. So no subject-first order can keep a trailing
932+
* remedy on the wire for every policy; at index 0 it survives any length. The
933+
* reason comes last and is the part a long subject may cut.
926934
*/
927935
function crossFieldRefusalForExplain(
928936
cause: unknown,
@@ -939,10 +947,10 @@ function crossFieldRefusalForExplain(
939947
: `The row-level security ${policies.length === 1 ? 'policy' : 'policies'} ` +
940948
`${policies.map((p) => `'${p}'`).join(', ')} on '${object}'`;
941949
const err = new Error(
942-
`${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement refuses every request this filter ` +
943-
'scopes instead of judging a record (the find answers INVALID_FILTER / 400), so explain answers with the ' +
944-
'same refusal and reports no verdict. Compare a field only with a field of the same class, or fix ' +
945-
'the declaration of the one that is declared with the wrong type.',
950+
'Compare a field only with a field of the same class, or fix the declaration of the one that is ' +
951+
`declared with the wrong type. ${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement ` +
952+
'refuses every request this filter scopes (the find answers INVALID_FILTER / 400), so explain answers ' +
953+
'with the same refusal and reports no verdict.',
946954
);
947955
const { code, status } = cause as { code?: string; status?: number };
948956
return Object.assign(err, { code, status, cause });

‎packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ for (const [driverName, makeDriver, available] of DRIVERS) {
204204
it(`${c.id} \`${c.predicate}\` — the 400 names neither column; the server log names the policy and both`, async () => {
205205
const w = await boot(makeDriver, 'check', c.predicate);
206206
const message = await messageOf(w.engine.insert(w.OBJ, NEW, { context: w.caller } as never));
207-
expect(message).toMatch(/^A field-to-field comparison/);
207+
// The remedy leads: the REST door cuts a long message's tail, never its head.
208+
expect(message).toMatch(/^In a row-level policy, compare a field only with a field of the same class/);
208209
for (const column of c.columns) expect(message).not.toContain(column);
209210

210211
const lines = w.refusalLines();

0 commit comments

Comments
 (0)