Skip to content

Commit 1ee5841

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20234-migrations-citations
2 parents 845e90f + 01e78dc commit 1ee5841

40 files changed

Lines changed: 1016 additions & 147 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
`GET /api/v1/analytics/meta` now publishes an analytics cube's `description`, each measure's and dimension's `description`, and each measure's `format`, when the cube definition declares them (#20282).
7+
8+
Clause-②: yes (widening)
9+
10+
- `CubeMeta` (`@objectstack/spec/contracts`) gains an optional `description` on the cube and on each measure and dimension, and an optional `format` on each measure. `AnalyticsMetadataResponseSchema` declares the same members. A definition that declares none of them is published exactly as before.
11+
- `AnalyticsService.getMeta` copies what the definition declares and fills in nothing. A cube compiled from a dataset carries each dataset measure's `format` and no `description`.
12+
- The liveness ledger rows `analytics_cube.description`, `measures.description` and `dimensions.description` move from `dead` to `live`.
13+
14+
This supersedes one sentence of this release's note on an authored cube's measure `format` and `granularities`: it says `GET /api/v1/analytics/meta` is unchanged and keeps `name`, `type` and `title`. With this change `/meta` also publishes each measure's declared `format`. A client that formats a result column still reads `format` off the query result's `fields[]`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/service-automation': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
Warnings, refusals and hints that cited a tracker number now say what was decided
8+
9+
Clause-②: no
10+
11+
Several runtime strings an author or operator reads sent the reader to an issue-tracker number for
12+
the reason behind them. Each now states that reason in the sentence itself:
13+
14+
- `@objectstack/objectql`: the two data-event warnings. A write that names no single record publishes
15+
no per-record event rather than one with an empty `recordId`; a predicate (`multi: true`) write
16+
publishes its own `data.records.*` event carrying the affected-row count and nothing else, so a
17+
driver result that is not a count publishes no bulk event either.
18+
- `@objectstack/service-automation`: the warning for a pausing node type that never declares
19+
`resumeAuthority`, the generic-route resume refusal (its log line and its error text), and the
20+
refusal of a suspension from a type that declares `supportsPause: false`. An undeclared
21+
`resumeAuthority` resolves to `'service'` (fail-closed), so the generic resume route refuses those
22+
pauses; guessing `'any'` is how a raw resume once walked past an approval decision no service had
23+
recorded.
24+
- `@objectstack/runtime`: the endpoint step's `NOT_IMPLEMENTED` message and its two hints (the
25+
composed runtime always threads the policy context and the execution wiring, because execution is
26+
reachable only past the policy chain), and the endpoint mapping refusals (the publish gate rejects
27+
the same shapes, so a declaration that reaches the runtime check was stored without passing it).
28+
29+
Text only: no error code, field name, status or behaviour changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/driver-memory': patch
5+
'@objectstack/service-analytics': patch
6+
---
7+
8+
fix: `sum` / `avg` answer the same double on every face the platform owns, added with one compensated fold that `@objectstack/core` now exports as `compensatedSum` (#20544)
9+
10+
Clause-②: yes
11+
12+
**New export.** `@objectstack/core` exports `compensatedSum(nums)`: the sum of
13+
`nums`, added in order with Kahan-Babuska-Neumaier compensation, which is the
14+
summation SQLite (3.43 and later) uses for its own `sum` and `avg`. It moved
15+
here from `@objectstack/objectql`'s rows path (`in-memory-aggregation.ts`),
16+
which now imports it instead of keeping a private copy.
17+
18+
**What changed.** Three folds still added a group's values naively, and now call
19+
the same function:
20+
21+
- `@objectstack/driver-memory`'s `aggregate()` and `find()` with aggregations,
22+
the path `engine.aggregate` takes on an in-memory datasource;
23+
- `@objectstack/driver-memory`'s analytics face (`MemoryAnalyticsService`),
24+
whose `sum` / `avg` measures are now a `$group` `$accumulator` in place of
25+
mingo's `$sum` / `$avg`;
26+
- `@objectstack/service-analytics`' draft preview.
27+
28+
Over a `number` column holding `0.1`, `0.2` and `0.3`, each of them answered
29+
`0.6000000000000001` / `0.20000000000000004`. They now answer `0.6` /
30+
`0.19999999999999998`, as SQLite and the engine's rows path do. Over
31+
`1e16, 1, -1e16` they answered `0` and now answer `1`. On driver-memory,
32+
`engine.aggregate` gave two answers depending on its path: `having { s: { $eq:
33+
0.6 } }` kept the group on the rows path and dropped it on the native path. It
34+
now keeps it on both.
35+
36+
**What did not move.** Two addends, integers whose running total stays within
37+
2^53, and a non-finite total give the same answer as before. Which values count
38+
as addends did not change either: booleans as 1 / 0, and nulls and non-numeric
39+
strings left out, as each face already had it. `count`, `min` and `max` are
40+
untouched. The analytics face's pipeline dump (`result.sql`) now renders the
41+
accumulator's functions by name, so a `sum` measure and an `avg` measure still
42+
dump differently.
43+
44+
**Residual.** PostgreSQL and MySQL add their doubles natively without
45+
compensation, and the platform does not wrap that arithmetic. So over three or
46+
more fractions their native path can still differ from these faces in the last
47+
place. An exact `$eq` on a fractional sum compares doubles; compare with a range.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
Provenance comments in `@objectstack/plugin-hono-server` were re-anchored
6+
7+
Comment and docblock lines under `src/` that cited tracker numbers which no
8+
longer resolve on GitHub now cite the commit in this repository's history that
9+
decided the matter, and say in their own words what was decided. Comments
10+
only: no route, error code, refusal text, type, export or runtime behaviour
11+
changes.

‎content/docs/api/data-api.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,13 @@ never as guaranteed present.
461461
The **cube's metric/dimension definition** (`MetricSchema.label` / `MetricSchema.format`,
462462
`DimensionSchema.label`) is the *declaration* surface, not a substitute for this one.
463463
`GET /analytics/meta` below publishes a deliberately narrow projection of it — `name`,
464-
`type` and `title` (the definition's `label`) only, with `format` dropped
464+
`type`, `title` (the definition's `label`), `description`, and on a measure its declared
465+
`format`; never `sql` or `granularities`
465466
([#6442](https://github.com/objectstack-ai/objectstack/issues/6442)). So a client that
466467
renders table headers or formats amounts takes `label` / `format` / `currency` /
467-
`percentScale` off the query result's `fields[]`: those are resolved server-side (the
468-
currency chain below, the percent-scale chain), and `format` is not reachable through
469-
the metadata endpoint at all.
468+
`percentScale` off the query result's `fields[]`: those are resolved server-side, per
469+
column (the currency chain below, the percent-scale chain), and `currency` and
470+
`percentScale` are not reachable through the metadata endpoint at all.
470471

471472
The currency chain runs on the dataset query (`POST /analytics/dataset/query`). A
472473
monetary measure column takes the measure's own `currency`; then, only when the source

‎content/docs/references/api/analytics.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const result = AnalyticsEndpoint.parse(data);
4747
| **success** | `boolean` | ✅ | Operation success status |
4848
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
4949
| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata |
50-
| **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. |
50+
| **data** | `{ name: string; title?: string; description?: string; measures: object[]; … }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title and description, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. |
5151

5252
### Nested Shape: `AnalyticsMetadataResponse.error`
5353

@@ -78,8 +78,9 @@ const result = AnalyticsEndpoint.parse(data);
7878
| :--- | :--- | :--- | :--- |
7979
| **name** | `string` | ✅ | Cube name |
8080
| **title** | `string` | optional | Human-readable cube title |
81-
| **measures** | `{ name: string; type: string; title?: string }[]` | ✅ | Measures this cube accepts in `/analytics/query` |
82-
| **dimensions** | `{ name: string; type: string; title?: string }[]` | ✅ | Dimensions this cube accepts in `/analytics/query` |
81+
| **description** | `string` | optional | Cube description, projected from the cube definition's `description` |
82+
| **measures** | `{ name: string; type: string; title?: string; description?: string; … }[]` | ✅ | Measures this cube accepts in `/analytics/query` |
83+
| **dimensions** | `{ name: string; type: string; title?: string; description?: string }[]` | ✅ | Dimensions this cube accepts in `/analytics/query` |
8384

8485

8586
---

‎content/docs/references/data/analytics.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ Type: `[string, string]`
148148
| **description** | `string` | optional | |
149149
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
150150
| **sql** | `string` | ✅ | SQL expression or field reference |
151-
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. |
151+
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
152152

153153
### Nested Shape: `Cube.dimensions[string]`
154154

@@ -221,7 +221,7 @@ Type: `[string, string]`
221221
| **description** | `string` | optional | |
222222
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
223223
| **sql** | `string` | ✅ | SQL expression or field reference |
224-
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. |
224+
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
225225

226226

227227
---

‎examples/app-showcase/src/data/analytics/showcase.cube.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export const DeliveryCube = defineCube({
3939
label: 'Done Rate (%)',
4040
type: 'number',
4141
sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)",
42-
format: 'percent',
42+
// A numeral pattern, the vocabulary `fields[].format` documents: `%` marks a
43+
// percent, `.0` one decimal. The value above is in percentage points (0-100).
44+
format: '0.0%',
4345
},
4446
},
4547
dimensions: {

‎packages/core/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export * from './utils/env.js';
4949
// Export timezone-aware calendar utilities (ADR-0053 Phase 2)
5050
export * from './utils/datetime.js';
5151

52+
// [#20544] The ONE compensated fold for `sum` / `avg`, hoisted from
53+
// `@objectstack/objectql`'s rows path for the reason `bucketDateKey` above
54+
// was: `driver-memory`'s two faces and `service-analytics`' draft preview add
55+
// a group's values in JavaScript too, neither package has objectql among its
56+
// runtime dependencies, and a copy per face is how one `sum` came to answer
57+
// two doubles.
58+
export * from './utils/compensated-sum.js';
59+
5260
// Export the shared batched-write helper (framework#2678)
5361
export * from './utils/bulk-write.js';
5462

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20544] `compensatedSum` — the one compensated fold every platform face that
5+
* adds a group's values in JavaScript calls (objectql's rows path,
6+
* driver-memory's data and analytics faces, service-analytics' draft preview).
7+
*
8+
* The expected values are SQLite's own `sum` answers, measured by #20489 with
9+
* better-sqlite3 (SQLite 3.53.4), sql.js (3.49.1) and @libsql/client (3.45.1);
10+
* the naive fold each face used before is asserted beside them, so a fixture
11+
* that cannot tell the two folds apart fails here instead of passing vacuously.
12+
* Each face pins the same fixtures through its own door, in its own package.
13+
*/
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { compensatedSum } from './compensated-sum';
17+
18+
/** The fold the faces used before: in order, one addition at a time. */
19+
const naiveSum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0);
20+
21+
describe('[#20544] compensatedSum — the one compensated fold', () => {
22+
it("the card's fixture: 0.1 + 0.2 + 0.3 is SQLite's 0.6, not the naive 0.6000000000000001", () => {
23+
expect(naiveSum([0.1, 0.2, 0.3])).toBe(0.6000000000000001);
24+
expect(compensatedSum([0.1, 0.2, 0.3])).toBe(0.6);
25+
// The mean every avg arm divides out of it: SQLite's 0.19999999999999998.
26+
expect(compensatedSum([0.1, 0.2, 0.3]) / 3).toBe(0.19999999999999998);
27+
});
28+
29+
it('a large cancellation keeps the small addend: 1e16 + 1 - 1e16 is 1, not 0', () => {
30+
expect(naiveSum([1e16, 1, -1e16])).toBe(0);
31+
expect(compensatedSum([1e16, 1, -1e16])).toBe(1);
32+
expect(compensatedSum([1e16, 0.5, -1e16])).toBe(0.5);
33+
});
34+
35+
it('two addends are unchanged: the compensated a + b is the naive one', () => {
36+
for (const pair of [[0.1, 0.2], [0.7, 0.1], [1e16, 1], [-0.3, 0.1]]) {
37+
expect(compensatedSum(pair), `${pair}`).toBe(naiveSum(pair));
38+
}
39+
expect(compensatedSum([0.1, 0.2])).toBe(0.30000000000000004);
40+
});
41+
42+
it('integers whose partial sums stay within 2^53 are unchanged', () => {
43+
for (const ints of [[1, 2, 3, 40, 500], [-7, 3, 12, 0, 9_000_000_000]]) {
44+
const s = compensatedSum(ints);
45+
expect(s, `${ints}`).toBe(naiveSum(ints));
46+
expect(Number.isInteger(s)).toBe(true);
47+
}
48+
expect(compensatedSum([1, 2, 3, 40, 500])).toBe(546);
49+
});
50+
51+
it('an empty list is 0, and one addend is itself', () => {
52+
expect(compensatedSum([])).toBe(0);
53+
expect(compensatedSum([0.1])).toBe(0.1);
54+
expect(compensatedSum([-2.5])).toBe(-2.5);
55+
});
56+
57+
it('a non-finite total is the naive one, as SQLite returns its running sum when the error term overflows', () => {
58+
for (const values of [
59+
[Infinity, 1, 2],
60+
[1, -Infinity, 0.3],
61+
[1e308, 1e308, -1e308],
62+
[Infinity, -Infinity, 1],
63+
[NaN, 0.1, 0.2],
64+
]) {
65+
expect(Object.is(compensatedSum(values), naiveSum(values)), `${values}`).toBe(true);
66+
}
67+
});
68+
});

0 commit comments

Comments
 (0)