Skip to content

Commit 658c596

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15734-report-chart-axis-own-selection
2 parents c0b21ac + e52bb6a commit 658c596

37 files changed

Lines changed: 2145 additions & 327 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `edges[].id` keeps its name, its type and its describe, and every flow whose edge ids are unique parses byte-identically. The only newly refused shape is two edges sharing one id — a collision, not a spelling — and its remedy is to renumber one of the two, which is authoring intent no `objectstack migrate meta` rewrite can choose for the author. The Zone-2 census over this repo (776 `edges[]` arrays, 1,098 edges under `packages/**` and `examples/**`, with a lit control) found zero instances, so there is no in-repo file to name. -->
8+
9+
**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]`
10+
carries two edges with the same `id` is now **refused at parse time** — by
11+
`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a
12+
flow through the schema (`objectstack validate`, the runtime publish gate, a
13+
stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under
14+
the repo's launch-window convention for breaking changes. Maintainer ruling
15+
2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option
16+
A — an `error`, not a `warning`; no opt-out, no transition window.
17+
18+
Every reader of an edge id assumes the ids in a flow are unique — a designer,
19+
a BPMN export, a flow diff, any traversal that dedupes by id — and nothing
20+
enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped
21+
through two releases of green CI in a downstream app and was inert only
22+
because the engine keys out-edges by `source`, never by `id`: the collision is
23+
invisible until something keys on ids, and then silently wrong rather than
24+
loudly broken. The id space is hand-authored, so the next author picking a
25+
"free" id from the sequence had no way to know it was taken.
26+
27+
**What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine`
28+
on the flow's `edges[]`. Each later occurrence of an already-declared id raises
29+
one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming
30+
both positions, so the formatted error points at the edge to renumber:
31+
32+
```text
33+
✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: …
34+
```
35+
36+
**What does NOT change:** `edges[].id` keeps its name, type and describe; the
37+
node vocabulary, the edge `type` enum and every other refusal are untouched;
38+
a flow with unique edge ids (or no edges) parses exactly as before. Node ids
39+
are not covered by this change.
40+
41+
The shape that is refused, and what the author does about it — a two-edge
42+
excerpt, the later edge renumbered:
43+
44+
```ts
45+
// before — parsed on green, both edges keyed 'e20'
46+
edges: [
47+
{ id: 'e20', source: 'qualify', target: 'convert' },
48+
{ id: 'e20', source: 'convert', target: 'end' },
49+
]
50+
51+
// after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one:
52+
edges: [
53+
{ id: 'e20', source: 'qualify', target: 'convert' },
54+
{ id: 'e21', source: 'convert', target: 'end' },
55+
]
56+
```
57+
58+
**Remedy.** Renumber the later edge to an id no other edge in that flow
59+
carries; nothing else in the flow needs to move. The census over this
60+
repository found no flow to migrate, so this is a release note, not a
61+
migration: no shipped example, fixture or seed in `packages/**` or
62+
`examples/**` declares a duplicate edge id, and the pinned objectui tree
63+
carries none in its authored flows. The one known downstream instance was
64+
renumbered before this change (hotcrm PR #1571).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
No authoring rule throws on a non-record entry of any stack collection.
6+
7+
A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape.
8+
9+
Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before.
10+
11+
The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path.
6+
7+
Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle.
8+
9+
A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now).
10+
11+
It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine.

packages/cli/src/commands/serve-driver-banner.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('describeRegisteredDriver', () => {
4949

5050
expect(describeRegisteredDriver(fakeKernel({
5151
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: './data.db' } } },
52-
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db' });
52+
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db', sqliteFile: './data.db' });
5353

5454
expect(describeRegisteredDriver(fakeKernel({
5555
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
@@ -133,10 +133,42 @@ describe('describeRegisteredDriver', () => {
133133
expect(describeRegisteredDriver(kernel)).toEqual({
134134
label: 'SqliteWasmDriver',
135135
url: './app.wasm.db',
136+
sqliteFile: './app.wasm.db',
136137
});
137138
});
138139

139140
it('returns null when no known driver is registered', () => {
140141
expect(describeRegisteredDriver(fakeKernel({}))).toBeNull();
141142
});
143+
144+
// `sqliteFile` is the SAME probe read a second time asking a different
145+
// question: not "what do I print" but "which file on this filesystem", so
146+
// the boot can `stat` it and notice later that the file it opened is no
147+
// longer the file at that path. It is the plumbing between the driver and
148+
// that watch, and nothing else pins it — a probe that stopped answering it
149+
// would leave the watch silently un-armed on every boot, which is
150+
// byte-identical to a deployment where nothing ever went wrong.
151+
describe('sqliteFile — the on-disk file the boot is serving', () => {
152+
it('answers the sqlite connection filename', () => {
153+
expect(describeRegisteredDriver(fakeKernel({
154+
'driver.com.objectstack.driver.sql': {
155+
config: { client: 'better-sqlite3', connection: { filename: '/app/.objectstack/data/objectstack.db' } },
156+
},
157+
}))?.sqliteFile).toBe('/app/.objectstack/data/objectstack.db');
158+
});
159+
160+
it('is absent for every driver that serves no file on disk', () => {
161+
expect(describeRegisteredDriver(fakeKernel({
162+
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: ':memory:' } } },
163+
}))?.sqliteFile).toBeUndefined();
164+
165+
expect(describeRegisteredDriver(fakeKernel({
166+
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
167+
}))?.sqliteFile).toBeUndefined();
168+
169+
expect(describeRegisteredDriver(fakeKernel({
170+
'driver.memory': { constructor: { name: 'InMemoryDriver' }, config: {} },
171+
}))?.sqliteFile).toBeUndefined();
172+
});
173+
});
142174
});

packages/cli/src/commands/serve.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ import {
7474
stackDeclaresMetadata,
7575
bundleDeclaresTranslations,
7676
} from '../utils/stack-collections.js';
77-
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
77+
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
78+
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
7879
// The posture prose `os serve` and `os doctor` BOTH print, declared once
7980
// (#12492) — and, since #12579, the multi-org runtime SPELLING those two
8081
// commands put in front of the same operator, declared once with it.
@@ -2099,6 +2100,11 @@ export default class Serve extends Command {
20992100
// is idempotent, so the second pass over an already-clean URL is a no-op.
21002101
let resolvedDriverLabel: string | undefined;
21012102
let resolvedDatabaseUrl: string | undefined;
2103+
// The on-disk SQLite file this boot serves, when it serves one. Kept apart
2104+
// from `resolvedDatabaseUrl` on purpose: that value is a string for a
2105+
// human and may be redacted or labelled, while this one is handed to
2106+
// `stat` — see `describeDriverSqliteFile`.
2107+
let servedSqliteFilePath: string | undefined;
21022108

21032109
// Resolve the kernel logger level up front. It decides more than the
21042110
// logger's own threshold: it decides whether the boot-quiet window below
@@ -2718,6 +2724,7 @@ export default class Serve extends Command {
27182724
trackPlugin(resolution.trackName);
27192725
resolvedDriverLabel = resolution.label;
27202726
resolvedDatabaseUrl = resolution.displayUrl;
2727+
servedSqliteFilePath = resolution.sqliteFilePath;
27212728

27222729
// ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry`
27232730
// datasource — a sibling SQLite file the engine routes every
@@ -4588,6 +4595,7 @@ export default class Serve extends Command {
45884595
if (probe) {
45894596
resolvedDriverLabel = probe.label;
45904597
resolvedDatabaseUrl = probe.url;
4598+
servedSqliteFilePath = probe.sqliteFile;
45914599
}
45924600
} catch {
45934601
// best-effort only
@@ -4756,6 +4764,35 @@ export default class Serve extends Command {
47564764
// old order lost, and the reason the repair is not reader-side polling.
47574765
publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner));
47584766

4767+
// ── Watch the served database file's identity ──────────────────
4768+
// Deleting the data directory under a running server (`rm -rf
4769+
// .objectstack/data`, what `demo:reset` does) unlinks the inode without
4770+
// touching this process: SQLite keeps serving the now-invisible file,
4771+
// health keeps answering 200, and a later boot creates a brand-new
4772+
// database at the same path. From then on every filesystem inspection of
4773+
// that path describes a DIFFERENT file than this server answers from —
4774+
// which is how "the row I edited had no effect" and "the user who just
4775+
// authenticated is not in the database" become simultaneously true
4776+
// readings of a healthy deployment.
4777+
//
4778+
// Started only once the boot is otherwise complete, so a failure here
4779+
// can never be mistaken for a boot problem, and only when an on-disk
4780+
// SQLite file is actually being served. It refuses nothing and reports
4781+
// once — see `served-database-file.ts` for why both.
4782+
if (servedSqliteFilePath) {
4783+
const openedDatabaseFile = captureServedDatabaseFile(servedSqliteFilePath);
4784+
if (openedDatabaseFile) {
4785+
watchServedDatabaseFile({
4786+
opened: openedDatabaseFile,
4787+
// `error`, and to stderr: by the degradation-log-level rule this is
4788+
// the durability/consistency class — the system keeps looking
4789+
// normal while what it claims is persisted is not at the path it
4790+
// names.
4791+
onDiverged: (message) => { console.error(chalk.red(message)); },
4792+
});
4793+
}
4794+
}
4795+
47594796
// Kernel already registers SIGINT/SIGTERM handlers during bootstrap.
47604797
// No duplicate handler needed here — just keep the process alive.
47614798

@@ -6121,7 +6158,9 @@ function emitMultiNodeCapTelemetry(
61216158
* arrives in, so a DSN-declared datasource no longer falls through to
61226159
* `(unknown)`, and no shape prints credentials.
61236160
*/
6124-
export function describeRegisteredDriver(kernel: any): { label: string; url: string } | null {
6161+
export function describeRegisteredDriver(
6162+
kernel: any,
6163+
): { label: string; url: string; sqliteFile?: string } | null {
61256164
const candidates = [
61266165
'driver.com.objectstack.driver.sql',
61276166
'driver.com.objectstack.driver.mongodb',
@@ -6153,7 +6192,16 @@ export function describeRegisteredDriver(kernel: any): { label: string; url: str
61536192

61546193
// A memory driver has no address to show — say so, rather than
61556194
// `(unknown)`, which reads as "we looked for one and failed".
6156-
return { label, url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)') };
6195+
//
6196+
// `sqliteFile` is the same config read a second time asking a DIFFERENT
6197+
// question: not "what do I print" but "which file on this filesystem", so
6198+
// a caller can `stat` it. Absent for every driver that serves no on-disk
6199+
// SQLite file, which is the majority of them.
6200+
return {
6201+
label,
6202+
url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)'),
6203+
sqliteFile: describeDriverSqliteFile(cfg),
6204+
};
61576205
}
61586206
return null;
61596207
}

packages/cli/src/utils/connection-display.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// shape leaks the credentials the DSN carries.
1313

1414
import { describe, it, expect } from 'vitest';
15-
import { redactConnectionUrl, describeDriverConnection } from './connection-display.js';
15+
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from './connection-display.js';
1616

1717
describe('redactConnectionUrl', () => {
1818
it('drops the userinfo segment from a DSN', () => {
@@ -124,3 +124,36 @@ describe('describeDriverConnection', () => {
124124
expect(describeDriverConnection({ client: 'pg', connection: () => ({ host: 'h' }) })).toBeUndefined();
125125
});
126126
});
127+
128+
describe('describeDriverSqliteFile', () => {
129+
// A SECOND question over the same config shapes: not "what do I print" but
130+
// "which file on this filesystem", so the answer can be handed to `stat`.
131+
it('reads the knex sqlite connection filename', () => {
132+
expect(describeDriverSqliteFile({
133+
client: 'better-sqlite3',
134+
connection: { filename: '/app/.objectstack/data/objectstack.db' },
135+
})).toBe('/app/.objectstack/data/objectstack.db');
136+
});
137+
138+
it('reads a filename kept at the top level', () => {
139+
expect(describeDriverSqliteFile({ filename: '/app/data.db' })).toBe('/app/data.db');
140+
});
141+
142+
it('refuses the in-memory database, which is not a file on disk', () => {
143+
expect(describeDriverSqliteFile({ connection: { filename: ':memory:' } })).toBeUndefined();
144+
});
145+
146+
it('refuses the empty name, SQLite\'s private temporary database', () => {
147+
expect(describeDriverSqliteFile({ connection: { filename: ' ' } })).toBeUndefined();
148+
});
149+
150+
it('answers undefined for a driver that serves no file', () => {
151+
expect(describeDriverSqliteFile({ url: 'mongodb://db.example.com/app' })).toBeUndefined();
152+
expect(describeDriverSqliteFile({
153+
client: 'pg',
154+
connection: { host: 'db.example.com', port: 5432, database: 'app' },
155+
})).toBeUndefined();
156+
expect(describeDriverSqliteFile(undefined)).toBeUndefined();
157+
expect(describeDriverSqliteFile('postgres://db.example.com/app')).toBeUndefined();
158+
});
159+
});

packages/cli/src/utils/connection-display.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,30 @@ export function describeDriverConnection(config: unknown): string | undefined {
9898
return readTarget(conn && typeof conn === 'object' ? conn as Record<string, unknown> : undefined)
9999
?? readTarget(cfg);
100100
}
101+
102+
/**
103+
* The on-disk SQLite file a driver config names, or `undefined` when it names
104+
* none.
105+
*
106+
* A SECOND question over the same shapes {@link describeDriverConnection}
107+
* already knows — and deliberately not a re-read of that function's output.
108+
* That one renders a string for a human and is free to redact, abbreviate or
109+
* label (`(in-memory)`, `(unknown)`); this one answers "which file on this
110+
* filesystem", which is an identity, and an identity parsed back out of a
111+
* display string is one redaction rule away from being wrong.
112+
*
113+
* `:memory:` and the empty name (SQLite's private temporary database) are not
114+
* files on disk and are refused here, so a caller can hand the result straight
115+
* to `stat` without re-deciding.
116+
*/
117+
export function describeDriverSqliteFile(config: unknown): string | undefined {
118+
if (!config || typeof config !== 'object') return undefined;
119+
const cfg = config as Record<string, unknown>;
120+
const conn = cfg.connection;
121+
const bag = conn && typeof conn === 'object' ? conn as Record<string, unknown> : cfg;
122+
const filename = bag.filename ?? cfg.filename;
123+
if (typeof filename !== 'string') return undefined;
124+
const trimmed = filename.trim();
125+
if (!trimmed || trimmed === ':memory:') return undefined;
126+
return trimmed;
127+
}

0 commit comments

Comments
 (0)