Skip to content

Commit 184d3fc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20802-relation-filter-lowering
2 parents 870dcc8 + 40d6c5f commit 184d3fc

28 files changed

Lines changed: 833 additions & 167 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/objectql': minor
4+
---
5+
6+
fix(core,objectql)!: a `datetime` value names a year from 1000 to 9999 at both engine doors, so one before year 1000 is refused as a written value and as a filter comparand; a `date` keeps 0001 to 9999
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a VALUE at the two engine doors: a datetime whose UTC year falls in 0001..0999, refused VALIDATION_FAILED (field code invalid_date) as a written value and INVALID_FILTER / 400 as a comparand. No authorable key, spelling, export or stored metadata shape moves: FieldSchema's datetime type, every object definition and every query shape parse as before, and @objectstack/core and @objectstack/objectql export nothing new and nothing less (isOutsideTemporalYearRange keeps its signature). A datetime already stored before year 1000 is record data, not metadata: nothing converts it, no conversion is registered, and it is never shifted; the body says how an operator finds such rows and that a write carrying one is refused, which is guidance about data, not a rewrite of any consumer's code or metadata. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers a value range and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING**: this narrows what the engine accepts as a `datetime`. The one range function both doors ask, `isOutsideTemporalYearRange` in `@objectstack/core`, now takes a lower bound per kind: a `datetime` starts at year 1000 (its UTC year), a `date` stays at 0001, and both still end at 9999. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13+
14+
**What is refused now.** A `datetime` whose UTC year falls in 0001..0999, which both doors accepted before:
15+
16+
- **The write door** (the record validator), in every spelling it took: an ISO instant string, a bare `YYYY-MM-DD` (midnight UTC), a zone-naive `YYYY-MM-DD HH:MM`, and a `Date`. It answers `VALIDATION_FAILED` with the field's `invalid_date` code, on `engine.insert`, `engine.update` (one row or many) and the dry-run `engine.validate`, so on `POST /api/v1/data/:object`, `PATCH /api/v1/data/:object/:id` and each row of `POST /api/v1/data/:object/import` too. A number was already refused there, because a `datetime` is stored as text.
17+
- **The comparand door** (the temporal-comparand door), in every spelling: an ISO string, a `Date` and epoch milliseconds as a number. It answers `INVALID_FILTER` / 400 at `where`, at a per-aggregation `filter` and at `having`, on the engine and on `POST /api/v1/data/:object/query`. The analytics native-SQL strategy asks the same predicate, so it declines such a comparand and the query reaches this door.
18+
- The year is the instant's UTC year: `1000-01-01T00:00:00+08:00` is year 999 in UTC and is refused, and `0999-12-31T23:00:00-02:00` is year 1000 in UTC and is read.
19+
20+
**What an author sees.** The comparand refusal names the field, the value, its position and the range: "an instant whose UTC year falls outside the years 1000 to 9999, the years a datetime value may name". It then says why the floor sits at 1000, instead of the misorder words that a year past 9999 still gets: MySQL documents its `DATETIME` from year 1000 only, and reads one stored in the years 0001 to 0099 back a century late. A comparand in those years that was already refused for its spelling or its day (a non-ISO spelling, a day that does not exist) is now named by its year first, as one past 9999 already was. The write refusal is the existing `invalid_date` sentence for a `datetime` field.
21+
22+
**Why.** MySQL documents `DATETIME` from year 1000, and it reads a stored `DATETIME` in 0001..0099 back a century late through its client's instant parser (`0009-03-04 10:00` comes back as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps. The range is the contract on every backend, so SQLite, PostgreSQL and the in-memory driver, which held these years, refuse them too. No writer or query of a `datetime` before year 1000 was found.
23+
24+
**A `datetime` already stored before year 1000.** Nothing rewrites it, and nothing shifts it into the range. It reads back as before, and on MySQL a year in 0001..0099 still presents a century late. To find such rows, filter the field with `$lt` on `1000-01-01T00:00:00.000Z`, the floor's first instant, which both doors admit; the comparison runs on the stored value, so it finds them on MySQL as well. An update that leaves the field out is accepted. A write that carries a year below 1000 is refused, so the field can be written again with an instant from year 1000 on, or with `null`, and the author decides which.
25+
26+
**Unchanged.** A `date` keeps 0001..9999, padded to four digits as before. A `time` column still reads the time of day of an instant in 0001..0999, and a `time` comparand refused for another reason keeps that reason's words. Every year from 1000 to 9999 on a `datetime`, and every refusal outside 0001..9999 on either kind, answers as before, apart from the range the words name.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/observability': patch
3+
---
4+
5+
A provenance comment in `@objectstack/observability` was re-anchored
6+
7+
The `SEMCONV` comment beside the retired `http_request_errors_total` entry
8+
cited a tracker number that no longer resolves on GitHub. It now cites the
9+
commit in this repository's history that moved `http_request_duration_ms` to
10+
the transport seam. Comment only: no metric name, label, export, type or
11+
runtime behaviour changes.

