@michengai/dsh-archive-manager 1.1.6 → 1.1.7

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.
@@ -1,6 +1,6 @@
1
1
  import { bindArchiveManagerRemote, installArchiveWorkspace, tolerateStaleFileUploadResolver } from "./workspace.js";
2
2
  import { installArchiveProjectionCache, installArchiveProjectionCacheGuards } from "./projcache.js";
3
- const inject = ["workspaceRegistry", "sessionProjectionCache", "typert"];
3
+ const inject = ["workspaceRegistry", "sessionProjectionCache", "sessionPersistence", "typert"];
4
4
  async function apply(ctx) {
5
5
  tolerateStaleFileUploadResolver(ctx);
6
6
  const registry = ctx.workspaceRegistry;
@@ -23,9 +23,9 @@ async function apply(ctx) {
23
23
  };
24
24
  try {
25
25
  try {
26
- uninstallCache = await installArchiveProjectionCache(cache);
26
+ uninstallCache = await installArchiveProjectionCache(cache, Reflect.get(ctx, "sessionPersistence"));
27
27
  } catch (error) {
28
- ctx.logger?.warn?.(`archive-manager: projection cache overlay skipped: ${String(error)}`);
28
+ ctx.logger?.warn?.(`archive-manager: projection cache handoff skipped: ${String(error)}`);
29
29
  uninstallCache = installArchiveProjectionCacheGuards(cache);
30
30
  }
31
31
  unbindRemote = bindArchiveManagerRemote(ctx);
package/lib/projcache.js CHANGED
@@ -151,156 +151,146 @@ function normalizeLegacyRecord(record) {
151
151
  checkpointRecord.parse(normalized);
152
152
  return normalized;
153
153
  }
154
- async function importMissingRecords(ctx, target, records, label) {
154
+ function sameIdentity(left, right) {
155
+ return left.createdAt === right.createdAt && left.cwd === right.cwd && left.formatVersion === right.formatVersion && (left.isSeeded ?? false) === (right.isSeeded ?? false) && (left.inheritedEventCount ?? 0) === (right.inheritedEventCount ?? 0);
156
+ }
157
+ function mergeCheckpoint(current, incoming) {
158
+ if (current !== void 0 && incoming.identity.formatVersion === void 0 && current.identity.formatVersion !== void 0 && current.identity.createdAt === incoming.identity.createdAt && current.identity.cwd === incoming.identity.cwd && (current.identity.isSeeded ?? false) === (incoming.identity.isSeeded ?? false)) return current;
159
+ if (current === void 0 || !sameIdentity(current.identity, incoming.identity)) return incoming;
160
+ const rows = { ...current.rows };
161
+ let changed = false;
162
+ for (const [key, row] of Object.entries(incoming.rows)) {
163
+ const previous = rows[key];
164
+ if (previous === void 0 || previous.ver === row.ver && previous.seq < row.seq) {
165
+ rows[key] = row;
166
+ changed = true;
167
+ }
168
+ }
169
+ return changed ? { identity: current.identity, rows } : current;
170
+ }
171
+ async function importPreviousProjectionCache(ctx, target, persistence, guards) {
172
+ if (persistence === void 0) return 0;
173
+ const sources = [
174
+ await readLegacySafeRecords(ctx, safeProjectionCacheDomainSpec, "archive-manager current"),
175
+ await readLegacySafeRecords(ctx, legacySafeV2ProjectionCacheDomainSpec, "archive-manager v2"),
176
+ // 逐记录后端会把不同版本当缺行,打开成功也须单独读取 v1。
177
+ await readLegacySafeRecords(ctx, legacySafeProjectionCacheDomainSpec, "archive-manager v1")
178
+ ];
155
179
  let imported = 0;
156
- for (const [sessionId, record] of records) {
157
- if (target.has(sessionId)) continue;
180
+ for (const source of sources) for (const [id, raw] of source.records) {
181
+ if (projectionCacheDomainSpec.layout === "per-record" && !/^[A-Za-z0-9_-]+$/.test(id)) continue;
182
+ guards?.revisions.set(id, 0);
183
+ const revision = 0;
184
+ const unchanged = () => !guards?.deleted.has(id) && (guards?.revisions.get(id) ?? 0) === revision;
158
185
  try {
159
- const normalized = normalizeLegacyRecord(record);
160
- if (normalized === void 0) {
161
- ctx.logger.warn(`archive-manager projcache: ${label} row "${sessionId}" import skipped because its lifecycle identity is incomplete`);
162
- continue;
186
+ const record = normalizeLegacyRecord(raw);
187
+ if (record === void 0 || !unchanged()) continue;
188
+ const snapshot = await persistence.stat(id);
189
+ const header = snapshot?.header;
190
+ if (!header || header.id !== id || header.createdAt !== record.identity.createdAt || header.cwd !== record.identity.cwd || (header.isSeeded ?? false) !== (record.identity.isSeeded ?? false) || record.identity.formatVersion !== void 0 && record.identity.formatVersion !== header.version) continue;
191
+ if (header.isSeeded) {
192
+ if (!persistence.open) continue;
193
+ const handle = await persistence.open(id, "read");
194
+ try {
195
+ if (handle.inheritedEventCount !== record.identity.inheritedEventCount) continue;
196
+ } finally {
197
+ await handle.close();
198
+ }
199
+ }
200
+ if (!unchanged()) continue;
201
+ const existing = target.get(id);
202
+ if (existing !== void 0 && mergeCheckpoint(existing, record) === existing) continue;
203
+ try {
204
+ await target.update(id, (current) => unchanged() ? mergeCheckpoint(current, record) : current);
205
+ } catch (error) {
206
+ if (!(error instanceof Error) || Reflect.get(error, "code") !== "missing-key") throw error;
207
+ if (!unchanged()) continue;
208
+ await target.put(id, record);
163
209
  }
164
- await target.put(sessionId, normalized);
165
210
  imported += 1;
166
211
  } catch (error) {
167
- ctx.logger.warn(`archive-manager projcache: ${label} row "${sessionId}" import failed: ${String(error)}`);
212
+ ctx.logger.warn(`archive-manager projcache: historical row "${id}" handoff failed: ${String(error)}`);
213
+ } finally {
214
+ guards?.revisions.delete(id);
168
215
  }
169
216
  }
170
217
  return imported;
171
218
  }
172
- async function importPreviousProjectionCache(ctx, target, currentTable) {
173
- const sameSpec = projectionCacheDomainSpec.version === legacyProjectionCacheDomainSpec.version && projectionCacheDomainSpec.layout === legacyProjectionCacheDomainSpec.layout;
174
- const legacySafeV2 = await readLegacySafeRecords(ctx, legacySafeV2ProjectionCacheDomainSpec, "archive-manager v2");
175
- const legacySafe = await readLegacySafeRecords(ctx, legacySafeProjectionCacheDomainSpec, "archive-manager v1");
176
- const legacy = sameSpec && currentTable !== void 0 ? { opened: true, records: [...currentTable.entries()] } : await readSourceRecords(ctx, legacyProjectionCacheDomainSpec, "legacy v3");
177
- let imported = 0;
178
- if (legacySafeV2.opened) imported += await importMissingRecords(ctx, target, legacySafeV2.records, "archive-manager v2");
179
- if (legacySafe.opened) imported += await importMissingRecords(ctx, target, legacySafe.records, "archive-manager v1");
180
- if (!sameSpec) {
181
- const current = currentTable !== void 0 ? { opened: true, records: [...currentTable.entries()] } : await readSourceRecords(ctx, projectionCacheDomainSpec, "current");
182
- if (current.opened) imported += await importMissingRecords(ctx, target, current.records, "current");
183
- }
184
- if (legacy.opened) imported += await importMissingRecords(ctx, target, legacy.records, "legacy v3");
185
- return imported;
186
- }
187
- var ArchiveProjectionCache = class extends CacheBase {
188
- /** 已永久删除的会话:其投影缓存行不再允许写入。 */
189
- deletedSessionIds = /* @__PURE__ */ new Set();
190
- /** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
191
- deletedSessionOrder = [];
192
- deletedSessionTombstoneLimit = 4096;
193
- /** 在途写入的队尾(只含已落定的 promise)。 */
194
- writeTail = Promise.resolve();
195
- constructor(ctx, config) {
196
- super(ctx, config);
197
- }
198
- /**
199
- * Replace the official cache domain with the path-safe compatibility
200
- * domain, then preserve the upstream listener/write implementation.
201
- */
219
+ class ArchiveProjectionCache extends CacheBase {
220
+ static inject = [...SessionProjectionCache.inject, "sessionPersistence"];
202
221
  async [Service.init]() {
203
- const domain = await this.ctx.storageDomain.open(safeProjectionCacheDomainSpec);
204
- this.ctx.effect(() => () => domain.close(), "sessionProjectionCache.domainClose");
205
- const table = new SafeSessionTable(domain.table("sessions"));
206
- this.table = table;
207
- await importPreviousProjectionCache(this.ctx, table);
208
- this.installWritePath();
209
- }
210
- /** 跟踪公开写入路径,避免依赖上游私有 `flushSoft` 的实现细节。 */
211
- write(session) {
212
- const task = this.writeCore(session);
213
- this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
214
- return task;
215
- }
216
- /**
217
- * 墓碑正确性依赖:super.write(session) 一次整体写入,返回后不再有后续异步落盘。
218
- * 若上游改成多阶段异步,(C) 补删会漏掉后续写入,deletedSessionIds 挡不住复活。
219
- */
220
- async writeCore(session) {
221
- if (this.deletedSessionIds.has(session.id)) return;
222
- await super.write(session);
223
- if (this.deletedSessionIds.has(session.id)) await this.requireTable().delete(session.id);
224
- }
225
- put(id, identity, rows) {
226
- if (this.deletedSessionIds.has(id)) return Promise.resolve();
227
- const task = this.putCore(id, identity, rows);
228
- this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
229
- return task;
230
- }
231
- async putCore(id, identity, rows) {
232
- await super.put(id, identity, rows);
233
- if (this.deletedSessionIds.has(id)) await this.requireTable().delete(id);
222
+ await super[Service.init]();
223
+ const uninstall = await installArchiveProjectionCache(this, Reflect.get(this.ctx, "sessionPersistence"));
224
+ this.ctx.effect(() => uninstall, "archive-manager.projcacheGuards");
234
225
  }
235
- /**
236
- * 全部被跟踪的在途写入落定后 resolve(含失败)。空闲缓存上调用立即返回。
237
- * @returns 跟踪写入落定后的 resolution。
238
- */
239
- whenIdle() {
240
- return this.writeTail;
241
- }
242
- /**
243
- * 永久移除一个会话的缓存投影行。
244
- * 同时清理当前表与底层原生表(如官方 session_projcache 域),防止残留孤儿文件。
245
- * @param id - 要删除缓存行的会话。
246
- * @returns 行删除完成后的 resolution。
247
- */
248
- async delete(id) {
249
- trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, id, this.deletedSessionTombstoneLimit);
250
- const tasks = [this.requireTable().delete(id)];
251
- const secondary = this.secondaryTable;
252
- if (secondary !== void 0 && typeof secondary.delete === "function") {
253
- tasks.push(secondary.delete(id).catch(() => false));
254
- }
255
- await Promise.all(tasks);
256
- }
257
- /** 撤销墓碑(供测试与同 id 新生命周期复用路径使用)。 */
258
- clearTombstone(id) {
259
- this.deletedSessionIds.delete(id);
260
- const idx = this.deletedSessionOrder.indexOf(id);
261
- if (idx !== -1) this.deletedSessionOrder.splice(idx, 1);
262
- }
263
- };
226
+ }
264
227
  const projectionGuards = /* @__PURE__ */ new WeakMap();
265
- function installArchiveProjectionCacheGuards(cache, table, secondaryTable) {
228
+ function installArchiveProjectionCacheGuards(cache) {
266
229
  const target = Reflect.get(cache, symbols.original) ?? cache;
267
230
  const existing = projectionGuards.get(target);
268
- if (existing) return existing;
269
- const fields = {
270
- table: table ?? target.table,
271
- secondaryTable: secondaryTable !== (table ?? target.table) ? secondaryTable : void 0,
272
- deletedSessionIds: /* @__PURE__ */ new Set(),
273
- deletedSessionOrder: [],
274
- deletedSessionTombstoneLimit: 4096,
275
- writeTail: Promise.resolve()
276
- };
277
- const receiver = Object.create(target, Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value, writable: true, configurable: true }])));
278
- const restoredMethods = [];
231
+ if (existing) return existing.uninstall;
232
+ const table = target.requireTable();
233
+ const originalWrite = target.write;
234
+ const originalPut = target.put;
235
+ const deleted = /* @__PURE__ */ new Set();
236
+ const order = [];
237
+ const revisions = /* @__PURE__ */ new Map();
238
+ let tail = Promise.resolve();
279
239
  let active = true;
240
+ const track = (task) => {
241
+ tail = Promise.allSettled([tail, task]).then(() => void 0);
242
+ return task;
243
+ };
244
+ const changed = (id) => {
245
+ if (revisions.has(id)) revisions.set(id, revisions.get(id) + 1);
246
+ };
247
+ const put = (id, identity, rows) => {
248
+ changed(id);
249
+ if (deleted.has(id)) return Promise.resolve();
250
+ return track(Reflect.apply(originalPut, receiver, [id, identity, rows]));
251
+ };
252
+ const writeTable = Object.create(table, { put: { value: (id, record) => deleted.has(id) ? Promise.resolve() : table.put(id, record) } });
253
+ const receiver = Object.create(target, {
254
+ put: { value: put, configurable: true },
255
+ requireTable: { value: () => writeTable }
256
+ });
257
+ const methods = {
258
+ write(session) {
259
+ if (deleted.has(session.id)) return Promise.resolve();
260
+ return track(Reflect.apply(originalWrite, receiver, [session]));
261
+ },
262
+ put,
263
+ delete(id) {
264
+ changed(id);
265
+ trackTombstone(deleted, order, id, 4096);
266
+ return track(table.delete(id).then(() => void 0));
267
+ },
268
+ whenIdle: () => tail,
269
+ clearTombstone(id) {
270
+ changed(id);
271
+ deleted.delete(id);
272
+ const index = order.indexOf(id);
273
+ if (index !== -1) order.splice(index, 1);
274
+ }
275
+ };
276
+ const restored = [];
280
277
  const restore = () => {
281
278
  active = false;
282
- for (const item of [...restoredMethods].reverse()) {
283
- if (item.descriptor) Object.defineProperty(target, item.name, item.descriptor);
284
- else delete target[item.name];
279
+ for (const { name, descriptor, wrapper } of [...restored].reverse()) {
280
+ if (target[name] !== wrapper) continue;
281
+ if (descriptor) Object.defineProperty(target, name, descriptor);
282
+ else delete target[name];
285
283
  }
286
284
  };
287
285
  try {
288
- for (const name of Object.getOwnPropertyNames(ArchiveProjectionCache.prototype)) {
289
- if (name === "constructor") continue;
290
- const method = Reflect.get(ArchiveProjectionCache.prototype, name);
291
- if (typeof method !== "function") continue;
292
- Object.defineProperty(receiver, name, { value: method, writable: true, configurable: true });
286
+ for (const [name, method] of Object.entries(methods)) {
293
287
  const previous = target[name];
294
- restoredMethods.push({ name, descriptor: Object.getOwnPropertyDescriptor(target, name) });
295
- target[name] = (...args) => {
296
- if (!active) {
297
- if (typeof previous === "function") return Reflect.apply(previous, target, args);
298
- throw new Error("archive-manager: projection cache guards are stopped");
299
- }
300
- const result = Reflect.apply(method, receiver, args);
301
- if (name === "delete") receiver.writeTail = Promise.allSettled([receiver.writeTail, result]).then(() => void 0);
302
- return result;
288
+ const wrapper = (...args) => {
289
+ if (!active && typeof previous === "function") return Reflect.apply(previous, target, args);
290
+ return Reflect.apply(method, target, args);
303
291
  };
292
+ restored.push({ name, descriptor: Object.getOwnPropertyDescriptor(target, name), wrapper });
293
+ target[name] = wrapper;
304
294
  }
305
295
  } catch (error) {
306
296
  restore();
@@ -309,50 +299,22 @@ function installArchiveProjectionCacheGuards(cache, table, secondaryTable) {
309
299
  let disposal;
310
300
  const uninstall = () => {
311
301
  if (disposal) return disposal;
312
- const drain = receiver.writeTail;
302
+ const drain = tail;
313
303
  restore();
314
304
  projectionGuards.delete(target);
315
305
  return disposal = drain;
316
306
  };
317
- projectionGuards.set(target, uninstall);
307
+ projectionGuards.set(target, { uninstall, deleted, revisions });
318
308
  return uninstall;
319
309
  }
320
- const projectionOverlay = Symbol.for("dsh-archive-manager.projcache-overlay");
321
- async function installArchiveProjectionCache(cache) {
310
+ async function installArchiveProjectionCache(cache, persistence) {
322
311
  const target = Reflect.get(cache, symbols.original) ?? cache;
323
- if (target[projectionOverlay] === true) return async () => {
324
- };
325
- const domain = await target.ctx.storageDomain.open(safeProjectionCacheDomainSpec);
326
- let installed = false;
327
- const previousTable = target.table;
328
- let uninstallGuards;
312
+ const uninstall = installArchiveProjectionCacheGuards(cache);
329
313
  try {
330
- const table = new SafeSessionTable(domain.table("sessions"));
331
- await importPreviousProjectionCache(target.ctx, table, previousTable);
332
- uninstallGuards = installArchiveProjectionCacheGuards(cache, table, previousTable);
333
- target.table = table;
334
- target[projectionOverlay] = true;
335
- installed = true;
336
- let disposal;
337
- return () => disposal ??= (async () => {
338
- if (target[projectionOverlay] !== true) return;
339
- const drain = uninstallGuards();
340
- target.table = previousTable;
341
- delete target[projectionOverlay];
342
- await drain;
343
- await domain.close();
344
- })();
314
+ await importPreviousProjectionCache(target.ctx, target.requireTable(), persistence, projectionGuards.get(target));
315
+ return uninstall;
345
316
  } catch (error) {
346
- if (!installed) {
347
- const drain = uninstallGuards?.();
348
- target.table = previousTable;
349
- delete target[projectionOverlay];
350
- try {
351
- await drain;
352
- } finally {
353
- await domain.close();
354
- }
355
- }
317
+ await uninstall();
356
318
  throw error;
357
319
  }
358
320
  }
@@ -5,11 +5,45 @@ import { join, resolve, basename, dirname } from "node:path";
5
5
  import * as zlib from "node:zlib";
6
6
  import { classifySessionError } from "./archive-discovery.js";
7
7
  const LIMIT = 32 * 1024 * 1024;
8
+ function scanFirstZstdFrame(buffer) {
9
+ if (buffer.length < 4 || buffer.readUInt32LE(0) !== 4247762216) return void 0;
10
+ let offset = 4;
11
+ if (offset >= buffer.length) return void 0;
12
+ const descriptor = buffer.readUInt8(offset++);
13
+ if ((descriptor & 24) !== 0) return void 0;
14
+ const contentSizeFlag = descriptor >>> 6;
15
+ const singleSegment = (descriptor & 32) !== 0;
16
+ const checksum = (descriptor & 4) !== 0;
17
+ const dictionaryFlag = descriptor & 3;
18
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
19
+ const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
20
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
21
+ if (buffer.length - offset < remainingHeaderBytes) return void 0;
22
+ offset += remainingHeaderBytes;
23
+ for (; ; ) {
24
+ if (buffer.length - offset < 3) return void 0;
25
+ const blockHeader = buffer.readUIntLE(offset, 3);
26
+ offset += 3;
27
+ const lastBlock = (blockHeader & 1) !== 0;
28
+ const blockType = blockHeader >>> 1 & 3;
29
+ const blockSize = blockHeader >>> 3;
30
+ if (blockType === 3) return void 0;
31
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
32
+ if (buffer.length - offset < payloadBytes) return void 0;
33
+ offset += payloadBytes;
34
+ if (lastBlock) break;
35
+ }
36
+ if (checksum) {
37
+ if (buffer.length - offset < 4) return void 0;
38
+ offset += 4;
39
+ }
40
+ return { start: 0, end: offset };
41
+ }
8
42
  function decompressFrame(bytes, maxOutputLength) {
9
- const result = record(zlib.zstdDecompressSync(bytes, { info: true, maxOutputLength }));
10
- const engine = record(result.engine);
11
- if (!Buffer.isBuffer(result.buffer) || typeof engine.bytesWritten !== "number") throw new Error("\u538B\u7F29\u5E27\u8FD4\u56DE\u683C\u5F0F\u65E0\u6548");
12
- return { buffer: result.buffer, engine: { bytesWritten: engine.bytesWritten } };
43
+ const frame = scanFirstZstdFrame(bytes);
44
+ const target = frame ? bytes.subarray(0, frame.end) : bytes;
45
+ const buffer = zlib.zstdDecompressSync(target, { maxOutputLength });
46
+ return { buffer, engine: { bytesWritten: frame ? frame.end : bytes.length } };
13
47
  }
14
48
  const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
15
49
  function decodeRepairLog(bytes, compressed) {
@@ -170,6 +204,8 @@ export {
170
204
  childCatalogFact,
171
205
  classifySessionError,
172
206
  decodeRepairLog,
207
+ decompressFrame,
173
208
  normalizeAutomationSources,
174
- prepareAutomationRepair
209
+ prepareAutomationRepair,
210
+ scanFirstZstdFrame
175
211
  };