Skip to content

Commit 6c86259

Browse files
committed
Merge origin/main into claude/issue-24228-actions-anonymous-floor
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
2 parents 194b25d + 7be3cc7 commit 6c86259

34 files changed

Lines changed: 1522 additions & 161 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ComponentPropsMap` gains a `list-view` row, so a list view embedded on a page is judged as a list view; stored page nodes in the old `objectName` / `viewType` spelling convert to `data` / `type` on read
6+
7+
Clause-②: yes (widening)
8+
9+
- **The row.** `ComponentPropsMap['list-view']` is the list view's own members by reference (`ListViewSchema.shape`), with both of its object-level checks attached (the calendar visualization and the chart dataset binding), plus the two members the list overlay member declares instead: `columns` is optional, and the legacy `options` bag is judged per kind (`options.kanban` by the kanban block's schema, `options.grid` refused because a grid has no per-kind block). Until this row a page `list-view` node's `properties` were judged by nothing, so a kanban block's `groupField` or an `options.grid` bag, refused at every list view door, saved green on a page. `os validate`, `os build`, `os lint` and the page save door now report them through the existing component-props rule (`component-props-unknown-key`), naming the key. No rule, tier or door is added, and `PageSchema`'s accept set does not move.
10+
- **The page carrier's old spelling.** A page `list-view` node names its object in `properties.data` and its list kind in `properties.type`, the spelling every other list view door uses. `properties.objectName` and `properties.viewType` are refused by name, with the prescription the react tier gives the same two aliases on `ListView` (`REACT_RETIRED_OVERLAY_PROPS.ListView`, now declared once for both tiers). Fix: replace `objectName: 'deal'` with `data: { provider: 'object', object: 'deal' }` (or name the object on the node's `dataSource.object`), and `viewType: 'kanban'` with `type: 'kanban'`; the node's own `type` stays `list-view`. `os migrate meta --from 17 --write` makes the edit where it can prove it.
11+
- **Stored pages.** The ADR-0087 D2 conversion `page-list-view-object-name-view-type-to-data-type` moves the two aliases to `data` and `type` on every page `list-view` node it reaches (regions, slots, nested containers), retired from the load path: stored `sys_metadata` page rows, compiled artifacts and installed packages replay it on read, while an authored source meets the refusal. An alias beside a spec key that says something different is left as stored and reported as a TODO (`OS_METADATA_CONVERSION_TODO`). Its D3 record is the semantic entry `page-list-view-object-name-view-type-retired`.
12+
- **Also classified:** a `list-view` inside a print page is refused with its reason (it pages its rows), and the view-level API data source refusal names `list-view` among the page components whose `data` prop takes a view data source.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli,runtime): a dangling symlink standing as the SQLite database's directory is refused at once by `os dev` and `os serve`, naming the database file and the directory, instead of failing the boot later in a plugin's start
7+
8+
Clause-②: no
9+
10+
**What was wrong.** `os dev` (for its default database under `OS_HOME/data`) and the standalone host behind `os serve` and `os start` (for a `file:` or `wasm-sqlite://` database URL) make the database file's directory before the driver opens it. Both accepted any `EEXIST` answer while making a missing level. A symbolic link that points nowhere answers `EEXIST` to `mkdir`, so it was taken for a directory, and the boot went on until the driver could not open the file. `os dev` then exited 1 with `Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'`, and `os serve` with `Plugin package-service failed to start - rollback complete: The database refused to run a raw statement`. Neither message named the file, the directory or the link.
11+
12+
**What an operator sees now.** Both make the directory with the same bounded walk as the other `OS_HOME` paths, which accepts `EEXIST` only when a directory stands there. A dangling link as the directory is refused before the boot starts: `os dev` prints `The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: EEXIST: …` and exits 1, and `os serve` prints `[StandaloneStack] SQLite database file '…' cannot be created: its directory '…' could not be made: EEXIST: …` and exits 1. Remove the link, or point it at a directory that exists. A dangling link higher up the path was already refused at once, with `ENOENT` at the level below the link; it is now refused with `EEXIST` at the link itself.
13+
14+
Nothing else changes. A directory that can be made is still made, every missing level of it; a directory that exists, or a symbolic link to one, is used as it stands; and a path the operating system refuses for any other reason (`ENOENT`, `ENOTDIR`, a regular file holding the directory's name) is refused with the same code and text as before.
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` says once when it cannot write its runtime state file, naming the file and the operating system's error, and goes on booting
6+
7+
Clause-②: no
8+
9+
- **Before:** when `runtime.<environment>.<project>.json` could not be written — an `OS_HOME` under `/proc`, or one below a regular file (`ENOTDIR`) — the write's catch printed nothing. The boot printed its ready banner, an `os dev` parent got its `objectstack:listening` message, and a supervisor that then read the file got `ENOENT` with no reason anywhere.
10+
- **Now:** one warning on stderr, among the other boot warnings and before the banner, names the file it could not write and carries the operating system's error verbatim (for example `ENOTDIR: not a directory, stat '…'`). It says the server runs on without the file and that `OS_HOME` must point at a directory the process can write.
11+
- **Unchanged:** the file stays best-effort, so the boot never fails on it; its name, its payload and its removal on exit; the banner and the IPC message; stdout, which stays the stdio MCP transport's alone.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
fix(cloud-connection): with `OS_RUNTIME_VERSION` unset, the server reports to the cloud the release `GET /api/v1/discovery` reports, not the Cloud Connection plugin's own `0.3.0`
6+
7+
Clause-②: no
8+
9+
`CloudConnectionPlugin` sends the server's version to the cloud in two places: as `runtime_version` on the approval links `POST /api/v1/cloud-connection/bind/start` returns, which the cloud's approval page shows next to the server's name, and as `runtime_version` in the registration request `POST /api/v1/cloud-connection/bind/poll` sends to the cloud's `/bind`. With `OS_RUNTIME_VERSION` unset, both carried the plugin's own `version` field, `0.3.0`. That string names the plugin, not the release the server runs, so an approver was shown `0.3.0` while discovery on the same server reported `18.0.0-next.*`.
10+
11+
- **Unset now:** both places carry the `version` the server's discovery document reports. The plugin reads it from the discovery document itself, through the `protocol` service, each time it builds the approval links or the registration request, so the two answers cannot drift.
12+
- **Set:** a set `OS_RUNTIME_VERSION` is sent verbatim, as before.
13+
- **No discovery document:** on a server with no `protocol` service, or when reading it fails, neither place carries a version. The approval links have no `runtime_version` parameter and the registration request has no `runtime_version` key. Before, both carried `0.3.0`.
14+
15+
What changes for you: nothing to rewrite. `OS_RUNTIME_VERSION` is now only needed to report a version other than the release, such as an image tag or a commit.

‎content/docs/deployment/cloud-connection.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,11 @@ explicit one before you connect if you want a readable, stable name. Whether
168168
the cloud account also lets you rename the entry directly is decided by the
169169
cloud account.
170170

171-
The version shown next to the name is `OS_RUNTIME_VERSION` when the deployment
172-
sets it. Unset, the server sends the Cloud Connection plugin's own version
173-
string, which is not your server's release, so set `OS_RUNTIME_VERSION` if
174-
approvers should see the release you run. That fallback is a known defect,
175-
tracked in [#24150](https://github.com/objectstack-ai/objectstack/issues/24150).
171+
The version shown next to the name is the release your server runs: the
172+
`version` its discovery document (`GET /api/v1/discovery`) reports. You do not
173+
need to set anything for it. `OS_RUNTIME_VERSION` is an override: set it to
174+
report something else, such as an image tag or a commit, and the server sends
175+
that value as it is.
176176

177177
## Disconnect [#disconnect]
178178

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 213 | 202 | 4 | 0 | 7 |
24+
| `ui/` | 214 | 203 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 80 |
39+
| `component.zod.ts` | 81 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 59 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **213** |
49+
| **total** | **214** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 213**, in 4 file(s).
57+
**7 strip of 214**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **213** |
65+
| **total** | **7** | **214** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/cli/src/commands/dev-database-directory.test.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,20 @@
3030
// 2. A path through a regular file: refused with `ENOTDIR`, naming the file.
3131
// 3. CONTROL: a creatable unified-default path three levels deep is made.
3232
// 4. CONTROL: any other source is left to the serve child — nothing is made.
33+
// 5. A dangling symlink AS the directory (`OS_HOME/data` pointing nowhere):
34+
// refused at once with `EEXIST`, naming the file and the directory (#24090).
35+
// The walk used to accept any `EEXIST` on a created level, so the link went
36+
// through to the serve child, whose boot failed seconds later with a
37+
// plugin-start error naming neither (measured at `os dev` on `origin/main`
38+
// b9ac35cbf: exit 1 after 8.3 s, "Plugin com.objectstack.auth failed to
39+
// start").
40+
// 6. The answers the fold onto `makeDirectorySync` keeps: a directory that
41+
// already exists, and a live symlink to one, are accepted as they stand; a
42+
// regular file holding the directory's own name is refused with `EEXIST`.
3343

3444
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3545
import { spawnSync } from 'node:child_process';
36-
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
46+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
3747
import { tmpdir } from 'node:os';
3848
import { dirname, join, resolve } from 'node:path';
3949
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -62,11 +72,22 @@ let outcomes: Map<string, Outcome>;
6272
beforeAll(() => {
6373
root = mkdtempSync(join(tmpdir(), 'os-23737-dev-'));
6474
writeFileSync(join(root, 'plain'), '');
75+
mkdirSync(join(root, 'dangling'));
76+
symlinkSync(join(root, 'nowhere'), join(root, 'dangling', 'data'));
77+
mkdirSync(join(root, 'existing', 'data'), { recursive: true });
78+
mkdirSync(join(root, 'live', 'target'), { recursive: true });
79+
symlinkSync(join(root, 'live', 'target'), join(root, 'live', 'data'));
80+
mkdirSync(join(root, 'held'));
81+
writeFileSync(join(root, 'held', 'data'), '');
6582
const cases: Array<{ label: string; url: string; source: string }> = [
6683
...(RUNS_PROC ? [{ label: 'proc', url: `file:${PROC_FILE}`, source: 'unified-default' }] : []),
6784
{ label: 'through-file', url: `file:${join(root, 'plain', 'data', 'objectstack.db')}`, source: 'unified-default' },
6885
{ label: 'creatable', url: `file:${join(root, 'home', 'a', 'data', 'objectstack.db')}`, source: 'unified-default' },
6986
{ label: 'other-source', url: `file:${join(root, 'env', 'a', 'data', 'objectstack.db')}`, source: 'env' },
87+
{ label: 'dangling', url: `file:${join(root, 'dangling', 'data', 'objectstack.db')}`, source: 'unified-default' },
88+
{ label: 'existing', url: `file:${join(root, 'existing', 'data', 'objectstack.db')}`, source: 'unified-default' },
89+
{ label: 'live-link', url: `file:${join(root, 'live', 'data', 'objectstack.db')}`, source: 'unified-default' },
90+
{ label: 'held', url: `file:${join(root, 'held', 'data', 'objectstack.db')}`, source: 'unified-default' },
7091
];
7192
const script = [
7293
'const [subject, json] = process.argv.slice(1);',
@@ -144,3 +165,29 @@ describe('prepareDevDatabaseDirectory — os dev\'s unified-default directory (#
144165
expect(existsSync(join(root, 'env'))).toBe(false);
145166
});
146167
});
168+
169+
describe('prepareDevDatabaseDirectory — a dangling symlink in the path is refused at once (#24090)', () => {
170+
it('refuses a dangling symlink as the directory with EEXIST, naming the file and the directory', () => {
171+
const o = outcomeOf('dangling');
172+
expect(o.outcome).toBe('rejected');
173+
expect(o.code).toBe('EEXIST');
174+
expect(o.message).toContain(`'${join(root, 'dangling', 'data', 'objectstack.db')}'`);
175+
expect(o.message).toContain(`'${join(root, 'dangling', 'data')}'`);
176+
expect(lstatSync(join(root, 'dangling', 'data')).isSymbolicLink()).toBe(true);
177+
expect(existsSync(join(root, 'nowhere'))).toBe(false);
178+
});
179+
180+
it('CONTROL: a directory that already exists, and a live symlink to one, are accepted as they stand', () => {
181+
expect(outcomeOf('existing').outcome).toBe('returned');
182+
expect(statSync(join(root, 'existing', 'data')).isDirectory()).toBe(true);
183+
expect(outcomeOf('live-link').outcome).toBe('returned');
184+
expect(lstatSync(join(root, 'live', 'data')).isSymbolicLink()).toBe(true);
185+
});
186+
187+
it('refuses a regular file holding the directory\'s own name with EEXIST, naming the file', () => {
188+
const o = outcomeOf('held');
189+
expect(o.outcome).toBe('rejected');
190+
expect(o.code).toBe('EEXIST');
191+
expect(o.message).toContain(`'${join(root, 'held', 'data', 'objectstack.db')}'`);
192+
});
193+
});

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

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '../utils/dev-restart.js';
1919
import { isOrganizationOwnershipRequiredMessage, upgradeDevelopmentDatabase } from '../utils/dev-organization-ownership-upgrade.js';
2020
import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js';
21+
import { makeDirectorySync } from '../utils/make-directory.js';
2122
// THE artifact precedence, written once (#21501) — shared with `start`, and
2223
// with the `serve` child this command spawns. ⛔ No rung of it is restated here.
2324
import {
@@ -93,7 +94,7 @@ export async function resolveDevDatabase(opts: {
9394

9495
/**
9596
* Make the directory of `os dev`'s unified-default SQLite database
96-
* (`<state dir>/data/`), bounded — or reject at once, naming the database
97+
* (`<state dir>/data/`), bounded — or throw at once, naming the database
9798
* file, the directory and the operating system's code (#23737).
9899
*
99100
* Only the `unified-default` source is prepared here; every other source is
@@ -108,40 +109,21 @@ export async function resolveDevDatabase(opts: {
108109
* `mkdir("/proc")` in its own process (64,440 failing calls in 25 s) and never
109110
* reached its next line.
110111
*
111-
* The walk is the one `driver-sql`'s connect and the runtime's standalone host
112-
* run (neither is on a package entry, hence this module-level copy): find the
113-
* nearest level that exists, create the missing ones top-down with a
114-
* NON-recursive `mkdir`, accept `EEXIST` on a created level (a concurrent
115-
* creator), and reject on anything else.
112+
* The walk is this package's {@link makeDirectorySync}: `EEXIST` on a created
113+
* level is accepted only when a directory stands there. A dangling symlink in
114+
* the path answers `EEXIST` to `mkdir` and `ENOENT` to `stat`, so it is refused
115+
* here, at once, naming the link (#24090). Accepting any `EEXIST` let it
116+
* through to the serve child, whose boot then failed seconds later with a
117+
* plugin-start error that named neither the file nor the directory.
116118
*/
117-
export async function prepareDevDatabaseDirectory(
119+
export function prepareDevDatabaseDirectory(
118120
resolved: Pick<ResolvedProjectDatabaseUrl, 'url' | 'source'>,
119-
): Promise<void> {
121+
): void {
120122
if (resolved.source !== 'unified-default') return;
121123
const filename = resolved.url.replace(/^file:/, '');
122124
const directory = path.resolve(path.dirname(filename));
123125
try {
124-
const missing: string[] = [];
125-
let level = directory;
126-
for (;;) {
127-
try {
128-
if (!(await fs.promises.stat(level)).isDirectory() && missing.length === 0) await fs.promises.mkdir(level);
129-
break;
130-
} catch (e) {
131-
if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e;
132-
missing.push(level);
133-
const parent = path.dirname(level);
134-
if (parent === level) break;
135-
level = parent;
136-
}
137-
}
138-
for (const each of missing.reverse()) {
139-
try {
140-
await fs.promises.mkdir(each);
141-
} catch (e) {
142-
if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e;
143-
}
144-
}
126+
makeDirectorySync(directory);
145127
} catch (e) {
146128
const code = (e as { code?: unknown } | null)?.code;
147129
const reason = e instanceof Error ? e.message : String(e);
@@ -639,7 +621,7 @@ export default class Dev extends Command {
639621
}
640622
// Bounded, and refused at once when the directory cannot be made (#23737).
641623
try {
642-
await prepareDevDatabaseDirectory(resolvedDb);
624+
prepareDevDatabaseDirectory(resolvedDb);
643625
} catch (error: any) {
644626
printError(error?.message ?? String(error));
645627
console.error(chalk.yellow(

0 commit comments

Comments
 (0)