index.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. /**
  2. * SQLite storage backend for the storage hub: one database file hosts every
  3. * routed unit, document-per-row (`key TEXT` / `value TEXT` JSON). Registers
  4. * as backend `sqlite`; the disposer unregisters first, then closes the medium.
  5. * @module @deepseek-ai/dsh-storage-sqlite
  6. */
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import z from '@deepseek-ai/schemastery'
  9. import type { DatabaseSync } from 'node:sqlite'
  10. import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
  11. import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
  12. import { openDatabase, recordTableName, type JournalMode } from './schema.ts'
  13. import { SqliteKvUnit } from './unit.ts'
  14. export { STORAGE_SQLITE_SCHEMA_VERSION, type JournalMode } from './schema.ts'
  15. /** Cordis plugin name. */
  16. export const name = 'storage-sqlite'
  17. /** The backend registers on the storage hub. */
  18. export const inject = ['storage']
  19. /** Plugin configuration. */
  20. export interface Config {
  21. /**
  22. * Filesystem path to the SQLite database file. The special value `:memory:`
  23. * opens an in-process database (tests). On filesystems with POSIX modes,
  24. * missing directories and databases are created owner-only; existing path
  25. * modes are preserved. Filesystem setup errors other than an existing
  26. * database fail the open. The backend does not protect confidentiality or
  27. * integrity when another principal can replace the database entry in its
  28. * parent directory.
  29. */
  30. path: string
  31. /**
  32. * SQLite `journal_mode` pragma. `wal` (the default) suits local disks; pick
  33. * a rollback-journal mode (`delete`/`truncate`/`persist`) on filesystems
  34. * where WAL's shared-memory files do not work (network mounts). See
  35. * {@link JournalMode}.
  36. */
  37. journalMode?: JournalMode
  38. }
  39. /** Schemastery validator for {@link Config}. */
  40. export const Config: z<Config> = z.object({
  41. path: z.string().required(),
  42. journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
  43. })
  44. /**
  45. * The SQLite {@link StorageBackend}. Owns one `DatabaseSync` connection and
  46. * the open-unit table; `kv.open` validates names, enforces the per-unit
  47. * version stamp in `units`, and ensures the unit's record tables.
  48. */
  49. export class SqliteStorageBackend implements StorageBackend {
  50. /** The key-value facet; the only shape this backend serves. */
  51. readonly kv: KvFacet = { open: descriptor => this.openUnit(descriptor) }
  52. private readonly ready: Promise<DatabaseSync>
  53. /** Open (or still-opening) units by name; presence is the double-open guard. */
  54. private readonly units = new Map<string, Promise<SqliteKvUnit>>()
  55. private closing: Promise<void> | undefined
  56. /**
  57. * @param config - Validated plugin configuration.
  58. */
  59. constructor(config: Config) {
  60. this.ready = openDatabase(config.path, (config as Required<Config>).journalMode)
  61. // Mark the rejection handled: every primitive re-awaits `ready`, so an
  62. // open failure still surfaces to each caller; this guard only prevents an
  63. // unhandled-rejection crash when the failure precedes the first use.
  64. this.ready.catch(() => {})
  65. }
  66. private openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
  67. if (this.closing !== undefined) {
  68. return Promise.reject(new StorageError('closed', 'sqlite storage backend is closed'))
  69. }
  70. if (!UNIT_NAME_RE.test(descriptor.name)) {
  71. return Promise.reject(new Error(`kv unit name '${descriptor.name}' violates ${UNIT_NAME_RE}`))
  72. }
  73. for (const table of descriptor.tables) {
  74. if (!UNIT_NAME_RE.test(table)) {
  75. return Promise.reject(new Error(`kv table name '${table}' in unit '${descriptor.name}' violates ${UNIT_NAME_RE}`))
  76. }
  77. }
  78. if (this.units.has(descriptor.name)) {
  79. return Promise.reject(new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`))
  80. }
  81. // Reserve the name synchronously so a concurrent second open of the same
  82. // name rejects instead of racing past the guard during the awaits below.
  83. const pending = this.materializeUnit(descriptor)
  84. this.units.set(descriptor.name, pending)
  85. pending.catch(() => this.units.delete(descriptor.name))
  86. return pending
  87. }
  88. private async materializeUnit(descriptor: KvUnitDescriptor): Promise<SqliteKvUnit> {
  89. const db = await this.ready
  90. const row = db.prepare('SELECT version FROM units WHERE name = ?').get(descriptor.name) as
  91. | { version: number }
  92. | undefined
  93. if (row === undefined) {
  94. db.prepare('INSERT INTO units (name, version) VALUES (?, ?)').run(descriptor.name, descriptor.version)
  95. } else if (row.version !== descriptor.version) {
  96. throw new StorageError(
  97. 'version-mismatch',
  98. `kv unit '${descriptor.name}' is stamped version ${row.version} on the medium, incompatible with descriptor version ${descriptor.version}`,
  99. )
  100. }
  101. for (const table of descriptor.tables) {
  102. // Both segments passed UNIT_NAME_RE, so the identifier is safe in DDL.
  103. db.exec(`
  104. CREATE TABLE IF NOT EXISTS "${recordTableName(descriptor.name, table)}" (
  105. key TEXT PRIMARY KEY,
  106. value TEXT NOT NULL
  107. ) STRICT
  108. `)
  109. }
  110. return new SqliteKvUnit(db, descriptor, () => {
  111. this.units.delete(descriptor.name)
  112. })
  113. }
  114. /**
  115. * Close every open unit and release the database. Idempotent; concurrent
  116. * and repeated calls resolve once teardown finishes.
  117. * @returns resolution after the medium is released.
  118. */
  119. close(): Promise<void> {
  120. this.closing ??= this.doClose()
  121. return this.closing
  122. }
  123. private async doClose(): Promise<void> {
  124. let db: DatabaseSync
  125. try {
  126. db = await this.ready
  127. } catch {
  128. // The medium never opened; that failure already rejected the opener and
  129. // every unit call, so there is nothing left to release here.
  130. return
  131. }
  132. for (const pending of [...this.units.values()]) {
  133. const unit = await pending.catch(() => undefined)
  134. await unit?.close()
  135. }
  136. db.close()
  137. }
  138. }
  139. /**
  140. * Register the SQLite backend as `sqlite` on the storage hub. The disposer
  141. * unregisters the name first, then closes the backend.
  142. * @param ctx - Plugin context (must inject `storage`).
  143. * @param config - Validated plugin configuration.
  144. */
  145. export function apply(ctx: Context, config: Config) {
  146. const backend = new SqliteStorageBackend(config)
  147. ctx.effect(() => {
  148. const dispose = ctx.storage.backend.register('sqlite', backend)
  149. return async () => {
  150. dispose()
  151. await backend.close()
  152. }
  153. }, 'storage-sqlite.registerBackend')
  154. ctx.provide(storageBackendServiceKey('sqlite'), backend)
  155. }