Skip to content

Commit 99fa874

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-oyagiq
2 parents 0f7f928 + df8aac4 commit 99fa874

26 files changed

Lines changed: 1007 additions & 229 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-form': patch
4+
'@object-ui/plugin-grid': patch
5+
---
6+
7+
feat(ui): an edit form's `state_machine` select offers only the moves the server accepts
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
A record whose `status` is governed by an error-severity `state_machine` validation used to offer every option in the edit form, and the server refused the invalid ones only after submit, without naming the allowed next states. The form now offers the persisted value plus the states the machine declares as its next moves.
13+
14+
- **`@object-ui/core`:** new export `stateMachineNextValues(objectSchema, fieldName, currentValue)`. It returns the set of values a field may move to from its persisted value, or `null` for "don't constrain". It narrows only where the server refuses: rules with `severity: 'error'` (the default), `active` not `false`, and `events` covering `update` (the default covers it). A from-state the transition map does not declare stays unconstrained, and several applicable rules on one field intersect. It is a read of the declared transition map for choice controls, not a client-side rule engine.
15+
- **`@object-ui/plugin-form`:** `gateFormFields` takes an optional `previousValues` (the persisted record) and narrows each single-value select governed by such a machine, on both the entry's own `options` and its `field.options`. Every layout passes its persisted record: the simple, modal, drawer, tabbed, split and wizard forms. The from-state is the stored row, never the live form value. A create form is unconstrained, and the current value always stays offered. Options outside the reachable set are left out rather than drawn disabled.
16+
- **`@object-ui/plugin-grid`:** `stateMachineNextValues` is now re-exported from `@object-ui/core`. The grid's inline editor therefore stops narrowing on a `warning` / `info` machine, an inactive one, or one whose `events` leave out `update`. The server never refuses those moves, so the editor now offers every option for them.

‎packages/cli/src/commands/migrate/object-scope.integration.test.ts‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'nod
4747
import { tmpdir } from 'node:os';
4848
import { dirname, join, resolve } from 'node:path';
4949
import { fileURLToPath } from 'node:url';
50+
import Database from 'better-sqlite3';
5051

