Skip to content

Commit 32e8411

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20697-semantic-conversion-ids
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
2 parents f9bd45c + 679f95e commit 32e8411

23 files changed

Lines changed: 947 additions & 77 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): every dataset answer names its base object as `object` (#20644)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** `queryDataset` set `object`, the dataset's base object, only
10+
while it built drill-through metadata, which it builds only when a drillable
11+
dimension is selected and at least one row came back. A dimension-less (KPI)
12+
answer, a zero-row answer and the degraded answer for an unavailable backing
13+
object carried no `object`, and neither did a draft preview (`previewDrafts`),
14+
grouped or not. `POST /api/v1/analytics/dataset/query` relays the service answer
15+
as it is, so a consumer that refreshes on that object's record changes had
16+
nothing to subscribe to for those answers.
17+
18+
**What changed.** Every `queryDataset` answer carries `object`, the dataset's
19+
`object` by machine name, whatever dimensions are selected and whether or not
20+
rows came back, as `AnalyticsResult.object` in `@objectstack/spec` declares. A
21+
grouped answer is unchanged: `object` sits beside the same drill-through keys
22+
as before. A cube `query` answer still carries no `object`.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/driver-memory': patch
3+
---
4+
5+
fix(driver-memory): a cube `where` `$lte` on a bare day keeps the whole day on a declared `datetime` field (#20661)
6+
7+
Clause-②: no
8+
9+
`MemoryAnalyticsService` put a `$lte` comparand into the field's storage form before it applied the whole-day rule for a bare-day upper bound. On a field declared `datetime` (through `syncSchema`) the storage form of `'2026-07-28'` is the instant `'2026-07-28T00:00:00.000Z'`, and the whole-day rule does not widen an instant. So `where: { created_at: { $lte: '2026-07-28' } }` compiled an inclusive bound at that midnight and dropped every row later in the named day, while `find()` with the same filter kept them. `generateSql()` echoed the same narrowed bound.
10+
11+
Both exits now follow ADR-0053's order: the bare day is widened first, and only the resulting bound is converted to the storage form. On a declared `datetime` field the example compiles `created_at < '2026-07-29T00:00:00.000Z'` and answers the same rows as `find()`. On `9999-12-31`, the last supported day, a declared `datetime` field now asks only for a value (`IS NOT NULL` in the echo), as an undeclared field already did.
12+
13+
Unchanged: an undeclared field, a declared `date` field, a full timestamp or `Date` comparand (inclusive, as written), and a `timeDimensions[].dateRange` end, which already widened the day before building its bounds. `$between` stays refused on this face (`INVALID_FILTER`, 400). Nothing is removed or renamed, and there is nothing to migrate.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: re-enabling a packaged flow is refused while a packaged subflow it calls is disabled, and the refusal names a remedy that subflow's state admits (#20678)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which enable calls the activation switch accepts. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, true)` on the automation service, and so `POST /api/v1/automation/:name/toggle` with `{"enabled": true}`, now refuses an enable it used to accept.
12+
13+
**What changed.** A packaged flow switched off in the activation ledger could be switched back on while a packaged flow it calls (the `flowName` of a `subflow` node, or of a `map` node) was itself disabled. The enable was accepted, and every run of the flow then failed at that node on the child's `FLOW_DISABLED` refusal. That enable is now refused with `RESOURCE_CONFLICT` / `409`, before anything is written: the ledger row still reads off, the trigger stays unbound, and runs are still refused. The message names each disabled subflow and what holds it off, and the remedy follows from that:
14+
15+
- **Switched off in the activation ledger**: enable that subflow first, then this flow.
16+
- **Disabled by its own definition's `status`** (`obsolete` or `invalid`): the activation switch never changes a status, so enabling the subflow through it would change nothing. Publish the subflow with status `active` (for a package that is read-only in this environment, that takes a package version that ships it active), then enable this flow.
17+
18+
**Not refused:**
19+
20+
- Enabling a flow that is already enabled. Nothing is re-armed.
21+
- A flow the customer authored, or a subflow the customer authored.
22+
- A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete.
23+
24+
Disabling a subflow is unchanged.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20661] The analytics (cube) face widens a bare-day `lte` BEFORE it puts the
5+
* bound into the field's storage form — ADR-0053 D-E3's order, the one `find()`
6+
* already follows (`memory-driver.ts` widens `val`, then converts `nextDay`).
7+
*
8+
* Both `lte` rows (the mingo `$match` and the SQL echo) used to ask
9+
* `nextUtcCalendarDay` about the comparand AFTER the conversion. On an
10+
* undeclared field the storage form is the authored string, so the day
11+
* widened. On a DECLARED `datetime` field it is the instant
12+
* `2026-07-28T00:00:00.000Z`, which the helper correctly refuses to widen, so
13+
* the cube compiled `<= '2026-07-28T00:00:00.000Z'` and dropped the rest of the
14+
* named day — one row where `find()` on the same filter answers two.
15+
*
16+
* Every case drives both exits and `find()` on the same data, for an
17+
* undeclared field and for the same field declared through `syncSchema`.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import type { Cube, FilterCondition } from '@objectstack/spec/data';
22+
import { InMemoryDriver } from './memory-driver.js';
23+
import { MemoryAnalyticsService } from './memory-analytics.js';
24+
25+
const ids = (rows: ReadonlyArray<Record<string, unknown>>) => rows.map((r) => String(r.id)).sort();
26+
27+
/** The card's two rows: the named day's morning, and the day before. */
28+
const CARD_ROWS = [
29+
{ id: 'r28', created_at: '2026-07-28T10:00:00.000Z', made_on: '2026-07-28' },
30+
{ id: 'r27', created_at: '2026-07-27T10:00:00.000Z', made_on: '2026-07-27' },
31+
] as const;
32+
33+
const CUBE = {
34+
name: 'tasks',
35+
title: 'Tasks',
36+
sql: 'task',
37+
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
38+
dimensions: {
39+
id: { label: 'Id', type: 'string', sql: 'id' },
40+
created_at: { label: 'Created', type: 'time', sql: 'created_at' },
41+
made_on: { label: 'Made on', type: 'time', sql: 'made_on' },
42+
},
43+
} as unknown as Cube;
44+
45+
type Declaration = 'undeclared' | 'declared';
46+
47+
async function setup(declaration: Declaration, rows: ReadonlyArray<Record<string, unknown>> = CARD_ROWS) {
48+
const driver = new InMemoryDriver({});
49+
await driver.connect();
50+
if (declaration === 'declared') {
51+
await driver.syncSchema('task', {
52+
name: 'task',
53+
fields: { created_at: { type: 'datetime' }, made_on: { type: 'date' } },
54+
});
55+
}
56+
for (const row of rows) await driver.create('task', { ...row });
57+
const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] });
58+
return { driver, service };
59+
}
60+
61+
const cubeQuery = (where: FilterCondition) =>
62+
({ cube: 'tasks', measures: ['count'], dimensions: ['id'], where }) as any;
63+
64+
/** The echo's WHERE clause alone. */
65+
const whereOf = (sql: string) => /WHERE (.*?)(?: GROUP BY|$)/.exec(sql)?.[1];
66+
67+
/**
68+
* One `where` on all three readings: `find()`, the cube's rows, and the echo.
69+
* The ids are asserted twice on purpose — equal to `find()` (the invariant) and
70+
* equal to the literal list (so the two cannot be wrong together).
71+
*/
72+
async function answer(declaration: Declaration, where: FilterCondition, rows?: ReadonlyArray<Record<string, unknown>>) {
73+
const { driver, service } = await setup(declaration, rows);
74+
return {
75+
find: ids(await driver.find('task', { where })),
76+
cube: ids((await service.query(cubeQuery(where))).rows),
77+
echo: whereOf((await service.generateSql(cubeQuery(where))).sql),
78+
};
79+
}
80+
81+
describe('[#20661] cube `lte` on a bare day widens the authored day, then converts the bound', () => {
82+
const where: FilterCondition = { created_at: { $lte: '2026-07-28' } };
83+
84+
it('undeclared `created_at`: both rows, the half-open bound at the next day', async () => {
85+
const got = await answer('undeclared', where);
86+
expect(got.cube).toEqual(got.find);
87+
expect(got.cube).toEqual(['r27', 'r28']);
88+
expect(got.echo).toBe("created_at < '2026-07-29'");
89+
});
90+
91+
it('declared `datetime` `created_at`: the same rows as find(), the bound in storage form', async () => {
92+
const got = await answer('declared', where);
93+
expect(got.cube).toEqual(got.find);
94+
// Before the fix: ['r27'], echoed as `created_at <= '2026-07-28T00:00:00.000Z'`.
95+
expect(got.cube).toEqual(['r27', 'r28']);
96+
expect(got.echo).toBe("created_at < '2026-07-29T00:00:00.000Z'");
97+
});
98+
99+
it('a full timestamp keeps instant semantics: inclusive, in storage form, not widened', async () => {
100+
const instant: FilterCondition = { created_at: { $lte: '2026-07-28T10:00:00Z' } };
101+
const got = await answer('declared', instant);
102+
expect(got.cube).toEqual(got.find);
103+
expect(got.cube).toEqual(['r27', 'r28']);
104+
expect(got.echo).toBe("created_at <= '2026-07-28T10:00:00.000Z'");
105+
});
106+
107+
it('declared `date` control: a bare day is its own storage form, so nothing moves', async () => {
108+
const dateWhere: FilterCondition = { made_on: { $lte: '2026-07-28' } };
109+
for (const declaration of ['undeclared', 'declared'] as const) {
110+
const got = await answer(declaration, dateWhere);
111+
expect(got.cube, declaration).toEqual(got.find);
112+
expect(got.cube, declaration).toEqual(['r27', 'r28']);
113+
expect(got.echo, declaration).toBe("made_on < '2026-07-29'");
114+
}
115+
});
116+
117+
it('declared `datetime` on the last supported day asks only for a value; the day before is a bound', async () => {
118+
const rows = [
119+
{ id: 'c26', created_at: '2026-07-15T14:00:00.000Z' },
120+
{ id: 'prev', created_at: '9999-12-30T10:00:00.000Z' },
121+
{ id: 'mid', created_at: '9999-12-31T10:00:00.000Z' },
122+
{ id: 'last', created_at: '9999-12-31T23:59:59.999Z' },
123+
];
124+
const lastDay = await answer('declared', { created_at: { $lte: '9999-12-31' } }, rows);
125+
expect(lastDay.cube).toEqual(lastDay.find);
126+
// Before the fix: ['c26', 'prev'], echoed as `created_at <= '9999-12-31T00:00:00.000Z'`.
127+
expect(lastDay.cube).toEqual(['c26', 'last', 'mid', 'prev']);
128+
expect(lastDay.echo).toBe('created_at IS NOT NULL');
129+
130+
const dayBefore = await answer('declared', { created_at: { $lte: '9999-12-30' } }, rows);
131+
expect(dayBefore.cube).toEqual(dayBefore.find);
132+
expect(dayBefore.cube).toEqual(['c26', 'prev']);
133+
expect(dayBefore.echo).toBe("created_at < '9999-12-31T00:00:00.000Z'");
134+
});
135+
});
136+
137+
describe('[#20661] the siblings on this face', () => {
138+
it('a `dateRange` bare-day end already widens the authored end: declared `datetime` keeps the whole day', async () => {
139+
const { driver, service } = await setup('declared');
140+
const result = await service.query({
141+
cube: 'tasks',
142+
measures: ['count'],
143+
dimensions: ['id'],
144+
timeDimensions: [{ dimension: 'created_at', dateRange: ['2026-07-01', '2026-07-28'] }],
145+
} as any);
146+
const found = ids(await driver.find('task', { where: { created_at: { $gte: '2026-07-01', $lte: '2026-07-28' } } }));
147+
expect(ids(result.rows)).toEqual(found);
148+
expect(ids(result.rows)).toEqual(['r27', 'r28']);
149+
});
150+
151+
it('`$between` is refused on both exits, so its maximum never reaches a bound here', async () => {
152+
// Not a lowering this face owns: widening `MONGO_TO_CUBE_OPERATOR` to take
153+
// `$between` turns this red, and its maximum then owes the same whole-day
154+
// order as the `lte` rows (`lteUpperBound`).
155+
const { service } = await setup('declared');
156+
const between = cubeQuery({ created_at: { $between: ['2026-07-01', '2026-07-28'] } });
157+
for (const exit of [() => service.query(between), () => service.generateSql(between)]) {
158+
const err = await exit().then(
159+
() => undefined,
160+
(e: unknown) => e as { code?: unknown; status?: unknown },
161+
);
162+
expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
163+
}
164+
});
165+
});

0 commit comments

Comments
 (0)