mikser-io 9.46.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.46.1",
3
+ "version": "9.47.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -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
- const durableTables = new Set(['mikser_meta'])
347
- for (const value of schemas.values()) {
348
- const { sql, durable } = schemaEntry(value)
349
- if (durable) for (const t of tableNamesFrom(sql)) durableTables.add(t)
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
- await rm(runtime.options.runtimeFolder, { recursive: true })
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