5152
const HERE = dirname(fileURLToPath(import.meta.url));
5253
const CLI_ROOT = resolve(HERE, '..', '..', '..');
@@ -125,26 +126,14 @@ process.exit(0);
125126
`;
126127

127128
/** The repro's off-shape value, written past the write path: a location keyed the retired way. */
128-
const OFF_SHAPE_CHILD = `
129-
const { SqlDriver } = await import('@objectstack/driver-sql');
130-
const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true });
131-
await raw.knex('os21644_site').where({ id: 's1' }).update({ geo: JSON.stringify({ latitude: 1, longitude: 2 }) });
132-
await raw.disconnect();
133-
process.exit(0);
134-
`;
135-
136-
/** The flag rows and the app rows, read on a connection of our own. */
137-
const READ_STATE_CHILD = `
138-
const { SqlDriver } = await import('@objectstack/driver-sql');
139-
const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true });
140-
const k = raw.knex;
141-
const flags = await k('sys_migration').select('id', 'last_run_at', 'verified_at', 'blocking').orderBy('id');
142-
const site = await k('os21644_site').select('id', 'geo').orderBy('id');
143-
const product = await k('os21644_product').select('id', 'image').orderBy('id');
144-
await raw.disconnect();
145-
process.stdout.write(JSON.stringify({ flags, site, product }));
146-
process.exit(0);
147-
`;
129+
function writeOffShape(db: string): void {
130+
const conn = new Database(db);
131+
try {
132+
conn.prepare('UPDATE os21644_site SET geo = ? WHERE id = ?').run(JSON.stringify({ latitude: 1, longitude: 2 }), 's1');
133+
} finally {
134+
conn.close();
135+
}
136+
}
148137

149138
interface Run {
150139
code: number | null;
@@ -186,10 +175,22 @@ function freshDb(): string {
186175
return db;
187176
}
188177

178+
/**
179+
* The flag rows and the app rows, read on a connection of our own, in this
180+
* process: a child `node` per read cost ~0.5 s each, sixteen times a file.
181+
*/
189182
function readState(db: string): State {
190-
const out = childNode(READ_STATE_CHILD, { FIXTURE_DB: db });
191-
if (out.status !== 0) throw new Error(`could not read the fixture database (status ${out.status})\n${out.stderr}`);
192-
return JSON.parse(out.stdout) as State;
183+
// Opened as the child it replaces opened it (read-write, knex's default).
184+
const conn = new Database(db);
185+
try {
186+
return {
187+
flags: conn.prepare('SELECT id, last_run_at, verified_at, blocking FROM sys_migration ORDER BY id').all() as FlagRow[],
188+
site: conn.prepare('SELECT id, geo FROM os21644_site ORDER BY id').all() as State['site'],
189+
product: conn.prepare('SELECT id, image FROM os21644_product ORDER BY id').all() as State['product'],
190+
};
191+
} finally {
192+
conn.close();
193+
}
193194
}
194195

195196
function flagRow(state: State, id: string): FlagRow | undefined {
@@ -296,8 +297,7 @@ beforeAll(async () => {
296297

297298
// The measured repro: one off-shape value stored, then a misspelled --object --apply.
298299
const reproDb = freshDb();
299-
const offShape = childNode(OFF_SHAPE_CHILD, { FIXTURE_DB: reproDb });
300-
if (offShape.status !== 0) throw new Error(`the off-shape value was not written\n${offShape.stderr}`);
300+
writeOffShape(reproDb);
301301
const reproRightDb = join(dir, 'data', 'repro-right.db');
302302
copyFileSync(reproDb, reproRightDb);
303303
reproFullDry = await runCommand('value-shapes', ['--json'], reproDb);

‎packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,14 @@ import Database from 'better-sqlite3';
5757

5858
const HERE = dirname(fileURLToPath(import.meta.url));
5959
const CLI_ROOT = resolve(HERE, '..', '..', '..');
60+
/**
61+
* The source entry, spawned by this process's own `node`, as the family's other spawners
62+
* (`data-commands.absent-database`, `object-scope`, `unmapped-columns`) spawn it. Not through
63+
* the `tsx` binary: the entry, its `src/` and its pinned `NODE_ENV=development` are the same
64+
* either way, and the wrapper only adds a second process and its own transform, ~3.4 s of
65+
* every spawn (#23181 slice 12: `tsx bin/run-dev.js --version` 8.28-8.44 s, `node` 4.93-4.99 s).
66+
*/
6067
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
61-
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
6268
const CASE_TIMEOUT_MS = 300_000;
6369
const NOT_AN_OBJECT = ['sys_packages'];
6470

@@ -98,10 +104,10 @@ interface Reading {
98104
function bootThenPlan(dir: string): Reading {
99105
const dbFile = join(dir, 'boot.db');
100106
const env = childEnv(dir, dbFile);
101-
const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
107+
const boot = spawnSync(process.execPath, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
102108
cwd: dir, env: { ...env, OS_MIGRATE_AND_EXIT: '1' }, encoding: 'utf8', timeout: 240_000,
103109
});
104-
const plan = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', '--json'], {
110+
const plan = spawnSync(process.execPath, [RUN_DEV, 'migrate', 'plan', '--json'], {
105111
cwd: dir, env, encoding: 'utf8', timeout: 240_000,
106112
});
107113
let payload: any;

‎packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,14 @@ import Database from 'better-sqlite3';
5555

5656
const HERE = dirname(fileURLToPath(import.meta.url));
5757
const CLI_ROOT = resolve(HERE, '..', '..', '..');
58+
/**
59+
* The source entry, spawned by this process's own `node`, as the family's other spawners
60+
* (`data-commands.absent-database`, `object-scope`, `unmapped-columns`) spawn it. Not through
61+
* the `tsx` binary: the entry, its `src/` and its pinned `NODE_ENV=development` are the same
62+
* either way, and the wrapper only adds a second process and its own transform, ~3.4 s of
63+
* every spawn (#23181 slice 12: `tsx bin/run-dev.js --version` 8.28-8.44 s, `node` 4.93-4.99 s).
64+
*/
5865
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
59-
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
6066
const CASE_TIMEOUT_MS = 600_000;
6167
const SPAWN_TIMEOUT_MS = 240_000;
6268

@@ -134,7 +140,7 @@ async function freePort(): Promise<number> {
134140
*/
135141
function devBootOnce(): string {
136142
const r = spawnSync(
137-
TSX,
143+
process.execPath,
138144
[RUN_DEV, 'dev', '--port', String(port), '--compile', '--no-watch', '--no-restart', '--no-seed-admin'],
139145
{ cwd: dir, env: env({ OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS },
140146
);
@@ -145,7 +151,7 @@ function devBootOnce(): string {
145151

146152
/** One read-only `os migrate` door's `--json` document. */
147153
function migrateJson(args: string[]): any {
148-
const r = spawnSync(TSX, [RUN_DEV, 'migrate', ...args, '--json'], {
154+
const r = spawnSync(process.execPath, [RUN_DEV, 'migrate', ...args, '--json'], {
149155
cwd: dir, env: env(), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS,
150156
});
151157
expect(r.status, `os migrate ${args.join(' ')} failed:\n${`${r.stdout}\n${r.stderr}`.slice(-1500)}`).toBe(0);

‎packages/cli/src/commands/migrate/plan.nullable-tenant-columns.integration.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,14 @@ import Database from 'better-sqlite3';
3232

3333
const HERE = dirname(fileURLToPath(import.meta.url));
3434
const CLI_ROOT = resolve(HERE, '..', '..', '..');
35+
/**
36+
* The source entry, spawned by this process's own `node`, as the family's other spawners
37+
* (`data-commands.absent-database`, `object-scope`, `unmapped-columns`) spawn it. Not through
38+
* the `tsx` binary: the entry, its `src/` and its pinned `NODE_ENV=development` are the same
39+
* either way, and the wrapper only adds a second process and its own transform, ~3.4 s of
40+
* every spawn (#23181 slice 12: `tsx bin/run-dev.js --version` 8.28-8.44 s, `node` 4.93-4.99 s).
41+
*/
3542
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
36-
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
3743
const CASE_TIMEOUT_MS = 300_000;
3844

3945
const APP_OBJECT = 'os25134_deal';
@@ -93,14 +99,14 @@ function childEnv(f: Fixture, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv
9399

94100
/** `os serve`'s own provision-and-exit: a fresh store, the full composition schema-synced into it. */
95101
function serveBoot(f: Fixture): void {
96-
const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
102+
const boot = spawnSync(process.execPath, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
97103
cwd: f.dir, env: childEnv(f, { OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: 240_000,
98104
});
99105
expect(boot.status, `the serving boot did not provision the database:\n${(boot.stderr ?? '').slice(-6000)}`).toBe(0);
100106
}
101107

102108
function plan(f: Fixture, json: boolean): { exit: number | null; payload: any; text: string } {
103-
const run = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', ...(json ? ['--json'] : [])], {
109+
const run = spawnSync(process.execPath, [RUN_DEV, 'migrate', 'plan', ...(json ? ['--json'] : [])], {
104110
cwd: f.dir, env: childEnv(f), encoding: 'utf8', timeout: 240_000,
105111
});
106112
let payload: any;

‎packages/cli/src/commands/migrate/plan.telemetry-sibling.integration.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ import Database from 'better-sqlite3';
4646

4747
const HERE = dirname(fileURLToPath(import.meta.url));
4848
const CLI_ROOT = resolve(HERE, '..', '..', '..');
49+
/**
50+
* The source entry, spawned by this process's own `node`, as the family's other spawners
51+
* (`data-commands.absent-database`, `object-scope`, `unmapped-columns`) spawn it. Not through
52+
* the `tsx` binary: the entry, its `src/` and its pinned `NODE_ENV=development` are the same
53+
* either way, and the wrapper only adds a second process and its own transform, ~3.4 s of
54+
* every spawn (#23181 slice 12: `tsx bin/run-dev.js --version` 8.28-8.44 s, `node` 4.93-4.99 s).
55+
*/
4956
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
50-
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
5157
const CASE_TIMEOUT_MS = 300_000;
5258

5359
/** The one table outside the object set (`PackageServicePlugin.start()`'s raw DDL; see the boot-parity pin). */
@@ -116,7 +122,7 @@ const BOOT_STREAM_TAIL = 8_000;
116122
* there at `error`), and an assertion that failed without it left the CI log unable to confirm or refute one.
117123
*/
118124
function serveBoot(f: Fixture, extra: NodeJS.ProcessEnv = {}): string {
119-
const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
125+
const boot = spawnSync(process.execPath, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
120126
cwd: f.dir, env: childEnv(f, { ...extra, OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: 240_000,
121127
});
122128
const output =
@@ -130,7 +136,7 @@ function serveBoot(f: Fixture, extra: NodeJS.ProcessEnv = {}): string {
130136
const afterBoot = (bootOutput: string, what: string): string => `${what}\n${bootOutput}`;
131137

132138
function command(f: Fixture, argv: string[], extra: NodeJS.ProcessEnv = {}): { exit: number | null; payload: any } {
133-
const run = spawnSync(TSX, [RUN_DEV, 'migrate', ...argv], { cwd: f.dir, env: childEnv(f, extra), encoding: 'utf8', timeout: 240_000 });
139+
const run = spawnSync(process.execPath, [RUN_DEV, 'migrate', ...argv], { cwd: f.dir, env: childEnv(f, extra), encoding: 'utf8', timeout: 240_000 });
134140
let payload: any;
135141
try { payload = JSON.parse(run.stdout); } catch { payload = { unparsed: `${run.stdout}\n${run.stderr}`.slice(-800) }; }
136142
return { exit: run.status, payload };

0 commit comments

Comments
 (0)