mikser-io 11.1.0 → 11.2.1

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": "11.1.0",
3
+ "version": "11.2.1",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/changeset.js CHANGED
@@ -84,6 +84,27 @@ registerMigrations('change-sets', [
84
84
  })
85
85
  },
86
86
  },
87
+ {
88
+ // What the write actually put there.
89
+ //
90
+ // Without it, a set that produced no commit is ambiguous in a way that
91
+ // matters: the file on disk being identical to the last commit means
92
+ // EITHER the write matched what was already committed, or something
93
+ // replaced it before the commit pass ran. The first is a no-op; the
94
+ // second is lost work. From disk alone they are the same picture, and
95
+ // a real one was misread as the harmless case on a live site.
96
+ //
97
+ // Recorded at write time, so a consumer can ask afterwards whether
98
+ // what it wrote is still there. Nullable: a writer that does not know
99
+ // its own bytes records nothing, and a consumer must then say it
100
+ // cannot tell rather than assume either way.
101
+ name: '002-write-checksums',
102
+ up: async (knex) => {
103
+ await knex.schema.alterTable('mikser_change_set_paths', (table) => {
104
+ table.string('checksum')
105
+ })
106
+ },
107
+ },
87
108
  ])
88
109
 
89
110
  // How many sets to keep. An undo log is only useful while the change is
@@ -197,7 +218,7 @@ async function settled() {
197
218
  try { await queue } catch { /* reported at the point of failure */ }
198
219
  }
199
220
 
200
- async function persist(knex, set, rel, operation, entityId) {
221
+ async function persist(knex, set, rel, operation, entityId, checksum) {
201
222
  // COALESCE rather than a plain merge: the first write's summary is the
202
223
  // request's own description of itself, and a later write in the same set
203
224
  // must not overwrite it with its own. `excluded` is the pseudo-table on
@@ -220,9 +241,17 @@ async function persist(knex, set, rel, operation, entityId) {
220
241
  })
221
242
 
222
243
  await knex('mikser_change_set_paths')
223
- .insert({ change_set: set.id, path: rel, operation, entity_id: entityId ?? null })
244
+ .insert({ change_set: set.id, path: rel, operation, entity_id: entityId ?? null,
245
+ checksum: checksum ?? null })
224
246
  .onConflict(['change_set', 'path'])
225
- .merge({ operation: knex.raw('excluded.operation') })
247
+ // The LATEST write to a path is the one that says what should be there
248
+ // now, so its checksum replaces the earlier one — but only when it has
249
+ // one, or a writer that knows its bytes is overwritten by one that
250
+ // does not and the path silently becomes unverifiable.
251
+ .merge({
252
+ operation: knex.raw('excluded.operation'),
253
+ checksum: knex.raw('COALESCE(excluded.checksum, mikser_change_set_paths.checksum)'),
254
+ })
226
255
 
227
256
  await prune(knex)
228
257
  }
@@ -267,7 +296,7 @@ async function rowsToSets(knex, rows) {
267
296
  const paths = await knex('mikser_change_set_paths')
268
297
  .whereIn('change_set', rows.map(row => row.id))
269
298
  .orderBy('path')
270
- .select('change_set', 'path', 'operation')
299
+ .select('change_set', 'path', 'operation', 'checksum')
271
300
 
272
301
  const bySet = new Map()
273
302
  for (const row of paths) {
@@ -292,6 +321,10 @@ async function rowsToSets(knex, rows) {
292
321
  outcome: row.outcome ?? null,
293
322
  paths: mine.map(p => p.path),
294
323
  deletions: mine.filter(p => p.operation === 'delete').map(p => p.path),
324
+ // path -> the checksum of what was written there, for the paths
325
+ // whose writer knew. Absent means unverifiable, not unchanged.
326
+ checksums: Object.fromEntries(
327
+ mine.filter(p => p.checksum).map(p => [p.path, p.checksum])),
295
328
  }
296
329
  })
297
330
  }
@@ -312,6 +345,9 @@ function memorySets(filter = () => true) {
312
345
  outcome: set.outcome ?? null,
313
346
  paths: [...set.paths.keys()],
314
347
  deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
348
+ // Same shape as the durable path, so a consumer reads one contract
349
+ // whether or not a database is configured.
350
+ checksums: Object.fromEntries(set.checksums ?? []),
315
351
  }))
316
352
  }
317
353
 
