mikser-io 11.0.2 → 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.0.2",
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
package/src/explain.js CHANGED
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // Follows --audit-output's shape: report and exit, no build phases run.
11
11
  import { existsSync } from 'node:fs'
12
- import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils/index.js'
12
+ import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum, isLocalUri, uriScheme } from './utils/index.js'
13
+ import { recomputeSourceChecksum } from './source.js'
13
14
  import { outputMissing } from './invalidation.js'
14
15
  import { filterKey } from './track.js'
15
16
  import { findEntity, findEntities, findById } from './catalog.js'
@@ -149,19 +150,51 @@ export async function explain(reference) {
149
150
  // would say "skipped", which is true of the catalog and misleading about
150
151
  // the next build. So check the file too, and say which is being reported.
151
152
  //
152
- // A caveat rather than a bug: some plugins compose a checksum from more
153
- // than one file (layouts folds in its .js sidecar), so a difference does
154
- // not always mean the entity's own source moved. Both values are reported
155
- // and the wording avoids claiming more than is known.
153
+ // Compared like with like.
154
+ //
155
+ // Some collections store a COMPOSED checksum rather than a file hash
156
+ // layouts stores md5("<template>:<sidecar>:<shared>") so a sidecar edit
157
+ // invalidates the layout. Comparing that against a fresh md5 of the
158
+ // template is comparing two recipes: it never matches, and this reported
159
+ // `differs: true` for every layout on every site, permanently, for files
160
+ // nobody had touched.
161
+ //
162
+ // So the collection is asked how to recompute its own value. Only where
163
+ // nothing is registered — every ordinary source — is the file hash the
164
+ // right comparison, and there it still is.
156
165
  let source = null
157
- if (entity.uri) {
166
+ // A provider-backed entity has no local file, and asking the filesystem
167
+ // about `https://...` returns ENOENT — which this used to report as
168
+ // "file is gone", and the verdict then told the reader a build would
169
+ // DELETE the entity. It would do no such thing: mikser-io-csv pulls rows
170
+ // over http and the entity is perfectly healthy.
171
+ //
172
+ // Whether the remote moved is a real question, but it is not one a local
173
+ // checksum can answer, and answering it here would put a network fetch
174
+ // inside a read-only explain. So the comparison is declined, out loud.
175
+ if (entity.uri && !isLocalUri(entity.uri)) {
176
+ source = {
177
+ uri: entity.uri,
178
+ scheme: uriScheme(entity.uri),
179
+ catalogChecksum: entity.checksum ?? null,
180
+ remote: true,
181
+ // No `differs`. Absent would read as false to anything checking
182
+ // truthiness, so the reason it is absent is stated instead.
183
+ notCompared: 'the source is fetched by a provider, so there is no local file to compare against',
184
+ }
185
+ } else if (entity.uri) {
158
186
  try {
159
187
  const onDisk = await fileChecksum(entity.uri)
188
+ const composed = await recomputeSourceChecksum(entity)
189
+ const comparable = composed ?? onDisk
160
190
  source = {
161
191
  uri: entity.uri,
162
192
  catalogChecksum: entity.checksum ?? null,
163
193
  fileChecksum: onDisk,
164
- differs: entity.checksum != null && entity.checksum !== onDisk,
194
+ // Reported only when it is not simply the file hash, so its
195
+ // presence means "this collection composes" rather than noise.
196
+ ...(composed ? { comparableChecksum: composed } : {}),
197
+ differs: entity.checksum != null && entity.checksum !== comparable,
165
198
  }
166
199
  } catch (err) {
167
200
  source = { uri: entity.uri, error: err.code === 'ENOENT' ? 'file is gone' : err.message }
@@ -293,7 +326,10 @@ export async function explain(reference) {
293
326
  })),
294
327
  // What a plain build would do next, stated plainly.
295
328
  verdict: contestedVerdict(competingFor(snapshots, entity.id))
296
- ?? (source?.error === 'file is gone'
329
+ ?? (source?.remote
330
+ ? `source is fetched over ${source.scheme} — a build asks its provider, and no local `
331
+ + 'file was compared here'
332
+ : source?.error === 'file is gone'
297
333
  ? 'source file is gone — a build would DELETE this entity and unlink its output'
298
334
  : source?.differs
299
335
  ? 'source differs from the catalog — a build would re-import it first, then re-render. '
@@ -12,6 +12,7 @@ import { useJournal } from '../journal.js'
12
12
  import { onFinalize, onLoaded } from '../lifecycle.js'
13
13
  import { unlink } from 'fs/promises'
14
14
  import { buildSnapshot, hashOutputFile, snapToRow } from './snapshot.js'
15
+ import { renderedByDependency } from '../report.js'
15
16
 
16
17
  // The manifest instance and its database, owned here because this is the only
17
18
  // place either is assigned: onLoaded builds them, onFinalize commits through
@@ -249,8 +250,26 @@ onFinalize(async () => {
249
250
  // still is knowable at the moment it happens, for the cost of one
250
251
  // lookup. That is a rendering change nobody asked for: an upgraded
251
252
  // renderer, a changed helper, a dependency that shifted under the
252
- // build. Every entity input is in inputHash by construction, so if
253
- // that did not move, the cause was not the content.
253
+ // build.
254
+ //
255
+ // "Every entity input is in inputHash by construction" is what this
256
+ // used to say, and it is false. inputHash covers the entity's OWN
257
+ // meta and checksum. An entity assembled from a query — a CSS bundle
258
+ // globbing styles/**/*.css, a page listing its collection — has every
259
+ // real input outside it, reaching the render through refClosure. Its
260
+ // inputHash is CONSTANT by construction, so editing any part tripped
261
+ // this check on every build, forever, telling the reader the cause was
262
+ // an upgraded renderer when it was the file they had just saved.
263
+ //
264
+ // The refClosure cannot settle it either: a query edge records the
265
+ // filter and how many matched, not a hash of what they contained, so
266
+ // it is identical before and after the edit.
267
+ //
268
+ // What does settle it is WHY the render happened. `ref-changed` and
269
+ // `query-matched` mean something this entity consumes moved — that is
270
+ // an input change the hashes cannot see, and not drift. Everything
271
+ // else with an unchanged inputHash still is: --force sweeps, retries,
272
+ // a renderer that stopped being a function of its inputs.
254
273
  //
255
274
  // Recorded here rather than checked later because later is too late —
256
275
  // the render rewrites its own snapshot, so by the time anything asks,
@@ -261,6 +280,7 @@ onFinalize(async () => {
261
280
  // build the unchanged ones are skipped and never reach here, so this
262
281
  // reports on what moved; under --force everything re-renders with
263
282
  // unchanged inputs, which makes it a full sweep.
283
+ const dependencyDriven = renderedByDependency()
264
284
  for (const snap of recordedSnapshots) {
265
285
  const prior = m._stmtLookup.get(snap.id, snap.destination)
266
286
  if (prior
@@ -268,7 +288,8 @@ onFinalize(async () => {
268
288
  && prior.inputHash === snap.inputHash
269
289
  && prior.outputHash
270
290
  && snap.outputHash
271
- && prior.outputHash !== snap.outputHash) {
291
+ && prior.outputHash !== snap.outputHash
292
+ && !dependencyDriven.has(snap.id)) {
272
293
  drifted.push({ id: snap.id, destination: snap.destination })
273
294
  }
274
295
  m._stmtUpsert.run(snapToRow(snap))
package/src/report.js CHANGED
@@ -128,7 +128,7 @@ export function resetReport() {
128
128
  // not in later ones, which is what actually happened.
129
129
  runtime.state.timings = {}
130
130
  runtime.state.pluginTimings = {}
131
- runtime.state.activity = { rendered: 0, changed: 0 }
131
+ runtime.state.activity = { rendered: 0, changed: 0, dependencyDriven: new Set() }
132
132
  }
133
133
 
134
134
  // Published on the runtime so runtime.js can start a fresh cycle for a
@@ -281,7 +281,19 @@ export function reportGated(count = 1) {
281
281
  // `matched`, `dependency` each mean something specific, and a single
282
282
  // polymorphic key would push the type switch onto every consumer.
283
283
  export function reportRendered(entity, reason, decision = {}) {
284
- activity().rendered++
284
+ const seen = activity()
285
+ seen.rendered++
286
+ // Recorded BEFORE the reportWanted gate, because the drift check depends
287
+ // on it and a check that only works under --report is not a check.
288
+ //
289
+ // These two reasons mean the entity re-rendered because something it
290
+ // consumes moved — a $-ref, a partial, an entity matching a recorded
291
+ // query. Its own inputHash did not move and cannot have: for a bundle
292
+ // assembled from a query, every real input is OUTSIDE inputHash by
293
+ // construction. See the drift check in manifest/cycle.js.
294
+ if (reason === 'ref-changed' || reason === 'query-matched') {
295
+ if (entity?.id) seen.dependencyDriven.add(entity.id)
296
+ }
285
297
  if (!reportWanted()) return
286
298
  store().rendered.push({
287
299
  id: entity?.id,
@@ -442,9 +454,17 @@ export function renderErrorCount() {
442
454
  // "nothing moved" on a build that had just rendered.
443
455
  //
444
456
  // Two integers cost nothing and are always true.
457
+ // The entities that re-rendered this cycle because a DEPENDENCY moved, rather
458
+ // than because their own inputs did. Read by the drift check, which cannot
459
+ // tell the difference from hashes alone.
460
+ export function renderedByDependency() {
461
+ return activity().dependencyDriven
462
+ }
463
+
445
464
  function activity() {
446
465
  runtime.state ??= {}
447
- runtime.state.activity ??= { rendered: 0, changed: 0 }
466
+ runtime.state.activity ??= { rendered: 0, changed: 0, dependencyDriven: new Set() }
467
+ runtime.state.activity.dependencyDriven ??= new Set()
448
468
  return runtime.state.activity
449
469
  }
450
470
 
package/src/source.js CHANGED
@@ -42,6 +42,7 @@ import { mkdir, readFile } from 'node:fs/promises'
42
42
  import { globby } from 'globby'
43
43
  import pMap from 'p-map'
44
44
  import runtime from './runtime.js'
45
+ import { useLogger } from './engine/index.js'
45
46
  import { ACTION } from './constants.js'
46
47
  import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils/index.js'
47
48
  import { reportGated, reportChanged } from './report.js'
@@ -82,6 +83,53 @@ const SCAN_CONCURRENCY = 16
82
83
  // disagree, and the losing combination (empty content + a checksum correct
83
84
  // for the finished file) is permanent, because every later sync then
84
85
  // short-circuits on "unchanged".
86
+ // How to recompute a collection's CATALOG checksum, for the collections that
87
+ // do not store a plain file hash.
88
+ //
89
+ // `gateChecksum` accepts `bytes`, so a plugin can store a checksum composed
90
+ // from several files — layouts folds in its .js sidecar and the shared digest,
91
+ // as md5("<template>:<sidecar>:<shared>"). That is deliberate and correct.
92
+ //
93
+ // What was not correct is anything comparing that stored value against a fresh
94
+ // md5 of the file: two different recipes, so they never match, and
95
+ // mikser_explain reported `differs: true` for EVERY layout on every site —
96
+ // telling the reader that someone had edited a file outside the build when
97
+ // nobody had. A permanent false alarm is worse than no alarm, because it
98
+ // trains the reader to skip the real one.
99
+ //
100
+ // A collection with nothing registered keeps the plain file hash, which is
101
+ // right for every ordinary source. A registered recompute may ALSO return null
102
+ // for an individual entity, meaning "this one is not composed" — layouts needs
103
+ // that for its sidecars, which live in the same collection and are gated on
104
+ // their own bytes.
105
+ const sourceChecksums = new Map()
106
+
107
+ export function registerSourceChecksum(collection, recompute) {
108
+ if (!collection || typeof recompute !== 'function') {
109
+ throw new Error('registerSourceChecksum(collection, recompute) requires both')
110
+ }
111
+ sourceChecksums.set(collection, recompute)
112
+ return () => { sourceChecksums.delete(collection) }
113
+ }
114
+
115
+ // The checksum the CATALOG would hold for this entity as it stands on disk —
116
+ // composed the way its own collection composes it. Returns null when the
117
+ // collection composes nothing, which tells the caller to use the file hash.
118
+ export async function recomputeSourceChecksum(entity) {
119
+ const recompute = sourceChecksums.get(entity?.collection)
120
+ if (!recompute) return null
121
+ try {
122
+ return await recompute(entity)
123
+ } catch (err) {
124
+ // "Cannot tell" rather than a crash — explain must still answer. But
125
+ // it says so: a silently swallowed recompute would send the reader
126
+ // back to comparing raw hashes with no hint that anything went wrong.
127
+ useLogger?.()?.debug('Source checksum for %s could not be recomputed: %s',
128
+ entity?.collection, err.message)
129
+ return null
130
+ }
131
+ }
132
+
85
133
  export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
86
134
  const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
87
135
  // What overrides the checksum is not this function's to decide — see
@@ -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'
@@ -149,6 +150,25 @@ const providerModuleCache = new Map()
149
150
  // built-in filesystem read.
150
151
  const URI_SCHEME_RE = /^([a-z][a-z0-9+\-.]*):\/\//i
151
152
 
153
+ // The scheme a uri names, lowercased, or null when it names none.
154
+ //
155
+ // `null` and `file` both mean the local filesystem — that is the split
156
+ // readEntityContent below dispatches on, and anything else asking "is there a
157
+ // file here to read" has to make the same distinction. Exported so it is made
158
+ // once: mikser_explain used to run a filesystem checksum over `https://...`,
159
+ // get ENOENT, and report "source file is gone — a build would DELETE this
160
+ // entity", about a healthy entity a provider had fetched.
161
+ export function uriScheme(uri) {
162
+ if (typeof uri !== 'string') return null
163
+ return URI_SCHEME_RE.exec(uri)?.[1]?.toLowerCase() ?? null
164
+ }
165
+
166
+ // Does this uri point at something on the local filesystem?
167
+ export function isLocalUri(uri) {
168
+ const scheme = uriScheme(uri)
169
+ return scheme === null || scheme === 'file'
170
+ }
171
+
152
172
  // Resolve `<scheme>` to the package `mikser-io-provider-<scheme>` and
153
173
  // import it. Cached per scheme; same package convention as renderers
154
174
  // (`mikser-io-render-<name>`) and postprocessors (`mikser-io-post-<name>`).
@@ -430,7 +450,11 @@ export function useCollection(runtime, name) {
430
450
  // of change sets still produces undoable work — the alternative is
431
451
  // every writer remembering, and the one that forgets is the one
432
452
  // whose edit cannot be taken back.
433
- 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) })
434
458
  return uri
435
459
  },
436
460
 
@@ -8,7 +8,7 @@
8
8
  // This is the surface. Everything imported from './utils/index.js' is
9
9
  // re-exported here, so the split costs no caller anything.
10
10
 
11
- export { changeExtension, getFormatInfo, isTextEntity, looksTextual, matchEntity, mimeForEntity, projectMeta, readEntityContent, useCollection } from './entity.js'
11
+ export { changeExtension, getFormatInfo, isLocalUri, isTextEntity, looksTextual, matchEntity, mimeForEntity, projectMeta, readEntityContent, uriScheme, useCollection } from './entity.js'
12
12
  export { AbortError, formatErrorContext, formatLogArgs } from './errors.js'
13
13
  export { ExpandError, expandEntity } from './expand.js'
14
14
  export { checksum, checksumOf, diffInputParts, inputHashOf, inputPartsOf, normalize } from './hash.js'
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,