Skip to content

Commit 6169615

Browse files
authored
feat(objectql)!: 媒体值形态 strict 按部署生效 —— #3438 中标记真正覆盖的那一半 (#3681)
媒体值形态的 strict 判定改为按部署生效:跑过 `os migrate files-to-references --apply` 且自检通过的部署,畸形 media 值从告警变为 `invalid_type` 拒绝;没迁移的部署完全不受影响。 证据要对得上。这里会被拒的遗留形态——inline `{url, name, …}` blob、裸 URL——正是那个迁移负责转换的东西,所以跑过并通过的部署已被证明没有残留。跟着版本号全体翻转的话,未迁移的部署会在升级瞬间让每个 media 字段的 update 开始失败。 只做媒体这一半是有意的。#3438 把三件事打包成一次翻转,而标记只覆盖其中一件:它断言「本部署的文件字段值已迁移并对过账」,对 lookup 的 id、location 的载荷是否规范什么都没说,对 D2 动作参数更无关系。把它们挂上去就是拿一个事实给另一个不相干的事实背书——和 #3617 修掉的读解析器缺陷同形:让一个权威去回答没有问它的问题。引用类与结构化 JSON 保持 warn-first,待各自的证据来源确定(已在 #3438 留言拆分)。 OS_DATA_VALUE_SHAPE_STRICT_ENABLED 语义未变,无需改名或兼容层。新增逃生阀 OS_ALLOW_LAX_MEDIA_VALUES;两者同时设置时宽松一方胜出——没人看的告警,代价小于停止写入的应用。 代价:事实在数据库里而校验器是同步逐次写入的,所以 engine 读一次并 memoize,且除非对象真的声明了 media 字段否则连这一次都不读(沿用 file-reference-lifecycle 已写明的休眠原则)。所有「不知道」的情形一律答「未验证」,读不到证据的部署继续写入而非开始拒绝。 端到端在同一个 SQLite 库上验证:同一次写入,迁移前放行 → 迁移后 invalid_type 拒绝 → 加逃生阀再次放行。
1 parent f2b8ac9 commit 6169615

8 files changed

