json-backend.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterAll, describe, expect, it } from 'vitest'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
  7. import { runKvBackendContract } from '../../storage/tests/contract.ts'
  8. import { Config, JsonStorageBackend, apply } from '../src/index.ts'
  9. const roots: string[] = []
  10. async function freshRoot(): Promise<string> {
  11. const root = await mkdtemp(join(tmpdir(), 'dsh-storage-json-'))
  12. roots.push(root)
  13. return root
  14. }
  15. afterAll(async () => {
  16. for (const root of roots) await rm(root, { recursive: true, force: true })
  17. })
  18. runKvBackendContract('json', async () => {
  19. const root = await freshRoot()
  20. return {
  21. backend: new JsonStorageBackend(root),
  22. reopen: async () => new JsonStorageBackend(root),
  23. }
  24. })
  25. describe('json backend specifics', () => {
  26. const descriptor = { name: 'shape', version: 1, tables: ['t'], hasGlobal: true }
  27. it('publishes a human-readable pretty-printed file', async () => {
  28. const root = await freshRoot()
  29. const backend = new JsonStorageBackend(root)
  30. const unit = await backend.kv.open(descriptor)
  31. await unit.putRecord('t', 'k', { hello: 'world' })
  32. const text = await readFile(join(root, 'shape.json'), 'utf8')
  33. expect(text).toBe(`${JSON.stringify(
  34. { unit: { name: 'shape', version: 1 }, global: null, tables: { t: { k: { hello: 'world' } } } },
  35. null,
  36. 2,
  37. )}\n`)
  38. await backend.close()
  39. })
  40. it('defers materialization until the first write', async () => {
  41. const root = await freshRoot()
  42. const backend = new JsonStorageBackend(root)
  43. await backend.kv.open(descriptor)
  44. await expect(readFile(join(root, 'shape.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  45. await backend.close()
  46. })
  47. it('rejects a malformed medium', async () => {
  48. const root = await freshRoot()
  49. await writeFile(join(root, 'shape.json'), 'not json at all', 'utf8')
  50. const backend = new JsonStorageBackend(root)
  51. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  52. await backend.close()
  53. })
  54. it('rejects a foreign unit header', async () => {
  55. const root = await freshRoot()
  56. await writeFile(
  57. join(root, 'shape.json'),
  58. JSON.stringify({ unit: { name: 'other', version: 1 }, global: null, tables: {} }),
  59. 'utf8',
  60. )
  61. const backend = new JsonStorageBackend(root)
  62. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  63. await backend.close()
  64. })
  65. it('rejects double-open of one unit as a plain caller error', async () => {
  66. const root = await freshRoot()
  67. const backend = new JsonStorageBackend(root)
  68. await backend.kv.open(descriptor)
  69. await expect(backend.kv.open(descriptor)).rejects.toThrow(/already open/)
  70. await backend.close()
  71. })
  72. it('rolls back memory when a publish fails', async () => {
  73. const root = await freshRoot()
  74. const backend = new JsonStorageBackend(root)
  75. const unit = await backend.kv.open(descriptor)
  76. await unit.putRecord('t', 'k', { v: 'committed' })
  77. await unit.setGlobal({ g: 'committed' })
  78. const path = join(root, 'shape.json')
  79. const backup = join(root, 'shape.committed.json')
  80. // A directory at the publish target rejects atomic replacement on every host.
  81. await rename(path, backup)
  82. await mkdir(path)
  83. await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow()
  84. await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow()
  85. await expect(unit.deleteRecord('t', 'k')).rejects.toThrow()
  86. await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow()
  87. await rm(path, { recursive: true })
  88. await rename(backup, path)
  89. const snapshot = await unit.loadAll()
  90. expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } })
  91. expect(snapshot.global).toEqual({ g: 'committed' })
  92. // The next successful publish must not carry rejected writes to disk.
  93. await unit.putRecord('t', 'k3', { v: 'later' })
  94. const text = await readFile(path, 'utf8')
  95. expect(text).not.toContain('rejected')
  96. await backend.close()
  97. })
  98. it('rejects undeclared table and global access as caller errors', async () => {
  99. const root = await freshRoot()
  100. const backend = new JsonStorageBackend(root)
  101. const unit = await backend.kv.open({ name: 'shape', version: 1, tables: ['t'], hasGlobal: false })
  102. await expect(unit.putRecord('undeclared', 'k', {})).rejects.toThrow(/does not declare table/)
  103. await expect(unit.setGlobal({})).rejects.toThrow(/does not declare a global slot/)
  104. await backend.close()
  105. })
  106. it('rejects invalid unit and table names', async () => {
  107. const root = await freshRoot()
  108. const backend = new JsonStorageBackend(root)
  109. await expect(backend.kv.open({ ...descriptor, name: 'Bad-Name' })).rejects.toMatchObject({
  110. name: 'StorageError',
  111. code: 'malformed-medium',
  112. })
  113. await expect(backend.kv.open({ ...descriptor, tables: ['ok', 'not ok'] })).rejects.toMatchObject({
  114. name: 'StorageError',
  115. code: 'malformed-medium',
  116. })
  117. await backend.close()
  118. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'closed' })
  119. })
  120. it('opens a file missing a declared table as that table empty', async () => {
  121. const root = await freshRoot()
  122. await writeFile(
  123. join(root, 'contract_unit.json'),
  124. JSON.stringify({ unit: { name: 'contract_unit', version: 3 }, global: null, tables: { alpha: { k: 1 } } }),
  125. 'utf8',
  126. )
  127. const backend = new JsonStorageBackend(root)
  128. const unit = await backend.kv.open({ name: 'contract_unit', version: 3, tables: ['alpha', 'beta'], hasGlobal: true })
  129. const snapshot = await unit.loadAll()
  130. expect(snapshot.tables['alpha']).toEqual({ k: 1 })
  131. expect(snapshot.tables['beta']).toEqual({})
  132. await backend.close()
  133. })
  134. it('propagates non-ENOENT read failures', async () => {
  135. const root = await freshRoot()
  136. const { mkdir } = await import('node:fs/promises')
  137. // A directory where the unit file should be: readFile fails with EISDIR.
  138. await mkdir(join(root, 'shape.json'))
  139. const backend = new JsonStorageBackend(root)
  140. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'EISDIR' })
  141. await backend.close()
  142. })
  143. it('rejects malformed table shapes and foreign versions distinctly', async () => {
  144. const root = await freshRoot()
  145. await writeFile(
  146. join(root, 'shape.json'),
  147. JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null, tables: { t: ['not', 'an', 'object'] } }),
  148. 'utf8',
  149. )
  150. const backend = new JsonStorageBackend(root)
  151. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  152. await writeFile(
  153. join(root, 'shape.json'),
  154. JSON.stringify({ unit: { name: 'shape', version: 9 }, global: null, tables: {} }),
  155. 'utf8',
  156. )
  157. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'version-mismatch' })
  158. await writeFile(join(root, 'shape.json'), JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null }), 'utf8')
  159. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  160. await writeFile(join(root, 'shape.json'), JSON.stringify('just a string'), 'utf8')
  161. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  162. await backend.close()
  163. })
  164. it('registers on the hub via apply and closes on dispose', async () => {
  165. const root = await freshRoot()
  166. const ctx = new Context()
  167. await ctx.plugin(Storage)
  168. const fiber = await ctx.plugin({ apply, Config, inject: ['storage'] }, { root })
  169. const backend = ctx.storage.backend.get('json')
  170. expect(ctx.get(storageBackendServiceKey('json'))).toBe(backend)
  171. const unit = await backend.kv!.open(descriptor)
  172. await unit.putRecord('t', 'k', { v: 1 })
  173. await fiber.dispose()
  174. expect(() => ctx.storage.backend.get('json')).toThrow()
  175. expect(ctx.get(storageBackendServiceKey('json'))).toBeUndefined()
  176. await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' })
  177. })
  178. it('close drains in-flight writes and blocks in-flight opens', async () => {
  179. const root = await freshRoot()
  180. const backend = new JsonStorageBackend(root)
  181. const unit = await backend.kv.open(descriptor)
  182. const bigWrite = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
  183. await unit.close()
  184. await expect(bigWrite).resolves.toBeUndefined()
  185. const onDisk = JSON.parse(await readFile(join(root, 'shape.json'), 'utf8')) as {
  186. tables: Record<string, Record<string, unknown>>
  187. }
  188. expect(onDisk.tables['t']?.['big']).toBeDefined()
  189. const backend2 = new JsonStorageBackend(root)
  190. const opening = backend2.kv.open(descriptor)
  191. const closing = backend2.close()
  192. await expect(opening.then(u => u.putRecord('t', 'x', {}))).rejects.toMatchObject({ code: 'closed' })
  193. await closing
  194. })
  195. })
  196. describe('per-record layout', () => {
  197. const descriptor = { name: 'recs', version: 2, layout: 'per-record' as const, tables: ['t'], hasGlobal: true }
  198. const recordPath = (root: string, key: string): string => join(root, 'recs', 't', `${key}.json`)
  199. it('stores one version-stamped document per record and defers materialization', async () => {
  200. const root = await freshRoot()
  201. const backend = new JsonStorageBackend(root)
  202. const unit = await backend.kv.open(descriptor)
  203. // Missing directory = empty unit; nothing materialized on the medium yet.
  204. expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
  205. await unit.putRecord('t', 'k1', { v: 1 })
  206. await unit.putRecord('t', 'k2', { v: 2 })
  207. await unit.setGlobal('G')
  208. expect(await readFile(recordPath(root, 'k1'), 'utf8'))
  209. .toBe(`${JSON.stringify({ version: 2, record: { v: 1 } }, null, 2)}\n`)
  210. expect((await readdir(join(root, 'recs', 't'))).sort()).toEqual(['k1.json', 'k2.json'])
  211. expect(JSON.parse(await readFile(join(root, 'recs', 'global.json'), 'utf8')))
  212. .toEqual({ version: 2, record: 'G' })
  213. expect(await unit.loadAll()).toEqual({ tables: { t: { k1: { v: 1 }, k2: { v: 2 } } }, global: 'G' })
  214. await backend.close()
  215. })
  216. it('overwrites and deletes one document at a time and persists across reopen', async () => {
  217. const root = await freshRoot()
  218. const backend = new JsonStorageBackend(root)
  219. const unit = await backend.kv.open(descriptor)
  220. await unit.putRecord('t', 'k', { v: 1 })
  221. await unit.putRecord('t', 'k', { v: 2 }) // overwrite the same document
  222. await unit.deleteRecord('t', 'missing') // idempotent no-op
  223. await unit.close()
  224. const unit2 = await backend.kv.open(descriptor)
  225. expect(await unit2.loadAll()).toEqual({ tables: { t: { k: { v: 2 } } }, global: null })
  226. await unit2.deleteRecord('t', 'k')
  227. expect(await unit2.loadAll()).toEqual({ tables: { t: {} }, global: null })
  228. await backend.close()
  229. })
  230. it('rejects unsafe keys and undeclared tables, and enforces the closed guard', async () => {
  231. const root = await freshRoot()
  232. const backend = new JsonStorageBackend(root)
  233. const unit = await backend.kv.open(descriptor)
  234. await expect(unit.putRecord('t', 'a/b', {})).rejects.toThrow(/not path-safe/)
  235. await expect(unit.deleteRecord('t', '..')).rejects.toThrow(/not path-safe/)
  236. await expect(unit.putRecord('bogus', 'k', {})).rejects.toThrow(/does not declare table/)
  237. await unit.close()
  238. await expect(unit.putRecord('t', 'k', {})).rejects.toMatchObject({ code: 'closed' })
  239. await expect(unit.deleteRecord('t', 'k')).rejects.toMatchObject({ code: 'closed' })
  240. await expect(unit.setGlobal('x')).rejects.toMatchObject({ code: 'closed' })
  241. await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' })
  242. await backend.close()
  243. })
  244. it('discards foreign documents (stale version, malformed, non-object, unsafe key) on open', async () => {
  245. const root = await freshRoot()
  246. const backend = new JsonStorageBackend(root)
  247. const unit = await backend.kv.open(descriptor)
  248. await unit.putRecord('t', 'good', { v: 1 })
  249. await unit.close()
  250. await writeFile(recordPath(root, 'stale'), JSON.stringify({ version: 1, record: { v: 0 } }), 'utf8')
  251. await writeFile(recordPath(root, 'broken'), '{oops', 'utf8')
  252. await writeFile(recordPath(root, 'scalar'), JSON.stringify(5), 'utf8')
  253. await writeFile(recordPath(root, 'unsafe%2Fkey'), JSON.stringify({ version: 2, record: { v: 0 } }), 'utf8')
  254. await writeFile(join(root, 'recs', 't', 'not-json.txt'), 'ignored', 'utf8')
  255. await writeFile(join(root, 'recs', 'global.json'), JSON.stringify({ version: 1, record: 'old' }), 'utf8')
  256. // Stray unit-root entries: an undeclared directory and a non-document file.
  257. await mkdir(join(root, 'recs', 'stray-dir'), { recursive: true })
  258. await writeFile(join(root, 'recs', 'stray.txt'), 'ignored', 'utf8')
  259. const unit2 = await backend.kv.open(descriptor)
  260. expect(await unit2.loadAll()).toEqual({ tables: { t: { good: { v: 1 } } }, global: null })
  261. await backend.close()
  262. })
  263. it('propagates non-ENOENT read failures and refuses a global slot that is not declared', async () => {
  264. const root = await freshRoot()
  265. const backend = new JsonStorageBackend(root)
  266. // A file where the unit directory should be: the lazy loadAll readdir
  267. // fails with ENOTDIR (opening itself touches nothing on the medium).
  268. await writeFile(join(root, 'recs'), 'not a directory', 'utf8')
  269. const unit = await backend.kv.open(descriptor)
  270. await expect(unit.loadAll()).rejects.toMatchObject({ code: 'ENOTDIR' })
  271. await unit.close()
  272. const noGlobal = { name: 'plain', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false }
  273. const unit2 = await backend.kv.open(noGlobal)
  274. await expect(unit2.setGlobal('x')).rejects.toThrow(/does not declare a global slot/)
  275. await backend.close()
  276. })
  277. it('close drains in-flight writes and an unreadable record document reads as absent', async () => {
  278. const root = await freshRoot()
  279. const backend = new JsonStorageBackend(root)
  280. const unit = await backend.kv.open(descriptor)
  281. const big = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
  282. await unit.close()
  283. await unit.close() // idempotent
  284. await expect(big).resolves.toBeUndefined()
  285. const onDisk = JSON.parse(await readFile(recordPath(root, 'big'), 'utf8')) as { record: { blob: string } }
  286. expect(onDisk.record).toEqual({ blob: 'x'.repeat(4 * 1024 * 1024) })
  287. await backend.close()
  288. })
  289. it('reads an unreadable record document as absent (per-record contract)', async () => {
  290. const root = await freshRoot()
  291. // A directory where the record document should be: readFile fails with
  292. // EISDIR on every platform (permission bits are unenforceable on win32).
  293. await mkdir(join(root, 'recs', 't', 'locked.json'), { recursive: true })
  294. const backend = new JsonStorageBackend(root)
  295. const unit = await backend.kv.open(descriptor)
  296. expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
  297. await backend.close()
  298. })
  299. it('bootstraps an empty per-record tree from a legacy whole-unit file and preserves it', async () => {
  300. const root = await freshRoot()
  301. // A legacy single-layout file for the same unit and version; the extra
  302. // table is not declared and must be skipped.
  303. const legacy = JSON.stringify({
  304. unit: { name: 'recs', version: descriptor.version },
  305. global: null,
  306. tables: { t: { old1: { v: 1 }, old2: { v: 2 } }, undeclared: { k: { v: 0 } } },
  307. })
  308. await writeFile(join(root, 'recs.json'), legacy, 'utf8')
  309. const backend = new JsonStorageBackend(root)
  310. const unit = await backend.kv.open(descriptor)
  311. expect(await unit.loadAll()).toEqual({ tables: { t: { old1: { v: 1 }, old2: { v: 2 } } }, global: null })
  312. await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
  313. await unit.close()
  314. const unit2 = await backend.kv.open(descriptor)
  315. expect(await unit2.loadAll()).toEqual({ tables: { t: { old1: { v: 1 }, old2: { v: 2 } } }, global: null })
  316. await backend.close()
  317. })
  318. it('bootstraps from a legacy file only when its stored version is accepted', async () => {
  319. // Version 3 is neither current (2) nor declared compat: the legacy file
  320. // is left alone and the unit reads empty — migrating unvouched records
  321. // would stamp them current and surface as schema failures at the domain
  322. // layer instead of a discardable stale cache.
  323. const root = await freshRoot()
  324. const legacy = JSON.stringify({
  325. unit: { name: 'recs', version: 3 },
  326. global: null,
  327. tables: { t: { old: { v: 1 } } },
  328. })
  329. await writeFile(join(root, 'recs.json'), legacy, 'utf8')
  330. const backend = new JsonStorageBackend(root)
  331. const unit = await backend.kv.open(descriptor)
  332. expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
  333. await expect(readFile(recordPath(root, 'old'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  334. await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
  335. await unit.close()
  336. await backend.close()
  337. // The same file bootstraps once version 3 is declared read-compatible…
  338. const root2 = await freshRoot()
  339. await writeFile(join(root2, 'recs.json'), legacy, 'utf8')
  340. const backend2 = new JsonStorageBackend(root2)
  341. const compat = { ...descriptor, version: 4, compatibleVersions: [3] }
  342. const unit2 = await backend2.kv.open(compat)
  343. expect(await unit2.loadAll()).toEqual({ tables: { t: { old: { v: 1 } } }, global: null })
  344. // …and the migrated documents are stamped with the CURRENT version.
  345. expect(JSON.parse(await readFile(join(root2, 'recs', 't', 'old.json'), 'utf8')))
  346. .toEqual({ version: 4, record: { v: 1 } })
  347. await unit2.close()
  348. await backend2.close()
  349. })
  350. it('backupRecord moves the document aside; reads see it absent and a write recreates it', async () => {
  351. const root = await freshRoot()
  352. const backend = new JsonStorageBackend(root)
  353. const unit = await backend.kv.open(descriptor)
  354. await unit.putRecord('t', 'k', { v: 1 })
  355. const moved = await unit.backupRecord!('t', 'k')
  356. expect(moved).toMatch(/k\.json\.bak\.\d{12}$/)
  357. expect(JSON.parse(await readFile(moved, 'utf8'))).toEqual({ version: 2, record: { v: 1 } })
  358. await expect(readFile(recordPath(root, 'k'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  359. // The moved file no longer ends in .json, so it reads as absent…
  360. expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
  361. // …and the key is free for a fresh write.
  362. await unit.putRecord('t', 'k', { v: 2 })
  363. expect(await unit.loadAll()).toEqual({ tables: { t: { k: { v: 2 } } }, global: null })
  364. await expect(unit.backupRecord!('t', 'a/b')).rejects.toThrow(/not path-safe/)
  365. await unit.close()
  366. await expect(unit.backupRecord!('t', 'k')).rejects.toMatchObject({ code: 'closed' })
  367. await backend.close()
  368. })
  369. it('reads per-record documents stamped with a declared compat version and stamps writes current', async () => {
  370. const root = await freshRoot()
  371. const backend = new JsonStorageBackend(root)
  372. const compat = { ...descriptor, compatibleVersions: [1] }
  373. await mkdir(join(root, 'recs', 't'), { recursive: true })
  374. await writeFile(recordPath(root, 'oldrec'), JSON.stringify({ version: 1, record: { v: 'old' } }), 'utf8')
  375. await writeFile(recordPath(root, 'ancient'), JSON.stringify({ version: 0, record: { v: 'no' } }), 'utf8')
  376. const unit = await backend.kv.open(compat)
  377. // Version 1 is declared compat and served; version 0 is not and discards.
  378. expect(await unit.loadAll()).toEqual({ tables: { t: { oldrec: { v: 'old' } } }, global: null })
  379. await unit.putRecord('t', 'oldrec', { v: 'new' })
  380. expect(JSON.parse(await readFile(recordPath(root, 'oldrec'), 'utf8')))
  381. .toEqual({ version: 2, record: { v: 'new' } })
  382. await backend.close()
  383. })
  384. it('ignores the legacy whole-unit file when any new document path exists', async () => {
  385. const root = await freshRoot()
  386. const legacy = JSON.stringify({
  387. unit: { name: 'recs', version: descriptor.version },
  388. global: null,
  389. tables: { t: { old: { v: 1 } } },
  390. })
  391. await writeFile(join(root, 'recs.json'), legacy, 'utf8')
  392. await mkdir(join(root, 'recs', 't'), { recursive: true })
  393. await writeFile(recordPath(root, 'broken'), '{oops', 'utf8')
  394. const backend = new JsonStorageBackend(root)
  395. const unit = await backend.kv.open(descriptor)
  396. expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
  397. await expect(readFile(recordPath(root, 'old'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  398. await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
  399. await backend.close()
  400. })
  401. it('leaves a foreign, shapeless, or malformed legacy file alone', async () => {
  402. const root = await freshRoot()
  403. await writeFile(join(root, 'recs.json'), JSON.stringify({ unit: { name: 'other', version: 3 }, tables: {} }), 'utf8')
  404. const backend = new JsonStorageBackend(root)
  405. const unit = await backend.kv.open(descriptor)
  406. expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
  407. await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toContain('other')
  408. await unit.close()
  409. await backend.close()
  410. const root2 = await freshRoot()
  411. await writeFile(join(root2, 'recs.json'), JSON.stringify({ tables: { t: { k: { v: 1 } } } }), 'utf8')
  412. const backend2 = new JsonStorageBackend(root2)
  413. const unit2 = await backend2.kv.open(descriptor)
  414. expect(await unit2.loadAll()).toEqual({ tables: { t: {} }, global: null })
  415. await expect(readFile(join(root2, 'recs.json'), 'utf8')).resolves.toContain('tables')
  416. await backend2.close()
  417. const root3 = await freshRoot()
  418. // A directory where the legacy file should be: the migration read fails loudly.
  419. await mkdir(join(root3, 'recs.json'))
  420. const backend3 = new JsonStorageBackend(root3)
  421. const unit3 = await backend3.kv.open(descriptor)
  422. await expect(unit3.loadAll()).rejects.toMatchObject({ code: 'EISDIR' })
  423. await backend3.close()
  424. const root4 = await freshRoot()
  425. await writeFile(join(root4, 'recs.json'), 'not json at all', 'utf8')
  426. const backend4 = new JsonStorageBackend(root4)
  427. const unit4 = await backend4.kv.open(descriptor)
  428. expect(await unit4.loadAll()).toEqual({ tables: { t: {} }, global: null })
  429. await expect(readFile(join(root4, 'recs.json'), 'utf8')).resolves.toBe('not json at all')
  430. await backend4.close()
  431. const root5 = await freshRoot()
  432. // A current-version stamp so the shapeless `tables` is what stops the bootstrap.
  433. await writeFile(
  434. join(root5, 'recs.json'),
  435. JSON.stringify({ unit: { name: 'recs', version: descriptor.version }, tables: 'not an object' }),
  436. 'utf8',
  437. )
  438. const backend5 = new JsonStorageBackend(root5)
  439. const unit5 = await backend5.kv.open(descriptor)
  440. expect(await unit5.loadAll()).toEqual({ tables: { t: {} }, global: null })
  441. await expect(readFile(join(root5, 'recs.json'), 'utf8')).resolves.toContain('not an object')
  442. await backend5.close()
  443. await writeFile(
  444. join(root5, 'recs.json'),
  445. JSON.stringify({ unit: { name: 'recs', version: descriptor.version }, tables: null }),
  446. 'utf8',
  447. )
  448. const backend6 = new JsonStorageBackend(root5)
  449. const unit6 = await backend6.kv.open(descriptor)
  450. expect(await unit6.loadAll()).toEqual({ tables: { t: {} }, global: null })
  451. await backend6.close()
  452. })
  453. })