From f9abb7949c240a555bbe95a75b8ed645936f79fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 03:11:33 +0000 Subject: [PATCH 1/5] feat(tests): extend structural fuzzer domain to filtered/merged entities; fix 4 merged-domain write-path fatals - Extract shared fuzz schema/op generators (helpers/fuzzSchema, helpers/fuzzOps, helpers/fuzzRandom) - Add filtered + extended (merged) fuzz modes with membership-only event reconciliation, filtered-predicate oracle, merged-union oracle, pairing-read oracle (#8) - Fix: row-occupancy check must not exclude filtered/merged-abstract records (merged FK link removal physically destroyed the host row, zero events) - Fix: id allocation must use resolvedBaseRecordName (view-name parallel sequences collided with physical ids and silently overwrote existing records) - Fix: combined nested-new create event defaults must be evaluated under the declared name (type-dispatched defaults incl. __type were dropped) - Fix: cascade-track record delete events must be emitted under the physical name - Add deterministic regressions (mergedWritePathRegressions.spec.ts) - EXT-1 family (merged input at x:1/combined endpoint => setup column misplacement) documented, generation domain restricted, full domain behind FUZZ_MERGED_FULL=1 Co-authored-by: Zhenyu Hou --- src/storage/erstorage/CreationExecutor.ts | 27 +- src/storage/erstorage/DeletionExecutor.ts | 18 +- src/storage/erstorage/RecordQueryAgent.ts | 5 +- tests/storage/helpers/eventCompleteness.ts | 22 +- tests/storage/helpers/fuzzOps.ts | 169 ++++++++ tests/storage/helpers/fuzzRandom.ts | 20 + tests/storage/helpers/fuzzSchema.ts | 262 +++++++++++ .../mergedWritePathRegressions.spec.ts | 134 ++++++ tests/storage/writePathStructuralFuzz.spec.ts | 406 ++++++------------ 9 files changed, 776 insertions(+), 287 deletions(-) create mode 100644 tests/storage/helpers/fuzzOps.ts create mode 100644 tests/storage/helpers/fuzzRandom.ts create mode 100644 tests/storage/helpers/fuzzSchema.ts create mode 100644 tests/storage/mergedWritePathRegressions.spec.ts diff --git a/src/storage/erstorage/CreationExecutor.ts b/src/storage/erstorage/CreationExecutor.ts index ed3acaba..d4b46995 100644 --- a/src/storage/erstorage/CreationExecutor.ts +++ b/src/storage/erstorage/CreationExecutor.ts @@ -284,6 +284,21 @@ export class CreationExecutor { } } + /** + * 按**物理身份序列**发号(r29,extended fuzzer seed 41 首跑抓获)。 + * + * CAUTION id 序列属于物理记录(resolvedBaseRecordName),不属于声明名: + * merged (union) 编译把 input 变成物理 base 上的视图、filtered entity/relation 同理—— + * 以视图名发号会开出**平行序列**,与物理表已有 id 碰撞后写路径按「外部 id」语义 + * 直接落列,静默覆写同 id 的既有记录(重复逻辑 id / 字段覆写,零事件)。 + * 顶层 create 的 NewRecordData.recordName 构造期已解析;嵌套分类列表上的 + * attr.recordName / attr.linkName 是**声明名**,所有发号点必须经这里归一。 + */ + private async allocateRecordId(recordName: string): Promise { + const resolved = this.map.getRecordInfo(recordName).resolvedBaseRecordName ?? recordName + return this.database.getAutoId(resolved) + } + /** * 构造同行数据的 update 事件(宿主记录 / combined 嵌套记录 / link 记录共用同一契约): * keys = 本次实际写入的属性名(含被联动重算的 computed 属性),record 带 id,oldRecord 为变更前快照。 @@ -403,7 +418,7 @@ export class CreationExecutor { // 也正是因为如此,所以我们通过一个参数 isUpdate 显式声明到底是不是 update,不能用有没有 id 来判断! if (!isUpdate && !newRawDataWithNewIds.id) { // 为自己分配 id,一定要在最前面,因为后面记录link 事件的地方一定要有 target/source 的 id - newRawDataWithNewIds.id = await this.database.getAutoId(newEntityData.recordName) + newRawDataWithNewIds.id = await this.allocateRecordId(newEntityData.recordName) } else if(isUpdate && !newRawDataWithNewIds.id) { // 因为用户传进来的 update 字段里面可能没有 id 字段,所以这里要加上。 // newRawDataWithNewIds 用在了后面的 event 里面,保证有 id 才正确。外部可能会从 event 里面读。 @@ -475,7 +490,7 @@ export class CreationExecutor { for (let record of newEntityData.combinedNewRecords) { newRawDataWithNewIds[record.info!.attributeName] = { ...newRawDataWithNewIds[record.info!.attributeName], - id: await this.database.getAutoId(record.info!.recordName!), + id: await this.allocateRecordId(record.info!.recordName!), } // CAUTION create 事件 payload 契约 = defaults + payload(r16 R-1)——base 名事件与 // filtered 视图事件是同一契约的两个消费方,统一走 completeEventPayloadWithDefaults @@ -483,8 +498,12 @@ export class CreationExecutor { // 缺席的普通值属性按 NULL 解读(快照完备性契约,r21 F-1)、StateMachine trigger / // Transform eventDeps 深度匹配失明——「谓词/匹配字段仅有默认值」形态下游静默 // 少计/不触发(r25 F-1)。 + // CAUTION defaults 必须按**声明名**(originalRecordName)求值(r29,extended fuzzer): + // merged input 的默认值经 mergeProperties 按具体类型分发(__type 判别列同理), + // 用 resolved 物理名求 defaults 会丢掉 type-dispatch 的全部默认值——事件 payload + // 缺 default-only 字段(行有值、payload 读 NULL)。事件的 recordName 仍是物理名。 const combinedCreatePayload = NewRecordData.completeEventPayloadWithDefaults( - this.map, record.recordName, newRawDataWithNewIds[record.info!.attributeName] + this.map, record.originalRecordName, newRawDataWithNewIds[record.info!.attributeName] ) events?.push({ type: 'create', @@ -519,7 +538,7 @@ export class CreationExecutor { } newRawDataWithNewIds[record.info!.attributeName][LINK_SYMBOL] = { ...(newRawDataWithNewIds[record.info!.attributeName][LINK_SYMBOL] || {}), - id: await this.database.getAutoId(record.info!.linkName!), + id: await this.allocateRecordId(record.info!.linkName!), } const linkRecord = {...newRawDataWithNewIds[record.info!.attributeName][LINK_SYMBOL]} diff --git a/src/storage/erstorage/DeletionExecutor.ts b/src/storage/erstorage/DeletionExecutor.ts index 49268f70..e387f04a 100644 --- a/src/storage/erstorage/DeletionExecutor.ts +++ b/src/storage/erstorage/DeletionExecutor.ts @@ -231,10 +231,14 @@ export class DeletionExecutor { const clearedFieldSet = new Set(fieldsToClear) // 2. 行占用判定:足迹之外的记录身份列仍有值 ⇒ 清列;否则删行。 + // CAUTION 不排除 filtered/merged-abstract 记录(r29,extended fuzzer seed 1 首跑抓获): + // merged (union) 编译后,物理身份列属于 merged-abstract 记录(input 是视图、其 id 字段 + // 解析到 merged base 的列)——把它们排除会让「link 行 = 宿主行」的 merged link 删除 + // 误判行无人占用而 DELETE ROW,宿主实体被物理销毁(零事件)。视图与 base 共享同一 + // id 字段,按字段判定天然去重,无需按记录种类排除。 const hasSameRowData = Object.entries(this.map.data.records).some(([name, recordData]) => { if (name === recordName || recordData.table !== recordInfo.table) return false - if (recordData.isFilteredEntity || recordData.isFilteredRelation || recordData.isMergedAbstract) return false - const idField = (recordData.attributes.id as { field?: string }).field + const idField = (recordData.attributes.id as { field?: string } | undefined)?.field if (!idField || clearedFieldSet.has(idField)) return false return row[idField] !== null && row[idField] !== undefined }) @@ -374,9 +378,17 @@ export class DeletionExecutor { // 事件位置保持在 record 本身的 delete 事件之前。 this.filteredEntityManager.settleDeletionMemberships(deletionSnapshot, recordName, records as Record[], linkAndCascadeEvents, ledgerEvents) + // CAUTION record 本身的 delete 事件必须以**物理名**发出(r29,extended fuzzer seed 37): + // 级联轨(sameTableReliance / handleDeletedRecordReliance)以声明面名字(attr.recordName) + // 递归到这里——对 merged input / filtered 端点,声明名是视图:视图名下按契约只有 + // 成员资格事件(由上方 settle 负责),record 级 delete 归物理 base 名。此前级联轨 + // 按视图名发 record delete:物理名事件整体缺失(监听物理名的计算对删除失明), + // 视图名事件与成员资格 settle 重复(双 delete)。canonical 轨(deleteRecord)的 + // recordName 经 RecordQuery.create 已解析,此处归一让两条轨同一契约。 + const physicalRecordName = this.map.getRecordInfo(recordName).resolvedBaseRecordName ?? recordName const recordDeleteEvents = records.map(record => ({ type: 'delete', - recordName: recordName, + recordName: physicalRecordName, record, }) as RecordMutationEvent) return { linkAndCascadeEvents, recordDeleteEvents } diff --git a/src/storage/erstorage/RecordQueryAgent.ts b/src/storage/erstorage/RecordQueryAgent.ts index dc6a2963..44c3d9a6 100644 --- a/src/storage/erstorage/RecordQueryAgent.ts +++ b/src/storage/erstorage/RecordQueryAgent.ts @@ -446,9 +446,12 @@ export class RecordQueryAgent implements RecordOperationAgent { // Transform eventDeps)此前对 flashOut 产生的 link create "查询可见、事件不可见"。 const stolenRelatedRef = { id: combinedRecordIdRef.getRef().id } const newOwnerRef = { id: newOwnerId ?? newEntityData.getData().id } + // id 序列按物理身份(resolvedBaseRecordName)发号——与 CreationExecutor.allocateRecordId + // 同一契约(r29:视图名平行序列会与物理表既有 id 碰撞并静默覆写)。 + const flashOutLinkName = combinedRecordIdRef.info!.linkName! result[combinedRecordIdRef.info?.attributeName!][LINK_SYMBOL] = { ...(combinedRecordIdRef.linkRecordData?.getData() || {}), - id: await this.database.getAutoId(combinedRecordIdRef.info!.linkName!), + id: await this.database.getAutoId(this.map.getRecordInfo(flashOutLinkName).resolvedBaseRecordName ?? flashOutLinkName), } // CAUTION base link create 事件必须补齐 default-only 字段 // (r25 F-1,与 preprocessSameRowData 的行内产生点同一契约): diff --git a/tests/storage/helpers/eventCompleteness.ts b/tests/storage/helpers/eventCompleteness.ts index 758e5022..53dfb648 100644 --- a/tests/storage/helpers/eventCompleteness.ts +++ b/tests/storage/helpers/eventCompleteness.ts @@ -101,6 +101,14 @@ export function expectEventsToExplainDiff( createPayloadExemptField?: (recordName: string, fieldName: string) => boolean /** relation 名集合:用于第 6 条 delete 端点完备性 */ relations?: string[] + /** + * 只按成员资格对账的记录名(filtered entity/relation 视图名,r29): + * 视图名下只有成员资格 create/delete 事件——字段 update 事件按契约恒以物理 base 名 + * 发出(r18 死监听不变量的事件面),create 事件 payload / 端点契约也归 base 轨对账。 + * 对这些名字只执行第 1/2/4 条(出现/消失必须有事件 + 无幻影事件), + * 跳过第 3/5/6/7 条(字段覆盖、payload 完备、端点完备)。 + */ + membershipOnlyRecords?: Set } ) { const trackedNames = new Set(before.keys()) @@ -108,8 +116,10 @@ export function expectEventsToExplainDiff( const ignoreField = options?.ignoreField const createPayloadExemptField = options?.createPayloadExemptField const relationNames = new Set(options?.relations ?? []) + const membershipOnlyRecords = options?.membershipOnlyRecords for (const recordName of trackedNames) { + const membershipOnly = membershipOnlyRecords?.has(recordName) ?? false const beforeRows = before.get(recordName)! const afterRows = after.get(recordName)! @@ -138,10 +148,11 @@ export function expectEventsToExplainDiff( } // 5. create 事件 payload 完备性:行上全部非 NULL 普通值字段必须出现在 payload 且值一致 // (快照完备性契约——r21 F-1 的本地求值把缺席键解读为 NULL;r25 F-1 的逃逸面)。 + // membership-only 名(filtered 视图)payload 契约归 base 轨对账,此处跳过。 const createEventsById = new Map(relevantEvents .filter(e => e.recordName === recordName && e.type === 'create') .map(e => [String(e.record?.id), e])) - for (const id of createdIds) { + for (const id of membershipOnly ? [] : createdIds) { const createEvent = createEventsById.get(id) if (!createEvent?.record) continue const row = afterRows.get(id)! @@ -172,8 +183,8 @@ export function expectEventsToExplainDiff( } // 6. relation delete 事件端点完备性(r26 F-1):payload 必须带 source.id / target.id, // 且与消失前快照一致。存在性规则(#2)拦不住「有 delete 缺端点」。 - const isRelation = relationNames.has(recordName) - || [...beforeRows.values(), ...afterRows.values()].some(row => 'source' in row || 'target' in row) + const isRelation = !membershipOnly && (relationNames.has(recordName) + || [...beforeRows.values(), ...afterRows.values()].some(row => 'source' in row || 'target' in row)) if (isRelation) { for (const event of deleteEvents) { const id = String(event.record?.id ?? event.oldRecord?.id) @@ -204,8 +215,9 @@ export function expectEventsToExplainDiff( } } } - // 3. 字段变化必须被 update 事件 keys 覆盖 - for (const { id, changedFields } of changed) { + // 3. 字段变化必须被 update 事件 keys 覆盖(membership-only 名跳过: + // 字段 update 事件恒以物理 base 名发出,视图名只有成员资格事件) + for (const { id, changedFields } of membershipOnly ? [] : changed) { const coveredKeys = new Set(updateEvents .filter(e => String(e.record?.id) === id) .flatMap(e => (e.keys as string[] | undefined) ?? [])) diff --git a/tests/storage/helpers/fuzzOps.ts b/tests/storage/helpers/fuzzOps.ts new file mode 100644 index 00000000..e42d49c1 --- /dev/null +++ b/tests/storage/helpers/fuzzOps.ts @@ -0,0 +1,169 @@ +/** + * 写路径生成式测试的共享操作决策器(从 writePathStructuralFuzz 抽取的唯一实现,r29)。 + * + * 决策器只产出「操作意图」(op + 载荷 + 具体 id),执行与判定归各 runner: + * - 结构化 fuzzer:单库执行 + 事件完备性/结构不变量; + * - 驱动差分 fuzzer:主库(SQLite)执行同一意图,经 id 双射翻译后在副库(PGLite)重放, + * 逐操作对账两侧的逻辑状态与事件流。 + * + * CAUTION 决策流契约:同一 (seed, pools 内容) 必须产出同一操作意图——rng 的调用次数 + * 与顺序就是契约本身。pools 的内容/顺序由 runner 保证跨库一致(按创建序)。 + */ +import type { RecordMutationEvent } from '@runtime'; +import { EntityQueryHandle, MatchExp } from '@storage'; +import { chance, int, pick, type Rng } from './fuzzRandom.js'; +import type { FuzzSchema, RelationChoice } from './fuzzSchema.js'; + +export type IdPools = Map + +// 公开 API 把 id 声明为 string(addRelationByNameById(sourceEntityId: string, ...)),HTTP 载荷 +// 携带的 id 也天然是字符串——ref 形态必须同时探索「驱动原生形态」与「字符串形态」两个合法取值 +// (r27 F-3 正是 fuzzer 首跑经字符串化 id 池抓获:SQL 面 1 == '1' 而 JS === 判不等)。 +export function idForPayload(rng: Rng, id: unknown): unknown { + return chance(rng, 0.4) ? String(id) : id +} + +export function genLinkData(rng: Rng, choice: RelationChoice): Record | undefined { + if (!choice.linkProps.length || chance(rng, 0.5)) return undefined + const data: Record = {} + for (const prop of choice.linkProps) { + if (chance(rng, 0.6)) data[prop] = prop === 'weight' ? int(rng, 100) : `n${int(rng, 10)}` + } + return Object.keys(data).length ? data : undefined +} + +/** 递归生成某实体的写载荷;depth 限制嵌套层数。 */ +export function genPayload(rng: Rng, schema: FuzzSchema, entityName: string, pools: IdPools, depth: number): Record { + const payload: Record = {} + for (const prop of schema.valueProps.get(entityName)!) { + if (chance(rng, 0.7)) payload[prop.name] = prop.type === 'string' ? `v${int(rng, 100)}` : int(rng, 100) + } + if (depth <= 0) return payload + + for (const choice of schema.relationChoices) { + const roles: Array<{ attr: string, related: string, isMany: boolean }> = [] + if (choice.source === entityName) { + roles.push({ attr: choice.sourceProperty, related: choice.target, isMany: choice.relType.endsWith('n') }) + } + if (!choice.symmetric && choice.target === entityName) { + roles.push({ attr: choice.targetProperty, related: choice.source, isMany: choice.relType.startsWith('n') }) + } + for (const role of roles) { + if (!chance(rng, 0.35)) continue // 多数属性省略,保持载荷自然 + const genOne = (): Record | null => { + const mode = pick(rng, ['new', 'ref', 'null'] as const) + if (mode === 'null') return null + if (mode === 'ref') { + const pool = pools.get(role.related) ?? [] + if (!pool.length) return genOne0('new') + const item: Record = { id: idForPayload(rng, pick(rng, pool)) } + const link = genLinkData(rng, choice) + if (link) item['&'] = link + return item + } + return genOne0('new') + } + const genOne0 = (mode: 'new'): Record => { + const nested = genPayload(rng, schema, role.related, pools, depth - 1) + const link = genLinkData(rng, choice) + if (link) nested['&'] = link + return nested + } + if (role.isMany) { + const count = 1 + int(rng, 2) + const items: unknown[] = [] + const seen = new Set() + for (let i = 0; i < count; i++) { + const item = genOne() + if (item === null) continue // 数组里不放 null + const id = (item as { id?: string }).id + if (id !== undefined) { + if (seen.has(String(id))) continue // 避免矛盾 `&` 的重复 ref 噪音 + seen.add(String(id)) + } + items.push(item) + } + if (items.length) payload[role.attr] = items + } else { + const value = genOne() + if (value !== undefined) payload[role.attr] = value + } + } + } + return payload +} + +// ---------- 操作意图 ---------- +export type FuzzOpIntent = + | { op: 'create', entityName: string, payload: Record } + | { op: 'update', entityName: string, id: unknown, payload: Record } + | { op: 'delete', entityName: string, id: unknown } + | { op: 'addRelation', relationName: string, sourceId: unknown, targetId: unknown, linkData: Record } + | { op: 'removeRelation', relationName: string, linkId: string } + | null // 前置条件不足(池空等),本步跳过 + +export const OP_MENU: Array<'create' | 'update' | 'delete' | 'addRelation' | 'removeRelation'> = + ['create', 'create', 'create', 'update', 'update', 'delete', 'addRelation', 'removeRelation'] + +/** + * 决策下一步操作。getLinkIds 惰性提供某关系当前的 link id 池(只在 removeRelation 被抽中时 + * 查询一次——与原实现的查询次数一致;跨库一致由 runner 保证顺序为创建序/查询序)。 + * targetableEntityNames:create/update/delete 的目标名池(filtered 模式下含 filtered 名—— + * 写经 filtered 名解析到 base,是「概念寄生位置」轴的写入面取值)。 + */ +export async function decideNextOp( + rng: Rng, + schema: FuzzSchema, + pools: IdPools, + getLinkIds: (relationName: string) => Promise, + targetableEntityNames?: { name: string, poolName: string }[] +): Promise { + const targets = targetableEntityNames ?? schema.entityNames.map(name => ({ name, poolName: name })) + const opKind = pick(rng, OP_MENU) + if (opKind === 'create') { + const target = pick(rng, targets) + const payload = genPayload(rng, schema, target.poolName, pools, 1 + int(rng, 2)) + return { op: 'create', entityName: target.name, payload } + } else if (opKind === 'update') { + const target = pick(rng, targets) + const pool = pools.get(target.poolName)! + if (!pool.length) return null + const id = idForPayload(rng, pick(rng, pool)) + const payload = genPayload(rng, schema, target.poolName, pools, 1 + int(rng, 1)) + return { op: 'update', entityName: target.name, id, payload } + } else if (opKind === 'delete') { + const target = pick(rng, targets) + const pool = pools.get(target.poolName)! + if (!pool.length) return null + const id = idForPayload(rng, pick(rng, pool)) + return { op: 'delete', entityName: target.name, id } + } else if (opKind === 'addRelation') { + const choice = pick(rng, schema.relationChoices) + const sourcePool = pools.get(choice.source)!, targetPool = pools.get(choice.target)! + if (!sourcePool.length || !targetPool.length) return null + const sourceId = idForPayload(rng, pick(rng, sourcePool)), targetId = idForPayload(rng, pick(rng, targetPool)) + if (choice.symmetric && String(sourceId) === String(targetId)) return null + return { op: 'addRelation', relationName: choice.relation.name!, sourceId, targetId, linkData: genLinkData(rng, choice) ?? {} } + } else { + const choice = pick(rng, schema.relationChoices) + const links = await getLinkIds(choice.relation.name!) + if (!links.length) return null + const linkId = String(pick(rng, links)) + return { op: 'removeRelation', relationName: choice.relation.name!, linkId } + } +} + +/** 在一个 EntityQueryHandle 上执行操作意图(id 已是该库的本地形态)。 */ +export async function executeOpIntent(handle: EntityQueryHandle, intent: Exclude, events: RecordMutationEvent[]): Promise { + if (intent.op === 'create') { + await handle.create(intent.entityName, intent.payload, events) + } else if (intent.op === 'update') { + await handle.update(intent.entityName, MatchExp.atom({ key: 'id', value: ['=', intent.id] }), intent.payload, events) + } else if (intent.op === 'delete') { + await handle.delete(intent.entityName, MatchExp.atom({ key: 'id', value: ['=', intent.id] }), events) + } else if (intent.op === 'addRelation') { + await handle.addRelationByNameById(intent.relationName, intent.sourceId as string, intent.targetId as string, intent.linkData, events) + } else { + await handle.removeRelationByName(intent.relationName, MatchExp.atom({ key: 'id', value: ['=', intent.linkId] }), events) + } +} diff --git a/tests/storage/helpers/fuzzRandom.ts b/tests/storage/helpers/fuzzRandom.ts new file mode 100644 index 00000000..414a7e5f --- /dev/null +++ b/tests/storage/helpers/fuzzRandom.ts @@ -0,0 +1,20 @@ +/** + * 生成式测试共用的确定性随机源(mulberry32)与抽样助手。 + * 各 fuzzer(写路径结构化 / 驱动差分 / 计算层 / 迁移)共享同一实现, + * 保证「种子 ⇒ 决策流」跨套件可复现。 + */ +export type Rng = () => number + +export function mulberry32(seed: number): Rng { + let a = seed >>> 0 + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export const pick = (rng: Rng, items: T[]): T => items[Math.floor(rng() * items.length)] +export const chance = (rng: Rng, p: number) => rng() < p +export const int = (rng: Rng, max: number) => Math.floor(rng() * max) diff --git a/tests/storage/helpers/fuzzSchema.ts b/tests/storage/helpers/fuzzSchema.ts new file mode 100644 index 00000000..dc1db264 --- /dev/null +++ b/tests/storage/helpers/fuzzSchema.ts @@ -0,0 +1,262 @@ +/** + * 写路径生成式测试的共享 schema 生成器(从 writePathStructuralFuzz 抽取的唯一实现,r29)。 + * + * - 随机 schema:从关系菜单(1:1 merged / 1:1 reliance-combined / 1:1 mergeLinks-combined / + * n:1 / 1:n / n:n / 对称 n:n,随机 link 属性)抽样——物理拓扑不是被枚举的轴, + * 而是从声明面自然涌现(Setup 决定编译结果,正如生产环境)。 + * - r29 扩展:filtered entity / filtered relation 进入生成域(谓词建立在 label/score 上, + * 含嵌套 filtered 链),由 `includeFiltered` 开关控制——storage 结构化 fuzzer 与 + * 驱动差分 fuzzer 共享同一 schema 决策流。 + * + * CAUTION 决策流契约:同一 (seed, options) 必须产出同一 schema——本模块内 rng 的 + * 调用次数与顺序就是契约本身,任何修改都等于换了一批种子。 + */ +import { Entity, Property, Relation, type EntityInstance, type RelationInstance, type PropertyInstance } from '@core'; +import { MatchExp } from '@storage'; +import { mulberry32, pick, chance, int, type Rng } from './fuzzRandom.js'; + +export { mulberry32, pick, chance, int, type Rng }; + +// ---------- 已知 fail-fast 白名单(合法拒绝;新增守卫时在此登记) ---------- +export const EXPECTED_REJECTIONS: RegExp[] = [ + /cannot be processed through this write/, // r27 F-1 守卫(combined 子记录嵌套结构) + /not an idempotent same-id reference/, // r27 F-1 守卫(原地 ref 嵌套异 id) + /cannot unlink reliance data/, // reliance 生命周期:只能随记录删除(r28 起 update 轨带具体属性信息) + /cannot bind a new reliance dependent/, // r27 F-4 守卫(reliance 置换 = 静默销毁旧依赖,fuzzer 首跑抓获) + /cannot claim .* as an endpoint of new relation record/, // r27 F-5 守卫(跨关系 combined 同住行的认领;r28 扩展到搬运子树 + host-attr 轨) + /cannot unlink combined relation .* both endpoints/, // r28 守卫(两端搬运子树都持有其他 combined 配对时的 relocate fail-fast) + /carries conflicting '&' link data/, // 重复引用携带矛盾 link 数据 + /cannot change (source|target) of relation record/, // 关系端点不可变 + /link already exist/, // addRelation 幂等冲突 + /cannot create record of merged \(union\) type/, // merged 抽象类型直建 +] + +export function isExpectedRejection(error: Error): boolean { + return EXPECTED_REJECTIONS.some(pattern => pattern.test(error.message)) +} + +// ---------- schema 生成 ---------- +export type RelationChoice = { + relation: RelationInstance + relType: '1:1' | 'n:1' | '1:n' | 'n:n' + source: string + target: string + sourceProperty: string + targetProperty: string + symmetric: boolean + linkProps: string[] +} +export type FilteredEntityChoice = { + entity: EntityInstance + name: string + baseName: string + /** 谓词的 JS 真值实现(供预言机独立求值) */ + predicate: (row: { [k: string]: unknown }) => boolean +} +export type FilteredRelationChoice = { + relation: RelationInstance + name: string + baseChoice: RelationChoice + predicate: (linkRow: { [k: string]: unknown }) => boolean +} +export type MergedEntityChoice = { + name: string + inputNames: string[] +} +export type FuzzSchema = { + entities: EntityInstance[] + relations: RelationInstance[] + mergeLinks: string[] + entityNames: string[] + relationChoices: RelationChoice[] + valueProps: Map + filteredEntities: FilteredEntityChoice[] + filteredRelations: FilteredRelationChoice[] + mergedEntities: MergedEntityChoice[] +} + +export function genSchema(rng: Rng, tag: string, options?: { includeFiltered?: boolean, includeMerged?: boolean }): FuzzSchema { + const entityNames = ['A', 'B', 'C', 'D'].map(n => `Fz${tag}${n}`) + const valueProps = new Map() + const entities = entityNames.map(name => { + const props: { name: string, type: 'string' | 'number' }[] = [ + { name: 'label', type: 'string' }, + { name: 'score', type: 'number' }, + ] + valueProps.set(name, props) + return Entity.create({ + name, + properties: [ + Property.create({ name: 'label', type: 'string' }), + // 有默认值的字段:覆盖 create payload 契约(defaults + payload)的对账面 + Property.create({ name: 'score', type: 'number', defaultValue: () => 7 }), + ] + }) + }) + const byName = new Map(entities.map(e => [e.name, e])) + + const relationChoices: RelationChoice[] = [] + const mergeLinks: string[] = [] + const usedProperty = new Set() + const relationCount = 3 + int(rng, 3) // 3..5 + for (let i = 0; i < relationCount; i++) { + const kind = pick(rng, ['1:1-merged', '1:1-reliance', '1:1-mergeLinks', 'n:1', '1:n', 'n:n', 'n:n-symmetric'] as const) + const sourceName = pick(rng, entityNames) + let targetName = pick(rng, entityNames) + const symmetric = kind === 'n:n-symmetric' + if (symmetric) targetName = sourceName + else if (targetName === sourceName) targetName = entityNames[(entityNames.indexOf(sourceName) + 1) % entityNames.length] + + const sourceProperty = symmetric ? `peers${i}` : `out${i}` + const targetProperty = symmetric ? `peers${i}` : `in${i}` + // 同一实体上属性名唯一 + if (usedProperty.has(`${sourceName}.${sourceProperty}`) || usedProperty.has(`${targetName}.${targetProperty}`)) continue + usedProperty.add(`${sourceName}.${sourceProperty}`) + usedProperty.add(`${targetName}.${targetProperty}`) + + const relType = kind === 'n:1' ? 'n:1' : kind === '1:n' ? '1:n' : kind.startsWith('1:1') ? '1:1' : 'n:n' + const linkProps: string[] = [] + const linkProperties: PropertyInstance[] = [] + if (chance(rng, 0.6)) { + linkProps.push('weight') + linkProperties.push(Property.create({ name: 'weight', type: 'number', defaultValue: () => 1 })) + } + if (chance(rng, 0.3)) { + linkProps.push('note') + linkProperties.push(Property.create({ name: 'note', type: 'string' })) + } + const relation = Relation.create({ + source: byName.get(sourceName)!, + sourceProperty, + target: byName.get(targetName)!, + targetProperty, + type: relType, + properties: linkProperties, + ...(kind === '1:1-reliance' ? { isTargetReliance: true } : {}), + }) + if (kind === '1:1-mergeLinks') mergeLinks.push(`${sourceName}.${sourceProperty}`) + relationChoices.push({ + relation, relType, source: sourceName, target: targetName, + sourceProperty, targetProperty, symmetric, linkProps, + }) + } + if (!relationChoices.length) { + // 极小概率全部属性名冲突:退化为固定一条 n:n + const relation = Relation.create({ + source: entities[0], sourceProperty: 'fallbackOut', target: entities[1], targetProperty: 'fallbackIn', type: 'n:n' + }) + relationChoices.push({ + relation, relType: 'n:n', source: entityNames[0], target: entityNames[1], + sourceProperty: 'fallbackOut', targetProperty: 'fallbackIn', symmetric: false, linkProps: [], + }) + } + + // ---------- r29:filtered entity / filtered relation 生成 ---------- + // CAUTION 谓词菜单刻意与预言机的本地 JS 求值保持同构(=、>、嵌套链), + // 每个 filtered 声明都携带自己的 predicate 真值实现——membership 预言机据此独立判定, + // 不依赖被测的 SQL 编译(否则预言机与实现同源,失去判定力)。 + const filteredEntities: FilteredEntityChoice[] = [] + const filteredRelations: FilteredRelationChoice[] = [] + const allEntities: EntityInstance[] = [...entities] + const allRelations: RelationInstance[] = relationChoices.map(c => c.relation) + if (options?.includeFiltered) { + const predicateMenu = [ + { + gen: (name: string) => ({ matchExpression: MatchExp.atom({ key: 'score', value: ['>', 50] }), predicate: (row: any) => typeof row.score === 'number' && row.score > 50 }) + }, + { + gen: (name: string) => ({ matchExpression: MatchExp.atom({ key: 'label', value: ['=', 'hot'] }), predicate: (row: any) => row.label === 'hot' }) + }, + { + gen: (name: string) => ({ matchExpression: MatchExp.atom({ key: 'score', value: ['=', null] }), predicate: (row: any) => row.score === null || row.score === undefined }) + }, + ] + const filteredCount = 1 + int(rng, 2) // 1..2 个 filtered entity + for (let i = 0; i < filteredCount; i++) { + const base = pick(rng, entityNames) + const menuItem = pick(rng, predicateMenu) + const name = `Fz${tag}F${i}` + const { matchExpression, predicate } = menuItem.gen(name) + const entity = Entity.create({ name, baseEntity: byName.get(base)!, matchExpression }) + allEntities.push(entity) + filteredEntities.push({ entity, name, baseName: base, predicate }) + // 30% 概率再套一层嵌套 filtered 链(谓词合取) + if (chance(rng, 0.3)) { + const nestedName = `Fz${tag}FN${i}` + const nested = Entity.create({ + name: nestedName, + baseEntity: entity, + matchExpression: MatchExp.atom({ key: 'label', value: ['=', 'hot'] }) + }) + allEntities.push(nested) + filteredEntities.push({ + entity: nested, name: nestedName, baseName: base, + predicate: (row: any) => predicate(row) && row.label === 'hot' + }) + } + } + // filtered relation:挑一条带 weight 的非对称关系 + const withWeight = relationChoices.filter(c => c.linkProps.includes('weight') && !c.symmetric) + if (withWeight.length && chance(rng, 0.7)) { + const baseChoice = pick(rng, withWeight) + const name = `Fz${tag}FR` + const relation = Relation.create({ + name, + baseRelation: baseChoice.relation, + sourceProperty: `fr_${baseChoice.sourceProperty}`, + targetProperty: `fr_${baseChoice.targetProperty}`, + matchExpression: MatchExp.atom({ key: 'weight', value: ['>', 50] }), + } as any) + allRelations.push(relation) + filteredRelations.push({ + relation, name, baseChoice, + predicate: (linkRow: any) => typeof linkRow.weight === 'number' && linkRow.weight > 50 + }) + } + } + + // ---------- r29:merged (union) entity 生成 ---------- + // CAUTION merged 编译把 inputs 变成物理 union base 上的视图(__type 判别): + // merged 名承载 base 事件契约(create/update/delete 全量对账),input 名只有 + // 成员资格事件。 + // CAUTION 生成域限制(r29 extended 首跑发现 EXT-1 开放家族,见 quality-plan §1.4b): + // x:1(merged FK)/ combined 关系的端点是 merged input 时,Setup 的字段-表装配在 + // rebase 后错位(查询期 "no such column" fail-loud,seeds 2/10/50/71/72/81 @ + // FUZZ_MERGED_FULL=1)。该交互收口前,CI 生成域把 merged pair 限制在 + // 「仅参与 n:n(isolated link)关系或无关系」的实体;FUZZ_MERGED_FULL=1 解除限制 + // 供家族收口时复现。mergeLinks 端点同样排除。 + const mergedEntities: MergedEntityChoice[] = [] + if (options?.includeMerged) { + const fullDomain = process.env.FUZZ_MERGED_FULL === '1' + const excluded = new Set() + for (const c of relationChoices) { + if (mergeLinks.includes(`${c.source}.${c.sourceProperty}`)) { + excluded.add(c.source); excluded.add(c.target) + } + if (!fullDomain && c.relType !== 'n:n') { + excluded.add(c.source); excluded.add(c.target) + } + } + const candidates = entityNames.filter(n => !excluded.has(n)) + if (candidates.length >= 2 && chance(rng, 0.7)) { + const x = pick(rng, candidates) + const y = pick(rng, candidates.filter(n => n !== x)) + const name = `Fz${tag}M` + const merged = Entity.create({ name, inputEntities: [byName.get(x)!, byName.get(y)!] }) + allEntities.push(merged) + mergedEntities.push({ name, inputNames: [x, y] }) + } + } + + return { + entities: allEntities, + relations: allRelations, + mergeLinks, + entityNames, + relationChoices, + valueProps, + filteredEntities, + filteredRelations, + mergedEntities, + } +} diff --git a/tests/storage/mergedWritePathRegressions.spec.ts b/tests/storage/mergedWritePathRegressions.spec.ts new file mode 100644 index 00000000..3857fd2a --- /dev/null +++ b/tests/storage/mergedWritePathRegressions.spec.ts @@ -0,0 +1,134 @@ +/** + * merged (union) 编译域的写路径回归(r29,extended fuzzer 首跑抓获的四个致命家族)。 + * + * 共同根源:merged 编译把 input 变成物理 base 上的视图之后,写路径若以**声明名/视图种类** + * 而非**物理身份**做判定,四个消费点各自出错: + * 1. 行占用判定按记录种类排除视图/抽象记录 → merged link 删除误判无人占用 → DELETE ROW + * 物理销毁宿主实体(零事件)——extended seed 1。 + * 2. combined 嵌套新建按声明名发号 → 视图名平行序列与物理表既有 id 碰撞 → 静默覆写 + * 既有记录字段——extended seed 41。 + * 3. combined 嵌套新建的 create 事件 payload 用物理名求 defaults → type-dispatch 的 + * 默认值(含 __type 判别列)整族缺席(行有值、payload 读 NULL)——extended seed 41/24。 + * 4. 级联删除轨按声明面名字发 record delete 事件 → 视图名下多出 record delete、 + * 物理名下整体缺失(监听物理名的计算对删除失明)——extended seed 37。 + * + * 修复面:DeletionExecutor.clearOrDeletePhysicalRow(按 id 字段判占用,不按记录种类排除)、 + * CreationExecutor.allocateRecordId(发号统一走 resolvedBaseRecordName)+ flashOut 同契约、 + * combined create 事件 defaults 按 originalRecordName 求值、 + * DeletionExecutor.deleteRecordSameRowDataGrouped 的 record delete 统一归物理名。 + */ +import { describe, expect, test } from "vitest"; +import { Entity, Property, Relation, type EntityInstance, type RelationInstance } from '@core'; +import { DBSetup, EntityToTableMap, MatchExp, EntityQueryHandle } from "@storage"; +import { PGLiteDB } from '@drivers'; +import { RecordMutationEvent } from "@runtime"; + +async function setupHandle(entities: EntityInstance[], relations: RelationInstance[]) { + const db = new PGLiteDB() + await db.open() + const setup = new DBSetup(entities, relations, db) + await setup.createTables() + return { db, handle: new EntityQueryHandle(new EntityToTableMap(setup.map, setup.aliasManager), db) } +} + +describe('merged (union) write-path regressions (r29 extended fuzzer findings)', () => { + test('removeRelation on a merged FK link must not physically destroy the merged-input host row (seed 1)', async () => { + const A = Entity.create({ name: 'MwrA', properties: [Property.create({ name: 'label', type: 'string' })] }) + const B = Entity.create({ name: 'MwrB', properties: [Property.create({ name: 'label', type: 'string' })] }) + const C = Entity.create({ name: 'MwrC', properties: [Property.create({ name: 'label', type: 'string' })] }) + const rel = Relation.create({ + source: A, sourceProperty: 'out', target: B, targetProperty: 'in', type: 'n:1', + properties: [Property.create({ name: 'weight', type: 'number', defaultValue: () => 1 })] + }) + const M = Entity.create({ name: 'MwrM', inputEntities: [A, C] }) + const { db, handle } = await setupHandle([A, B, C, M], [rel]) + + const b1 = await handle.create('MwrB', { label: 'b1' }) + await handle.create('MwrA', { label: 'a1', out: { id: b1.id } }) + const links = await handle.findRelationByName(rel.name!, undefined, undefined, ['id']) + expect(links.length).toBe(1) + + const events: RecordMutationEvent[] = [] + await handle.removeRelationByName(rel.name!, MatchExp.atom({ key: 'id', value: ['=', String(links[0].id)] }), events) + + // 宿主行必须存活(此前:占用判定排除视图/抽象记录 → 整行 DELETE,宿主物理消失且零事件) + expect((await handle.find('MwrA', undefined, undefined, ['id', 'label'])).length).toBe(1) + expect((await handle.find('MwrM', undefined, undefined, ['id', 'label'])).length).toBe(1) + expect(events.some(e => e.type === 'delete' && e.recordName === 'MwrA')).toBe(false) + await db.close() + }) + + test('combined nested-new child of a merged input allocates ids from the physical sequence (seed 41)', async () => { + const C = Entity.create({ name: 'MwrIdC', properties: [Property.create({ name: 'label', type: 'string' })] }) + const D = Entity.create({ name: 'MwrIdD', properties: [Property.create({ name: 'label', type: 'string' })] }) + const E = Entity.create({ name: 'MwrIdE', properties: [Property.create({ name: 'label', type: 'string' })] }) + // D—C 1:1 reliance ⇒ C 与 D 合行(combined),嵌套新建 C 走 combinedNewRecords 发号 + const rel = Relation.create({ source: D, sourceProperty: 'own', target: C, targetProperty: 'owner', type: '1:1', isTargetReliance: true }) + const M = Entity.create({ name: 'MwrIdM', inputEntities: [C, E] }) + const { db, handle } = await setupHandle([C, D, E, M], [rel]) + + // 先经另一 input 名推进物理序列:视图名平行序列会从头发号并撞上它 + const e1 = await handle.create('MwrIdE', { label: 'e1' }) + const d1 = await handle.create('MwrIdD', { label: 'd1', own: { label: 'c-nested' } }) + + const mRows = await handle.find('MwrIdM', undefined, undefined, ['id', 'label']) + const ids = mRows.map(r => String(r.id)) + // id 必须互不相同(此前:C 从视图名序列发出与 e1 相同的 id,静默覆写 e1 的字段) + expect(new Set(ids).size).toBe(ids.length) + const e1Row = mRows.find(r => String(r.id) === String(e1.id)) + expect(e1Row?.label).toBe('e1') + await db.close() + }) + + test('combined nested-new create event payload carries type-dispatched defaults incl. __type (seed 41/24)', async () => { + const C = Entity.create({ + name: 'MwrDefC', properties: [ + Property.create({ name: 'label', type: 'string' }), + Property.create({ name: 'score', type: 'number', defaultValue: () => 7 }), + ] + }) + const D = Entity.create({ name: 'MwrDefD', properties: [Property.create({ name: 'label', type: 'string' })] }) + const E = Entity.create({ name: 'MwrDefE', properties: [Property.create({ name: 'label', type: 'string' })] }) + const rel = Relation.create({ source: D, sourceProperty: 'own', target: C, targetProperty: 'owner', type: '1:1', isTargetReliance: true }) + const M = Entity.create({ name: 'MwrDefM', inputEntities: [C, E] }) + const { db, handle } = await setupHandle([C, D, E, M], [rel]) + + const events: RecordMutationEvent[] = [] + await handle.create('MwrDefD', { label: 'd1', own: { label: 'c1' } }, events) + + // create 事件 payload 契约 = defaults + payload;merged input 的 defaults 按声明名 type-dispatch + const mCreate = events.find(e => e.type === 'create' && e.recordName === 'MwrDefM') + expect(mCreate, 'combined child create event must be emitted under the physical (merged) name').toBeTruthy() + expect(mCreate!.record!.score, 'type-dispatched default must be present in payload').toBe(7) + expect(mCreate!.record!.__type).toBe('MwrDefC') + // 视图名(input)下是成员资格 create + expect(events.some(e => e.type === 'create' && e.recordName === 'MwrDefC')).toBe(true) + await db.close() + }) + + test('reliance cascade of a merged input emits record delete under the physical name (seed 37)', async () => { + const B = Entity.create({ name: 'MwrCasB', properties: [Property.create({ name: 'label', type: 'string' })] }) + const C = Entity.create({ name: 'MwrCasC', properties: [Property.create({ name: 'label', type: 'string' })] }) + const D = Entity.create({ name: 'MwrCasD', properties: [Property.create({ name: 'label', type: 'string' })] }) + const rel = Relation.create({ source: D, sourceProperty: 'own', target: C, targetProperty: 'owner', type: '1:1', isTargetReliance: true }) + const M = Entity.create({ name: 'MwrCasM', inputEntities: [C, B] }) + const { db, handle } = await setupHandle([B, C, D, M], [rel]) + + const c1 = await handle.create('MwrCasC', { label: 'c1' }) + const d1 = await handle.create('MwrCasD', { label: 'd1' }) + await handle.addRelationByNameById(rel.name!, String(d1.id), String(c1.id), {}) + + const events: RecordMutationEvent[] = [] + await handle.delete('MwrCasD', MatchExp.atom({ key: 'id', value: ['=', d1.id] }), events) + + expect((await handle.find('MwrCasC', undefined, undefined, ['id'])).length).toBe(0) + expect((await handle.find('MwrCasM', undefined, undefined, ['id'])).length).toBe(0) + const deletesByName = events.filter(e => e.type === 'delete').map(e => e.recordName) + // 物理名 record delete 必须恰好一次;视图名下是成员资格 delete(同样恰好一次,不重复) + expect(deletesByName.filter(n => n === 'MwrCasM').length, + `physical-name delete missing/duplicated in ${JSON.stringify(deletesByName)}`).toBe(1) + expect(deletesByName.filter(n => n === 'MwrCasC').length, + `view-name membership delete missing/duplicated in ${JSON.stringify(deletesByName)}`).toBe(1) + await db.close() + }) +}) diff --git a/tests/storage/writePathStructuralFuzz.spec.ts b/tests/storage/writePathStructuralFuzz.spec.ts index e6b5b8bc..56da7343 100644 --- a/tests/storage/writePathStructuralFuzz.spec.ts +++ b/tests/storage/writePathStructuralFuzz.spec.ts @@ -5,30 +5,32 @@ * (你枚举不出没想到的维度)。r27 F-1 的六种损坏形态放进事件完备性预言机全部当场变红, * 它们逃过 26 轮的唯一原因是**这些输入形状从未被生成过**。本 fuzzer 的职责就是生成: * - * - 随机 schema:从关系菜单(1:1 merged / 1:1 reliance-combined / 1:1 mergeLinks-combined / - * n:1 / 1:n / n:n / 对称 n:n,随机 link 属性)抽样——物理拓扑不是被枚举的轴, - * 而是从声明面自然涌现(Setup 决定编译结果,正如生产环境)。 - * - 随机操作序列:create/update/delete/addRelation/removeRelation,载荷生成器递归产生 - * 嵌套新建 / ref / null / 数组 / `&` link 数据的任意组合(深度 ≤ 3)——覆盖「载荷嵌套 - * 深度 × 子记录拓扑」轴上人不会想到去写的格子。 + * - 随机 schema + 随机操作序列:生成器与操作决策器抽取为共享实现 + * (helpers/fuzzSchema.ts / helpers/fuzzOps.ts,与驱动差分 fuzzer 共用决策流)。 + * - r29 扩展模式(FUZZ_FILTERED=1 或 filtered 描述组):filtered entity/relation 进入 + * 生成域;操作有概率经 filtered 名写入(概念寄生位置轴的写入面)。 * * 判定(全部复用/扩展既有预言机,见 helpers/eventCompleteness.ts): * 1. 事件完备性(数据 diff ⟺ 事件流,含 payload/端点契约 7 条规则)——非抛错操作逐一对账; + * filtered 名按 membership-only 对账(字段 update 事件按契约只在 base 名下发出); * 2. 双向一致性(正反查询同一事实)——每步之后全关系断言; * 3. 排他侧唯一(INV-3)——每步之后全 x:1 关系断言; * 4. 逻辑 id 唯一(r27 F-1 ⑤⑥ 的损坏面:同一逻辑 id 物理两行)——每步之后全记录名断言; * 5. 无身份记录(r27 F-1 ④ 的损坏面:嵌套可见但无 id)——每步之后断言一切查询返回的 - * 嵌套对象凡携带非空值字段必有 id。 + * 嵌套对象凡携带非空值字段必有 id; + * 6. 配对读取一致性(r28 复盘落地,预言机第 8 条):实体嵌套读取面与 findRelationByName + * 面必须给出同一配对集合——「同住 ≠ 配对」家族(幻影读取)的机器化收口; + * 7. filtered 谓词一致性(r29):find(filteredName) 的 id 集合必须等于按声明谓词的 + * **独立 JS 真值**过滤 base 全集的结果(预言机不依赖被测的 SQL 编译)。 * * 错误语义:已知 fail-fast(EXPECTED_REJECTIONS 白名单)是合法拒绝——操作跳过,但内部 - * 一致性(2–5)仍必须成立(守卫必须在破坏性写入之前抛出);未知异常 = 发现,带种子报告。 + * 一致性仍必须成立(守卫必须在破坏性写入之前抛出);未知异常 = 发现,带种子报告。 * * 再现:失败信息携带 seed 与操作日志;FUZZ_SEED_START/FUZZ_SEED_COUNT/FUZZ_OPS 环境变量 * 可扩大探索(CI 跑固定小种子集保证确定性与时长)。 */ import { expect, test, describe } from "vitest"; -import { Entity, Property, Relation, type EntityInstance, type RelationInstance, type PropertyInstance } from '@core'; -import { DBSetup, EntityToTableMap, MatchExp, EntityQueryHandle } from "@storage"; +import { DBSetup, EntityToTableMap, EntityQueryHandle } from "@storage"; import { SQLiteDB } from '@drivers'; import { RecordMutationEvent } from "@runtime"; import { @@ -38,213 +40,8 @@ import { assertBidirectionalConsistency, EventCompletenessSchema, } from "./helpers/eventCompleteness.js"; - -// ---------- 确定性 PRNG(mulberry32) ---------- -function mulberry32(seed: number) { - let a = seed >>> 0 - return function () { - a |= 0; a = (a + 0x6D2B79F5) | 0 - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } -} -type Rng = () => number -const pick = (rng: Rng, items: T[]): T => items[Math.floor(rng() * items.length)] -const chance = (rng: Rng, p: number) => rng() < p -const int = (rng: Rng, max: number) => Math.floor(rng() * max) - -// ---------- 已知 fail-fast 白名单(合法拒绝;新增守卫时在此登记) ---------- -const EXPECTED_REJECTIONS: RegExp[] = [ - /cannot be processed through this write/, // r27 F-1 守卫(combined 子记录嵌套结构) - /not an idempotent same-id reference/, // r27 F-1 守卫(原地 ref 嵌套异 id) - /cannot unlink reliance data/, // reliance 生命周期:只能随记录删除(r28 起 update 轨带具体属性信息) - /cannot bind a new reliance dependent/, // r27 F-4 守卫(reliance 置换 = 静默销毁旧依赖,fuzzer 首跑抓获) - /cannot claim .* as an endpoint of new relation record/, // r27 F-5 守卫(跨关系 combined 同住行的认领;r28 扩展到搬运子树 + host-attr 轨) - /cannot unlink combined relation .* both endpoints/, // r28 守卫(两端搬运子树都持有其他 combined 配对时的 relocate fail-fast) - /carries conflicting '&' link data/, // 重复引用携带矛盾 link 数据 - /cannot change (source|target) of relation record/, // 关系端点不可变 - /link already exist/, // addRelation 幂等冲突 - /cannot create record of merged \(union\) type/, // merged 抽象类型直建 -] - -// ---------- schema 生成 ---------- -type RelationChoice = { - relation: RelationInstance - relType: '1:1' | 'n:1' | '1:n' | 'n:n' - source: string - target: string - sourceProperty: string - targetProperty: string - symmetric: boolean - linkProps: string[] -} -type FuzzSchema = { - entities: EntityInstance[] - relations: RelationInstance[] - mergeLinks: string[] - entityNames: string[] - relationChoices: RelationChoice[] - valueProps: Map -} - -function genSchema(rng: Rng, tag: string): FuzzSchema { - const entityNames = ['A', 'B', 'C', 'D'].map(n => `Fz${tag}${n}`) - const valueProps = new Map() - const entities = entityNames.map(name => { - const props: { name: string, type: 'string' | 'number' }[] = [ - { name: 'label', type: 'string' }, - { name: 'score', type: 'number' }, - ] - valueProps.set(name, props) - return Entity.create({ - name, - properties: [ - Property.create({ name: 'label', type: 'string' }), - // 有默认值的字段:覆盖 create payload 契约(defaults + payload)的对账面 - Property.create({ name: 'score', type: 'number', defaultValue: () => 7 }), - ] - }) - }) - const byName = new Map(entities.map(e => [e.name, e])) - - const relationChoices: RelationChoice[] = [] - const mergeLinks: string[] = [] - const usedProperty = new Set() - const relationCount = 3 + int(rng, 3) // 3..5 - for (let i = 0; i < relationCount; i++) { - const kind = pick(rng, ['1:1-merged', '1:1-reliance', '1:1-mergeLinks', 'n:1', '1:n', 'n:n', 'n:n-symmetric'] as const) - const sourceName = pick(rng, entityNames) - let targetName = pick(rng, entityNames) - const symmetric = kind === 'n:n-symmetric' - if (symmetric) targetName = sourceName - else if (targetName === sourceName) targetName = entityNames[(entityNames.indexOf(sourceName) + 1) % entityNames.length] - - const sourceProperty = symmetric ? `peers${i}` : `out${i}` - const targetProperty = symmetric ? `peers${i}` : `in${i}` - // 同一实体上属性名唯一 - if (usedProperty.has(`${sourceName}.${sourceProperty}`) || usedProperty.has(`${targetName}.${targetProperty}`)) continue - usedProperty.add(`${sourceName}.${sourceProperty}`) - usedProperty.add(`${targetName}.${targetProperty}`) - - const relType = kind === 'n:1' ? 'n:1' : kind === '1:n' ? '1:n' : kind.startsWith('1:1') ? '1:1' : 'n:n' - const linkProps: string[] = [] - const linkProperties: PropertyInstance[] = [] - if (chance(rng, 0.6)) { - linkProps.push('weight') - linkProperties.push(Property.create({ name: 'weight', type: 'number', defaultValue: () => 1 })) - } - if (chance(rng, 0.3)) { - linkProps.push('note') - linkProperties.push(Property.create({ name: 'note', type: 'string' })) - } - const relation = Relation.create({ - source: byName.get(sourceName)!, - sourceProperty, - target: byName.get(targetName)!, - targetProperty, - type: relType, - properties: linkProperties, - ...(kind === '1:1-reliance' ? { isTargetReliance: true } : {}), - }) - if (kind === '1:1-mergeLinks') mergeLinks.push(`${sourceName}.${sourceProperty}`) - relationChoices.push({ - relation, relType, source: sourceName, target: targetName, - sourceProperty, targetProperty, symmetric, linkProps, - }) - } - if (!relationChoices.length) { - // 极小概率全部属性名冲突:退化为固定一条 n:n - const relation = Relation.create({ - source: entities[0], sourceProperty: 'fallbackOut', target: entities[1], targetProperty: 'fallbackIn', type: 'n:n' - }) - relationChoices.push({ - relation, relType: 'n:n', source: entityNames[0], target: entityNames[1], - sourceProperty: 'fallbackOut', targetProperty: 'fallbackIn', symmetric: false, linkProps: [], - }) - } - return { entities, relations: relationChoices.map(c => c.relation), mergeLinks, entityNames, relationChoices, valueProps } -} - -// ---------- 载荷生成 ---------- -type IdPools = Map - -// 公开 API 把 id 声明为 string(addRelationByNameById(sourceEntityId: string, ...)),HTTP 载荷 -// 携带的 id 也天然是字符串——ref 形态必须同时探索「驱动原生形态」与「字符串形态」两个合法取值 -// (r27 F-3 正是 fuzzer 首跑经字符串化 id 池抓获:SQL 面 1 == '1' 而 JS === 判不等)。 -function idForPayload(rng: Rng, id: unknown): unknown { - return chance(rng, 0.4) ? String(id) : id -} - -function genLinkData(rng: Rng, choice: RelationChoice): Record | undefined { - if (!choice.linkProps.length || chance(rng, 0.5)) return undefined - const data: Record = {} - for (const prop of choice.linkProps) { - if (chance(rng, 0.6)) data[prop] = prop === 'weight' ? int(rng, 100) : `n${int(rng, 10)}` - } - return Object.keys(data).length ? data : undefined -} - -/** 递归生成某实体的写载荷;depth 限制嵌套层数,forUpdate 时不生成本体 id。 */ -function genPayload(rng: Rng, schema: FuzzSchema, entityName: string, pools: IdPools, depth: number): Record { - const payload: Record = {} - for (const prop of schema.valueProps.get(entityName)!) { - if (chance(rng, 0.7)) payload[prop.name] = prop.type === 'string' ? `v${int(rng, 100)}` : int(rng, 100) - } - if (depth <= 0) return payload - - for (const choice of schema.relationChoices) { - const roles: Array<{ attr: string, related: string, isMany: boolean }> = [] - if (choice.source === entityName) { - roles.push({ attr: choice.sourceProperty, related: choice.target, isMany: choice.relType.endsWith('n') }) - } - if (!choice.symmetric && choice.target === entityName) { - roles.push({ attr: choice.targetProperty, related: choice.source, isMany: choice.relType.startsWith('n') }) - } - for (const role of roles) { - if (!chance(rng, 0.35)) continue // 多数属性省略,保持载荷自然 - const genOne = (): Record | null => { - const mode = pick(rng, ['new', 'ref', 'null'] as const) - if (mode === 'null') return null - if (mode === 'ref') { - const pool = pools.get(role.related) ?? [] - if (!pool.length) return genOne0('new') - const item: Record = { id: idForPayload(rng, pick(rng, pool)) } - const link = genLinkData(rng, choice) - if (link) item['&'] = link - return item - } - return genOne0('new') - } - const genOne0 = (mode: 'new'): Record => { - const nested = genPayload(rng, schema, role.related, pools, depth - 1) - const link = genLinkData(rng, choice) - if (link) nested['&'] = link - return nested - } - if (role.isMany) { - const count = 1 + int(rng, 2) - const items: unknown[] = [] - const seen = new Set() - for (let i = 0; i < count; i++) { - const item = genOne() - if (item === null) continue // 数组里不放 null - const id = (item as { id?: string }).id - if (id !== undefined) { - if (seen.has(String(id))) continue // 避免矛盾 `&` 的重复 ref 噪音 - seen.add(String(id)) - } - items.push(item) - } - if (items.length) payload[role.attr] = items - } else { - const value = genOne() - if (value !== undefined) payload[role.attr] = value - } - } - } - return payload -} +import { mulberry32, genSchema, isExpectedRejection, type FuzzSchema } from "./helpers/fuzzSchema.js"; +import { decideNextOp, executeOpIntent, type IdPools } from "./helpers/fuzzOps.js"; // ---------- 不变量组(每步之后,含合法拒绝之后) ---------- // CAUTION id 保留驱动原生形态(SQLite number / PGLite string): @@ -267,8 +64,8 @@ async function assertStructuralInvariants(handle: EntityQueryHandle, schema: Fuz const ids = await collectIds(handle, choice.relation.name!, true) expect(new Set(ids.map(String)).size, `${label} [unique-id] ${choice.relation.name} has duplicate logical ids`).toBe(ids.length) } - // 2/3/5. 双向一致 + 排他唯一 + 无身份记录 - for (const choice of schoiceIterable(schema)) { + // 2/3/5/6. 双向一致 + 排他唯一 + 无身份记录 + 配对读取一致 + for (const choice of schema.relationChoices) { if (!choice.symmetric) { await assertBidirectionalConsistency(handle, { sourceEntity: choice.source, sourceProperty: choice.sourceProperty, @@ -292,9 +89,59 @@ async function assertStructuralInvariants(handle: EntityQueryHandle, schema: Fuz } } } + // 6. 配对读取一致性(预言机第 8 条,r28 幻影配对家族的机器化收口): + // 实体嵌套读取面(source→related 对集合)与 link 记录面(findRelationByName 端点对集合) + // 必须给出同一配对事实。对称关系的嵌套读取是无向扇出,暂由双向一致性覆盖。 + if (!choice.symmetric) { + const entityPairs = new Set() + for (const row of rows) { + const related = row[choice.sourceProperty] + const items = Array.isArray(related) ? related : (related ? [related] : []) + for (const item of items as Record[]) { + if (item.id !== null && item.id !== undefined) entityPairs.add(`${row.id}->${item.id}`) + } + } + const links = await handle.findRelationByName(choice.relation.name!, undefined, undefined, + ['id', ['source', { attributeQuery: ['id'] }], ['target', { attributeQuery: ['id'] }]]) + const linkPairs = new Set(links.map(link => + `${(link.source as { id?: unknown })?.id}->${(link.target as { id?: unknown })?.id}`)) + expect([...entityPairs].sort(), `${label} [pairing-read] ${choice.relation.name}: entity nested-read pairs diverge from link-record pairs`) + .toEqual([...linkPairs].sort()) + } + } + // 7. filtered 谓词一致性(r29):查询面结果 = 声明谓词的独立 JS 真值 ∘ base 全集 + for (const filtered of schema.filteredEntities) { + const baseRows = await handle.find(filtered.baseName, undefined, undefined, ['*']) + const expectedIds = baseRows.filter(row => filtered.predicate(row)).map(row => String(row.id)).sort() + const filteredRows = await handle.find(filtered.name, undefined, undefined, ['id']) + const actualIds = filteredRows.map(row => String(row.id)).sort() + expect(actualIds, `${label} [filtered-predicate] ${filtered.name} membership diverges from declared predicate over ${filtered.baseName}`) + .toEqual(expectedIds) + } + for (const filteredRelation of schema.filteredRelations) { + const baseLinks = await handle.findRelationByName(filteredRelation.baseChoice.relation.name!, undefined, undefined, ['*']) + const expectedIds = baseLinks.filter(link => filteredRelation.predicate(link)).map(link => String(link.id)).sort() + const filteredLinks = await handle.findRelationByName(filteredRelation.name, undefined, undefined, ['id']) + const actualIds = filteredLinks.map(link => String(link.id)).sort() + expect(actualIds, `${label} [filtered-predicate] ${filteredRelation.name} membership diverges from declared predicate over ${filteredRelation.baseChoice.relation.name}`) + .toEqual(expectedIds) + } + // 8. merged (union) 一致性(r29):find(merged) 的 id 集合 = 各 input id 集合的不相交并 + for (const merged of schema.mergedEntities) { + const inputIdSets: string[][] = [] + for (const inputName of merged.inputNames) { + const rows = await handle.find(inputName, undefined, undefined, ['id']) + inputIdSets.push(rows.map(row => String(row.id))) + } + const unionIds = inputIdSets.flat().sort() + expect(new Set(unionIds).size, `${label} [merged-union] ${merged.name}: input id sets overlap (union base must give disjoint ids)`) + .toBe(unionIds.length) + const mergedRows = await handle.find(merged.name, undefined, undefined, ['id']) + const mergedIds = mergedRows.map(row => String(row.id)).sort() + expect(mergedIds, `${label} [merged-union] ${merged.name} id set diverges from the union of its inputs (${merged.inputNames.join(' ∪ ')})`) + .toEqual(unionIds) } } -function schoiceIterable(schema: FuzzSchema) { return schema.relationChoices } // ---------- 操作执行 ---------- type OpLog = { step: number, op: string, detail: unknown, outcome: 'ok' | 'rejected', error?: string } @@ -305,9 +152,11 @@ async function refreshPools(handle: EntityQueryHandle, schema: FuzzSchema, pools } } -async function runFuzzCase(seed: number, opsCount: number) { +async function runFuzzCase(seed: number, opsCount: number, mode: 'base' | 'filtered' | 'extended') { + const includeFiltered = mode !== 'base' + const includeMerged = mode === 'extended' const rng = mulberry32(seed) - const schema = genSchema(rng, `S${seed}_`) + const schema = genSchema(rng, `S${seed}_`, { includeFiltered, includeMerged }) const db = new SQLiteDB(':memory:') await db.open() let handle: EntityQueryHandle @@ -321,96 +170,85 @@ async function runFuzzCase(seed: number, opsCount: number) { return { seed, executed: 0, rejected: 0, declarationRejected: true } } + // merged (union) 编译后:merged 名承载 base 事件契约(全量对账),input 名只有成员资格事件 + const mergedInputNames = new Set(schema.mergedEntities.flatMap(m => m.inputNames)) const eventSchema: EventCompletenessSchema = { - entities: schema.entityNames, - relations: schema.relationChoices.map(c => c.relation.name!), + entities: [ + ...schema.entityNames, + ...schema.filteredEntities.map(f => f.name), + ...schema.mergedEntities.map(m => m.name), + ], + relations: [...schema.relationChoices.map(c => c.relation.name!), ...schema.filteredRelations.map(f => f.name)], } + // filtered 名与 merged input 名下只有成员资格 create/delete 事件 + // (字段 update 恒以物理 base 名发出)——membership-only 对账 + const membershipOnlyRecords = new Set([ + ...schema.filteredEntities.map(f => f.name), + ...schema.filteredRelations.map(f => f.name), + ...mergedInputNames, + ]) + // filtered 模式下操作有概率经 filtered 名写入(写经 filtered 名解析到 base) + const targetableEntityNames = [ + ...schema.entityNames.map(name => ({ name, poolName: name })), + ...schema.filteredEntities.map(f => ({ name: f.name, poolName: f.baseName })), + ] const pools: IdPools = new Map(schema.entityNames.map(n => [n, []])) const opLog: OpLog[] = [] let executed = 0, rejected = 0 + const modeTag = mode === 'base' ? '' : ` ${mode}` const failWith = (message: string): never => { const logSlice = process.env.FUZZ_VERBOSE ? opLog : opLog.slice(-5) const schemaDump = process.env.FUZZ_VERBOSE ? `\nschema: ${JSON.stringify(schema.relationChoices.map(c => ({ name: c.relation.name, relType: c.relType, source: c.source, target: c.target, sourceProperty: c.sourceProperty, targetProperty: c.targetProperty, reliance: (c.relation as { isTargetReliance?: boolean }).isTargetReliance ?? false, linkProps: c.linkProps, mergeLinks: schema.mergeLinks })), null, 2)}` + + `\nfiltered: ${JSON.stringify(schema.filteredEntities.map(f => ({ name: f.name, base: f.baseName })))}` + + `\nmerged: ${JSON.stringify(schema.mergedEntities)}` : '' - throw new Error(`[fuzz seed=${seed}] ${message}${schemaDump}\nop log${process.env.FUZZ_VERBOSE ? '' : ' tail'}: ${JSON.stringify(logSlice, null, 2)}`) + throw new Error(`[fuzz seed=${seed}${modeTag}] ${message}${schemaDump}\nop log${process.env.FUZZ_VERBOSE ? '' : ' tail'}: ${JSON.stringify(logSlice, null, 2)}`) } for (let step = 0; step < opsCount; step++) { await refreshPools(handle, schema, pools) - const opKind = pick(rng, ['create', 'create', 'create', 'update', 'update', 'delete', 'addRelation', 'removeRelation'] as const) + const intent = await decideNextOp(rng, schema, pools, + (relationName) => collectIds(handle, relationName, true), + includeFiltered ? targetableEntityNames : undefined) + if (!intent) continue const before = await snapshotLogicalState(handle, eventSchema) const events: RecordMutationEvent[] = [] - let detail: unknown = null + const detail: unknown = intent let threw: Error | null = null try { - if (opKind === 'create') { - const entityName = pick(rng, schema.entityNames) - const payload = genPayload(rng, schema, entityName, pools, 1 + int(rng, 2)) - detail = { entityName, payload } - await handle.create(entityName, payload, events) - } else if (opKind === 'update') { - const entityName = pick(rng, schema.entityNames) - const pool = pools.get(entityName)! - if (!pool.length) continue - const id = idForPayload(rng, pick(rng, pool)) - const payload = genPayload(rng, schema, entityName, pools, 1 + int(rng, 1)) - detail = { entityName, id, payload } - await handle.update(entityName, MatchExp.atom({ key: 'id', value: ['=', id] }), payload, events) - } else if (opKind === 'delete') { - const entityName = pick(rng, schema.entityNames) - const pool = pools.get(entityName)! - if (!pool.length) continue - const id = idForPayload(rng, pick(rng, pool)) - detail = { entityName, id } - await handle.delete(entityName, MatchExp.atom({ key: 'id', value: ['=', id] }), events) - } else if (opKind === 'addRelation') { - const choice = pick(rng, schema.relationChoices) - const sourcePool = pools.get(choice.source)!, targetPool = pools.get(choice.target)! - if (!sourcePool.length || !targetPool.length) continue - const sourceId = idForPayload(rng, pick(rng, sourcePool)), targetId = idForPayload(rng, pick(rng, targetPool)) - if (choice.symmetric && String(sourceId) === String(targetId)) continue - detail = { relation: choice.relation.name, sourceId, targetId } - await handle.addRelationByNameById(choice.relation.name!, sourceId as string, targetId as string, genLinkData(rng, choice) ?? {}, events) - } else { - const choice = pick(rng, schema.relationChoices) - const links = await handle.findRelationByName(choice.relation.name!, undefined, undefined, ['id']) - if (!links.length) continue - const linkId = String(pick(rng, links.map(l => l.id))) - detail = { relation: choice.relation.name, linkId } - await handle.removeRelationByName(choice.relation.name!, MatchExp.atom({ key: 'id', value: ['=', linkId] }), events) - } + await executeOpIntent(handle, intent, events) } catch (error) { threw = error instanceof Error ? error : new Error(String(error)) } if (threw) { - const known = EXPECTED_REJECTIONS.some(pattern => pattern.test(threw!.message)) - opLog.push({ step, op: opKind, detail, outcome: 'rejected', error: threw.message.slice(0, 160) }) - if (!known) { - failWith(`step ${step} ${opKind} threw an UNEXPECTED error: ${threw.message}\ndetail: ${JSON.stringify(detail)}`) + opLog.push({ step, op: intent.op, detail, outcome: 'rejected', error: threw.message.slice(0, 160) }) + if (!isExpectedRejection(threw)) { + failWith(`step ${step} ${intent.op} threw an UNEXPECTED error: ${threw.message}\ndetail: ${JSON.stringify(detail)}`) } rejected++ } else { - opLog.push({ step, op: opKind, detail, outcome: 'ok' }) + opLog.push({ step, op: intent.op, detail, outcome: 'ok' }) executed++ // 1. 事件完备性(仅非抛错操作:无事务语义下错误路径允许部分写) const after = await snapshotLogicalState(handle, eventSchema) try { - expectEventsToExplainDiff(before, after, events, `[fuzz seed=${seed} step=${step} ${opKind}]`, { + expectEventsToExplainDiff(before, after, events, `[fuzz seed=${seed}${modeTag} step=${step} ${intent.op}]`, { relations: eventSchema.relations, + membershipOnlyRecords, }) } catch (error) { - failWith(`event oracle failed at step ${step} ${opKind}: ${error instanceof Error ? error.message : String(error)}\ndetail: ${JSON.stringify(detail)}`) + failWith(`event oracle failed at step ${step} ${intent.op}: ${error instanceof Error ? error.message : String(error)}\ndetail: ${JSON.stringify(detail)}`) } } - // 2–5. 结构不变量:每步之后(含合法拒绝之后——守卫必须先于破坏性写入) + // 2–7. 结构不变量:每步之后(含合法拒绝之后——守卫必须先于破坏性写入) try { - await assertStructuralInvariants(handle, schema, `[fuzz seed=${seed} step=${step} ${opKind}(${threw ? 'rejected' : 'ok'})]`) + await assertStructuralInvariants(handle, schema, `[fuzz seed=${seed}${modeTag} step=${step} ${intent.op}(${threw ? 'rejected' : 'ok'})]`) } catch (error) { - failWith(`structural invariant failed after step ${step} ${opKind} (${threw ? 'rejected op' : 'ok op'}): ${error instanceof Error ? error.message : String(error)}\ndetail: ${JSON.stringify(detail)}`) + failWith(`structural invariant failed after step ${step} ${intent.op} (${threw ? 'rejected op' : 'ok op'}): ${error instanceof Error ? error.message : String(error)}\ndetail: ${JSON.stringify(detail)}`) } } @@ -422,14 +260,34 @@ async function runFuzzCase(seed: number, opsCount: number) { const SEED_START = Number(process.env.FUZZ_SEED_START ?? 1) const SEED_COUNT = Number(process.env.FUZZ_SEED_COUNT ?? 8) const OPS = Number(process.env.FUZZ_OPS ?? 30) +// filtered 模式的种子宇宙独立(决策流包含 filtered 生成与 filtered 名写入) +const FILTERED_SEED_START = Number(process.env.FUZZ_FILTERED_SEED_START ?? 1) +const FILTERED_SEED_COUNT = Number(process.env.FUZZ_FILTERED_SEED_COUNT ?? 8) describe('write-path structural fuzz (generator + oracles)', () => { const seeds = Array.from({ length: SEED_COUNT }, (_, i) => SEED_START + i) test.each(seeds.map(s => [s]))('seed %i: random schema × random op sequence upholds all oracles', async (seed) => { - const result = await runFuzzCase(seed, OPS) + const result = await runFuzzCase(seed, OPS, 'base') // 覆盖度自检:非声明期拒绝的种子必须真正执行了操作(防退化为全拒绝的空跑) if (!result.declarationRejected) { expect(result.executed, `seed ${seed} executed no ops (over-rejection? pools never filled?)`).toBeGreaterThan(0) } }, 120000) }) + +const filteredSeeds = Array.from({ length: FILTERED_SEED_COUNT }, (_, i) => FILTERED_SEED_START + i) +;(filteredSeeds.length ? describe : describe.skip)('write-path structural fuzz — extended mode (filtered/merged entities in the generation domain, r29)', () => { + // extended = filtered entity/relation + merged (union) entity 同时进入生成域; + // 声明期拒绝率抽样监控(防生成域塌缩为全拒绝的假绿)。 + const declarationRejections: number[] = [] + test.each((filteredSeeds.length ? filteredSeeds : [0]).map(s => [s]))('extended seed %i: membership/union/predicate consistency uphold all oracles', async (seed) => { + const result = await runFuzzCase(seed, OPS, 'extended') + if (result.declarationRejected) { + declarationRejections.push(seed) + expect(declarationRejections.length, `extended mode declaration-rejection rate too high (rejected seeds: ${declarationRejections.join(',')}) — generation domain collapsed`) + .toBeLessThanOrEqual(Math.max(2, Math.floor(filteredSeeds.length * 0.3))) + } else { + expect(result.executed, `extended seed ${seed} executed no ops (over-rejection? pools never filled?)`).toBeGreaterThan(0) + } + }, 120000) +}) From ab3132519a1132d79ef06560250c1e872adfc1dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 03:21:59 +0000 Subject: [PATCH 2/5] feat(tests): driver differential fuzzer (SQLite vs PGLite, same seed, per-op reconciliation) - Same intent stream executed on both drivers via id bijection built from lockstep create-event pairing (scoped per recordName, preserving id-form axis) - Reconciles error semantics, event multisets (order within an op is driver dialect - contract decision from first-run seed 35), update keys, endpoint presence, and full logical snapshots per operation - 120 seeds x 25-30 ops green Co-authored-by: Zhenyu Hou --- tests/storage/driverDifferentialFuzz.spec.ts | 404 +++++++++++++++++++ tests/storage/helpers/fuzzOps.ts | 15 +- 2 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 tests/storage/driverDifferentialFuzz.spec.ts diff --git a/tests/storage/driverDifferentialFuzz.spec.ts b/tests/storage/driverDifferentialFuzz.spec.ts new file mode 100644 index 00000000..3f626aa4 --- /dev/null +++ b/tests/storage/driverDifferentialFuzz.spec.ts @@ -0,0 +1,404 @@ +/** + * 驱动差分 fuzz(r29,quality-plan §1.3 第 1 步:r24/r25 驱动分裂家族的机器化收口)。 + * + * 动机:r24 的 getAutoId id 型别分裂、r25 的 timestamp 形态分裂都属于「同一逻辑操作在 + * 不同驱动下产生不同可观察结果」——单库预言机结构上抓不到(每一侧各自自洽)。 + * 收口方式是把「跨驱动等价」本身升格为预言机:同一种子、同一操作意图流, + * 在 SQLite(主)与 PGLite(副)上逐操作对账。 + * + * 机制: + * - 决策流共享:schema 与操作意图由共享生成器产出(helpers/fuzzSchema / helpers/fuzzOps), + * 意图以主库的具体 id 表达,经 **id 双射**翻译后在副库重放。 + * - id 双射:两侧同一操作的 create 事件按 (type, recordName) 分组、组内按位置配对登记 + * `recordName:主id ↔ 副id`。组尺寸失配 = 事件流结构分裂;组内位置配对若配错 + * (仅当两条新记录值面完全同构时才可能),后续快照值面对账会当场戳穿—— + * 即预言机对「记录同构性」以内的配对选择不敏感(isomorphism up to field values)。 + * - 逐操作对账面: + * 1. 错误语义:一侧抛、另一侧不抛 = 驱动分裂;两侧都抛按同一白名单分类 + * (错误消息不要求逐字相同——SQL 层措辞属驱动方言); + * 2. 事件流:(type, recordName) 组多重集一致;delete/update 的身份多重集(经双射)一致; + * update 的 keys 集合按身份配对一致;relation 事件端点存在性一致。 + * CAUTION 组内**顺序**刻意不比较:一个操作内的兄弟 unlink/级联按内部查询返回序 + * 处理,行序是驱动方言(无 ORDER BY 承诺)——「事件多重集一致、顺序不承诺」 + * 是本预言机固化下来的跨驱动契约决策(差分 fuzz 首跑种子 35 暴露的决策点)。 + * 3. 逻辑状态快照:全部实体/关系的 id 集合(经双射)与全部值字段严格相等。 + * - id 的 JS 形态(number vs string)**刻意不比较**:形态契约是记录中的开放决策 + * (r27 F-3 数据面已收口到 sameRecordId);本预言机比较 String 归一后的**身份**。 + * + * 再现:FUZZ_DIFF_SEED_START / FUZZ_DIFF_SEED_COUNT / FUZZ_DIFF_OPS 扩池; + * 失败信息带种子与操作日志(FUZZ_VERBOSE=1 全量)。 + */ +import { describe, expect, test } from "vitest"; +import { DBSetup, EntityToTableMap, EntityQueryHandle } from "@storage"; +import { SQLiteDB, PGLiteDB } from '@drivers'; +import { RecordMutationEvent } from "@runtime"; +import { snapshotLogicalState, type EventCompletenessSchema, type LogicalSnapshot } from "./helpers/eventCompleteness.js"; +import { mulberry32, genSchema, isExpectedRejection, type FuzzSchema } from "./helpers/fuzzSchema.js"; +import { decideNextOp, executeOpIntent, type FuzzOpIntent, type IdPools } from "./helpers/fuzzOps.js"; + +// ---------- id 双射 ---------- +class IdBijection { + private primToSec = new Map() + private secToPrim = new Map() + private key(recordName: string, id: unknown) { return `${recordName}:${String(id)}` } + register(recordName: string, primId: unknown, secId: unknown, context: string) { + const primKey = this.key(recordName, primId), secKey = this.key(recordName, secId) + const existingSec = this.primToSec.get(primKey), existingPrim = this.secToPrim.get(secKey) + if (existingSec !== undefined && String(existingSec) !== String(secId)) { + throw new Error(`id bijection conflict at ${context}: ${primKey} already maps to ${String(existingSec)}, now ${String(secId)}`) + } + if (existingPrim !== undefined && String(existingPrim) !== String(primId)) { + throw new Error(`id bijection conflict at ${context}: secondary ${secKey} already maps to ${String(existingPrim)}, now ${String(primId)}`) + } + this.primToSec.set(primKey, secId) + this.secToPrim.set(secKey, primId) + } + toSecondary(recordName: string, primId: unknown): unknown { + const mapped = this.primToSec.get(this.key(recordName, primId)) + if (mapped === undefined) throw new Error(`no secondary id mapped for ${this.key(recordName, primId)}`) + // 保留意图的 id 形态轴:字符串形态映射后仍是字符串形态 + return typeof primId === 'string' ? String(mapped) : mapped + } + toPrimary(recordName: string, secId: unknown): unknown | undefined { + return this.secToPrim.get(this.key(recordName, secId)) + } +} + +// ---------- 意图翻译(主库 id → 副库 id) ---------- +type AttrRelatedMap = Map> + +function buildAttrRelatedMap(schema: FuzzSchema): AttrRelatedMap { + const result: AttrRelatedMap = new Map(schema.entityNames.map(n => [n, new Map()])) + for (const choice of schema.relationChoices) { + result.get(choice.source)!.set(choice.sourceProperty, choice.target) + if (!choice.symmetric) result.get(choice.target)!.set(choice.targetProperty, choice.source) + } + return result +} + +function translatePayload(payload: Record, entityName: string, attrRelated: AttrRelatedMap, bijection: IdBijection): Record { + const result: Record = {} + for (const [key, value] of Object.entries(payload)) { + const relatedName = attrRelated.get(entityName)?.get(key) + if (!relatedName || value === null || typeof value !== 'object') { + result[key] = value + continue + } + const translateItem = (item: unknown): unknown => { + if (item === null || typeof item !== 'object') return item + const obj = item as Record + const translated = 'id' in obj && obj.id !== undefined && obj.id !== null + ? { ...translatePayload(obj, relatedName, attrRelated, bijection), id: bijection.toSecondary(relatedName, obj.id) } + : translatePayload(obj, relatedName, attrRelated, bijection) + if (obj['&']) translated['&'] = obj['&'] + return translated + } + result[key] = Array.isArray(value) ? value.map(translateItem) : translateItem(value) + } + return result +} + +function translateIntent(intent: Exclude, schema: FuzzSchema, attrRelated: AttrRelatedMap, bijection: IdBijection): Exclude { + if (intent.op === 'create') { + return { ...intent, payload: translatePayload(intent.payload, intent.entityName, attrRelated, bijection) } + } else if (intent.op === 'update') { + return { + ...intent, + id: bijection.toSecondary(intent.entityName, intent.id), + payload: translatePayload(intent.payload, intent.entityName, attrRelated, bijection), + } + } else if (intent.op === 'delete') { + return { ...intent, id: bijection.toSecondary(intent.entityName, intent.id) } + } else if (intent.op === 'addRelation') { + const choice = schema.relationChoices.find(c => c.relation.name === intent.relationName)! + return { + ...intent, + sourceId: bijection.toSecondary(choice.source, intent.sourceId), + targetId: bijection.toSecondary(choice.target, intent.targetId), + } + } else { + return { ...intent, linkId: String(bijection.toSecondary(intent.relationName, intent.linkId)) } + } +} + +// ---------- 事件流锁步对账 + 双射登记 ---------- +function eventIdOf(event: RecordMutationEvent): unknown { + return event.record?.id ?? event.oldRecord?.id +} + +function endpointPresence(event: RecordMutationEvent): string { + return (['source', 'target'] as const).map(endpoint => { + const value = (event.record?.[endpoint] as { id?: unknown } | undefined)?.id + ?? (event.oldRecord?.[endpoint] as { id?: unknown } | undefined)?.id + return value === undefined || value === null ? '0' : '1' + }).join('') +} + +function reconcileEventStreams( + primEvents: RecordMutationEvent[], + secEvents: RecordMutationEvent[], + bijection: IdBijection, + fail: (message: string) => never, + context: string, +) { + // 按 (type, recordName) 分组(组内保持流序)。组多重集必须一致;组内顺序不承诺(见头注)。 + const groupBy = (events: RecordMutationEvent[]) => { + const groups = new Map() + for (const event of events) { + const key = `${event.type}|${event.recordName}` + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(event) + } + return groups + } + const primGroups = groupBy(primEvents), secGroups = groupBy(secEvents) + const allKeys = new Set([...primGroups.keys(), ...secGroups.keys()]) + for (const key of allKeys) { + const prims = primGroups.get(key) ?? [], secs = secGroups.get(key) ?? [] + if (prims.length !== secs.length) { + fail(`${context}: event group "${key}" size diverges (SQLite ${prims.length} vs PGLite ${secs.length})\n` + + `SQLite: ${JSON.stringify(primEvents.map(e => `${e.type} ${e.recordName}#${String(eventIdOf(e))}`))}\n` + + `PGLite: ${JSON.stringify(secEvents.map(e => `${e.type} ${e.recordName}#${String(eventIdOf(e))}`))}`) + } + const [type, recordName] = key.split('|') + if (type === 'create') { + // 嵌套兄弟 create 按载荷序产生(非查询序):位置配对登记双射。 + // 若两条新记录值面完全同构导致配错,随后的快照值面对账会当场戳穿。 + for (let i = 0; i < prims.length; i++) { + const primId = eventIdOf(prims[i]), secId = eventIdOf(secs[i]) + if ((primId === undefined) !== (secId === undefined)) { + fail(`${context}: create ${recordName} event #${i} id presence diverges`) + } + if (primId !== undefined) bijection.register(recordName, primId, secs[i].record!.id, `${context} ${key}#${i}`) + if (endpointPresence(prims[i]) !== endpointPresence(secs[i])) { + fail(`${context}: create ${recordName} event #${i} endpoint presence diverges ` + + `(SQLite ${endpointPresence(prims[i])} vs PGLite ${endpointPresence(secs[i])})`) + } + } + } else { + // delete/update 兄弟事件来自内部查询(行序是驱动方言):按身份多重集配对。 + const secByIdentity = new Map() + for (const sec of secs) { + const mapped = bijection.toPrimary(recordName, eventIdOf(sec)) + const identity = String(mapped) + if (!secByIdentity.has(identity)) secByIdentity.set(identity, []) + secByIdentity.get(identity)!.push(sec) + } + for (const prim of prims) { + const identity = String(eventIdOf(prim)) + const candidates = secByIdentity.get(identity) + if (!candidates?.length) { + fail(`${context}: ${type} ${recordName}#${identity} present on SQLite but PGLite stream has no counterpart ` + + `(PGLite identities: ${JSON.stringify(secs.map(e => String(bijection.toPrimary(recordName, eventIdOf(e)))))})`) + } + const sec = candidates.shift()! + if (type === 'update') { + const primKeys = [...(prim.keys ?? [])].sort(), secKeys = [...(sec.keys ?? [])].sort() + if (JSON.stringify(primKeys) !== JSON.stringify(secKeys)) { + fail(`${context}: update ${recordName}#${identity} keys diverge (SQLite ${JSON.stringify(primKeys)} vs PGLite ${JSON.stringify(secKeys)})`) + } + } + if (endpointPresence(prim) !== endpointPresence(sec)) { + fail(`${context}: ${type} ${recordName}#${identity} endpoint presence diverges ` + + `(SQLite ${endpointPresence(prim)} vs PGLite ${endpointPresence(sec)})`) + } + } + } + } +} + +// ---------- 快照对账(副库快照经双射归一到主库 id 空间) ---------- +function normalizeSecondarySnapshot(snapshot: LogicalSnapshot, bijection: IdBijection, fail: (message: string) => never, context: string): LogicalSnapshot { + const result: LogicalSnapshot = new Map() + for (const [recordName, rows] of snapshot) { + const mappedRows = new Map() + for (const [secId, row] of rows) { + const primId = bijection.toPrimary(recordName, secId) + if (primId === undefined) { + fail(`${context}: PGLite has ${recordName}#${secId} with no SQLite counterpart (never seen in a create event)`) + } + const mappedRow: { [k: string]: unknown } = { ...row } + for (const endpoint of ['source', 'target'] as const) { + if (mappedRow[endpoint] !== undefined && mappedRow[endpoint] !== null) { + // 端点 id 的记录名未知(可能是任一实体)——快照面端点按 String 保留, + // 由对面(主库)同字段的 String 比较承担一致性判定。 + mappedRow[endpoint] = String(mappedRow[endpoint]) + } + } + mappedRows.set(String(primId), mappedRow) + } + result.set(recordName, mappedRows) + } + return result +} + +function reconcileSnapshots( + primSnapshot: LogicalSnapshot, + secSnapshot: LogicalSnapshot, + schema: FuzzSchema, + bijection: IdBijection, + fail: (message: string) => never, + context: string, +) { + const endpointRecordNames = new Map() + for (const choice of schema.relationChoices) { + endpointRecordNames.set(choice.relation.name!, { source: choice.source, target: choice.target }) + } + const mappedSec = normalizeSecondarySnapshot(secSnapshot, bijection, fail, context) + for (const [recordName, primRows] of primSnapshot) { + const secRows = mappedSec.get(recordName) ?? new Map() + const primIds = [...primRows.keys()].sort(), secIds = [...secRows.keys()].sort() + if (JSON.stringify(primIds) !== JSON.stringify(secIds)) { + fail(`${context}: ${recordName} id sets diverge\nSQLite: ${JSON.stringify(primIds)}\nPGLite(mapped): ${JSON.stringify(secIds)}`) + } + const endpoints = endpointRecordNames.get(recordName) + for (const [id, primRow] of primRows) { + const secRow = secRows.get(id)! + const fields = new Set([...Object.keys(primRow), ...Object.keys(secRow)]) + for (const field of fields) { + let primValue = primRow[field] ?? null, secValue = secRow[field] ?? null + if ((field === 'source' || field === 'target') && endpoints) { + // 端点比较经双射:主库端点 id vs 副库端点 id 映射回主库 + const endpointRecord = field === 'source' ? endpoints.source : endpoints.target + const mappedBack = secValue === null ? null : bijection.toPrimary(endpointRecord, secValue) + if (String(primValue) !== String(mappedBack)) { + fail(`${context}: ${recordName}#${id} ${field} endpoint diverges (SQLite ${String(primValue)}, PGLite maps to ${String(mappedBack)})`) + } + continue + } + if (JSON.stringify(primValue) !== JSON.stringify(secValue)) { + fail(`${context}: ${recordName}#${id} field "${field}" diverges (SQLite ${JSON.stringify(primValue)} vs PGLite ${JSON.stringify(secValue)})`) + } + } + } + } +} + +// ---------- runner ---------- +type OpLog = { step: number, op: string, detail: unknown, outcome: string } + +async function runDifferentialCase(seed: number, opsCount: number) { + // 同一种子生成两份结构同构、实例独立的 schema(决策流逐位一致) + const rngPrimary = mulberry32(seed) + const rngSecondary = mulberry32(seed) + const schema = genSchema(rngPrimary, `Dp${seed}_`, {}) + const schemaSecondary = genSchema(rngSecondary, `Dp${seed}_`, {}) + + const primaryDb = new SQLiteDB(':memory:') + const secondaryDb = new PGLiteDB() + await primaryDb.open() + await secondaryDb.open() + let primary: EntityQueryHandle, secondary: EntityQueryHandle + try { + const primarySetup = new DBSetup(schema.entities, schema.relations, primaryDb, schema.mergeLinks.length ? schema.mergeLinks : undefined) + await primarySetup.createTables() + primary = new EntityQueryHandle(new EntityToTableMap(primarySetup.map, primarySetup.aliasManager), primaryDb) + const secondarySetup = new DBSetup(schemaSecondary.entities, schemaSecondary.relations, secondaryDb, schemaSecondary.mergeLinks.length ? schemaSecondary.mergeLinks : undefined) + await secondarySetup.createTables() + secondary = new EntityQueryHandle(new EntityToTableMap(secondarySetup.map, secondarySetup.aliasManager), secondaryDb) + } catch (error) { + await primaryDb.close() + await secondaryDb.close() + return { seed, executed: 0, declarationRejected: true } + } + + const eventSchema: EventCompletenessSchema = { + entities: schema.entityNames, + relations: schema.relationChoices.map(c => c.relation.name!), + } + const attrRelated = buildAttrRelatedMap(schema) + const bijection = new IdBijection() + const pools: IdPools = new Map(schema.entityNames.map(n => [n, []])) + const opLog: OpLog[] = [] + let executed = 0 + + const failWith = (message: string): never => { + const logSlice = process.env.FUZZ_VERBOSE ? opLog : opLog.slice(-5) + throw new Error(`[diff-fuzz seed=${seed}] ${message}\nop log${process.env.FUZZ_VERBOSE ? '' : ' tail'}: ${JSON.stringify(logSlice, null, 2)}`) + } + + for (let step = 0; step < opsCount; step++) { + // 池与意图都来自主库(副库是跟随者);池顺序 = 查询序(两侧一致性由快照对账保证) + for (const entityName of schema.entityNames) { + const rows = await primary.find(entityName, undefined, undefined, ['id']) + pools.set(entityName, rows.map(r => r.id)) + } + const intent = await decideNextOp(rngPrimary, schema, pools, + async (relationName) => (await primary.findRelationByName(relationName, undefined, undefined, ['id'])).map(r => r.id)) + if (!intent) continue + + const context = `step ${step} ${intent.op}` + let secondaryIntent: Exclude + try { + secondaryIntent = translateIntent(intent, schema, attrRelated, bijection) + } catch (error) { + failWith(`${context}: intent translation failed: ${error instanceof Error ? error.message : String(error)}\ndetail: ${JSON.stringify(intent)}`) + } + + const primEvents: RecordMutationEvent[] = [] + const secEvents: RecordMutationEvent[] = [] + let primError: Error | null = null, secError: Error | null = null + try { + await executeOpIntent(primary, intent, primEvents) + } catch (error) { + primError = error instanceof Error ? error : new Error(String(error)) + } + try { + await executeOpIntent(secondary, secondaryIntent!, secEvents) + } catch (error) { + secError = error instanceof Error ? error : new Error(String(error)) + } + + // 1. 错误语义对账 + if ((primError === null) !== (secError === null)) { + failWith(`${context}: error semantics diverge — SQLite ${primError ? `threw: ${primError.message.slice(0, 140)}` : 'succeeded'}, ` + + `PGLite ${secError ? `threw: ${secError.message.slice(0, 140)}` : 'succeeded'}\ndetail: ${JSON.stringify(intent)}`) + } + if (primError && secError) { + const primExpected = isExpectedRejection(primError), secExpected = isExpectedRejection(secError) + if (primExpected !== secExpected) { + failWith(`${context}: rejection classification diverges — SQLite ${primExpected ? 'expected' : `UNEXPECTED: ${primError.message.slice(0, 140)}`}, ` + + `PGLite ${secExpected ? 'expected' : `UNEXPECTED: ${secError.message.slice(0, 140)}`}`) + } + if (!primExpected) { + failWith(`${context}: both drivers threw an UNEXPECTED error: ${primError.message.slice(0, 200)}\ndetail: ${JSON.stringify(intent)}`) + } + opLog.push({ step, op: intent.op, detail: intent, outcome: 'rejected-both' }) + } else { + opLog.push({ step, op: intent.op, detail: intent, outcome: 'ok' }) + executed++ + } + + // 2. 事件流锁步对账(含 create 双射登记;错误路径的部分事件同样必须一致) + try { + reconcileEventStreams(primEvents, secEvents, bijection, failWith, context) + } catch (error) { + if (error instanceof Error && error.message.startsWith('[diff-fuzz')) throw error + failWith(`${context}: ${error instanceof Error ? error.message : String(error)}`) + } + + // 3. 逻辑状态快照对账 + const primSnapshot = await snapshotLogicalState(primary, eventSchema) + const secSnapshot = await snapshotLogicalState(secondary, eventSchema) + reconcileSnapshots(primSnapshot, secSnapshot, schema, bijection, failWith, context) + } + + await primaryDb.close() + await secondaryDb.close() + return { seed, executed, declarationRejected: false } +} + +// ---------- 入口 ---------- +const SEED_START = Number(process.env.FUZZ_DIFF_SEED_START ?? 1) +const SEED_COUNT = Number(process.env.FUZZ_DIFF_SEED_COUNT ?? 6) +const OPS = Number(process.env.FUZZ_DIFF_OPS ?? 25) + +describe('driver differential fuzz (SQLite vs PGLite, same seed, per-op reconciliation)', () => { + const seeds = Array.from({ length: SEED_COUNT }, (_, i) => SEED_START + i) + test.each(seeds.map(s => [s]))('seed %i: same intent stream yields identical events + logical state on both drivers', async (seed) => { + const result = await runDifferentialCase(seed, OPS) + if (!result.declarationRejected) { + expect(result.executed, `diff seed ${seed} executed no ops`).toBeGreaterThan(0) + } + }, 180000) +}) diff --git a/tests/storage/helpers/fuzzOps.ts b/tests/storage/helpers/fuzzOps.ts index e42d49c1..b9e63174 100644 --- a/tests/storage/helpers/fuzzOps.ts +++ b/tests/storage/helpers/fuzzOps.ts @@ -10,10 +10,19 @@ * 与顺序就是契约本身。pools 的内容/顺序由 runner 保证跨库一致(按创建序)。 */ import type { RecordMutationEvent } from '@runtime'; -import { EntityQueryHandle, MatchExp } from '@storage'; +import { MatchExp } from '@storage'; import { chance, int, pick, type Rng } from './fuzzRandom.js'; import type { FuzzSchema, RelationChoice } from './fuzzSchema.js'; +/** 写执行器的最小结构契约:storage 层 EntityQueryHandle 与 runtime 层 System.storage 都满足。 */ +export type FuzzWriteExecutor = { + create(entityName: string, rawData: Record, events?: RecordMutationEvent[]): Promise + update(entityName: string, match: unknown, rawData: Record, events?: RecordMutationEvent[]): Promise + delete(entityName: string, match: unknown, events?: RecordMutationEvent[]): Promise + addRelationByNameById(relationName: string, sourceId: string, targetId: string, rawData?: Record, events?: RecordMutationEvent[]): Promise + removeRelationByName(relationName: string, match: unknown, events?: RecordMutationEvent[]): Promise +} + export type IdPools = Map // 公开 API 把 id 声明为 string(addRelationByNameById(sourceEntityId: string, ...)),HTTP 载荷 @@ -153,8 +162,8 @@ export async function decideNextOp( } } -/** 在一个 EntityQueryHandle 上执行操作意图(id 已是该库的本地形态)。 */ -export async function executeOpIntent(handle: EntityQueryHandle, intent: Exclude, events: RecordMutationEvent[]): Promise { +/** 在一个写执行器上执行操作意图(id 已是该库的本地形态)。 */ +export async function executeOpIntent(handle: FuzzWriteExecutor, intent: Exclude, events: RecordMutationEvent[]): Promise { if (intent.op === 'create') { await handle.create(intent.entityName, intent.payload, events) } else if (intent.op === 'update') { From fc3f0f063abf950ffebed8c4948d8f9644fb11b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 03:31:08 +0000 Subject: [PATCH 3/5] feat(tests): computation-layer generative fuzz (random aggregate declarations vs naive recompute) - Random (source x aggregation x host position) declarations: global dicts over entities/relations/filtered views, property-level aggregates on both relation sides - Count/Summation/Average/Every/Any/WeightedSummation with field-aware menus (relation sources use weight; no-field sources restricted to count) - Oracle: after every op, every declared value equals independent JS naive recompute (non-idempotent aggregates make incremental double-runs/misses visible as value drift) - Oracle sensitivity verified (corrupted truth => 6/6 seeds red) - 60 seeds x 15-20 ops green Co-authored-by: Zhenyu Hou --- .../runtime/computationGenerativeFuzz.spec.ts | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/runtime/computationGenerativeFuzz.spec.ts diff --git a/tests/runtime/computationGenerativeFuzz.spec.ts b/tests/runtime/computationGenerativeFuzz.spec.ts new file mode 100644 index 00000000..3d99e494 --- /dev/null +++ b/tests/runtime/computationGenerativeFuzz.spec.ts @@ -0,0 +1,362 @@ +/** + * 计算层生成式测试(r29,quality-plan §1.3 第 2 步)。 + * + * 动机:aggregationConsistencyMatrix / symmetricAggregationMatrix 已把「聚合 × 源形态」 + * 铺成手工矩阵,但矩阵的 schema 是**固定夹具**——r27 F-2(多 dataDeps 双跑)、r28 F-4 + * (eventDeps 重叠双插)这类 bug 只在特定**声明组合**下现形,夹具枚举不出没想到的组合。 + * 本 fuzzer 把声明本身放进生成域: + * + * - schema:复用共享生成器(helpers/fuzzSchema,含 filtered entity——聚合源可以是视图); + * - 计算声明:随机 (源 × 聚合种类 × 宿主位置) —— 全局 Dictionary 与 property 级 + * (关系两侧)都在菜单内;Count/Summation/Average/Every/Any/WeightedSummation; + * - 操作序列:复用共享操作决策器(对 MonoSystem.storage 直写——计算对 mutation 事件 + * 响应,与 dispatch 无关;事务性 dispatch 轨道由 postgresqlConcurrency 套件承担); + * - 预言机:每步之后,每个声明的**朴素全量重算**(独立 JS 真值,从新查询算起) + * 必须等于存储的计算值。Count/Summation 天然非幂等——增量双跑/漏跑直接体现为 + * 值偏差(r27 F-2 的「夹具幂等性遮蔽」在这里结构性不存在)。 + * + * 表达域刻意未含(后续扩张点,见 quality-plan §1.5):StateMachine/Transform 等 + * 事件驱动计算(需 InteractionEvent 轨道)、async 计算、activity 层。 + * + * 再现:FUZZ_COMP_SEED_START / FUZZ_COMP_SEED_COUNT / FUZZ_COMP_OPS;FUZZ_VERBOSE=1。 + */ +import { describe, expect, test } from "vitest"; +import { + Any, Average, Controller, Count, Dictionary, Every, KlassByName, MonoSystem, + Property, Summation, WeightedSummation, +} from 'interaqt'; +import { PGLiteDB } from '@drivers'; +import { mulberry32, chance, int, pick, genSchema, isExpectedRejection, type FuzzSchema, type Rng } from "../storage/helpers/fuzzSchema.js"; +import { decideNextOp, executeOpIntent, type FuzzOpIntent, type IdPools } from "../storage/helpers/fuzzOps.js"; + +// ---------- 聚合菜单:声明工厂 + 独立 JS 真值 ---------- +type Row = Record +type AggKind = { + name: string + valueType: 'number' | 'boolean' + /** 是否需要数值字段(false 的格可用于无数值字段的关系源) */ + needsField: boolean + /** target: record(全局 dict 用实体/关系实例,field = 源上的数值字段)或 property 名(property 级) */ + createForRecord: (record: unknown, field: string) => unknown + createForProperty: (propertyName: string, hasWeight: boolean) => unknown + truthOverRows: (rows: Row[], field: string, empty: unknown) => unknown +} + +const numOf = (row: Row, field: string): number => typeof row[field] === 'number' ? row[field] as number : 0 +const scoreOf = (row: Row): number => numOf(row, 'score') +const weightOf = (row: Row): number => { + const link = row['&'] as Row | undefined + return typeof link?.weight === 'number' ? link.weight : 1 +} + +const AGG_MENU: AggKind[] = [ + { + name: 'count', valueType: 'number', needsField: false, + createForRecord: (record) => Count.create({ record, attributeQuery: [], callback: () => true } as any), + createForProperty: (propertyName) => Count.create({ property: propertyName } as any), + truthOverRows: (rows) => rows.length, + }, + { + name: 'countCb', valueType: 'number', needsField: true, + createForRecord: (record, field) => Count.create({ record, attributeQuery: [field], callback: (r: Row) => numOf(r, field) > 50 } as any), + createForProperty: (propertyName) => Count.create({ property: propertyName, attributeQuery: ['score'], callback: (r: Row) => scoreOf(r) > 50 } as any), + truthOverRows: (rows, field) => rows.filter(r => numOf(r, field) > 50).length, + }, + { + name: 'sum', valueType: 'number', needsField: true, + createForRecord: (record, field) => Summation.create({ record, attributeQuery: [field] } as any), + createForProperty: (propertyName) => Summation.create({ property: propertyName, attributeQuery: ['score'] } as any), + truthOverRows: (rows, field) => rows.reduce((acc, r) => acc + numOf(r, field), 0), + }, + { + name: 'avg', valueType: 'number', needsField: true, + createForRecord: (record, field) => Average.create({ record, attributeQuery: [field] } as any), + createForProperty: (propertyName) => Average.create({ property: propertyName, attributeQuery: ['score'] } as any), + truthOverRows: (rows, field, empty) => { + const nums = rows.map(r => r[field]).filter((v): v is number => typeof v === 'number') + return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : empty + }, + }, + { + name: 'every', valueType: 'boolean', needsField: true, + createForRecord: (record, field) => Every.create({ record, attributeQuery: [field], callback: (r: Row) => numOf(r, field) > 10, notEmpty: true } as any), + createForProperty: (propertyName) => Every.create({ property: propertyName, attributeQuery: ['score'], callback: (r: Row) => scoreOf(r) > 10, notEmpty: true } as any), + truthOverRows: (rows, field, empty) => rows.length === 0 ? empty : rows.every(r => numOf(r, field) > 10), + }, + { + name: 'any', valueType: 'boolean', needsField: true, + createForRecord: (record, field) => Any.create({ record, attributeQuery: [field], callback: (r: Row) => numOf(r, field) > 80 } as any), + createForProperty: (propertyName) => Any.create({ property: propertyName, attributeQuery: ['score'], callback: (r: Row) => scoreOf(r) > 80 } as any), + truthOverRows: (rows, field, empty) => rows.length === 0 ? empty : rows.some(r => numOf(r, field) > 80), + }, + { + name: 'weighted', valueType: 'number', needsField: true, + createForRecord: (record, field) => WeightedSummation.create({ + record, attributeQuery: [field], + callback: (r: Row) => ({ weight: 2, value: numOf(r, field) }), + } as any), + createForProperty: (propertyName, hasWeight) => WeightedSummation.create({ + property: propertyName, + attributeQuery: hasWeight ? ['score', ['&', { attributeQuery: ['weight'] }]] : ['score'], + callback: hasWeight + ? (r: Row) => ({ weight: weightOf(r), value: scoreOf(r) }) + : (r: Row) => ({ weight: 2, value: scoreOf(r) }), + } as any), + truthOverRows: () => { throw new Error('weighted truth is context-specific, computed at cell build time') }, + }, +] + +// ---------- 声明生成 ---------- +type GlobalCell = { + cell: string + dictName: string + /** 独立 JS 真值:从查询行重算 */ + naive: (rows: Row[], empty: unknown) => unknown + /** 数据源查询(实体名或关系名 + 是否关系) */ + sourceName: string + isRelation: boolean + /** filtered 源:谓词真值(作用于 base 全集);无谓词 = 全集 */ + basePredicate?: (row: Row) => boolean + baseName?: string +} +type PropertyCell = { + cell: string + hostEntity: string + propertyName: string + /** 宿主一侧的关系属性名(嵌套读取用);含 & weight */ + relationProperty: string + hasWeight: boolean + naive: (relatedRows: Row[], empty: unknown) => unknown +} + +function genComputationCells(rng: Rng, schema: FuzzSchema) { + const dictionaries: unknown[] = [] + const globalCells: GlobalCell[] = [] + const propertyCells: PropertyCell[] = [] + const declarationErrors: string[] = [] + + // 全局 Dictionary:2..4 个 (源 × 聚合)。源菜单 = 实体 ∪ 关系 ∪ filtered 实体。 + // 数值字段:实体/filtered 源用 score;关系源用 weight(无 weight 的关系只能上无字段聚合)。 + type SourceChoice = { key: string, record: unknown, sourceName: string, isRelation: boolean, numericField: string | null, basePredicate?: (row: Row) => boolean, baseName?: string } + const sourceMenu: SourceChoice[] = [ + ...schema.entities.filter(e => schema.entityNames.includes(e.name)).map(entity => ({ + key: `entity:${entity.name}`, record: entity, sourceName: entity.name, isRelation: false, numericField: 'score', + })), + ...schema.relationChoices.map(choice => ({ + key: `relation:${choice.relation.name}`, record: choice.relation, sourceName: choice.relation.name!, isRelation: true, + numericField: choice.linkProps.includes('weight') ? 'weight' : null, + })), + ...schema.filteredEntities.map(filtered => ({ + key: `filtered:${filtered.name}`, record: filtered.entity, sourceName: filtered.name, isRelation: false, numericField: 'score', + basePredicate: filtered.predicate, baseName: filtered.baseName, + })), + ] + const globalCount = 2 + int(rng, 3) + for (let i = 0; i < globalCount; i++) { + const source = pick(rng, sourceMenu) + const menu = source.numericField ? AGG_MENU : AGG_MENU.filter(a => !a.needsField) + const agg = pick(rng, menu) + const field = source.numericField ?? '' + const dictName = `fz_${i}_${agg.name}` + const naive: GlobalCell['naive'] = agg.name === 'weighted' + ? (rows) => rows.reduce((acc, r) => acc + 2 * numOf(r, field), 0) + : (rows, empty) => agg.truthOverRows(rows, field, empty) + try { + const computation = agg.createForRecord(source.record, field) + dictionaries.push(Dictionary.create({ name: dictName, type: agg.valueType, collection: false, computation } as any)) + globalCells.push({ + cell: `${source.key}/${agg.name}`, dictName, + sourceName: source.sourceName, isRelation: source.isRelation, + basePredicate: source.basePredicate, baseName: source.baseName, + naive, + }) + } catch (error) { + declarationErrors.push(`${source.key}/${agg.name}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + // property 级:1..3 个 (关系一侧 × 聚合),宿主属性由计算维护 + const roleMenu = schema.relationChoices.flatMap(choice => { + const roles = [{ host: choice.source, relationProperty: choice.sourceProperty, choice }] + if (!choice.symmetric) roles.push({ host: choice.target, relationProperty: choice.targetProperty, choice }) + return roles + }) + const propertyCount = 1 + int(rng, 3) + for (let i = 0; i < propertyCount && roleMenu.length; i++) { + const role = pick(rng, roleMenu) + const agg = pick(rng, AGG_MENU) + const hasWeight = role.choice.linkProps.includes('weight') + const propertyName = `fzp${i}_${agg.name}` + // property 级读的是关联**实体**行(score 恒在),weighted 额外读 & weight + const naive: PropertyCell['naive'] = agg.name === 'weighted' + ? (rows) => rows.reduce((acc, r) => acc + (hasWeight ? weightOf(r) : 2) * scoreOf(r), 0) + : (rows, empty) => agg.truthOverRows(rows, 'score', empty) + try { + const computation = agg.createForProperty(role.relationProperty, hasWeight) + const hostEntity = schema.entities.find(e => e.name === role.host)! + hostEntity.properties.push(Property.create({ name: propertyName, type: agg.valueType, computation } as any)) + propertyCells.push({ + cell: `${role.host}.${role.relationProperty}/${agg.name}`, + hostEntity: role.host, propertyName, + relationProperty: role.relationProperty, hasWeight, + naive, + }) + } catch (error) { + declarationErrors.push(`${role.host}.${role.relationProperty}/${agg.name}: ${error instanceof Error ? error.message : String(error)}`) + } + } + return { dictionaries, globalCells, propertyCells, declarationErrors } +} + +// property 级空集约定(框架语义,与 symmetricAggregationMatrix 的断言一致): +// count/sum/weighted/avg → 0;every(notEmpty:true) → false;any → false +const PROPERTY_EMPTY: Record = { + count: 0, countCb: 0, sum: 0, avg: 0, weighted: 0, every: false, any: false, +} + +const near = (a: unknown, b: unknown) => { + if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) < 1e-9 + return a === b +} + +// ---------- runner ---------- +async function runComputationFuzzCase(seed: number, opsCount: number) { + const rng = mulberry32(seed) + // filtered entity 进入生成域(聚合源可以是视图);merged 不进(EXT-1 收口前,见 fuzzSchema 注释) + const schema = genSchema(rng, `C${seed}_`, { includeFiltered: true }) + const { dictionaries, globalCells, propertyCells, declarationErrors } = genComputationCells(rng, schema) + + const system = new MonoSystem(new PGLiteDB()) + system.conceptClass = KlassByName + let controller: Controller + try { + controller = new Controller({ + system, + entities: schema.entities as any, + relations: schema.relations as any, + dict: dictionaries as any, + }) + await controller.setup(true) + } catch (error) { + // 声明期拒绝:合法 fail-fast(如 mergeLinks 冲突、计算源不支持某拓扑的显式错误) + if (error instanceof Error && /must|cannot|not supported|already|conflict/i.test(error.message)) { + await system.destroy() + return { seed, executed: 0, declarationRejected: true } + } + throw new Error(`[comp-fuzz seed=${seed}] setup threw an UNEXPECTED error: ${error instanceof Error ? error.message : String(error)}\n` + + `cells: ${JSON.stringify([...globalCells.map(c => c.cell), ...propertyCells.map(c => c.cell)])}`) + } + const storage = system.storage + + // 空集约定:setup 后全局 dict 的初始值就是该声明的空集语义 + const globalEmpty: Record = {} + for (const cell of globalCells) { + globalEmpty[cell.cell] = await storage.dict.get(cell.dictName) + } + + const opLog: { step: number, op: string, detail: unknown, outcome: string }[] = [] + let executed = 0 + + const failWith = (message: string): never => { + const logSlice = process.env.FUZZ_VERBOSE ? opLog : opLog.slice(-6) + const cellDump = JSON.stringify([...globalCells.map(c => c.cell), ...propertyCells.map(c => c.cell)]) + throw new Error(`[comp-fuzz seed=${seed}] ${message}\ncells: ${cellDump}\n` + + `declaration rejections: ${JSON.stringify(declarationErrors)}\n` + + `op log${process.env.FUZZ_VERBOSE ? '' : ' tail'}: ${JSON.stringify(logSlice, null, 2)}`) + } + + const assertAllCells = async (context: string) => { + // 全局格:值 = 朴素重算(源全集) + for (const cell of globalCells) { + const actual = await storage.dict.get(cell.dictName) + let rows: Row[] + if (cell.isRelation) { + rows = await storage.findRelationByName(cell.sourceName, undefined, undefined, + ['*', ['source', { attributeQuery: ['id'] }], ['target', { attributeQuery: ['id'] }]]) as Row[] + } else if (cell.basePredicate && cell.baseName) { + // filtered 源的真值独立于被测查询编译:从 base 全集 + 声明谓词重算 + const baseRows = await storage.find(cell.baseName, undefined, undefined, ['*']) as Row[] + rows = baseRows.filter(cell.basePredicate) + } else { + rows = await storage.find(cell.sourceName, undefined, undefined, ['*']) as Row[] + } + const expected = cell.naive(rows, globalEmpty[cell.cell]) + if (!near(actual, expected)) { + failWith(`${context}: global cell ${cell.cell} (dict ${cell.dictName}) diverges from naive recompute — expected ${JSON.stringify(expected)}, actual ${JSON.stringify(actual)}`) + } + } + // property 格:每个宿主的值 = 朴素重算(该宿主的关联行) + for (const cell of propertyCells) { + const hosts = await storage.find(cell.hostEntity, undefined, undefined, + ['id', cell.propertyName, + [cell.relationProperty, { attributeQuery: ['id', 'score', ...(cell.hasWeight ? [['&', { attributeQuery: ['weight'] }]] as any[] : [])] }]]) as Row[] + for (const host of hosts) { + const related = host[cell.relationProperty] + const relatedRows = (Array.isArray(related) ? related : (related ? [related] : [])) as Row[] + const expected = cell.naive(relatedRows, PROPERTY_EMPTY[cell.cell.split('/')[1]]) + const actual = host[cell.propertyName] + if (!near(actual, expected)) { + failWith(`${context}: property cell ${cell.cell} on ${cell.hostEntity}#${host.id} diverges from naive recompute — ` + + `expected ${JSON.stringify(expected)}, actual ${JSON.stringify(actual)} (related: ${JSON.stringify(relatedRows)})`) + } + } + } + } + + await assertAllCells('after setup') + + const pools: IdPools = new Map(schema.entityNames.map(n => [n, []])) + for (let step = 0; step < opsCount; step++) { + for (const entityName of schema.entityNames) { + const rows = await storage.find(entityName, undefined, undefined, ['id']) + pools.set(entityName, rows.map((r: Row) => r.id)) + } + // 前置条件不足(池空)时重抽几次:计算层 fuzz 的价值在「写序列触发增量维护」, + // 空跑步浪费格子(重抽只消耗 rng,决策流仍由种子完全决定) + let intent: FuzzOpIntent = null + for (let attempt = 0; attempt < 8 && !intent; attempt++) { + intent = await decideNextOp(rng, schema, pools, + async (relationName) => (await storage.findRelationByName(relationName, undefined, undefined, ['id'])).map((r: Row) => r.id)) + } + if (!intent) continue + + let threw: Error | null = null + try { + await executeOpIntent(storage, intent, []) + } catch (error) { + threw = error instanceof Error ? error : new Error(String(error)) + } + if (threw) { + opLog.push({ step, op: intent.op, detail: intent, outcome: `rejected: ${threw.message.slice(0, 120)}` }) + if (!isExpectedRejection(threw)) { + failWith(`step ${step} ${intent.op} threw an UNEXPECTED error: ${threw.message}\ndetail: ${JSON.stringify(intent)}`) + } + } else { + opLog.push({ step, op: intent.op, detail: intent, outcome: 'ok' }) + executed++ + } + await assertAllCells(`step ${step} ${intent.op}(${threw ? 'rejected' : 'ok'})`) + } + + // 覆盖度自检就地报告(带操作日志——全拒绝的空跑必须可诊断) + if (executed === 0 && opsCount > 0) { + failWith(`executed no ops (over-rejection? pools never filled?)`) + } + await system.destroy() + return { seed, executed, declarationRejected: false } +} + +// ---------- 入口 ---------- +const SEED_START = Number(process.env.FUZZ_COMP_SEED_START ?? 1) +const SEED_COUNT = Number(process.env.FUZZ_COMP_SEED_COUNT ?? 6) +const OPS = Number(process.env.FUZZ_COMP_OPS ?? 20) + +describe('computation-layer generative fuzz (random aggregate declarations × random write sequences vs naive recompute)', () => { + const seeds = Array.from({ length: SEED_COUNT }, (_, i) => SEED_START + i) + test.each(seeds.map(s => [s]))('seed %i: every declared aggregation equals naive recompute after every op', async (seed) => { + const result = await runComputationFuzzCase(seed, OPS) + if (!result.declarationRejected) { + expect(result.executed, `comp seed ${seed} executed no ops`).toBeGreaterThan(0) + } + }, 300000) +}) From 2ab13d873df68f736185a63613abdc7de655b9e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 03:43:43 +0000 Subject: [PATCH 4/5] feat(tests): migration generative fuzz (random schema pair + data -> migrate vs oracles, kill-resume) - Random v1 schema (stable uuids) x random seed data x 1-3 additive mutations (property+default / entity / n:n relation / global Count dict / property Count) - Real two-step review flow (generateMigrationDiff -> approve -> migrate) - Oracles: pre/post logical snapshot fidelity, default backfill, computation backfill vs naive recompute, post-migration smoke writes, kill-resume convergence (fault-injected db proxy, even seeds; crash -> fresh controller -> re-migrate) - Fix: property-mode aggregation template full compute crashed on to-one relation attributes (object, not array) - migration backfill track only; all six aggregation kinds share the single normalized read point - Extract shared approveGeneratedMigrationDiff helper (migration.spec now imports) - 60 seeds green (incl. injected-crash resume paths) Co-authored-by: Zhenyu Hou --- .../computations/aggregationTemplate.ts | 8 +- tests/runtime/helpers/migrationApproval.ts | 100 ++++ tests/runtime/migration.spec.ts | 95 +--- tests/runtime/migrationGenerativeFuzz.spec.ts | 445 ++++++++++++++++++ 4 files changed, 553 insertions(+), 95 deletions(-) create mode 100644 tests/runtime/helpers/migrationApproval.ts create mode 100644 tests/runtime/migrationGenerativeFuzz.spec.ts diff --git a/src/runtime/computations/aggregationTemplate.ts b/src/runtime/computations/aggregationTemplate.ts index 932e260f..05be8c14 100644 --- a/src/runtime/computations/aggregationTemplate.ts +++ b/src/runtime/computations/aggregationTemplate.ts @@ -372,7 +372,13 @@ export abstract class PropertyRelationAggregationHandle { - const relations = _current[this.relationAttr] || [] + // CAUTION x:1 关系的宿主属性是 to-one:查询返回对象而非数组(r29,迁移生成 fuzz 首跑 + // seed 3 抓获)。运行期增量路径按 link 事件逐项维护、从不带着已填充的 to-one 走全量 + // compute,所以裸 for...of 一直没炸;迁移/手动全量重算轨会带着填充值进来—— + // 「property 聚合的关联行集合」在此归一,所有聚合模板(Count/Sum/Avg/Every/Any/ + // Weighted 的 property 模式)共用这一个读取点。 + const relatedOfCurrent = _current[this.relationAttr] + const relations = Array.isArray(relatedOfCurrent) ? relatedOfCurrent : (relatedOfCurrent ? [relatedOfCurrent] : []) const values: V[] = [] for (const relatedItem of relations) { const relationStateRecord = relatedItem[LINK_SYMBOL] || relatedItem diff --git a/tests/runtime/helpers/migrationApproval.ts b/tests/runtime/helpers/migrationApproval.ts new file mode 100644 index 00000000..8b0aa38f --- /dev/null +++ b/tests/runtime/helpers/migrationApproval.ts @@ -0,0 +1,100 @@ +/** + * 迁移审批测试助手(从 migration.spec.ts 抽取的唯一实现,r29): + * 生成 diff → 按推荐决策批准全部 requiredDecisions → migrate。 + * migration.spec 与迁移生成式 fuzzer(migrationGenerativeFuzz.spec.ts)共用。 + */ +import { Controller } from "interaqt"; + +export async function approveGeneratedMigrationDiff(controller: Controller, options: { + includeFunctionText?: boolean; + includeDestructiveScope?: boolean; + eventHandlers?: Record; + asyncHandlers?: Record; + computationDecisions?: Record; +} = {}) { + const diff = await controller.generateMigrationDiff({ + includeFunctionText: options.includeFunctionText ?? true, + includeDestructiveScope: options.includeDestructiveScope ?? true, + }); + const decisions = [ + ...diff.decisions, + ...diff.requiredDecisions.map(requirement => { + if (requirement.kind === "computation") { + return { + kind: "computation" as const, + id: requirement.id, + dataContext: requirement.dataContext, + decision: options.computationDecisions?.[requirement.id] || requirement.recommendedDecision, + reason: "approved by migration test", + }; + } + if (requirement.kind === "event-rebuild-handler") { + return { + kind: "event-rebuild-handler" as const, + dataContext: requirement.dataContext, + handlerRef: options.eventHandlers?.[requirement.dataContext] || requirement.dataContext, + reason: "approved by migration test", + }; + } + if (requirement.kind === "async-completion-handler") { + return { + kind: "async-completion-handler" as const, + dataContext: requirement.dataContext, + handlerRef: options.asyncHandlers?.[requirement.dataContext] || requirement.dataContext, + reason: "approved by migration test", + }; + } + if (requirement.kind === "computation-takeover") { + return { + kind: "computation-takeover" as const, + dataContext: requirement.dataContext, + computationId: requirement.computationId, + targetType: requirement.targetType, + previousAuthority: requirement.previousAuthority, + nextAuthority: requirement.nextAuthority, + oldDataStrategy: requirement.oldDataStrategy, + expectedExistingCount: requirement.expectedExistingCount, + expectedHostCount: requirement.expectedHostCount, + destructiveScopeRef: requirement.destructiveScopeRef, + reason: "approved by migration test", + }; + } + if (requirement.kind === "empty-fact-record-removal") { + return { + kind: "empty-fact-record-removal" as const, + recordName: requirement.recordName, + tableName: requirement.tableName, + expectedCount: requirement.expectedCount, + reason: "approved by migration test", + }; + } + if (requirement.kind === "scoped-sequence-seed" || requirement.kind === "scoped-sequence-no-seed") { + return { + ...requirement, + reason: "approved by migration test", + }; + } + return { + kind: "destructive-scope" as const, + dataContext: requirement.dataContext, + recordName: requirement.recordName, + ids: requirement.ids, + reason: "approved by migration test", + }; + }), + ]; + return { + ...diff, + status: "approved" as const, + decisions, + }; +} + +export async function migrateWithApproval(controller: Controller, options: Parameters[0] = {}) { + const approvedDiff = options.approvedDiff || await approveGeneratedMigrationDiff(controller); + return controller.migrate({ ...options, approvedDiff }); +} + +export async function dryRunWithApproval(controller: Controller, options: Parameters[0] = {}) { + return migrateWithApproval(controller, { ...options, dryRun: true }); +} diff --git a/tests/runtime/migration.spec.ts b/tests/runtime/migration.spec.ts index 89f94578..a1b7b06e 100644 --- a/tests/runtime/migration.spec.ts +++ b/tests/runtime/migration.spec.ts @@ -1,100 +1,7 @@ import { describe, expect, test } from "vitest"; import { Average, Any, Controller, ComputationResult, Count, Custom, Dictionary, Entity, Every, Expression, GlobalBoundState, KlassByName, MatchExp, MonoSystem, NonNullConstraint, Property, RealTime, RecordMutationSideEffect, Relation, StateMachine, StateNode, StateTransfer, Summation, Transform, UniqueConstraint, WeightedSummation, computationManifestId, createMigrationManifest, hashMigrationDiff, readMigrationManifest, validateApprovedDiff, writeMigrationManifest } from "interaqt"; import { PGLiteDB } from "@drivers"; - -async function approveGeneratedMigrationDiff(controller: Controller, options: { - includeFunctionText?: boolean; - includeDestructiveScope?: boolean; - eventHandlers?: Record; - asyncHandlers?: Record; - computationDecisions?: Record; -} = {}) { - const diff = await controller.generateMigrationDiff({ - includeFunctionText: options.includeFunctionText ?? true, - includeDestructiveScope: options.includeDestructiveScope ?? true, - }); - const decisions = [ - ...diff.decisions, - ...diff.requiredDecisions.map(requirement => { - if (requirement.kind === "computation") { - return { - kind: "computation" as const, - id: requirement.id, - dataContext: requirement.dataContext, - decision: options.computationDecisions?.[requirement.id] || requirement.recommendedDecision, - reason: "approved by migration test", - }; - } - if (requirement.kind === "event-rebuild-handler") { - return { - kind: "event-rebuild-handler" as const, - dataContext: requirement.dataContext, - handlerRef: options.eventHandlers?.[requirement.dataContext] || requirement.dataContext, - reason: "approved by migration test", - }; - } - if (requirement.kind === "async-completion-handler") { - return { - kind: "async-completion-handler" as const, - dataContext: requirement.dataContext, - handlerRef: options.asyncHandlers?.[requirement.dataContext] || requirement.dataContext, - reason: "approved by migration test", - }; - } - if (requirement.kind === "computation-takeover") { - return { - kind: "computation-takeover" as const, - dataContext: requirement.dataContext, - computationId: requirement.computationId, - targetType: requirement.targetType, - previousAuthority: requirement.previousAuthority, - nextAuthority: requirement.nextAuthority, - oldDataStrategy: requirement.oldDataStrategy, - expectedExistingCount: requirement.expectedExistingCount, - expectedHostCount: requirement.expectedHostCount, - destructiveScopeRef: requirement.destructiveScopeRef, - reason: "approved by migration test", - }; - } - if (requirement.kind === "empty-fact-record-removal") { - return { - kind: "empty-fact-record-removal" as const, - recordName: requirement.recordName, - tableName: requirement.tableName, - expectedCount: requirement.expectedCount, - reason: "approved by migration test", - }; - } - if (requirement.kind === "scoped-sequence-seed" || requirement.kind === "scoped-sequence-no-seed") { - return { - ...requirement, - reason: "approved by migration test", - }; - } - return { - kind: "destructive-scope" as const, - dataContext: requirement.dataContext, - recordName: requirement.recordName, - ids: requirement.ids, - reason: "approved by migration test", - }; - }), - ]; - return { - ...diff, - status: "approved" as const, - decisions, - }; -} - -async function migrateWithApproval(controller: Controller, options: Parameters[0] = {}) { - const approvedDiff = options.approvedDiff || await approveGeneratedMigrationDiff(controller); - return controller.migrate({ ...options, approvedDiff }); -} - -async function dryRunWithApproval(controller: Controller, options: Parameters[0] = {}) { - return migrateWithApproval(controller, { ...options, dryRun: true }); -} +import { approveGeneratedMigrationDiff, dryRunWithApproval, migrateWithApproval } from "./helpers/migrationApproval.js"; describe("Data migration phase 1", () => { test("generateMigrationDiff and approvedDiff validation enforce two-step review", async () => { diff --git a/tests/runtime/migrationGenerativeFuzz.spec.ts b/tests/runtime/migrationGenerativeFuzz.spec.ts new file mode 100644 index 00000000..3fd1f82f --- /dev/null +++ b/tests/runtime/migrationGenerativeFuzz.spec.ts @@ -0,0 +1,445 @@ +/** + * 迁移生成式测试(r29,quality-plan §1.3 第 3 步)。 + * + * 动机:migration.spec 的 90 个手工用例各覆盖一个决策路径,但「随机 v1 schema × 随机 + * 存量数据 × 随机加法变异 × migrate」的组合空间从未被机器探索——迁移引擎的正确性 + * 契约(存量数据保真、默认值回填、新计算回填、恢复幂等)是全局性质,正适合预言机判定。 + * + * 机制(单条 rng 决策流,种子完全决定): + * - v1 schema:3 实体(label/score+default) × 2..3 关系(n:n / n:1 / 1:1,含 link 属性)—— + * 全部声明携带**稳定 uuid**(迁移按 uuid 对齐两版声明); + * - 存量数据:共享操作决策器直写 10..15 步(创建/更新/嵌套/关系增删全形态); + * - v2 = v1 + 1..3 个加法变异(菜单:加默认值属性 / 加实体 / 加 n:n 关系 / + * 加全局 Count dict / 加 property 级 Count); + * - migrate:经 generateMigrationDiff → 全决策批准 → migrate(真实两步审查流)。 + * + * 预言机: + * 1. 存量保真:迁移前后 v1 逻辑快照逐字段相等(id、值字段、关系端点); + * 2. 默认值回填:加属性变异后,存量行的新列 = defaultValue()(migration.spec 固化的契约); + * 3. 新计算回填:加计算变异后,计算值 = 朴素全量重算(独立 JS 真值); + * 4. 迁移后可写:每个实体(含新增)创建冒烟写 + 计算联动断言; + * 5. kill-resume(偶数种子):首次 migrate 注入一次故障(第 N 次 DB 调用抛错)→ + * 重跑 migrate 必须成功收敛,且 1-4 全部成立(恢复幂等契约)。 + * + * 再现:FUZZ_MIG_SEED_START / FUZZ_MIG_SEED_COUNT / FUZZ_MIG_OPS;FUZZ_VERBOSE=1。 + */ +import { describe, expect, test } from "vitest"; +import { Controller, Count, Dictionary, Entity, KlassByName, MonoSystem, Property, Relation } from 'interaqt'; +import type { Database } from '@runtime'; +import { PGLiteDB } from '@drivers'; +import { mulberry32, chance, int, pick, isExpectedRejection, type Rng } from "../storage/helpers/fuzzSchema.js"; +import { decideNextOp, executeOpIntent, type FuzzOpIntent, type IdPools } from "../storage/helpers/fuzzOps.js"; +import type { FuzzSchema, RelationChoice } from "../storage/helpers/fuzzSchema.js"; +import { snapshotLogicalState, type EventCompletenessSchema, type LogicalSnapshot } from "../storage/helpers/eventCompleteness.js"; +import { approveGeneratedMigrationDiff } from "./helpers/migrationApproval.js"; + +type Row = Record + +// ---------- 版本对生成(共享 uuid) ---------- +type MutationDescriptor = + | { kind: 'addProperty', entityName: string, propertyName: string, defaultValue: number } + | { kind: 'addEntity', entityName: string } + | { kind: 'addRelation', relationName: string, source: string, target: string, sourceProperty: string, targetProperty: string } + | { kind: 'addGlobalCount', dictName: string, sourceEntity: string } + | { kind: 'addPropertyCount', hostEntity: string, propertyName: string, relationProperty: string } + +type VersionedDecls = { + entities: unknown[] + relations: unknown[] + dictionaries: unknown[] +} + +function genMigrationPair(rng: Rng, tag: string): { + v1: VersionedDecls, v2: VersionedDecls, + v1View: FuzzSchema, mutations: MutationDescriptor[], +} { + const entityNames = ['A', 'B', 'C'].map(n => `Mg${tag}${n}`) + const relTypeMenu = ['n:n', 'n:1', '1:1'] as const + + // 关系决策先抽好(两版共享同一决策) + const relationCount = 2 + int(rng, 2) + const relationDecisions: Array<{ relType: '1:1' | 'n:1' | 'n:n', source: string, target: string, hasWeight: boolean, index: number }> = [] + for (let i = 0; i < relationCount; i++) { + const relType = pick(rng, relTypeMenu as unknown as Array<'1:1' | 'n:1' | 'n:n'>) + const source = pick(rng, entityNames) + let target = pick(rng, entityNames) + if (target === source) target = entityNames[(entityNames.indexOf(source) + 1) % entityNames.length] + relationDecisions.push({ relType, source, target, hasWeight: chance(rng, 0.5), index: i }) + } + // 变异决策也先抽好 + const mutationCount = 1 + int(rng, 3) + const mutationKinds: MutationDescriptor['kind'][] = [] + for (let i = 0; i < mutationCount; i++) { + mutationKinds.push(pick(rng, ['addProperty', 'addEntity', 'addRelation', 'addGlobalCount', 'addPropertyCount'] as const)) + } + const mutationTargets = mutationKinds.map(() => ({ entityPick: rng(), relationPick: rng() })) + + // 同一决策构造一版声明(uuid 稳定 ⇒ 两次调用产出可对齐的两套实例) + const build = (version: 1 | 2): { decls: VersionedDecls, v1View?: FuzzSchema, mutations: MutationDescriptor[] } => { + const entityByName = new Map>() + const valueProps = new Map() + for (const name of entityNames) { + const entity = new Entity({ + name, + properties: [ + new Property({ name: 'label', type: 'string' }, { uuid: `${tag}-${name}-label` }), + new Property({ name: 'score', type: 'number', defaultValue: () => 7 }, { uuid: `${tag}-${name}-score` }), + ], + }, { uuid: `${tag}-${name}` }) + entityByName.set(name, entity) + valueProps.set(name, [{ name: 'label', type: 'string' }, { name: 'score', type: 'number' }]) + } + const relations: unknown[] = [] + const relationChoices: RelationChoice[] = [] + for (const decision of relationDecisions) { + const sourceProperty = `out${decision.index}`, targetProperty = `in${decision.index}` + const linkProps = decision.hasWeight ? ['weight'] : [] + const relation = new Relation({ + source: entityByName.get(decision.source)!, + sourceProperty, + target: entityByName.get(decision.target)!, + targetProperty, + type: decision.relType, + properties: decision.hasWeight + ? [new Property({ name: 'weight', type: 'number', defaultValue: () => 1 }, { uuid: `${tag}-rel${decision.index}-weight` })] + : [], + } as any, { uuid: `${tag}-rel${decision.index}` }) + relations.push(relation) + relationChoices.push({ + relation: relation as any, relType: decision.relType, + source: decision.source, target: decision.target, + sourceProperty, targetProperty, symmetric: false, linkProps, + }) + } + + const dictionaries: unknown[] = [] + const mutations: MutationDescriptor[] = [] + if (version === 2) { + for (let i = 0; i < mutationKinds.length; i++) { + const kind = mutationKinds[i] + const entityName = entityNames[Math.floor(mutationTargets[i].entityPick * entityNames.length)] + const relationChoice = relationChoices[Math.floor(mutationTargets[i].relationPick * relationChoices.length)] + if (kind === 'addProperty') { + const propertyName = `extra${i}` + entityByName.get(entityName)!.properties.push( + new Property({ name: propertyName, type: 'number', defaultValue: () => 5 }, { uuid: `${tag}-mut${i}-prop` })) + mutations.push({ kind, entityName, propertyName, defaultValue: 5 }) + } else if (kind === 'addEntity') { + const newName = `Mg${tag}N${i}` + const entity = new Entity({ + name: newName, + properties: [ + new Property({ name: 'label', type: 'string' }, { uuid: `${tag}-mut${i}-label` }), + new Property({ name: 'score', type: 'number', defaultValue: () => 7 }, { uuid: `${tag}-mut${i}-score` }), + ], + }, { uuid: `${tag}-mut${i}-entity` }) + entityByName.set(newName, entity) + mutations.push({ kind, entityName: newName }) + } else if (kind === 'addRelation') { + const source = entityName + const target = entityNames[(entityNames.indexOf(source) + 1) % entityNames.length] + const sourceProperty = `mout${i}`, targetProperty = `min${i}` + const relation = new Relation({ + source: entityByName.get(source)!, sourceProperty, + target: entityByName.get(target)!, targetProperty, + type: 'n:n', properties: [], + } as any, { uuid: `${tag}-mut${i}-rel` }) + relations.push(relation) + mutations.push({ kind, relationName: (relation as { name?: string }).name!, source, target, sourceProperty, targetProperty }) + } else if (kind === 'addGlobalCount') { + const dictName = `mg_${tag}_cnt${i}` + dictionaries.push(Dictionary.create({ + name: dictName, type: 'number', collection: false, + computation: Count.create({ record: entityByName.get(entityName)!, attributeQuery: [], callback: () => true } as any), + } as any)) + mutations.push({ kind, dictName, sourceEntity: entityName }) + } else { + const propertyName = `mcnt${i}` + entityByName.get(relationChoice.source)!.properties.push( + new Property({ + name: propertyName, type: 'number', + computation: Count.create({ property: relationChoice.sourceProperty } as any), + } as any, { uuid: `${tag}-mut${i}-pcnt` })) + mutations.push({ kind, hostEntity: relationChoice.source, propertyName, relationProperty: relationChoice.sourceProperty }) + } + } + } + + const v1View: FuzzSchema = { + entities: [...entityByName.values()] as any, + relations: relations as any, + mergeLinks: [], + entityNames, + relationChoices, + valueProps, + filteredEntities: [], + filteredRelations: [], + mergedEntities: [], + } + return { decls: { entities: [...entityByName.values()], relations, dictionaries }, v1View, mutations } + } + + // CAUTION 两次 build 消耗 rng 的方式必须一致(决策已提前抽好,build 内不再抽签) + const v1Build = build(1) + const v2Build = build(2) + return { v1: v1Build.decls, v2: v2Build.decls, v1View: v1Build.v1View!, mutations: v2Build.mutations } +} + +// ---------- 故障注入(kill-resume) ---------- +function createFaultInjectedDb(inner: Database, faultAtCall: number): Database & { arm: () => void } { + let armed = false + let calls = 0 + const interceptable = new Set(['scheme', 'query', 'insert', 'update', 'delete']) + const wrapper = new Proxy(inner as object, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + if (prop === 'arm') return () => { armed = true; calls = 0 } + if (typeof value === 'function' && interceptable.has(String(prop))) { + return (...args: unknown[]) => { + if (armed && ++calls >= faultAtCall) { + armed = false + throw new Error(`[fault-injection] simulated crash at db call #${calls} (${String(prop)})`) + } + return (value as (...a: unknown[]) => unknown).apply(target, args) + } + } + return typeof value === 'function' ? (value as (...a: unknown[]) => unknown).bind(target) : value + } + }) + return wrapper as Database & { arm: () => void } +} + +// ---------- runner ---------- +async function runMigrationFuzzCase(seed: number, opsCount: number) { + const rng = mulberry32(seed) + const tag = `S${seed}` + const { v1, v2, v1View, mutations } = genMigrationPair(rng, tag) + const injectFault = seed % 2 === 0 // 偶数种子注入 kill-resume + + const rawDb = new PGLiteDB() + const db = createFaultInjectedDb(rawDb as unknown as Database, 5 + int(rng, 40)) + + const failWith = (message: string): never => { + throw new Error(`[mig-fuzz seed=${seed}${injectFault ? ' kill-resume' : ''}] ${message}\n` + + `mutations: ${JSON.stringify(mutations)}\n` + + `relations: ${JSON.stringify(v1View.relationChoices.map(c => ({ name: c.relation.name, relType: c.relType, source: c.source, target: c.target })))}`) + } + + // ---- v1:setup + 存量数据 ---- + const systemV1 = new MonoSystem(db as any) + systemV1.conceptClass = KlassByName + const controllerV1 = new Controller({ system: systemV1, entities: v1.entities as any, relations: v1.relations as any }) + await controllerV1.setup(true) + const storageV1 = systemV1.storage + + const pools: IdPools = new Map(v1View.entityNames.map(n => [n, []])) + let executed = 0 + for (let step = 0; step < opsCount; step++) { + for (const entityName of v1View.entityNames) { + const rows = await storageV1.find(entityName, undefined, undefined, ['id']) + pools.set(entityName, rows.map((r: Row) => r.id)) + } + let intent: FuzzOpIntent = null + for (let attempt = 0; attempt < 8 && !intent; attempt++) { + intent = await decideNextOp(rng, v1View, pools, + async (relationName) => (await storageV1.findRelationByName(relationName, undefined, undefined, ['id'])).map((r: Row) => r.id)) + } + if (!intent) continue + try { + await executeOpIntent(storageV1, intent, []) + executed++ + } catch (error) { + const rejection = error instanceof Error ? error : new Error(String(error)) + if (!isExpectedRejection(rejection)) { + failWith(`v1 data op ${intent.op} threw an UNEXPECTED error: ${rejection.message}\ndetail: ${JSON.stringify(intent)}`) + } + } + } + if (executed === 0) failWith('v1 phase executed no data ops') + + // ---- 迁移前快照(v1 声明面) ---- + const eventSchema: EventCompletenessSchema = { + entities: v1View.entityNames, + relations: v1View.relationChoices.map(c => c.relation.name!), + } + const beforeSnapshot = await snapshotLogicalState(storageV1, eventSchema) + + // ---- v2:migrate(偶数种子首跑注入故障,第二跑必须收敛) ---- + const makeV2Controller = () => { + const systemV2 = new MonoSystem(db as any) + systemV2.conceptClass = KlassByName + return new Controller({ + system: systemV2, + entities: v2.entities as any, + relations: v2.relations as any, + dict: v2.dictionaries as any, + }) + } + let controllerV2 = makeV2Controller() + if (injectFault) { + const approvedDiff = await approveGeneratedMigrationDiff(controllerV2) + ;(db as { arm: () => void }).arm() + let firstRunFailed = false + try { + await controllerV2.migrate({ approvedDiff }) + } catch (error) { + firstRunFailed = true + } + if (process.env.FUZZ_MIG_DEBUG) console.log(`[mig-fuzz seed=${seed}] first migrate ${firstRunFailed ? 'CRASHED (injected)' : 'completed before fault point'}`) + if (firstRunFailed) { + // 崩溃后从头恢复:新 controller、重新生成/批准 diff(真实恢复路径) + controllerV2 = makeV2Controller() + try { + const resumeDiff = await approveGeneratedMigrationDiff(controllerV2) + await controllerV2.migrate({ approvedDiff: resumeDiff }) + } catch (error) { + failWith(`kill-resume: second migrate failed to converge: ${error instanceof Error ? error.message : String(error)}`) + } + } + // 故障点可能在 migrate 完成后才触到(调用数超过全程调用量)——两种情况都必须收敛到相同终态 + } else { + try { + const approvedDiff = await approveGeneratedMigrationDiff(controllerV2) + await controllerV2.migrate({ approvedDiff }) + } catch (error) { + failWith(`migrate threw: ${error instanceof Error ? error.message : String(error)}`) + } + } + const storageV2 = controllerV2.system.storage + + // ---- 预言机 1:存量保真 ---- + const afterSnapshot = await snapshotLogicalState(storageV2, eventSchema) + for (const [recordName, beforeRows] of beforeSnapshot) { + const afterRows = afterSnapshot.get(recordName)! + const beforeIds = [...beforeRows.keys()].sort(), afterIds = [...afterRows.keys()].sort() + if (JSON.stringify(beforeIds) !== JSON.stringify(afterIds)) { + failWith(`data fidelity: ${recordName} id sets diverge after migration\nbefore: ${JSON.stringify(beforeIds)}\nafter: ${JSON.stringify(afterIds)}`) + } + for (const [id, beforeRow] of beforeRows) { + const afterRow = afterRows.get(id)! + for (const [field, beforeValue] of Object.entries(beforeRow)) { + const afterValue = afterRow[field] + if (JSON.stringify(beforeValue ?? null) !== JSON.stringify(afterValue ?? null)) { + failWith(`data fidelity: ${recordName}#${id} field "${field}" changed across migration ` + + `(${JSON.stringify(beforeValue)} -> ${JSON.stringify(afterValue)})`) + } + } + } + } + + // ---- 预言机 2/3:默认值回填 + 新计算回填 = 朴素重算 ---- + for (const mutation of mutations) { + if (mutation.kind === 'addProperty') { + const rows = await storageV2.find(mutation.entityName, undefined, undefined, ['id', mutation.propertyName]) as Row[] + for (const row of rows) { + if (row[mutation.propertyName] !== mutation.defaultValue) { + failWith(`default backfill: ${mutation.entityName}#${row.id}.${mutation.propertyName} = ${JSON.stringify(row[mutation.propertyName])}, expected ${mutation.defaultValue}`) + } + } + } else if (mutation.kind === 'addGlobalCount') { + const actual = await storageV2.dict.get(mutation.dictName) + const rows = await storageV2.find(mutation.sourceEntity, undefined, undefined, ['id']) as Row[] + if (actual !== rows.length) { + failWith(`computation backfill: dict ${mutation.dictName} = ${JSON.stringify(actual)}, naive recompute = ${rows.length}`) + } + } else if (mutation.kind === 'addPropertyCount') { + const hosts = await storageV2.find(mutation.hostEntity, undefined, undefined, + ['id', mutation.propertyName, [mutation.relationProperty, { attributeQuery: ['id'] }]]) as Row[] + for (const host of hosts) { + const related = host[mutation.relationProperty] + const count = Array.isArray(related) ? related.length : (related ? 1 : 0) + if (host[mutation.propertyName] !== count) { + failWith(`computation backfill: ${mutation.hostEntity}#${host.id}.${mutation.propertyName} = ${JSON.stringify(host[mutation.propertyName])}, naive recompute = ${count}`) + } + } + } else if (mutation.kind === 'addEntity') { + const rows = await storageV2.find(mutation.entityName, undefined, undefined, ['id']) as Row[] + if (rows.length !== 0) failWith(`new entity ${mutation.entityName} must start empty, has ${rows.length} rows`) + } else { + const links = await storageV2.findRelationByName(mutation.relationName, undefined, undefined, ['id']) as Row[] + if (links.length !== 0) failWith(`new relation ${mutation.relationName} must start empty, has ${links.length} links`) + } + } + + // ---- 预言机 4:迁移后可写(冒烟)---- + const newEntityNames = mutations.filter(m => m.kind === 'addEntity').map(m => m.entityName) + for (const entityName of [...v1View.entityNames, ...newEntityNames]) { + const created = await storageV2.create(entityName, { label: 'post-migration' }) as Row + const found = await storageV2.findOne(entityName, undefined, undefined, ['id', 'label', 'score']) as Row | undefined + if (!created?.id || !found) failWith(`post-migration write on ${entityName} failed`) + } + for (const mutation of mutations) { + if (mutation.kind === 'addGlobalCount') { + const actual = await storageV2.dict.get(mutation.dictName) + const rows = await storageV2.find(mutation.sourceEntity, undefined, undefined, ['id']) as Row[] + if (actual !== rows.length) { + failWith(`post-migration incremental: dict ${mutation.dictName} = ${JSON.stringify(actual)}, naive = ${rows.length} (new computation not wired into incremental maintenance)`) + } + } + } + + await (controllerV2.system as MonoSystem).destroy() + return { seed, executed } +} + +// ---------- 入口 ---------- +const SEED_START = Number(process.env.FUZZ_MIG_SEED_START ?? 1) +const SEED_COUNT = Number(process.env.FUZZ_MIG_SEED_COUNT ?? 6) +const OPS = Number(process.env.FUZZ_MIG_OPS ?? 12) + +describe('migration generative fuzz (random schema pair + data -> migrate vs full-recompute oracles, incl. kill-resume)', () => { + const seeds = Array.from({ length: SEED_COUNT }, (_, i) => SEED_START + i) + test.each(seeds.map(s => [s]))('seed %i: migration preserves data, backfills defaults/computations, survives injected crash', async (seed) => { + const result = await runMigrationFuzzCase(seed, OPS) + expect(result.executed).toBeGreaterThan(0) + }, 300000) +}) + +describe('deterministic regressions from migration-fuzz findings (r29)', () => { + test('adding a property-level Count over a to-one relation backfills via full recompute (seed 3)', async () => { + // 聚合模板的全量 compute 曾裸 for...of 宿主的关系属性——x:1 关系查询返回对象而非 + // 数组,迁移回填(runFullRecompute)当场 TypeError。运行期增量路径从不带着已填充的 + // to-one 走全量 compute,所以 60 个计算层 fuzz 种子全绿、只有迁移轨现形。 + const db = new PGLiteDB() + const mk = (version: 1 | 2) => { + const A = new Entity({ + name: 'MigToOneA', properties: [ + new Property({ name: 'label', type: 'string' }, { uuid: 'mig-toone-a-label' }), + ...(version === 2 ? [new Property({ + name: 'outCount', type: 'number', + computation: Count.create({ property: 'out' } as any), + } as any, { uuid: 'mig-toone-a-cnt' })] : []), + ] + }, { uuid: 'mig-toone-a' }) + const B = new Entity({ + name: 'MigToOneB', + properties: [new Property({ name: 'label', type: 'string' }, { uuid: 'mig-toone-b-label' })] + }, { uuid: 'mig-toone-b' }) + const rel = new Relation({ + source: A, sourceProperty: 'out', target: B, targetProperty: 'in', type: 'n:1', properties: [], + } as any, { uuid: 'mig-toone-rel' }) + return { A, B, rel } + } + const v1 = mk(1) + const systemV1 = new MonoSystem(db) + systemV1.conceptClass = KlassByName + const controllerV1 = new Controller({ system: systemV1, entities: [v1.A, v1.B] as any, relations: [v1.rel] as any }) + await controllerV1.setup(true) + const b1 = await systemV1.storage.create('MigToOneB', { label: 'b1' }) + await systemV1.storage.create('MigToOneA', { label: 'a-linked', out: { id: b1.id } }) + await systemV1.storage.create('MigToOneA', { label: 'a-lone' }) + + const v2 = mk(2) + const systemV2 = new MonoSystem(db) + systemV2.conceptClass = KlassByName + const controllerV2 = new Controller({ system: systemV2, entities: [v2.A, v2.B] as any, relations: [v2.rel] as any }) + const approvedDiff = await approveGeneratedMigrationDiff(controllerV2) + await controllerV2.migrate({ approvedDiff }) + + const rows = await systemV2.storage.find('MigToOneA', undefined, undefined, ['label', 'outCount']) as Row[] + const byLabel = new Map(rows.map(r => [r.label, r.outCount])) + expect(byLabel.get('a-linked')).toBe(1) + expect(byLabel.get('a-lone')).toBe(0) + await systemV2.destroy() + }, 60000) +}) From 3b97bb9e22015a60634c7adfe497d405750f71cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 03:49:13 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20r29=20quality-pillars=20report,=20p?= =?UTF-8?q?lan=20=C2=A71.3/1.4b/1.5=20update,=20registry=20axes,=20AGENTS.?= =?UTF-8?q?md=20generative=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Zhenyu Hou --- AGENTS.md | 7 +- ...p-review-2026-07-15-r29-quality-pillars.md | 100 ++++++++++++++++++ .../output/quality-foundation-plan-r27.md | 60 +++++++++-- tests/runtime/WritingComputationTests.md | 2 + 4 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 agentspace/output/deep-review-2026-07-15-r29-quality-pillars.md diff --git a/AGENTS.md b/AGENTS.md index 68f6eece..6eebfe8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,7 +210,12 @@ Core types use: interface → CreateArgs → `Entity.create(args)` → static re - Do not manually set entity IDs — let the framework generate them - Always `await controller.setup(true)` before dispatching - When adding a test **matrix**, consult the dimension registry in `tests/runtime/WritingComputationTests.md` — every dimension (including degenerate values and the mechanism axes) must be explicitly decided -- **Structural fuzzing**: `tests/storage/writePathStructuralFuzz.spec.ts` generates random schemas (all physical topologies emerge from declarations) × random nested write sequences, judged by the event-completeness oracle + structural invariants. When touching the storage write path, run it with an extended seed pool (`FUZZ_SEED_START=100 FUZZ_SEED_COUNT=100 FUZZ_OPS=40 npx vitest run tests/storage/writePathStructuralFuzz.spec.ts`); a failing seed prints its schema and full op log for deterministic reproduction (`FUZZ_SEED_START= FUZZ_SEED_COUNT=1 FUZZ_VERBOSE=1`). Known open finding families are tracked in `agentspace/output/quality-foundation-plan-r27.md` §1.4 +- **Structural fuzzing**: `tests/storage/writePathStructuralFuzz.spec.ts` generates random schemas (all physical topologies emerge from declarations) × random nested write sequences, judged by the event-completeness oracle + structural invariants. When touching the storage write path, run it with an extended seed pool (`FUZZ_SEED_START=100 FUZZ_SEED_COUNT=100 FUZZ_OPS=40 npx vitest run tests/storage/writePathStructuralFuzz.spec.ts`); a failing seed prints its schema and full op log for deterministic reproduction (`FUZZ_SEED_START= FUZZ_SEED_COUNT=1 FUZZ_VERBOSE=1`). The extended mode (filtered/merged entities in the generation domain) uses `FUZZ_FILTERED_SEED_START/COUNT`; the full merged domain (open finding EXT-1) is behind `FUZZ_MERGED_FULL=1`. Known open finding families are tracked in `agentspace/output/quality-foundation-plan-r27.md` §1.4/§1.4b +- **Generative suites (r29)** — expand the relevant pool when touching the corresponding subsystem: + - `tests/storage/driverDifferentialFuzz.spec.ts` — SQLite vs PGLite same-seed per-op reconciliation (`FUZZ_DIFF_SEED_START/COUNT`, `FUZZ_DIFF_OPS`); run when touching drivers or the storage write path + - `tests/runtime/computationGenerativeFuzz.spec.ts` — random aggregate declarations vs naive recompute (`FUZZ_COMP_SEED_START/COUNT`, `FUZZ_COMP_OPS`); run when touching computation handles or the scheduler + - `tests/runtime/migrationGenerativeFuzz.spec.ts` — random schema pairs + data → migrate vs fidelity/backfill oracles incl. kill-resume (`FUZZ_MIG_SEED_START/COUNT`, `FUZZ_MIG_OPS`); run when touching migration + - Shared generators live in `tests/storage/helpers/fuzzSchema.ts` / `fuzzOps.ts` — the rng call order is the decision-stream contract; changing it invalidates existing seed pools (re-verify the base pool 1–499 after any refactor) ### Bug fixing: fix the class, not the instance diff --git a/agentspace/output/deep-review-2026-07-15-r29-quality-pillars.md b/agentspace/output/deep-review-2026-07-15-r29-quality-pillars.md new file mode 100644 index 00000000..7ba2b813 --- /dev/null +++ b/agentspace/output/deep-review-2026-07-15-r29-quality-pillars.md @@ -0,0 +1,100 @@ +# r29:三支柱落地——fuzzer 扩域、驱动差分、迁移生成(2026-07-15) + +> 任务:完成 r28 复盘承诺的三步硬活。产出 = 三个新生成式套件 + storage fuzzer 两个新模式 +> + 五个致命/严重 bug 收口 + 一个开放家族建档。本文是执行纪要与发现档案; +> 计划面的登记在 `quality-foundation-plan-r27.md` §1.3/§1.4b。 + +## 一、交付物 + +| 支柱 | 套件 | 机制 | 规模(本轮验证) | +|------|------|------|------| +| fuzzer 扩域 | `writePathStructuralFuzz.spec.ts` 新增 filtered / extended 模式 | filtered entity/relation(嵌套链)+ merged (union) entity 进生成域;membership-only 事件对账 + filtered 谓词一致性(独立 JS 真值)+ merged 并集一致性 + 配对读取一致性(预言机第 8 条) | base 1–499 × 40 绿;extended 1–120 × 30 绿 | +| 驱动差分 | `driverDifferentialFuzz.spec.ts` | 同种子意图流,SQLite(主)/PGLite(副)经 id 双射逐操作对账:错误语义、事件多重集、update keys、端点存在性、全量逻辑快照 | 1–120 × 25-30 绿 | +| 计算层生成 | `computationGenerativeFuzz.spec.ts` | 随机 (源 × 聚合 × 宿主位置) 声明 × 随机写序列;每步朴素全量重算对照;源含 filtered 视图 | 1–60 × 15-20 绿 + 敏感性自检 | +| 迁移生成 | `migrationGenerativeFuzz.spec.ts` | 随机 v1(稳定 uuid)× 存量数据 × 加法变异 → 真实两步审查 migrate;存量保真/回填/朴素重算/冒烟写/kill-resume 收敛 | 1–60 × 12-15 绿(偶数种子注入崩溃) | + +共享基建:`tests/storage/helpers/{fuzzRandom,fuzzSchema,fuzzOps}.ts`(决策流唯一实现, +四个 runner 复用);`tests/runtime/helpers/migrationApproval.ts`(migration.spec 同步改为导入)。 +决策流兼容性由 base 全池 1–499 重跑验证(逐位一致,否则老种子失效)。 + +## 二、发现与收口(fix-the-class 检查表逐条执行) + +### MRG-1|merged FK link 删除物理销毁宿主行(致命,extended seed 1 首跑抓获) + +- **症状**:`M = merged(A, C)`,关系 `A --n:1--> B`(merged FK 落在 A 行)。 + `removeRelationByName` 删 link → **A 的整行从物理表消失**,仅有 link delete 事件。 +- **机制**:r28 引入的 `clearOrDeletePhysicalRow` 行占用判定把 + `isFilteredEntity / isFilteredRelation / isMergedAbstract` 记录整类排除。 + merged 编译后物理身份列属于 merged-abstract 记录、input 是视图——两类都被排除 + ⇒ link 的行足迹之外"查无占用" ⇒ 走 DELETE ROW。 +- **修复(收敛点)**:占用判定改按 **id 字段是否有值**,不按记录种类排除——视图与 base + 共享同一 id 字段,字段面判定天然去重。此判定只有这一个实现点。 +- **读者枚举**:`deleteRecordSameRowDataGrouped`(canonical + 级联两轨都经此)✅; + `clearRowDataForMigration`(搬迁清行)同函数复用 ✅。 + +### MRG-2|combined 嵌套新建按视图名发号 → id 碰撞静默覆写(致命,extended seed 41) + +- **症状**:`M = merged(C, E)`;`D --1:1 reliance--> C`。经其他 input 推进 M 的物理序列后, + `create D { own: {…嵌套新建 C} }` 给 C 从 **'C' 名下的平行序列**发号 → 与 M 表既有 id + 相同 → 写路径按"外部 id"语义落列,**覆写既有记录的字段**(零 create 事件差异)。 +- **机制**:顶层 create 的 `NewRecordData.recordName` 构造期已解析物理名;嵌套分类列表上的 + `attr.recordName / attr.linkName` 是**声明名**。四个发号点中三个用了声明名。 +- **修复(收敛点)**:`CreationExecutor.allocateRecordId(recordName)`——一切发号经 + `resolvedBaseRecordName` 归一;RecordQueryAgent 的 flashOut 发号点同契约。 +- **读者枚举**:`getAutoId` 全仓四个调用点逐一核对(406/478/522 + RecordQueryAgent:451)✅。 + +### MRG-3|combined 嵌套新建 create 事件丢 type-dispatch 默认值(严重,extended seed 41/24) + +- **症状**:同上形态,M 名下的 combined 子记录 create 事件 payload 缺 `score`(行=7、 + payload=null)与 `__type`。 +- **机制**:`completeEventPayloadWithDefaults` 收到**解析后的物理名**——merged 的属性默认值 + 经 `mergeProperties` 按具体类型分发(`__type` 判别列同理),物理名求 defaults 全部落空。 +- **修复**:defaults 按 `record.originalRecordName`(声明名)求值;事件 `recordName` + 仍是物理名。快照完备性契约(r21 F-1 / r25 F-1 的产出面)在 merged 域闭合。 + +### MRG-4|级联删除按声明面名字发 record delete(严重,extended seed 37) + +- **症状**:reliance 级联删除 merged input 记录 → 事件流 `[delete C#1, delete C#1]`: + 视图名下双 delete、物理名(M)record delete **整体缺失**——监听物理名的计算对删除失明。 +- **机制**:canonical 轨(`deleteRecord`)的 recordName 经 `RecordQuery.create` 已解析; + 级联轨(`sameTableReliance` / `handleDeletedRecordReliance`)以 attr 上的**声明名**直达 + `deleteRecordSameRowDataGrouped`。r18「字段 update 事件恒以物理名发出」的死监听不变量 + 在 delete 轨上有同构要求,但只有一条轨遵守。 +- **修复(收敛点)**:grouped 里 record delete 事件统一经 `resolvedBaseRecordName` 归一 + ——两条轨共用产出点,一处修复覆盖全部来路;视图名成员资格事件仍由 settle 负责(恰好一次)。 + +### MIG-1|property 聚合模板全量 compute 对 to-one 崩溃(严重,mig-fuzz seed 3) + +- **症状**:迁移给存量数据回填新增的 `Count.create({property: 'out'})`(out 是 n:1 的 + to-one 属性)→ `TypeError: relations is not iterable`。 +- **机制**:`aggregationTemplate.compute` 裸 `for...of _current[relationAttr]`。 + 运行期增量路径逐 link 事件维护、从不带着已填充的 to-one 走全量 compute——所以计算层 + fuzz 60 种子全绿、只有迁移轨(`runFullRecompute`)现形。**这是"同一声明面、不同消费轨" + 的又一实例**:计算的正确性不能只在增量轨验证。 +- **修复(收敛点)**:模板的关联行读取点归一(对象→单元素数组)——六种聚合的 property + 模式共用这一个点。回归固化在 `migrationGenerativeFuzz.spec.ts` 的 deterministic 组。 + +### EXT-1|merged input 作为 x:1/combined 端点的 Setup 装配错位(开放,建档) + +- **症状**:`no such column: .`——查询期 fail-loud。 +- **代表种子**:extended 2/10/50/71/72/81(`FUZZ_MERGED_FULL=1` 复现)。 +- **初判**:rebase 之后 link FK 字段或属性字段落错物理表(与 mergeLinks 无关,纯 merged × + x:1/combined 即触发)。需要专门一轮 Setup 装配审计(字段生成 → rebase → buildTables + 的三段一致性),不在本轮仓促修复。 +- **风险面**:fail-loud(无静默损坏面);CI 生成域已把 merged pair 限制在 + 仅 n:n/无关系实体(`fuzzSchema.ts` CAUTION 注释 + 本表互为索引)。 + +## 三、方法论笔记(进 r30 的先验) + +1. **扩域首跑必出货**:filtered 模式 200 种子全绿(r25-r27 多轮已收口该域);merged 模式 + 前 60 种子 10 红——「从未生成过的输入形状」仍是最大逃逸面,与 r28 复盘的预测精确一致。 +2. **本轮五个修复共享一个类**:*写路径的身份判定必须区分「声明名 / 物理名 / 记录种类」三个 + 概念面*。四个 merged bug 分别是发号、占用、defaults、事件名四个消费点在同一个类上跌倒。 + 已在登记册补「概念寄生位置 × 写路径身份消费点」轴。 +3. **差分预言机的第一个产出是契约决策而非 bug**:种子 35 暴露「同操作内兄弟事件的顺序在 + 两驱动上不同」——按 r25 时间戳归一的先例,决策为**多重集一致、顺序不承诺**并写进套件 + 头注。差分 fuzz 的价值一半在抓分裂,一半在把隐式跨驱动契约显式化。 +4. **迁移 fuzz 的 kill-resume 注入触发率 ~2/3**(其余种子在故障点前完成)——两种终态 + (崩溃恢复 / 完整跑完)都必须收敛到同一预言机,天然覆盖"故障点晚于完成"的边界。 +5. **预言机敏感性自检应成为惯例**:计算层 fuzz 落地时先用坏真值验证 6/6 种子变红再转绿。 + 全绿的预言机首先要证明自己会红。 diff --git a/agentspace/output/quality-foundation-plan-r27.md b/agentspace/output/quality-foundation-plan-r27.md index 57c2e048..9450f304 100644 --- a/agentspace/output/quality-foundation-plan-r27.md +++ b/agentspace/output/quality-foundation-plan-r27.md @@ -61,15 +61,34 @@ ### 1.3 展开路径(按杠杆排序) -1. **驱动差分**(下一步,成本低杠杆高):同一种子序列跑 SQLite + PGLite,逐操作 diff +> **【r29 落地纪要】1–3 全部落地(详见 `deep-review-2026-07-15-r29-quality-pillars.md`):** +> - **驱动差分** → `tests/storage/driverDifferentialFuzz.spec.ts`:同种子意图流经 id 双射在 +> SQLite(主)/PGLite(副)逐操作对账(错误语义 + 事件多重集 + 逻辑快照)。120 种子绿。 +> 固化契约决策:**一个操作内的兄弟事件顺序是驱动方言(无 ORDER BY 承诺),只比多重集**。 +> - **计算层生成** → `tests/runtime/computationGenerativeFuzz.spec.ts`:随机 (源 × 聚合 × +> 宿主位置) 声明(全局 dict / property 级、实体 / 关系 / filtered 视图源)× 随机写序列, +> 每步与朴素全量重算对照。Count/Summation 天然非幂等 ⇒ 增量双跑/漏跑直接体现为值漂移 +> (r27 F-2 的"执行计数 spy"由此结构性内建)。60 种子绿 + 预言机敏感性自检(坏真值必红)。 +> - **迁移生成** → `tests/runtime/migrationGenerativeFuzz.spec.ts`:随机 v1 schema(稳定 +> uuid)× 随机存量数据 × 随机加法变异 → 真实两步审查 migrate。预言机:存量保真 / 默认值 +> 回填 / 新计算回填=朴素重算 / 迁移后可写 / **kill-resume 收敛**(偶数种子在第 N 次 DB +> 调用注入故障 → 重跑必须收敛)。60 种子绿。 +> - storage fuzzer 表达域纳入 **filtered entity/relation + merged (union) entity** +> (filtered/extended 两个新模式)+ 预言机第 8 条(配对读取一致性)。首跑抓获 merged 域 +> 4 个致命家族并收口(见 §1.4b),EXT-1 开放家族建档。 + +1. **驱动差分**(✅ r29):同一种子序列跑 SQLite + PGLite,逐操作 diff 查询结果与事件流——r24/r25 驱动分裂家族的机器化收口。真实 PG/MySQL 进 nightly。 -2. **计算层生成**:随机 dataDeps/聚合声明 + 随机 dispatch 序列,预言机 = 朴素全量重算对照 +2. **计算层生成**(✅ r29 数据驱动聚合面):随机 dataDeps/聚合声明 + 随机 dispatch 序列,预言机 = 朴素全量重算对照 (`symmetricAggregationMatrix` 已有样板)+ **执行计数 spy**(r27 F-2 的夹具幂等性遮蔽 - 只有非幂等观察者能抓)。 -3. **迁移生成**:随机 schema 对(前后版本)+ 随机存量数据 → migrate → 与"drop 重建 + 全量 + 只有非幂等观察者能抓;r29 以非幂等聚合的值漂移结构性实现)。 + 剩余扩张点:StateMachine/Transform 等事件驱动计算(需 InteractionEvent 轨)、async 计算。 +3. **迁移生成**(✅ r29):随机 schema 对(前后版本)+ 随机存量数据 → migrate → 与"drop 重建 + 全量 重算"对照;随机注入 kill-resume 点。 -4. **CI 编排**:PR 跑固定种子集(当前 8 种子 <1s);nightly 跑大种子池 + 长序列; - 失败种子自动进回归集(种子即测试用例)。 + 剩余扩张点:破坏性变异(删属性/删实体 → destructive-scope 决策轨)、计算**变更**(非新增)轨。 +4. **CI 编排**:PR 跑固定种子集(storage base 8 + extended 8 + 差分 6 + 计算 6 + 迁移 6, + 合计 <40s);nightly 跑大种子池 + 长序列(各套件 `FUZZ_*_SEED_START/COUNT/OPS` 环境变量 + 扩池);失败种子自动进回归集(种子即测试用例)。 ### 1.4 扩展探索的开放发现(100 种子 × 40 操作首跑,25 种子失败,去重后 ≥4 个独立家族) @@ -100,11 +119,34 @@ 超过了此前数轮人肉审查的总和。CI 固定种子集保持在已收口的 1–8(全绿); 扩展池的收口进度以本表为准跟踪。 +### 1.4b r29 扩域首跑发现(filtered/merged 入生成域,60→120 种子) + +**已收口(当轮修复 + `mergedWritePathRegressions.spec.ts` 固化)——共同根源是 +「写路径以声明名/记录种类判定,而 merged 编译把 input 变成物理 base 上的视图」:** + +| 家族 | 症状 | 代表种子 | 收敛点修复 | +|------|------|---------|-----------| +| MRG-1 | merged FK link 删除把宿主实体**整行物理销毁**(零事件) | extended 1 | `clearOrDeletePhysicalRow` 行占用判定改按 **id 字段**(不按记录种类排除视图/抽象记录) | +| MRG-2 | combined 嵌套新建按**视图名**发号 → 平行序列撞物理表既有 id → 静默覆写既有记录 | extended 41 | `CreationExecutor.allocateRecordId`:全部发号点经 `resolvedBaseRecordName` 归一(含 flashOut) | +| MRG-3 | combined 嵌套新建 create 事件 payload 丢 type-dispatch 默认值(含 `__type`) | extended 41/24 | defaults 按 `originalRecordName` 求值(事件 recordName 仍是物理名) | +| MRG-4 | 级联删除轨按**声明面名字**发 record delete → 物理名事件缺失 + 视图名双 delete | extended 37 | `deleteRecordSameRowDataGrouped` record delete 统一归物理名(canonical 轨已解析,级联轨归一) | +| MIG-1 | property 聚合模板全量 compute 对 **to-one** 关系属性裸 for...of → 迁移回填 TypeError | mig-fuzz 3 | `aggregationTemplate.compute` 关联行读取点归一(对象→单元素数组),六种聚合共用 | + +**开放家族(生成域已相应收缩,收口后解除):** + +| 家族 | 症状 | 代表种子 | 初判 | +|------|------|---------|------| +| EXT-1 | merged input 作为 x:1 / combined 关系端点时 Setup 字段-表装配错位 → 查询期 `no such column`(fail-loud) | extended 2/10/50/71/72/81(`FUZZ_MERGED_FULL=1`) | rebase 后 link FK 字段/属性字段落错表——需要专门一轮走 Setup 装配审计;CI 生成域暂把 merged pair 限制在仅 n:n/无关系实体(`fuzzSchema.ts` 有 CAUTION 注释) | + ### 1.5 诚实的边界 -- fuzzer 的强度 = 预言机的强度 × 生成器的表达域。当前生成器不产出:filtered entity/relation、 - merged (union) entity、computed/computation 属性、事务并发交错、activity/interaction 层。 - 这些按 1.3 逐步纳入表达域,纳入一类就等于把该类的全部人肉格子换成机器铺设。 +- fuzzer 的强度 = 预言机的强度 × 生成器的表达域。r29 后生成器已产出:filtered + entity/relation(嵌套链)、merged (union) entity(受限域,见 EXT-1)、数据驱动聚合声明 + (全局/property 级)、迁移加法变异。仍不产出:**事件驱动计算(StateMachine/Transform)、 + async 计算、事务并发交错、activity/interaction 层、迁移破坏性变异**。 + 这些按 1.3 剩余扩张点逐步纳入,纳入一类就等于把该类的全部人肉格子换成机器铺设。 +- 驱动差分当前覆盖 SQLite×PGLite;真实 PostgreSQL/MySQL 差分进 nightly 的机制已就绪 + (runner 只依赖 Database 接口),但尚未接线。 - 随机化不证明不存在 bug,只把「逃逸概率」变成种子数量的函数——这正是对指数空间唯一 诚实的陈述方式。 diff --git a/tests/runtime/WritingComputationTests.md b/tests/runtime/WritingComputationTests.md index 6c3a95e9..d4a60235 100644 --- a/tests/runtime/WritingComputationTests.md +++ b/tests/runtime/WritingComputationTests.md @@ -304,6 +304,8 @@ test('should handle negative values correctly', async () => { | 物理搬迁 ≠ 逻辑删除(r28 引入,fuzzer seed 270/424 抓获) | flashOut 抢夺 / relocate 解除的「清旧行」是**纯物理操作**(记录逻辑身份不变、随后整体重插新行),绝不能复用逻辑删除的机制:deleteRecordSameRowData 的级联(sameTableReliance 成员按"死亡"处理 → 其 isolated link 行 / 异表 reliance 被物理删除且零事件)、defaultValue 补齐(查询快照对 NULL 列**省略键**+写路径对缺席键应用默认值 ⇒ 显式 null 被静默改写回默认值)、link id 重新发号(搬迁行上的 merged link 逻辑身份改变且零事件)。r28 落地 `clearRowDataForMigration`(无级联清列)+ NULL 物化 + 携带 link id 保留。**判定快照数据缺席键的语义前先问:这份数据来自哪个查询、该查询是否加载了该键** | r28 seed 270(isolated n:n link 连坐删除)、seed 424/446(null→默认值改写)、seed 114(link id 重发号) | | 同住 ≠ 配对:combined 读取的真相源(r28 引入) | combined x:1 按「同物理行」编译(无 JOIN 无 ON),**行槽位排他不变量**保证一行每类型至多一个实例,但**配对事实的唯一真相源是 link id 列**:孤儿同住(hub 亡故余留 co-tenant)、多 owner reliance 装配都会造出「同住但未配对」的行——裸同行读取产生幻影关联(嵌套读取返回从未 link 的记录、match 路径误命中、删除按幻影配对级联销毁无辜记录)。消费面全枚举:嵌套读取(synthetic `&` + 结果剪枝)/ match 路径(combined 段 IS NOT NULL 守卫原子,对称变体逐一)/ 删除级联(幻影剪枝 + link id 核验)/ **flashOut 行认领刻意按物理同住寻址(physicalRowMatch/physicalRowRead 显式豁免——被领养的独居记录没有配对)**。Setup 面:同对实体的第二条 combined 放置违反行槽位排他(幻影配对/行碰撞/互 reliance 深查询无终止),显式 mergeLinks fail-fast、reliance 自动合表降级为 merged link | r28 seed 123(互 reliance 栈溢出)、136/156(双 combined 对幻影+槽位碰撞)、369(幻影级联+半清 link)、查询面幻影读取(多 owner reliance 孤儿同住) | | 驱动差异轴(r24 引入,r25 扩展,r26 补 close) | 「PGLite ≈ PostgreSQL 语义」只在 SQL 方言层成立,**驱动机制层必须逐格验证**:id 分配方式 × 读回类型(r24 F-1);**方言入口对 fieldType 字符串的识别必须覆盖自家 `mapToDBFieldType` 的全部产出形态(大小写、type:'json' vs object/collection,r25 I-1)**;**连接管理幂等不变量(open/openForSchemaRead/close 任意顺序重入不泄漏/不抛错)四驱动逐一验证(r22 I-5 SQLite open → r25 I-2 MySQL open → r26 I-4 四驱动 close)**;atomic 读/写路径与 find/storage-write 的类型归一化对称(r24 I-1 读 → r25 I-3 写)。**环境可得性本身是轴**:env-gated 套件(postgresql* / mysql*)沉睡面提供的置信度为零(r24 复盘);**timestamp 读写归一化(r26 收口):JS 面契约 = epoch 毫秒(写接受 Date|ms|ISO,find/atomic 读恒 number),语义类型(Property.type)而非 DB 列型驱动判定——SQLite 的 timestamp 列 fieldType 是 INT,从列型无法识别** | r24 F-1(PG id 类型分裂存活 22 轮)、r25 I-1(type:'json' 匹配 PG 裸报错——PGLite 掩盖)、r25 I-2(MySQL open 泄漏——r22 只修 SQLite)、r26 I-4(close 幂等是 open 家族的对称面) | +| 概念寄生位置 × 写路径身份消费点(r29 引入,extended fuzzer 首跑抓获四连) | 写路径的身份判定必须区分三个概念面:**声明名**(用户写入时用的名字,type-dispatch 默认值/`__type` 判别按它求值)、**物理名**(`resolvedBaseRecordName`,id 发号序列/record 级事件名/快照对账按它归一)、**记录种类**(filtered/merged-abstract/plain——**不得**作为行占用等物理判定的排除依据;视图与 base 共享 id 字段,按字段判定天然去重)。merged (union) 编译把 input 变成物理 base 上的视图后,四个消费点各自跌倒:发号按声明名(平行序列 id 碰撞静默覆写)、占用按记录种类(merged link 删除物理销毁宿主行)、defaults 按物理名(type-dispatch 默认值整族缺席)、级联事件按声明名(物理名 delete 缺失+视图名双发)。**新增身份消费点时必须显式选择用哪个面并留注释** | r29 MRG-1..4(`mergedWritePathRegressions.spec.ts`);EXT-1 开放家族(Setup 装配的字段-表一致性,同一个类的声明期面) | +| 宿主属性 to-one/to-many × 计算执行轨(r29 引入,迁移 fuzzer 抓获) | property 级计算读宿主关系属性时,**x:1 返回对象、x:n 返回数组**——集合读取点必须归一(`aggregationTemplate.compute` 单点收口,六种聚合共用)。**同一计算的增量轨与全量轨是两个独立消费者**:运行期增量按 link 事件逐项维护、从不带已填充的 to-one 走全量 compute,60 个计算层 fuzz 种子全绿;只有迁移回填(`runFullRecompute`)/手动全量重算轨现形。计算正确性的验证矩阵必须显式含全量轨(迁移生成 fuzz 天然覆盖) | r29 MIG-1(`migrationGenerativeFuzz.spec.ts` deterministic 组) | **正交轴说明(r19 复盘引入)**:数据形态轴与机制轴都长在"响应式数据流"上(mutation → 事件 → 计算)。 底层逻辑原语(`BoolExp.evaluate`、match 求值、算术求值)是数据流的**上游依赖**,不产生 mutation、不进事件流、