Lines changed: 504 additions & 23 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql)!: media value shapes enforce once THIS deployment has verified its file migration (#3438 D1 media half, gated by #3617)
6+
7+
A `file` / `image` / `avatar` / `video` / `audio` value that does not match the
8+
stored contract (an opaque `sys_file` id) now **rejects with `invalid_type`**
9+
instead of warning — but only on a deployment that has run
10+
`os migrate files-to-references --apply` and passed its self-check.
11+
12+
**Why this is not a version-wide flip.** The legacy media values this rejects —
13+
inline `{url, name, …}` blobs, bare URLs — are exactly what that migration
14+
converts. A deployment that has run it has been *shown* to hold none; a
15+
deployment that has not would have every media-field update start failing the
16+
moment it upgraded. So the enforcement follows the evidence, per deployment,
17+
rather than the release. Nothing changes for a deployment until it migrates.
18+
19+
**Upgrading:**
20+
21+
```bash
22+
os migrate files-to-references # dry run: reports what would convert
23+
os migrate files-to-references --apply # convert, verify, record the flag
24+
```
25+
26+
If a write starts failing after you migrate, the value genuinely does not match
27+
the contract — the error names the field. `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens
28+
media leniency while you diagnose.
29+
30+
**Scope — deliberately only media.** `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` is
31+
unchanged and still opts every class into strict (and still forces media strict
32+
on a deployment that has not migrated). Reference types (`lookup`, `user`, …)
33+
and structured JSON (`location`, `address`, `repeater`, …) stay warn-first: the
34+
file migration is evidence about file values and says nothing about whether a
35+
`location` is well formed, so gating them on its flag would be borrowing
36+
evidence for a fact it does not cover. They flip when something can vouch for
37+
them — see #3438.
38+
39+
**Cost.** Dormant unless the written object declares a media field, and the
40+
flag read is memoized, so this is one query per process for apps that store
41+
files and zero for those that do not. A running server picks up a
42+
newly-recorded migration on restart, or via `engine.invalidateDataMigrationFlags()`.

content/docs/deployment/cli.mdx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,18 +545,31 @@ field instead.
545545

546546
The run then reconciles what records actually hold against what `sys_file`
547547
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.
548+
flag — and that flag, not the version number, is what enables behaviour that
549+
depends on the data actually being migrated. Never running it is safe: files
550+
are simply retained forever, and media values keep warning instead of failing.
552551

553552
Exit status is `0` only when the self-check passes, so CI can gate on it.
554553

554+
#### What the flag turns on
555+
556+
| Once verified | Effect |
557+
| :--- | :--- |
558+
| **Media value shapes** | A malformed `file` / `image` / `avatar` / `video` / `audio` value is **rejected** (`400 invalid_type`) instead of warned about. Set `OS_ALLOW_LAX_MEDIA_VALUES=1` to re-open leniency while diagnosing. |
559+
| **Released-file collection** | Not yet shipped — the flag is the gate it will read. |
560+
561+
Other value classes are unaffected: a `lookup` or `location` value keeps its own
562+
warn-first rollout, because this migration is evidence about *file* values and
563+
says nothing about theirs.
564+
555565
<Callout type="warn">
556566
A dry run writes **nothing** — not the conversions, and not the flag either,
557567
even when the self-check would pass. `--apply` is the only writing mode. A
558568
later run that *fails* its self-check clears the flag's verified state, so a
559569
database that has drifted closes its own gate.
570+
571+
A running server reads the flag once; after migrating, **restart it** for
572+
enforcement to take effect.
560573
</Callout>
561574

562575
### Scaffolding

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,12 @@ Therefore the target end-state is **two enforcement points, each with one job**:
419419
- **Build/validate time → hard reject.** Net-new metadata (the AI's output)
420420
must fail loudly at authoring. This is the primary defence against AI error.
421421
- **Runtime → warn-first, then flip.** Deployed data keeps working through the
422-
warn window; the flip to strict-by-default (tracked in #3438) closes it once
423-
telemetry is quiet.
422+
warn window; the flip to strict-by-default (tracked in #3438) closes it.
423+
*(Amended by the second 2026-07-27 addendum: "once telemetry is quiet" was
424+
our telemetry deciding for their deployments. The **media** half now flips
425+
per deployment, when that deployment's own file-as-reference migration has
426+
verified. The reference and structured-JSON halves still await an evidence
427+
source of their own — the file migration does not vouch for them.)*
424428

425429
This refines D2 (today runtime-only) and reshapes the #3438 flip: the priority
426430
is adding the **build-time** rejection for value shapes and action params, not
@@ -670,6 +674,28 @@ a deployment when that deployment has completed and verified its migration —
670674
not when a version number arrives. One flag, not two gates that can disagree:
671675
they are gating on the same fact.
672676

677+
**Amendment while implementing this (2026-07-27, later still).** "#3438 reads
678+
the flag" was too broad: #3438 is three things, and the flag only covers one.
679+
The flag asserts *this deployment's file-field values have been migrated and
680+
reconciled*. That is evidence about **media** value shapes and nothing else —
681+
it says nothing about whether a `lookup` id or a `location` payload is well
682+
formed, and nothing at all about D2's action parameters. Gating those on it
683+
would be borrowing evidence for a fact it does not cover, which is the same
684+
error one layer down: an authority answering a question it was not asked.
685+
686+
So the split is:
687+
688+
| | evidence | status |
689+
|---|---|---|
690+
| D1 media (`file`/`image`/`avatar`/`video`/`audio`) | the `adr-0104-file-references` flag | flips per deployment |
691+
| D1 references + structured JSON | none yet — needs its own | stays warn-first |
692+
| D2 action params | unrelated to any data migration | stays warn-first |
693+
694+
The last two are not blocked on the mechanism — it exists and works. They are
695+
blocked on someone deciding what would constitute evidence that a deployment's
696+
`location` values are sound, which is a different question from the one this
697+
addendum answers.
698+
673699
### What cannot be automated, and does not block
674700

675701
Whether an **external URL** (`https://cdn…`) should become an explicit `url`

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ export default class MigrateFilesToReferences extends Command {
253253
if (apply) {
254254
printSuccess(
255255
'Self-check passed — deployment flag recorded (adr-0104-file-references). ' +
256-
'This deployment is now eligible for strict media enforcement and, once shipped, file collection.',
256+
'Media value shapes are now ENFORCED on this deployment: a malformed ' +
257+
'file/image value is rejected rather than warned about. ' +
258+
'(Set OS_ALLOW_LAX_MEDIA_VALUES=1 to re-open leniency while diagnosing.)',
257259
);
258260
} else if (result.backfill.converted > 0) {
259261
printInfo(

packages/objectql/src/engine.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,3 +2075,132 @@ describe('ObjectQL Engine', () => {
20752075
});
20762076
});
20772077
});
2078+
2079+
/**
2080+
* #3617 / #3438 — the engine carries the deployment's own migration evidence
2081+
* into the (synchronous, per-write) value-shape validator.
2082+
*
2083+
* The fact lives in `sys_migration`, so it needs a database read; the write
2084+
* path needs it on every insert/update. These prove the read happens once,
2085+
* and that every way of NOT knowing lands on lenient — a deployment whose
2086+
* evidence cannot be read keeps writing rather than starting to reject.
2087+
*/
2088+
describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
2089+
let engine: ObjectQL;
2090+
let driver: IDataDriver;
2091+
2092+
const verifiedRow = {
2093+
id: 'adr-0104-file-references',
2094+
last_run_at: '2026-07-27T00:00:00.000Z',
2095+
verified_at: '2026-07-27T00:00:00.000Z',
2096+
blocking: 0,
2097+
};
2098+
2099+
beforeEach(() => {
2100+
vi.clearAllMocks();
2101+
driver = {
2102+
name: 'default-driver',
2103+
connect: vi.fn().mockResolvedValue(undefined),
2104+
disconnect: vi.fn().mockResolvedValue(undefined),
2105+
find: vi.fn().mockResolvedValue([]),
2106+
findOne: vi.fn(),
2107+
create: vi.fn().mockResolvedValue({ id: '1' }),
2108+
update: vi.fn().mockResolvedValue({ id: '1' }),
2109+
delete: vi.fn(),
2110+
count: vi.fn(),
2111+
capabilities: {} as any,
2112+
} as unknown as IDataDriver;
2113+
engine = new ObjectQL();
2114+
engine.registerDriver(driver, true);
2115+
});
2116+
2117+
/** `doc` holds a legacy inline blob — exactly what the migration converts. */
2118+
const withMediaObject = () => {
2119+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) => {
2120+
if (name === 'note') return { name: 'note', fields: { doc: { type: 'file' } } } as any;
2121+
if (name === 'sys_migration') return { name: 'sys_migration', fields: { id: { type: 'text' } } } as any;
2122+
return undefined;
2123+
});
2124+
};
2125+
const legacyBlob = { doc: { url: 'https://cdn/f.pdf', name: 'f.pdf' } };
2126+
2127+
it('a verified flag makes a malformed media value reject', async () => {
2128+
withMediaObject();
2129+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2130+
2131+
await expect(engine.insert('note', { ...legacyBlob })).rejects.toThrow(/invalid file value/i);
2132+
});
2133+
2134+
it('no flag row → lenient (the write goes through)', async () => {
2135+
withMediaObject();
2136+
vi.mocked(driver.find).mockResolvedValue([]);
2137+
2138+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2139+
});
2140+
2141+
it('a failed run (verified_at cleared) → lenient', async () => {
2142+
withMediaObject();
2143+
vi.mocked(driver.find).mockResolvedValue([{ ...verifiedRow, verified_at: null, blocking: 3 }] as any);
2144+
2145+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2146+
});
2147+
2148+
it('an unreadable sys_migration → lenient, and the write is not failed by the probe', async () => {
2149+
withMediaObject();
2150+
vi.mocked(driver.find).mockRejectedValue(new Error('no such table: sys_migration'));
2151+
2152+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2153+
});
2154+
2155+
/**
2156+
* Dormancy, mirroring the storage module's own rule: an object with no
2157+
* file-class field can hold no malformed media value, so it must not pay
2158+
* even one query to learn that. Nearly every object is in this case.
2159+
*/
2160+
it('costs no query for an object that declares no media field', async () => {
2161+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) => {
2162+
if (name === 'invoice') return { name: 'invoice', fields: { amount: { type: 'number' } } } as any;
2163+
if (name === 'sys_migration') return { name: 'sys_migration', fields: { id: { type: 'text' } } } as any;
2164+
return undefined;
2165+
});
2166+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2167+
2168+
await expect(engine.insert('invoice', { amount: 10 })).resolves.toBeDefined();
2169+
expect(vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration')).toHaveLength(0);
2170+
});
2171+
2172+
/** No storage service → no sys_migration object → not even a query. */
2173+
it('costs no query on a kernel without the storage objects', async () => {
2174+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) =>
2175+
name === 'note' ? ({ name: 'note', fields: { doc: { type: 'file' } } } as any) : undefined,
2176+
);
2177+
vi.mocked(driver.find).mockResolvedValue([]);
2178+
2179+
await expect(engine.insert('note', { ...legacyBlob })).resolves.toBeDefined();
2180+
expect(driver.find).not.toHaveBeenCalled();
2181+
});
2182+
2183+
it('reads the flag once per process, not once per write', async () => {
2184+
withMediaObject();
2185+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2186+
2187+
await expect(engine.insert('note', { doc: 'file_01' })).resolves.toBeDefined();
2188+
await expect(engine.insert('note', { doc: 'file_02' })).resolves.toBeDefined();
2189+
await expect(engine.insert('note', { doc: 'file_03' })).resolves.toBeDefined();
2190+
2191+
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
2192+
expect(flagReads).toHaveLength(1);
2193+
});
2194+
2195+
it('invalidateDataMigrationFlags forces a re-read', async () => {
2196+
withMediaObject();
2197+
vi.mocked(driver.find).mockResolvedValue([verifiedRow] as any);
2198+
2199+
await engine.insert('note', { doc: 'file_01' });
2200+
engine.invalidateDataMigrationFlags();
2201+
await engine.insert('note', { doc: 'file_02' });
2202+
2203+
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
2204+
expect(flagReads).toHaveLength(2);
2205+
});
2206+
});

0 commit comments

Comments
 (0)