@mohou/host 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. package/themes/theme-zoro.css +4 -0
@@ -0,0 +1,461 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import Database from 'better-sqlite3'
5
+
6
+ import type { AppKeyValueTable, AppStorage, SqlParams, SqlRow, SqlValue } from '@mohou/contract'
7
+
8
+ import { StorageError } from './codes.ts'
9
+ import { storageDatabase, storageQuarantine } from './layout.ts'
10
+ import { applySchema } from './schema.ts'
11
+ import { assertRuntimeSql } from './sql.ts'
12
+
13
+ /** A bound SQLite file. `connect` is what a call receives. `close` is host-only. */
14
+ export interface StorageFile {
15
+ connect(options?: StorageConnect): AppStorage
16
+ close(): void
17
+ /** Host-only. Not `ctx.storage`. */
18
+ listTables(): string[]
19
+ /** Host-only export. `kv` values are parsed. */
20
+ exportTable(name: string, maxRows: number): unknown[]
21
+ }
22
+
23
+ /** Per-call limits. The row cap is host policy and is not locked. */
24
+ export interface StorageConnect {
25
+ readonly signal?: AbortSignal
26
+ readonly maxRows?: number
27
+ }
28
+
29
+ /** Physical layout version. Stamped last. Any other stamp rejects. */
30
+ const SCHEMA_VERSION = 2
31
+
32
+ /** Bytes past which a committed write may emit one size notice. Host policy, not locked. */
33
+ export const DEFAULT_STORAGE_NOTICE_BYTES = 64 * 1024 * 1024
34
+
35
+ /** Host policy for the pre-migration snapshot. The number is not locked. */
36
+ export interface StorageOpenOptions {
37
+ readonly backupMaxBytes?: number
38
+ /** Emit one advisory notice after a committed write past this size. Not a locked number. */
39
+ readonly noticeBytes?: number
40
+ readonly onNotice?: (notice: { table: string; keys: string[] }) => void
41
+ }
42
+
43
+ /** How many heavy keys a size notice names. Host policy, not a locked number. */
44
+ const NOTICE_KEYS = 5
45
+
46
+ interface KvStatements {
47
+ get: Database.Statement
48
+ set: Database.Statement
49
+ delete: Database.Statement
50
+ clear: Database.Statement
51
+ }
52
+
53
+ interface Shared {
54
+ db: Database.Database
55
+ kv: KvStatements
56
+ tail: Promise<void>
57
+ depth: number
58
+ noticeBytes?: number
59
+ onNotice?: (notice: { table: string; keys: string[] }) => void
60
+ lastNotice: string | undefined
61
+ }
62
+
63
+ /**
64
+ * Open the app database from {@link storageLayout}, creating the reserved `kv` table.
65
+ * A corrupt file is renamed aside. A later open does not create a replacement.
66
+ * @param appDir - absolute app directory
67
+ */
68
+ export async function openStorage(appDir: string, options?: StorageOpenOptions): Promise<StorageFile> {
69
+ const file = storageDatabase(appDir)
70
+ const marker = storageQuarantine(appDir)
71
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
72
+ if (existsSync(marker)) {
73
+ throw new StorageError('storage-corrupt', `storage file is quarantined: ${file}`)
74
+ }
75
+ ensurePrivateFile(file)
76
+ const db = openDatabase(file, marker)
77
+ try {
78
+ await applySchema(db, appDir, options?.backupMaxBytes)
79
+ } catch (error) {
80
+ db.close()
81
+ throw error
82
+ }
83
+ const shared: Shared = {
84
+ db,
85
+ kv: prepareKv(db),
86
+ tail: Promise.resolve(),
87
+ depth: 0,
88
+ ...options?.noticeBytes === undefined ? {} : { noticeBytes: options.noticeBytes },
89
+ ...options?.onNotice === undefined ? {} : { onNotice: options.onNotice },
90
+ lastNotice: undefined,
91
+ }
92
+ return {
93
+ connect(options) {
94
+ return facade(shared, options)
95
+ },
96
+ close() {
97
+ db.close()
98
+ },
99
+ listTables() {
100
+ return listTables(db)
101
+ },
102
+ exportTable(name, maxRows) {
103
+ return exportTable(db, name, maxRows)
104
+ },
105
+ }
106
+ }
107
+
108
+ function listTables(db: Database.Database): string[] {
109
+ const rows = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as Array<{ name: string }>
110
+ return rows.map(row => row.name)
111
+ }
112
+
113
+ function exportTable(db: Database.Database, name: string, maxRows: number): unknown[] {
114
+ const tables = listTables(db)
115
+ if (!tables.includes(name)) throw new StorageError('storage-forbidden', `table is not in this file: ${name}`)
116
+ const count = db.prepare(`SELECT COUNT(*) AS n FROM ${quoteIdent(name)}`).get() as { n: number }
117
+ if (count.n > maxRows) throw new StorageError('storage-too-large', `table exceeds the host cap: ${name}`)
118
+ const rows = db.prepare(`SELECT * FROM ${quoteIdent(name)}`).all() as Array<Record<string, unknown>>
119
+ if (name !== 'kv') return rows
120
+ return rows.map(row => ({
121
+ key: row.key,
122
+ value: parseKv(row.value),
123
+ }))
124
+ }
125
+
126
+ function quoteIdent(name: string): string {
127
+ return `"${name.replaceAll('"', '""')}"`
128
+ }
129
+
130
+ function parseKv(value: unknown): unknown {
131
+ if (typeof value !== 'string') return value
132
+ try {
133
+ return JSON.parse(value) as unknown
134
+ } catch {
135
+ return value
136
+ }
137
+ }
138
+
139
+ function noteSize(shared: Shared, table: string): void {
140
+ if (shared.noticeBytes === undefined || shared.onNotice === undefined) return
141
+ try {
142
+ const pages = shared.db.prepare('PRAGMA page_count').get() as { page_count: number }
143
+ const size = shared.db.prepare('PRAGMA page_size').get() as { page_size: number }
144
+ if (pages.page_count * size.page_size <= shared.noticeBytes) {
145
+ shared.lastNotice = undefined
146
+ return
147
+ }
148
+ const keys = heaviestKeys(shared.db, table)
149
+ const signature = `${table}:${keys.join('\0')}`
150
+ if (shared.lastNotice === signature) return
151
+ shared.lastNotice = signature
152
+ shared.onNotice({ table, keys })
153
+ } catch {
154
+ // A notice never fails the write that already committed.
155
+ }
156
+ }
157
+
158
+ function heaviestKeys(db: Database.Database, table: string): string[] {
159
+ if (table === 'kv') {
160
+ const rows = db.prepare('SELECT key FROM kv ORDER BY length(value) DESC LIMIT ?').all(NOTICE_KEYS) as Array<{ key: string }>
161
+ return rows.map(row => row.key)
162
+ }
163
+ const columns = db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ name: string }>
164
+ if (columns.length === 0) return []
165
+ const weight = columns.map(column => `length(CAST(${quoteIdent(column.name)} AS TEXT))`).join(' + ')
166
+ const key = columns.some(column => column.name === 'id') ? quoteIdent('id') : 'rowid'
167
+ const rows = db.prepare(`SELECT ${key} AS key FROM ${quoteIdent(table)} ORDER BY ${weight} DESC LIMIT ?`).all(NOTICE_KEYS) as Array<{ key: unknown }>
168
+ return rows.map(row => String(row.key))
169
+ }
170
+
171
+ function writtenTable(sql: string): string | undefined {
172
+ const match = /\b(?:into|update|table)\s+([A-Za-z0-9_]+)/.exec(sql)
173
+ return match?.[1]
174
+ }
175
+
176
+ function ensurePrivateFile(file: string): void {
177
+ try {
178
+ closeSync(openSync(file, 'wx', 0o600))
179
+ } catch (error) {
180
+ if (!isCode(error, 'EEXIST')) throw error
181
+ }
182
+ }
183
+
184
+ function openDatabase(file: string, marker: string): Database.Database {
185
+ let db: Database.Database
186
+ try {
187
+ db = new Database(file)
188
+ } catch (error) {
189
+ if (isCorrupt(error)) {
190
+ quarantine(file, marker)
191
+ throw new StorageError('storage-corrupt', `storage file is corrupt: ${file}`, { cause: error })
192
+ }
193
+ throw error
194
+ }
195
+ try {
196
+ db.pragma('foreign_keys = ON')
197
+ db.pragma('journal_mode = WAL')
198
+ const check = db.prepare('PRAGMA quick_check').all() as Array<{ quick_check: string }>
199
+ if (check.some(row => row.quick_check !== 'ok')) {
200
+ throw new StorageError('storage-corrupt', `storage file is corrupt: ${file}`)
201
+ }
202
+ stampSchema(db)
203
+ return db
204
+ } catch (error) {
205
+ if (db.open) db.close()
206
+ if (error instanceof StorageError && error.code === 'storage-corrupt') {
207
+ quarantine(file, marker)
208
+ throw error
209
+ }
210
+ if (isCorrupt(error)) {
211
+ quarantine(file, marker)
212
+ throw new StorageError('storage-corrupt', `storage file is corrupt: ${file}`, { cause: error })
213
+ }
214
+ throw error
215
+ }
216
+ }
217
+
218
+ function stampSchema(db: Database.Database): void {
219
+ const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
220
+ if (row.user_version !== 0 && row.user_version !== 1 && row.user_version !== SCHEMA_VERSION) {
221
+ throw new StorageError('storage-version', `storage schema version ${row.user_version} is not ${SCHEMA_VERSION}`)
222
+ }
223
+ if (row.user_version === 0) {
224
+ db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT')
225
+ }
226
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, checksum TEXT NOT NULL) STRICT')
227
+ if (row.user_version !== SCHEMA_VERSION) db.pragma(`user_version = ${SCHEMA_VERSION}`)
228
+ }
229
+
230
+ function prepareKv(db: Database.Database): KvStatements {
231
+ return {
232
+ get: db.prepare('SELECT value FROM kv WHERE key = ?'),
233
+ set: db.prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'),
234
+ delete: db.prepare('DELETE FROM kv WHERE key = ?'),
235
+ clear: db.prepare('DELETE FROM kv'),
236
+ }
237
+ }
238
+
239
+ function quarantine(file: string, marker: string): void {
240
+ const kept = `${file}.corrupt-${Date.now()}`
241
+ renameSync(file, kept)
242
+ for (const suffix of ['-wal', '-shm']) {
243
+ if (existsSync(`${file}${suffix}`)) renameSync(`${file}${suffix}`, `${kept}${suffix}`)
244
+ }
245
+ writeFileSync(marker, kept)
246
+ }
247
+
248
+ function facade(shared: Shared, options?: StorageConnect): AppStorage {
249
+ const signal = options?.signal
250
+ const maxRows = options?.maxRows
251
+ const tx: AppStorage = {
252
+ kv: () => kv(shared, signal, true),
253
+ query: (sql, params) => asPromise(() => queryNow(shared, signal, maxRows, sql, params)),
254
+ run: (sql, params) => asPromise(() => runNow(shared, signal, sql, params)),
255
+ transaction: work => savepoint(shared, signal, maxRows, work),
256
+ }
257
+ return {
258
+ kv: () => kv(shared, signal, false),
259
+ query: (sql, params) => enqueue(shared, signal, () => queryNow(shared, signal, maxRows, sql, params)),
260
+ run: (sql, params) => enqueue(shared, signal, () => runNow(shared, signal, sql, params)),
261
+ transaction: work => enqueue(shared, signal, () => inTransaction(shared, signal, tx, work)),
262
+ }
263
+ }
264
+
265
+ function kv(shared: Shared, signal: AbortSignal | undefined, direct: boolean): AppKeyValueTable {
266
+ const run = <T>(fn: () => T): Promise<T> => direct
267
+ ? asPromise(fn)
268
+ : enqueue(shared, signal, fn)
269
+ return {
270
+ get: key => run(() => readKey(shared, signal, key)),
271
+ set: (key, value) => run(() => {
272
+ writeKey(shared, signal, key, value)
273
+ noteSize(shared, 'kv')
274
+ }),
275
+ delete: key => run(() => {
276
+ assertLive(signal)
277
+ shared.kv.delete.run(key)
278
+ noteSize(shared, 'kv')
279
+ }),
280
+ clear: () => run(() => {
281
+ assertLive(signal)
282
+ shared.kv.clear.run()
283
+ noteSize(shared, 'kv')
284
+ }),
285
+ }
286
+ }
287
+
288
+ function readKey(shared: Shared, signal: AbortSignal | undefined, key: string): unknown {
289
+ assertLive(signal)
290
+ const row = shared.kv.get.get(key) as { value: string } | undefined
291
+ if (row === undefined) return null
292
+ try {
293
+ return JSON.parse(row.value) as unknown
294
+ } catch (error) {
295
+ throw new StorageError('storage-corrupt', `kv value is not JSON: ${key}`, { cause: error })
296
+ }
297
+ }
298
+
299
+ function writeKey(shared: Shared, signal: AbortSignal | undefined, key: string, value: unknown): void {
300
+ assertLive(signal)
301
+ shared.kv.set.run(key, encode(value))
302
+ }
303
+
304
+ function queryNow(
305
+ shared: Shared,
306
+ signal: AbortSignal | undefined,
307
+ maxRows: number | undefined,
308
+ sql: string,
309
+ params?: SqlParams,
310
+ ): SqlRow[] {
311
+ assertLive(signal)
312
+ const stmt = prepare(shared, sql)
313
+ if (!stmt.reader) {
314
+ throw new StorageError('storage-statement', 'query expects a statement that returns rows')
315
+ }
316
+ const rows = bind(stmt, params).all() as SqlRow[]
317
+ if (maxRows !== undefined && rows.length > maxRows) {
318
+ throw new StorageError('storage-too-large', 'query returned too many rows')
319
+ }
320
+ return rows
321
+ }
322
+
323
+ function runNow(
324
+ shared: Shared,
325
+ signal: AbortSignal | undefined,
326
+ sql: string,
327
+ params?: SqlParams,
328
+ ): { changes: number; lastInsertRowid: number | bigint } {
329
+ assertLive(signal)
330
+ const stmt = prepare(shared, sql)
331
+ if (stmt.reader) {
332
+ throw new StorageError('storage-statement', 'run expects a statement that does not return rows')
333
+ }
334
+ const info = bind(stmt, params).run()
335
+ const table = writtenTable(sql)
336
+ if (table !== undefined) noteSize(shared, table)
337
+ return { changes: info.changes, lastInsertRowid: info.lastInsertRowid }
338
+ }
339
+
340
+ async function inTransaction<T>(
341
+ shared: Shared,
342
+ signal: AbortSignal | undefined,
343
+ tx: AppStorage,
344
+ work: (tx: AppStorage) => Promise<T>,
345
+ ): Promise<T> {
346
+ assertLive(signal)
347
+ shared.depth += 1
348
+ shared.db.exec('BEGIN IMMEDIATE')
349
+ try {
350
+ const value = await work(tx)
351
+ shared.db.exec('COMMIT')
352
+ return value
353
+ } catch (error) {
354
+ shared.db.exec('ROLLBACK')
355
+ throw error
356
+ } finally {
357
+ shared.depth -= 1
358
+ }
359
+ }
360
+
361
+ async function savepoint<T>(
362
+ shared: Shared,
363
+ signal: AbortSignal | undefined,
364
+ maxRows: number | undefined,
365
+ work: (tx: AppStorage) => Promise<T>,
366
+ ): Promise<T> {
367
+ assertLive(signal)
368
+ const name = `sp_${shared.depth}`
369
+ shared.depth += 1
370
+ shared.db.exec(`SAVEPOINT ${name}`)
371
+ const nested: AppStorage = {
372
+ kv: () => kv(shared, signal, true),
373
+ query: (sql, params) => asPromise(() => queryNow(shared, signal, maxRows, sql, params)),
374
+ run: (sql, params) => asPromise(() => runNow(shared, signal, sql, params)),
375
+ transaction: inner => savepoint(shared, signal, maxRows, inner),
376
+ }
377
+ try {
378
+ const value = await work(nested)
379
+ shared.db.exec(`RELEASE ${name}`)
380
+ return value
381
+ } catch (error) {
382
+ shared.db.exec(`ROLLBACK TO ${name}`)
383
+ shared.db.exec(`RELEASE ${name}`)
384
+ throw error
385
+ } finally {
386
+ shared.depth -= 1
387
+ }
388
+ }
389
+
390
+ function asPromise<T>(run: () => T): Promise<T> {
391
+ try {
392
+ return Promise.resolve(run())
393
+ } catch (error) {
394
+ return Promise.reject(error instanceof Error ? error : new Error('storage call failed'))
395
+ }
396
+ }
397
+
398
+ function enqueue<T>(shared: Shared, signal: AbortSignal | undefined, run: () => T | Promise<T>): Promise<T> {
399
+ if (signal?.aborted) return Promise.reject(new StorageError('cancelled', 'storage call was cancelled'))
400
+ if (shared.depth > 0) {
401
+ return Promise.reject(new StorageError('storage-forbidden', 'use the transaction handle'))
402
+ }
403
+ const job = shared.tail.then(() => Promise.resolve(run()))
404
+ shared.tail = job.then(() => undefined, () => undefined)
405
+ return job
406
+ }
407
+
408
+ function prepare(shared: Shared, sql: string): Database.Statement {
409
+ assertAllowed(sql)
410
+ try {
411
+ return shared.db.prepare(sql)
412
+ } catch (error) {
413
+ if (error instanceof RangeError && /more than one statement/i.test(error.message)) {
414
+ throw new StorageError('storage-forbidden', 'only one statement is allowed', { cause: error })
415
+ }
416
+ throw new StorageError('storage-sql', error instanceof Error ? error.message : 'storage sql failed', { cause: error })
417
+ }
418
+ }
419
+
420
+ function assertAllowed(sql: string): void {
421
+ assertRuntimeSql(sql)
422
+ }
423
+
424
+ function bind(stmt: Database.Statement, params?: SqlParams): { all(): unknown[]; run(): Database.RunResult } {
425
+ if (params === undefined) return { all: () => stmt.all(), run: () => stmt.run() }
426
+ if (Array.isArray(params)) {
427
+ const values = params.map(normalize)
428
+ return { all: () => stmt.all(...values), run: () => stmt.run(...values) }
429
+ }
430
+ const named: Record<string, SqlValue> = {}
431
+ for (const [key, value] of Object.entries(params)) named[key] = normalize(value)
432
+ return { all: () => stmt.all(named), run: () => stmt.run(named) }
433
+ }
434
+
435
+ function normalize(value: SqlValue): SqlValue {
436
+ if (typeof value !== 'object' || value === null || Buffer.isBuffer(value)) return value
437
+ return Buffer.from(value)
438
+ }
439
+
440
+ function encode(value: unknown): string {
441
+ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
442
+ throw new StorageError('storage-not-json', 'storage value is not JSON')
443
+ }
444
+ try {
445
+ return JSON.stringify(value)
446
+ } catch (error) {
447
+ throw new StorageError('storage-not-json', 'storage value is not JSON', { cause: error })
448
+ }
449
+ }
450
+
451
+ function assertLive(signal: AbortSignal | undefined): void {
452
+ if (signal?.aborted) throw new StorageError('cancelled', 'storage call was cancelled')
453
+ }
454
+
455
+ function isCorrupt(error: unknown): boolean {
456
+ return isCode(error, 'SQLITE_NOTADB') || isCode(error, 'SQLITE_CORRUPT')
457
+ }
458
+
459
+ function isCode(error: unknown, code: string): boolean {
460
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === code
461
+ }
@@ -0,0 +1,176 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import Database from 'better-sqlite3'
6
+
7
+ import { appTrees } from '@mohou/contract'
8
+
9
+ import { StorageError } from './codes.ts'
10
+ import { storageBackup, storageBackupMeta, storageBlocked, storageDatabase, storageDir } from './layout.ts'
11
+ import { assertMigrationSql } from './sql.ts'
12
+
13
+ interface SchemaFile {
14
+ id: number
15
+ name: string
16
+ checksum: string
17
+ sql: string
18
+ }
19
+
20
+ interface Applied {
21
+ id: number
22
+ checksum: string
23
+ }
24
+
25
+ /**
26
+ * Apply pending `schema/NNN_name.sql` files. One backup covers the batch.
27
+ * A foreign checksum, a gap, or a blocked checksum fails before any new file runs.
28
+ * @param db - open database with the host tables already stamped
29
+ * @param appDir - app directory
30
+ * @param backupMaxBytes - host cap; omitted means no byte cap
31
+ */
32
+ export async function applySchema(db: Database.Database, appDir: string, backupMaxBytes?: number): Promise<void> {
33
+ const files = readSchema(appDir)
34
+ const applied = db.prepare('SELECT id, checksum FROM schema_migrations ORDER BY id').all() as Applied[]
35
+ assertConsistent(files, applied)
36
+ const pending = files.filter(file => !applied.some(row => row.id === file.id))
37
+ if (pending.length === 0) return
38
+ const blocked = readBlocked(appDir)
39
+ const stopped = pending.find(file => blocked.has(file.checksum))
40
+ if (stopped !== undefined) {
41
+ throw new StorageError('storage-migration', `schema file is blocked, write a new file: ${stopped.name}`)
42
+ }
43
+ const first = pending[0]
44
+ if (first === undefined) return
45
+ assertBackupFits(db, backupMaxBytes)
46
+ await writeBackup(db, appDir, first.id)
47
+ const appliedThisBatch: string[] = []
48
+ for (const [index, file] of pending.entries()) {
49
+ try {
50
+ runFile(db, file)
51
+ } catch (error) {
52
+ const done = appliedThisBatch.length === 0
53
+ ? 'no schema file in this batch was applied'
54
+ : `applied ${appliedThisBatch.join(', ')}`
55
+ const rest = pending.slice(index + 1).map(item => item.name)
56
+ const stopped = rest.length === 0 ? '' : ` Later files were not started: ${rest.join(', ')}.`
57
+ throw new StorageError(
58
+ 'storage-migration',
59
+ `${done}. ${file.name} was not applied and will run again after it is fixed.${stopped}`,
60
+ { cause: error },
61
+ )
62
+ }
63
+ appliedThisBatch.push(file.name)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Replace the database with the latest pre-migration snapshot and block the
69
+ * checksums applied after that snapshot. The caller must close the live handle first.
70
+ * @param appDir - app directory
71
+ */
72
+ export function restoreStorageBackup(appDir: string): void {
73
+ const metaPath = storageBackupMeta(appDir)
74
+ const backupPath = storageBackup(appDir)
75
+ if (!existsSync(metaPath) || !existsSync(backupPath)) {
76
+ throw new StorageError('storage-migration', 'no schema backup to restore')
77
+ }
78
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as { beforeId?: number }
79
+ if (meta.beforeId === undefined) {
80
+ throw new StorageError('storage-migration', 'schema backup is missing beforeId')
81
+ }
82
+ const live = storageDatabase(appDir)
83
+ const db = new Database(live)
84
+ let checksums: string[] = []
85
+ try {
86
+ checksums = (db.prepare('SELECT checksum FROM schema_migrations WHERE id >= ?').all(meta.beforeId) as Applied[])
87
+ .map(row => row.checksum)
88
+ } finally {
89
+ db.close()
90
+ }
91
+ writeBlocked(appDir, checksums)
92
+ for (const suffix of ['', '-wal', '-shm']) rmSync(`${live}${suffix}`, { force: true })
93
+ copyFileSync(backupPath, live)
94
+ }
95
+
96
+ function readSchema(appDir: string): SchemaFile[] {
97
+ const dir = join(appDir, appTrees.schema)
98
+ if (!existsSync(dir)) return []
99
+ const files = readdirSync(dir).flatMap((name) => {
100
+ const match = /^(\d+)_.+\.sql$/.exec(name)
101
+ if (match === null) {
102
+ throw new StorageError('storage-migration', `schema file name is not a numbered migration: ${name}`)
103
+ }
104
+ const id = Number(match[1])
105
+ if (!Number.isSafeInteger(id) || id < 1) {
106
+ throw new StorageError('storage-migration', `schema file id is invalid: ${name}`)
107
+ }
108
+ const sql = readFileSync(join(dir, name))
109
+ return [{ id, name, sql: sql.toString('utf8'), checksum: createHash('sha256').update(sql).digest('hex') }]
110
+ }).sort((left, right) => left.id - right.id)
111
+ for (let index = 0; index < files.length; index += 1) {
112
+ const file = files[index]
113
+ if (file === undefined || file.id !== index + 1) {
114
+ throw new StorageError('storage-migration', 'schema file ids must be contiguous from 1')
115
+ }
116
+ }
117
+ return files
118
+ }
119
+
120
+ function assertConsistent(files: SchemaFile[], applied: Applied[]): void {
121
+ for (const row of applied) {
122
+ const file = files.find(item => item.id === row.id)
123
+ if (file === undefined) {
124
+ throw new StorageError('storage-migration', `applied schema file is missing: ${row.id}`)
125
+ }
126
+ if (file.checksum !== row.checksum) {
127
+ throw new StorageError('storage-migration', `applied schema file changed: ${file.name}; write a new file`)
128
+ }
129
+ }
130
+ }
131
+
132
+ function assertBackupFits(db: Database.Database, backupMaxBytes: number | undefined): void {
133
+ if (backupMaxBytes === undefined) return
134
+ const pageSize = db.prepare('PRAGMA page_size').get() as { page_size: number }
135
+ const pageCount = db.prepare('PRAGMA page_count').get() as { page_count: number }
136
+ if (pageSize.page_size * pageCount.page_count > backupMaxBytes) {
137
+ throw new StorageError('storage-backup-too-large', 'schema backup would exceed the host cap')
138
+ }
139
+ }
140
+
141
+ async function writeBackup(db: Database.Database, appDir: string, beforeId: number): Promise<void> {
142
+ const dir = storageDir(appDir)
143
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
144
+ await db.backup(storageBackup(appDir))
145
+ writeFileSync(storageBackupMeta(appDir), JSON.stringify({ beforeId }))
146
+ }
147
+
148
+ function runFile(db: Database.Database, file: SchemaFile): void {
149
+ assertMigrationSql(file.sql, file.name)
150
+ db.exec('BEGIN IMMEDIATE')
151
+ try {
152
+ db.exec(file.sql)
153
+ db.prepare('INSERT INTO schema_migrations (id, checksum) VALUES (?, ?)').run(file.id, file.checksum)
154
+ db.exec('COMMIT')
155
+ } catch (error) {
156
+ db.exec('ROLLBACK')
157
+ if (error instanceof StorageError) throw error
158
+ throw new StorageError('storage-migration', `schema file failed: ${file.name}`, { cause: error })
159
+ }
160
+ }
161
+
162
+ function readBlocked(appDir: string): Set<string> {
163
+ const path = storageBlocked(appDir)
164
+ if (!existsSync(path)) return new Set()
165
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
166
+ if (typeof parsed !== 'object' || parsed === null || !('checksums' in parsed) || !Array.isArray(parsed.checksums)) {
167
+ throw new StorageError('storage-migration', 'blocked schema list is invalid')
168
+ }
169
+ return new Set(parsed.checksums.filter((item): item is string => typeof item === 'string'))
170
+ }
171
+
172
+ function writeBlocked(appDir: string, checksums: string[]): void {
173
+ const path = storageBlocked(appDir)
174
+ const next = [...readBlocked(appDir), ...checksums]
175
+ writeFileSync(path, JSON.stringify({ checksums: [...new Set(next)] }))
176
+ }
@@ -0,0 +1,40 @@
1
+ import { StorageError } from './codes.ts'
2
+
3
+ /** Strip comments and string literals so keyword checks do not match quoted text. */
4
+ export function stripSql(sql: string): string {
5
+ return sql
6
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
7
+ .replace(/--[^\n]*/g, ' ')
8
+ .replace(/'(?:''|[^'])*'/g, "''")
9
+ }
10
+
11
+ /** Runtime SQL may read or write rows. It may not change schema or touch host tables. */
12
+ export function assertRuntimeSql(sql: string): void {
13
+ const bare = stripSql(sql).trim()
14
+ assertHostTables(bare, 'storage-forbidden')
15
+ if (!/^(select|insert|update|delete|replace|with)\b/i.test(bare)
16
+ || /\b(create|drop|alter|pragma|vacuum|reindex|analyze|attach|detach|begin|commit|rollback|savepoint|release)\b/i.test(bare)
17
+ || /\bload_extension\s*\(/i.test(bare)) {
18
+ throw new StorageError('storage-forbidden', 'statement is not allowed')
19
+ }
20
+ }
21
+
22
+ /** A schema file may change app tables and rewrite app rows. It may not touch host tables or leave the file. */
23
+ export function assertMigrationSql(sql: string, name: string): void {
24
+ const bare = stripSql(sql).trim()
25
+ if (bare === '') {
26
+ throw new StorageError('storage-migration', `schema file is empty: ${name}`)
27
+ }
28
+ assertHostTables(bare, 'storage-migration')
29
+ if (/\b(attach|detach|begin|commit|rollback|savepoint|release)\b/i.test(bare)
30
+ || /\bload_extension\s*\(/i.test(bare)
31
+ || /\bvacuum\s+into\b/i.test(bare)) {
32
+ throw new StorageError('storage-migration', `schema file is not allowed: ${name}`)
33
+ }
34
+ }
35
+
36
+ function assertHostTables(bare: string, code: 'storage-forbidden' | 'storage-migration'): void {
37
+ if (/\b(kv|schema_migrations)\b/i.test(bare) || /["`\[](kv|schema_migrations)["`\]]/i.test(bare)) {
38
+ throw new StorageError(code, 'host table is reserved')
39
+ }
40
+ }
@@ -0,0 +1,20 @@
1
+ /** Codes the theme pin emits. A bad file is ignored, not this error. */
2
+
3
+ export const themeCodes = ['theme-invalid'] as const
4
+
5
+ export type ThemeCode = (typeof themeCodes)[number]
6
+
7
+ /** A rejected pin save. The previous file is unchanged. */
8
+ export class ThemeError extends Error {
9
+ readonly code: ThemeCode
10
+
11
+ /**
12
+ * @param code - one of {@link themeCodes}
13
+ * @param message - human text; not the match key
14
+ */
15
+ constructor(code: ThemeCode, message: string) {
16
+ super(message)
17
+ this.name = 'ThemeError'
18
+ this.code = code
19
+ }
20
+ }