fix: preserve independent persistence type graphs

This commit is contained in:
yudshj
2026-09-25 01:09:44 +08:00
parent cc3c631699
commit cf6276e78d
15 changed files with 81 additions and 35 deletions
@@ -15,8 +15,8 @@
en: 5fca19ac2bb1ddac
zh: 00d78fe7e80ba4f7
/cookbook-reviewing-persistence-type-changes/optional-inspect-the-change:
en: 39443a54ee68b24a
zh: cdece79db4977116
en: 3f85edb803afe870
zh: 545c651b9e5ccb56
/cookbook-reviewing-persistence-type-changes/1-record-the-change:
en: 342bd49412db070e
zh: a8d14958e9a637d9
@@ -41,7 +41,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types.
The current machine inventory stores complete graphs in `roots`; each `types` entry contains only `digest`, `names`, and `sources`. Readers reconstruct type graphs from root subgraphs by digest and also accept historical entries with an explicit `schema`. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement.
The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement.
<a id="acknowledge"></a>
## 1. Record the change
@@ -41,7 +41,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。
当前机器清单在 `roots` 中保存完整图;每个 `types` 条目只包含 `digest`、`names` 和 `sources`。读取器按摘要从根的子图重建类型图,同时接受带有显式 `schema` 的历史条目。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。
当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。
<a id="acknowledge"></a>
## 1. 记录变更
+5 -2
View File
@@ -1260,8 +1260,11 @@
en: a0d0018c68abc564
zh: 299d86dbba56b528
/session-persistence-event-catalog/resolved-persistence-types/boolean:
en: b121d42640099ec1
zh: b121d42640099ec1
en: 40616917f0b8f9cc
zh: 40616917f0b8f9cc
/session-persistence-event-catalog/resolved-persistence-types/false:
en: 0e3a8e3c2c62bdd7
zh: 0e3a8e3c2c62bdd7
/session-persistence-event-catalog/resolved-persistence-types/never~2:
en: a35333b49c104e92
zh: a35333b49c104e92
+8
View File
@@ -5176,6 +5176,14 @@ SHA-256: `aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877`
`boolean`
<a id="persistence-type-sha256-0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3"></a>
### `false`
SHA-256: `0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3`
`false`
<a id="persistence-type-sha256-fcf427290c6213c2a7feb57492d832ce324f5b9737a759f424dab69e6bd101a0"></a>
### `never`
+8
View File
@@ -5178,6 +5178,14 @@ SHA-256: `aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877`
`boolean`
<a id="persistence-type-sha256-0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3"></a>
### `false`
SHA-256: `0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3`
`false`
<a id="persistence-type-sha256-fcf427290c6213c2a7feb57492d832ce324f5b9737a759f424dab69e6bd101a0"></a>
### `never`
@@ -15,8 +15,8 @@
en: 14c57f30e676f6e3
zh: b1a0ccf26fab3727
/session-persistence-formats/format-references:
en: 528c6482cbafbc3e
zh: 6215d623ca04df65
en: 45dba01dd6d69c80
zh: c366d1a3991118c9
/session-persistence-formats/scope-and-evidence:
en: b220062139ddac1e
zh: 7c4c0efe05dafa9c
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 586 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 |
<!-- persistence-format-index:end -->
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 586 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 |
<!-- persistence-format-index:end -->
+14
View File
@@ -21808,6 +21808,20 @@
"names": [],
"sources": []
},
{
"digest": "0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3",
"schema": {
"root": 0,
"nodes": [
{
"kind": "literal",
"value": false
}
]
},
"names": [],
"sources": []
},
{
"digest": "0d47a8f3d847243c4488e755ad4cab7a04ac5d4621b9c81865524cf3f4d8536f",
"names": [],
+1 -1
View File
@@ -109,7 +109,7 @@ function validateInventory(inventory: PersistenceSchemaInventory, version: numbe
for (const type of inventory.types) {
if (listed.has(type.digest)) throw new Error(`${label}: duplicate schema type ${type.digest}`)
listed.add(type.digest)
// Legacy current inventories can retain types erased by normalization; the generator gate checks current output.
// The current extractor can retain types erased by normalization; its generator gate checks that inventory.
if (!current && !reachable.has(type.digest)) throw new Error(`${label}: unreferenced schema type ${type.digest}`)
remaining.delete(type.digest)
}
+15 -6
View File
@@ -4,6 +4,7 @@
*/
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
/** Recorded reader promise for explicitly attributed source additions. */
export interface SourceCompatibility {
@@ -71,8 +72,10 @@ export interface PersistenceSchemaInventory {
readonly types: readonly PersistenceType[]
}
/** On-disk type metadata; complete graphs are stored only in roots. */
export type PersistenceTypeIndex = Omit<PersistenceType, 'schema'>
/** Type metadata with an explicit graph when roots cannot reconstruct it exactly. */
export interface PersistenceTypeIndex extends Omit<PersistenceType, 'schema'> {
readonly schema?: CanonicalSchema
}
/** Compact inventory representation; formatVersion still pins normalization. */
export interface PersistenceSchemaSnapshot {
@@ -98,15 +101,21 @@ export function reachableSchemaTypes(schemas: readonly CanonicalSchema[]): Map<s
}
/**
* Omit redundant type graphs from a current-source inventory for serialization.
* @param inventory - complete inventory whose types are reachable from its roots.
* @returns root graphs and their type metadata, preserving inventory order.
* Omit type graphs exactly reconstructable from roots, retaining all other graphs.
* @param inventory - expanded inventory, including types erased from roots by normalization.
* @returns a lossless snapshot preserving root graphs, type order, and declaration metadata.
*/
export function persistenceSchemaSnapshot(inventory: PersistenceSchemaInventory): PersistenceSchemaSnapshot {
const reachable = reachableSchemaTypes(inventory.roots.map(root => root.schema))
return {
formatVersion: inventory.formatVersion,
roots: inventory.roots,
types: inventory.types.map(({ digest, names, sources }) => ({ digest, names, sources })),
types: inventory.types.map(({ digest, schema, names, sources }) => ({
digest,
...(isDeepStrictEqual(schema, reachable.get(digest)) ? {} : { schema }),
names,
sources,
})),
}
}
@@ -40,6 +40,25 @@ describe('compact persistence snapshots', () => {
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
})
it.each([true, false])('retains independent recursive types with root graphs=%s', (withRoots) => {
const base = inventory()
const schema = canonicalizeSchema([
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'flag', type: 1, optional: false }], indices: [] },
{ kind: 'literal', value: false },
], 0)
const independent = { digest: schemaDigest(schema), schema, names: ['example.ts#Independent', 'example.ts#Alias'], sources: ['example.ts'] }
const original = { ...base, roots: withRoots ? base.roots : [], types: [independent, ...base.types] }
const snapshot = persistenceSchemaSnapshot(original)
expect(snapshot.types[0]).toEqual(independent)
if (withRoots) expect(snapshot.types.slice(1).every(type => type.schema === undefined)).toBe(true)
else expect(snapshot.types).toEqual(original.types)
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
expect(restored).toEqual(original)
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
expect(persistenceSchemaSnapshot(restored)).toEqual(snapshot)
expect(renderPersistenceSchemaDefinitions(restored)).toBe(renderPersistenceSchemaDefinitions(original))
})
it('reads legacy full graphs and keeps partial acknowledgement inventories empty', () => {
const original = inventory()
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(original)))).toEqual(original)
-11
View File
@@ -56,17 +56,6 @@ function event(inventory: PersistenceSchemaInventory, name = 'test/record'): str
}
describe('persistent source type extraction', () => {
it('indexes normalized reachable types without erased boolean literals', () => {
const root = fixture('export interface Payload { enabled: boolean; next?: Payload }')
const inventory = extractPersistenceSchema(root)
const reachable = new Set(inventory.roots.flatMap(root => root.schema.nodes
.map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index)))))
expect(new Set(inventory.types.map(type => type.digest))).toEqual(reachable)
expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'primitive' && type.schema.nodes[0].type === 'boolean')).toBe(true)
expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'literal' && type.schema.nodes[0].value === false)).toBe(false)
expect(inventory.types.some(type => type.names.some(name => name.endsWith('#Payload')))).toBe(true)
})
it('keeps authored names and anonymous declaration locations without synthesized traversal names', () => {
const root = fixture('interface Section { text: string }\nexport interface Payload { inserted: { source: { kind: "hooks-codex"; sections: Section[] } }[] }')
const inventory = extractPersistenceSchema(root)
+3 -7
View File
@@ -9,7 +9,6 @@ import { validSourceCompatibility } from './persistence-source-policy.ts'
import {
canonicalizeSchema,
schemaChildren,
reachableSchemaTypes,
schemaDigest,
schemaHasCompatibility,
type PersistenceRoot,
@@ -437,18 +436,15 @@ class SchemaExtractor {
for (const child of schemaChildren(node)) visit(child)
}
for (const input of inputs) visit(input.node)
const types = new Map<string, { schema: PersistenceType['schema']; names: Set<string>; sources: Set<string> }>(
[...reachableSchemaTypes(roots.map(root => root.schema))].map(([digest, schema]) =>
[digest, { schema, names: new Set<string>(), sources: new Set<string>() }]),
)
const types = new Map<string, { schema: PersistenceType['schema']; names: Set<string>; sources: Set<string> }>()
for (const id of found) {
const schema = canonicalizeSchema(this.nodes, id)
const digest = schemaDigest(schema)
const item = types.get(digest)
if (item === undefined) continue
const item = types.get(digest) ?? { schema, names: new Set<string>(), sources: new Set<string>() }
const declarationMetadata = this.declarationMetadata.get(id)
for (const name of declarationMetadata?.names ?? []) item.names.add(name)
for (const source of declarationMetadata?.sources ?? []) item.sources.add(source)
types.set(digest, item)
}
return {
formatVersion: roots.some(root => schemaHasCompatibility(root.schema)) ? 2 : 1,