mikser-io 9.46.0 → 9.47.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 +6 -0
- package/package.json +1 -1
- package/src/changeset.js +23 -16
- package/src/database/index.js +82 -7
- package/src/engine.js +22 -2
package/docs/api-reference.md
CHANGED
|
@@ -646,6 +646,12 @@ happened, and losing them would be worse than not attributing them.
|
|
|
646
646
|
rest into unattributed ones, which is what lets it offer undo for the first and
|
|
647
647
|
not the second.
|
|
648
648
|
|
|
649
|
+
The engine records the grouping and takes no position on what is done with it —
|
|
650
|
+
it knows nothing about commits, branches or reverts. Versioning the paths
|
|
651
|
+
together is one use; a snapshot, an audit trail, a draft-then-publish gate or a
|
|
652
|
+
filesystem-level rollback all want the same fact. That is why change sets live
|
|
653
|
+
in core and git does not.
|
|
654
|
+
|
|
649
655
|
## Search
|
|
650
656
|
|
|
651
657
|
`queryEntities` sifts **meta**. `searchEntities` answers the other question —
|
package/package.json
CHANGED
package/src/changeset.js
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
// second are indistinguishable, so removing one removes the other.
|
|
9
9
|
//
|
|
10
10
|
// A change set is the missing grain. The caller names it, the writes
|
|
11
|
-
// accumulate under it, and a consumer
|
|
12
|
-
//
|
|
11
|
+
// accumulate under it, and a consumer can act on exactly those paths.
|
|
12
|
+
//
|
|
13
|
+
// The engine records the grouping and takes no position on what is done with
|
|
14
|
+
// it. Versioning the paths together is one use — mikser-io-git's — but a
|
|
15
|
+
// snapshot, an audit trail, a draft-then-publish gate or a filesystem-level
|
|
16
|
+
// rollback all want the same fact, and none of them is a commit.
|
|
13
17
|
//
|
|
14
18
|
// Deliberately NOT a transaction. Nothing is held back, nothing rolls back on
|
|
15
19
|
// failure, and a half-finished set is a real set containing what actually
|
|
@@ -55,8 +59,11 @@ function store() {
|
|
|
55
59
|
return runtime.changeSets
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
62
|
+
// Relative to the working folder, POSIX-separated.
|
|
63
|
+
//
|
|
64
|
+
// The working folder is the root every consumer already reasons in, and
|
|
65
|
+
// forward slashes are the separator entity ids use — so a path here matches
|
|
66
|
+
// the vocabulary of the rest of the engine rather than the host's.
|
|
60
67
|
function relativeToWorkingFolder(uri) {
|
|
61
68
|
const root = runtime.options?.workingFolder
|
|
62
69
|
if (!root || !uri) return null
|
|
@@ -82,9 +89,9 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
82
89
|
undoOf ??= ambient?.undoOf
|
|
83
90
|
if (!changeSet || !uri) return null
|
|
84
91
|
const rel = relativeToWorkingFolder(uri)
|
|
85
|
-
// Outside the working folder there is nothing a
|
|
86
|
-
// do with the path, and silently keeping an absolute one would
|
|
87
|
-
//
|
|
92
|
+
// Outside the working folder there is nothing a consumer scoped to the
|
|
93
|
+
// project can do with the path, and silently keeping an absolute one would
|
|
94
|
+
// produce a selector that quietly matches nothing.
|
|
88
95
|
if (!rel) return null
|
|
89
96
|
|
|
90
97
|
const sets = store()
|
|
@@ -94,9 +101,9 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
94
101
|
id: changeSet,
|
|
95
102
|
summary: summary ?? null,
|
|
96
103
|
principal: principal ?? null,
|
|
97
|
-
// Set when this change set exists to take another one back, so
|
|
98
|
-
//
|
|
99
|
-
//
|
|
104
|
+
// Set when this change set exists to take another one back, so an
|
|
105
|
+
// undo is itself an ordinary, undoable change rather than a
|
|
106
|
+
// privileged operation that rewrites the record.
|
|
100
107
|
undoOf: undoOf ?? null,
|
|
101
108
|
startedAt: Date.now(),
|
|
102
109
|
paths: new Map(),
|
|
@@ -110,8 +117,8 @@ export function recordChangeSetWrite({ changeSet, summary, principal, uri, opera
|
|
|
110
117
|
return set.id
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
// Every set with writes not yet consumed, oldest first — the order
|
|
114
|
-
//
|
|
120
|
+
// Every set with writes not yet consumed, oldest first — the order the work
|
|
121
|
+
// actually happened in, which is the order a consumer should record it in.
|
|
115
122
|
export function pendingChangeSets() {
|
|
116
123
|
return [...store().values()]
|
|
117
124
|
.filter(set => set.paths.size)
|
|
@@ -129,10 +136,10 @@ export function pendingChangeSets() {
|
|
|
129
136
|
|
|
130
137
|
// Drop sets a consumer has dealt with.
|
|
131
138
|
//
|
|
132
|
-
// Called after the paths
|
|
133
|
-
// between loses the attribution but not the work, which
|
|
134
|
-
// consumer as an unclaimed write. That is the right way
|
|
135
|
-
// a convenience, the bytes are not.
|
|
139
|
+
// Called after a consumer has durably recorded the paths, not after they are
|
|
140
|
+
// written: a crash in between loses the attribution but not the work, which
|
|
141
|
+
// then reaches the consumer as an unclaimed write. That is the right way
|
|
142
|
+
// round — attribution is a convenience, the bytes are not.
|
|
136
143
|
export function clearChangeSets(ids = []) {
|
|
137
144
|
const sets = store()
|
|
138
145
|
for (const id of ids) sets.delete(id)
|
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
|