json-backend.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { mkdir, mkdtemp, readFile, 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 InvariantRegistry from '@deepseek-ai/dsh-invariants'
  8. import { runKvBackendContract } from '../../storage/tests/contract.ts'
  9. import { Config, JsonStorageBackend, apply } from '../src/index.ts'
  10. import * as InvariantCompanion from '../src/invariant.ts'
  11. const roots: string[] = []
  12. async function freshRoot(): Promise<string> {
  13. const root = await mkdtemp(join(tmpdir(), 'dsh-storage-json-'))
  14. roots.push(root)
  15. return root
  16. }
  17. afterAll(async () => {
  18. for (const root of roots) await rm(root, { recursive: true, force: true })
  19. })
  20. runKvBackendContract('json', async () => {
  21. const root = await freshRoot()
  22. return {
  23. backend: new JsonStorageBackend(root),
  24. reopen: async () => new JsonStorageBackend(root),
  25. }
  26. })
  27. describe('json backend specifics', () => {
  28. const descriptor = { name: 'shape', version: 1, tables: ['t'], hasGlobal: true }
  29. it('publishes a human-readable pretty-printed file', async () => {
  30. const root = await freshRoot()
  31. const backend = new JsonStorageBackend(root)
  32. const unit = await backend.kv.open(descriptor)
  33. await unit.putRecord('t', 'k', { hello: 'world' })
  34. const text = await readFile(join(root, 'shape.json'), 'utf8')
  35. expect(text).toBe(`${JSON.stringify(
  36. { unit: { name: 'shape', version: 1 }, global: null, tables: { t: { k: { hello: 'world' } } } },
  37. null,
  38. 2,
  39. )}\n`)
  40. await backend.close()
  41. })
  42. it('defers materialization until the first write', async () => {
  43. const root = await freshRoot()
  44. const backend = new JsonStorageBackend(root)
  45. await backend.kv.open(descriptor)
  46. await expect(readFile(join(root, 'shape.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  47. await backend.close()
  48. })
  49. it('rejects a malformed medium', async () => {
  50. const root = await freshRoot()
  51. await writeFile(join(root, 'shape.json'), 'not json at all', 'utf8')
  52. const backend = new JsonStorageBackend(root)
  53. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  54. await backend.close()
  55. })
  56. it('rejects a foreign unit header', async () => {
  57. const root = await freshRoot()
  58. await writeFile(
  59. join(root, 'shape.json'),
  60. JSON.stringify({ unit: { name: 'other', version: 1 }, global: null, tables: {} }),
  61. 'utf8',
  62. )
  63. const backend = new JsonStorageBackend(root)
  64. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  65. await backend.close()
  66. })
  67. it('rejects double-open of one unit as a plain caller error', async () => {
  68. const root = await freshRoot()
  69. const backend = new JsonStorageBackend(root)
  70. await backend.kv.open(descriptor)
  71. await expect(backend.kv.open(descriptor)).rejects.toThrow(/already open/)
  72. await backend.close()
  73. })
  74. it('rolls back memory when a publish fails', async () => {
  75. const root = await freshRoot()
  76. const backend = new JsonStorageBackend(root)
  77. const unit = await backend.kv.open(descriptor)
  78. await unit.putRecord('t', 'k', { v: 'committed' })
  79. await unit.setGlobal({ g: 'committed' })
  80. const path = join(root, 'shape.json')
  81. const backup = join(root, 'shape.committed.json')
  82. // A directory at the publish target rejects atomic replacement on every host.
  83. await rename(path, backup)
  84. await mkdir(path)
  85. await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow()
  86. await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow()
  87. await expect(unit.deleteRecord('t', 'k')).rejects.toThrow()
  88. await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow()
  89. await rm(path, { recursive: true })
  90. await rename(backup, path)
  91. const snapshot = await unit.loadAll()
  92. expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } })
  93. expect(snapshot.global).toEqual({ g: 'committed' })
  94. // The next successful publish must not carry rejected writes to disk.
  95. await unit.putRecord('t', 'k3', { v: 'later' })
  96. const text = await readFile(path, 'utf8')
  97. expect(text).not.toContain('rejected')
  98. await backend.close()
  99. })
  100. it('rejects undeclared table and global access as caller errors', async () => {
  101. const root = await freshRoot()
  102. const backend = new JsonStorageBackend(root)
  103. const unit = await backend.kv.open({ name: 'shape', version: 1, tables: ['t'], hasGlobal: false })
  104. await expect(unit.putRecord('undeclared', 'k', {})).rejects.toThrow(/does not declare table/)
  105. await expect(unit.setGlobal({})).rejects.toThrow(/does not declare a global slot/)
  106. await backend.close()
  107. })
  108. it('rejects invalid unit and table names', async () => {
  109. const root = await freshRoot()
  110. const backend = new JsonStorageBackend(root)
  111. await expect(backend.kv.open({ ...descriptor, name: 'Bad-Name' })).rejects.toMatchObject({
  112. name: 'StorageError',
  113. code: 'malformed-medium',
  114. })
  115. await expect(backend.kv.open({ ...descriptor, tables: ['ok', 'not ok'] })).rejects.toMatchObject({
  116. name: 'StorageError',
  117. code: 'malformed-medium',
  118. })
  119. await backend.close()
  120. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'closed' })
  121. })
  122. it('opens a file missing a declared table as that table empty', async () => {
  123. const root = await freshRoot()
  124. await writeFile(
  125. join(root, 'contract_unit.json'),
  126. JSON.stringify({ unit: { name: 'contract_unit', version: 3 }, global: null, tables: { alpha: { k: 1 } } }),
  127. 'utf8',
  128. )
  129. const backend = new JsonStorageBackend(root)
  130. const unit = await backend.kv.open({ name: 'contract_unit', version: 3, tables: ['alpha', 'beta'], hasGlobal: true })
  131. const snapshot = await unit.loadAll()
  132. expect(snapshot.tables['alpha']).toEqual({ k: 1 })
  133. expect(snapshot.tables['beta']).toEqual({})
  134. await backend.close()
  135. })
  136. it('propagates non-ENOENT read failures', async () => {
  137. const root = await freshRoot()
  138. const { mkdir } = await import('node:fs/promises')
  139. // A directory where the unit file should be: readFile fails with EISDIR.
  140. await mkdir(join(root, 'shape.json'))
  141. const backend = new JsonStorageBackend(root)
  142. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'EISDIR' })
  143. await backend.close()
  144. })
  145. it('rejects malformed table shapes and foreign versions distinctly', async () => {
  146. const root = await freshRoot()
  147. await writeFile(
  148. join(root, 'shape.json'),
  149. JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null, tables: { t: ['not', 'an', 'object'] } }),
  150. 'utf8',
  151. )
  152. const backend = new JsonStorageBackend(root)
  153. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  154. await writeFile(
  155. join(root, 'shape.json'),
  156. JSON.stringify({ unit: { name: 'shape', version: 9 }, global: null, tables: {} }),
  157. 'utf8',
  158. )
  159. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'version-mismatch' })
  160. await writeFile(join(root, 'shape.json'), JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null }), 'utf8')
  161. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  162. await writeFile(join(root, 'shape.json'), JSON.stringify('just a string'), 'utf8')
  163. await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
  164. await backend.close()
  165. })
  166. it('registers on the hub via apply and closes on dispose', async () => {
  167. const root = await freshRoot()
  168. const ctx = new Context()
  169. await ctx.plugin(Storage)
  170. const fiber = await ctx.plugin({ apply, Config, inject: ['storage'] }, { root })
  171. const backend = ctx.storage.backend.get('json')
  172. expect(ctx.get(storageBackendServiceKey('json'))).toBe(backend)
  173. const unit = await backend.kv!.open(descriptor)
  174. await unit.putRecord('t', 'k', { v: 1 })
  175. await fiber.dispose()
  176. expect(() => ctx.storage.backend.get('json')).toThrow()
  177. expect(ctx.get(storageBackendServiceKey('json'))).toBeUndefined()
  178. await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' })
  179. })
  180. it('registers the invariant companion and disposes cleanly', async () => {
  181. const ctx = new Context()
  182. await ctx.plugin(InvariantRegistry)
  183. const fiber = await ctx.plugin(InvariantCompanion)
  184. // Disposal releases the reservation: a fresh mount succeeds.
  185. await fiber.dispose()
  186. await ctx.plugin(InvariantCompanion)
  187. })
  188. it('close drains in-flight writes and blocks in-flight opens', async () => {
  189. const root = await freshRoot()
  190. const backend = new JsonStorageBackend(root)
  191. const unit = await backend.kv.open(descriptor)
  192. const bigWrite = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
  193. await unit.close()
  194. await expect(bigWrite).resolves.toBeUndefined()
  195. const onDisk = JSON.parse(await readFile(join(root, 'shape.json'), 'utf8')) as {
  196. tables: Record<string, Record<string, unknown>>
  197. }
  198. expect(onDisk.tables['t']?.['big']).toBeDefined()
  199. const backend2 = new JsonStorageBackend(root)
  200. const opening = backend2.kv.open(descriptor)
  201. const closing = backend2.close()
  202. await expect(opening.then(u => u.putRecord('t', 'x', {}))).rejects.toMatchObject({ code: 'closed' })
  203. await closing
  204. })
  205. })