From e5ca9caaa6fb2d5deea21a112d3e4a9559ccd5be Mon Sep 17 00:00:00 2001 From: xcs Date: Mon, 31 Aug 2026 11:35:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(ledger):=20=E8=8D=89=E7=A8=BF=E5=B1=82?= =?UTF-8?q?=E6=8C=89=E8=AF=9E=E7=94=9F=E5=BA=8F=E8=AF=BB=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BF=9D=E5=AD=98=E8=BF=87=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B7=B3=E5=88=B0=E5=88=97=E8=A1=A8=E6=9C=AB?= =?UTF-8?q?=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「点击保存后,列表里面的元素每次排列顺序都不一致,需要保持顺序的一致性」 病根在读:`select * from ledger.__draft ... order by updated_at`,而 `put` 的 upsert 分支每次都写 `updated_at = now()` —— **人保存了哪几行,哪几行就跳到列表末尾**; 同一批写下的行(配方一次 project 铺 7 行)`updated_at` 又完全相同,并列之间没有次级键, PostgreSQL 返回什么顺序都算数(更新一行 = 旧版本作废 + 新版本插到堆尾,物理顺序本来 就会变),于是每保存一次屏上的表就重排一次。 加 `seq bigserial`:insert 那一刻取号、upsert 的 update 分支不碰它 —— 记的正是「这一行 是第几个被写下的」,而配方铺行的顺序就是人期望在件上看到的顺序(原 PN 的参数在前、 候选的在后,组内按字典序 —— 配方那么遍历的)。单键、严格全序、没有并列。 **列加在 DRAFT_DDL 里,不写迁移**:这张表由启动时的 DDL 自建(`container.ts` 调 `DRAFT_DDL`),`landed_at` 立的就是这个先例 —— 头一版我没读到那行注释,写了迁移 0036 又撤掉(journal 已回退;开发库那条孤儿记录无害,drizzle 只按 when 比大小)。 测:ledger 集成测试补一条(对真库),同批写下五行 → 顺序原样;改中间那行、再改另外两行 → 顺序一个字不变;值确实写进去了(不是靠没写成功换来的稳定);新写的行排末尾。 变异验证:读序退回 updated_at,这一条真红(也证明它连的是真库,不是被 skip 掉)。 pnpm gates 退出码 0(2495 测)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../ledger/__tests__/ledger-store.it.test.ts | 47 ++++++++++++++++++- .../ledger/internal/adapter/ledger-store.ts | 30 ++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts b/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts index b343db7..933f600 100644 --- a/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts +++ b/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts @@ -32,6 +32,7 @@ const T6 = 't-it-6' as TaskId; const T7 = 't-it-7' as TaskId; const T8 = 't-it-8' as TaskId; const T9 = 't-it-9' as TaskId; +const T10 = 't-it-10' as TaskId; /** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */ const SIMULATED = new Set(); @@ -76,7 +77,7 @@ afterAll(async () => { .query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN]) .catch(() => undefined); await pool - .query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9]]) + .query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]]) .catch(() => undefined); await pool.end(); } @@ -463,3 +464,47 @@ describe('ledger · 数据工单确认即入库(范围放行)', () => { expect((await ledger.taskDrafts(T9))[0]).toMatchObject({ landed: false }); }, 60_000); }); + +/** + * **保存过的行不许跳位**(2026-08-31 用户:「点击保存后,列表里面的元素每次排列顺序 + * 都不一致,需要保持顺序的一致性」)。 + * + * 病根是草稿层从前按 `updated_at` 读:`put` 的 upsert 分支每次都写 `updated_at = now()`, + * 人保存了哪几行、哪几行就跳到列表末尾;同一批写下的行(配方一次 project 铺 7 行) + * `updated_at` 又完全相同,并列之间没有次级键 —— PostgreSQL 更新一行是「旧版本作废 + + * 新版本插到堆尾」,物理顺序本来就会变,于是每保存一次屏上的表就重排一次。 + * + * 这一条**只有对真库才测得出来**:假 store 的 Map 天然按插入序,永远看不到这个毛病。 + * 断言两件事:①同一批写下的多行,读出来就是写下的顺序;②改中间那一行(件上按保存) + * 之后**顺序一个字不变** —— 用真的 `draftWrite`(它走的就是那条 upsert)。 + */ +describe('ledger · 草稿层读出来的顺序稳定(件上的列表不许每次保存都重排)', () => { + const REF = { domain: DOMAIN, object: OBJECT }; + const keysOf = async (taskId: TaskId): Promise => + (await ledger.taskDrafts(taskId)).map((d) => String((d.key as Record)['pn'])); + + itDb()('按诞生序读:同批写下的顺序原样保留,改了中间那一行也不跳位', async () => { + const pns = ['PN-A', 'PN-B', 'PN-C', 'PN-D', 'PN-E']; + for (const pn of pns) { + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn }, patch: { pn, eau: 1 } }); + } + expect(await keysOf(T10)).toEqual(pns); + + /* 件上按保存 = 对已有的那一行再写一笔(走 upsert 的 update 分支) */ + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-B' }, patch: { eau: 999 } }); + expect(await keysOf(T10)).toEqual(pns); + + /* 再保存两次、换不同的行 —— 顺序仍是诞生序,不是「最近改过的在最后」 */ + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-D' }, patch: { eau: 7 } }); + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-A' }, patch: { eau: 8 } }); + expect(await keysOf(T10)).toEqual(pns); + + /* 值确实写进去了 —— 不是靠「没写成功」换来的稳定 */ + const rows = await ledger.taskDrafts(T10); + expect(rows.find((d) => (d.key as Record)['pn'] === 'PN-B')?.patch).toMatchObject({ eau: 999 }); + + /* 新写的行排在末尾(诞生序的另一半:后来的就该在后面) */ + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-F' }, patch: { pn: 'PN-F', eau: 2 } }); + expect(await keysOf(T10)).toEqual([...pns, 'PN-F']); + }, 60_000); +}); diff --git a/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts b/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts index 940ba24..df9a3fa 100644 --- a/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts +++ b/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts @@ -319,7 +319,16 @@ create table if not exists ${DRAFT_TABLE} ( ); -- 已落地记号(2026-08-27,数据工单确认即入库):非空 = 这一行已在一道更早的门上并进正式表, -- 放行时不再写它。加列走 if not exists —— 这张表由启动时的 DDL 自建,不走迁移。 -alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz;`; +alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz; +-- **诞生序**(2026-08-31 用户:「点击保存后,列表里面的元素每次排列顺序都不一致」): +-- 读草稿层从前按 updated_at 排,而 upsert 每次都写 updated_at = now() —— 人保存了哪几行, +-- 哪几行就跳到列表末尾;同一批写下的行(配方一次 project 铺 7 行)updated_at 又完全相同, +-- 并列之间没有次级键,PostgreSQL 返回什么顺序都算数(更新一行 = 旧版本作废 + 新版本插到 +-- 堆尾,物理顺序本来就会变),于是每保存一次屏上就重排一次。 +-- bigserial:**insert 那一刻取号,upsert 的 update 分支不碰它** —— 记的是「这一行是第几个 +-- 被写下的」,而配方铺行的顺序正是人期望在件上看到的顺序。单键、严格全序、没有并列。 +-- 与 landed_at 同款走 DDL 加列(这张表不走迁移,见上一句)。 +alter table ${DRAFT_TABLE} add column if not exists seq bigserial;`; type DraftRow = { task_id: string; @@ -346,16 +355,31 @@ function toEntry(row: DraftRow): DraftEntry & ObjectRef { export function createDraftStore(runner: Queryable): DraftStore { return { + /* + * **按诞生序读,不按更新时刻读**(2026-08-31 用户:「点击保存后,列表里面的元素 + * 每次排列顺序都不一致,需要保持顺序的一致性」)。 + * + * 从前是 `order by updated_at`,而 `put` 的 upsert 分支每次都写 `updated_at = now()` + * —— **人保存了哪几行,哪几行就跳到列表末尾**;同一批写下的行(配方一次 project + * 铺 7 行)`updated_at` 又完全相同,并列之间没有次级键,PostgreSQL 返回什么顺序都 + * 算数(更新一行 = 旧版本作废 + 新版本插到堆尾,物理顺序本来就会变),于是每保存 + * 一次,屏上的表就重排一次。 + * + * `seq` 是 `bigserial`(迁移 0036):**insert 那一刻取号,upsert 的 update 分支不碰它** + * —— 记的正是「这一行是第几个被写下的」。而配方铺行的顺序就是人期望在件上看到的 + * 顺序(原 PN 的参数在前、候选的在后,组内按字典序 —— 配方那么遍历的)。 + * 单键、严格全序、没有并列,也就没有「并列之间谁先谁后」这种不确定。 + */ async list(taskId) { const res = await runner.query( - `select * from ${DRAFT_TABLE} where task_id = $1 order by updated_at`, + `select * from ${DRAFT_TABLE} where task_id = $1 order by seq`, [taskId], ); return res.rows.map(toEntry); }, async listForObject(taskId, ref) { const res = await runner.query( - `select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by updated_at`, + `select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by seq`, [taskId, ref.domain, ref.object], ); return res.rows.map(toEntry);