@things-factory/fact-change 0.0.0-stage → 10.1.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist-server/index.d.ts +6 -0
  2. package/dist-server/index.js +19 -0
  3. package/dist-server/index.js.map +1 -0
  4. package/dist-server/service/fact-change-rows.d.ts +45 -0
  5. package/dist-server/service/fact-change-rows.js +44 -0
  6. package/dist-server/service/fact-change-rows.js.map +1 -0
  7. package/dist-server/service/fact-change-service.d.ts +17 -0
  8. package/dist-server/service/fact-change-service.js +34 -0
  9. package/dist-server/service/fact-change-service.js.map +1 -0
  10. package/dist-server/service/fact-change-text.d.ts +37 -0
  11. package/dist-server/service/fact-change-text.js +53 -0
  12. package/dist-server/service/fact-change-text.js.map +1 -0
  13. package/dist-server/service/fact-change.d.ts +32 -0
  14. package/dist-server/service/fact-change.js +102 -0
  15. package/dist-server/service/fact-change.js.map +1 -0
  16. package/dist-server/tsconfig.tsbuildinfo +1 -0
  17. package/package.json +29 -4
  18. package/server/index.ts +16 -0
  19. package/server/service/fact-change-rows.ts +87 -0
  20. package/server/service/fact-change-service.ts +48 -0
  21. package/server/service/fact-change-text.ts +75 -0
  22. package/server/service/fact-change.ts +81 -0
  23. package/server/tsconfig.json +10 -0
  24. package/test/fact-change-db.test.ts +90 -0
  25. package/test/fact-change-rows.test.ts +65 -0
  26. package/test/fact-change-text.test.ts +87 -0
  27. package/test/resolve-ts-siblings.mjs +63 -0
  28. package/things-factory.config.js +1 -0
  29. package/translations/en.json +4 -0
  30. package/translations/ja.json +4 -0
  31. package/translations/ko.json +4 -0
  32. package/translations/ms.json +4 -0
  33. package/translations/zh.json +4 -0
  34. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@things-factory/fact-change",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "10.1.142",