‎.changeset/20681-dev-no-watch.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev --no-watch` turns watch mode off, and `os dev` fails when its PACKAGE argument selects no workspace package
6+
7+
Clause-②: no
8+
9+
`os dev` watches `objectstack.config.ts` and `src/` by default, and it already
10+
had a branch for running without that watcher. No argument reached it:
11+
12+
- `os dev --no-watch` was refused as a nonexistent flag (exit 2).
13+
- `os dev --watch=false` is not a form the CLI reads for a boolean flag. It
14+
parsed as `--watch` plus the PACKAGE argument `false`, so the command switched
15+
to monorepo mode, ran `pnpm --filter false dev`, printed "No projects found",
16+
and exited 0 with nothing started.
17+
18+
What changes:
19+
20+
- `os dev --no-watch` boots the environment with the watch-and-rebuild loop off.
21+
Plain `os dev` keeps it on, as before.
22+
- In monorepo mode, a PACKAGE argument that selects no workspace package now
23+
exits 1, and the failure line names the value. The command passes
24+
`--fail-if-no-match` to pnpm, so pnpm decides whether the filter matched, for
25+
every filter form it accepts. `os dev --watch=false` is one such run: it now
26+
fails instead of reporting success. Write `--no-watch` instead.
27+
- `os dev --no-watch` in monorepo mode (a PACKAGE argument, or a workspace root
28+
with no `objectstack.config.ts`) exits 1 and names the flag. In that mode each
29+
package's own `dev` script decides whether it watches, so the CLI cannot turn
30+
watching off. Run it in the project directory, or pass `--artifact`.
31+
32+
Monorepo mode now needs pnpm 8.13.1 or later, the release that added
33+
`--fail-if-no-match`.

‎packages/cli/src/commands/dev.ts‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ export function forwardSeedSettledToParent(msg: unknown): boolean {
175175
return true;
176176
}
177177

178+
/**
179+
* Whether this `os dev` boot runs the watch-recompile loop (#20681).
180+
*
181+
* Off when the operator passed `--no-watch`, when `--artifact` was given (there
182+
* is no source to watch), or when the cwd has no `objectstack.config.ts`. The
183+
* one decision both the loop and the stale-artifact remedy line read, exported
184+
* so `dev-no-watch.pin.test.ts` can drive it with what oclif actually parsed.
185+
*/
186+
export function devWatchActive(opts: { watch: boolean; artifact?: string; configExists: boolean }): boolean {
187+
return opts.watch && !opts.artifact && opts.configExists;
188+
}
189+
178190
export default class Dev extends Command {
179191
static override description =
180192
'Start development mode — watch sources, rebuild the artifact, and restart the server on change';
@@ -184,7 +196,18 @@ export default class Dev extends Command {
184196
};
185197

186198
static override flags = {
187-
watch: Flags.boolean({ char: 'w', description: 'Enable watch mode (default)', default: true }),
199+
// `allowNo` is what makes the off branch below (`devWatchActive`, the
200+
// watch-recompile loop) reachable at all: without it `--no-watch` is a
201+
// "Nonexistent flag", and `--watch=false` is not a boolean spelling oclif
202+
// reads — it parses as `--watch` plus the PACKAGE positional `false`
203+
// (#20681). Same declaration as the sibling `compile` / `restart` /
204+
// `seed-admin` switches.
205+
watch: Flags.boolean({
206+
char: 'w',
207+
description: 'Watch objectstack.config.ts and src/, rebuilding on change (default: on). Disable with --no-watch.',
208+
default: true,
209+
allowNo: true,
210+
}),
188211
ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/' }),
189212
verbose: Flags.boolean({ char: 'v', description: 'Verbose output (shortcut for --log-level debug)' }),
190213
'log-level': Flags.string({
@@ -380,7 +403,7 @@ export default class Dev extends Command {
380403
// and dev still printed `Plugins: 38 loaded` until a manual build.
381404
// Warn loudly and name the remedy; never gate the boot (per triage:
382405
// remove the silence, not the start).
383-
const watchActive = flags.watch !== false && !flags.artifact && configExists;
406+
const watchActive = devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists });
384407
if (!needsCompile && !flags.artifact && configExists) {
385408
const stale = assessArtifactStaleness({
386409
artifactPath,
@@ -697,7 +720,7 @@ export default class Dev extends Command {
697720
// running server keeps the old build.
698721
//
699722
// Skipped when:
700-
// - --watch=false (user opted out)
723+
// - --no-watch (user opted out)
701724
// - --artifact was passed (no source to watch)
702725
// - the environment has no objectstack.config.ts
703726
if (watchActive) {
@@ -728,7 +751,25 @@ export default class Dev extends Command {
728751
process.exit(1);
729752
}
730753

731-
const filter = packageName === 'all' ? '' : `--filter ${packageName}`;
754+
// `--no-watch` turns off THIS process's watch-recompile loop, which only
755+
// exists when `os dev` boots one environment. Here each package's own
756+
// `dev` script runs and decides its own watching, so the flag cannot be
757+
// honoured — refuse it rather than print "Watch: enabled" under an
758+
// operator who just asked for the opposite (#20681).
759+
if (!flags.watch) {
760+
printError('--no-watch has no effect in monorepo orchestration mode: each package\'s own `dev` script decides whether it watches.');
761+
console.error(chalk.yellow(' Run it in a directory with objectstack.config.ts, or pass --artifact <path|url>, to boot one environment with watch off.'));
762+
process.exit(1);
763+
}
764+
765+
// `--fail-if-no-match`: a PACKAGE that selects no workspace project fails
766+
// non-zero instead of exiting 0 having started nothing (#20681). pnpm
767+
// owns the filter grammar (names, globs, `./dir`, `...pkg`), so pnpm
768+
// answers whether it matched — ⛔ no second reading of the workspace
769+
// here. The measured trap it closes: `os dev --watch=false` parses as
770+
// `--watch` plus the PACKAGE `false`, and used to print "No projects
771+
// found" and exit 0.
772+
const filter = packageName === 'all' ? '' : `--filter ${packageName} --fail-if-no-match`;
732773
printKV('Package', packageName === 'all' ? 'All packages' : packageName, '📦');
733774
printKV('Watch', 'enabled', '🔄');
734775

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#20681) — `os dev`'s watch opt-out is reachable, and the argv that used
5+
* to miss it fails instead of exiting 0.
6+
*
7+
* Measured on `main` before this change, through the source entry
8+
* (`tsx bin/run-dev.js`), in a directory holding an `objectstack.config.ts`:
9+
*
10+
* ```
11+
* $ os dev --no-watch
12+
* Error: Nonexistent flag: --no-watch # exit 2
13+
*
14+
* $ os dev --watch=false
15+
* 📦 Package: false
16+
* 🔄 Watch: enabled
17+
* $ pnpm --filter false dev
18+
* No projects found in "…" # exit 0, nothing started
19+
* ```
20+
*
21+
* The first because `watch` declared no `allowNo`. The second because a boolean
22+
* flag has no `=value` spelling in oclif — `--watch=false` parses as `--watch`
23+
* plus the PACKAGE positional `false` — and pnpm exits 0 on a filter that
24+
* selects nothing.
25+
*
26+
* Two halves, two instruments:
27+
*
28+
* • the opt-out is a PARSE-AND-DECIDE fact: oclif's own parser over the
29+
* command's real declarations, fed into the one decision the watch loop
30+
* reads (`devWatchActive`). Observing the watcher's absence on a real boot
31+
* costs a full kernel boot per case and proves nothing further;
32+
* • the loud failures are SHELL facts: an exit status only exists once a real
33+
* child has exited (the reason `invocation-loudness.e2e.test.ts` spawns), so
34+
* those cases spawn the source entry — which is what puts this file in the
35+
* `integration` tier (`vitest-tiers.ts`).
36+
*
37+
* ⛔ Refusal prose is not pinned. The spawned cases assert the exit status, the
38+
* value the failure names, and that nothing was started.
39+
*/
40+
41+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
42+
import { execFile } from 'node:child_process';
43+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
44+
import { tmpdir } from 'node:os';
45+
import { join, resolve } from 'node:path';
46+
import { fileURLToPath } from 'node:url';
47+
import { Parser } from '@oclif/core';
48+
import Dev, { devWatchActive } from '../src/commands/dev.js';
49+
import { childEnv } from './helpers/serve-process.js';
50+
51+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
52+
const CLI = resolve(HERE, '../bin/run-dev.js');
53+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
54+
55+
/** oclif + tsx cold start, with every command module loaded; ~2-10 s when healthy. */
56+
const RUN_TIMEOUT_MS = 180_000;
57+
58+
/** What the fixture workspace's own `dev` script prints — proof it ran. */
59+
const DEV_SCRIPT_MARK = 'DEV-SCRIPT-RAN';
60+
61+
const parseDev = (argv: string[]) =>
62+
Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: true });
63+
64+
interface Run {
65+
code: number;
66+
out: string;
67+
}
68+
69+
function runDev(args: string[], cwd: string): Promise<Run> {
70+
return new Promise((resolvePromise) => {
71+
execFile(
72+
TSX,
73+
[CLI, 'dev', ...args],
74+
{ cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
75+
(err, stdout, stderr) => {
76+
resolvePromise({
77+
// `err.code` is the real exit status; a signalled child has none and
78+
// is reported as a failure, never as 0.
79+
code: err
80+
? typeof (err as { code?: unknown }).code === 'number'
81+
? (err as unknown as { code: number }).code
82+
: 1
83+
: 0,
84+
out: `${String(stdout)}\n${String(stderr)}`,
85+
});
86+
},
87+
);
88+
});
89+
}
90+
91+
/** The `✗` line `printError` writes — the one sentence the failure is judged by. */
92+
const failureLine = (out: string): string =>
93+
out.split('\n').find((line) => line.trimStart().startsWith('✗')) ?? '';
94+
95+
describe('the watch opt-out is reachable', () => {
96+
it('`os dev --no-watch` parses, and the boot it describes runs no watcher', async () => {
97+
const { flags } = await parseDev(['--no-watch']);
98+
expect(flags.watch).toBe(false);
99+
expect(devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists: true })).toBe(false);
100+
});
101+
102+
it('bare `os dev` keeps watch on', async () => {
103+
const { flags } = await parseDev([]);
104+
expect(flags.watch).toBe(true);
105+
expect(devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists: true })).toBe(true);
106+
});
107+
108+
it('`--watch=false` is still NOT an off spelling — it parses as the PACKAGE `false`', async () => {
109+
// Why the orchestration half below exists: oclif gives a boolean flag no
110+
// `=value` form, so this argv reaches the monorepo branch with a package
111+
// name, not the single-environment boot with watch off.
112+
const { flags, args } = await parseDev(['--watch=false']);
113+
expect(flags.watch).toBe(true);
114+
expect(args.package).toBe('false');
115+
});
116+
});
117+
118+
let projectDir: string;
119+
let workspaceDir: string;
120+
let watchEqualsFalse: Run;
121+
let noWatchAtWorkspace: Run;
122+
let bareAtWorkspace: Run;
123+
124+
beforeAll(async () => {
125+
// A project directory: a config file, and no pnpm workspace above it.
126+
projectDir = mkdtempSync(join(tmpdir(), 'os-dev-no-watch-project-'));
127+
writeFileSync(join(projectDir, 'objectstack.config.ts'), 'export default {};\n');
128+
129+
// A workspace root whose own `dev` script announces that it ran — so the
130+
// `--no-watch` refusal below is measured against a fixture that DOES start
131+
// something when asked (`bareAtWorkspace` is that control).
132+
workspaceDir = mkdtempSync(join(tmpdir(), 'os-dev-no-watch-workspace-'));
133+
writeFileSync(join(workspaceDir, 'pnpm-workspace.yaml'), 'packages: []\n');
134+
writeFileSync(
135+
join(workspaceDir, 'package.json'),
136+
`${JSON.stringify({
137+
name: 'os-dev-no-watch-fixture',
138+
private: true,
139+
scripts: { dev: `node -e "console.log('${DEV_SCRIPT_MARK}')"` },
140+
})}\n`,
141+
);
142+
143+
// Sequential on purpose: three cold tsx starts, each loading every command
144+
// module, in a container several agents share.
145+
watchEqualsFalse = await runDev(['--watch=false'], projectDir);
146+
noWatchAtWorkspace = await runDev(['--no-watch'], workspaceDir);
147+
bareAtWorkspace = await runDev([], workspaceDir);
148+
}, RUN_TIMEOUT_MS);
149+
150+
afterAll(() => {
151+
rmSync(projectDir, { recursive: true, force: true });
152+
rmSync(workspaceDir, { recursive: true, force: true });
153+
});
154+
155+
describe('a PACKAGE that selects no workspace project fails', () => {
156+
it('`os dev --watch=false` exits 1 instead of 0', () => {
157+
expect(watchEqualsFalse.code).toBe(1);
158+
});
159+
160+
it('and the failure names the value that matched nothing', () => {
161+
expect(failureLine(watchEqualsFalse.out)).toMatch(/(^|\s)false(\s|$)/);
162+
});
163+
});
164+
165+
describe('`--no-watch` where os dev boots no environment is refused, not ignored', () => {
166+
it('the fixture starts its own dev script when asked — the control', () => {
167+
expect(bareAtWorkspace.code).toBe(0);
168+
expect(bareAtWorkspace.out).toContain(DEV_SCRIPT_MARK);
169+
});
170+
171+
it('exits 1, naming the flag', () => {
172+
expect(noWatchAtWorkspace.code).toBe(1);
173+
expect(failureLine(noWatchAtWorkspace.out)).toContain('--no-watch');
174+
});
175+
176+
it('and starts nothing', () => {
177+
expect(noWatchAtWorkspace.out).not.toContain(DEV_SCRIPT_MARK);
178+
});
179+
});

‎packages/core/src/utils/datetime.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export interface WallClockParts extends CalendarParts {
4646
*
4747
* Not `Date.UTC(year, …)` and not `new Date(year, …)`: both read a year from 0
4848
* to 99 as 1900 + year (ECMA-262 `MakeFullYear`), so `0050-01-01` is built as
49-
* 1950-01-01 — while 0001..9999 is the supported range (`temporalStorageForm`).
49+
* 1950-01-01 — while 0001..9999 is a `date`'s supported range ([#20280] a
50+
* `datetime`'s is 1000..9999; `isOutsideTemporalYearRange`).
5051
* `setUTCFullYear` takes the year as written.
5152
*
5253
* Every component rolls over past its end exactly as `Date.UTC` rolls it, and

0 commit comments

Comments
 (0)