Skip to content

Commit 1fb060e

Browse files
authored
Merge branch 'main' into claude/priceless-mirzakhani-5c8b08
2 parents 4ef69fc + 9498eb4 commit 1fb060e

16 files changed

Lines changed: 507 additions & 25 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
feat(analytics): emit a half-open date-range drill scope for granularity-bucketed date dimensions (#1752)
7+
8+
A report/dashboard cell grouped by a `dateGranularity` date dimension ("2026-Q2")
9+
covers a SPAN of records, so drilling it needs a range (`>= start AND < nextStart`),
10+
which the equality drill contract (`drillRawRows`) can't express — date dims were
11+
therefore excluded from drill metadata and a drill landed on an unscoped superset.
12+
13+
- **`@objectstack/core`** adds `bucketKeyToCalendarRange(key, granularity)`, the
14+
inverse of `bucketDateValue`: it turns a canonical bucket key into its half-open
15+
`[start, end)` calendar span (`YYYY-MM-DD`, `end` exclusive). Pure, timezone-naive
16+
calendar arithmetic; returns `null` for unbucketable / out-of-range keys so the
17+
caller falls back to an unscoped (superset) drill rather than emit a wrong bound.
18+
- **`@objectstack/service-analytics`** emits a `drillRanges` sidecar (aligned to
19+
`rows` by index — the range companion to `drillRawRows`) for `date` +
20+
`dateGranularity` dimensions, computed from the canonical bucket key in the
21+
pre-label-resolution snapshot pass. A `datetime` field under a non-UTC reference
22+
timezone is omitted (host drills a superset) until instant-boundary support
23+
lands; a tz-naive `date` field is exact under any timezone (ADR-0053).
24+
25+
Consumed by objectui's report drill-through to scope the drilled record list to the
26+
clicked time bucket.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
test(automation): big-loop run-history integration test + fix pre-existing test-file type errors
5+
6+
Test-only; no runtime/API change (nothing under `src/` behaviour is touched), so
7+
this releases nothing. Adds an end-to-end `run-history.test.ts` case exercising
8+
the region-aware history compaction (#3234) through the real engine
9+
(execute → recordTerminal → restart → getRun) with a >MAX-step loop, and clears
10+
the pre-existing `tsc --noEmit` errors across the package's test files (missing
11+
`maturity`, implicit `any`, `ConnectorProviderFactory` test-double defs, an
12+
unused param, a `{}`-typed output access) so the whole package type-checks clean.

packages/core/src/utils/datetime.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,120 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts {
5858
day: d.getUTCDate(),
5959
};
6060
}
61+
62+
/**
63+
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s
64+
* `DateGranularity` enum but kept as a local literal union so this low-level
65+
* package needs no dependency on spec.
66+
*/
67+
export type BucketGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
68+
69+
/**
70+
* ISO-8601 week label (Mon-start weeks, week 1 = the week of the first
71+
* Thursday) of a UTC calendar day. The forward-direction companion used to
72+
* *validate* a reconstructed week boundary; it mirrors the week branch of
73+
* `@objectstack/objectql`'s `bucketDateValue` (kept in lockstep by the
74+
* round-trip parity test in objectql).
75+
*/
76+
function isoWeekLabelUtc(d: Date): string {
77+
const target = new Date(d.getTime());
78+
const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6
79+
target.setUTCDate(target.getUTCDate() - dayNum + 3); // shift to that week's Thursday
80+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
81+
const weekNo =
82+
1 +
83+
Math.round(
84+
((target.getTime() - firstThursday.getTime()) / 86400000 -
85+
3 +
86+
((firstThursday.getUTCDay() + 6) % 7)) /
87+
7,
88+
);
89+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
90+
}
91+
92+
/**
93+
* The half-open calendar span `[start, end)` of a canonical date-bucket KEY,
94+
* as `YYYY-MM-DD` strings (`start` inclusive, `end` exclusive — the next
95+
* bucket's first day).
96+
*
97+
* The input MUST be the canonical key produced by `bucketDateValue` /
98+
* `buildDateBucketExpr` (`2026`, `2026-Q2`, `2026-06`, `2026-06-15`,
99+
* `2026-W23`) — NEVER a localized / humanized display label. The span is pure,
100+
* timezone-naive calendar arithmetic; a caller that needs instant bounds for a
101+
* `datetime` field in a reference timezone layers that on top (and, per
102+
* ADR-0053, a `date` field compares against these `YYYY-MM-DD` bounds directly).
103+
*
104+
* Returns `null` for the null/empty bucket, an unparseable key, or a key that
105+
* is shape-valid but out of range (e.g. `2026-13`, a `-W53` in a 52-week year,
106+
* `2026-02-30`). Callers drop the range and fall back to an unscoped (superset)
107+
* drill rather than emit a wrong bound.
108+
*/
109+
export function bucketKeyToCalendarRange(
110+
key: string,
111+
granularity: BucketGranularity,
112+
): { start: string; end: string } | null {
113+
if (typeof key !== 'string' || key.length === 0) return null;
114+
const fmt = (dt: Date) =>
115+
`${String(dt.getUTCFullYear()).padStart(4, '0')}-${String(dt.getUTCMonth() + 1).padStart(
116+
2,
117+
'0',
118+
)}-${String(dt.getUTCDate()).padStart(2, '0')}`;
119+
120+
switch (granularity) {
121+
case 'year': {
122+
const m = /^(\d{4})$/.exec(key);
123+
if (!m) return null;
124+
const y = Number(m[1]);
125+
return { start: fmt(new Date(Date.UTC(y, 0, 1))), end: fmt(new Date(Date.UTC(y + 1, 0, 1))) };
126+
}
127+
case 'quarter': {
128+
const m = /^(\d{4})-Q([1-4])$/.exec(key);
129+
if (!m) return null;
130+
const y = Number(m[1]);
131+
const startMonth = (Number(m[2]) - 1) * 3; // Q1→0, Q2→3, Q3→6, Q4→9
132+
return {
133+
start: fmt(new Date(Date.UTC(y, startMonth, 1))),
134+
end: fmt(new Date(Date.UTC(y, startMonth + 3, 1))), // Date.UTC rolls Q4 into next year
135+
};
136+
}
137+
case 'month': {
138+
const m = /^(\d{4})-(\d{2})$/.exec(key);
139+
if (!m) return null;
140+
const mo = Number(m[2]);
141+
if (mo < 1 || mo > 12) return null;
142+
const y = Number(m[1]);
143+
return {
144+
start: fmt(new Date(Date.UTC(y, mo - 1, 1))),
145+
end: fmt(new Date(Date.UTC(y, mo, 1))),
146+
};
147+
}
148+
case 'day': {
149+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
150+
if (!m) return null;
151+
const y = Number(m[1]);
152+
const mo = Number(m[2]);
153+
const d = Number(m[3]);
154+
const start = new Date(Date.UTC(y, mo - 1, d));
155+
if (fmt(start) !== key) return null; // reject an impossible day that rolled over
156+
return { start: key, end: fmt(new Date(Date.UTC(y, mo - 1, d + 1))) };
157+
}
158+
case 'week': {
159+
const m = /^(\d{4})-W(\d{2})$/.exec(key);
160+
if (!m) return null;
161+
const isoYear = Number(m[1]);
162+
const week = Number(m[2]);
163+
if (week < 1 || week > 53) return null;
164+
// Monday of ISO week 1 is the Monday on/before Jan 4; add (week-1) weeks.
165+
const jan4 = new Date(Date.UTC(isoYear, 0, 4));
166+
const jan4Dow = (jan4.getUTCDay() + 6) % 7; // Mon=0..Sun=6
167+
const start = new Date(jan4.getTime());
168+
start.setUTCDate(jan4.getUTCDate() - jan4Dow + (week - 1) * 7);
169+
if (isoWeekLabelUtc(start) !== key) return null; // reject -W53 overflow etc.
170+
const end = new Date(start.getTime());
171+
end.setUTCDate(start.getUTCDate() + 7);
172+
return { start: fmt(start), end: fmt(end) };
173+
}
174+
default:
175+
return null;
176+
}
177+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Round-trip parity: `bucketKeyToCalendarRange` (@objectstack/core) is the
4+
// INVERSE of `bucketDateValue` (this package). A drill on a date-bucketed
5+
// report cell turns the bucket KEY back into the half-open `[start, end)` span
6+
// of records that fall in it, so the two MUST agree on every boundary — any
7+
// drift silently mis-scopes the drilled record list. This test pins them
8+
// together (mirrors the driver-sql `buildDateBucketExpr` parity harness).
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { bucketKeyToCalendarRange, type BucketGranularity } from '@objectstack/core';
12+
import { bucketDateValue } from './in-memory-aggregation.js';
13+
14+
const DAY_MS = 86_400_000;
15+
const utcMidnight = (ymd: string) => Date.parse(`${ymd}T00:00:00.000Z`);
16+
17+
// Instants chosen to hit cross-year / cross-quarter / cross-month and the ISO
18+
// week boundary (2024-12-30 is ISO week 2025-W01). Same spirit as the driver
19+
// parity fixture.
20+
const INSTANTS = [
21+
'2024-01-15T10:00:00Z',
22+
'2024-04-01T00:00:00Z',
23+
'2024-06-30T23:59:59Z',
24+
'2024-07-01T00:00:00Z',
25+
'2024-12-30T12:00:00Z', // ISO 2025-W01
26+
'2025-01-01T00:00:00Z',
27+
'2025-05-19T09:00:00Z',
28+
'2026-02-15T00:00:00Z',
29+
'2026-12-31T23:00:00Z',
30+
];
31+
const GRANULARITIES: BucketGranularity[] = ['day', 'week', 'month', 'quarter', 'year'];
32+
33+
describe('bucketKeyToCalendarRange ↔ bucketDateValue round-trip (UTC)', () => {
34+
for (const iso of INSTANTS) {
35+
for (const g of GRANULARITIES) {
36+
it(`${iso} @ ${g}`, () => {
37+
const d = new Date(iso);
38+
const key = bucketDateValue(d, g); // no tz → UTC key
39+
const range = bucketKeyToCalendarRange(key, g);
40+
expect(range, `range for key ${key}`).not.toBeNull();
41+
const startMs = utcMidnight(range!.start);
42+
const endMs = utcMidnight(range!.end);
43+
44+
// the instant lies inside the half-open span
45+
expect(startMs).toBeLessThanOrEqual(d.getTime());
46+
expect(d.getTime()).toBeLessThan(endMs);
47+
48+
// start is the FIRST day of this bucket; end is the next bucket's start
49+
expect(bucketDateValue(new Date(startMs), g)).toBe(key);
50+
expect(bucketDateValue(new Date(endMs - 1), g)).toBe(key); // last instant still in bucket
51+
expect(bucketDateValue(new Date(endMs), g)).not.toBe(key); // boundary belongs to next bucket
52+
53+
// half-open width sanity for fixed-width granularities
54+
if (g === 'day') expect(endMs - startMs).toBe(DAY_MS);
55+
if (g === 'week') expect(endMs - startMs).toBe(7 * DAY_MS);
56+
});
57+
}
58+
}
59+
});
60+
61+
describe('bucketKeyToCalendarRange exact boundaries', () => {
62+
it('year / quarter / month / day / week', () => {
63+
expect(bucketKeyToCalendarRange('2026', 'year')).toEqual({ start: '2026-01-01', end: '2027-01-01' });
64+
expect(bucketKeyToCalendarRange('2026-Q1', 'quarter')).toEqual({ start: '2026-01-01', end: '2026-04-01' });
65+
expect(bucketKeyToCalendarRange('2026-Q2', 'quarter')).toEqual({ start: '2026-04-01', end: '2026-07-01' });
66+
expect(bucketKeyToCalendarRange('2026-Q4', 'quarter')).toEqual({ start: '2026-10-01', end: '2027-01-01' });
67+
expect(bucketKeyToCalendarRange('2026-12', 'month')).toEqual({ start: '2026-12-01', end: '2027-01-01' });
68+
expect(bucketKeyToCalendarRange('2024-02', 'month')).toEqual({ start: '2024-02-01', end: '2024-03-01' });
69+
expect(bucketKeyToCalendarRange('2024-02-29', 'day')).toEqual({ start: '2024-02-29', end: '2024-03-01' });
70+
expect(bucketKeyToCalendarRange('2025-W01', 'week')).toEqual({ start: '2024-12-30', end: '2025-01-06' });
71+
});
72+
});
73+
74+
describe('bucketKeyToCalendarRange rejects unbucketable / out-of-range keys → null (superset fallback)', () => {
75+
it('null and empty buckets', () => {
76+
expect(bucketKeyToCalendarRange('(null)', 'month')).toBeNull();
77+
expect(bucketKeyToCalendarRange('', 'day')).toBeNull();
78+
});
79+
it('shape mismatch vs. granularity', () => {
80+
expect(bucketKeyToCalendarRange('2026', 'month')).toBeNull();
81+
expect(bucketKeyToCalendarRange('2026-06', 'day')).toBeNull();
82+
expect(bucketKeyToCalendarRange('2026-Q2', 'month')).toBeNull();
83+
});
84+
it('out-of-range fields', () => {
85+
expect(bucketKeyToCalendarRange('2026-13', 'month')).toBeNull(); // month 13
86+
expect(bucketKeyToCalendarRange('2026-02-30', 'day')).toBeNull(); // impossible day
87+
expect(bucketKeyToCalendarRange('2025-W53', 'week')).toBeNull(); // 2025 has 52 ISO weeks
88+
});
89+
});

packages/runtime/src/sandbox/body-runner.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ export function hookBodyRunnerFactory(
7171
name: hook.name,
7272
object: typeof (hook as any).object === 'string' ? (hook as any).object : undefined,
7373
},
74-
timeoutMs: (body as any).timeoutMs ?? 250,
74+
// When the body declares no timeout, leave opts.timeoutMs unset so the
75+
// runner's own configurable hook default applies (QuickJSScriptRunner
76+
// defaults to 250ms). Hardcoding the fallback here would make that
77+
// constructor option dead for hooks.
78+
timeoutMs: (body as any).timeoutMs,
7579
});
7680
applyMutationsToInput(engineCtx, result);
7781
} catch (err: any) {
@@ -123,7 +127,9 @@ export function actionBodyRunnerFactory(
123127
});
124128
const result = await runner.run(body, sandboxCtx, {
125129
origin: { kind: 'action', name: action.name, object: action.object },
126-
timeoutMs: (body as any).timeoutMs ?? action.timeoutMs ?? 5000,
130+
// As with hooks above: no declared timeout → let the runner's
131+
// configurable action default (5000ms) apply.
132+
timeoutMs: (body as any).timeoutMs ?? action.timeoutMs,
127133
});
128134
return result.value;
129135
} catch (err: any) {

packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on
8686
engine.registerDriver(driver, true);
8787
await engine.init();
8888
for (const o of [EXPENSE_REPORT, EXPENSE_LINE]) engine.registry.registerObject(o as any);
89-
engine.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'expense' }));
89+
// Generous hook budget — the subject is the nested-write path on a real
90+
// driver, not the 250ms default (see nested-write.integration.test.ts).
91+
engine.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }), { ql: engine, appId: 'expense' }));
9092
bindHooksToEngine(engine, [ROLLUP_HOOK as any], { packageId: 'expense' });
9193
return engine;
9294
}

