mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

d1-sqlite.ts

Language TypeScript6.2 KB163 lines

apps/worker/test/d1-sqlite.ts

  1. /**
  2. * A D1 stand-in backed by real SQLite, so the migration and the queries are
  3. * exercised rather than described.
  4. *
  5. * Not a hand-written fake: one that returns canned rows would pass with a schema
  6. * that does not apply, an upsert that does not upsert and a CHECK that does not
  7. * fire, and every one of those only shows up on live data. Running
  8. * `0001_checkin.sql` through an actual engine is what makes the migration file
  9. * a contract instead of a document.
  10. *
  11. * Why it might not run: `node:sqlite` arrived mid-Node-22, so a contributor
  12. * on a runtime older than the engines field asks for has no SQLite.
  13. * The suite must not fail for them and must not quietly shrink either: see
  14. * `sqliteAvailable` and the skip message in checkin-store.test.ts. A test that
  15. * silently stops running is rule 8 with the volume turned down.
  16. *
  17. * What it is not: D1. It has no network, no eventual consistency and no
  18. * batch isolation beyond SQLite's own, so it can prove the SQL is right and
  19. * cannot prove the deploy is.
  20. */
  21. import { readdirSync, readFileSync } from 'node:fs'
  22. import { fileURLToPath } from 'node:url'
  23. type Any = any
  24. /**
  25. * `process.getBuiltinModule`, not `import('node:sqlite')`.
  26. *
  27. * Vitest resolves imports through Vite, which strips the `node:` prefix and
  28. * then cannot find a module called `sqlite`, so a dynamic import here reports
  29. * "Does the file exist?" and every SQL test below skips, on a runtime that has
  30. * SQLite perfectly well. That is the silent-shrink failure this file's own skip
  31. * message exists to prevent, arriving through the bundler instead. This call
  32. * goes straight to the runtime and is not rewritten.
  33. */
  34. const builtin = (process as { getBuiltinModule?: (id: string) => Any }).getBuiltinModule
  35. const DatabaseSync: Any = (() => {
  36. try {
  37. return builtin?.('node:sqlite')?.DatabaseSync ?? null
  38. } catch {
  39. return null
  40. }
  41. })()
  42. export const sqliteAvailable = (): boolean => DatabaseSync !== null
  43. export const MIGRATIONS_DIR = fileURLToPath(new URL('../migrations/', import.meta.url))
  44. /**
  45. * Every migration, in the order `wrangler d1 migrations apply` would run them.
  46. *
  47. * All of them, not one hardcoded path: a harness applying only 0001 passes
  48. * every test for a feature whose table does not exist on it, so the suite would
  49. * be exercising a schema no deploy ever has. Rule 8: a tool that fails silently is worse than one that fails loudly,
  50. * and that includes our own.
  51. */
  52. export function migrationFiles(): string[] {
  53. return readdirSync(MIGRATIONS_DIR)
  54. .filter((f) => f.endsWith('.sql'))
  55. // The names are zero-padded, so a lexical sort IS the numeric one. That
  56. // assumption is pinned by a test rather than trusted: the day somebody
  57. // writes `10_x.sql`, it should fail there and not quietly apply it second.
  58. .sort()
  59. .map((f) => MIGRATIONS_DIR + f)
  60. }
  61. /**
  62. * One migration by filename, or all of them concatenated in apply order.
  63. *
  64. * A caller naming a file is asserting something about THAT file; a caller
  65. * naming none wants the schema a deploy would have.
  66. */
  67. export function migrationSql(which?: string): string {
  68. const files = which
  69. ? migrationFiles().filter((f) => f.endsWith(which))
  70. : migrationFiles()
  71. if (!files.length) throw new Error(`no migration matching ${which}`)
  72. return files.map((f) => readFileSync(f, 'utf8')).join('\n')
  73. }
  74. /**
  75. * An in-memory database with the migration applied, shaped like the bits of
  76. * D1's interface this code actually calls.
  77. */
  78. export function makeD1(): Any {
  79. const db = new DatabaseSync(':memory:')
  80. db.exec(migrationSql())
  81. const prepare = (sql: string): Any => {
  82. let args: unknown[] = []
  83. const stmt = {
  84. bind(...vals: unknown[]): Any {
  85. args = vals
  86. return stmt
  87. },
  88. first<T>(): Promise<T | null> {
  89. return Promise.resolve((db.prepare(sql).get(...args) as T) ?? null)
  90. },
  91. all(): Promise<{ results: unknown[]; success: true }> {
  92. return Promise.resolve({ results: db.prepare(sql).all(...args), success: true })
  93. },
  94. /**
  95. * D1 reports rows touched in `meta.changes`, and the private list's
  96. * updates DEPEND on that number: an UPDATE scoped by person and by a
  97. * stale-check token matches zero rows when either fails, and zero is how
  98. * the route learns to say "not yours / already moved on" instead of
  99. * "done". A stand-in that omitted it would make every one of those
  100. * refusals look like a success.
  101. */
  102. run(): Promise<{ results: unknown[]; success: true; meta: { changes: number } }> {
  103. const r = db.prepare(sql).run(...args)
  104. return Promise.resolve({
  105. results: [],
  106. success: true,
  107. meta: { changes: Number(r.changes ?? 0) },
  108. })
  109. },
  110. /** Used only by batch(), which runs the statements itself. */
  111. __run(): { results: unknown[]; success: true } {
  112. const s = db.prepare(sql)
  113. // A SELECT answers with rows; everything else answers with none. D1
  114. // makes the same distinction and callers read `.results` either way.
  115. const results = /^\s*select/i.test(sql) ? s.all(...args) : (s.run(...args), [])
  116. return { results, success: true }
  117. },
  118. }
  119. return stmt
  120. }
  121. return {
  122. prepare,
  123. batch(stmts: Any[]): Promise<{ results: unknown[]; success: true }[]> {
  124. // One transaction, like D1's. A half-applied batch would leave the
  125. // append-only log describing some of the versions, which is not a log.
  126. db.exec('BEGIN')
  127. try {
  128. const out = stmts.map((s) => s.__run())
  129. db.exec('COMMIT')
  130. return Promise.resolve(out)
  131. } catch (err) {
  132. db.exec('ROLLBACK')
  133. return Promise.reject(err)
  134. }
  135. },
  136. /** Escape hatch for assertions that want to look at the raw rows. */
  137. __raw: db,
  138. }
  139. }
  140. /** A binding that is present and refuses every query, for the failed state. */
  141. export function makeBrokenD1(message = 'D1_ERROR: no such table: checkin'): Any {
  142. const boom = (): never => {
  143. throw new Error(message)
  144. }
  145. const stmt: Any = {
  146. bind: () => stmt,
  147. first: () => Promise.reject(new Error(message)),
  148. all: () => Promise.reject(new Error(message)),
  149. __run: boom,
  150. }
  151. return { prepare: () => stmt, batch: () => Promise.reject(new Error(message)) }
  152. }