mikser-io 11.1.0 → 11.2.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": "11.1.0",
3
+ "version": "11.2.0",
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
@@ -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,