4
+ "main": "dist-server/index.js",
5
+ "things-factory": true,
6
+ "license": "MIT",
7
+ "author": "heartyoh <heartyoh@hatiolab.com>",
8
+ "description": "What moved on a recorded fact, field by field — from what, to what, who, and through which approval",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "@things-factory:registry": "https://registry.npmjs.org"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hatiolab/things-factory.git",
16
+ "directory": "packages/fact-change"
17
+ },
18
+ "scripts": {
19
+ "build": "npm run build:server",
20
+ "build:server": "npx tsc --p ./server/tsconfig.json",
21
+ "clean:server": "npx rimraf dist-server",
22
+ "clean": "npm run clean:server",
23
+ "test": "npx tsc --p ./server/tsconfig.json && node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts"
24
+ },
25
+ "dependencies": {
26
+ "@things-factory/auth-base": "^10.1.142",
27
+ "@things-factory/shell": "^10.1.142",
28
+ "@things-factory/webhook": "^10.1.142"
29
+ },
30
+ "gitHead": "73522201151b0156168b0eeea15f2962cda0bd8f"
31
+ }
@@ -0,0 +1,16 @@
1
+ /*
2
+ * What moved on a recorded fact, field by field (ADR-0124).
3
+ *
4
+ * plant (`fact_changes`) and warehouse (`warehouse_change_records`) each kept the same table, and
5
+ * station asked every product for a function that wrote into it. This package holds the one table, the
6
+ * writer every product and station call, and the helpers that turn two versions of a fact into rows.
7
+ * Products keep their vocabulary (`factKind`, `source`) and their own read queries and privileges.
8
+ */
9
+ import { FactChange } from './service/fact-change.js'
10
+
11
+ export * from './service/fact-change.js'
12
+ export * from './service/fact-change-text.js'
13
+ export * from './service/fact-change-rows.js'
14
+ export * from './service/fact-change-service.js'
15
+
16
+ export const entities = [FactChange]
@@ -0,0 +1,87 @@
1
+ import { Refusal } from '@things-factory/shell/dist-server/refusal/refusal.js'
2
+ import { isWritingTransaction, type MaybeTransactional } from '@things-factory/webhook/writing-transaction'
3
+
4
+ import type { FieldChange } from './fact-change-text.js'
5
+
6
+ /**
7
+ * Which rows a change becomes, and when it may be written — judgement only, no storage, so tests run
8
+ * without a database.
9
+ */
10
+ export interface FactChangeWhat {
11
+ /** Which kind of fact — the product's vocabulary (`job-order`, `station-screen-spec`, …). */
12
+ factKind: string
13
+ /** The fact, by name. */
14
+ factName: string
15
+ changes: FieldChange[]
16
+ at?: Date
17
+ }
18
+
19
+ export interface FactChangeBy {
20
+ user?: unknown
21
+ /** Where the change came from, in the product's own words (plant: `manual` · `twin-command` · `intake`). */
22
+ source?: string
23
+ /** Why. May be empty: empty means nobody wrote one, not that there was no reason. */
24
+ reason?: string
25
+ /** The approval it came through. Who approved is kept by worklist; this points there. */
26
+ activityInstanceId?: string
27
+ }
28
+
29
+ export interface FactChangeRow extends FieldChange {
30
+ factKind: string
31
+ factName: string
32
+ at: Date
33
+ source?: string
34
+ reason?: string
35
+ activityInstanceId?: string
36
+ changedBy?: unknown
37
+ }
38
+
39
+ const blank = (value: unknown) => typeof value !== 'string' || !value.trim()
40
+
41
+ /**
42
+ * The rows to write. No moved field, no row — a history filled with "nothing happened" is a history
43
+ * nobody reads.
44
+ *
45
+ * A change that does not say which fact and which field moved is refused: a row about nothing can never
46
+ * be found again and only inflates the count.
47
+ */
48
+ export function factChangeRows(what: FactChangeWhat, by: FactChangeBy, now: Date): FactChangeRow[] {
49
+ if (!what.changes?.length) return []
50
+
51
+ if (blank(what.factKind) || blank(what.factName) || what.changes.some(one => blank(one.field))) {
52
+ throw new Refusal(
53
+ 'change-names-no-fact',
54
+ { kind: what.factKind ?? '', name: what.factName ?? '' },
55
+ 'a change record has to say which fact and which field moved'
56
+ )
57
+ }
58
+
59
+ const at = what.at ?? now
60
+
61
+ return what.changes.map(one => ({
62
+ factKind: what.factKind,
63
+ factName: what.factName,
64
+ field: one.field,
65
+ ...(one.fromValue !== undefined ? { fromValue: one.fromValue } : {}),
66
+ ...(one.toValue !== undefined ? { toValue: one.toValue } : {}),
67
+ at,
68
+ ...(by.source ? { source: by.source } : {}),
69
+ ...(by.reason ? { reason: by.reason } : {}),
70
+ ...(by.activityInstanceId ? { activityInstanceId: by.activityInstanceId } : {}),
71
+ ...(by.user ? { changedBy: by.user } : {})
72
+ }))
73
+ }
74
+
75
+ /**
76
+ * A change row is written in the transaction that makes the change. Committed on its own, it outlives a
77
+ * change that then fails, and the history says something happened that did not.
78
+ */
79
+ export function assertFactChangeInTransaction(manager: MaybeTransactional | undefined | null): void {
80
+ if (isWritingTransaction(manager)) return
81
+
82
+ throw new Refusal(
83
+ 'change-record-outside-transaction',
84
+ {},
85
+ 'a change record is written in the transaction that makes the change - on its own it can outlive a change that failed'
86
+ )
87
+ }
@@ -0,0 +1,48 @@
1
+ import type { EntityManager } from 'typeorm'
2
+
3
+ import type { Domain } from '@things-factory/shell'
4
+
5
+ import { FactChange } from './fact-change.js'
6
+ import { assertFactChangeInTransaction, factChangeRows, type FactChangeBy, type FactChangeWhat } from './fact-change-rows.js'
7
+
8
+ /**
9
+ * Writes the fields that moved, inside the caller's transaction. Returns how many rows were written;
10
+ * an empty change list writes nothing.
11
+ */
12
+ export async function recordFactChanges(
13
+ manager: EntityManager,
14
+ domain: Domain,
15
+ what: FactChangeWhat,
16
+ by: FactChangeBy = {}
17
+ ): Promise<number> {
18
+ assertFactChangeInTransaction(manager as any)
19
+
20
+ const rows = factChangeRows(what, by, new Date())
21
+ if (!rows.length) return 0
22
+
23
+ await manager.getRepository(FactChange).save(rows.map(row => ({ ...row, domain })) as any)
24
+ return rows.length
25
+ }
26
+
27
+ /**
28
+ * What moved on one fact. The order is the reader's: a panel of recent changes reads newest first, a
29
+ * history reads in the order things happened.
30
+ *
31
+ * There is no door here that reads a whole domain at once: the table only grows. Each product opens its
32
+ * own query per kind, under that kind's privilege.
33
+ */
34
+ export async function readFactChanges(
35
+ manager: EntityManager,
36
+ domain: Domain,
37
+ factKind: string,
38
+ factName: string,
39
+ order: 'newest-first' | 'oldest-first' = 'newest-first'
40
+ ): Promise<FactChange[]> {
41
+ const direction = order === 'newest-first' ? 'DESC' : 'ASC'
42
+
43
+ return await manager.getRepository(FactChange).find({
44
+ where: { domain: { id: domain.id }, factKind, factName },
45
+ relations: { changedBy: true },
46
+ order: { at: direction, createdAt: direction }
47
+ })
48
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Turning field values into the text a change row holds, and picking which fields moved.
3
+ *
4
+ * Values are written as text because fields have different types (numbers, times, arrays, open bags)
5
+ * and the row is for reading, not for computing again — the current value lives on the fact itself.
6
+ * Times are ISO so a reader in another region knows which instant it was; arrays and bags are JSON so
7
+ * a reordering shows as a change.
8
+ *
9
+ * "No value" and "empty value" are different facts. `asText` keeps `undefined` for none, so the column
10
+ * stays empty, and writes `''` only when the value really was an empty string.
11
+ */
12
+ export interface FieldChange {
13
+ field: string
14
+ /** The value before; `undefined` when there was none — not an empty string. */
15
+ fromValue?: string
16
+ /** The value after; `undefined` when it was cleared. */
17
+ toValue?: string
18
+ }
19
+
20
+ export function asText(value: unknown): string | undefined {
21
+ if (value === undefined || value === null) return undefined
22
+ if (value instanceof Date) return value.toISOString()
23
+ if (typeof value === 'object') return JSON.stringify(value)
24
+ return String(value)
25
+ }
26
+
27
+ /**
28
+ * Which of `fields` moved between two versions of a fact.
29
+ *
30
+ * The caller names the fields because some move on every save without anyone changing them
31
+ * (`updatedAt`, stamps); comparing everything would bury the real changes under those.
32
+ */
33
+ export function changedFields(
34
+ before: Record<string, unknown>,
35
+ after: Record<string, unknown>,
36
+ fields: readonly string[]
37
+ ): FieldChange[] {
38
+ const moved: FieldChange[] = []
39
+
40
+ for (const field of fields) {
41
+ const fromValue = asText(before[field])
42
+ const toValue = asText(after[field])
43
+ if (fromValue === toValue) continue
44
+ moved.push({ field, ...(fromValue !== undefined ? { fromValue } : {}), ...(toValue !== undefined ? { toValue } : {}) })
45
+ }
46
+
47
+ return moved
48
+ }
49
+
50
+ /**
51
+ * What an overwrite changes, for rows rewritten in place (a decision taken again, a policy, a
52
+ * destination). Unlike `changedFields`, an empty string counts as no value here: those forms clear a
53
+ * field by sending `''`, and clearing is not a change from nothing to nothing.
54
+ */
55
+ export function fieldChanges<T extends Record<string, unknown>>(
56
+ before: Partial<T> | null | undefined,
57
+ after: Partial<T>,
58
+ fields: readonly (keyof T & string)[]
59
+ ): FieldChange[] {
60
+ const text = (value: unknown) => asText(value === '' ? undefined : value)
61
+
62
+ return fields
63
+ .map(field => ({ field, fromValue: text(before?.[field]), toValue: text(after[field]) }))
64
+ .filter(one => one.fromValue !== one.toValue)
65
+ }
66
+
67
+ /**
68
+ * A row as it was, at the moment it is deleted: one change per content column with a value, from that
69
+ * value to nothing. A deletion is reversible only if what it removed stays readable (ADR-0064 ③).
70
+ */
71
+ export function deletionChanges(snapshot: Record<string, unknown>): FieldChange[] {
72
+ return Object.entries(snapshot)
73
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
74
+ .map(([field, value]) => ({ field, fromValue: asText(value) }))
75
+ }
@@ -0,0 +1,81 @@
1
+ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
2
+ import { Field, ID, ObjectType } from 'type-graphql'
3
+
4
+ import { User } from '@things-factory/auth-base'
5
+ import { Domain } from '@things-factory/shell'
6
+
7
+ /**
8
+ * One field of a recorded fact moved — from what, to what, when, who, and through which approval.
9
+ *
10
+ * The audit columns a fact carries (`updatedAt`, `updater`) answer only who touched it last; three
11
+ * changes a day erase the first two. This table keeps each move.
12
+ *
13
+ * The fact is addressed by `factKind` + `factName` in the product's vocabulary, so one table serves job
14
+ * orders, production reports, station screens and anything else without a table per kind (ADR-0124).
15
+ * Approval details are not copied: `activityInstanceId` points at the worklist record that holds them.
16
+ */
17
+ @Entity({ name: 'fact_changes' })
18
+ @Index('ix_fact_changes_0', (change: FactChange) => [change.domain, change.factKind, change.factName, change.at])
19
+ @ObjectType({ description: 'One recorded change to a fact — what moved, who moved it, and why' })
20
+ export class FactChange {
21
+ @PrimaryGeneratedColumn('uuid')
22
+ @Field(type => ID)
23
+ readonly id: string
24
+
25
+ @ManyToOne(type => Domain)
26
+ @Field(type => Domain)
27
+ domain?: Domain
28
+
29
+ @RelationId((change: FactChange) => change.domain)
30
+ domainId?: string
31
+
32
+ @Column()
33
+ @Field({ description: 'which kind of fact this change belongs to' })
34
+ factKind: string
35
+
36
+ @Column()
37
+ @Field({ description: 'the fact this change belongs to, by name' })
38
+ factName: string
39
+
40
+ @Column()
41
+ @Field({ description: 'which field moved' })
42
+ field: string
43
+
44
+ @Column({ nullable: true })
45
+ @Field({ nullable: true, description: 'the value before, as text — empty means it had none' })
46
+ fromValue?: string
47
+
48
+ @Column({ nullable: true })
49
+ @Field({ nullable: true, description: 'the value after, as text' })
50
+ toValue?: string
51
+
52
+ @Column()
53
+ @Field({ description: 'when it moved' })
54
+ at: Date
55
+
56
+ /** Where the change came from, in the product's words. Empty when the product does not say. */
57
+ @Column({ type: 'varchar', nullable: true })
58
+ @Field({ nullable: true, description: "where this change came from, in the product's words" })
59
+ source?: string
60
+
61
+ /** Why. Empty means nobody wrote one, not that there was no reason. */
62
+ @Column({ nullable: true })
63
+ @Field({ nullable: true, description: 'why — may be empty; empty means nobody wrote one' })
64
+ reason?: string
65
+
66
+ @Column({ nullable: true })
67
+ @Field({ nullable: true, description: 'the approval this came through' })
68
+ activityInstanceId?: string
69
+
70
+ /** Who changed it. Empty when the system did; `source` then says which. */
71
+ @ManyToOne(type => User, { nullable: true })
72
+ @Field(type => User, { nullable: true })
73
+ changedBy?: User
74
+
75
+ @RelationId((change: FactChange) => change.changedBy)
76
+ changedById?: string
77
+
78
+ @CreateDateColumn()
79
+ @Field({ nullable: true })
80
+ createdAt?: Date
81
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "strict": false,
5
+ "module": "commonjs",
6
+ "outDir": "../dist-server",
7
+ "baseUrl": "./"
8
+ },
9
+ "include": ["./**/*"]
10
+ }
@@ -0,0 +1,90 @@
1
+ /*
2
+ * The change log written in a real transaction and read back. It runs on the built output (`dist-server`)
3
+ * because the entity needs its decorators compiled; `npm test` builds first.
4
+ */
5
+ import assert from 'node:assert/strict'
6
+ import { createRequire } from 'node:module'
7
+ import { after, before, test } from 'node:test'
8
+
9
+ const req = createRequire(import.meta.url)
10
+ const { NamingStrategy, addDataSource, removeDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
11
+ const { entities: authEntities } = req('@things-factory/auth-base')
12
+ const factChange = req('../dist-server/index.js')
13
+ const { DataSource } = req('typeorm')
14
+
15
+ let ds: any
16
+ let domain: any
17
+
18
+ const withdrawn = (name: string) => ({
19
+ factKind: 'station-screen-spec',
20
+ factName: name,
21
+ changes: [
22
+ { field: 'name', fromValue: 'dock receiving' },
23
+ { field: 'controlled', fromValue: 'true' }
24
+ ]
25
+ })
26
+
27
+ before(async () => {
28
+ ds = new DataSource({
29
+ type: 'sqlite',
30
+ database: ':memory:',
31
+ synchronize: true,
32
+ namingStrategy: new NamingStrategy(),
33
+ entities: [...shellEntities, ...authEntities, ...factChange.entities],
34
+ logging: false
35
+ })
36
+ await ds.initialize()
37
+ addDataSource('default', ds)
38
+ domain = await ds.getRepository(Domain).save({ name: 'change-test', subdomain: 'change-test' })
39
+ })
40
+
41
+ after(async () => {
42
+ removeDataSource?.('default')
43
+ if (ds?.isInitialized) await ds.destroy()
44
+ })
45
+
46
+ test('written inside the transaction that makes the change, one row per field, in the table fact_changes', async () => {
47
+ await ds.transaction(async (tx: any) => {
48
+ assert.equal(await factChange.recordFactChanges(tx, domain, withdrawn('board-1'), { activityInstanceId: 'ai-1' }), 2)
49
+ })
50
+
51
+ const tables = await ds.query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'fact_changes'`)
52
+ assert.equal(tables.length, 1)
53
+
54
+ const rows = await factChange.readFactChanges(ds.manager, domain, 'station-screen-spec', 'board-1', 'oldest-first')
55
+ assert.deepEqual(rows.map((row: any) => [row.field, row.fromValue, row.activityInstanceId]).sort(), [
56
+ ['controlled', 'true', 'ai-1'],
57
+ ['name', 'dock receiving', 'ai-1']
58
+ ])
59
+ })
60
+
61
+ test('a change that fails takes its record with it', async () => {
62
+ await assert.rejects(
63
+ ds.transaction(async (tx: any) => {
64
+ await factChange.recordFactChanges(tx, domain, withdrawn('board-2'))
65
+ throw new Error('the delete after the record failed')
66
+ })
67
+ )
68
+ assert.deepEqual(await factChange.readFactChanges(ds.manager, domain, 'station-screen-spec', 'board-2'), [])
69
+ })
70
+
71
+ test('outside a transaction nothing is written', async () => {
72
+ await assert.rejects(
73
+ factChange.recordFactChanges(ds.manager, domain, withdrawn('board-3')),
74
+ (error: any) => error?.code === 'change-record-outside-transaction'
75
+ )
76
+ assert.deepEqual(await factChange.readFactChanges(ds.manager, domain, 'station-screen-spec', 'board-3'), [])
77
+ })
78
+
79
+ test('reads newest first unless the reader asks for the order things happened', async () => {
80
+ const kind = 'job-order'
81
+ await ds.transaction(async (tx: any) => {
82
+ await factChange.recordFactChanges(tx, domain, { factKind: kind, factName: 'WO-1', at: new Date('2026-10-01T00:00:00Z'), changes: [{ field: 'priority', fromValue: '1', toValue: '2' }] })
83
+ await factChange.recordFactChanges(tx, domain, { factKind: kind, factName: 'WO-1', at: new Date('2026-10-02T00:00:00Z'), changes: [{ field: 'priority', fromValue: '2', toValue: '3' }] })
84
+ })
85
+
86
+ const newest = await factChange.readFactChanges(ds.manager, domain, kind, 'WO-1')
87
+ const oldest = await factChange.readFactChanges(ds.manager, domain, kind, 'WO-1', 'oldest-first')
88
+ assert.deepEqual(newest.map((row: any) => row.toValue), ['3', '2'])
89
+ assert.deepEqual(oldest.map((row: any) => row.toValue), ['2', '3'])
90
+ })
@@ -0,0 +1,65 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { Refusal } from '@things-factory/shell/dist-server/refusal/refusal.js'
5
+
6
+ import { assertFactChangeInTransaction, factChangeRows } from '../server/service/fact-change-rows.js'
7
+
8
+ /*
9
+ * Which rows a change becomes, and when it may be written. Ported from operato-warehouse's change-record
10
+ * tests when the log moved here (ADR-0124).
11
+ */
12
+ const AT = new Date('2026-09-24T03:00:00.000Z')
13
+ const withdrawn = {
14
+ factKind: 'station-screen-spec',
15
+ factName: 'board-7',
16
+ changes: [
17
+ { field: 'name', fromValue: 'dock receiving' },
18
+ { field: 'controlled', fromValue: 'true' }
19
+ ]
20
+ }
21
+
22
+ test('each field that moved is one row, with what it was, who moved it, when, and through what', () => {
23
+ const rows = factChangeRows(withdrawn, { user: { id: 'u-1' }, activityInstanceId: 'ai-9', source: 'manual', reason: 'retired' }, AT)
24
+
25
+ assert.deepEqual(
26
+ rows.map(row => [row.field, row.fromValue, row.toValue, row.at.toISOString(), row.activityInstanceId, row.source, row.reason]),
27
+ [
28
+ ['name', 'dock receiving', undefined, AT.toISOString(), 'ai-9', 'manual', 'retired'],
29
+ ['controlled', 'true', undefined, AT.toISOString(), 'ai-9', 'manual', 'retired']
30
+ ]
31
+ )
32
+ assert.deepEqual(rows[0].changedBy, { id: 'u-1' })
33
+ })
34
+
35
+ test('a product that keeps no source or reason writes none', () => {
36
+ const [row] = factChangeRows(withdrawn, {}, AT)
37
+ assert.equal('source' in row, false)
38
+ assert.equal('reason' in row, false)
39
+ })
40
+
41
+ test('the time the change says wins over the time it was written down', () => {
42
+ const said = new Date('2026-09-20T00:00:00.000Z')
43
+ assert.equal(factChangeRows({ ...withdrawn, at: said }, {}, AT)[0].at.toISOString(), said.toISOString())
44
+ })
45
+
46
+ test('nothing moved is nothing written', () => {
47
+ assert.deepEqual(factChangeRows({ ...withdrawn, changes: [] }, {}, AT), [])
48
+ })
49
+
50
+ test('a change about no fact is refused rather than written about nothing', () => {
51
+ for (const what of [{ ...withdrawn, factKind: '' }, { ...withdrawn, factName: ' ' }, { ...withdrawn, changes: [{ field: '' }] }]) {
52
+ assert.throws(
53
+ () => factChangeRows(what, {}, AT),
54
+ (error: unknown) => error instanceof Refusal && error.code === 'change-names-no-fact'
55
+ )
56
+ }
57
+ })
58
+
59
+ test('outside a transaction it refuses: the record and the change it describes go in together', () => {
60
+ assert.throws(
61
+ () => assertFactChangeInTransaction({}),
62
+ (error: unknown) => error instanceof Refusal && error.code === 'change-record-outside-transaction'
63
+ )
64
+ assert.doesNotThrow(() => assertFactChangeInTransaction({ queryRunner: { isTransactionActive: true } }))
65
+ })
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { asText, changedFields, deletionChanges, fieldChanges } from '../server/service/fact-change-text.js'
5
+
6
+ /*
7
+ * A recorded fact can be corrected, and the history has to say what moved. Ported from operato-plant's
8
+ * fact-change-diff and deletion-changes tests when the log moved here (ADR-0124).
9
+ */
10
+ describe('changedFields', () => {
11
+ it('names only the fields that moved', () => {
12
+ const moved = changedFields(
13
+ { goodCount: 100, nonconformingCount: 0, status: 'started' },
14
+ { goodCount: 98, nonconformingCount: 2, status: 'started' },
15
+ ['goodCount', 'nonconformingCount', 'status']
16
+ )
17
+ assert.deepEqual(moved.map(one => one.field), ['goodCount', 'nonconformingCount'])
18
+ })
19
+
20
+ it('looks only at the fields the caller names, so fields that move on every save stay out', () => {
21
+ const moved = changedFields(
22
+ { goodCount: 1, updatedAt: new Date('2026-09-08T00:00:00Z') },
23
+ { goodCount: 2, updatedAt: new Date('2026-09-08T01:00:00Z') },
24
+ ['goodCount']
25
+ )
26
+ assert.deepEqual(moved.map(one => one.field), ['goodCount'])
27
+ })
28
+
29
+ it('keeps zero apart from absent', () => {
30
+ const counted = changedFields({ goodCount: undefined }, { goodCount: 0 }, ['goodCount'])
31
+ assert.equal(counted.length, 1)
32
+ assert.equal(counted[0].fromValue, undefined)
33
+ assert.equal(counted[0].toValue, '0')
34
+ })
35
+
36
+ it('counts clearing a value as a change', () => {
37
+ const cleared = changedFields({ reason: 'jig wear' }, { reason: undefined }, ['reason'])
38
+ assert.equal(cleared[0].fromValue, 'jig wear')
39
+ assert.equal(cleared[0].toValue, undefined)
40
+ })
41
+
42
+ it('treats none on both sides as no change', () => {
43
+ assert.deepEqual(changedFields({ a: undefined }, { a: null }, ['a']), [])
44
+ })
45
+
46
+ it('writes times as ISO, and arrays and bags in their own shape', () => {
47
+ assert.equal(asText(new Date('2026-09-08T14:05:00Z')), '2026-09-08T14:05:00.000Z')
48
+ const moved = changedFields(
49
+ { equipmentNames: ['EQ-A'], materialActual: [{ definitionId: 'M1', quantity: 2 }] },
50
+ { equipmentNames: ['EQ-B'], materialActual: [{ definitionId: 'M1', quantity: 3 }] },
51
+ ['equipmentNames', 'materialActual']
52
+ )
53
+ assert.equal(moved[0].fromValue, '["EQ-A"]')
54
+ assert.match(moved[1].toValue!, /"quantity":3/)
55
+ })
56
+
57
+ it('writes nothing for a save that sends the same values again', () => {
58
+ const same = { goodCount: 24, status: 'completed', endTime: new Date('2026-09-08T10:00:00Z') }
59
+ assert.deepEqual(changedFields(same, { ...same }, ['goodCount', 'status', 'endTime']), [])
60
+ })
61
+ })
62
+
63
+ describe('fieldChanges', () => {
64
+ it('reads an empty string as no value, so clearing a blank field is no change', () => {
65
+ assert.deepEqual(fieldChanges({ note: '' }, { note: undefined }, ['note']), [])
66
+ assert.deepEqual(fieldChanges({ note: 'x' }, { note: '' }, ['note']), [{ field: 'note', fromValue: 'x', toValue: undefined }])
67
+ })
68
+
69
+ it('reads a missing before as nothing', () => {
70
+ assert.deepEqual(fieldChanges(null, { limit: 3 }, ['limit']), [{ field: 'limit', fromValue: undefined, toValue: '3' }])
71
+ })
72
+ })
73
+
74
+ describe('deletionChanges', () => {
75
+ it('turns a deleted row into one change per filled column, dates and objects as text', () => {
76
+ const at = new Date('2026-10-03T00:00:00.000Z')
77
+ assert.deepEqual(
78
+ deletionChanges({ name: 'HOLIDAY', description: null, appliesTo: '', fromDateTime: at, flow: { steps: [1] }, controlled: false }),
79
+ [
80
+ { field: 'name', fromValue: 'HOLIDAY' },
81
+ { field: 'fromDateTime', fromValue: '2026-10-03T00:00:00.000Z' },
82
+ { field: 'flow', fromValue: '{"steps":[1]}' },
83
+ { field: 'controlled', fromValue: 'false' }
84
+ ]
85
+ )
86
+ })
87
+ })
@@ -0,0 +1,63 @@
1
+ /*
2
+ * ★ 소스 테스트가 형제 모듈을 부를 수 있게 한다 — **`.js` 로 적힌 상대 경로를 `.ts` 로 되짚는다.**
3
+ *
4
+ * ── 무엇이 서고 있었나 (2026-08-22) ─────────────────────────────────────────
5
+ * 이 패키지의 `npm test` 는 `node --test test/*.test.ts` 로 **소스를 그대로 실행**한다. 그런데 서버
6
+ * 소스는 tsc 로 빌드되는 코드이므로 상대 import 를 `'./x.js'` 로 적는다(그것이 빌드 산출물에서 맞는
7
+ * 경로다). 타입만 지우는 실행은 그 `.js` 를 `.ts` 로 바꿔 주지 않는다.
8
+ *
9
+ * 그래서 **소스 모듈이 형제 모듈을 값으로 참조하는 순간** 그 모듈 그래프를 부르는 테스트가 전부 선다.
10
+ * 실측으로 하루에 세 종류가 이 벽에 걸렸다: `identity/namespace.js`(정체성 이름공간) ·
11
+ * `engine/log.js`(로그 문) · `isa95-coverage.js`(적합성 판정). 늘 같은 오류다.
12
+ *
13
+ * Cannot find module '…/server/identity/namespace.js' imported from '…/templates/wms-unit-load.ts'
14
+ *
15
+ * ── 왜 이 방식인가 ──────────────────────────────────────────────────────────
16
+ * 고르던 길이 둘 있었다.
17
+ *
18
+ * ① 그 테스트들을 **빌드 산출물**(`dist-server`)로 돌린다 — 이 패키지에 전례가 있다. 그러나 테스트마다
19
+ * 그 배선을 적어야 하고, 걸리는 테스트가 늘 때마다 같은 일을 다시 한다. 그리고 산출물이 낡으면
20
+ * **고친 코드가 아닌 것이 돌면서 초록으로 보인다.**
21
+ * ② 상수를 쓰는 파일 안으로 옮긴다 — 값 참조가 사라지지만 **정본이 흩어진다.** 그것이 이 상수를 한
22
+ * 자리에 모은 이유와 정면으로 어긋난다.
23
+ *
24
+ * 둘 다 증상을 피하는 것이고, 원인은 **해석 규칙 하나**다. 그래서 규칙을 고친다: 상대 경로 `.js` 가
25
+ * 없고 같은 이름의 `.ts` 가 있으면 그것을 쓴다. 한 자리에 두므로 걸리는 테스트가 늘어도 할 일이 없다.
26
+ *
27
+ * ── 무엇을 하지 않는가 ─────────────────────────────────────────────────────
28
+ * **패키지 이름(`@things-factory/…`)은 손대지 않는다.** 그것은 의존 패키지의 산출물을 가리키는
29
+ * 것이고 실제로 `.js` 가 있다. 되짚는 것은 **상대 경로**뿐이고, 그것도 `.ts` 가 실제로 있을 때만이다 —
30
+ * 없으면 원래 오류가 그대로 난다(없는 것을 지어내지 않는다).
31
+ *
32
+ * ── 확장자를 아예 안 적은 것도 같다 (2026-08-30) ────────────────────────────
33
+ * 이 패키지의 서버 소스는 상대 경로를 확장자 없이 적는다(`'./work-window'`). 타입만 참조하는 동안에는
34
+ * 그 줄이 지워져 드러나지 않다가, **값을 참조하는 순간** 같은 벽에 걸린다. 규칙 하나로 둘 다 받는다.
35
+ */
36
+ import { registerHooks } from 'node:module'
37
+ import { existsSync } from 'node:fs'
38
+ import { fileURLToPath, pathToFileURL } from 'node:url'
39
+
40
+ registerHooks({
41
+ resolve(specifier, context, nextResolve) {
42
+ /* 상대 경로만 본다 — 패키지 이름은 그대로 둔다. */
43
+ if (!specifier.startsWith('.')) return nextResolve(specifier, context)
44
+
45
+ /* `.js` 로 적혔거나 확장자가 없는 것만 되짚는다. 다른 확장자(`.json`·`.css`)는 그 뜻대로 둔다. */
46
+ const writtenAsJs = specifier.endsWith('.js')
47
+ const hasOtherExtension = !writtenAsJs && /\.[a-zA-Z0-9]+$/.test(specifier)
48
+ if (hasOtherExtension) return nextResolve(specifier, context)
49
+
50
+ const base = writtenAsJs ? specifier.slice(0, -3) : specifier
51
+
52
+ try {
53
+ return nextResolve(specifier, context)
54
+ } catch (err) {
55
+ /* 해석이 실패한 그때만 `.ts` 를 본다 — 성공하는 경로의 뜻을 바꾸지 않는다. */
56
+ const parent = context?.parentURL
57
+ if (!parent) throw err
58
+ const asTs = new URL(base + '.ts', parent)
59
+ if (!existsSync(fileURLToPath(asTs))) throw err
60
+ return { url: pathToFileURL(fileURLToPath(asTs)).href, shortCircuit: true, format: 'module-typescript' }
61
+ }
62
+ }
63
+ })
@@ -0,0 +1 @@
1
+ export default {}