mikser-io 9.46.1 → 9.48.0
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/docs/api-reference.md +15 -2
- package/package.json +1 -1
- package/src/changeset.js +202 -31
- package/src/database/index.js +82 -7
- package/src/engine.js +22 -2
- package/src/utils.js +3 -4
package/docs/api-reference.md
CHANGED
|
@@ -601,8 +601,21 @@ clearChangeSets(['req-42'])
|
|
|
601
601
|
| `withChangeSet({ changeSet, summary, principal }, fn)` | run `fn` with a set in effect |
|
|
602
602
|
| `currentChangeSet()` | the set in effect, or null |
|
|
603
603
|
| `recordChangeSetWrite({ changeSet, summary, principal, uri, operation, undoOf })` | attach one path to a set |
|
|
604
|
-
| `
|
|
605
|
-
| `
|
|
604
|
+
| `listChangeSets({ limit })` | the log, newest first |
|
|
605
|
+
| `findChangeSet(id)` | resolve one id |
|
|
606
|
+
| `pendingChangeSets()` | sets no consumer has recorded yet, oldest first |
|
|
607
|
+
| `markChangeSetsRecorded(ids, recordedAs)` | mark recorded, and say what as |
|
|
608
|
+
|
|
609
|
+
The log is **durable** and survives a restart: nothing else can reconstruct
|
|
610
|
+
which writes belonged to one request. Not the files, which show the result and
|
|
611
|
+
not the grouping — and not a consumer's own history, which may not exist yet,
|
|
612
|
+
or at all. It keeps the most recent 200 sets.
|
|
613
|
+
|
|
614
|
+
`recordedAs` is what a consumer recorded the set as — a commit sha. Its absence
|
|
615
|
+
is meaningful: the set is real and listable, but there is nothing to revert
|
|
616
|
+
from yet. `mikser_undo` reports that as `not-yet-committed`, which is a
|
|
617
|
+
different answer from `unknown-change-set` and sends a reader somewhere
|
|
618
|
+
different.
|
|
606
619
|
|
|
607
620
|
### Ambient, not threaded
|
|
608
621
|
|
package/package.json
CHANGED
package/src/changeset.js
CHANGED
|
@@ -27,6 +27,43 @@
|
|
|
27
27
|
import path from 'node:path'
|
|
28
28
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
29
29
|
import runtime from './runtime.js'
|
|
30
|
+
import { registerSchema } from './database/index.js'
|
|
31
|
+
|
|
32
|
+
// The log is DURABLE. It records which writes belonged to which request, and
|
|
33
|
+
// nothing can reconstruct that: not the files, which show the result and not
|
|
34
|
+
// the grouping, and not a consumer's own history, which may not exist yet or
|
|
35
|
+
// at all. Losing it turns every id already handed to an agent into a dangling
|
|
36
|
+
// handle.
|
|
37
|
+
registerSchema('change_sets', `
|
|
38
|
+
CREATE TABLE IF NOT EXISTS mikser_change_sets (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
summary TEXT,
|
|
41
|
+
principal TEXT,
|
|
42
|
+
undo_of TEXT,
|
|
43
|
+
created_at INTEGER NOT NULL,
|
|
44
|
+
-- Set when a consumer has durably recorded the set somewhere of its
|
|
45
|
+
-- own — a commit, a snapshot. Until then the set is real and listable
|
|
46
|
+
-- but there is nothing to revert FROM, which is a different answer
|
|
47
|
+
-- from "no such change set".
|
|
48
|
+
recorded_at INTEGER,
|
|
49
|
+
recorded_as TEXT
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_mikser_change_sets_created
|
|
52
|
+
ON mikser_change_sets (created_at DESC);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS mikser_change_set_paths (
|
|
55
|
+
change_set TEXT NOT NULL,
|
|
56
|
+
path TEXT NOT NULL,
|
|
57
|
+
operation TEXT NOT NULL,
|
|
58
|
+
entity_id TEXT,
|
|
59
|
+
PRIMARY KEY (change_set, path)
|
|
60
|
+
);
|
|
61
|
+
`, { durable: true })
|
|
62
|
+
|
|
63
|
+
// How many sets to keep. An undo log is only useful while the change is
|
|
64
|
+
// recent enough to be worth taking back, and unbounded growth in a durable
|
|
65
|
+
// table is a leak nothing cleans up.
|
|
66
|
+
const KEEP_SETS = 200
|
|
30
67
|
|
|
31
68
|
// The change set in effect for the current call.
|
|
32
69
|
//
|
|
@@ -54,9 +91,87 @@ export function currentChangeSet() {
|
|
|
54
91
|
return changeSetContext.getStore() ?? null
|
|
55
92
|
}
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
// The database when there is one, memory when there is not.
|
|
95
|
+
//
|
|
96
|
+
// A write can happen before the engine opens its database — a plugin acting at
|
|
97
|
+
// load time, a unit test — and losing the attribution then would be worse than
|
|
98
|
+
// keeping it somewhere weaker. Both back ends answer the same questions, so no
|
|
99
|
+
// caller has to know which is in play.
|
|
100
|
+
const memory = new Map()
|
|
101
|
+
|
|
102
|
+
function db() {
|
|
103
|
+
// Read off the runtime rather than calling useDatabase(): this module is
|
|
104
|
+
// reached from utils.js, which loads before the database module can be
|
|
105
|
+
// imported without closing a cycle.
|
|
106
|
+
const handle = runtime.database?.handle
|
|
107
|
+
return handle?.prepare ? handle : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function persist(handle, set, rel, operation, entityId) {
|
|
111
|
+
handle.prepare(`
|
|
112
|
+
INSERT INTO mikser_change_sets (id, summary, principal, undo_of, created_at)
|
|
113
|
+
VALUES (@id, @summary, @principal, @undoOf, @createdAt)
|
|
114
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
115
|
+
summary = COALESCE(mikser_change_sets.summary, excluded.summary),
|
|
116
|
+
principal = COALESCE(mikser_change_sets.principal, excluded.principal),
|
|
117
|
+
undo_of = COALESCE(mikser_change_sets.undo_of, excluded.undo_of)
|
|
118
|
+
`).run({
|
|
119
|
+
id: set.id, summary: set.summary, principal: set.principal,
|
|
120
|
+
undoOf: set.undoOf, createdAt: set.startedAt,
|
|
121
|
+
})
|
|
122
|
+
handle.prepare(`
|
|
123
|
+
INSERT INTO mikser_change_set_paths (change_set, path, operation, entity_id)
|
|
124
|
+
VALUES (?, ?, ?, ?)
|
|
125
|
+
ON CONFLICT(change_set, path) DO UPDATE SET operation = excluded.operation
|
|
126
|
+
`).run(set.id, rel, operation, entityId ?? null)
|
|
127
|
+
prune(handle)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function prune(handle) {
|
|
131
|
+
handle.prepare(`
|
|
132
|
+
DELETE FROM mikser_change_set_paths WHERE change_set IN (
|
|
133
|
+
SELECT id FROM mikser_change_sets
|
|
134
|
+
ORDER BY created_at DESC LIMIT -1 OFFSET ?
|
|
135
|
+
)
|
|
136
|
+
`).run(KEEP_SETS)
|
|
137
|
+
handle.prepare(`
|
|
138
|
+
DELETE FROM mikser_change_sets WHERE id IN (
|
|
139
|
+
SELECT id FROM mikser_change_sets ORDER BY created_at DESC LIMIT -1 OFFSET ?
|
|
140
|
+
)
|
|
141
|
+
`).run(KEEP_SETS)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rowsToSets(handle, rows) {
|
|
145
|
+
const stmt = handle.prepare(
|
|
146
|
+
'SELECT path, operation FROM mikser_change_set_paths WHERE change_set = ? ORDER BY path')
|
|
147
|
+
return rows.map(row => {
|
|
148
|
+
const paths = stmt.all(row.id)
|
|
149
|
+
return {
|
|
150
|
+
id: row.id,
|
|
151
|
+
summary: row.summary,
|
|
152
|
+
principal: row.principal,
|
|
153
|
+
undoOf: row.undo_of,
|
|
154
|
+
startedAt: row.created_at,
|
|
155
|
+
recordedAt: row.recorded_at ?? null,
|
|
156
|
+
recordedAs: row.recorded_as ?? null,
|
|
157
|
+
paths: paths.map(p => p.path),
|
|
158
|
+
deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function memorySets(filter = () => true) {
|
|
164
|
+
return [...memory.values()].filter(set => set.paths.size).filter(filter).map(set => ({
|
|
165
|
+
id: set.id,
|
|
166
|
+
summary: set.summary,
|
|
167
|
+
principal: set.principal,
|
|
168
|
+
undoOf: set.undoOf,
|
|
169
|
+
startedAt: set.startedAt,
|
|
170
|
+
recordedAt: set.recordedAt ?? null,
|
|
171
|
+
recordedAs: set.recordedAs ?? null,
|
|
172
|
+
paths: [...set.paths.keys()],
|
|
173
|
+
deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
|
|
174
|
+
}))
|
|
60
175
|
}
|
|
61
176
|
|
|
62
177
|
// Relative to the working folder, POSIX-separated.
|
|
@@ -78,7 +193,9 @@ function relativeToWorkingFolder(uri) {
|
|
|
78
193
|
// nothing downstream will ever know it as well — a reader choosing what to
|
|
79
194
|
// undo needs "changed the hero text on the devices page", not a file count.
|
|
80
195
|
// First one wins: later writes in the same set are the same request.
|
|
81
|
-
export function recordChangeSetWrite({
|
|
196
|
+
export function recordChangeSetWrite({
|
|
197
|
+
changeSet, summary, principal, uri, operation = 'write', undoOf, entityId,
|
|
198
|
+
} = {}) {
|
|
82
199
|
// An explicit id always wins; otherwise take whatever set is in effect.
|
|
83
200
|
// A write with neither stays unclaimed, which is the correct outcome for
|
|
84
201
|
// an API or human write that no request owns.
|
|
@@ -94,8 +211,7 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
94
211
|
// produce a selector that quietly matches nothing.
|
|
95
212
|
if (!rel) return null
|
|
96
213
|
|
|
97
|
-
|
|
98
|
-
let set = sets.get(changeSet)
|
|
214
|
+
let set = memory.get(changeSet)
|
|
99
215
|
if (!set) {
|
|
100
216
|
set = {
|
|
101
217
|
id: changeSet,
|
|
@@ -108,43 +224,98 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
108
224
|
startedAt: Date.now(),
|
|
109
225
|
paths: new Map(),
|
|
110
226
|
}
|
|
111
|
-
|
|
227
|
+
memory.set(changeSet, set)
|
|
112
228
|
}
|
|
113
229
|
if (!set.summary && summary) set.summary = summary
|
|
114
230
|
if (!set.principal && principal) set.principal = principal
|
|
115
231
|
if (!set.undoOf && undoOf) set.undoOf = undoOf
|
|
116
232
|
set.paths.set(rel, operation)
|
|
233
|
+
|
|
234
|
+
const handle = db()
|
|
235
|
+
if (handle) {
|
|
236
|
+
try { persist(handle, set, rel, operation, entityId) } catch { /* memory still holds it */ }
|
|
237
|
+
}
|
|
117
238
|
return set.id
|
|
118
239
|
}
|
|
119
240
|
|
|
120
|
-
//
|
|
121
|
-
// actually happened in, which is the order
|
|
241
|
+
// Sets a consumer has not yet durably recorded, oldest first — the order the
|
|
242
|
+
// work actually happened in, which is the order it should be recorded in.
|
|
122
243
|
export function pendingChangeSets() {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
|
|
134
|
-
}))
|
|
244
|
+
const handle = db()
|
|
245
|
+
if (handle) {
|
|
246
|
+
try {
|
|
247
|
+
const rows = handle.prepare(`
|
|
248
|
+
SELECT * FROM mikser_change_sets WHERE recorded_at IS NULL ORDER BY created_at ASC
|
|
249
|
+
`).all()
|
|
250
|
+
return rowsToSets(handle, rows).filter(set => set.paths.length)
|
|
251
|
+
} catch { /* fall through to memory */ }
|
|
252
|
+
}
|
|
253
|
+
return memorySets(set => !set.recordedAt).sort((a, b) => a.startedAt - b.startedAt)
|
|
135
254
|
}
|
|
136
255
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
256
|
+
// The log an agent reads: every set, newest first, whether or not a consumer
|
|
257
|
+
// has recorded it anywhere.
|
|
258
|
+
export function listChangeSets({ limit = 20 } = {}) {
|
|
259
|
+
const handle = db()
|
|
260
|
+
if (handle) {
|
|
261
|
+
try {
|
|
262
|
+
const rows = handle.prepare(`
|
|
263
|
+
SELECT * FROM mikser_change_sets ORDER BY created_at DESC LIMIT ?
|
|
264
|
+
`).all(Math.max(1, Math.min(limit, 200)))
|
|
265
|
+
return rowsToSets(handle, rows)
|
|
266
|
+
} catch { /* fall through to memory */ }
|
|
267
|
+
}
|
|
268
|
+
return memorySets().sort((a, b) => b.startedAt - a.startedAt).slice(0, limit)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function findChangeSet(id) {
|
|
272
|
+
if (!id) return null
|
|
273
|
+
const handle = db()
|
|
274
|
+
if (handle) {
|
|
275
|
+
try {
|
|
276
|
+
const row = handle.prepare('SELECT * FROM mikser_change_sets WHERE id = ?').get(id)
|
|
277
|
+
return row ? rowsToSets(handle, [row])[0] : null
|
|
278
|
+
} catch { /* fall through to memory */ }
|
|
279
|
+
}
|
|
280
|
+
return memorySets(set => set.id === id)[0] ?? null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Mark sets a consumer has durably recorded, and say what it recorded them AS
|
|
284
|
+
// — a commit sha, a snapshot id. That reference is what an undo reverts from,
|
|
285
|
+
// and its absence is why "recorded but not yet committed" is a different
|
|
286
|
+
// answer from "no such change set".
|
|
287
|
+
export function markChangeSetsRecorded(ids = [], recordedAs = null) {
|
|
288
|
+
const at = Date.now()
|
|
289
|
+
for (const id of ids) {
|
|
290
|
+
const set = memory.get(id)
|
|
291
|
+
if (set) { set.recordedAt = at; set.recordedAs = recordedAs }
|
|
292
|
+
}
|
|
293
|
+
const handle = db()
|
|
294
|
+
if (!handle) return
|
|
295
|
+
try {
|
|
296
|
+
const stmt = handle.prepare(
|
|
297
|
+
'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ? WHERE id = ?')
|
|
298
|
+
for (const id of ids) stmt.run(at, recordedAs, id)
|
|
299
|
+
} catch { /* memory still holds it */ }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Kept as the name consumers already call. Marking recorded is what "done
|
|
303
|
+
// with it" means now — the set stays in the log so it can still be undone.
|
|
304
|
+
export function clearChangeSets(ids = [], recordedAs = null) {
|
|
305
|
+
markChangeSetsRecorded(ids, recordedAs)
|
|
146
306
|
}
|
|
147
307
|
|
|
148
308
|
export function forgetAllChangeSets() {
|
|
149
|
-
|
|
309
|
+
memory.clear()
|
|
310
|
+
const handle = db()
|
|
311
|
+
if (!handle) return
|
|
312
|
+
try {
|
|
313
|
+
handle.exec('DELETE FROM mikser_change_set_paths; DELETE FROM mikser_change_sets;')
|
|
314
|
+
} catch { /* nothing to clear */ }
|
|
150
315
|
}
|
|
316
|
+
|
|
317
|
+
// Published on the runtime so the write primitives in utils.js can record
|
|
318
|
+
// without importing this module. utils.js loads early — before the database
|
|
319
|
+
// module can be imported here without closing a cycle — and an import purely
|
|
320
|
+
// to reach one function is what would close it.
|
|
321
|
+
runtime.recordChangeSetWrite = recordChangeSetWrite
|
package/src/database/index.js
CHANGED
|
@@ -123,6 +123,57 @@ function tableNamesFrom(sqlScript) {
|
|
|
123
123
|
return names
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// What the given schema map says about each table it names: durable, or not.
|
|
127
|
+
//
|
|
128
|
+
// Takes the map rather than reading the module registry: createSqliteDatabase
|
|
129
|
+
// receives its schemas as an option, and a caller that passes its own — every
|
|
130
|
+
// test, and any embedder — has a registry the module never sees.
|
|
131
|
+
function declaredTables(schemas) {
|
|
132
|
+
const durable = new Set()
|
|
133
|
+
const transient = new Set()
|
|
134
|
+
for (const value of schemas?.values?.() ?? []) {
|
|
135
|
+
const entry = schemaEntry(value)
|
|
136
|
+
for (const t of tableNamesFrom(entry.sql)) (entry.durable ? durable : transient).add(t)
|
|
137
|
+
}
|
|
138
|
+
return { durable, transient }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The tables a wipe must keep.
|
|
142
|
+
//
|
|
143
|
+
// A declaration by THIS process wins, in both directions — marking a table
|
|
144
|
+
// non-durable has to be able to take effect, or the flag is one-way forever
|
|
145
|
+
// and a plugin can never undo it. The record only answers for tables this
|
|
146
|
+
// process says nothing about, which is exactly the case it exists for: a
|
|
147
|
+
// config that never loaded the plugin owning the data.
|
|
148
|
+
function durableSet(schemas, handle) {
|
|
149
|
+
const { durable, transient } = declaredTables(schemas)
|
|
150
|
+
const keep = new Set(durable)
|
|
151
|
+
for (const t of recordedDurableTables(handle)) {
|
|
152
|
+
if (!transient.has(t)) keep.add(t)
|
|
153
|
+
}
|
|
154
|
+
return keep
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Table names any earlier open recorded as durable. Read defensively: a
|
|
158
|
+
// database written before this was introduced simply has no row.
|
|
159
|
+
function recordedDurableTables(handle) {
|
|
160
|
+
try {
|
|
161
|
+
const row = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?').get('durable_tables')
|
|
162
|
+
const parsed = row?.value ? JSON.parse(row.value) : []
|
|
163
|
+
return Array.isArray(parsed) ? parsed.filter(t => typeof t === 'string') : []
|
|
164
|
+
} catch {
|
|
165
|
+
return []
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Write back what the next process should assume. Already reconciled by
|
|
170
|
+
// durableSet, so a table this process demoted is genuinely dropped.
|
|
171
|
+
function rememberDurableTables(handle, stmtStamp, keep) {
|
|
172
|
+
try {
|
|
173
|
+
stmtStamp.run('durable_tables', JSON.stringify([...keep].sort()))
|
|
174
|
+
} catch { /* a read-only run cannot record, and does not need to */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
126
177
|
export function registerSchema(name, sqlScript, { durable = false } = {}) {
|
|
127
178
|
if (typeof name !== 'string' || !name.length) {
|
|
128
179
|
throw new Error('registerSchema: name must be a non-empty string')
|
|
@@ -218,6 +269,11 @@ export function useDatabase() {
|
|
|
218
269
|
// onLoaded below.
|
|
219
270
|
export function createSqliteDatabase({
|
|
220
271
|
runtimeFolder, version, logger, config = {}, schemas, provisioners: provisionersArg,
|
|
272
|
+
// Clear the cache on this open even though nothing changed — what
|
|
273
|
+
// `--clear` asks for. Routed through the same wipe as a version change so
|
|
274
|
+
// it honours `durable`, rather than removing the file and taking the
|
|
275
|
+
// credentials with it.
|
|
276
|
+
forceWipe = false,
|
|
221
277
|
}) {
|
|
222
278
|
// Tests inject their own provisioners; the runtime path falls
|
|
223
279
|
// through to the module-level `provisioners` array that plugins
|
|
@@ -300,7 +356,7 @@ export function createSqliteDatabase({
|
|
|
300
356
|
const reportOnly = isReportOnlyRun()
|
|
301
357
|
|
|
302
358
|
let upgradedFromVersion = null
|
|
303
|
-
if (reportOnly && ((recorded && recorded !== version) || configChanged)) {
|
|
359
|
+
if (reportOnly && !forceWipe && ((recorded && recorded !== version) || configChanged)) {
|
|
304
360
|
logger?.warn(
|
|
305
361
|
'The cache is stale (%s changed since it was written) and this is a read-only run, '
|
|
306
362
|
+ 'so it was NOT wiped — the answer below describes the last build, which may not '
|
|
@@ -314,7 +370,7 @@ export function createSqliteDatabase({
|
|
|
314
370
|
runtime.options.config,
|
|
315
371
|
)
|
|
316
372
|
}
|
|
317
|
-
if (!reportOnly && ((recorded && recorded !== version) || configChanged)) {
|
|
373
|
+
if (!reportOnly && (forceWipe || (recorded && recorded !== version) || configChanged)) {
|
|
318
374
|
// Schema mismatch on upgrade or downgrade. Per ADR-0002 the
|
|
319
375
|
// files on disk are the source of truth and this database
|
|
320
376
|
// is a derived cache, so the right behavior is to wipe the
|
|
@@ -330,6 +386,8 @@ export function createSqliteDatabase({
|
|
|
330
386
|
'Database schema mismatch: stored=%s, current=%s. Wiping the cache and rebuilding from sources (files are the source of truth — no source data is affected).',
|
|
331
387
|
recorded, version,
|
|
332
388
|
)
|
|
389
|
+
} else if (forceWipe) {
|
|
390
|
+
logger?.info('Clearing the cache and rebuilding from sources (durable data is kept).')
|
|
333
391
|
}
|
|
334
392
|
// What must survive. A wipe exists because the cache is
|
|
335
393
|
// DERIVED — ADR-0002, the files are the source of truth, so
|
|
@@ -343,11 +401,10 @@ export function createSqliteDatabase({
|
|
|
343
401
|
// So a schema registered `durable` is kept and everything else
|
|
344
402
|
// goes. mikser_meta stays too — its stamps are rewritten a few
|
|
345
403
|
// lines down, and dropping it would only mean recreating it.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
404
|
+
// What this process declares, plus what any earlier process
|
|
405
|
+
// recorded. The second half is what makes a wipe safe from a
|
|
406
|
+
// config that does not load the plugin owning the data.
|
|
407
|
+
const durableTables = new Set(['mikser_meta', ...durableSet(schemas, handle)])
|
|
351
408
|
|
|
352
409
|
if (durableTables.size > 1 && dbPath !== ':memory:') {
|
|
353
410
|
// Drop table by table rather than unlinking, so the durable
|
|
@@ -391,6 +448,21 @@ export function createSqliteDatabase({
|
|
|
391
448
|
stmtStamp.run('schema_version', version)
|
|
392
449
|
if (currentConfig) stmtStamp.run('config_checksum', currentConfig)
|
|
393
450
|
|
|
451
|
+
// Remember which tables are durable, IN the database.
|
|
452
|
+
//
|
|
453
|
+
// Durability was otherwise a property of what happened to be loaded:
|
|
454
|
+
// the wipe asked the schema registry, so a run whose config does not
|
|
455
|
+
// include the plugin that owns a durable table saw no durable tables
|
|
456
|
+
// at all, took the unlink branch, and destroyed it. A dev config
|
|
457
|
+
// without the auth plugin, opening the same working folder, signed
|
|
458
|
+
// every connected agent out — and nothing in that run mentioned auth.
|
|
459
|
+
//
|
|
460
|
+
// Recorded here and unioned at wipe time, so the answer survives a
|
|
461
|
+
// process that has never heard of the plugin. Additive: a table stays
|
|
462
|
+
// on the list once recorded, because forgetting one costs data nothing
|
|
463
|
+
// can reproduce while keeping a stale name costs an empty table.
|
|
464
|
+
rememberDurableTables(handle, stmtStamp, durableSet(schemas, handle))
|
|
465
|
+
|
|
394
466
|
// Build provisioning context. firstRun is true when the file
|
|
395
467
|
// didn't exist before this open OR when the schema mismatch
|
|
396
468
|
// wiped it (and the previous open's stamp is gone) — both shapes
|
|
@@ -499,6 +571,9 @@ onLoaded(async () => {
|
|
|
499
571
|
logger,
|
|
500
572
|
config,
|
|
501
573
|
schemas,
|
|
574
|
+
// `--clear` asks for the cache to go. It is honoured here rather than
|
|
575
|
+
// by deleting the file, so the tables registered `durable` survive it.
|
|
576
|
+
forceWipe: Boolean(runtime.options.clear),
|
|
502
577
|
})
|
|
503
578
|
|
|
504
579
|
db.open()
|
package/src/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { Command } from 'commander'
|
|
3
|
-
import { rm, lstat, realpath, mkdir, unlink } from 'fs/promises'
|
|
3
|
+
import { rm, lstat, realpath, mkdir, unlink, readdir } from 'fs/promises'
|
|
4
4
|
import { existsSync } from 'fs'
|
|
5
5
|
import _ from 'lodash'
|
|
6
6
|
import Piscina from 'piscina'
|
|
@@ -186,11 +186,31 @@ export async function setup(options) {
|
|
|
186
186
|
// and never appears in outputFolder.
|
|
187
187
|
runtime.options.previewFolder = path.join(runtime.options.runtimeFolder, 'preview')
|
|
188
188
|
|
|
189
|
+
// The sqlite file and the WAL sidecars it leaves beside it.
|
|
190
|
+
const isDatabaseArtifact = (name) =>
|
|
191
|
+
name.endsWith('.sqlite') || name.endsWith('.sqlite-wal') || name.endsWith('.sqlite-shm')
|
|
192
|
+
|
|
189
193
|
if (runtime.options.clear) {
|
|
190
194
|
try {
|
|
191
195
|
runtime.engine.logger.info('Clearing folders')
|
|
192
196
|
await rm(runtime.options.outputFolder, { recursive: true })
|
|
193
|
-
|
|
197
|
+
// Everything in the runtime folder EXCEPT the database.
|
|
198
|
+
//
|
|
199
|
+
// Removing the folder wholesale took the database with it, and
|
|
200
|
+
// with it every table registered `durable` — an OAuth client
|
|
201
|
+
// registration, a refresh token, a form submission: data no
|
|
202
|
+
// file can reproduce, which is the whole reason that flag
|
|
203
|
+
// exists. `--clear` promising a rebuild and delivering a
|
|
204
|
+
// sign-out is the same bug the durable flag was added to fix,
|
|
205
|
+
// reached by a different route.
|
|
206
|
+
//
|
|
207
|
+
// The database is cleared too, but through its own wipe, which
|
|
208
|
+
// drops the derived tables and keeps the durable ones.
|
|
209
|
+
for (const entry of await readdir(runtime.options.runtimeFolder, { withFileTypes: true })
|
|
210
|
+
.catch(() => [])) {
|
|
211
|
+
if (isDatabaseArtifact(entry.name)) continue
|
|
212
|
+
await rm(path.join(runtime.options.runtimeFolder, entry.name), { recursive: true, force: true })
|
|
213
|
+
}
|
|
194
214
|
} catch (err) {
|
|
195
215
|
if (err.code != 'ENOENT')
|
|
196
216
|
throw err
|
package/src/utils.js
CHANGED
|
@@ -11,7 +11,6 @@ import yaml from 'yaml'
|
|
|
11
11
|
import { contentType } from 'mime-types'
|
|
12
12
|
import runtime from './runtime.js'
|
|
13
13
|
import { trackedInfo, untrack, recordReads } from './track.js'
|
|
14
|
-
import { recordChangeSetWrite } from './changeset.js'
|
|
15
14
|
|
|
16
15
|
// Stable content fingerprint for entities — used by manifest snapshots,
|
|
17
16
|
// engine mutation tracking, and the layouts dispatcher's hash-aware
|
|
@@ -794,7 +793,7 @@ export async function writeEntity(entity, patch = {}) {
|
|
|
794
793
|
// The other file-writing primitive. A rename cascade rewrites every
|
|
795
794
|
// referring file through here, which is the largest fan-out any single
|
|
796
795
|
// request has and therefore the one most worth being able to take back.
|
|
797
|
-
recordChangeSetWrite({ uri: entity.uri })
|
|
796
|
+
runtime.recordChangeSetWrite?.({ uri: entity.uri })
|
|
798
797
|
|
|
799
798
|
return entity.uri
|
|
800
799
|
}
|
|
@@ -1114,14 +1113,14 @@ export function useCollection(runtime, name) {
|
|
|
1114
1113
|
// of change sets still produces undoable work — the alternative is
|
|
1115
1114
|
// every writer remembering, and the one that forgets is the one
|
|
1116
1115
|
// whose edit cannot be taken back.
|
|
1117
|
-
recordChangeSetWrite({ uri })
|
|
1116
|
+
runtime.recordChangeSetWrite?.({ uri })
|
|
1118
1117
|
return uri
|
|
1119
1118
|
},
|
|
1120
1119
|
|
|
1121
1120
|
async remove(relativePath) {
|
|
1122
1121
|
const uri = resolveWithin(relativePath)
|
|
1123
1122
|
await unlink(uri)
|
|
1124
|
-
recordChangeSetWrite({ uri, operation: 'delete' })
|
|
1123
|
+
runtime.recordChangeSetWrite?.({ uri, operation: 'delete' })
|
|
1125
1124
|
},
|
|
1126
1125
|
}
|
|
1127
1126
|
}
|