packages/runtime/src/sandbox/nested-write.integration.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,13 @@ describe('#1867 nested cross-object write from a hook (real engine + sandbox)',
9090
engine.registerDriver(driver, true);
9191
await engine.init();
9292
for (const o of [parent, child]) engine.registry.registerObject(o as any);
93+
// Generous hook budget: the subject here is nested-write correctness, not
94+
// the 250ms default. Each hook invocation compiles a fresh WASM module and
95+
// the nested parent hook compiles another inside the child's budget — on a
96+
// loaded CI machine that overhead alone blew 250ms ("hook
97+
// 'rollup_parent_total' exceeded timeout of 250ms").
9398
engine.setDefaultBodyRunner(
94-
hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'test' }),
99+
hookBodyRunnerFactory(new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }), { ql: engine, appId: 'test' }),
95100
);
96101
});
97102

packages/runtime/src/sandbox/quickjs-runner.test.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { describe, it, expect } from 'vitest';
44
import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js';
55
import type { ScriptContext, ScriptRunOptions } from './script-runner.js';
66

7-
const runner = new QuickJSScriptRunner();
7+
// Generous hook budget: these tests exercise sandbox *behaviour*, not the stock
8+
// 250ms hook budget. Every invocation compiles a fresh WASM module, and nested
9+
// hooks compile another one inside the parent's budget — on a loaded CI machine
10+
// that fixed cost alone can blow 250ms and flake (e.g. "hook 'lvl4' exceeded
11+
// timeout of 250ms"). Tests that ARE about the default budget use
12+
// `defaultRunner` below.
13+
const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 });
814
const hookOpts: ScriptRunOptions = { origin: { kind: 'hook', name: 't' } };
915
const actionOpts: ScriptRunOptions = { origin: { kind: 'action', name: 't' } };
1016

