Archived
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user