Repository navigation
fix(analytics)!: a query window outside the non-negative integers is refused at the door, and an offset with no limit runs on SQLite - #21399
Conversation
…der an offset-only window per dialect AnalyticsQuerySchema's limit and offset become non-negative integers (the dataset selection reads the same declarations). The native statement takes the dialect's no-limit spelling in front of an OFFSET with no LIMIT. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…et-only window per dialect Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ivers and both faces Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…window Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… semantic entry Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 98ab58eb0af7648f63d169e0875e1da81c1b1b75 && git checkout 98ab58eb0af7648f63d169e0875e1da81c1b1b75
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecb6ca0258176466767588a6805363387c5777a6 b1a8ea073b357d1b05e163e438aa30bd1977c833 && git checkout -B drift-repro ecb6ca0258176466767588a6805363387c5777a6 && git merge --no-ff b1a8ea073b357d1b05e163e438aa30bd1977c833
node scripts/docs-audit/affected-docs.mjs --json ecb6ca0258176466767588a6805363387c5777a6
|
…ne measure with a dimension too — refuse two or more at values, and rename the check to checkDashboardWidgetChartMeasureArity (objectstack-ai#21293) (objectstack-ai#21425) Fixes objectstack-ai#21293 Clause-②: yes (narrowing) Extends objectstack-ai#20958's per-type measure-arity rule to the dimensioned arm, as triage ruled on objectstack-ai/objectui#11417 (comment `5943580519`, carried onto this card as `5944795015`): for `pie`, `donut`, `funnel`, `treemap` and `sankey`, two or more measures are refused **whatever the dimension**. It uses objectstack-ai#20958's mechanism and message shape: the same check, one more arm, and one ADR-0087 semantic entry in objectstack-ai#20958's shape. No second rule is added. `scatter` and `radar` with a dimension are outside the ruling and parse as before. ## What changed - **`packages/spec/src/ui/dashboard.zod.ts`** - New module-private `SINGLE_SERIES_CHART_TYPES` (`:784`): `pie` / `donut` / `funnel` / `treemap` / `sankey`, typed `as const satisfies readonly ChartType[]`. It is private, like the metric family's list. The check, its refusal text and the `values` doc string read it. - The check gains a second arm, the **single-series arm**. When `dimensions` is declared and non-empty, `values` has two or more members, and `type` is one of the five, it emits ONE `custom` issue at `values`. The issue names the widget `id`, the count and the type, and says the type "draws ONE series whatever its `dimensions`". It steers to `type: 'table'` (a column per measure), a bar-family type (one bar per measure in each category), or one widget per measure. - The dimensionless arm is unchanged, byte for byte, in both verdict and message. The arms are exclusive on `dimensions`, so a dimensionless `pie` still gets the dimensionless message, and only one issue. - The metric family still returns early (its sibling check owns it), and so do types outside `ChartTypeSchema`. - **Renamed export:** `checkDashboardWidgetDimensionlessMeasureArity` is now `checkDashboardWidgetChartMeasureArity` (`:878`, chained at `:1461`). The reason is in "The rename" below. - The `values` doc string now states the single-series rule. It reads the private constant. - **ADR-0087 entry** `packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts`, plus the `gen:migration-registry` lap. Nothing between the markers was hand-edited. Its acceptance criteria name the doors as measured (below), state that the TODO cannot name per-document measures, and list what is and is not refused. It also records the export rename (FROM → TO). - **The objectstack-ai#20958 entry, amended in two sentences** (`18.dashboard-widget-dimensionless-multi-measure-refused.ts`). It named the old export, and it said "the same seven types WITH a dimension keep accepting several measures exactly as before". This change makes both false, and `os migrate meta` prints both entries in the same hop. They now name the new export (noting the old name) and point the five at this entry. This file is outside the claim's listed file surface; see Deviations. - **Tests** - `dashboard.test.ts`: a new `[objectstack-ai#21293]` block, plus the fixture triage below. - `object-refinement-check-exports.test.ts`: the parity catalogue row is renamed, and its fixtures are triaged. - **Changeset** `.changeset/21293-single-series-multi-measure-refused.md`: `minor`, a **BREAKING** banner, the `(narrowing)` arm, and exactly one ADR-0087 marker (`registered dashboard-widget-single-series-multi-measure-refused`). It carries a FROM → TO table that includes the export rename. - **Regenerated:** - `api-surface/ui.json` and `export-origins/ui.json`: one row renamed in each, `-1 / +1`. - The `values` row of `content/docs/references/ui/dashboard.mdx`. - `src/migrations/registry.ts`. ## The rename, and what objectui's mirror chains today The check now judges widgets WITH a dimension, so `...DimensionlessMeasureArity` names a boundary that no longer exists. A mirror deciding which export to chain reasons from that name first. This is the cheapest moment there will ever be to change it, because nothing chains it yet: - At the `.objectui-sha` pin `89cad75d55702cc4f267bead5bf267de575d5842`, `git grep "DimensionlessMeasureArity\|MULTI_MEASURE_TYPES"` returns **0 hits**. The control, `checkDashboardWidgetMetricMeasureArity` in `packages/types/src/zod/complex.zod.ts`, returns 2. objectui's `DashboardWidgetSchema` mirror chains `checkDashboardWidgetStageOrder` and `checkDashboardWidgetMetricMeasureArity` only (`complex.zod.ts:1304`, and the `attached` row in `spec-object-refinements-7715.test.ts:135`). - On objectui `origin/main` `5988b6b53` the readings are the same: 0 hits, control 2. - In this repository the old name had no consumer outside `packages/spec`'s own tests (`git grep`). - The spec object's check COUNT is unchanged at three, so objectui's census test sees no count change at the bump. A rename is a removal plus an addition, so the changeset carries the import FROM → TO, and `Clause-②` is `yes`. ## Measured before building (the dispatch's four hypotheses) The branch base is `4b20c84748`. It is newer than the dispatch's `1d0600bf66`, and `dashboard.zod.ts` did not change between the two. - **H1, holds.** The check returned early on any non-empty `dimensions` (old `:834`), and `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` listed the eight multi-measure types. At the base I built `dist` and ran `DashboardWidgetSchema.safeParse` on `{ id, type, dataset, dimensions: ['stage'], values: ['revenue', 'cost'] }`. It returned **ACCEPT** for `pie`, `donut`, `funnel`, `treemap`, `sankey`, `scatter`, `radar`, `bar` and `table`. One measure returned ACCEPT on all of them. The lit control, the same body plus `bogusProp`, returned REFUSE `unrecognized_keys` on every type. - **H2, holds.** objectui's renderer at the pin (`packages/plugin-charts/src/AdvancedChartImpl.tsx`) binds the first series on exactly these arms: - `pie` / `donut` at `:2095` (`const pieDataKey = series[0]?.dataKey || 'value'`); - `funnel` at `:2161`, `treemap` at `:2243` and `sankey` at `:2278` (`const dataKey = series[0]?.dataKey || 'value'`). - `radar` maps every series (`:2406` onward, `series.map(...)`). - objectui's `DatasetWidget.tsx` at the pin already names the same five as `SINGLE_SERIES_CHART_FAMILIES` (`:387`–`:413`), which is the objectstack-ai#11417 diagnostic. It says `scatter` "refuses a second series out loud". - **H3, partly falsified.** There is no declared "order": `build-migration-registry.ts` derives the order by sorting entry ids ("Order is DERIVED, never declared"). The serial constraint did hold. PR objectstack-ai#21399 (`analytics-query-window-non-negative-integer`) landed on `main` first, so this branch merged `origin/main` `db3fee3dc9` through `scripts/pm/os-regen-merge.sh` (merge commit `b3b3c516f9`). - The registry text-merged clean. - `gen:migration-registry` on the merged tree rewrote it byte-identical: 346 semantic entries, `git status` empty. - All three sibling entries are present at HEAD. Quoted-exact-name `git grep`, HEAD vs `origin/main`: `analytics-query-window-non-negative-integer` 1/1, `dashboard-widget-dimensionless-multi-measure-refused` 1/1, and `dashboard-widget-single-series-multi-measure-refused` 1 at HEAD, 0 on main. - **H4, census: no authored hit.** I scanned brace-local literals carrying `values: [...]` over every tracked `.ts/.tsx/.js/.mjs/.cjs/.json/.md/.mdx/.yml` file. - objectstack at `4b20c84748`: 496 literals, 33 on the seven types, 1 dimensioned multi-measure on the five. That one is `packages/spec/src/ui/object-refinement-check-exports.test.ts:365`, the fixture that pinned the old acceptance, triaged below. - Lit control: 9 dimensioned multi-measure literals on other types (`table` / `combo` widgets in `examples/app-showcase`, and others). - objectui at the pin: 480 literals, 13 on the seven, and 1 hit on the five. That hit is the prose example in `.changeset/11417-dashboard-single-series-dropped-measure.md:10`, not metadata. - `examples/**`, the showcase and CRM apps, and `packages/platform-objects` dashboards carry zero instances. - Dynamically built fixtures do not show up in a static scan. I inspected each downstream test that loops over chart types or builds widgets (`lint` `validate-widget-bindings`, `runtime-gate`, `validate-dashboard-widget-options`, and `metadata-protocol`'s two donut fixtures). All carry one measure. ## Doors after the change (built `dist`, one-shot probe, deleted afterwards, `git status` clean) | body (`dimensions: ['stage']`) | `DashboardSchema` | `getMetadataTypeSchema('dashboard')` | `ObjectStackDefinitionSchema` | `defineStack` | |---|---|---|---|---| | `pie`, 2 measures | REFUSE `widgets.0.values:custom` | REFUSE `widgets.0.values:custom` | REFUSE `dashboards.0.widgets.0.values:custom` | throws `defineStack validation failed (1 issue)`, naming the widget | | `sankey`, 2 measures | REFUSE | REFUSE | REFUSE | throws | | `pie`, 1 measure (control) | ACCEPT | ACCEPT | ACCEPT | returns | | `radar`, 2 measures (control) | ACCEPT | ACCEPT | ACCEPT | returns | | `bar`, 2 measures (control) | ACCEPT | ACCEPT | ACCEPT | returns | I did not re-measure `os validate` or the metadata save path. The entry does not claim them. They parse through the same schema at the same attachment point as objectstack-ai#20958, which measured both. ## Fixture triage (pins whose premise this change makes false) - **`object-refinement-check-exports.test.ts`.** The row "two measures on a `pie` WITH a dimension — out of this rule" (`refusesAt: []`) is this arm's subject now. It becomes five refusing rows, one per single-series type. Accepting rows are added for `scatter` / `radar` with a dimension and for a one-measure dimensioned `pie`. - **`dashboard.test.ts`, the objectstack-ai#20958 block.** "accepts `%s` with ONE dimension and two measures" iterated all seven types. It keeps `scatter` and `radar`, and the five move to the new block as refusals. - **`dashboard.test.ts`, the objectstack-ai#17779 block.** "leaves `%s` — every NON-metric type — accepting three measures" ran on `WIDGET_BASE`, which carries a dimension. Its own claim is that the METRIC check leaves non-metric types alone, so that claim is now read off the metric export directly for all 15 types. The parse leg keeps the 10 types no check refuses. - **objectstack-ai#20958's dimensionless pins are unchanged.** That covers the refusals, the explicit `dimensions: []` refusals, the message pin and the metric-family one-issue pin. ## New pins (`[objectstack-ai#21293]` block in `dashboard.test.ts`) - Each of the five, with a dimension and two measures: REFUSE, `custom` at `values`, message naming the type. - The control: one measure on each of the five with a dimension: ACCEPT. - Each of the five with TWO dimensions: REFUSE. - `scatter` / `radar` with a dimension and two measures: ACCEPT. - Every multi-measure type with a dimension and three measures: ACCEPT. - **Taxonomy read off the door:** with a dimension, the set of `ChartTypeSchema` types refused at two measures is exactly the metric family plus the five. - A dimensionless widget of each of the five keeps the dimensionless message, word for word and as ONE issue. The parse leg equals the direct call. - The metric family with a dimension keeps its own refusal, and the chart export stays silent on it. - Direct-call legs: a dimensioned `pie` is refused; a wider-enum type (`custom`) and a dimensioned `scatter` are not. - `DashboardSchema.widgets[]` travel, and the `values` doc string. - The old export name is absent from `@objectstack/spec/ui`. The new export is the lit control. ## Verification Every reading below is at HEAD `b3b3c516f9` unless it names another commit. **Reverse verification**, at committed `221df98d72`, through `scripts/ablation-replace.mjs`: - The mutation turned the single-series arm's type guard into an unconditional `return`. The subject is imported from `src` by relative path, so no `dist` is involved. - The prediction was "turns red", and that is the direction observed. ``` anchor x1 -> x0 · marker x0 -> x1 · blob 9a8c228 -> cfdc26ae3cbf (mutation landed) RED vitest exit=1 19 failed | 342 passed (361) (dashboard.test.ts + object-refinement-check-exports.test.ts) restored blob 9a8c228 == HEAD blob, git diff HEAD empty; marker count after restore 0 ``` The 19 red tests: - 14 in the new block: 5 refusals, 5 two-dimension refusals, the taxonomy pin, the message pin, the direct-call pin and the travel pin; - 5 parity rows in the exports catalogue, one per dimensioned single-series fixture. **Tests.** Each is the package's own vitest run. Downstream packages were chosen by direction: the dependents of `@objectstack/spec` that import or parse the dashboard schemas (`git grep`). This is not the full `...@objectstack/spec` sweep. | package | result | |---|---| | `@objectstack/spec` (`--project local`) | 600 files, 17666 passed, 1 todo, exit 0 | | `@objectstack/spec` typecheck (tsc + scripts + test layer) | exit 0 (and at `221df98d72` before the merge). The test-layer ledger held at 52 files / 246 errors / 135 signatures; `tsc -p tsconfig.test.json --listFiles` lists both touched test files and the new entry. | | `@objectstack/lint` | 119 files, 5575 tests, exit 0 | | `@objectstack/sdui-parser` | 13 files, 218 tests, exit 0 | | `@objectstack/platform-objects` | 59 files, 949 tests, exit 0 | | `@objectstack/metadata-protocol` | 201 passed, 3 skipped (204 files); 2983 passed, 19 skipped; exit 0 | | `@objectstack/metadata` | 56 files, 836 tests, exit 0 | | `@objectstack/service-analytics` | 168 files; 3794 passed, 89 skipped; exit 0 | | `@objectstack/cli` (`--project unit`) | 247 files, 3538 tests, exit 0. The integration tier is declared to CI: no spawn entry or integration file is touched. | | `@objectstack/objectql` (`--project local`) | 364 files, 7360 tests, exit 0 | **Generated artefacts.** `pnpm --filter @objectstack/spec check:generated` reports "All 15 generated artifacts are up to date" on the merged tree, after a rebuild. `check:migration-registry` reports `registry.ts is current (346 semantic, 243 retired-key, 212 retired-def)`. **Gates.** - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `b3b3c516f9` derives 115 commands. The 10 changed paths are measured against merge base `db3fee3dc`; 671 changed lines. - All 115 ran. `--ran` reconciliation: "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - Six first answered `PREREQUISITE NOT MET` (exit 3) and were re-run to a real exit 0 after a workspace build: - `check:doc-formula-expressions` and `check:doc-security-posture` (`lint` / `formula` not built); - `check:skill-examples` (`client-react` not built); - `check:docs-transcript-drift` (`lint` not built); - `check:dual-build-cjs-loads` (86 packages had no `dist`); - `check:lean-entry-closure` (`objectql` not built). - Compared with the seat's dispatch-time list (109), the re-derivation adds six: the changeset-driven `check-empty-changeset` pair, `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:objectui-changeset` and `check:pm-changeset-deadline-census`. All six ran. - Verdict lines: - `check-adr-0087-registration --base origin/main`: `[BREAKING+bang+clause-②-narrowing] registered dashboard-widget-single-series-multi-measure-refused (new here: dashboard-widget-single-series-multi-measure-refused)`. - `check:api-surface`: "public API surface + factory signatures unchanged ✓", against the regenerated snapshot. - `check:doc-authoring`: no internal issue-id in the 17250 customer-facing strings. The refusal text carries none. - `check:nul-bytes`: OK. **`Clause-②`, measured.** `node scripts/pm/check-widening-tells.mjs --declaration no --diff` on `git diff origin/main...HEAD` exits 4: - It reports five T2 tells, `dashboard.zod.ts:785`–`:789`. These are the members of the module-private `SINGLE_SERIES_CHART_TYPES` `as const` array. The array narrows the accept set, so these tells are false as widenings. - T3 does not fire, because the `api-surface/ui.json` hunk removes the old row beside the new one. - The export listing does gain a row: `checkDashboardWidgetChartMeasureArity (function)`, the renamed export. - `--declaration yes` exits 0. So the measured arm is `Clause-②: yes (narrowing)`. The `yes` holds because a renamed check function is exported for objectui's `.shape` mirror to chain, which is the dispatch's own example. The `(narrowing)` holds because the accept set shrinks. **Lint**, a measured narrowing rather than the repo-wide run (that run belongs to CI): - I ran `eslint --no-inline-config --format json` over the 6 touched `.ts` files. The JSON reports 6 files, 0 errors and 0 warnings. - Population: `eslint --print-config` returns a rule set for each file, so none is ignored. - Invariance: `parserOptions.project` and `projectService` are null for every file, and `eslint.config.mjs:327` states that it never enables type-aware linting. This diff therefore cannot move a verdict on any untouched file. ## Deviations - **One file outside the claim's listed surface:** the objectstack-ai#20958 entry. Two of its sentences, one naming the export and one saying the dimensioned arm keeps accepting, became false in this change. `os migrate meta` prints both entries in one hop, so leaving them would print a contradiction. The standing dev definition requires fixing released text that a change makes false. The claim's "stop on breach" reads the other way, so the conflict is named here rather than settled silently. The edit is two sentences, and the registry region is regenerated, not hand-edited. ## Acceptance notes None of these is filed. None is a reproducible defect, a contract violation or an authoring trap. - **`check-widening-tells` T2 fires on a module-private `as const` set whose members narrow.** The declaration is `yes` for the export row anyway, so the tells decide nothing here. The matcher was not touched. The precedent (`SINGLE_MEASURE_WIDGET_TYPES`) predates the gate. Carrier: none. - **The single-series set is not exported.** objectui's dataset widget keeps its own renderer-derived copy (`SINGLE_SERIES_CHART_FAMILIES`, "Kept here, once, because the renderer has no declaration of it to read"). No import of a spec list is measured anywhere. If objectui's mirror or diagnostic later wants it, exporting it is a free widening. Carrier: none. - **No `STEP18_RATIONALE` fragment**, following objectstack-ai#20958's precedent. ## Downstream: objectui's mirror (not in this PR; objectstack-ai/objectui#11417 carries it) `@object-ui/types` builds `DashboardWidgetSchema` from a `.shape` spread, so it runs only the checks it chains, and today it chains neither arm. At the pin bump that carries this change: - Import `checkDashboardWidgetChartMeasureArity` from `@objectstack/spec/ui`. - Chain it in `packages/types/src/zod/complex.zod.ts` after `.superRefine(checkDashboardWidgetMetricMeasureArity)`. - Add it to the `attached` list of the `DashboardWidgetSchema (complex.zod.ts)` row in `spec-object-refinements-7715.test.ts`. - Refusal shape: ONE `custom` issue at `path: ['values']`. With a dimension, the message starts "Widget `ID` declares N measures on `type: 'T'`, a single-series type (...) that draws ONE series whatever its `dimensions`". Without one, it keeps objectstack-ai#20958's "declares N measures with no `dimensions`" text. objectstack-ai/objectui#11417 remains open: this PR carries only the spec half. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…mit as a statement the dialect runs (objectstack-ai#21440) Fixes objectstack-ai#21365 Clause-②: no ## What this changes This is the remainder of objectstack-ai#21365 after PR objectstack-ai#21399 (`6d67ad5ec`, which read `Part of`). That PR narrowed the window contract and made the native face render an offset-only window with the dialect's no-limit spelling. The ObjectQL face's echo still wrote its own window. `ObjectQLStrategy.generateSql` (`packages/services/service-analytics/src/strategies/objectql-strategy.ts`) wrote `LIMIT n` when a limit was set, then `OFFSET n` when an offset was. An `offset` with no `limit` therefore echoed a bare `OFFSET`, and SQLite's grammar has no bare `OFFSET`. Those two lines are now one call: - `windowClauseSql(query.limit, query.offset, sqlDialectFor(ctx, tableName))` - `windowClauseSql` is the function the native face runs, already exported from `native-sql-strategy.ts`. That file is not edited, and there is no second spelling table. The ObjectQL face's echoed `sql` and the `POST /api/v1/analytics/sql` body (both are `generateSql`) now carry the same window clause the native face executes on the same driver. ## Measured at the real route The harness is a scratch copy of `packages/runtime/src/analytics-query-window-validity.test.ts`. It uses the real `dispatcher-plugin` mount over `AnalyticsServicePlugin` and a real `ObjectQL` engine with `SqlDriver` (better-sqlite3). It boots the default composition and one narrowed to the engine aggregate (the ObjectQL face). The query is `order: { note: 'asc' }, offset: 1`, with no limit. Each echoed statement was then run on the same SQLite database through knex, to read SQLite's own diagnostic. The harness was deleted and never committed. | | `main` `bdd3654f2` | this branch | |:--|:--|:--| | ObjectQL face, `/query` rows | x y z | x y z | | ObjectQL face, echoed `sql` = `/sql` body | `… ORDER BY "note" ASC OFFSET 1` | `… ORDER BY "note" ASC LIMIT -1 OFFSET 1` | | that statement on SQLite | `near "OFFSET": syntax error` | x y z | | native face, executed = echoed = `/sql` | `… ASC LIMIT -1 OFFSET 1`, x y z | unchanged | | CONTROL `limit: 2, offset: 1`, ObjectQL echo | `… LIMIT 2 OFFSET 1`, runs | unchanged | On this query the ObjectQL echo now equals, byte for byte, the statement the native face runs on SQLite. ## The dialect source (dispatch, Zone 2 item 2) `generateSql` already has the strategy context in scope, and it already reads `sqlDialectFor(ctx, tableName)` for its read-scope compile. That is the same `sqlDialect` hook, read the same way, that `NativeSQLStrategy.generateSql` uses with `sqlDialectFor(ctx, this.extractObjectName(cube))`. `tableName` here is `this.extractObjectName(cube)` too. `AnalyticsServicePlugin` wires that hook from the data engine's driver whatever the query capabilities are, so the narrowed composition above reads `sqlite`. No new hook. ## Pin `packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts` has three tests per cell: - **The card's row.** The ObjectQL face runs no raw statement and does run the engine aggregate. It answers x y z. Its echo ends with the dialect's window. `generateSql` (the `/analytics/sql` body) equals the echo, with no params. From `ORDER BY` on, the echo equals the statement the native face executes on the same driver. Run through the engine's raw-SQL bridge, the echo answers the face's rows. - **The `unknown` arm.** An `ObjectQLStrategy` whose context wires no `sqlDialect` hook echoes `LIMIT 9223372036854775807 OFFSET 1`, and that statement runs. - **CONTROL.** `limit: 2, offset: 1` keeps its bytes, and the echo runs. The cells: - **sqlite**: executed on every run. - **live postgres**: executed where `OS_TEST_POSTGRES_URL` is set, and a named skip otherwise. No CI step provisions that variable for this package, so CI runs only the SQLite cell. I ran it locally against PostgreSQL 16.14. There the echo keeps `OFFSET 1` alone, byte-identical to before. The comparison with the native statement starts at `ORDER BY` because on PostgreSQL the native face also casts the summed column. **Ablation** at committed `2cfcc44ee`, through `node scripts/ablation-replace.mjs`. The anchor hit once, and the blob changed `9ed31b50c50b` to `e8b910c3c13f`. The mutation restored the two old window lines. The pin reads `src/` with no `dist/` leg (objectql is aliased to source, and the strategy is imported from `src`). Result, with live PostgreSQL: **3 failed, 3 passed**. - Red: the SQLite card row (the echo ends with a bare `OFFSET 1`), and the `unknown` arm on both engines. - Green: both controls, and the PostgreSQL card row, whose bytes this change does not move. I predicted that direction before the run. Restore was proven: the blob after restore equals HEAD `9ed31b50c50b`, and `git diff HEAD` is empty. ## Tests and gates All of these ran at head `b9456bff3`, which is this branch with `origin/main` `8b123c0ae` merged in. That merge brought PR objectstack-ai#21424's `native-sql-strategy.ts` change. `windowClauseSql` is unchanged by it. - `pnpm --filter @objectstack/service-analytics test`, with `OS_TEST_POSTGRES_URL` set to a local PostgreSQL 16.14: **Test Files 170 passed (170), Tests 3957 passed (3957)**. `os-verify-lock` printed `VERDICT command-exit 0`. - `pnpm --filter @objectstack/service-analytics typecheck`: `VERDICT command-exit 0`. `tsc --noEmit --listFiles` lists the new pin once. The package `tsconfig` includes `src` and does not exclude tests. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) derived 63 commands. All 63 ran at `b9456bff3`. 62 exited 0 on the first run. `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3), because the workspace was not fully built. After a full `turbo run build` (72 tasks, exit 0), it exited 0. `--ran` reconciles **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN**. The list includes `check:nul-bytes`, `check:changeset-gate-self-tests`, `check:adr-0087-registration`, `check:changeset-no-major` and `check:test-source-alias`. No gate touched `AGENTS.md`, and the tree stayed clean. - eslint: `eslint --no-inline-config --format json` on the 2 touched `.ts` files read 2 files, 0 errors, 0 warnings, none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so an untouched file's verdict cannot move. The repo-wide `pnpm lint` is left to CI. ## Docs I grepped `content/docs/**` outside `releases/` for the analytics echo, `/analytics/sql` and window rendering. `api/data-api.mdx` describes `/analytics/sql` as a dry run returning `{ sql, params }`, and `api/client-sdk.mdx` shows `client.analytics.explain`. Neither says how a window renders. No sentence is made false, so there is no docs edit. ## Acceptance notes - **The bucketed echo is a separate truth (dispatch, Zone 2 item 3), not fixed here.** A month-bucketed dimension echoes `date_trunc('month', closed_on)`. That holds on both faces, and in the default composition too, because the native face declines granularity. SQLite refuses it with `no such function: date_trunc`, measured on this branch after the window fix. On `main` the same statement failed earlier, at the bare `OFFSET`. The driver runs `strftime('%Y-%m', …)` there (`driver-sql` `sql-driver.ts`). The comment over `dimExpr` says the echo renders "the SQL shape the driver's own bucketing implements", and on SQLite that is not so. This is `dimExpr`, not the two window lines, so it is reported to the seat rather than fixed here. - **`mysql`.** The window cell is `windowClauseSql`'s own (`LIMIT 18446744073709551615`). It is NOT MEASURED on either face here, because no MySQL server is available. That is the same declared skip PR objectstack-ai#21399 carries. - **Unknown dialect on PostgreSQL.** A host whose `sqlDialect` hook names no dialect now echoes `LIMIT 9223372036854775807 OFFSET n` where it echoed `OFFSET n`. The native face already runs that statement for such a host. It answers the same rows on PostgreSQL and SQLite, as the pin's `unknown` arm measures. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #21365
Clause-②: yes (narrowing)
What this changes
Two halves of one family: an analytics query window that had no single answer across drivers and faces.
1. The contract (
packages/spec).AnalyticsQuerySchema.limitand.offsetwere a barez.number(). They arez.number().int().nonnegative()now (packages/spec/src/data/analytics.zod.ts), each with a.describe().DatasetSelectionSchemareads the same two declarations offAnalyticsQuerySchema.shape, andAnalyticsQueryRequestSchemaextends the query. So all three doors hold one accept set, and there is no second declaration. A spec pin asserts that the dataset selection's two members are the same instances.POST /analytics/queryandPOST /analytics/sql(packages/runtime/src/domains/analytics.ts) andPOST /analytics/dataset/query(packages/rest/src/analytics-selection-door.ts). A negative or fractional value now answers400 VALIDATION_FAILED, withdetails.fields[].fieldnaminglimitoroffset(selection.limit/selection.offsetat the dataset door), before any engine runs. There is no new refusal code and no runtime code change.limit: 0stays legal (LIMIT 0, no rows). An offset with no limit stays legal.number). Only the parse narrows.2. The offset-only window on the native face (
service-analytics).NativeSQLStrategy.assembleStatementwroteOFFSET nwith noLIMITin front of it, which SQLite cannot parse. The window clause is nowwindowClauseSql(limit, offset, dialect)innative-sql-strategy.ts, with the dialect read off the existingsqlDialecthook (sqlDialectFor, filled by the plugin from the executing driver'sdialectName). An offset with no limit takes the dialect's no-limit spelling:sqliteLIMIT -1 OFFSET npostgresOFFSET n(bytes unchanged)unknown(no hook wired)LIMIT 9223372036854775807 OFFSET nmysqlLIMIT 18446744073709551615 OFFSET nThe named-dialect cells are the spelling the driver's own query compiler emits for the same window (knex 3.3.0:
limit -1in the sqlite3 compiler,limit 18446744073709551615in the mysql one, nothing in the base compiler PostgreSQL uses).unknownis everything the hook could not name, SQLite included, so its cell is the largestLIMITevery LIMIT dialect parses. That follows the residue-arm reasoningtext-match-sql.tsalready records for the same hook. The door does not refuse the offset-only window.The echoed
sqlandPOST /analytics/sqlon the native face are the statement that ran, byte for byte (pinned).Measured before and after, at the route
Real
POST /api/v1/analytics/queryand/analytics/sql(createDispatcherPlugin), withAnalyticsServicePluginover a realObjectQLengine andSqlDriver. Two compositions: the default one (native face) and one narrowed to the engine aggregate (ObjectQL face). Four groups bynote(w, x, y, z),order { note: 'asc' }, SQLite (better-sqlite3) and a private PostgreSQL 16.14 server.mainee75aae1a: native SQLitelimit: -1VALIDATION_FAILED(limit)limit: 1.5VALIDATION_FAILED(limit)offset: -1VALIDATION_FAILED(offset)offset: 1, nolimitnear "OFFSET": syntax error)limit: 2, offset: 1On
main,/analytics/sqlanswered 200 for every row, rendering the statement. On this branch it answers the same 400 for rows 1 to 3. The dataset door's selection check (datasetSelectionRefusal) answers400 VALIDATION_FAILEDnamingselection.limit/selection.offsetfor the same four refused values, and passes{ offset: 1 }and{ limit: 2, offset: 1 }. That was measured at the helper, not over HTTP.Pins
packages/spec/src/data/analytics-query-window-integer.test.ts: on each of the three schemas, a negative limit, a fractional limit, a negative offset and a fractional offset are refused at the member's own path, with zod's issue code (never its message). Integer windows,limit: 0,offset: 0, an offset with no limit and no window are each a whole-safeParsesuccess. Plus the identity pin on the dataset selection's two members.packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts(SQLite always, PostgreSQL whereOS_TEST_POSTGRES_URLis set):generateSqlequal the statement that ran;unknownarm (aNativeSQLStrategywhose context wires nosqlDialect) runs and answers the same rows;windowClauseSqlper dialect, with the MySQL cell as text only.packages/runtime/src/analytics-query-window-validity.test.ts(route level, SQLite always, PostgreSQL whereOS_TEST_POSTGRES_URLis set): rows 1 to 3 plusoffset: 1.5answer 400VALIDATION_FAILEDnaming the member at both routes, on both faces, and the engine sees no raw statement and no aggregate. Row 4 answers x y z on both faces, and the native echo and/analytics/sqlequal the statement that ran. CONTROLs:limit 2, offset 1andlimit 0.No CI step provisions
OS_TEST_POSTGRES_URLfor these two packages, so the PostgreSQL cells are red-capable and un-run in CI. The numbers below include them, run against a private local PostgreSQL 16.14.Ablations
Each leg ran on the committed tree at
b1a8ea073throughscripts/ablation-replace.mjs. The anchor was proven to land (count and blob hash), and the restore was proven by blob hash equal toHEADand an emptygit diff HEAD. The spec and service-analytics pins import fromsrc/, and the runtime suite aliases@objectstack/spectosrc/, so none of these legs goes through adist/..int()dropped (both members)limit: 1.5andoffset: 1.5, both cells. Native PostgreSQL answered 200 withLIMIT 1.5andOFFSET 1.5.nonnegative()droppedlimit: -1andoffset: -1, both cellswindowClauseSqlwrites nothing beforeOFFSET)unknownarm; on PostgreSQL only theunknownarm. 7 green: both CONTROLs and the PostgreSQL cells that run a bareOFFSETEach leg turned red in the direction expected beforehand. The route pin's row 4 reads
service-analyticsfromdist/(KNOWN_UNALIASED_TEST_IMPORTS), so the no-limit leg was run on the service pin, which imports fromsrc/.ADR-0087 disposition
The changeset is declared breaking and carries the disposition
registered analytics-query-window-non-negative-integer. That is a new D3 semantic entry,packages/spec/src/migrations/entries/semantic/18.analytics-query-window-non-negative-integer.ts, andregistry.tswas regenerated bygen:migration-registry. The gate's other categories do not apply: the package is published, nothing was registered before, the changeset carries a FROM → TO prescription (sono-migration-prescriptionis refused), and this is a metadata parse, not a runtime interface or a type surface. No D2 conversion: the window is a query-time request field, not a storedsys_metadatashape, and the refused values meant different windows per backend.spec-changes.jsonand the upgrade guide fold majors up to 17, so a major-18 entry leaves both byte-identical.check:spec-changesandcheck:upgrade-guidepass.Acceptance notes
OFFSET nwith noLIMITon SQLite.objectql-strategy.tsgenerateSql, the two window lines. The face's rows are right. Only the echoed text cannot run on SQLite. Measured on this branch: a month-bucketedorder+offset: 1(which lands on that face on every composition) echoes… ORDER BY "closed_on" ASC OFFSET 1on both drivers. That echo also rendersdate_trunc(…), which SQLite cannot run either. A non-bucketed query on a composition narrowed to the engine aggregate echoes… ORDER BY "note" ASC OFFSET 1. Rendering it withwindowClauseSqlis a two-line change, butobjectql-strategy.tsis outside this claim's file surface and held by another claim (domain:engine#1's #5930 step 4 (domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula,having); the memory reference matcher retires (D6) #20822 group 4, prose only), so it is not changed here. It is raised in the report.mysqlcell is text only. Separately, and NOT MEASURED: the native statement quotes identifiers with ANSI double quotes, which MySQL withoutANSI_QUOTESreads as string literals (the CI workflow's own comment says this repo's MySQL runs without it). So the native face may not run on MySQL at all, whatever its window. This is an inference from reading the code, not a measurement, and is noted only.AnalyticsService.querydoes not parse its input, so a host that builds a query in code is not refused by this narrowing. The changeset and the semantic entry say so. The census found no producer of a negative or fractional window: none in examples, fixtures, docs or published skills, and the one stored producer that lowers into a selection, a dashboard widget'slimit, is alreadyz.number().int().positive(). The sibling console repository was not checked out here, so its census is NOT MEASURED.Number.MAX_SAFE_INTEGERare refused too (zod's.int()). That is stated in the changeset.content/docs/references/{api,data}/analytics.mdx) were regenerated bycheck:generated --fix, and it proved onlycheck:docsstale. No hand-written page incontent/docs/**(outsidereleases/) orskills/**says anything this change makes false.Verification (head
b1a8ea073, after mergingmain6d487d209)turbo run build(72 tasks) after the merge. Then:pnpm --filter @objectstack/service-analytics testwith live PostgreSQL: 168 files, 3883 tests passed;pnpm --filter @objectstack/spec test: 599 files, 17557 passed, 1 todo;@objectstack/runtime: the new route pin, 16 passed on both cells.typecheckof@objectstack/spec,@objectstack/service-analyticsand@objectstack/runtime: exit 0. Each new test file is in a compiled program (tsc --listFiles: the spec and runtime test configs, the service-analytics main config), andcheck:test-typecheckholds both debt ledgers unchanged.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 116 commands atb1a8ea073, and all 116 exited 0.--ranreconciles 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN..tsfiles:eslint --no-inline-config --format jsonread 7 files, 0 errors, 0 warnings, none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the verdict on an untouched file. The repo-widepnpm lintis left to CI.packages/cliandpackages/restare untouched.@objectstack/runtimechanges by one test file only.Generated by Claude Code