Skip to content

Commit 2163e80

Browse files
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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/25779-seed-files.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/service-storage': minor
5+
'@objectstack/runtime': minor
6+
'@objectstack/cli': minor
7+
---
8+
9+
feat(spec,metadata-protocol,service-storage,runtime,cli)!: a seed record can carry a file the package ships, `seedFile(path)`, and a file field's id that names no stored file is refused
10+
11+
Clause-②: yes (narrowing)
12+
Metadata-protocol: narrowed
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped. The narrowing is a seed load refusing a value it used to write: an id-shaped string in a file field that names no stored file. There is nothing for `objectstack migrate meta` to rewrite and no tombstone; the fix is the author's choice of the file the field should hold. -->
15+
16+
A `file`, `image`, `avatar`, `video` or `audio` field stores the id of a stored file, and only an upload made one, so a seed could not fill a required file field at all. A seed record now names the file by the path the package ships it at:
17+
18+
```ts
19+
import { defineSeed, seedFile } from '@objectstack/spec/data';
20+
21+
defineSeed(ContractVersion, {
22+
externalId: 'code',
23+
records: [
24+
{ code: 'NDA-001-V1', file: seedFile('fixtures/nda-v1.pdf') },
25+
{ code: 'NDA-002-V1', file: seedFile('fixtures/nda-v2', { name: 'NDA v2.pdf', contentType: 'application/pdf' }) },
26+
],
27+
});
28+
```
29+
30+
- **The path** is relative to the directory holding `objectstack.config.ts`, uses `/`, and stays inside it: an absolute path, a drive letter, a `\`, or an empty, `.` or `..` segment is refused at parse. A `multiple: true` file field takes an array of `seedFile(...)` values. `defineSeed` refuses `seedFile(...)` in a field that does not store a file.
31+
- **`os build` / `os compile`** reads each referenced file and writes its bytes into the dataset's new `files` map (`{ contentType, size, sha256, data }`, base64), so the compiled artifact carries them to every boot that loads it: `os dev`, `os serve`, a published package, an artifact URL. A path that names no file, or a file whose type is neither named with `contentType` nor known by its extension, fails the build, and `os validate` refuses the same paths. `os serve` / `os dev` booting a config rather than an artifact reads the files the same way, so both boots seed the same files.
32+
- **The seed loader** stores the bytes through the new `field-files` service (`FIELD_FILE_SERVICE`, `IFieldFileService`, registered by `@objectstack/service-storage`), writes the file's id into the field, and makes the written record the file's owner, the (object, record, field) ownership an upload gets. Each row gets a file of its own. A replay that finds the row holding a file stored from the same path with the same bytes, name and type keeps it, so a restart does not store a new file for an unchanged fixture. A file stays `pending` until its row lands, so a file whose row is refused is reclaimed by the pending-upload retention.
33+
- **Refused, by row, before the row is written**: a reference whose bytes the dataset does not carry, bytes that do not match their recorded size and SHA-256, a load with no `field-files` service, a field mixing `seedFile(...)` with other values, and several `seedFile(...)` values in a one-file field. Each is counted, reported in the load's errors and logged at `error`.
34+
- **The inline boot seed** of an app whose datasets carry seed files runs at `kernel:bootstrapped` (as one with `demoStaff` already does), once the storage service has read its settings, so the files land in the store the deployment serves from.
35+
- `SeedLoaderService` takes an optional fourth argument, `{ getFieldFiles }`; every platform caller passes the kernel's `field-files` service.
36+
37+
**BREAKING** (a seed-load narrowing). A seed record whose file field holds an id-shaped string (letters, digits, `_` and `-`, at most 64 characters) that names no stored file in the row's organization is now refused, by row, with an error naming the field and the value. Before, it was written as given, and the field then read as holding no file. An external URL or an inline value is not an id and is written as before; so is any value on a deployment without `@objectstack/service-storage`.
38+
39+
**What to do.** If a seed load now reports `… holds '<value>', which names no stored file`, replace the value with the file the field should hold, `seedFile('<path in the package>')`, or remove it.

‎content/docs/data-modeling/seed-data.mdx‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,47 @@ duplicates on every replay boot ([#3434](https://github.com/objectstack-ai/objec
440440

441441
---
442442

443+
## File Fields — `seedFile(path)`
444+
445+
A `file`, `image`, `avatar`, `video` or `audio` field stores the id of a stored
446+
file, and a seed has no upload to make one. Name the file by the path your
447+
package ships it at instead, and the platform stores it for the record:
448+
449+
```typescript
450+
import { defineSeed, seedFile } from '@objectstack/spec/data';
451+
452+
export const contractVersionsSeed = defineSeed(ContractVersion, {
453+
externalId: 'code',
454+
records: [
455+
{ code: 'NDA-001-V1', file: seedFile('fixtures/nda-v1.pdf') },
456+
// `name` is what a download is saved as; `contentType` for an extension
457+
// the build does not know.
458+
{ code: 'MSA-001-V1', file: seedFile('fixtures/msa-v1', { name: 'MSA v1.pdf', contentType: 'application/pdf' }) },
459+
// A `multiple: true` file field takes an array.
460+
{ code: 'SOW-001-V1', file: seedFile('fixtures/sow.pdf'), scans: [seedFile('fixtures/p1.png'), seedFile('fixtures/p2.png')] },
461+
],
462+
});
463+
```
464+
465+
- **The path** is relative to the directory holding `objectstack.config.ts` and
466+
stays inside it — `/` separators, no leading `/`, no `.` or `..` segment.
467+
- **`os build`** reads each referenced file and bundles its bytes into the
468+
compiled artifact, so the seed carries them wherever the artifact goes
469+
(`os dev`, `os serve`, a published package). A path that names no file fails
470+
the build, and `os validate` refuses it too. `os serve` booting the config
471+
itself reads the files the same way.
472+
- **The seed load** stores each file and writes its id into the field; the
473+
record owns the file exactly as if it had been uploaded into that field. Every
474+
row gets a file of its own, even when two rows name the same path. A replay
475+
keeps a file whose bytes did not change, so restarting does not store it
476+
again; changing the file and rebuilding replaces it.
477+
- **Refused, by row, loudly**: a reference whose bytes the build did not bundle
478+
(a dataset written by hand without its `files`), bytes that do not match what
479+
the build recorded, and a file field holding an id-shaped string that names no
480+
stored file — write `seedFile(...)` there instead.
481+
482+
---
483+
443484
## Dynamic Values (CEL)
444485

445486
Any field value may be a **CEL expression** evaluated at install time against a
@@ -639,6 +680,9 @@ function defineSeed<
639680
records: Array<SeedRecord<TObj['fields']>>; // declared fields + injectable system columns
640681
}
641682
): Seed
683+
684+
// A file the package ships, for a file-class field (see "File Fields").
685+
function seedFile(path: string, options?: { name?: string; contentType?: string }): { $file: string; name?: string; contentType?: string }
642686
```
643687