@@ -336,6 +372,11 @@ function relativeToWorkingFolder(uri) {
336
372
  // First one wins: later writes in the same set are the same request.
337
373
  export function recordChangeSetWrite({
338
374
  changeSet, summary, principal, uri, operation = 'write', undoOf, entityId,
375
+ // The checksum of the bytes just written, when the caller knows them.
376
+ // Optional on purpose: a writer that does not have the content in hand
377
+ // records nothing rather than a wrong value, and a consumer reading the
378
+ // log must treat its absence as "cannot tell".
379
+ checksum,
339
380
  } = {}) {
340
381
  // An explicit id always wins; otherwise take whatever set is in effect.
341
382
  // A write with neither stays unclaimed, which is the correct outcome for
@@ -373,6 +414,7 @@ export function recordChangeSetWrite({
373
414
  if (!set.undoOf && undoOf) set.undoOf = undoOf
374
415
  set.updatedAt = Date.now()
375
416
  set.paths.set(rel, operation)
417
+ if (checksum) (set.checksums ??= new Map()).set(rel, checksum)
376
418
 
377
419
  if (db()) {
378
420
  // Snapshotted, because the queued write runs later and the set keeps
@@ -381,7 +423,7 @@ export function recordChangeSetWrite({
381
423
  const snapshot = { ...set }
382
424
  enqueue(async () => {
383
425
  const knex = db()
384
- if (knex) await persist(knex, snapshot, rel, operation, entityId)
426
+ if (knex) await persist(knex, snapshot, rel, operation, entityId, checksum)
385
427
  })
386
428
  }
387
429
  return set.id
package/src/render.js CHANGED
@@ -544,6 +544,25 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
544
544
  * @returns {Promise<{output, entity}>}
545
545
  */
546
546
  async function render(entity, { timeout = defaultTimeout, catalog = true, save = true } = {}) {
547
+ // Was this row already in the catalog BEFORE the render? `catalog:
548
+ // false` means "do not leave a row behind", and that is only the
549
+ // render's to decide for a row the render created. Asked here, before
550
+ // the render puts one there.
551
+ //
552
+ // Without it, `catalog: false` deleted whatever it was handed: a
553
+ // preview of an EXISTING entity pruned the real row, so the entity
554
+ // vanished from the site while its file sat on disk — no change set,
555
+ // no cycle, and the removal logged only at debug. It cost a day to
556
+ // find, in a form that rendered once and then answered "Entity not
557
+ // found".
558
+ // Imported HERE, not at the top: catalog.js reaches back into this
559
+ // module, and a static import makes that cycle load-bearing at
560
+ // module-evaluation time — it surfaced as "Cannot access 'schemas'
561
+ // before initialization" in three unrelated test files.
562
+ const preexisting = catalog === false && entity?.id
563
+ ? Boolean(await (await import('./catalog.js')).findById(entity.id))
564
+ : false
565
+
547
566
  const result = await new Promise((resolve, reject) => {
548
567
  const correlationId = randomUUID()
549
568
  // Engine-set fields live under entity.options. The caller's
@@ -585,7 +604,14 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
585
604
  // and wrong for one that did. `catalog: false, save: true`
586
605
  // therefore keeps its row, and says so rather than dropping
587
606
  // the output on the floor.
588
- if (save === false) {
607
+ if (preexisting) {
608
+ // Someone else's row. It was here before this render and is
609
+ // not this render's to remove.
610
+ useLogger()?.debug(
611
+ 'render: catalog:false ignored for %s — the entity was already in the catalog',
612
+ result.entity.id,
613
+ )
614
+ } else if (save === false) {
589
615
  await runtime.delete(result.entity)
590
616
  } else {
591
617
  useLogger()?.warn(
@@ -3,6 +3,7 @@ import path from 'path'
3
3
  import runtime from '../runtime.js'
4
4
  import yaml from 'yaml'
5
5
 
6
+ import { checksumOf } from './hash.js'
6
7
  import { isRefKey } from './refs.js'
7
8
  import { contentType } from 'mime-types'
8
9
  import { minimatch } from 'minimatch'
@@ -449,7 +450,11 @@ export function useCollection(runtime, name) {
449
450
  // of change sets still produces undoable work — the alternative is
450
451
  // every writer remembering, and the one that forgets is the one
451
452
  // whose edit cannot be taken back.
452
- runtime.recordChangeSetWrite?.({ uri })
453
+ // The checksum goes with it, from the bytes just written. Same
454
+ // reason the recording is here: a consumer asking later whether
455
+ // its write is still on disk needs to know what it wrote, and the
456
+ // only place that reliably knows is the write itself.
457
+ runtime.recordChangeSetWrite?.({ uri, checksum: checksumOf(content) })
453
458
  return uri
454
459
  },
455
460
 
package/src/write.js CHANGED
@@ -22,7 +22,7 @@ import { readdir, readFile } from 'node:fs/promises'
22
22
 
23
23
  import runtime from './runtime.js'
24
24
  import { readEntity, findEntities } from './catalog.js'
25
- import { useCollection, checksum, readEntityContent, lookupKeys, validateSource } from './utils/index.js'
25
+ import { useCollection, checksum, checksumOf, readEntityContent, lookupKeys, validateSource } from './utils/index.js'
26
26
  import { nextCycleId, whenCycleCompletes } from './report.js'
27
27
  import { recordChangeSetWrite, currentChangeSet } from './changeset.js'
28
28
 
@@ -275,7 +275,10 @@ export async function writeEntitySource({
275
275
  // AFTER the write, so a set only ever claims paths that actually moved.
276
276
  // Claiming on intent would make a failed write undoable, and undoing a
277
277
  // write that never happened is a way to delete someone else's file.
278
- if (changeSet) recordChangeSetWrite({ changeSet, summary, principal, uri })
278
+ // The checksum of what was just written, so a consumer can tell later
279
+ // whether what it wrote is still there. See the change-set log's
280
+ // 002-write-checksums migration.
281
+ if (changeSet) recordChangeSetWrite({ changeSet, summary, principal, uri, checksum: checksumOf(content) })
279
282
 
280
283
  const result = {
281
284
  ok: true, collection, relativePath,