Skip to content

Commit fdb4f50

Browse files
authored
feat(migrate): 平台形态的迁移门禁 —— 带自检的数据迁移 + 部署级标记 (#3617) (#3638)
部署级迁移门禁:`os migrate files-to-references`,以及它背后的 sys_migration 标记。 ADR-0104 D3 wave 2 原来的验收门是一份 SaaS 形态的运维 runbook——"在真实租户上跑回填,连续 7 天零阻断项,然后翻开关"。每个成分都假设我们运营那个租户。但 ObjectStack 是开发平台:第三方部署自己决定何时升级,数据我们看不到,不存在一个我们的观察窗口能让别人的数据在里面等。这样表述的门禁不是严格,而是无处可满足。 R4 的实质不变——一本账在被证明与现实一致之前,不能被授予删除字节的权力。变的是证据由谁产生:从我们一次,变成每个部署为自己产生。 apply 且对账零阻断项才写 sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }。回收(#3459 PR-5b)与 strict 翻转(#3438)读这个标记而非版本号,且共用 spec 里同一个 isDataMigrationFlagVerified 谓词,两个门禁不可能对同一事实产生分歧。没跑或没通过 → 文件继续保留,零数据丢失;后续运行未通过会清空 verified_at,数据漂移的部署自行关门。dry-run 什么都不写,包括标记。 同时修掉一个会让门禁假通过的缺陷:活体 kernel 上读解析器会在扫描看到之前把存储 id 展开,使对账把持有中的引用报告为不存在——漏掉的 unowned_reference 会让门禁误开。读路径现在可经 RAW_FILE_VALUES_CONTEXT_KEY 退出解析。 端到端在 showcase + SQLite 实跑验证:10 个遗留值转换并认领、对账干净、标记落库;dry-run 对同一数据库不留痕迹。
1 parent 67452d1 commit fdb4f50

29 files changed

Lines changed: 1533 additions & 26 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/service-storage": minor
5+
"@objectstack/objectql": minor
6+
"@objectstack/cli": minor
7+
---
8+
9+
feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617)
10+
11+
The ADR-0104 file-as-reference migration ships as a command a deployment runs
12+
against its own database, and the deployment-level flag it records is what may
13+
later authorise irreversible behaviour — never the platform version.
14+
15+
```bash
16+
os migrate files-to-references # dry run: reports, writes nothing
17+
os migrate files-to-references --apply # converts, verifies, records the flag
18+
```
19+
20+
The run backfills legacy file-field values (inline metadata blobs, own-resolver
21+
URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership
22+
ledger against what records actually hold, and — only on an `--apply` run whose
23+
reconciliation reports **zero blocking discrepancies** — records
24+
`sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`.
25+
26+
**Why a flag rather than a release note.** ObjectStack is a development
27+
platform: third-party deployments upgrade on their own schedule and their data
28+
is not observable by anyone else, so no release-side soak can vouch for them.
29+
The evidence has to be produced where the data is. Consequences:
30+
31+
- Installing a new version never starts deleting bytes. Running the migration
32+
and passing its self-check is the consent.
33+
- Not run, or not passed → files are retained forever. Wasted storage, zero
34+
data loss.
35+
- A later failing run **clears** `verified_at`: a deployment whose data has
36+
drifted closes its own gate.
37+
- A dry run writes nothing at all — not the conversions, and not the flag,
38+
even when the self-check would pass.
39+
- External URLs stay advisory. They are not `sys_file`s, so they can never
40+
enter collection; whether to remodel them as a `url` field is the app
41+
author's decision (ADR-0104 R7), not a gate.
42+
43+
Ships alongside:
44+
45+
- `@objectstack/spec``DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`,
46+
and the single `isDataMigrationFlagVerified` predicate both future consumers
47+
(collection #3459, strict value-shape #3438) read, so the two gates cannot
48+
disagree about the same fact.
49+
- `@objectstack/platform-objects` — the `sys_migration` object plus
50+
`readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`.
51+
Reads fail toward "not verified": a gate that cannot read its evidence stays
52+
closed.
53+
- `@objectstack/objectql` — a read may now opt out of file-reference expansion
54+
via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's
55+
bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to
56+
their expanded form before the reconciliation sees them, which reports held
57+
references as absent — noisy `stale_owner` findings, and a missed
58+
`unowned_reference` would have been a false pass of the collection gate.

content/docs/deployment/cli.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,48 @@ first. It never drops a table that is absent from your metadata, and on SQLite
517517
it reconciles via a table rebuild (copy → swap) that preserves your data.
518518
</Callout>
519519

520+
#### Data migrations
521+
522+
The commands above reconcile **schema**. A *data* migration rewrites rows, and
523+
whether it is done is a fact about **your** database, not about the platform
524+
version you installed — so each deployment runs it, and its result is recorded
525+
where the data lives.
526+
527+
| Command | Description |
528+
|---------|-------------|
529+
| `os migrate files-to-references` | Convert legacy file-field values to `sys_file` references, verify the ownership ledger, and record the deployment's migration flag |
530+
531+
```bash
532+
os migrate files-to-references # Dry run: full report, writes nothing
533+
os migrate files-to-references --apply # Convert, verify, record the flag (prompts)
534+
os migrate files-to-references --apply --yes --json # CI / scripts
535+
os migrate files-to-references --object product # Restrict to one object (repeatable)
536+
```
537+
538+
A `file` / `image` / `avatar` / `video` / `audio` field value is an opaque
539+
`sys_file` id that the platform owns. Values written before that (an inline
540+
`{url, name, …}` blob, or a URL naming this platform's own
541+
`…/storage/files/:id` resolver) are converted in place; **external URLs are
542+
reported, never re-hosted** — re-hosting third-party content is a licensing and
543+
privacy decision, and the right fix is usually to model the field as a `url`
544+
field instead.
545+
546+
The run then reconciles what records actually hold against what `sys_file`
547+
records as each file's owner. Zero blocking discrepancies is what records the
548+
flag — and that flag, not the version number, is what later enables features
549+
that act irreversibly on migrated data (reclaiming the bytes of released files;
550+
rejecting rather than warning about legacy media values). Never running it is
551+
safe: files are simply retained forever.
552+
553+
Exit status is `0` only when the self-check passes, so CI can gate on it.
554+
555+
<Callout type="warn">
556+
A dry run writes **nothing** — not the conversions, and not the flag either,
557+
even when the self-check would pass. `--apply` is the only writing mode. A
558+
later run that *fails* its self-check clears the flag's verified state, so a
559+
database that has drifted closes its own gate.
560+
</Callout>
561+
520562
### Scaffolding
521563

522564
| Command | Alias | Description |

content/docs/references/system/migration.mdx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,37 @@ description: Migration protocol schemas
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
77

8+
Migration protocol — the two kinds of migration, kept apart on purpose.
9+
10+
A **schema migration** (`ChangeSet` + its atomic operations) reshapes the
11+
12+
physical database to match metadata: add a field, change a type, create an
13+
14+
object, run SQL. It is derivable from the metadata and applied by
15+
16+
`os migrate plan` / `os migrate apply`.
17+
18+
A **data migration** rewrites the rows themselves, and whether it is done is
19+
20+
a property of ONE DEPLOYMENT's database rather than of the installed code
21+
22+
version — so it cannot be expressed as a ChangeSet, and its completion cannot
23+
24+
be inferred from a release. `DataMigrationFlag` is the per-deployment record
25+
26+
that one ran here and its self-check passed; consumers that would act
27+
28+
irreversibly on migrated data gate on the flag instead of the version.
29+
830
<Callout type="info">
931
**Source:** `packages/spec/src/system/migration.zod.ts`
1032
</Callout>
1133

1234
## TypeScript Usage
1335

1436
```typescript
15-
import { AddFieldOperation, ChangeSet, CreateObjectOperation, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
16-
import type { AddFieldOperation, ChangeSet, CreateObjectOperation, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
37+
import { AddFieldOperation, ChangeSet, CreateObjectOperation, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
38+
import type { AddFieldOperation, ChangeSet, CreateObjectOperation, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
1739

1840
// Validate data
1941
const result = AddFieldOperation.parse(data);
@@ -69,6 +91,25 @@ Create a new object
6991
| **object** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` || Full object definition to create |
7092

7193

94+
---
95+
96+
## DataMigrationFlag
97+
98+
Deployment-level record that a data migration ran here and its self-check passed — the evidence gate consumers read instead of the platform version
99+
100+
### Properties
101+
102+
| Property | Type | Required | Description |
103+
| :--- | :--- | :--- | :--- |
104+
| **id** | `string` || Migration id (e.g. adr-0104-file-references) — one row per data migration |
105+
| **last_run_at** | `string` || When this migration last completed a gated (apply-mode) run on this deployment |
106+
| **verified_at** | `string \| null` | optional | When the self-check last PASSED. Null/absent until it does — and cleared again by a later failing run, so a regression closes the gate |
107+
| **applied_at** | `string \| null` | optional | When the backfill last ran in apply mode (writes enabled) |
108+
| **blocking** | `integer` || Blocking discrepancies reported by the last self-check. The gate requires 0 |
109+
| **advisory** | `integer` | optional | Advisory findings from the last run (external URLs, stale owners, …) — cost storage or need a modelling decision, never block the gate |
110+
| **details** | `string` | optional | JSON-encoded counts from the last run, for diagnostics |
111+
112+
72113
---
73114

74115
## DeleteObjectOperation

docs/adr/0104-field-runtime-value-shape-contract.md

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -591,10 +591,13 @@ legacy values would reject any update that rewrites such a field, breaking
591591
working apps until the backfill catches up. Backfilling first leaves the cutover
592592
nothing legacy to reject.
593593

594-
R4's acceptance gate is now executable rather than aspirational: step 6 may not
595-
merge until step 5 reports **zero blocking discrepancies for ≥7 consecutive
596-
days** on real tenant data. R5 (public-posture inventory) and R6 (sub-key read
597-
scan) stand.
594+
R4's acceptance gate is now executable rather than aspirational: step 5's
595+
reconciliation must report **zero blocking discrepancies** before step 6 may
596+
act. (The original wording — "for ≥7 consecutive days on real tenant data" —
597+
assumed a tenant we operate and observe; see the 2026-07-27 platform-form
598+
addendum below, which relocates the same evidence requirement to the one place
599+
that can satisfy it.) R5 (public-posture inventory) and R6 (sub-key read scan)
600+
stand.
598601

599602
Steps 4 and 6 are the two that can break something. Step 4 is a declared
600603
protocol major and breaks *loudly* — a rejected write, recoverable. Step 6
@@ -604,15 +607,86 @@ adoption that consumes the new form, while step 6 additionally requires the
604607
reconciliation evidence above, and neither the enable nor the migration run
605608
should be performed without an explicit human decision.
606609

610+
## Addendum (2026-07-27, later) — the evidence gate is per deployment, not per release
611+
612+
The gate above was written as an operations runbook: *run the backfill on the
613+
tenant, watch `verifyFileReferences` stay clean for a week, then flip the
614+
switch.* Every clause of that sentence assumes **we** run the tenant, **we**
615+
observe it, and **we** flip.
616+
617+
ObjectStack is a development platform. Third-party developers build metadata
618+
apps on it and ship versions onward; each deployment decides for itself when to
619+
upgrade, and its data is not visible to us — nor should it be. **There is no
620+
observation window of ours that anyone else's data can wait inside.** A gate
621+
phrased as one is not strict, merely unlocatable: it can be satisfied by no
622+
deployment at all, including the deployments whose bytes are at stake.
623+
624+
### What survives, and what moves
625+
626+
R4's substance is unchanged: **a ledger may not be given the power to delete
627+
bytes until it has been shown to agree with reality.** What changes is *who
628+
produces the evidence* — from us, once, to each deployment, for itself.
629+
630+
That rules out a one-shot script (a script whose output nobody must read is not
631+
a gate) and rules in a **data-migration step with a self-check, whose passing
632+
result is recorded on the deployment**:
633+
634+
```
635+
os migrate files-to-references
636+
1. backfill (dry run by default; --apply writes)
637+
2. verifyFileReferences (reconcile the ledger against what records hold)
638+
3. zero blocking findings → record sys_migration { id: 'adr-0104-file-references',
639+
verified_at, blocking: 0 }
640+
4. collection + strict enforcement read THAT ROW, never the version number
641+
```
642+
643+
### The properties this buys
644+
645+
- **Upgrading does not start deleting.** Installing a new version is not
646+
consent; running the migration and passing its self-check is.
647+
- **A third-party developer need not understand R4.** They run one command.
648+
Green means done; red names the record holding a file nobody owns.
649+
- **Not run, or not passed → files live forever.** Wasted storage, zero data
650+
loss: "fail toward retention" holds on the *deployment* axis too. The same
651+
rule applies to a regression — a later failing run clears `verified_at`, so a
652+
deployment whose data has drifted closes its own gate.
653+
- **Each deployment gets its own 30-day tombstone window.** Nobody waits inside
654+
anyone else's soak. The irreversible moment is day 30 after *that* deployment
655+
enabled collection, not release day.
656+
- **A dry run changes nothing** — not the data, and not the flag either, even
657+
when the self-check would pass. `--apply` is the only writing mode, so
658+
"did this run change my posture" never depends on what the run found.
659+
660+
### D1/D2 carry the identical error, and the identical fix
661+
662+
The strict-by-default flip for value-shape and action-param validation (#3438)
663+
was scheduled as "flip in a later minor once telemetry is quiet" — again
664+
*our* telemetry, deciding for *their* deployments. A deployment that has not
665+
finished backfilling would have every media-field update rejected the moment it
666+
upgraded: a working app broken by a decision made on its behalf.
667+
668+
Both therefore read the **same** flag. Strict enforcement becomes effective for
669+
a deployment when that deployment has completed and verified its migration —
670+
not when a version number arrives. One flag, not two gates that can disagree:
671+
they are gating on the same fact.
672+
673+
### What cannot be automated, and does not block
674+
675+
Whether an **external URL** (`https://cdn…`) should become an explicit `url`
676+
field is a modelling decision only the app's author can make (R7). It also
677+
cannot block: an external URL is not a `sys_file` and can never enter
678+
collection. It is reported as advisory, never as a gate failure.
679+
607680
### Why this stays inside ADR-0104 rather than a new ADR
608681

609682
D3 is already this ADR's third phase; the two-wave split and the
610683
enforcement-point principle are a **refinement of the D4 rollout**, not a new
611684
decision, so they live here. Wave 2's migration mechanics (dual-read window,
612685
`os migrate` backfill, the R4/R5/R6 gates) are specified in §D3 above and need
613-
no separate record. Wave 2's implementation did surface one genuinely new
614-
decision — the exclusive-ownership model — and it is recorded as the 2026-07-27
615-
addendum above rather than a separate ADR, because it settles *how* D3's
616-
already-accepted "field values point into `sys_file`" behaves rather than
617-
revisiting whether to do it. A future choice that stands on its own (say, a
618-
chunked-migration protocol, or byte-layer content dedup) would earn its own ADR.
686+
no separate record. Wave 2's implementation did surface two genuinely new
687+
decisions — the exclusive-ownership model, and relocating the evidence gate to
688+
the deployment — and both are recorded as 2026-07-27 addenda above rather than
689+
separate ADRs, because each settles *how* D3's already-accepted "field values
690+
point into `sys_file`" reaches production rather than revisiting whether to do
691+
it. A future choice that stands on its own (say, a chunked-migration protocol,
692+
or byte-layer content dedup) would earn its own ADR.

0 commit comments

Comments
 (0)