Repository navigation
Commit 2163e80
feat(spec,metadata-protocol,service-storage,runtime,cli)!: a seed record can carry a file the package ships, seedFile(path) (#26244)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbPyKYmEAacytyQzzRaqtbnS)_
Fixes #25779
Clause-②: yes (narrowing)
Metadata-protocol: narrowed
## 维护者速读
**改了什么**
- 种子数据现在可以给文件类字段(file / image / avatar / video / audio)填一个应用包里自带的文件,写法是
`file: seedFile('fixtures/nda-v1.pdf')`。
- `os build` 在构建时读出这些文件,把内容打进产物。所以 `os dev`、`os serve`、已发布的包、产物 URL
这几种启动方式都带着文件,运行时不用再读磁盘。`os validate` 做同样的路径检查,找不到文件会在校验阶段就报错。
- 启动时,种子加载器把文件存进存储服务,把文件 id
写进字段,再把这条记录登记为文件的归属。这和用户上传的文件是同一种归属。每条记录各有一个文件,重启也不会重复存。
- 新增一条拒绝规则:如果种子在文件字段里写了一个像文件
id、却查不到对应文件的值,这一行会被拒绝并报错。以前这种值会照常写入,读出来却是空文件。
**为什么改**
- 维护者在 hotclm#19 裁定 D:平台的种子加载器负责携带应用包里的样例文件,应用里的 `file` 字段保持必填。
- 改之前,文件 id 只能通过上传得到,所以带必填文件字段的对象完全无法用种子数据填充。
**风险与代价(含回滚)**
- 这是破坏性的收窄。文件字段里查不到文件的 id 形值,以前是静默写入、读出为空,现在整行被拒。
- 仓库内没有任何种子受影响。唯一带文件字段的示例 app-showcase 没有给文件字段写种子。
- 外部 URL、内联值,以及没装存储服务的部署,都不受影响。
- 代价:产物体积大约增加样例文件大小的 4/3(base64 编码)。已发布的种子还会在 `sys_metadata` 里多存一份。
- 带种子文件的应用,启动种子改到 `kernel:bootstrapped` 时执行,和已有的 `demoStaff`
一样,这样文件会写进部署实际使用的存储。
- 声明行有偏差:认领写的是 `Clause-②: yes` 和 `Metadata-protocol: added`,本 PR 和
changeset 照抄了。实测这是收窄,应写作 `yes (narrowing)` 和 `narrowed`,详见下方 Acceptance
notes,由席位修正认领。两种写法的发布级别都是 minor,版本号不受影响。
- 回滚:revert 本 PR 即可,没有数据迁移。已存进存储的种子文件会留着,不受影响。已经用了 `seedFile`
的应用需要改回去,因为 revert 之后这个函数就不存在了。
**席位意见**
建议批准。契约复核在本 head 通过(PR 评论 6425043305,CONTRACT_REVIEW_TIER):新增的
`seedFile` 写法与「查不到文件的 id 形值整行拒绝」两处接受集变化都判定正确,changeset 的收窄声明、BREAKING
说明与 ADR-0087 标记与实际发布内容一致。本仓库没有任何种子受这条收窄影响。唯一未测量的是类型检查,它因 tsc 内存溢出(exit
134,不是类型错误)失败,根因已立卡 #26288,与本 PR 无关,需要重跑。
**你要做的**
- 审阅并批准本 PR。它触及 `skills/**` 的一行生成内容,改动又超过 3000 行(含生成物),所以需要人工批准。
## What it does
- **The capability.** A seed record can now fill a `file` / `image` /
`avatar` / `video` / `audio` field with a file the package ships: `file:
seedFile('fixtures/nda-v1.pdf')`. This is the hotclm#19 ruling D: the
platform's seed loader carries the bundled fixture file, and the app
keeps `file` required.
- **The shape choice: a reference resolved at build time, with the bytes
carried in the compiled artifact.** `seedFile(path)` writes `{ $file:
path }` into the record. `os build` / `os compile` reads the file,
relative to the directory that holds `objectstack.config.ts`, and embeds
it in the dataset's new `files` map as base64 with its size and SHA-256.
Every boot path then has the bytes with no filesystem access at runtime:
`os dev`, `os serve`, a published package, or an artifact URL.
- `os validate` runs the same gate (`collectSeedFiles`), so it refuses
the paths `os build` refuses: a missing file, a path that climbs out of
the package, or an unknown type. Only `os build` writes the bytes
(`placeSeedFiles`).
- `os serve` / `os dev` booting a **config** (not an artifact) mirrors
the collection, as it already mirrors package docs. Without that mirror
a config boot carried the reference and not the bytes, and refused every
such row. The boot test's negative control measured exactly that: 0 rows
with the mirror removed, 2 with it.
- The alternative was resolving the path at load time against the
package directory. It was rejected because a published package or an
artifact URL has no package directory.
- The cost: the artifact grows by about 4/3 of the fixture bytes. A
published seed also keeps those bytes in `sys_metadata`.
- **What the loader does with it.**
- It stores the bytes through a new `field-files` service
(`IFieldFileService`, registered by `@objectstack/service-storage`) as a
`pending` `sys_file` in the row's organization.
- It writes the id into the field and, once the row lands, makes the
(object, record, field) slot the file's owner. That ownership is the
same one an upload gets, and it commits the file.
- Each row gets its own file.
- It is idempotent across boots. When a replay finds the row already
holding a file stored from the same path with the same bytes, name and
type, it keeps that file.
- A file whose row is refused stays `pending`, so the existing
pending-upload retention reclaims it.
- **The narrowing (shape 3), refused rather than warned.** A seed value
in a file field that looks like an id but names no stored file in the
row's organization is now refused for that row. Before this change it
was written, and the field then read as holding no file. URLs and inline
values are untouched, and so is a deployment without the storage
service.
- Refuse versus warn is **not** a real fork; all four axes agree:
- Business need: a seed that silently produces "no file" in a required
field is worse than a loud row error.
- Long-term design: ADR-0104 already classes such a value as a blocking
`unowned_reference`.
- Preventing AI mistakes: a fabricated "id that looks right" is exactly
the pattern this catches. `examples/app-showcase`'s seed docblock names
it.
- Keeping scope small at this stage: refuse adds no warn channel and no
switch.
- No seed in this repository is refused. The only example with file
fields, app-showcase, seeds none of them.
## Readiness: why the inline seed of an app with seed files moves to
`kernel:bootstrapped`
- The `field-files` service is registered at the storage plugin's `init`
and resolves the engine per call, so it can serve a seed that runs in a
plugin's `start()`.
- The storage adapter can be swapped at `kernel:ready`, once settings
are read. An AppPlugin inline seed whose datasets carry seed files
therefore defers to `kernel:bootstrapped`, as one with `demoStaff`
already does, so its files land in the store the deployment serves from.
- When the field-reference hooks are armed, they claim the file on the
record write, and `claimFiles` then only commits it. When they are not
armed yet, the loader's explicit claim is the ownership. Either way a
file has exactly one owner.
- Residual edge: a multi-tenant organization created before the settings
read seeds through the constructor's adapter. That is the same as an
upload in that window, and this PR does not change it.
## Files touched
| Area | Files |
|---|---|
| Spec: authoring | `packages/spec/src/data/seed-file.zod.ts` (new:
`seedFile`, `SeedFileReferenceSchema`, `SeedFileContentSchema`,
`SeedFilePathSchema`), `seed.zod.ts` (`files` map, record-value
refinement, `defineSeed` refuses `seedFile` in a non-file field),
`data/index.ts` |
| Spec: contract | `packages/spec/src/contracts/storage-service.ts`
(`FIELD_FILE_SERVICE`, `IFieldFileService`, its input and output types)
|
| Storage |
`packages/services/service-storage/src/field-file-service.ts` (new),
`storage-service-plugin.ts` (registers `field-files`),
`file-reference-lifecycle.ts` (**only** `export` added to
`releaseOwnership` and `ownedBySlot`) |
| Loader | `packages/metadata-protocol/src/seed-files.ts` (new),
`seed-loader.ts`, `protocol.ts`, `protocol-publish-batch.ts`, `index.ts`
|
| Runtime | `packages/runtime/src/app-plugin.ts`,
`packages/runtime/src/domains/packages.ts` (pass the `field-files`
service to every `SeedLoaderService`) |
| CLI | `packages/cli/src/utils/bundle-seed-files.ts` (new:
`collectSeedFiles` gate, `placeSeedFiles` assembly),
`commands/compile.ts`, `commands/validate.ts`, `commands/serve.ts`
(config-boot mirror); the gate-parity and call-site ledgers in
`test/validate-build-gate-parity.test.ts` and
`test/normalized-call-sites.test.ts` classify both |
| Docs | `content/docs/data-modeling/seed-data.mdx` (new section "File
Fields — `seedFile(path)`") |
| Generated / ledgers | spec authorable surface, JSON-schema manifest,
api-surface, export origins, declaration map, references docs (new
`data/seed-file` page), skill references, strictness-ledger counts plus
a hand-written triage row for `seed-file.zod.ts` (both sites strict from
birth), `dropped-refinements.baseline.json` (the record-value
refinement, by site), liveness `seed.json` + counts |
**Governed path.** The diff touches one governed path,
`skills/objectstack-data/references/_index.md`. It is a single generated
line from `gen:skill-refs` (the new `data/seed-file.zod.ts` reference),
and `check:skill-refs` regenerates it byte-exact. The two `skills/**`
readings, in lines:
- The changed file: 73 before, 74 after.
- The whole package (every `SKILL.md` summed): 4383 before, 4383 after.
No `SKILL.md` changes.
**Overlap with #25801 (PR #26134).** That PR opened first, and the two
diffs share no file. This PR's only edit near #25801's area is
`packages/services/service-storage/src/file-reference-lifecycle.ts`,
where it adds the `export` keyword to two existing functions and changes
no logic.
## Tests
- `packages/spec/src/data/seed-file.zod.test.ts`: the path rule's accept
and refuse tables, the reference shape, `SeedSchema` with refs and
bytes, the malformed-reference path, and `defineSeed`'s refusal.
- `packages/spec/src/contracts/storage-service.test.ts`: the
`field-files` slot and a fake round trip.
- `packages/services/service-storage/src/field-file-service.test.ts`:
real ObjectQL on sqlite. It covers:
- store before `kernel:ready`;
- the 413 size refusal;
- validation;
- organization-scoped describe under the `isolated` posture;
- claim, idempotence and release;
- 409 and 404;
- agreement with the reference hooks;
- a `multiple` field.
- `packages/metadata-protocol/src/seed-loader-seed-files.test.ts`. It
covers:
- first load, replay keeping the file, and changed bytes storing a new
file;
- `multiple` fields, a file per row, and the self-reference path;
- every by-row refusal: missing bytes, a SHA mismatch, no service, a
mixed field, several references in a one-file field, shape 3 (including
per organization), and a claim failure;
- dry run, and an object with no file fields.
- A refused row stores nothing.
- `packages/cli/src/utils/bundle-seed-files.test.ts`: bundling, package
bodies, the gate writing nothing, a declared type winning over the
extension, and refusals (missing file, unknown type, a path that climbs
out).
- `packages/cli/test/build-seed-files-artifact.e2e.test.ts` (nightly
tier): the real `os build` writes the bytes beside the reference,
`loadArtifactBundle` reads them back, and both `os build` and `os
validate` refuse a path that names no file.
- `packages/cli/test/serve-config-boot-seed-files.e2e.test.ts` (nightly
tier) is **the card's acceptance, end to end**. `os serve --dev` boots a
project whose `clm_version.file` is required and seeds two versions from
one fixture. It then checks:
- each row holds a file of its own;
- following `GET /storage/files/:fileId` returns the fixture's bytes, so
the file is stored, committed and owned.
- Negative control: with the `serve.ts` mirror removed, the same boot
seeds 0 rows.
## Local verification
The head is `2d0dd7dd`, which merges cleanly with current `main`. It
differs from `b8fe94f8` only by the changeset's two declaration lines,
with no code change.
- **Derived gates, on `b8fe94f8`.**
- `dispatch-gates --commands` derives 140 commands for this diff, and
139 of them exit 0.
- `check:dual-build-cjs-loads` exits 3 because it needs a full workspace
build, so it is left to CI.
- `dispatch-gates --ran` reconciles all 140, with 0 unrun.
- The four changeset gates ran again on `2d0dd7dd` and exit 0:
`check-adr-0087-registration --base origin/main`,
`check-changeset-no-major --base origin/main`, `check-empty-changeset
--base origin/main` and `check:changeset-gate-self-tests`.
- **Dogfood, on `b8fe94f8`** (`packages/qa/dogfood`): 296 files passed
and 3 skipped; 2523 tests passed and 18 skipped.
- **Full package suites, on the final code:**
- `@objectstack/metadata-protocol`: 267 files; 21554 tests passed and 19
skipped.
- `@objectstack/service-storage`: 66 files; 1206 tests passed.
- `@objectstack/runtime`: 457 files; 8352 tests passed and 19 skipped.
- `@objectstack/cli`: 506 files, 0 failed tests.
- 491 files ran on `a7fc798d`. The commits after it touch no CLI file,
and their only source changes are type-level: an annotation in the
storage plugin and type aliases in `seed-file.zod.ts`.
- The 15 files that need a built CLI ran on `b8fe94f8`.
- **Rerun directly on the final code:**
- the two nightly-tier suites, with `OS_TEST_TIERS=nightly`: 3 tests
pass;
- the CLI unit suites for the gate and its two ledgers: 46 tests pass;
- the spec suites (the alias pin at 790, `seed-file.zod`, the storage
contract): 51 tests pass.
The full gate list with exit codes is in the dev report on #25779.
## Acceptance notes
- **The declaration lines differ from what was measured.** The two lines
at the top of this body, and the same two in
`.changeset/25779-seed-files.md`, are copied verbatim from the Claim.
- Measured, the change narrows: shape 3 refuses a seed value that used
to be written.
- The rule in `pr-automation.yml` says to name the kind highest in
breaking-ness. By that rule the lines should read `Clause-②: yes
(narrowing)` and `Metadata-protocol: narrowed`.
- Both kinds grade `minor`, so the release level does not change.
- The changeset carries the **BREAKING** banner, the ADR-0087
disposition marker (`not-required (no-migration-prescription)`) and a
"What to do" line either way.
- The correction: amend the Claim, then change both lines here and in
the changeset.
- **Scope beyond the Claim's file surface.** Each item was needed for
the card's acceptance:
- `os validate` and the `os serve` config-boot mirror;
- the CLI's gate-parity and call-site ledgers;
- the `packages/runtime` callers that hand the loader the `field-files`
service;
- the contract in `packages/spec/src/contracts/storage-service.ts`.
- **The landing point** is the metadata-protocol loader, as the Claim's
premise measured. `packages/runtime/src/seed-loader.ts` is not touched.
- **Out-of-scope findings:** none.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_012eJQ8dtut5bZsnhFE3XGHF
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e931621 commit 2163e80
55 files changed
Lines changed: 3362 additions & 40 deletions
File tree
- .changeset
- content/docs
- data-modeling
- getting-started
- references
- api
- data
- kernel
- docs/audits
- 2026-07-unknown-key-strictness-ledger.counts
- packages
- cli
- src
- commands
- utils
- test
- metadata-protocol/src
- runtime/src
- domains
- services/service-storage/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- scripts
- src
- contracts
- data
- scripts
- skills/objectstack-data/references
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
440 | 440 | | |
441 | 441 | | |
442 | 442 | | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
443 | 484 | | |
444 | 485 | | |
445 | 486 | | |
| |||
639 | 680 | | |
640 | 681 | | |
641 | 682 | | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
642 | 686 | | |
643 | 687 | | |
644 | 688 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
250 | 250 | | |
251 | 251 | | |
252 | 252 | | |
| 253 | + | |
253 | 254 | | |
254 | 255 | | |
255 | 256 | | |
| |||
358 | 359 | | |
359 | 360 | | |
360 | 361 | | |
| 362 | + | |
361 | 363 | | |
362 | 364 | | |
363 | 365 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
| 39 | + | |
39 | 40 | | |
40 | 41 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
234 | 234 | | |
235 | 235 | | |
236 | 236 | | |
| 237 | + | |
237 | 238 | | |
238 | 239 | | |
239 | 240 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
| |||
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
52 | 64 | | |
53 | 65 | | |
54 | 66 | | |
| |||
0 commit comments