fix(ledger): 草稿层按诞生序读 —— 保存过的行不再跳到列表末尾

用户:「点击保存后,列表里面的元素每次排列顺序都不一致,需要保持顺序的一致性」

病根在读:`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-31 11:35:03 +08:00
co-authored by Claude Opus 5
parent 2265ab7833
commit e5ca9caaa6
2 changed files with 73 additions and 4 deletions
@@ -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<string>();
@@ -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<string[]> =>
(await ledger.taskDrafts(taskId)).map((d) => String((d.key as Record<string, unknown>)['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<string, unknown>)['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);
});
@@ -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<DraftRow>(
`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<DraftRow>(
`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);