Skip to content

Commit 91cf990

Browse files
os-salesclaude
andcommitted
Merge origin/main into claude/issue-16483-retire-os-create-example
Brings in #16654 (scripts/sync-scaffold-emission-policy.mjs and its `check:scaffold-emission-policy` gate), which landed while this PR was in contract review. Merged rather than left to the queue because that gate's declared inputs include packages/cli/src/commands/init.ts and the bundled templates, and this PR touches init.ts (comment-only) and removes a template — close enough that it should be measured on the PR instead of first discovered in a dequeue. ⛔ No code change for the `allSixManifests()` drift this merge creates (#16654's helper is named for five CLI emissions plus the on-ramp, and this PR takes the CLI side to four). It is main's code now, a card of its own, and repairing it here would widen this PR past its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
2 parents fd31e93 + 2539f4a commit 91cf990

57 files changed

Lines changed: 4198 additions & 288 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/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,

content/docs/references/api/analytics.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,22 @@ const result = AnalyticsEndpoint.parse(data);
8383
| **measures** | `string[]` || List of metrics to calculate |
8484
| **dimensions** | `string[]` | optional | List of dimensions to group by |
8585
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
86-
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: string \| string[] }[]` | optional | |
86+
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
8787
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
8888
| **limit** | `number` | optional | |
8989
| **offset** | `number` | optional | |
9090
| **timezone** | `string` | optional | |
9191
| **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. |
9292
| **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. |
9393

94+
### Nested Shape: `AnalyticsQueryRequest.timeDimensions[number]`
95+
96+
| Property | Type | Required | Description |
97+
| :--- | :--- | :--- | :--- |
98+
| **dimension** | `string` || |
99+
| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
100+
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. |
101+
94102

95103
---
96104

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +298 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +299 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` || Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -93,6 +93,7 @@ const result = ApiErrorSchema.parse(data);
9393
* `ACTION_DISABLED`
9494
* `ALREADY_REVERTED`
9595
* `AMBIGUOUS_MATCH`
96+
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
9697
* `ANALYTICS_QUERY_FAILED`
9798
* `APPROVAL_ACTIONS_FAILED`
9899
* `APPROVAL_APPROVE_FAILED`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const result = ErrorCode.parse(data);
209209
* `ACTION_DISABLED`
210210
* `ALREADY_REVERTED`
211211
* `AMBIGUOUS_MATCH`
212+
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
212213
* `ANALYTICS_QUERY_FAILED`
213214
* `APPROVAL_ACTIONS_FAILED`
214215
* `APPROVAL_APPROVE_FAILED`

content/docs/references/data/analytics.mdx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ This layer decouples the "Physical Data" (Tables/Columns) from the
2020
## TypeScript Usage
2121

2222
```typescript
23-
import { AggregationMetricType, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data';
24-
import type { AggregationMetricType, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data';
23+
import { AggregationMetricType, AnalyticsDateRangeSchema, AnalyticsDateRangePresetSchema, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data';
24+
import type { AggregationMetricType, AnalyticsDateRange, AnalyticsDateRangePreset, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data';
2525

2626
// Validate data
2727
const result = AggregationMetricType.parse(data);
@@ -44,6 +44,48 @@ const result = AggregationMetricType.parse(data);
4444
* `boolean`
4545

4646

47+
---
48+
49+
## AnalyticsDateRange
50+
51+
### Union Options
52+
53+
This schema accepts one of the following structures:
54+
55+
#### Option 1
56+
57+
Allowed Values: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, `last_90_days`
58+
59+
---
60+
61+
#### Option 2
62+
63+
Type: `string[]`
64+
65+
---
66+
67+
68+
---
69+
70+
## AnalyticsDateRangePreset
71+
72+
### Allowed Values
73+
74+
* `today`
75+
* `yesterday`
76+
* `this_week`
77+
* `last_week`
78+
* `this_month`
79+
* `last_month`
80+
* `this_quarter`
81+
* `last_quarter`
82+
* `this_year`
83+
* `last_year`
84+
* `last_7_days`
85+
* `last_30_days`
86+
* `last_90_days`
87+
88+
4789
---
4890

4991
## AnalyticsQuery
@@ -56,12 +98,20 @@ const result = AggregationMetricType.parse(data);
5698
| **measures** | `string[]` || List of metrics to calculate |
5799
| **dimensions** | `string[]` | optional | List of dimensions to group by |
58100
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
59-
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: string \| string[] }[]` | optional | |
101+
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
60102
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
61103
| **limit** | `number` | optional | |
62104
| **offset** | `number` | optional | |
63105
| **timezone** | `string` | optional | |
64106

107+
### Nested Shape: `AnalyticsQuery.timeDimensions[number]`
108+
109+
| Property | Type | Required | Description |
110+
| :--- | :--- | :--- | :--- |
111+
| **dimension** | `string` || |
112+
| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
113+
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. |
114+
65115

66116
---
67117

0 commit comments

Comments
 (0)