@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.
- package/README.md +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- 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
|
+
}
|