Skip to content

Commit 71e1246

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18683-card-comments-page-ladder
2 parents e5c52fd + 6de7a2d commit 71e1246

5 files changed

Lines changed: 251 additions & 3 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
docs(client): the published README's `packages.install` example is a manifest `ManifestSchema` actually accepts (#18607)
6+
7+
The example shipped in the `@objectstack/client` npm tarball was refused on three
8+
counts when parsed against the contract its own call site declares
9+
(`PackageInstallRequestSchema`, whose `manifest` key is `ManifestSchema`):
10+
`invalid_type` at `[manifest, id]`, `invalid_value` at `[manifest, type]` — both
11+
required and absent — and `unrecognized_keys` at `[manifest]` for a `label` key
12+
that `ManifestSchema`'s `strictObject` close refuses by name.
13+
14+
```diff
15+
await client.packages.install({
16+
- name: 'vendor_plugin',
17+
- label: 'Vendor Plugin',
18+
+ id: 'com.vendor.plugin',
19+
+ type: 'plugin',
20+
+ name: 'Vendor Plugin',
21+
version: '1.0.0',
22+
});
23+
```
24+
25+
`label` is not a root manifest key and never was: the root shape declares `name`
26+
for the human-readable string (measured — `ManifestSchema` declares 25 root keys
27+
and `label` is not among them), so the example's `label` value moves to `name`
28+
and the machine identifier becomes the reverse-domain `id` the key documents.
29+
`type: 'plugin'` is the enum member the example's own subject names — a
30+
general-purpose functionality extension, not the consumer-installable `app`
31+
bundle. Required root keys, read off the schema rather than the prose: `id`,
32+
`name`, `type`, `version`.
33+
34+
Nothing parses that contract at the install door today, so the example "worked"
35+
by being posted unvalidated — which is what made it a timed charge rather than a
36+
live outage: closing the door turns a silently-wrong published example into a
37+
loudly-broken one for every reader who copied it.
38+
39+
Pinned in `packages/client/src/readme-package-install-example.test.ts`, which
40+
parses every `packages.install` manifest literal in this README against that
41+
schema and fails if the corpus is ever empty.
42+
43+
Clause-②: no
44+
45+
No schema, export, type or runtime behaviour changes. It ships because the README
46+
is listed in this package's `files[]` and is the first thing a new integrator
47+
copies.

.claude/skills/pm-dispatch/references/platform-readings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
- `needs:contract-review` 是合并闸:实测 `blocked``mergeable: true`,无标签同形兄弟回 `clean`
2727
- ready 翻转实测两序列 `clean→blocked→clean``blocked→unstable→clean`;`unstable` 瞬态非失败。
2828
- `unstable` 可源自 check-runs 看不见的 commit STATUS(如 `Vercel`)⇒ ③ 另读 `/commits/{sha}/status`
29+
- 零 legacy status 的仓恒答空集默认值 `pending`+`total_count: 0`,⛔ 非门禁读数,门禁读 check-runs。
2930
- 判头脏走零配额本地试合并:fetch PR ref 后 `git merge-tree --write-tree origin/main <ref>`
3031
- 它直接列出冲突文件;读数随 fetch 老化,重跑先 fetch。
3132
- 它跑 `git merge` 的 merge-ort ⇒ 注册 `merge=os-regen` 的克隆照用驱动,未注册的退回文本合并。
@@ -340,7 +341,6 @@
340341
- 去掉横线只写页脚则原样存活;评论不受影响,两种拼法都活。
341342
- ⇒ 失效既依拼写又依载体:评论里验过页脚对 PR 正文什么都没证明。
342343
- ⇒ PR 正文页脚不带前置横线,且写后回读正文 —— 那是唯一检测手段;评论两形皆可。
343-
- 署名页脚的写侧变异按通道与输入双重定域,⛔ 不是一条定律。
344344
- MCP `update_pull_request` 包装器删掉 PR 正文的页脚块;该通道锁 1 已拒,读作历史。
345345
- 裸 REST `PATCH /pulls` 追加一个裸页脚并保留既有 session-URL 页脚,差恰 58 字节。
346346
- 同路送无页脚正文存回恰一条(平台裸形)⇒ 该格处方是不送页脚,⛔ 不是不重送正文。

