index.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. /**
  2. * JSON storage backend: one human-readable file per unit under a configured
  3. * root, published by atomic whole-file rewrite. Registers as backend `json`
  4. * on the storage hub.
  5. * @module @deepseek-ai/dsh-storage-json
  6. */
  7. import { mkdir } from 'node:fs/promises'
  8. import { join } from 'node:path'
  9. import type { Context } from 'cordis'
  10. import z from 'schemastery'
  11. import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
  12. import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
  13. import { openJsonUnit } from './unit.ts'
  14. /** Cordis plugin name. */
  15. export const name = 'storage-json'
  16. /** The hub must exist before the backend can register. */
  17. export const inject = ['storage']
  18. /**
  19. * Plugin configuration.
  20. * `root` has NO default on purpose: a `process.cwd()` fallback would scatter
  21. * unit files wherever the process happens to start; assemblies state the
  22. * location explicitly.
  23. */
  24. export interface Config {
  25. /** Directory holding one `<unit>.json` file per unit. */
  26. root: string
  27. }
  28. /** Config schema. */
  29. export const Config: z<Config> = z.object({
  30. root: z.string().required(),
  31. })
  32. /** JSON backend: owns the file-tree root and serves the `kv` facet. */
  33. export class JsonStorageBackend implements StorageBackend {
  34. private readonly open = new Map<string, KvUnit>()
  35. // Reserved synchronously at open() entry so a concurrent open of the same
  36. // unit fails, and close() can await opens still in flight.
  37. private readonly opening = new Map<string, Promise<KvUnit>>()
  38. private closed = false
  39. constructor(private readonly root: string) {}
  40. readonly kv: KvFacet = {
  41. // The body up to the first await runs synchronously, so the opening-slot
  42. // reservation below still excludes a concurrent open of the same unit.
  43. open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
  44. if (this.closed) throw new StorageError('closed', 'json backend is closed')
  45. validateDescriptor(descriptor)
  46. if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
  47. // Double-open is a caller bug, not a medium condition.
  48. throw new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`)
  49. }
  50. const opening = this.openUnit(descriptor)
  51. this.opening.set(descriptor.name, opening)
  52. return opening.finally(() => this.opening.delete(descriptor.name))
  53. },
  54. }
  55. private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
  56. await mkdir(this.root, { recursive: true, mode: 0o700 })
  57. const path = join(this.root, `${descriptor.name}.json`)
  58. const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
  59. if (this.closed) {
  60. // The backend closed while this open was in flight: do not hand out a
  61. // live unit past close().
  62. await unit.close()
  63. throw new StorageError('closed', 'json backend is closed')
  64. }
  65. this.open.set(descriptor.name, unit)
  66. return unit
  67. }
  68. async close(): Promise<void> {
  69. if (!this.closed) {
  70. this.closed = true
  71. }
  72. await Promise.allSettled([...this.opening.values()])
  73. for (const unit of [...this.open.values()]) {
  74. await unit.close()
  75. }
  76. }
  77. }
  78. function validateDescriptor(descriptor: KvUnitDescriptor): void {
  79. if (!UNIT_NAME_RE.test(descriptor.name)) {
  80. throw new StorageError('malformed-medium', `invalid unit name '${descriptor.name}'`)
  81. }
  82. for (const table of descriptor.tables) {
  83. if (!UNIT_NAME_RE.test(table)) {
  84. throw new StorageError('malformed-medium', `invalid table name '${table}' in unit '${descriptor.name}'`)
  85. }
  86. }
  87. }
  88. /**
  89. * Register the `json` backend on the storage hub.
  90. * @param ctx - Plugin context.
  91. * @param config - Validated configuration.
  92. */
  93. export function apply(ctx: Context, config: Config) {
  94. const backend = new JsonStorageBackend(config.root)
  95. ctx.effect(() => {
  96. const unregister = ctx.storage.backend.register('json', backend)
  97. return async () => {
  98. unregister()
  99. await backend.close()
  100. }
  101. })
  102. ctx.provide(storageBackendServiceKey('json'), backend)
  103. }