sqlite-backend.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { DatabaseSync } from 'node:sqlite'
  7. import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
  8. import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
  9. import { runKvBackendContract } from '../../storage/tests/contract.ts'
  10. import * as StorageSqlite from '../src/index.ts'
  11. import { Config, SqliteStorageBackend, STORAGE_SQLITE_SCHEMA_VERSION } from '../src/index.ts'
  12. /** Mirror the loader: resolve schemastery defaults before construction. */
  13. function backendAt(path: string): SqliteStorageBackend {
  14. return new SqliteStorageBackend(new Config({ path }))
  15. }
  16. const dirs: string[] = []
  17. afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
  18. async function freshDbPath(): Promise<string> {
  19. const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-'))
  20. dirs.push(dir)
  21. return join(dir, 'storage.db')
  22. }
  23. // The contract suite's reopen() needs a surviving medium, so the harness binds
  24. // a real file; :memory: gets its own cases below.
  25. runKvBackendContract('sqlite', async () => {
  26. const path = await freshDbPath()
  27. return {
  28. backend: backendAt(path),
  29. reopen: async () => backendAt(path),
  30. }
  31. })
  32. const DESCRIPTOR: KvUnitDescriptor = {
  33. name: 'specimen',
  34. version: 1,
  35. tables: ['records'],
  36. hasGlobal: true,
  37. }
  38. describe('sqlite backend specifics', () => {
  39. it('opens an in-memory database', async () => {
  40. const backend = backendAt(':memory:')
  41. const unit = await backend.kv.open(DESCRIPTOR)
  42. await unit.putRecord('records', 'k', { n: 1 })
  43. expect((await unit.loadAll()).tables['records']).toEqual({ k: { n: 1 } })
  44. await backend.close()
  45. })
  46. it('materializes STRICT record tables and stamps the schema version', async () => {
  47. const path = await freshDbPath()
  48. const backend = backendAt(path)
  49. const unit = await backend.kv.open(DESCRIPTOR)
  50. await unit.putRecord('records', 'k', { n: 1 })
  51. await backend.close()
  52. const db = new DatabaseSync(path)
  53. try {
  54. const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number }
  55. expect(version).toBe(STORAGE_SQLITE_SCHEMA_VERSION)
  56. const table = db.prepare(
  57. "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'u_specimen_records'",
  58. ).get() as { sql: string } | undefined
  59. expect(table?.sql).toContain('STRICT')
  60. const unitRow = db.prepare('SELECT version FROM units WHERE name = ?').get('specimen') as { version: number }
  61. expect(unitRow.version).toBe(DESCRIPTOR.version)
  62. } finally {
  63. db.close()
  64. }
  65. })
  66. it('rejects a mismatched database schema version', async () => {
  67. const path = await freshDbPath()
  68. const db = new DatabaseSync(path)
  69. db.exec('PRAGMA user_version = 999')
  70. db.close()
  71. const backend = backendAt(path)
  72. await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({
  73. name: 'StorageError',
  74. code: 'version-mismatch',
  75. })
  76. await backend.close()
  77. })
  78. it('rejects invalid unit and table names before touching the medium', async () => {
  79. const backend = backendAt(':memory:')
  80. await expect(backend.kv.open({ ...DESCRIPTOR, name: 'Bad-Name' })).rejects.toThrow(/violates/)
  81. await expect(backend.kv.open({ ...DESCRIPTOR, tables: ['ok', '1bad'] })).rejects.toThrow(/violates/)
  82. await backend.close()
  83. })
  84. it('rejects a second open of the same unit name', async () => {
  85. const backend = backendAt(':memory:')
  86. await backend.kv.open(DESCRIPTOR)
  87. await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/already open/)
  88. await backend.close()
  89. })
  90. it('allows re-open after unit close, and rejects open on a closed backend', async () => {
  91. const backend = backendAt(':memory:')
  92. const unit = await backend.kv.open(DESCRIPTOR)
  93. await unit.close()
  94. const again = await backend.kv.open(DESCRIPTOR)
  95. await again.putRecord('records', 'k', 1)
  96. await backend.close()
  97. await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
  98. })
  99. it('round-trips prototype-polluting keys as own properties', async () => {
  100. const backend = backendAt(':memory:')
  101. const unit = await backend.kv.open(DESCRIPTOR)
  102. await unit.putRecord('records', '__proto__', { evil: true })
  103. await unit.putRecord('records', 'constructor', { n: 1 })
  104. const { tables } = await unit.loadAll()
  105. const records = tables['records']!
  106. expect(Object.hasOwn(records, '__proto__')).toBe(true)
  107. expect(records['__proto__']).toEqual({ evil: true })
  108. expect(records['constructor']).toEqual({ n: 1 })
  109. expect(Object.getPrototypeOf({})).not.toHaveProperty('evil')
  110. await backend.close()
  111. })
  112. // Repeated durable DDL across four connections needs an instrumented I/O budget.
  113. it('leaves a failed materialization unstamped so a repaired medium reopens', async () => {
  114. const path = await freshDbPath()
  115. // Obstruct table creation: an index squatting on the unit_globals name
  116. // makes CREATE TABLE IF NOT EXISTS throw AFTER the units table exists.
  117. const setup = new DatabaseSync(path)
  118. try {
  119. setup.exec('CREATE TABLE squatter (x TEXT)')
  120. setup.exec('CREATE INDEX unit_globals ON squatter(x)')
  121. } finally {
  122. setup.close()
  123. }
  124. const broken = backendAt(path)
  125. try {
  126. await expect(broken.kv.open(DESCRIPTOR)).rejects.toThrow(/already an index/)
  127. } finally {
  128. await broken.close()
  129. }
  130. // Clear the obstruction; the medium must still be version 0, not a
  131. // half-materialized database stamped as current.
  132. const repair = new DatabaseSync(path)
  133. try {
  134. expect((repair.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(0)
  135. expect(repair.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'units'").get())
  136. .toEqual({ name: 'units' })
  137. repair.exec('DROP INDEX unit_globals')
  138. expect(repair.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'unit_globals'").get())
  139. .toBeUndefined()
  140. } finally {
  141. repair.close()
  142. }
  143. const backend = backendAt(path)
  144. try {
  145. const unit = await backend.kv.open(DESCRIPTOR)
  146. await unit.putRecord('records', 'k', { n: 1 })
  147. expect((await unit.loadAll()).tables['records']).toEqual({ k: { n: 1 } })
  148. } finally {
  149. await backend.close()
  150. }
  151. }, 90_000)
  152. it('rejects unparsable stored JSON with malformed-medium', async () => {
  153. const path = await freshDbPath()
  154. const backend = backendAt(path)
  155. const unit = await backend.kv.open(DESCRIPTOR)
  156. await unit.putRecord('records', 'good', { n: 1 })
  157. await unit.setGlobal({ g: 1 })
  158. await backend.close()
  159. const db = new DatabaseSync(path)
  160. db.prepare('UPDATE u_specimen_records SET value = ? WHERE key = ?').run('{not json', 'good')
  161. db.close()
  162. const reopened = backendAt(path)
  163. const damaged = await reopened.kv.open(DESCRIPTOR)
  164. await expect(damaged.loadAll()).rejects.toMatchObject({
  165. name: 'StorageError',
  166. code: 'malformed-medium',
  167. })
  168. await reopened.close()
  169. })
  170. it('wraps a non-Error toJSON throw into an Error rejection', async () => {
  171. const backend = backendAt(':memory:')
  172. const unit = await backend.kv.open(DESCRIPTOR)
  173. // JSON.stringify propagates a value's own toJSON throw verbatim; the unit
  174. // must still reject with an Error instance.
  175. const hostile = { toJSON: () => { throw 'not an error' } }
  176. await expect(unit.putRecord('records', 'k', hostile)).rejects.toThrow('not an error')
  177. await expect(unit.putRecord('records', 'k', hostile)).rejects.toBeInstanceOf(Error)
  178. await backend.close()
  179. })
  180. it('rejects setGlobal on a unit without a global slot and writes to undeclared tables', async () => {
  181. const backend = backendAt(':memory:')
  182. const unit = await backend.kv.open({ ...DESCRIPTOR, hasGlobal: false })
  183. await expect(unit.setGlobal({ g: 1 })).rejects.toThrow(/declared no global slot/)
  184. await expect(unit.putRecord('undeclared', 'k', 1)).rejects.toThrow(/declared no table/)
  185. expect((await unit.loadAll()).global).toBeNull()
  186. await backend.close()
  187. })
  188. it('drains a still-pending failed open during close', async () => {
  189. const path = await freshDbPath()
  190. const first = backendAt(path)
  191. await (await first.kv.open(DESCRIPTOR)).close()
  192. await first.close()
  193. const backend = backendAt(path)
  194. // Do not await: close() must tolerate an in-flight open that will reject
  195. // (version mismatch) while its name is still reserved in the unit table.
  196. const pending = backend.kv.open({ ...DESCRIPTOR, version: 99 })
  197. const closed = backend.close()
  198. await expect(pending).rejects.toMatchObject({ code: 'version-mismatch' })
  199. await closed
  200. })
  201. it('propagates filesystem errors other than an existing database file', async () => {
  202. if (process.platform === 'win32') return
  203. const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-'))
  204. dirs.push(dir)
  205. await chmod(dir, 0o500)
  206. const backend = backendAt(join(dir, 'storage.db'))
  207. await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'EACCES' })
  208. await backend.close()
  209. await chmod(dir, 0o700)
  210. })
  211. it('propagates an invalid database filename before opening SQLite', async () => {
  212. const path = await freshDbPath()
  213. const backend = backendAt(`${path}\0invalid`)
  214. await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/null bytes/i)
  215. await backend.close()
  216. })
  217. it('preserves the mode of an existing database file', async () => {
  218. if (process.platform === 'win32') return
  219. const path = await freshDbPath()
  220. await writeFile(path, '', { mode: 0o644 })
  221. await chmod(path, 0o644)
  222. const backend = backendAt(path)
  223. const unit = await backend.kv.open(DESCRIPTOR)
  224. await unit.putRecord('records', 'k', 1)
  225. await backend.close()
  226. })
  227. it('registers on the storage hub as backend sqlite and closes on dispose', async () => {
  228. const ctx = new Context()
  229. await ctx.plugin(Storage)
  230. const fiber = await ctx.plugin(StorageSqlite, { path: ':memory:' })
  231. const backend = ctx.storage.backend.get('sqlite')
  232. expect(ctx.get(storageBackendServiceKey('sqlite'))).toBe(backend)
  233. const unit = await backend.kv!.open(DESCRIPTOR)
  234. await unit.putRecord('records', 'k', { n: 1 })
  235. await fiber.dispose()
  236. expect(ctx.storage.backend.names()).toEqual([])
  237. expect(ctx.get(storageBackendServiceKey('sqlite'))).toBeUndefined()
  238. await expect(backend.kv!.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
  239. })
  240. it('rejects an unparsable global slot with malformed-medium', async () => {
  241. const path = await freshDbPath()
  242. const backend = backendAt(path)
  243. const unit = await backend.kv.open(DESCRIPTOR)
  244. await unit.setGlobal({ g: 1 })
  245. await backend.close()
  246. const db = new DatabaseSync(path)
  247. db.prepare('UPDATE unit_globals SET value = ? WHERE unit = ?').run('][', 'specimen')
  248. db.close()
  249. const reopened = backendAt(path)
  250. const damaged = await reopened.kv.open(DESCRIPTOR)
  251. await expect(damaged.loadAll()).rejects.toMatchObject({
  252. name: 'StorageError',
  253. code: 'malformed-medium',
  254. })
  255. await reopened.close()
  256. })
  257. })