.github/workflows/ci.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,6 +1444,56 @@ jobs:
14441444
run: |
14451445
pnpm --filter @objectstack/metadata-protocol exec vitest run live-mysql live-postgres
14461446
1447+
# ── The cascade-delete multi-value lookup pin, on the live PostgreSQL ──
1448+
#
1449+
# `packages/runtime` rides this job for the same reason metadata-protocol
1450+
# above does: this is where a live PostgreSQL already exists. Only the
1451+
# matrix file runs here — the rest of that package's suite has no server
1452+
# axis and runs in Test Core.
1453+
#
1454+
# WHY (#18734). #18172 was a p1 that reached TWO published releases and
1455+
# was found by a customer rather than by CI: every DELETE of an object
1456+
# targeted by a `multiple: true` reference answered 500 on PostgreSQL,
1457+
# always, while the only real-stack pin of that path stayed green
1458+
# throughout — because it hard-coded `client: 'better-sqlite3'`. #18732
1459+
# removed that literal and gave the file a driver axis, and SHOWED the
1460+
# live cell red: ablating `SqlDriver.applyJsonMembership` produced
1461+
# `6 failed | 9 passed`, all six failures in the live cell and ZERO in
1462+
# SQLite, reproducing `operator does not exist: json ~~ text` (42883).
1463+
#
1464+
# ⚠ "There is a pin that can go red" and "that pin will be run" are two
1465+
# different claims, and until these two steps only the first was true.
1466+
# Measured on `origin/main` before this landed: `@objectstack/runtime`
1467+
# occurred ZERO times in this job's window, against a firing control of
1468+
# SEVEN for `driver-sql`. Without a URL the cell is a NAMED SKIP that
1469+
# says which variable would run it — a report, NOT coverage, and the
1470+
# shape that reads as "already fixed" in every review.
1471+
#
1472+
# `OS_EXPECT_LIVE_DIALECT_MATRIX` is scoped to the STEP, like the two
1473+
# legs above and deliberately not to the job: it asserts "the runner
1474+
# provisioned what THIS step needs", so it stays honest if a later step's
1475+
# server is ever dropped. No stall guard, matching the metadata-protocol
1476+
# leg — the two legs that carry one are the two that actually hit #4331.
1477+
#
1478+
# ⛔ THE POSITIONAL IS A SUBSTRING, NOT A GLOB — the same reading the
1479+
# metadata-protocol step records above, where the glob form matched zero
1480+
# files. ⛔ Do NOT "improve" it into `src/cascade-delete-*.test.ts`: that
1481+
# selects nothing, and a test that cannot fail reports success, which is
1482+
# the exact defect class this step exists to close. (`packages/runtime`'s
1483+
# vitest config also runs `runFilterPreflight` (#17853/#17978), which
1484+
# speaks up when a named path selects no tests — a second net, not a
1485+
# licence to change the form.)
1486+
- name: Build runtime and its dependencies
1487+
run: pnpm exec turbo run build --filter=@objectstack/runtime... --concurrency=4
1488+
1489+
- name: Run the runtime cascade-delete matrix against live PostgreSQL
1490+
env:
1491+
OS_TEST_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres
1492+
OS_EXPECT_LIVE_DIALECT_MATRIX: '1'
1493+
run: |
1494+
pnpm --filter @objectstack/runtime exec vitest run --project local \
1495+
cascade-delete-multivalue-lookup-real-driver
1496+
14471497
dogfood:
14481498
# Sharded 3-way: the suite is ~60 independent test files, each booting its
14491499
# own in-process app; a single 4-vCPU runner needed ~7½ minutes for the