644688
`SeedRecord` keys are the object's declared fields (a `lookup` / `master_detail`

‎content/docs/getting-started/quick-reference.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Categories that have no section here at all are named under
2222
[Categories Without a Section](#categories-without-a-section) — that curation is stated,
2323
not left implicit.
2424

25-
## Data Protocol (15 of 30 schemas)
25+
## Data Protocol (15 of 31 schemas)
2626

2727
Core business logic and data modeling schemas.
2828

‎content/docs/references/api/package-api.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope
250250
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
251251
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
252252
| **records** | `Record<string, any>[]` | ✅ | Data records |
253+
| **files** | `Record<string, { contentType: string; size: integer; sha256: string; data: string }>` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. |
253254
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
254255
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
255256
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
@@ -358,6 +359,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope
358359
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
359360
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
360361
| **records** | `Record<string, any>[]` | ✅ | Data records |
362+
| **files** | `Record<string, { contentType: string; size: integer; sha256: string; data: string }>` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. |
361363
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
362364
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
363365
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |

‎content/docs/references/data/index.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Data Protocol — complete schema reference
33
navTitle: Data Protocol
4-
description: "The ObjectStack Data Protocol in 30 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
4+
description: "The ObjectStack Data Protocol in 31 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -37,6 +37,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.
3737
<Card href="/docs/references/data/picklist" title="Picklist" description="Source: packages/spec/src/data/picklist.zod.ts" />
3838
<Card href="/docs/references/data/query" title="Query" description="Source: packages/spec/src/data/query.zod.ts" />
3939
<Card href="/docs/references/data/seed" title="Seed" description="Source: packages/spec/src/data/seed.zod.ts" />
40+
<Card href="/docs/references/data/seed-file" title="Seed File" description="Source: packages/spec/src/data/seed-file.zod.ts" />
4041
<Card href="/docs/references/data/seed-loader" title="Seed Loader" description="Source: packages/spec/src/data/seed-loader.zod.ts" />
4142
<Card href="/docs/references/data/validation" title="Validation" description="Source: packages/spec/src/data/validation.zod.ts" />
4243
</Cards>

‎content/docs/references/data/meta.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"driver-sqlite",
3636
"driver-turso",
3737
"field-value",
38-
"picklist"
38+
"picklist",
39+
"seed-file"
3940
]
4041
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
title: Seed File schema — Data Protocol reference
3+
navTitle: Seed File
4+
description: "A file / image / avatar / video / audio field stores an opaque sys_file id, and only an upload mints one."
5+
---
6+
7+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
8+
9+
## Seed files — a file a package ships, written into a file field by a seed
10+
11+
A `file` / `image` / `avatar` / `video` / `audio` field stores an opaque
12+
`sys_file` id (ADR-0104 D3 wave 2), and only an upload mints one. A seed has
13+
no upload, so a seed record names the file by the PATH the package ships it
14+
at instead, and the platform carries the bytes from there (#25779, ruled on
15+
hotclm#19: "the platform's seed loader should carry bundled fixture files;
16+
the app keeps `file` required"):
17+
18+
1. **Authoring.** A record writes `seedFile('fixtures/nda-v1.pdf')` — the
19+
value `SeedFileReferenceSchema` — into a file-class field. The path
20+
is relative to the package root: the directory holding
21+
`objectstack.config.ts`, the root a declarative connector's file ref
22+
resolves against too.
23+
2. **Bundling.** `os build` / `os compile` reads every referenced file and
24+
writes its bytes into the dataset's `files` map
25+
(`SeedFileContentSchema`), keyed by the same path. The compiled
26+
artifact is what every boot reads — `os dev`, `os serve`, a published
27+
package, an artifact URL — so the bytes travel with the seed wherever it
28+
goes. A path that names no file fails the build.
29+
3. **Loading.** The seed loader stores the bytes as a `sys_file`, writes its
30+
id into the field, and has the written record own it — (object, record,
31+
field), the ownership an upload gets. A replay that finds the row still
32+
holding a file with the same bytes keeps it, so a boot does not mint a
33+
new file for an unchanged fixture. A reference whose bytes the dataset
34+
does not carry is refused, by row, naming the path.
35+
36+
300 rows may name the same path: each row gets a file of its own, because a
37+
file has one owning field (ADR-0104 D3).
38+
39+
<Callout type="info">
40+
**Source:** `packages/spec/src/data/seed-file.zod.ts`
41+
</Callout>
42+
43+
## TypeScript Usage
44+
45+
```typescript
46+
import { SeedFileContentSchema, SeedFilePathSchema, SeedFileReferenceSchema } from '@objectstack/spec/data';
47+
import type { SeedFileContent, SeedFilePath, SeedFileReference } from '@objectstack/spec/data';
48+
49+
// Validate data
50+
const result = SeedFileContentSchema.parse(data);
51+
```
52+
53+
---
54+
55+
## SeedFileContent
56+
57+
One bundled seed file: its bytes and what they must hash to
58+
59+
### Properties
60+
61+
| Property | Type | Required | Description |
62+
| :--- | :--- | :--- | :--- |
63+
| **contentType** | `string` | ✅ | MIME type the stored file carries |
64+
| **size** | `integer` | ✅ | Byte length of the file |
65+
| **sha256** | `string` | ✅ | SHA-256 of the bytes, lowercase hex — the loader refuses bytes that do not match it |
66+
| **data** | `string` | ✅ | The bytes, base64-encoded |
67+
68+
69+
---
70+
71+
## SeedFilePath
72+
73+
Path of a file the package ships, relative to the package root (e.g. `fixtures/nda-v1.pdf`)
74+
75+
**Type:** `string`
76+
77+
78+
---
79+
80+
## SeedFileReference
81+
82+
A file the package ships, by package-relative path, written into a file field by a seed
83+
84+
### Properties
85+
86+
| Property | Type | Required | Description |
87+
| :--- | :--- | :--- | :--- |
88+
| **$file** | `string` | ✅ | Path of a file the package ships, relative to the package root (e.g. `fixtures/nda-v1.pdf`) |
89+
| **name** | `string` | optional | File name the stored file carries (what a download is saved as); defaults to the last segment of the path |
90+
| **contentType** | `string` | optional | MIME type of the file; defaults to the one `os build` infers from the path's extension |
91+
92+
93+
---
94+

‎content/docs/references/data/seed-loader.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ Seed loader request with datasets and configuration
234234
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
235235
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
236236
| **records** | `Record<string, any>[]` | ✅ | Data records |
237+
| **files** | `Record<string, { contentType: string; size: integer; sha256: string; data: string }>` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. |
237238
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
238239
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
239240
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |

‎content/docs/references/data/seed.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const result = SeedSchema.parse(data);
3535
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
3636
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
3737
| **records** | `Record<string, any>[]` | ✅ | Data records |
38+
| **files** | `Record<string, { contentType: string; size: integer; sha256: string; data: string }>` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. |
3839
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
3940
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
4041
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
@@ -49,6 +50,17 @@ const result = SeedSchema.parse(data);
4950
| :--- | :--- | :--- | :--- |
5051
| **applyOn** | `Enum<'create' \| 'always'>` | optional (default: `"always"`) | When a seed load writes this column. `always` (default) writes it on every row the load creates or updates. `create` writes it only when the load creates the row: an `upsert` replay that matches an existing row leaves the row's value as it is, and an `update` dataset never writes it, so replaying the seed refreshes the `always` columns without reverting state the app wrote since the first load. A column of the dataset's `externalId` is still matched on and stays in the matched row's update. The same values and meaning as an import mapping entry's `applyOn`. |
5152

53+
### Nested Shape: `Seed.files[string]`
54+
55+
One bundled seed file: its bytes and what they must hash to
56+
57+
| Property | Type | Required | Description |
58+
| :--- | :--- | :--- | :--- |
59+
| **contentType** | `string` | ✅ | MIME type the stored file carries |
60+
| **size** | `integer` | ✅ | Byte length of the file |
61+
| **sha256** | `string` | ✅ | SHA-256 of the bytes, lowercase hex — the loader refuses bytes that do not match it |
62+
| **data** | `string` | ✅ | The bytes, base64-encoded |
63+
5264

5365
---
5466

0 commit comments

Comments
 (0)