@@ -364,6 +370,10 @@ describe('QuickJSScriptRunner — nested sandbox re-entrancy (#1867)', () => {
364370
// the 250ms hook default and killed mid-flight.
365371
// ---------------------------------------------------------------------------
366372
describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#1867)', () => {
373+
// Stock engine defaults (250ms hooks) — these tests assert the default budget
374+
// itself, so they must NOT use the generous shared `runner` above.
375+
const defaultRunner = new QuickJSScriptRunner();
376+
367377
it('honors a hook body timeoutMs above the 250ms hook default', async () => {
368378
// Host call settles at ~600ms — comfortably past the old 250ms hook cap but
369379
// within the body's declared 5000ms budget. Must resolve, not time out.
@@ -375,7 +385,7 @@ describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#186
375385
},
376386
}),
377387
};
378-
const r = await runner.runScript(
388+
const r = await defaultRunner.runScript(
379389
{
380390
language: 'js',
381391
source: "return await ctx.api.object('x').update({});",
@@ -390,27 +400,26 @@ describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#186
390400

391401
it('still applies the 250ms hook default when the body declares no timeoutMs', async () => {
392402
const api = { object: () => ({ update: () => new Promise<never>(() => {}) }) };
393-
const start = Date.now();
394403
await expect(
395-
runner.runScript(
404+
defaultRunner.runScript(
396405
{ language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'] },
397406
ctx({ api }),
398407
hookOpts,
399408
),
400-
).rejects.toThrow(/timeout/i);
401-
// Fired near the 250ms default, not some larger value.
402-
expect(Date.now() - start).toBeLessThan(2000);
409+
// The error message embeds the effective budget — asserting on it proves
410+
// the 250ms default applied without a flaky wall-clock measurement.
411+
).rejects.toThrow(/timeout of 250ms/);
403412
}, 10000);
404413

405414
it('lets a hook body LOWER its timeout below the default', async () => {
406415
const api = { object: () => ({ update: () => new Promise<never>(() => {}) }) };
407416
await expect(
408-
runner.runScript(
417+
defaultRunner.runScript(
409418
{ language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'], timeoutMs: 50 },
410419
ctx({ api }),
411420
hookOpts,
412421
),
413-
).rejects.toThrow(/timeout/i);
422+
).rejects.toThrow(/timeout of 50ms/);
414423
}, 10000);
415424
});
416425

0 commit comments

Comments
 (0)