mikser-io 10.2.0 → 10.3.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": "10.2.0",
3
+ "version": "10.3.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -44,6 +44,7 @@
44
44
 
45
45
  import path from 'node:path'
46
46
  import { mkdirSync, unlinkSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
47
+ import { createHash } from 'node:crypto'
47
48
  import Database from 'better-sqlite3'
48
49
  import runtime from '../runtime.js'
49
50
  import { isReportOnlyRun } from '../tools.js'
@@ -232,6 +233,43 @@ export function useDatabase() {
232
233
  return db
233
234
  }
234
235
 
236
+ // What the cache's SHAPE is, as a value that changes only when the cache
237
+ // stops being reusable.
238
+ //
239
+ // It used to be the package version, so every release wiped every
240
+ // deployment's cache and rebuilt from cold — a patch that touched a README
241
+ // discarded 14k entities and re-rendered a site. The version was standing in
242
+ // for "something might have changed", which is true of every release and
243
+ // therefore says nothing.
244
+ //
245
+ // Two things actually invalidate a cache, and both are in here:
246
+ //
247
+ // the SQL — a column, index or table that moved. Hashed from the
248
+ // registered scripts themselves, so it cannot drift from what
249
+ // is applied: the fingerprint and the schema come from one
250
+ // string.
251
+ // the SHAPE — what the rows MEAN, when the SQL is untouched. A change to
252
+ // how inputHash is computed, to the edge kinds in a
253
+ // refClosure, to what a destination is relative to. No parser
254
+ // can see these, so they are a number someone bumps.
255
+ //
256
+ // BUMP DERIVED_SHAPE when a release changes the meaning of anything already
257
+ // stored. Getting that wrong is a silent stale cache, which is the failure
258
+ // this whole subsystem keeps producing — so when in doubt, bump. A needless
259
+ // wipe costs one cold rebuild; a missed one costs a site serving wrong
260
+ // output with every signal green.
261
+ const DERIVED_SHAPE = 1
262
+
263
+ // `<shape>:<sql-hash>` rather than a bare hash: the stored value is read by
264
+ // a human when a wipe happens, and the two halves say WHICH moved.
265
+ function cacheFingerprint(registered) {
266
+ const sql = [...registered.entries()]
267
+ .map(([name, value]) => `${name}\n${schemaEntry(value).sql}`)
268
+ .sort()
269
+ .join('\n')
270
+ return `${DERIVED_SHAPE}:${createHash('sha1').update(sql).digest('hex').slice(0, 12)}`
271
+ }
272
+
235
273
  // Build a sqlite-backed database handle. Exported so tests can exercise
236
274
  // the lifecycle and transaction semantics in isolation (without driving
237
275
  // the full onLoaded chain). The runtime path uses this internally from
@@ -296,6 +334,11 @@ export function createSqliteDatabase({
296
334
 
297
335
  const stmtMeta = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
298
336
  const recorded = stmtMeta.get('schema_version')?.value
337
+ // The identity of the cache's shape, not of the release that wrote
338
+ // it. `version` is still carried — into `built_by_version` at stamp
339
+ // time — because "which mikser built this" is worth knowing when
340
+ // reading a cache; it is simply not a reason to throw one away.
341
+ const fingerprint = cacheFingerprint(schemas)
299
342
 
300
343
  // A config change invalidates the cache for the same reason a version
301
344
  // change does: the derived state was computed under different rules.
@@ -327,13 +370,13 @@ export function createSqliteDatabase({
327
370
  const reportOnly = isReportOnlyRun()
328
371
 
329
372
  let upgradedFromVersion = null
330
- if (reportOnly && !forceWipe && ((recorded && recorded !== version) || configChanged)) {
373
+ if (reportOnly && !forceWipe && ((recorded && recorded !== fingerprint) || configChanged)) {
331
374
  logger?.warn(
332
375
  'The cache is stale (%s changed since it was written) and this is a read-only run, '
333
376
  + 'so it was NOT wiped — the answer below describes the last build, which may not '
334
377
  + 'match your sources. Run a build to refresh it.',
335
378
  configChanged ? 'config' : 'schema version')
336
- } else if (configChanged && !(recorded && recorded !== version)) {
379
+ } else if (configChanged && !(recorded && recorded !== fingerprint)) {
337
380
  logger?.warn(
338
381
  'Config changed since the last run. Wiping the cache and rebuilding from sources '
339
382
  + '(files are the source of truth — no source data is affected). The stamp covers %s and '
@@ -341,7 +384,7 @@ export function createSqliteDatabase({
341
384
  runtime.options.config,
342
385
  )
343
386
  }
344
- if (!reportOnly && (forceWipe || (recorded && recorded !== version) || configChanged)) {
387
+ if (!reportOnly && (forceWipe || (recorded && recorded !== fingerprint) || configChanged)) {
345
388
  // Schema mismatch on upgrade or downgrade. Per ADR-0002 the
346
389
  // files on disk are the source of truth and this database
347
390
  // is a derived cache, so the right behavior is to wipe the
@@ -352,10 +395,27 @@ export function createSqliteDatabase({
352
395
  // expect a cold-start rebuild on this run. No data loss
353
396
  // beyond the cache itself; everything in mikser.sqlite is
354
397
  // recoverable from the working folder.
355
- if (recorded && recorded !== version) {
398
+ if (recorded && recorded !== fingerprint) {
399
+ // Names WHICH half moved, because the two mean different
400
+ // things to whoever is reading. A shape bump is a deliberate
401
+ // decision someone made in this release; a SQL change is a
402
+ // table that moved. "stored=10.0.1, current=10.1.0" said
403
+ // neither, and could not — it only ever meant "a release
404
+ // happened".
405
+ const [storedShape, storedSql] = String(recorded).split(':')
406
+ const [shape, sql] = fingerprint.split(':')
407
+ const cause = storedSql === undefined
408
+ ? 'the cache predates schema fingerprints'
409
+ : storedShape !== shape && storedSql !== sql
410
+ ? 'the schema tables AND the meaning of what is stored both changed'
411
+ : storedShape !== shape
412
+ ? 'this release changed the meaning of what is already stored'
413
+ : 'the schema tables changed'
356
414
  logger?.warn(
357
- '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).',
358
- recorded, version,
415
+ 'Cache shape changed — %s (stored=%s, current=%s, this is mikser %s). Wiping the cache '
416
+ + 'and rebuilding from sources (files are the source of truth — no source data is '
417
+ + 'affected). A release that changes neither reuses the cache.',
418
+ cause, recorded, fingerprint, version,
359
419
  )
360
420
  } else if (forceWipe) {
361
421
  logger?.info('Clearing the cache and rebuilding from sources.')
@@ -365,8 +425,8 @@ export function createSqliteDatabase({
365
425
  // the same output whether the version moved, the config moved, or
366
426
  // someone passed --clear.
367
427
  reportWipe(
368
- recorded && recorded !== version ? 'version' : forceWipe ? 'clear' : 'config',
369
- recorded && recorded !== version ? { from: recorded, to: version } : {},
428
+ recorded && recorded !== fingerprint ? 'version' : forceWipe ? 'clear' : 'config',
429
+ recorded && recorded !== fingerprint ? { from: recorded, to: fingerprint } : {},
370
430
  )
371
431
 
372
432
  // Unlink, rather than dropping table by table.
@@ -412,7 +472,7 @@ export function createSqliteDatabase({
412
472
  // no stamp, so the next start sees a mismatch and rebuilds properly.
413
473
  // config_checksum goes with it for the same reason — a config change
414
474
  // that half-applied must not look applied.
415
- pendingStamp = { version, config: currentConfig }
475
+ pendingStamp = { fingerprint, version, config: currentConfig }
416
476
 
417
477
  // Build provisioning context. firstRun is true when the file
418
478
  // didn't exist before this open OR when the schema mismatch
@@ -528,7 +588,11 @@ export function createSqliteDatabase({
528
588
  commitStamp() {
529
589
  if (!pendingStamp || !handle) return false
530
590
  const stamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
531
- stamp.run('schema_version', pendingStamp.version)
591
+ stamp.run('schema_version', pendingStamp.fingerprint)
592
+ // Which release wrote this cache. Recorded because it is the
593
+ // first thing a person wants when reading one, and deliberately
594
+ // NOT compared against anything — that was the bug.
595
+ stamp.run('built_by_version', pendingStamp.version)
532
596
  if (pendingStamp.config) stamp.run('config_checksum', pendingStamp.config)
533
597
  pendingStamp = null
534
598
  runtime.options.cacheRebuildInterrupted = false