packages/client/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,9 @@ await client.auth.refreshToken('refresh-token-string');
245245
// Package Management
246246
await client.packages.list();
247247
await client.packages.install({
248-
name: 'vendor_plugin',
249-
label: 'Vendor Plugin',
248+
id: 'com.vendor.plugin',
249+
type: 'plugin',
250+
name: 'Vendor Plugin',
250251
version: '1.0.0',
251252
});
252253
await client.packages.enable('plugin-id');
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18607] Every `client.packages.install(<manifest>)` example in THIS
5+
* package's PUBLISHED README is parsed against the contract that door
6+
* declares — `PackageInstallRequestSchema`, whose `manifest` key is
7+
* `ManifestSchema`.
8+
*
9+
* ## The defect it exists to prevent
10+
*
11+
* The README shipped this manifest in the npm tarball:
12+
*
13+
* await client.packages.install({
14+
* name: 'vendor_plugin',
15+
* label: 'Vendor Plugin',
16+
* version: '1.0.0',
17+
* });
18+
*
19+
* Parsed against the declared contract it is refused on THREE counts:
20+
* `invalid_type` at `[manifest, id]`, `invalid_value` at `[manifest, type]`
21+
* (both keys are required and absent), and `unrecognized_keys` at
22+
* `[manifest]` for `label` — a key `ManifestSchema`'s `strictObject` close
23+
* refuses BY NAME. `label` is not a root manifest key and never was: the
24+
* root shape declares `name` for the human-readable string, and the only
25+
* `label` anywhere near this surface belonged to the nested, since-RETIRED
26+
* `contributes.themes` `{ id, label, path }` entry — a sibling shape, not
27+
* this one.
28+
*
29+
* Nothing parses the contract at that door today, so the example "worked":
30+
* the SDK posts whatever literal it is handed, and `install(manifest: any)`
31+
* type-checks it away. That is what made this a timed charge rather than a
32+
* live outage — closing the door turns a silently-wrong published example
33+
* into a loudly-broken one for every reader who copied it.
34+
*
35+
* ## Why a pin, and why this one CAN fail
36+
*
37+
* Nothing else reads these literals. `check:published-readme-exports` has
38+
* the right population but reads fenced blocks for IMPORTED SYMBOLS and has
39+
* no notion of a schema; no gate parses an example payload against the
40+
* schema its own call site declares. Restore any of the three original
41+
* defects and this test reds on that specific issue code.
42+
*
43+
* Two shapes deliberately fail rather than pass quietly, because a pin that
44+
* measures nothing is worse than none (Route & surface ownership §3):
45+
* the corpus going EMPTY (the anchor renamed, the fence relabelled) and a
46+
* literal carrying a node kind the reader does not model.
47+
*/
48+
49+
import { readFileSync } from 'node:fs';
50+
import { fileURLToPath } from 'node:url';
51+
52+
import { PackageInstallRequestSchema } from '@objectstack/spec/api';
53+
import ts from 'typescript';
54+
import { describe, expect, it } from 'vitest';
55+
56+
/** This package's own published README — inside the package, no escape. */
57+
const README = fileURLToPath(new URL('../README.md', import.meta.url));
58+
59+
/** The call whose first argument IS the manifest. */
60+
const INSTALL_CALL = 'packages.install';
61+
62+
/** ```ts / ```typescript fences — the only regions read as code. */
63+
const TS_FENCE = /^```(?:ts|typescript)\s*$\n([\s\S]*?)^```\s*$/gm;
64+
65+
/**
66+
* An object literal, as a value. ⛔ Never a partial read: an unmodelled node
67+
* kind throws, because a manifest quietly missing the key that carried the
68+
* defect would parse green and pin nothing.
69+
*/
70+
function literalToValue(node: ts.Expression): unknown {
71+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
72+
if (ts.isNumericLiteral(node)) return Number(node.text);
73+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
74+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
75+
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
76+
if (ts.isArrayLiteralExpression(node)) return node.elements.map(literalToValue);
77+
if (ts.isObjectLiteralExpression(node)) {
78+
const out: Record<string, unknown> = {};
79+
for (const prop of node.properties) {
80+
if (!ts.isPropertyAssignment(prop)) {
81+
throw new Error(`Unmodelled object member in a README manifest: ${ts.SyntaxKind[prop.kind]}`);
82+
}
83+
const key = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
84+
? prop.name.text
85+
: undefined;
86+
if (key === undefined) {
87+
throw new Error(`Unmodelled property name in a README manifest: ${ts.SyntaxKind[prop.name.kind]}`);
88+
}
89+
out[key] = literalToValue(prop.initializer);
90+
}
91+
return out;
92+
}
93+
throw new Error(`Unmodelled expression in a README manifest: ${ts.SyntaxKind[node.kind]}`);
94+
}
95+
96+
interface InstallExample {
97+
/** 1-based line of the call inside the README, for the failure message. */
98+
readonly line: number;
99+
readonly manifest: unknown;
100+
readonly source: string;
101+
}
102+
103+
function collectInstallExamples(readme: string): InstallExample[] {
104+
const found: InstallExample[] = [];
105+
for (const fence of readme.matchAll(TS_FENCE)) {
106+
const code = fence[1] ?? '';
107+
const fenceLine = readme.slice(0, fence.index ?? 0).split('\n').length;
108+
const sourceFile = ts.createSourceFile('readme-fence.ts', code, ts.ScriptTarget.Latest, true);
109+
const visit = (node: ts.Node): void => {
110+
if (
111+
ts.isCallExpression(node)
112+
&& node.expression.getText(sourceFile).endsWith(INSTALL_CALL)
113+
&& node.arguments.length > 0
114+
) {
115+
const [first] = node.arguments;
116+
if (first !== undefined && ts.isObjectLiteralExpression(first)) {
117+
found.push({
118+
line: fenceLine + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line,
119+
manifest: literalToValue(first),
120+
source: first.getText(sourceFile),
121+
});
122+
}
123+
}
124+
ts.forEachChild(node, visit);
125+
};
126+
visit(sourceFile);
127+
}
128+
return found;
129+
}
130+
131+
const EXAMPLES = collectInstallExamples(readFileSync(README, 'utf8'));
132+
133+
describe('published README — packages.install examples parse as manifests', () => {
134+
it('finds at least one `packages.install` manifest literal to judge', () => {
135+
// Anti-vacuity floor. An empty corpus means the anchor moved, not that
136+
// every example is correct.
137+
expect(EXAMPLES.length).toBeGreaterThan(0);
138+
});
139+
140+
it.each(EXAMPLES.map((e) => [e.line, e] as const))(
141+
'README line %i is accepted by PackageInstallRequestSchema',
142+
(_line, example) => {
143+
const result = PackageInstallRequestSchema.safeParse({ manifest: example.manifest });
144+
const refusals = result.success
145+
? []
146+
: result.error.issues.map((issue) => `${issue.code} at [${issue.path.join(', ')}]`);
147+
expect(refusals, `${example.source}\n→ ${refusals.join('; ')}`).toEqual([]);
148+
},
149+
);
150+
});

0 commit comments

Comments
 (0)