@deepseek-ai/dsh-storage-json 0.1.2-alpha.4 → 0.1.2-alpha.5

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.
package/lib/index.js CHANGED
@@ -132,16 +132,18 @@ function serializeRecord(version, value) {
132
132
  }
133
133
  /**
134
134
  * Parse one per-record document, validating its version stamp. A document
135
- * that is malformed or stamped with a different version is FOREIGN and reads
136
- * as absent — the per-record contract: one bad or stale record file must not
137
- * brick the whole unit, and a version bump discards stale records instead of
138
- * migrating them (the whole-unit format rejects instead, because there is
139
- * exactly one document).
135
+ * that is malformed or stamped with an unaccepted version is FOREIGN and
136
+ * reads as absent — the per-record contract: one bad or stale record file
137
+ * must not brick the whole unit, and a version bump discards stale records
138
+ * instead of migrating them (the whole-unit format rejects instead, because
139
+ * there is exactly one document).
140
140
  * @param text - Raw per-record document content.
141
- * @param version - Expected unit version; a mismatch discards the document.
141
+ * @param versions - Accepted unit versions (the current one plus the
142
+ * descriptor's compatibleVersions); any other stamp discards the
143
+ * document.
142
144
  * @returns the record value, or `undefined` for a foreign document.
143
145
  */
