Repository navigation
Commit b285508
feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar (#20414)
Fixes #20336
Clause-②: yes
## What this adds
This is lane (1) of the split triage routed on #15661's precedent: the
**contract** for the number-comparand declared-type door, plus the
platform's **one numeric grammar** for a string. It adds one module to
`@objectstack/spec/data`, `filter-number-comparand-declared-type.ts`,
beside `filter-text-operator-declared-type.ts`, together with its test,
the barrel line, the regenerated `api-surface/data.json` and
`export-origins/data.json`, and a `minor` changeset. **No door is
written here, and no evaluator changes.** The engine door and its
three-driver, REST and per-aggregation `filter` pins are #20351's, which
stays open and is `Blocked-by:` this card. The write side's string half
adopts the same grammar under #20309, which also stays open.
The new exports (27, all additive; `api-surface/data.json` +27 / -0):
- **The grammar**
- `NUMERIC_STRING_PATTERN`.
- `parseNumericString(s)` returns the number, or `undefined`.
- `readNumericString(s)` returns the number, or one of eight named forms
(`NON_NUMERIC_STRING_FORMS`).
- `NUMERIC_STRING_GRAMMAR_CASES` is the table of forms: 41 rows, each
with its reason.
- **The verdict**
- `numberComparandFieldVerdict(field)` returns `judged` / `not-judged` /
`deferred`.
- `numberComparandDoorVerdict(field, comparand)` returns one of:
- `door-refusal` (`INVALID_FILTER` / 400, with a `form`);
- `narrows` (with the number);
- `passes`;
- `deferred`.
- `NUMBER_COMPARAND_DOOR_JUDGED_TYPES` is `NUMERIC_VALUE_TYPES` itself,
by identity.
- `NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` /
`NUMBER_COMPARAND_DOOR_LIST_OPERATORS`.
- **The words:** `numberComparandRefusalMessage(site, context?)`. It
names the field, the declared type, the bounded comparand, its position
and what is wrong with the string, all ahead of the remedy.
- **For the engine suite**
- `NUMBER_COMPARAND_DOOR_FIXTURE` has one field per `FieldType`, plus
the formula variants.
- `NUMBER_COMPARAND_DOOR_CASES` has 136 derived cases: 52 refusals, 23
narrowings, 60 passes, 1 deferred.
## Decisions this contract takes (each argued in the module header; the
four-axis reasons are below)
1. **The grammar is a JSON number literal that names a finite double.**
Its forms were checked one by one against the card's A2 list
(`NUMERIC_STRING_GRAMMAR_CASES` is the record):
- **Admitted:** `"12"`, `"-3"`, `"12.5"`, `"0.10"`, `"1e3"`, `"2.5E+3"`,
`"1e-7"`, `"1e+21"`.
- Every `String(n)` for a finite `n` is admitted and parses back to `n`.
This is pinned as a round-trip property over 418 numbers.
- So a URL query is never refused: `?amount=5` is lowered to an implicit
`{ amount: "5" }` in `metadata-protocol`'s `findData`.
- Neither is `URLSearchParams`, nor a plain CSV cell.
- Exponents are admitted because `String(1e-7)` is `"1e-7"`.
- **Refused, and `Number()` would have read them:** `""`, `" "`, `" 12
"`, `"0x10"`, `"0o17"`, `"0b101"`, `"+5"`, `".5"`, `"5."`, `"007"`.
- Hex and whitespace-padded strings are refused by name in #20309's
triage direction.
- The rest are refused for their spelling alone.
- The test pins exactly this list, so that the narrowing against
`Number()` stays deliberate.
- **Refused:** `"Infinity"`, `"NaN"`, `"1e400"`, `"1,000"`, `"1.000,5"`,
`"1_000"`, `"1 000"`, full-width digits, U+2212, and every
`{placeholder}`.
2. **The door narrows a numeric string to its number**, copy-on-write.
- This is the pattern the comparand-type door already uses for an
exact-range `bigint`, and the write side's "store the parsed number".
- Left as a string, `"12"` is read three ways:
- JS loose equality and relational comparison coerce it
(`driver-memory`'s matcher, read at source).
- SQLite applies numeric affinity.
- `driver-mongodb` compares by BSON type: its filter compiler coerces
temporal comparands only (read at source).
3. **`summary` is judged.** Its column is numeric on every SQL dialect:
`numericColumnFor` is defined for every judged type, and that is pinned.
The write door exempts `summary` through `COMPUTED_VALUE_TYPES`, but
that set answers *who writes*, not *what may be compared*. This answers
A1.
4. **`formula` is judged by its `returnType`**, through the text door's
own `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`:
- `number` is judged;
- `text`, `boolean` and `date` are not number fields;
- an absent `returnType` is deferred.
As with the text door, no formula filter reaches the engine seam,
because the unmaterializable-field door refuses every one of them first
with `INVALID_FIELD`. The case table says the engine suite must
partition those rows out. This also answers A1.
5. **Judged positions:** implicit equality; `$eq` / `$ne` / `$gt` /
`$gte` / `$lt` / `$lte`; and each member of `$in` / `$nin` / `$between`.
- Pinned: these, the seven text operators, and `$null` / `$exists`
partition `FieldOperatorsSchema`'s keys exactly.
- Only string comparands are judged. A number, `null`, a `Date`, a
boolean or a `bigint` passes.
6. **A `{placeholder}` against a number field is refused, not stepped
around.**
- Every filter token resolves to a user or organization id, a
`YYYY-MM-DD` day or an ISO instant (`resolveFilterToken` in
`@objectstack/core`). None of these is numeric, so a resolved token
reaches PostgreSQL as the same 500.
- The field-aware doors run before the token resolver.
7. **A blank string is refused.**
- On the write side, a blank becomes `null` before the number arm
(#20308).
- On the read side, `is_empty` lowers to `$null`, and #20311's ruling B
keeps a number field on null alone.
- objectui drops rows whose value is empty before they reach the server:
`viewFilterFold.ts` and `ListView.convertFilterGroupToAST`, read at
objectui `9f0c84a`. So the console sends no `{ amount: "" }`.
8. **The words and the envelope (A4):** no new error code.
`INVALID_FILTER` / 400 is spelled as a literal, as
`filter-comparand-type.ts` does, and pinned to `StandardErrorCode`. Each
form's clause states only what holds for that form, so `"+5"` is not
claimed to diverge between backends. Every refusal in the case table
fits inside the 500-character client bound, and that is pinned.
### Four-axis reasons (for the grammar and the narrowing)
- **Real business need:** the measured producers of numeric strings.
- Read side: REST query parameters, lowered to implicit filters
(`metadata-protocol` `findData`).
- Write side: objectui's CSV-import legacy fallback (census 5863923799
on #20309).
- Both carry what `String(n)` or a spreadsheet writes for a plain
number. Every such form is admitted.
- No measured producer writes `+5`, `.5`, `5.` or `007`. The objectui
filter builder sends JS numbers (`convertScalarToFamily`).
- **Long-term soundness:** one grammar in one place, read by both doors.
It is externally specified (RFC 8259 §6), so it is one sentence.
Narrowing on the read side mirrors the write side's "store the parsed
number", so there is one reading per string.
- **Keeping AI from writing wrong metadata or code:** the contract is
tightened, never loosened.
- Every form the backends read differently is refused loudly, with a
named form and a remedy: blank, padded, hex, Infinity/NaN, locale
separators, placeholders.
- Nothing is silently coerced beyond the one declared conversion.
- **No scope creep at the startup stage:** one module, no new error
code, no gate, no door. The engine door stays #20351's.
## Premise check (rule 6)
- `normalizeFilterComparandTypes` takes no schema. This was confirmed at
`ab6fb027`, which is why this is a new module (the triage retriage
route).
- The precedent's shape matches A1: sets referenced by identity, a pure
verdict, a fixture, and a derived case table.
- A3 holds: the verdict needs no spec change beyond the new module.
`@objectstack/objectql` imports `@objectstack/spec/data` already. Its
build closure builds against this spec (turbo, 14/14 tasks at
`b49a49b0`), and `check:lean-entry-closure` holds its admitted set (15
packages).
- No package imports the new module yet. `git grep -l
"filter-number-comparand-declared-type" -- packages` finds only the
barrel, the module's own test and the generated
`export-origins/data.json` shard.
## Tests (at `b49a49b0` unless noted)
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
`check:test-typecheck` OK, with the new test file compiled and no debt
added.
- `vitest run --project local` (spec `test`): 562 files, 16551 tests
passed (1 todo).
- `vitest run --project repo` (spec `test:repo`): 35 files, 634 tests
passed. It also passed at `a5680789`, before the main merge.
- The new test file: 34 tests. It pins these, in both directions:
- the grammar table;
- the round-trip property;
- the stricter-than-`Number()` list;
- the FieldType census;
- `summary`'s numeric column;
- the operator partition;
- the envelope literal;
- that every form's words differ and carry no tracker number;
- the 500-character bound and front-loading;
- fixture legality (`FieldSchema` / `ObjectSchema`);
- the case-table agreement with the verdict;
- `parseFilterAST` accepting every case.
- `pnpm --filter @objectstack/spec check:generated` at `b49a49b0`, after
rebuilding from the merged tree: all 15 artifacts are up to date.
## Gates (`dispatch-gates --commands --repo objectstack-ai/objectstack`
at `b49a49b0`: 85 derived)
- 83 exited 0, and 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET):
- `check:dual-build-cjs-loads`: needs every package built.
- `check:type-check-debt`: needs the full build closure.
CI runs both.
- `dispatch-gates --ran`: 85 derived families are accounted for (83 run,
2 NOT-MEASURED, derived from the recorded exit 3), 0 UNRUN.
- For comparison, the dispatch list derived at `ab6fb027` had 72 lines.
The diff and changeset added 13 families, all of them run above:
- `check-empty-changeset` ×2
- `release-rehearsal-clone --self-test`
- spec `check:generated`
- `check:engine-double-contract`
- `check:objectql-double-limit`
- `check:objectui-changeset`
- `check:pm-changeset-deadline-census`
- `check:pm-widening-tells`
- `check:query-options-erasure`
- `check:type-check-coverage`
- `check:type-check-debt`
- `check:where-matcher`
- On the first pass these gates needed a prerequisite and were rerun
green:
- `check-plugin-teardown-shape --self-test` needed its pinned fixture
commit fetched at depth 1 on this shallow clone.
- `check:doc-formula-expressions` and `check:lean-entry-closure` needed
the `@objectstack/objectql` closure built.
## Acceptance notes
- **The door and its pins are #20351's.** The case table's census
comparands are chosen so that no other door on the seam refuses them:
`"2026-01-01"` / `"12:00"` for the temporal fields, `"abc"` otherwise.
That choice is a design claim for #20351 to measure, not something
measured here.
- **Not measured live in this change:**
- PostgreSQL's reading of the admitted and refused forms (documented
numeric input only);
- `driver-mongodb`'s numeric-string answer (read at source);
- SQLite's affinity on read comparands (the write-side cells were
measured on #20309).
- **Out of this card's scope, noted and not filed:** a boolean or a
`Date` compared against a number field is not judged here. The card and
its direction are about strings. PostgreSQL's answer for those is not
measured.
- **Line budget:** n/a (no `skills/**` path in the diff).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3cf6449 commit b285508
6 files changed
Lines changed: 1228 additions & 0 deletions
File tree
- .changeset
- packages/spec
- api-surface
- export-origins
- src/data
Lines changed: 13 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
438 | 438 | | |
439 | 439 | | |
440 | 440 | | |
| 441 | + | |
441 | 442 | | |
442 | 443 | | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
443 | 451 | | |
444 | 452 | | |
445 | 453 | | |
| 454 | + | |
| 455 | + | |
446 | 456 | | |
447 | 457 | | |
448 | 458 | | |
| |||
462 | 472 | | |
463 | 473 | | |
464 | 474 | | |
| 475 | + | |
465 | 476 | | |
466 | 477 | | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
467 | 487 | | |
| 488 | + | |
| 489 | + | |
468 | 490 | | |
469 | 491 | | |
470 | 492 | | |
| |||
831 | 853 | | |
832 | 854 | | |
833 | 855 | | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
834 | 859 | | |
835 | 860 | | |
836 | 861 | | |
837 | 862 | | |
838 | 863 | | |
839 | 864 | | |
| 865 | + | |
840 | 866 | | |
841 | 867 | | |
842 | 868 | | |
843 | 869 | | |
844 | 870 | | |
845 | 871 | | |
846 | 872 | | |
| 873 | + | |
847 | 874 | | |
848 | 875 | | |
849 | 876 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
428 | 428 | | |
429 | 429 | | |
430 | 430 | | |
| 431 | + | |
431 | 432 | | |
432 | 433 | | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
433 | 441 | | |
434 | 442 | | |
435 | 443 | | |
| 444 | + | |
| 445 | + | |
436 | 446 | | |
437 | 447 | | |
438 | 448 | | |
| |||
452 | 462 | | |
453 | 463 | | |
454 | 464 | | |
| 465 | + | |
455 | 466 | | |
456 | 467 | | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
457 | 477 | | |
| 478 | + | |
| 479 | + | |
458 | 480 | | |
459 | 481 | | |
460 | 482 | | |
| |||
818 | 840 | | |
819 | 841 | | |
820 | 842 | | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
821 | 846 | | |
822 | 847 | | |
823 | 848 | | |
824 | 849 | | |
825 | 850 | | |
826 | 851 | | |
| 852 | + | |
827 | 853 | | |
828 | 854 | | |
829 | 855 | | |
830 | 856 | | |
831 | 857 | | |
832 | 858 | | |
833 | 859 | | |
| 860 | + | |
834 | 861 | | |
835 | 862 | | |
836 | 863 | | |
| |||
0 commit comments