Skip to content

Commit d7d47a2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16019-driver-declared-fault
2 parents a5acd49 + cf74a11 commit d7d47a2

70 files changed

Lines changed: 5964 additions & 387 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
feat(spec)!: `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041)
7+
8+
<!-- adr-0087: registered analytics-time-dimension-date-range-vocabulary-closed -->
9+
10+
**BREAKING** — an accept-set narrowing on a published analytics contract.
11+
`AnalyticsQuerySchema.timeDimensions[].dateRange` (and with it the
12+
`POST /analytics/query` / `/analytics/sql` bodies, `AnalyticsQueryRequestSchema`,
13+
and the `AnalyticsQuery` type every driver and `AnalyticsService.query` caller is
14+
typed against) used to accept ANY string. It now accepts exactly the thirteen
15+
dashboard date-range preset names, derived from `data/date-range-presets.ts`
16+
(`z.enum(DATE_RANGE_PRESETS)` — the vocabulary's single source of truth since
17+
#4614, so the two cannot drift), or the unchanged `[start, end]` array arm.
18+
Shipped as `minor` under the repo's launch-window convention for breaking
19+
changes; the hand-migration prescription is registered under protocol major 18.
20+
Maintainer ruling on #16041 (2026-09-06, decision batch #57, option A —
21+
contract first, 「同意」): 「本项目以协议为基准。所以开发应该对其协议,协议有问题应该立卡修改协议」.
22+
23+
## What was wrong
24+
25+
The arm was a bare `z.string()` whose only documented example — `"Last 7 days"`,
26+
in the schema's own comment — was a value no driver could parse. `driver-memory`
27+
recognised exactly `today` and a case-sensitive `last N <unit>` and fell every
28+
other string through to a `[range, range]` pseudo-window that (measured through
29+
mingo, 2026-09-05) matched **every `Date`-typed row**, 2099 included, because a
30+
`Date` compares above a `String` under BSON cross-type ordering. The SQL
31+
strategies read the same bare string as a single ISO day. A dashboard asking for
32+
one week silently got all of history on one backend and one day on the other,
33+
at HTTP 200 on both.
34+
35+
## What it does now
36+
37+
- The string arm is `AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS)`
38+
(`today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`,
39+
`this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`,
40+
`last_30_days`, `last_90_days`); the schema example is corrected to
41+
`'last_7_days'`.
42+
- Any other value raises ONE prescriptive issue at `timeDimensions.N.dateRange`
43+
(`analyticsDateRangeRefusalMessage`: the value, the vocabulary, the array
44+
spelling for an explicit window). `@objectstack/spec/data` exports the
45+
structural predicate `isAnalyticsDateRangeRefusalIssue` for doors.
46+
- `POST /analytics/query` and `/analytics/sql` answer the ADR-0112 envelope
47+
**`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`** — a new `ERROR_CODE_LEDGER` member
48+
registered under `@objectstack/runtime` — and the analytics service is never
49+
reached. A body wrong in more places than the `dateRange` stays the generic
50+
`400 VALIDATION_FAILED` + `details.fields[]`.
51+
52+
## FROM → TO
53+
54+
| you wrote | write instead |
55+
|:--|:--|
56+
| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` |
57+
| `dateRange: 'Last 30 days'` / `'last 30 days'` | `dateRange: 'last_30_days'` |
58+
| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` |
59+
| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` |
60+
| `dateRange: 'This week'` | `dateRange: 'this_week'` |
61+
| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged |
62+
63+
Measured in this repository at the ruling: three authored `'Last 7 days'`, all
64+
in `packages/spec` tests (re-spelled here), and no published dashboard authors
65+
the string arm at all — the shipped console lowers presets to the array arm
66+
before querying. The drivers' own refusal of a non-conforming value that reaches
67+
them in-process (past the schema) is the sibling card #16322, blocked by this
68+
one; the fenced `service-analytics` fixture that authors the retired bare-ISO
69+
spelling is that card's to re-triage.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
The client SDK reads the CRUD data prefix off the discovery document instead of restating `/data` as a literal, so a deployment that sets a non-default `crud.dataPrefix` is reachable through the scoped surface.
6+
7+
`crud.dataPrefix` moves two things together: REST mounts every CRUD route under `${basePath}${crud.dataPrefix}`, and the discovery handler advertises the same value as `routes.data = ${realBase}${crud.dataPrefix}`. The SDK is the third surface describing those same paths, and its scoped half was not reading the value — it wrote `/data` into all seventeen of its data methods. On a deployment that moved the prefix, that half called paths the server does not mount, while the unscoped half of the *same* SDK called the right ones: the unscoped methods build `${baseUrl}${getRoute('data')}` and `routes.data` already carries the prefix. One SDK disagreed with itself about where the data routes are.
8+
9+
- **`_dataPrefix()` recovers the prefix from the advertised routes.** `routes.data` is one string carrying two unknowns (`{realBase}{dataPrefix}`) and no discovery key carries either half alone, so the split is recovered in two steps. A value that already ends with the conventional `/data` *is* the default prefix — taken first, which is what makes the change unable to regress any deployment that works today: every later rule can only run in the branch where the previous single-literal code was already wrong. Otherwise `routes.metadata` supplies the missing equation, being `{realBase}{metadata.prefix}` over the same base, so the two advertised routes share exactly `realBase` plus whatever their prefixes share; cutting that common run back to its last `/` lands on the boundary. This also covers a document served from the environment-scoped mount, where both routes carry the same `/environments/{id}` segment.
10+
- **It declines rather than guess.** Where the document does not determine the split — no advertised routes, no `routes.metadata`, or a derived prefix of `/` or empty — the derivation returns the conventional `/data`, which is byte-identical to the previous behaviour. This follows the rule the neighbouring `_apiBase()` already sets in this file, and it is why an unconnected client is unaffected.
11+
- **`_apiBase()` strips the advertised prefix instead of the literal `/data`.** It previously declined whenever the prefix was non-default, because the only suffix it knew how to strip was `/data`. It now strips whatever `_dataPrefix()` read, so the base and the prefix are derived by one rule and cannot disagree. On every default-prefix deployment the result is unchanged.
12+
13+
No new client option and no new configuration: the value is read from the server that already publishes it. A client that never calls `connect()` builds exactly the URLs it built before.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os i18n extract --check --json` now COMPARES. It used to exit 0 having compared nothing, on a tree whose bundles had provably drifted.
6+
7+
The machine face returned before the comparison ran: `if (flags.json) { … return; }` sat ahead of both the `--check` needs-`--out` guard and the comparison block. Driven on one fixture, two invocations differing only by `--json` — the first exited 1 with `missing: OUT/zh-CN.objects.generated.ts` and `Translation bundles have drifted from the schema`, the second exited 0 with the ordinary extract payload. The first run is the second one's positive control: the drift was really there. Same shape as the `--dry-run` branch repaired one release earlier, and `--json` is if anything the more likely CI spelling of the two, because a pipeline that wants to parse the result reaches for it.
8+
9+
⚠️ **A pipeline that runs `os i18n extract … --check --json` and was green may now go red, and that is this repair working.** The green was a comparison that never happened; the red is the drift that was already in the tree. The fix is the one the failure names — re-run the same command without `--check` **and without `--json`**, then commit what it writes. Neither of those two flags writes files, and the command the failure prints now has both taken out of it.
10+
11+
What each invocation now does, with no new member on any published payload:
12+
13+
- **drift found** — the run ends on this command's existing `{ "error": … }` envelope with exit 1, carrying the same sentence the console face prints, the regenerate-and-commit command included. Deliberately not a new `drift` / `missing` / `stale` payload member: every other way this command can fail already speaks that envelope, and naming the drifted files in the machine payload would widen a published output face.
14+
- **in sync** — unchanged: the ordinary extract payload, exit 0.
15+
- **`--check` with no `--out`** — the refusal is now reachable under `--json` too, in the same `{ "error": … }` envelope with exit 1. It used to exit 0 with a payload, having been asked for a comparison it could not make.
16+
- **`--json` without `--check`** — unchanged in every respect.
17+
18+
The run leaves through exactly one of those faces, so stdout still parses as exactly one JSON document.
19+
20+
One more thing moved with it: the command a drifted `--check` prints as its remedy now has `--json` taken out of it as well as `--check`. It used to keep `--json`, so the machine face named a command that emits a payload, writes zero files, and leaves the next run failing with the same advice.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
`GET /forms/:slug/lookup/:field` answers a search again: the public-form lookup picker no longer refuses every non-empty query with `400 INVALID_FILTER`.
6+
7+
The route composed its filter list out of `ViewFilterRule` objects — the `{ field, operator, value }` dialect `FormFieldPublicPickerSchema.filter` declares in so many words ("Same `{ field, operator, value }` dialect as list-view filters") — and put them straight onto the `findData` filter slot. That slot accepts a `FilterCondition` object or a `FilterArray` (`[field, operator, value]`, a logical node, or a list of those) and refuses anything else. The refusal did not depend on an author declaring `publicPicker.filter`: the route's own `q` predicate is built in the same object shape, so **every** non-empty search was refused and only the degenerate empty-filter call could succeed — on an anonymous surface where a public-form applicant has no way around it.
8+
9+
- **The route lowers; the parser is untouched.** The composed rows are translated to the array grammar the ingress parses, at the one door that speaks both dialects. ⛔ The repair deliberately NOT taken is teaching `findData` a second dialect: that maintains two filter grammars in the data layer permanently and spreads the object shape to every `findData` caller. The declaration already promises the object dialect on the authoring surface, so what changes is the side that failed to honour the promise. A test keeps the control that the object shape fed to the parser directly is still refused, so "the route lowers" cannot be confused with "the parser was loosened".
10+
- **Both branches.** The declared `publicPicker.filter` rows and the route's own `contains` search row are lowered together and ANDed explicitly; no declared filter still means no filter (`[]`), never an empty logical node the ingress would refuse.
11+
- **The operator fold is the spec's own.** Lowering reuses `normalizeFilterOperator` from `@objectstack/spec/ui` — the fold `ViewFilterRuleSchema.operator` itself runs — so a stored row carrying a legacy spelling (`notEquals`, `isNotEmpty`, `gt`) folds exactly as the schema folds it. No second alias table.
12+
- **A rule that cannot be read is forwarded, not dropped.** The request is then refused exactly as before. That direction is deliberate: a picker's static filter is often the only thing keeping an anonymous visitor's search inside the rows a form may expose, and silently skipping a row nobody understood would answer 200 over an unfiltered table.
13+
14+
No authoring surface moves: `FormFieldPublicPickerSchema` already declared this dialect as accepted, and this makes the runtime honour it.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
The REST server's own `findData` calls now build the canonical QueryAST instead of an undeclared wire dialect, and the helper that erased the type on that one slot is gone.
6+
7+
Four server-built query literals in `rest-server.ts` — the import-job loader, the import-job listing, the export chunk loop and the public reference picker — spelled their query in transport aliases (`$filter`, `$top`, `$skip`, `$orderby`, `$expand`, plus the bare `filters` / `select` / `sort`). None of those spellings is declared by `QuerySchema`, so three of them were routed through a `wireDialectQuery` helper that cast the `query` member to `FindDataRequest['query']`, and the fourth escaped the compiler entirely because its protocol handle was typed `any`. All four now spell `object` / `where` / `orderBy` / `limit` / `offset` / `fields` / `expand`, so the slot compiles against the declared contract like every other member of the request, and the helper is retired.
8+
9+
**No behaviour moves, and that is measured rather than asserted.** `@objectstack/metadata-protocol`'s `findData` folds every alias onto its canonical key by the spec's own table (`RPC_QUERY_ALIAS_SLOTS`) and moves the value verbatim, so both spellings reach `engine.find` as the same option bag. `rest-server-canonical-query-ast.test.ts` drives all four before/after pairs through the real normalizer and asserts that equality, and reads the source to keep the erasure retired — a cast compiles, so a type-check alone could not hold this ground.
10+
11+
**Nothing is removed from the published surface.** `wireDialectQuery` was a module-local `const` in `rest-server.ts`: it carried no `export` keyword, `packages/rest/src/index.ts` never named it, and it appeared in no other file in the tree. Deleting it moves no exported symbol, which is why this is a patch.
12+
13+
**What this change deliberately does NOT do:** it does not touch how the HTTP door treats a *caller's* query. The wire aliases stay accepted on `GET /data/:object` exactly as before — declaring them in the spec's alias table is a separate piece of work — and `GET /data/:object` still forwards the caller's own querystring bag untouched.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"create-objectstack": minor
3+
---
4+
5+
`npx create-objectstack` now declares the same TypeScript range as `os init` and
6+
`os create`, and the value is generated rather than restated.
7+
8+
Three scaffolders write a new project's `package.json`, and the range that
9+
decides whether that project type-checks at all had split: `os init` and
10+
`os create` emitted `typescript: ^5.3.0` from a shared emission policy, while
11+
this package's bundled template carried `^6.0.0`. Two projects created the same
12+
day got different TypeScript **majors** depending on which documented entry
13+
point the reader followed.
14+
15+
- **What changed for a scaffolded project.** Its declared `typescript`
16+
devDependency floor moves from `^6.0.0` to `^5.3.0`. Both resolve to the same
17+
installed compiler on a fresh install; what moves is the floor the project
18+
**declares**, and a floor is a support promise. `^5.3.0` is the promise the
19+
docs already make — "ObjectStack works with TypeScript 5.3+" on the getting
20+
started page, "TypeScript 5.3.0 or later" in the deployment troubleshooting
21+
page — and it is measured rather than assumed: TypeScript 5.3.3 type-checks
22+
every shape these scaffolders emit with results identical to 6.0.3. The repo's
23+
own `typescript@^6.0.3` devDependency is deliberately not this value; the same
24+
doc sentence states both halves ("…but the project itself is built and tested
25+
against TypeScript 6.x"). `engines.pnpm` was already in agreement and is now
26+
held there by the same mechanism.
27+
- **Why the value is generated.** This package cannot import from
28+
`@objectstack/cli`: the dependency edge runs the other way, and the `npx`
29+
package must not pull the CLI's package closure. So the values are stamped
30+
into the bundled template at build time by
31+
`scripts/sync-scaffold-emission-policy.mjs`, read out of the same
32+
`SCAFFOLD_*` constants the other two scaffolders import, and
33+
`pnpm check:scaffold-emission-policy` reddens the moment the inlined values
34+
disagree with that source. Editing the two into agreement by hand would have
35+
left them free to diverge again on the next move, silently, for the same
36+
structural reason — which is how they diverged the first time.

.github/workflows/lint.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,25 @@ jobs:
20462046
- name: Template version-time rewriter self-test
20472047
run: pnpm check:template-version-sync
20482048

2049+
# #16485 — the BUILD-time generator beside the version-time one above, and
2050+
# the only one of the two whose --check leg runs on a real corpus here.
2051+
# `create-objectstack` cannot import @objectstack/cli (the dependency edge
2052+
# runs the other way, and the npx package must not pull the CLI's closure),
2053+
# so its bundled template RESTATED the scaffold emission policy and the
2054+
# restatement decayed: `typescript` reached `^6.0.0` there while `os init`
2055+
# and `os create` emitted `^5.3.0` from the shared constants, so two
2056+
# projects created the same day got different TypeScript majors depending
2057+
# on which documented entry point the reader followed. The values are now
2058+
# generated into the template from `packages/cli/src/commands/init.ts`, and
2059+
# this is the leg that reddens on drift — a hand edit into agreement would
2060+
# have satisfied the acceptance box and diverged again on the next move.
2061+
# Both legs run: --self-test covers the red paths a green corpus cannot
2062+
# reach (a renamed policy constant, a template omitting a stamped key, an
2063+
# unparseable template, an empty templates directory), --check covers the
2064+
# live tree.
2065+
- name: Scaffold emission policy generated into the on-ramp
2066+
run: pnpm check:scaffold-emission-policy
2067+
20492068
# #15332 — the THIRD version-time rewriter, and the third self-test beside
20502069
# the two above. scripts/sync-release-index-currency.mjs joins the root
20512070
# `version` chain and stamps the release index's "current series: X.Y.Z,

0 commit comments

Comments
 (0)