144
- function parseRecord(text, version) {
146
+ function parseRecord(text, versions) {
145
147
  let document;
146
148
  try {
147
149
  document = JSON.parse(text);
@@ -150,7 +152,7 @@ function parseRecord(text, version) {
150
152
  }
151
153
  if (typeof document !== "object" || document === null) return void 0;
152
154
  const { version: stamped, record } = document;
153
- if (stamped !== version) return void 0;
155
+ if (typeof stamped !== "number" || !versions.includes(stamped)) return void 0;
154
156
  return record;
155
157
  }
156
158
  //#endregion
@@ -281,15 +283,18 @@ var SingleJsonUnit = class {
281
283
  * memory unchanged.
282
284
  *
283
285
  * Per-record contract: a record document that is malformed or stamped with a
284
- * different version reads as an absent record one bad or stale file never
285
- * bricks the whole unit, and a version bump discards stale records instead
286
- * of migrating them. Record keys become path segments, so they must be
287
- * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
286
+ * version outside the accepted set (the descriptor's current version plus
287
+ * its `compatibleVersions`) reads as an absent record one bad or stale
288
+ * file never bricks the whole unit, and a version bump discards stale
289
+ * records instead of migrating them. Record keys become path segments, so
290
+ * they must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
288
291
  *
289
292
  * Legacy bootstrap: when the new tree has no document path, a legacy
290
293
  * whole-unit file `<root>/<name>.json` (the pre-per-record layout) seeds
291
- * per-record documents. Any new document path, including one whose contents
292
- * are unreadable or stale, suppresses the bootstrap for the whole unit. The
294
+ * per-record documents, provided its stored unit version is in the accepted
295
+ * set a legacy file stamped with any other version is left alone and reads
296
+ * as the empty unit. Any new document path, including one whose contents are
297
+ * unreadable or stale, suppresses the bootstrap for the whole unit. The
293
298
  * legacy file is never changed or deleted.
294
299
  * @module @deepseek-ai/dsh-storage-json/src/per-record-unit
295
300
  */
@@ -318,6 +323,7 @@ async function openPerRecordUnit(descriptor, root, onClose) {
318
323
  * @returns the authoritative state reconstructed from the tree.
319
324
  */
320
325
  async function loadPerRecordState(descriptor, dir) {
326
+ const versions = acceptedStamps(descriptor);
321
327
  const state = {
322
328
  version: descriptor.version,
323
329
  global: null,
@@ -332,10 +338,10 @@ async function loadPerRecordState(descriptor, dir) {
332
338
  if (!(entries === void 0 ? false : (await Promise.all(entries.map(async (entry) => {
333
339
  if (entry.isDirectory()) {
334
340
  const records = state.tables.get(entry.name);
335
- if (records !== void 0) return loadTableRecords(records, descriptor.version, join(dir, entry.name));
341
+ if (records !== void 0) return loadTableRecords(records, versions, join(dir, entry.name));
336
342
  }
337
343
  if (entry.name === "global.json" && descriptor.hasGlobal) {
338
- const global = await readRecord(join(dir, entry.name), descriptor.version);
344
+ const global = await readRecord(join(dir, entry.name), versions);
339
345
  if (global !== void 0) state.global = global;
340
346
  return true;
341
347
  }
@@ -343,12 +349,20 @@ async function loadPerRecordState(descriptor, dir) {
343
349
  }))).some(Boolean))) await bootstrapLegacyUnit(descriptor, dir, state);
344
350
  return state;
345
351
  }
352
+ /** The version stamps this unit reads as its own: current plus declared compatible versions. */
353
+ function acceptedStamps(descriptor) {
354
+ return [descriptor.version, ...descriptor.compatibleVersions ?? []];
355
+ }
346
356
  /**
347
357
  * Bootstrap an empty per-record tree from a legacy whole-unit file
348
358
  * (`<root>/<name>.json`, the pre-per-record layout). Every declared-table
349
359
  * record is copied into a current-version document, while the legacy file is
350
360
  * retained unchanged. A missing, foreign (another unit's name), malformed,
351
- * or non-unit legacy file is left alone; other read failures propagate.
361
+ * or non-unit legacy file is left alone, and so is one whose stored unit
362
+ * version is outside the accepted set — migrating records the owner never
363
+ * vouched for would stamp them with the current version and turn a
364
+ * discardable stale cache into schema failures at the domain layer. Other
365
+ * read failures propagate.
352
366
  * @param descriptor - Static identity and shape of the unit.
353
367
  * @param dir - The per-record unit directory (`<root>/<name>`).
354
368
  * @param state - The empty tree state; bootstrapped records are added.
@@ -369,6 +383,8 @@ async function bootstrapLegacyUnit(descriptor, dir, state) {
369
383
  return;
370
384
  }
371
385
  if (document.unit?.name !== descriptor.name) return;
386
+ const stamped = document.unit.version;
387
+ if (typeof stamped !== "number" || !acceptedStamps(descriptor).includes(stamped)) return;
372
388
  const tables = document.tables;
373
389
  if (typeof tables !== "object" || tables === null) return;
374
390
  const recordsByTable = tables;
@@ -391,23 +407,23 @@ async function bootstrapLegacyUnit(descriptor, dir, state) {
391
407
  * @returns whether the directory contains any `.json` document path,
392
408
  * independently of key safety, readability, or stored version.
393
409
  */
394
- async function loadTableRecords(records, version, dir) {
410
+ async function loadTableRecords(records, versions, dir) {
395
411
  const files = await readdir(dir, { withFileTypes: true });
396
412
  const hasDocuments = files.some((file) => file.name.endsWith(".json"));
397
413
  const loaded = await Promise.all(files.map(async (file) => {
398
414
  if (!file.name.endsWith(".json")) return;
399
415
  const key = file.name.slice(0, -5);
400
416
  if (!SAFE_KEY_RE.test(key)) return;
401
- const record = await readRecord(join(dir, file.name), version);
417
+ const record = await readRecord(join(dir, file.name), versions);
402
418
  if (record !== void 0) return [key, record];
403
419
  }));
404
420
  for (const record of loaded) if (record !== void 0) records.set(...record);
405
421
  return hasDocuments;
406
422
  }
407
423
  /** Read one record document; a foreign (unreadable or stale) one reads as absent. */
408
- async function readRecord(path, version) {
424
+ async function readRecord(path, versions) {
409
425
  try {
410
- return parseRecord(await readFile(path, "utf8"), version);
426
+ return parseRecord(await readFile(path, "utf8"), versions);
411
427
  } catch {
412
428
  return;
413
429
  }
@@ -453,6 +469,21 @@ var PerRecordJsonUnit = class {
453
469
  assertSafeKey(this.descriptor.name, key);
454
470
  await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true }));
455
471
  }
472
+ /**
473
+ * Move one record's document aside as `<key>.json.bak.<YYYYMMDDHHmm>`. The
474
+ * moved file no longer ends in `.json`, so every later read ignores it; the
475
+ * bytes stay on disk for inspection. A same-minute backup of the same
476
+ * key overwrites the previous backup (the newer bytes are the ones worth
477
+ * keeping).
478
+ */
479
+ async backupRecord(table, key) {
480
+ this.assertOpen();
481
+ assertSafeKey(this.descriptor.name, key);
482
+ const path = join(this.tableDir(table), `${key}.json`);
483
+ const moved = `${path}.bak.${backupStamp(/* @__PURE__ */ new Date())}`;
484
+ await this.tracked(rename(path, moved));
485
+ return moved;
486
+ }
456
487
  /** Durably replace the global singleton. Only valid when declared. */
457
488
  async setGlobal(value) {
458
489
  this.assertOpen();
@@ -494,6 +525,11 @@ var PerRecordJsonUnit = class {
494
525
  return write;
495
526
  }
496
527
  };
528
+ /** Local-time `YYYYMMDDHHmm` suffix for backed-up documents. */
529
+ function backupStamp(now) {
530
+ const pad = (value) => String(value).padStart(2, "0");
531
+ return `${String(now.getFullYear())}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`;
532
+ }
497
533
  /** Reject a record key that would be unsafe as a path segment. */
498
534
  function assertSafeKey(unit, key) {
499
535
  if (!SAFE_KEY_RE.test(key)) throw new Error(`unit '${unit}': per-record key '${key}' is not path-safe (must match ${SAFE_KEY_RE})`);
@@ -39,14 +39,16 @@ export declare function parse(text: string, descriptor: KvUnitDescriptor): UnitS
39
39
  export declare function serializeRecord(version: number, value: unknown): string;
40
40
  /**
41
41
  * Parse one per-record document, validating its version stamp. A document
42
- * that is malformed or stamped with a different version is FOREIGN and reads
43
- * as absent — the per-record contract: one bad or stale record file must not
44
- * brick the whole unit, and a version bump discards stale records instead of
45
- * migrating them (the whole-unit format rejects instead, because there is
46
- * exactly one document).
42
+ * that is malformed or stamped with an unaccepted version is FOREIGN and
43
+ * reads as absent — the per-record contract: one bad or stale record file
44
+ * must not brick the whole unit, and a version bump discards stale records
45
+ * instead of migrating them (the whole-unit format rejects instead, because
46
+ * there is exactly one document).
47
47
  * @param text - Raw per-record document content.
48
- * @param version - Expected unit version; a mismatch discards the document.
48
+ * @param versions - Accepted unit versions (the current one plus the
49
+ * descriptor's compatibleVersions); any other stamp discards the
50
+ * document.
49
51
  * @returns the record value, or `undefined` for a foreign document.
50
52
  */
51
- export declare function parseRecord(text: string, version: number): unknown;
53
+ export declare function parseRecord(text: string, versions: readonly number[]): unknown;
52
54
  //# sourceMappingURL=format.d.ts.map
@@ -10,15 +10,18 @@
10
10
  * memory unchanged.
11
11
  *
12
12
  * Per-record contract: a record document that is malformed or stamped with a
13
- * different version reads as an absent record one bad or stale file never
14
- * bricks the whole unit, and a version bump discards stale records instead
15
- * of migrating them. Record keys become path segments, so they must be
16
- * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
13
+ * version outside the accepted set (the descriptor's current version plus
14
+ * its `compatibleVersions`) reads as an absent record one bad or stale
15
+ * file never bricks the whole unit, and a version bump discards stale
16
+ * records instead of migrating them. Record keys become path segments, so
17
+ * they must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
17
18
  *
18
19
  * Legacy bootstrap: when the new tree has no document path, a legacy
19
20
  * whole-unit file `<root>/<name>.json` (the pre-per-record layout) seeds
20
- * per-record documents. Any new document path, including one whose contents
21
- * are unreadable or stale, suppresses the bootstrap for the whole unit. The
21
+ * per-record documents, provided its stored unit version is in the accepted
22
+ * set a legacy file stamped with any other version is left alone and reads
23
+ * as the empty unit. Any new document path, including one whose contents are
24
+ * unreadable or stale, suppresses the bootstrap for the whole unit. The
22
25
  * legacy file is never changed or deleted.
23
26
  * @module @deepseek-ai/dsh-storage-json/src/per-record-unit
24
27
  */
@@ -56,6 +59,14 @@ export declare class PerRecordJsonUnit implements KvUnit {
56
59
  putRecord(table: string, key: string, value: unknown): Promise<void>;
57
60
  /** Durably delete one record. Idempotent: a missing key is a no-op. */
58
61
  deleteRecord(table: string, key: string): Promise<void>;
62
+ /**
63
+ * Move one record's document aside as `<key>.json.bak.<YYYYMMDDHHmm>`. The
64
+ * moved file no longer ends in `.json`, so every later read ignores it; the
65
+ * bytes stay on disk for inspection. A same-minute backup of the same
66
+ * key overwrites the previous backup (the newer bytes are the ones worth
67
+ * keeping).
68
+ */
69
+ backupRecord(table: string, key: string): Promise<string>;
59
70
  /** Durably replace the global singleton. Only valid when declared. */
60
71
  setGlobal(value: unknown): Promise<void>;
61
72
  /** Drain in-flight writes and release the unit. Idempotent. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-storage-json",
3
3
  "description": "JSON file KV storage backend for the DeepSeek Harness storage hub",
4
- "version": "0.1.2-alpha.4",
4
+ "version": "0.1.2-alpha.5",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,14 +27,14 @@
27
27
  ],
28
28
  "license": "MIT",
29
29
  "peerDependencies": {
30
- "@deepseek-ai/dsh-storage": "^0.1.2-alpha.4",
30
+ "@deepseek-ai/dsh-storage": "^0.1.2-alpha.5",
31
31
  "@deepseek-ai/cordis": "^4.0.2"
32
32
  },
33
33
  "dependencies": {
34
34
  "@deepseek-ai/schemastery": "^3.18.2"
35
35
  },
36
36
  "devDependencies": {
37
- "@deepseek-ai/dsh-storage": "^0.1.2-alpha.4",
37
+ "@deepseek-ai/dsh-storage": "^0.1.2-alpha.5",
38
38
  "@deepseek-ai/cordis": "^4.0.2"
39
39
  }
40
40
  }