Skip to content

Commit 5dfa45e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20437-replica-needs-syncurl
2 parents 6a07672 + 4a1df19 commit 5dfa45e

44 files changed

Lines changed: 3659 additions & 82 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a number, currency, percent, rating, slider or progress field reads a string by the platform's numeric grammar and stores the number it denotes (#20309)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING**: shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level). The
12+
narrowing: a string that `Number()` reads as a finite number but the platform's
13+
numeric grammar does not is now refused with `400 VALIDATION_FAILED` /
14+
`invalid_number`. It used to be accepted.
15+
16+
**What a caller sees, before → after.** One of these strings written to one of
17+
those fields: `201`, stored as sent (memory kept the string; SQLite kept
18+
`'0x10'` as TEXT and the others as numbers by column affinity) → `400
19+
VALIDATION_FAILED` with the field code `invalid_number`, nothing stored. The
20+
REST create, batch, update and updateMany routes all answer it, and `validate`
21+
(the dry run) predicts it. The forms:
22+
23+
- a radix literal: `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`;
24+
- a whitespace-padded number: `' 12 '`, `'12\n'`, `'\t-3'`;
25+
- a spelling that is not a JSON number: `'+5'`, `'.5'`, `'5.'`, `'007'`.
26+
27+
The fix, when a write is refused: send a JS number, or the number's plain JSON
28+
spelling — `'16'`, `'12'`, `'-3'`, `'5'`, `'0.5'`, `'7'`. `String(n)` of any
29+
finite number always qualifies, exponent forms included (`'1e-7'`, `'1e+21'`).
30+
31+
## What was wrong
32+
33+
This is the separate change the earlier #20309 note (arrays, booleans and
34+
objects refused) left open. The record validator judged a string by `Number()`
35+
while the write carried the string itself, so an accepted string reached the
36+
driver as sent:
37+
38+
- **memory** stored `'12'` as the string `'12'` and read it back as a string;
39+
- **SQLite** stored `'0x10'` as the TEXT `'0x10'` (read back as `16`), and the
40+
other accepted strings as numbers through the column's affinity.
41+
42+
One write, two stored shapes, depending on the backend.
43+
44+
## What changes
45+
46+
- A string is judged by `parseNumericString` from `@objectstack/spec/data`, the
47+
one numeric grammar the filter door also reads: the whole string is a JSON
48+
number literal naming a finite double. Its case table,
49+
`NUMERIC_STRING_GRAMMAR_CASES`, decides every form. No second grammar lives in
50+
the engine.
51+
- An admitted string is stored as the number it denotes, on every backend:
52+
`'12'` is written as `12`, `'1e3'` as `1000`. The rewrite runs at the write
53+
door, before the middleware, the hooks, the `readonlyWhen` locks and
54+
validation read the payload, so a `before*` hook now sees the number. The
55+
caller's own object is not mutated.
56+
- `min`, `max`, `scale` and `precision` read that number, exactly as they read
57+
a number: `'12.50'` passes `scale: 1` (it is `12.5`), and `'150'` over
58+
`max: 100` is `max_value`.
59+
- This holds on every engine, REST, batch and updateMany door, and in
60+
`validate` (the dry run). The server `/import` route is unchanged: its own
61+
cell reader turns a numeric cell into a number before the write, so the
62+
grammar never sees a string from it.
63+
- A blank is still `null` before the check (#20308). `summary` is still not
64+
judged. A number, and an array, boolean or object, are answered as before.
65+
66+
## Who sends numeric strings
67+
68+
objectui's CSV import wizard, on its legacy per-row fallback (`legacyImport`,
69+
used only when the connected client cannot reach the server `/import` route),
70+
posts each raw cell to `create` after a client check of
71+
`!isNaN(Number(value))`. Its parser trims cells, so of the refused forms it can
72+
send the radix literals and the non-JSON spellings. Those rows now fail with
73+
`invalid_number` instead of storing a string. The fix there is the wizard's
74+
default path: import through the server `/import` route, whose cell reader
75+
converts the number before the write. Every interactive form widget sends a JS
76+
number or `null`, and is unaffected.
77+
78+
## Rows already stored
79+
80+
This judges new writes only; a stored value is never re-read by the check. On
81+
memory, an accepted string stayed a string until the record is next written.
82+
On SQLite, the earlier #20309 note's query finds a numeric column holding TEXT
83+
(such as `'0x10'`):
84+
85+
```sql
86+
SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text';
87+
```
88+
89+
OBJECT is the object name and FIELD is the field name. Nothing here rewrites
90+
such a cell; decide its number by hand.
91+
92+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched and no metadata key is added, removed or reshaped, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What narrows is the set of caller-written string VALUES a number-typed field accepts at the write door, judged by the spec's existing numeric grammar; stored rows are never re-read, and a caller that sends a number, a blank or a grammar-admitted string is unaffected. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a string compared against a number field must be a number: a non-numeric one is refused with `INVALID_FILTER` / 400 on `where`, a per-aggregation `filter` and `having`, and a numeric one is narrowed to its number (#20351)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a filter COMPARAND at the engine's query door: no authorable key, spelling or stored shape moves, `packages/spec` is untouched (the grammar, the verdict and the words shipped with the spec contract), and no stored row is read or rewritten. What is refused is a string that names no number, compared against a declared numeric field or a numeric aggregated column; which number the caller meant is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a filter comparand (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what a filter may compare a number field with. A string the platform's numeric grammar does not read as a number used to answer 200 with no rows (every row under `$ne`) on memory and SQLite and a 500 on PostgreSQL; it now answers 400, before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. `@objectstack/objectql`'s root exports are unchanged.
12+
13+
FROM a string that is not a JSON number spelling of a finite number (`"abc"`, `""`, `" 12 "`, `"0x10"`, `"1,000"`, `"+5"`, `"007"`, `"Infinity"`, a `{placeholder}`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) at the implicit comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, or a member of `$in` / `$nin` / `$between` → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number (`12`, `-3.5`, `1e3`) or a string of exactly that spelling (`"12"`).
14+
15+
Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), three rows (5, 12, 30):
16+
17+
| position | comparand on a `number` field | before: memory · SQLite · PostgreSQL 16 | now, on all three |
18+
|:--|:--|:--|:--|
19+
| `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"`; `$eq ""` | no rows · no rows · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
20+
| `where` | `$ne "abc"` | every row · every row · 500 | `INVALID_FILTER` / 400 |
21+
| `where`, over REST | `$gt "{current_user_id}"` (resolved to the user's id) | no rows · no rows · 500 | `INVALID_FILTER` / 400 |
22+
| per-aggregation `filter` | `$gt "abc"` (`$ne "abc"`) | count 0 (3), on all three | `INVALID_FILTER` / 400 |
23+
| `having` on `sum(amount)` | `$gt "abc"` (`$ne "abc"`) | no group (every group), on all three | `INVALID_FILTER` / 400 |
24+
| `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1 row, the number's answer |
25+
26+
What changes:
27+
28+
- A new door at the engine's single filter collection point, after the temporal-comparand door. It reads `@objectstack/spec/data`'s published contract (`numberComparandDoorVerdict` over `NUMERIC_VALUE_TYPES`, the numeric grammar, and `numberComparandRefusalMessage` for the words); the engine carries no numeric grammar of its own.
29+
- It runs on `where` in both spellings (the filter object and the `FilterArray` sugar) on `find`, `findOne`, `count`, `aggregate`, `update` and `delete`, and on `IObjectQLEngine.judgeFilter`; on each per-aggregation `filter`, against the object's declared fields; and on `having`, over the columns the engine classes numeric.
30+
- A numeric string is rewritten to its number, copy-on-write, before any driver or in-memory evaluator reads it. InMemoryDriver used to compare `"12"` as a string and match nothing; it now matches what `12` matches, as SQLite and PostgreSQL already did.
31+
- A `{placeholder}` compared against a number field is refused unresolved: every filter token resolves to an id or a date, never a number.
32+
33+
**Who is affected.** A caller that compares a number field with a string that is not a plain number, through any door that reaches the engine: a REST query parameter (`?amount=abc`), a `where` / `$filter` / `filter` body of `POST /data/:object/query`, or an in-process engine call. A caller that sends a number, or a numeric string such as `"12"` or `"1e3"`, is unaffected, except that memory now answers it as the other drivers do.
34+
35+
**Unchanged.** A numeric comparand: the `$gt 10` controls at `where`, the per-aggregation `filter` and `having` answered identically before and after on memory, SQLite and PostgreSQL, through the engine and REST. Not judged by this door, so the filter reaches the driver as written (pinned per case in the engine suite): a string compared against a non-numeric field; `$null` / `$exists` / `$empty`; the text operators (a text operator over a number field keeps its own refusal); a `{ $field }` reference; a dotted key; a key that names no declared field. A `formula` field is still refused one door earlier with `INVALID_FIELD`. RLS, sharing and tenant predicates the security layer composes onto a query are not judged by this door; a policy predicate is judged at authoring where the host hands the rule the engine's `judgeFilter`. A boolean or a `Date` compared against a number field is outside this contract (it judges strings) and keeps its old answer, measured: `$gt true` no rows on memory, every row on SQLite and a 500 on PostgreSQL; a `Date` no rows on memory and SQLite and a 500 on PostgreSQL.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/metadata-protocol": patch
5+
---
6+
7+
A grouped or aggregated query now honours `search`: the groups and every aggregated number are computed over the searched rows, exactly the rows the same query without `groupBy` / `aggregations` returns.
8+
9+
Clause-②: yes (widening) — `EngineAggregateOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the aggregate options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them.
10+
11+
`QuerySchema.search` (ADR-0061) is declared on the query beside `groupBy` and `aggregations`, with no carve-out. Until now, `POST /data/:object/query` accepted a body such as `{ groupBy: ["business_unit"], aggregations: [{ function: "count", alias: "count" }], search: "harbour" }` and answered it with the UNSEARCHED groups — no error and no warning — while the same body without `groupBy` / `aggregations` returned only the searched rows. A grouped list view under a toolbar search would therefore show group headers that ignore what the user typed.
12+
13+
- **`@objectstack/spec`** — `EngineAggregateOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. A parse used to strip them.
14+
- **`@objectstack/objectql`** — `engine.aggregate()` (and `ctx.api.object(name).aggregate()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` uses: the same server-resolved searchable fields, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two. It applies on both aggregate paths, native `driver.aggregate()` and the in-memory lowering. A key the verb still does not execute, such as `$search`, is refused as before.
15+
- **`@objectstack/metadata-protocol`** — `findData`'s grouped branch passes `search` / `searchFields` to `engine.aggregate()`. `searchFields` is validated on that branch exactly as on the flat one: a column search cannot scan is `400 INVALID_FIELD`.
16+
17+
Nothing to migrate. A caller that worked around the gap, for example by grouping a page of searched rows on the client, can send the grouped query with its `search` instead.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`hook.form.ts`'s `condition` row now declares `language: 'expression'`, matching the CEL predicate `HookSchema.condition` actually is.
6+
7+
Clause-②: no
8+
9+
The row previously declared `language: 'javascript'` — the same declared language as a real script row (`body.source`) — over a field that is `EvaluatedExpressionInputSchema`, a CEL predicate. A consumer keyed on the row's declared language could not tell the predicate apart from a script. The `helpText` moves from "Optional formula — skip the hook when this evaluates to false" to "CEL predicate — the hook runs only when TRUE", matching the phrasing every sibling predicate row (`field.form.ts` / `object.form.ts`'s `visibleWhen` / `readonlyWhen` / `requiredWhen`, and the formula `expression` row) already uses.
10+
11+
No key is added, removed, narrowed or widened, and no parse verdict changes — `type: 'code'` and `language` are already-declared form-DSL vocabulary. `metadata-form-declared-rows.pin.test.ts` pins the new value, with a control against a sibling predicate row.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics): both analytics filter faces answer `$empty` by the field's declared type (#20445)
6+
7+
Clause-②: yes (widening)
8+
9+
`$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Both of this package's filter faces now answer it by that table, through the spec's one expansion (`expandEmptyOperator`), instead of refusing it:
10+
11+
- **The analytics `where`** (`/analytics/query`, `/analytics/sql`, dataset filters): `NativeSQLStrategy` and the `ObjectQLStrategy` SQL echo compile the field's declared row; the ObjectQL execute path hands `{ $empty }` to the data engine, which answers it once the engine's own arm lands (until then the engine refuses it, `INVALID_FILTER` / 400, as it does today).
12+
- **Row-level read scopes** compiled to SQL (`compileScopedFilterToSql`): same rows, in the read-scope envelope.
13+
14+
A multi-value field's empty list is tested with a JSON function per SQL dialect (`json_array_length` on SQLite, a `jsonb` comparison on Postgres, `JSON_LENGTH` on MySQL).
15+
16+
**Refused, never guessed** — `INVALID_FILTER` / 400 on the `where` face, `READ_SCOPE_COMPILE_FAILED` / 500 on a read scope — when the host cannot name the field's declared type (no `sourceFieldMeta` wired, or no such field), when a multi-value field's datasource dialect is unknown, and when the flag is not a boolean (`$empty: 'true'` is refused like a non-boolean `$null`).
17+
18+
Host API (two new optional members, hence `minor`): `AnalyticsServiceConfig.sourceFieldMeta` may now answer `multiple` beside `type`, and `AnalyticsServicePlugin` relays it from the field definition; `compileScopedFilterToSql` takes an optional `declaredValueShape` option. A host whose `sourceFieldMeta` answers `type` but not `multiple` has every multi-capable field it declared `multiple: true` (select / radio / lookup / user / file / image) read as single-valued, which is the null-only row. On such a field a read scope's `$empty: false` then admits rows holding `[]`, and `$empty: true` misses them. Relay the field's `multiple` from its definition to get the list row.
19+
20+
`$empty` stays staged: it is not in `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` still lower to `$null`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): correct `GanttConfig.timeZone`'s describe — persisted gantt drops on a `date` field are not "real instants" (#20466)
6+
7+
Clause-②: no
8+
9+
The `GanttConfig` `timeZone` member's `.describe()` said "persisted data stays real
10+
instants" for every field. That is false for a `Field.date` column: per the spec's own
11+
storage rule (`temporalStorageForm` / ADR-0053), a gantt drop on a `date` field writes the
12+
calendar day it landed on, as a timezone-naive `YYYY-MM-DD`, while a `datetime` field
13+
still writes the real instant. Only the false clause is replaced — "a datetime value is
14+
still written as the real instant, and a date value as the calendar day it was dropped on
15+
in this zone's calendar (`YYYY-MM-DD`)" — the rest of the describe, and every other
16+
member, is unchanged.
17+
18+
No key moves and no verdict moves: this corrects a published describe's prose to match
19+
the contract it already had, it does not add, remove or re-scope anything authorable. The
20+
JSON Schema and reference docs regenerate from the corrected source.

0 commit comments

Comments
 (0)