memory-backend.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /**
  2. * In-memory {@link StorageBackend} test double implementing the full KvUnit
  3. * primitive set. Shared test infrastructure: the domain suite uses it to
  4. * exercise open/route/write semantics without touching disk, and the
  5. * workspace package's tests import it by relative path (it lives under
  6. * `tests/`, never `src/`, so it stays out of the published surface).
  7. *
  8. * Fidelity to the backend contract (`dsh-storage` `src/backend.ts`): version
  9. * stamping and `version-mismatch` on reopen, `malformed` never (memory cannot
  10. * corrupt), per-call atomicity trivially, `closed` after close, delete
  11. * idempotence. Media survive across backends through the shared `media` map
  12. * passed into the constructor, which simulates process restarts; stamp
  13. * `versions` directly to fabricate an on-medium version and force a
  14. * `version-mismatch` without a prior open.
  15. * @module
  16. */
  17. import { StorageError } from '@deepseek-ai/dsh-storage'
  18. import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
  19. /** One unit's medium: tables of records plus the global slot (`null` = never written). */
  20. export interface MemoryMedium {
  21. tables: Map<string, Map<string, unknown>>
  22. global: unknown
  23. }
  24. /**
  25. * Shared media pool. Construct one and hand it to several
  26. * {@link MemoryStorageBackend} instances to simulate reopening the same
  27. * medium after a restart; `versions` holds the stamped unit versions and is
  28. * writable by tests to inject a mismatching on-medium version, and
  29. * `failNextWrites` injects write-primitive failures.
  30. */
  31. export class MemoryMediaPool {
  32. /** Unit name → its records; a missing entry is a never-materialized unit. */
  33. readonly media = new Map<string, MemoryMedium>()
  34. /** Unit name → stamped version; tests may pre-stamp to force `version-mismatch`. */
  35. readonly versions = new Map<string, number>()
  36. /**
  37. * When positive, that many subsequent write primitives (putRecord /
  38. * deleteRecord / setGlobal) reject without touching the medium, decrementing
  39. * per rejection. Negative-path seam: callers assert their state is
  40. * untouched after a durability failure.
  41. */
  42. failNextWrites = 0
  43. /** Consume one injected failure, throwing in a rejected write's place. */
  44. consumeInjectedFailure(): void {
  45. if (this.failNextWrites > 0) {
  46. this.failNextWrites -= 1
  47. throw new Error('injected write failure')
  48. }
  49. }
  50. }
  51. /** In-memory KV unit over one pooled medium. */
  52. class MemoryKvUnit implements KvUnit {
  53. private closed = false
  54. constructor(
  55. private readonly pool: MemoryMediaPool,
  56. private readonly medium: MemoryMedium,
  57. private readonly descriptor: KvUnitDescriptor,
  58. private readonly onClose: () => void,
  59. ) {}
  60. private assertOpen(): void {
  61. if (this.closed) {
  62. throw new StorageError('closed', `memory unit '${this.descriptor.name}' is closed`)
  63. }
  64. }
  65. async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
  66. this.assertOpen()
  67. const tables: Record<string, Record<string, unknown>> = {}
  68. for (const table of this.descriptor.tables) {
  69. tables[table] = Object.fromEntries(this.medium.tables.get(table) ?? [])
  70. }
  71. return { tables, global: this.medium.global }
  72. }
  73. async putRecord(table: string, key: string, value: unknown): Promise<void> {
  74. this.assertOpen()
  75. this.pool.consumeInjectedFailure()
  76. let records = this.medium.tables.get(table)
  77. if (records === undefined) {
  78. records = new Map()
  79. this.medium.tables.set(table, records)
  80. }
  81. records.set(key, value)
  82. }
  83. async deleteRecord(table: string, key: string): Promise<void> {
  84. this.assertOpen()
  85. this.pool.consumeInjectedFailure()
  86. this.medium.tables.get(table)?.delete(key)
  87. }
  88. async setGlobal(value: unknown): Promise<void> {
  89. this.assertOpen()
  90. this.pool.consumeInjectedFailure()
  91. this.medium.global = value
  92. }
  93. async close(): Promise<void> {
  94. if (this.closed) return
  95. this.closed = true
  96. this.onClose()
  97. }
  98. }
  99. /**
  100. * In-memory storage backend with a `kv` facet. Pass a shared
  101. * {@link MemoryMediaPool} to let a second instance reopen the same media;
  102. * omit it for a throwaway isolated pool.
  103. */
  104. export class MemoryStorageBackend implements StorageBackend {
  105. readonly kv: KvFacet
  106. private readonly openUnits = new Set<string>()
  107. private closed = false
  108. /**
  109. * @param pool - Media shared across instances; a fresh private pool when omitted.
  110. */
  111. constructor(readonly pool: MemoryMediaPool = new MemoryMediaPool()) {
  112. this.kv = {
  113. open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
  114. if (this.closed) {
  115. throw new StorageError('closed', 'memory backend is closed')
  116. }
  117. // Double-open is a caller bug per the backend contract; no dedicated
  118. // StorageError code exists for it, so a plain Error is correct.
  119. if (this.openUnits.has(descriptor.name)) {
  120. throw new Error(`memory unit '${descriptor.name}' is already open (double-open is a caller bug)`)
  121. }
  122. const stamped = this.pool.versions.get(descriptor.name)
  123. if (stamped === undefined) {
  124. this.pool.versions.set(descriptor.name, descriptor.version)
  125. } else if (stamped !== descriptor.version) {
  126. throw new StorageError(
  127. 'version-mismatch',
  128. `memory unit '${descriptor.name}' is stamped v${stamped}, descriptor wants v${descriptor.version}`,
  129. )
  130. }
  131. let medium = this.pool.media.get(descriptor.name)
  132. if (medium === undefined) {
  133. medium = { tables: new Map(), global: null }
  134. this.pool.media.set(descriptor.name, medium)
  135. }
  136. this.openUnits.add(descriptor.name)
  137. return new MemoryKvUnit(this.pool, medium, descriptor, () => this.openUnits.delete(descriptor.name))
  138. },
  139. }
  140. }
  141. async close(): Promise<void> {
  142. this.closed = true
  143. this.openUnits.clear()
  144. }
  145. }