|
|
@@ -10,20 +10,23 @@
|
|
|
* memory unchanged.
|
|
|
*
|
|
|
* Per-record contract: a record document that is malformed or stamped with a
|
|
|
- * different version reads as an absent record — one bad or stale file never
|
|
|
- * bricks the whole unit, and a version bump discards stale records instead
|
|
|
- * of migrating them. Record keys become path segments, so they must be
|
|
|
- * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
|
|
|
+ * version outside the accepted set (the descriptor's current version plus
|
|
|
+ * its `compatibleVersions`) reads as an absent record — one bad or stale
|
|
|
+ * file never bricks the whole unit, and a version bump discards stale
|
|
|
+ * records instead of migrating them. Record keys become path segments, so
|
|
|
+ * they must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
|
|
|
*
|
|
|
* Legacy bootstrap: when the new tree has no document path, a legacy
|
|
|
* whole-unit file `<root>/<name>.json` (the pre-per-record layout) seeds
|
|
|
- * per-record documents. Any new document path, including one whose contents
|
|
|
- * are unreadable or stale, suppresses the bootstrap for the whole unit. The
|
|
|
+ * per-record documents, provided its stored unit version is in the accepted
|
|
|
+ * set — a legacy file stamped with any other version is left alone and reads
|
|
|
+ * as the empty unit. Any new document path, including one whose contents are
|
|
|
+ * unreadable or stale, suppresses the bootstrap for the whole unit. The
|
|
|
* legacy file is never changed or deleted.
|
|
|
* @module @deepseek-ai/dsh-storage-json/src/per-record-unit
|
|
|
*/
|
|
|
|
|
|
-import { mkdir, readFile, readdir, rm } from 'node:fs/promises'
|
|
|
+import { mkdir, readFile, readdir, rename, rm } from 'node:fs/promises'
|
|
|
import { dirname, join } from 'node:path'
|
|
|
import type { Dirent } from 'node:fs'
|
|
|
import { StorageError } from '@deepseek-ai/dsh-storage'
|
|
|
@@ -64,6 +67,7 @@ export async function openPerRecordUnit(
|
|
|
* @returns the authoritative state reconstructed from the tree.
|
|
|
*/
|
|
|
async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Promise<UnitState> {
|
|
|
+ const versions = acceptedStamps(descriptor)
|
|
|
const state: UnitState = {
|
|
|
version: descriptor.version,
|
|
|
global: null,
|
|
|
@@ -83,11 +87,11 @@ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Pr
|
|
|
if (entry.isDirectory()) {
|
|
|
const records = state.tables.get(entry.name)
|
|
|
if (records !== undefined) {
|
|
|
- return loadTableRecords(records, descriptor.version, join(dir, entry.name))
|
|
|
+ return loadTableRecords(records, versions, join(dir, entry.name))
|
|
|
}
|
|
|
}
|
|
|
if (entry.name === 'global.json' && descriptor.hasGlobal) {
|
|
|
- const global = await readRecord(join(dir, entry.name), descriptor.version)
|
|
|
+ const global = await readRecord(join(dir, entry.name), versions)
|
|
|
if (global !== undefined) state.global = global
|
|
|
return true
|
|
|
}
|
|
|
@@ -97,12 +101,21 @@ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Pr
|
|
|
return state
|
|
|
}
|
|
|
|
|
|
+/** The version stamps this unit reads as its own: current plus declared compatible versions. */
|
|
|
+function acceptedStamps(descriptor: KvUnitDescriptor): readonly number[] {
|
|
|
+ return [descriptor.version, ...descriptor.compatibleVersions ?? []]
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Bootstrap an empty per-record tree from a legacy whole-unit file
|
|
|
* (`<root>/<name>.json`, the pre-per-record layout). Every declared-table
|
|
|
* record is copied into a current-version document, while the legacy file is
|
|
|
* retained unchanged. A missing, foreign (another unit's name), malformed,
|
|
|
- * or non-unit legacy file is left alone; other read failures propagate.
|
|
|
+ * or non-unit legacy file is left alone, and so is one whose stored unit
|
|
|
+ * version is outside the accepted set — migrating records the owner never
|
|
|
+ * vouched for would stamp them with the current version and turn a
|
|
|
+ * discardable stale cache into schema failures at the domain layer. Other
|
|
|
+ * read failures propagate.
|
|
|
* @param descriptor - Static identity and shape of the unit.
|
|
|
* @param dir - The per-record unit directory (`<root>/<name>`).
|
|
|
* @param state - The empty tree state; bootstrapped records are added.
|
|
|
@@ -116,16 +129,18 @@ async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, st
|
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
|
return
|
|
|
}
|
|
|
- // The legacy document is runtime data: only `unit.name` and the tables map
|
|
|
- // shape are checked here — the record values are migrated as-is and the
|
|
|
- // domain layer's schemas judge them.
|
|
|
- let document: { unit?: { name?: unknown }; tables?: unknown }
|
|
|
+ // The legacy document is runtime data: only `unit.name`, `unit.version`,
|
|
|
+ // and the tables map shape are checked here — the record values are
|
|
|
+ // migrated as-is and the domain layer's schemas judge them.
|
|
|
+ let document: { unit?: { name?: unknown; version?: unknown }; tables?: unknown }
|
|
|
try {
|
|
|
- document = JSON.parse(text) as { unit?: { name?: unknown }; tables?: unknown }
|
|
|
+ document = JSON.parse(text) as { unit?: { name?: unknown; version?: unknown }; tables?: unknown }
|
|
|
} catch {
|
|
|
return // Malformed legacy file: not ours to interpret or delete.
|
|
|
}
|
|
|
if (document.unit?.name !== descriptor.name) return
|
|
|
+ const stamped = document.unit.version
|
|
|
+ if (typeof stamped !== 'number' || !acceptedStamps(descriptor).includes(stamped)) return
|
|
|
const tables = document.tables
|
|
|
if (typeof tables !== 'object' || tables === null) return
|
|
|
const recordsByTable = tables as Record<string, Record<string, unknown>>
|
|
|
@@ -146,14 +161,14 @@ async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, st
|
|
|
* @returns whether the directory contains any `.json` document path,
|
|
|
* independently of key safety, readability, or stored version.
|
|
|
*/
|
|
|
-async function loadTableRecords(records: Map<string, unknown>, version: number, dir: string): Promise<boolean> {
|
|
|
+async function loadTableRecords(records: Map<string, unknown>, versions: readonly number[], dir: string): Promise<boolean> {
|
|
|
const files = await readdir(dir, { withFileTypes: true })
|
|
|
const hasDocuments = files.some(file => file.name.endsWith('.json'))
|
|
|
const loaded = await Promise.all(files.map(async (file) => {
|
|
|
if (!file.name.endsWith('.json')) return
|
|
|
const key = file.name.slice(0, -'.json'.length)
|
|
|
if (!SAFE_KEY_RE.test(key)) return
|
|
|
- const record = await readRecord(join(dir, file.name), version)
|
|
|
+ const record = await readRecord(join(dir, file.name), versions)
|
|
|
if (record !== undefined) return [key, record] as const
|
|
|
}))
|
|
|
for (const record of loaded) {
|
|
|
@@ -163,9 +178,9 @@ async function loadTableRecords(records: Map<string, unknown>, version: number,
|
|
|
}
|
|
|
|
|
|
/** Read one record document; a foreign (unreadable or stale) one reads as absent. */
|
|
|
-async function readRecord(path: string, version: number): Promise<unknown> {
|
|
|
+async function readRecord(path: string, versions: readonly number[]): Promise<unknown> {
|
|
|
try {
|
|
|
- return parseRecord(await readFile(path, 'utf8'), version)
|
|
|
+ return parseRecord(await readFile(path, 'utf8'), versions)
|
|
|
} catch {
|
|
|
return undefined
|
|
|
}
|
|
|
@@ -213,6 +228,22 @@ export class PerRecordJsonUnit implements KvUnit {
|
|
|
await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true }))
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Move one record's document aside as `<key>.json.bak.<YYYYMMDDHHmm>`. The
|
|
|
+ * moved file no longer ends in `.json`, so every later read ignores it; the
|
|
|
+ * bytes stay on disk for inspection. A same-minute backup of the same
|
|
|
+ * key overwrites the previous backup (the newer bytes are the ones worth
|
|
|
+ * keeping).
|
|
|
+ */
|
|
|
+ async backupRecord(table: string, key: string): Promise<string> {
|
|
|
+ this.assertOpen()
|
|
|
+ assertSafeKey(this.descriptor.name, key)
|
|
|
+ const path = join(this.tableDir(table), `${key}.json`)
|
|
|
+ const moved = `${path}.bak.${backupStamp(new Date())}`
|
|
|
+ await this.tracked(rename(path, moved))
|
|
|
+ return moved
|
|
|
+ }
|
|
|
+
|
|
|
/** Durably replace the global singleton. Only valid when declared. */
|
|
|
async setGlobal(value: unknown): Promise<void> {
|
|
|
this.assertOpen()
|
|
|
@@ -267,6 +298,12 @@ export class PerRecordJsonUnit implements KvUnit {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/** Local-time `YYYYMMDDHHmm` suffix for backed-up documents. */
|
|
|
+function backupStamp(now: Date): string {
|
|
|
+ const pad = (value: number): string => String(value).padStart(2, '0')
|
|
|
+ return `${String(now.getFullYear())}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`
|
|
|
+}
|
|
|
+
|
|
|
/** Reject a record key that would be unsafe as a path segment. */
|
|
|
function assertSafeKey(unit: string, key: string): void {
|
|
|
if (!SAFE_KEY_RE.test(key)) {
|