/* * ★ 트윈은 자기 원본을 기억한다 — **기억하지 않으면 다시 읽을 수 없다.** * * 트윈은 원본을 비출 뿐이므로(ADR-0028) 원본이 달라지면 다시 읽어야 한다. 그런데 트윈은 * "내가 무엇에서 나왔나" 를 남기지 않았다 — 어댑터로 들어온 트윈에는 `TwinReference` 행이 * 있었지만 템플릿으로 세운 트윈에는 아무 표식도 없었다. 그 트윈은 **첫날의 모습에 영원히 * 머문다.** 실제로 이 앱의 트윈 둘이 그 상태였다(`twinReferences` 도 비었고 `source` 도 null). * * 여기서 지키는 것 셋: * · 인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자리에 * · 원천을 모르는 재프로비전이 **이미 아는 것을 지우지 않는다** — 한 번 지우면 다시 못 읽는다 * · 못 읽는 트윈은 **못 읽는다고 말한다** — 짐작으로 아무 원본이나 집지 않는다 * * Since 2026-09-24 (chief architect ruling, engine/producer-guard.ts) a re-provision that does not come * from the twin's own producer is **refused** rather than applied: a silent one used to keep the origin * but replace the structure, and a different producer used to replace both — the second is how the * plant mirror `miratek` got the warehouse's structure. The two tests below now pin the refusal, and * that the refused twin's origin and structure are untouched. */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const BORN = 'origin-probe' const ORPHAN = 'orphan-probe' const master = (source: string, bins: number, origin?: any): any => ({ source, system: 'wms', siteName: '원천 창고', space: { width: 40, depth: 20, unit: 'm' }, locations: [ { id: 'dock-recv', type: 'dock', role: 'location', name: '입고 도크', capacity: 100 }, ...Array.from({ length: bins }, (_, i) => ({ id: `bin-${i + 1}`, type: 'storage', role: 'location', name: `빈${i + 1}`, capacity: 20 })) ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }], ...(origin ? { origin } : {}) }) const originOf = async (instanceId: string) => (await ds.getRepository(TwinInstance).findOne({ where: { instanceId } }))?.origin before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const d = await ds.getRepository(Domain).save({ name: 'origin-test', subdomain: 'origin-test' } as any) domainId = d.id }) after(async () => { for (const id of [BORN, ORPHAN]) await TwinEngine.stop(domainId, id).catch(() => {}) await ds?.destroy() }) test('인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자리에', async () => { await TwinEngine.ingestMaster(domainId, master(BORN, 3, { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } }), 'reset') assert.deepEqual(await originOf(BORN), { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } }) }) const TEMPLATE = { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } } const locationsOf = async (instanceId: string) => (await ds.getRepository(TwinInstance).findOne({ where: { instanceId } }))?.model?.locations?.length test('원천을 모르는 재프로비전은 거절된다 — 원천도 구조도 그대로다', async () => { /* * 이 경로는 실제로 있다 — 원천을 모르는 호출자(스크립트·복구·옛 코드)가 같은 트윈을 다시 인제스트한다. * Its origin used to be kept while the structure was replaced by whatever that caller had. It is refused * now (ruling (b)): the twin's own producer re-reads it; anything else deletes it first. */ await assert.rejects(TwinEngine.ingestMaster(domainId, master(BORN, 5), 'reset'), (e: any) => { assert.equal(e.code, 'instance-taken-by-another-source') assert.deepEqual(e.params, { instance: BORN, producer: 'wh-unit-load' }) return true }) assert.deepEqual(await originOf(BORN), TEMPLATE) assert.equal(await locationsOf(BORN), 4, 'the refused master did not land (dock 1 + bins 3)') }) test('다른 생산자는 거절된다 — 이름을 대고, 원천도 구조도 그대로다', async () => { await assert.rejects( TwinEngine.ingestMaster(domainId, master(BORN, 5, { kind: 'reference', source: 'oracle-wms' }), 'reset'), (e: any) => e.code === 'instance-taken-by-another-source' && e.params?.producer === 'wh-unit-load' ) assert.deepEqual(await originOf(BORN), TEMPLATE) assert.equal(await locationsOf(BORN), 4) }) test('같은 생산자가 다시 읽으면 지나간다 — 노브가 달라도', async () => { const again = { ...TEMPLATE, params: { bins: 5 } } await TwinEngine.ingestMaster(domainId, master(BORN, 5, again), 'reset') assert.deepEqual(await originOf(BORN), again) assert.equal(await locationsOf(BORN), 6, 'its own template re-run rebuilds it (dock 1 + bins 5)') }) test('원천 없이 태어난 트윈은 원천이 없다 — 짐작해 채우지 않는다', async () => { await TwinEngine.ingestMaster(domainId, master(ORPHAN, 2), 'reset') assert.equal(await originOf(ORPHAN), null, '모르는 것을 아는 척하면 엉뚱한 원본으로 현장을 덮는다') }) test('조회가 다시 읽을 수 있는지를 먼저 답한다 — 못 하는 것을 못 한다고 알린다', async () => { const { TwinModelQuery } = req('../dist-server/service/twin-model/twin-model-query.js') const q = new TwinModelQuery() const ctx = { state: { domain: { id: domainId } } } as any const born = await q.twinModelSummary(BORN, ctx) assert.equal(born.canResync, true) assert.deepEqual(born.origin, { ...TEMPLATE, params: { bins: 5 } }) const orphan = await q.twinModelSummary(ORPHAN, ctx) assert.equal(orphan.canResync, false, '버튼을 그대로 내면 누를 때마다 실패한다') assert.equal(orphan.origin, null) })