mikser-io 9.7.0 → 9.12.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.
@@ -59,6 +59,7 @@ These options are part of `runtime.options` and apply to the engine itself.
59
59
  | `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
60
60
  | `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
61
61
  | `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-webdav` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
62
+ | — (plugin) | — | object | — | `sources({ styles: { folder: 'styles', extensions: ['css'] } })` registers build inputs as catalog entities, one collection per key. A sidecar can then read them with `findEntities()`, whose queries land in the render's `refClosure` — so editing, adding or removing a part re-renders the bundle and nothing else. Reading the same files with `fs` instead works for one build and silently breaks watch, because the engine has no dependency on a file it never saw. Nothing is linked into `outputFolder`: these are inputs, not output. Named `sources` rather than `inputs` because `entity.inputs` already means something adjacent — bytes an output depends on without being entities at all. |
62
63
  | `cors` / `no-cors` | `--cors` / `--no-cors` | boolean | — | Toggle CORS on the engine's shared Express app. See `src/server.js` for the extensible header arrays plugins push onto. |
63
64
  | `server.requestTimeout` | — | number | node default (`300000`) | Milliseconds a single request may take, set on the underlying `http.Server`. Node's 5-minute default is effectively an **upload size limit expressed in seconds** — a large file over a slow link is indistinguishable from a stalled request, so it is cut off and the caller sees a truncated write rather than a readable error. Only reachable from the `http.Server`, which the engine owns, so a plugin that mounts an upload surface (`mikser-io-webdav`, `forms` with large attachments) cannot raise it for itself. `0` disables the cap: reasonable on a trusted-network build server, bad facing the internet, where it removes the only bound on how long a client can hold a connection open doing nothing. `headersTimeout` is clamped to stay at or below it. The server is also exposed as `runtime.options.httpServer`. |
64
65
  | `url` | `-u, --url <url>` | string | — | Public URL where this mikser is reachable (e.g. `https://blog.me.com`). Validated, trailing slash stripped, stamped on `runtime.options.url`. Read by webhook-capable plugins for push-vs-poll gating (`url.startsWith('https://')`); used by anything that surfaces absolute URLs externally — MCP preview URLs returned to agents, forms share links, email tracking pixels. Plugins that just need internal URLs keep using `runtime.options.port`. |
package/index.js CHANGED
@@ -24,6 +24,7 @@ export * from './src/routes.js'
24
24
  // for the v9 plugin shape.
25
25
  export { api } from './src/plugins/api.js'
26
26
  export { assets } from './src/plugins/assets.js'
27
+ export { sources } from './src/plugins/sources.js'
27
28
  export { commands } from './src/plugins/commands.js'
28
29
  export { data } from './src/plugins/data.js'
29
30
  export { documents } from './src/plugins/documents.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.7.0",
3
+ "version": "9.12.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": {
package/src/config.js CHANGED
@@ -1,12 +1,34 @@
1
1
  import runtime from './runtime.js'
2
2
  import { useLogger } from './engine.js'
3
3
  import { onLoad } from './lifecycle.js'
4
+ import { checksum } from './utils.js'
4
5
  import path from 'node:path'
5
6
 
6
7
  onLoad(async () => {
7
8
  const logger = useLogger()
8
9
  const configFile = path.resolve(runtime.options.config)
9
10
  logger.info('Config: %s', configFile)
11
+
12
+ // Stamp the config so a change to it can invalidate the derived cache.
13
+ //
14
+ // Without this, editing mikser.config.js invalidated NOTHING: flipping
15
+ // an option that changes every page's destination reported "36 unchanged"
16
+ // and left the previous output in place. The config was genuinely read —
17
+ // --force applied it immediately — it simply took part in no
18
+ // invalidation, so the only symptom was output that did not match the
19
+ // config, with nothing saying so.
20
+ //
21
+ // The file's bytes only. A config that imports other modules will not
22
+ // notice a change in those, which is a real limit worth knowing rather
23
+ // than a reason to hash the whole module graph.
24
+ try {
25
+ runtime.options.configChecksum = await checksum(configFile)
26
+ } catch {
27
+ // No config file is a legitimate state (defaults all the way down);
28
+ // absent stamp means "nothing to compare", not "changed".
29
+ runtime.options.configChecksum = null
30
+ }
31
+
10
32
  try {
11
33
  const config = await import(configFile)
12
34
  if (typeof config.default == 'function') {
@@ -247,10 +247,38 @@ export function createSqliteDatabase({
247
247
  handle = new Database(dbPath)
248
248
  setupConnection()
249
249
 
250
- const recorded = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
251
- .get('schema_version')?.value
250
+ const stmtMeta = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
251
+ const recorded = stmtMeta.get('schema_version')?.value
252
+
253
+ // A config change invalidates the cache for the same reason a version
254
+ // change does: the derived state was computed under different rules.
255
+ //
256
+ // Before this, editing mikser.config.js invalidated nothing — flipping
257
+ // an option that changes every page's destination reported "36
258
+ // unchanged" and left the previous output in place. The config was
259
+ // read; it simply took part in no invalidation, so the only symptom
260
+ // was output that did not match the config and nothing saying so.
261
+ //
262
+ // Treated exactly like a version mismatch rather than something
263
+ // narrower: a config edit can change how sources are PARSED (a mapper
264
+ // transform, documents() options) as well as how they are rendered,
265
+ // so invalidating only the render manifest would still leave stale
266
+ // entities. Per ADR-0002 the files are the source of truth, so
267
+ // rebuilding is always safe — just slower.
268
+ const recordedConfig = stmtMeta.get('config_checksum')?.value
269
+ const currentConfig = runtime.options.configChecksum ?? null
270
+ const configChanged = Boolean(recordedConfig && currentConfig && recordedConfig !== currentConfig)
271
+
252
272
  let upgradedFromVersion = null
253
- if (recorded && recorded !== version) {
273
+ if (configChanged && !(recorded && recorded !== version)) {
274
+ logger?.warn(
275
+ 'Config changed since the last run. Wiping the cache and rebuilding from sources ' +
276
+ '(files are the source of truth — no source data is affected). Note this tracks the ' +
277
+ 'bytes of %s only: a change in a module it imports is not seen.',
278
+ runtime.options.config,
279
+ )
280
+ }
281
+ if ((recorded && recorded !== version) || configChanged) {
254
282
  // Schema mismatch on upgrade or downgrade. Per ADR-0002 the
255
283
  // files on disk are the source of truth and this database
256
284
  // is a derived cache, so the right behavior is to wipe the
@@ -261,10 +289,12 @@ export function createSqliteDatabase({
261
289
  // expect a cold-start rebuild on this run. No data loss
262
290
  // beyond the cache itself; everything in mikser.sqlite is
263
291
  // recoverable from the working folder.
264
- logger?.warn(
265
- '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).',
266
- recorded, version,
267
- )
292
+ if (recorded && recorded !== version) {
293
+ logger?.warn(
294
+ '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).',
295
+ recorded, version,
296
+ )
297
+ }
268
298
  handle.close()
269
299
  handle = null
270
300
 
@@ -277,12 +307,16 @@ export function createSqliteDatabase({
277
307
  }
278
308
  }
279
309
 
280
- upgradedFromVersion = recorded
310
+ // Non-null marks the provisioning context as firstRun/upgraded,
311
+ // which is the right shape for a config change too: what the
312
+ // provisioners see is an empty-state database either way.
313
+ upgradedFromVersion = recorded ?? 'config'
281
314
  handle = new Database(dbPath)
282
315
  setupConnection()
283
316
  }
284
- handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
285
- .run('schema_version', version)
317
+ const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
318
+ stmtStamp.run('schema_version', version)
319
+ if (currentConfig) stmtStamp.run('config_checksum', currentConfig)
286
320
 
287
321
  // Build provisioning context. firstRun is true when the file
288
322
  // didn't exist before this open OR when the schema mismatch
@@ -86,19 +86,53 @@ export function assets(options = {}) {
86
86
  const type = 'preset'
87
87
  const checksumMap = new Set()
88
88
 
89
+ // A preset that matches nothing builds green and says nothing, which is
90
+ // how a mistyped pattern ships. `files({ outputFolder })` prefixes `name`
91
+ // and `meta.url` but NOT `id`, and `match` runs against `id` — so
92
+ // '/files/media/devices/**' looks right and matches zero. Tallied here,
93
+ // reported once per process at onFinalize.
94
+ const matchTally = { evaluated: 0, matched: new Set(), reported: false }
95
+
89
96
  async function getEntityPresets(entity) {
90
97
  const entityPresets = []
98
+ matchTally.evaluated++
91
99
  for (let preset in (options.presets || {})) {
92
100
  const { matches } = normalizePresetConfig(options.presets[preset])
93
101
  for (let match of matches) {
94
102
  if (matchEntity(entity, match)) {
95
103
  entityPresets.push(preset)
104
+ matchTally.matched.add(preset)
96
105
  }
97
106
  }
98
107
  }
99
108
  return entityPresets
100
109
  }
101
110
 
111
+ // Report presets that matched none of the entities this run evaluated.
112
+ //
113
+ // Deliberately phrased as what was OBSERVED rather than "this preset is
114
+ // broken": an incremental cycle only re-evaluates changed entities, so a
115
+ // preset can legitimately match nothing in a run of three. The count and
116
+ // the --force hint let the reader tell the two apart, which a bare
117
+ // "matched nothing" would not. Once per process, so watch mode is quiet.
118
+ function reportUnmatchedPresets(logger) {
119
+ if (matchTally.reported) return
120
+ const configured = Object.keys(options.presets || {})
121
+ if (!configured.length || !matchTally.evaluated) return
122
+ matchTally.reported = true
123
+
124
+ for (const preset of configured) {
125
+ if (matchTally.matched.has(preset)) continue
126
+ const { matches } = normalizePresetConfig(options.presets[preset])
127
+ logger.warn(
128
+ 'Assets preset %j matched none of the %d entities evaluated (patterns: %s). ' +
129
+ 'Patterns run against entity.id, which files({ outputFolder }) does NOT prefix — ' +
130
+ 'the prefix appears on name and meta.url only. On an incremental run unchanged ' +
131
+ 'entities are not re-evaluated; use --force to check the whole catalog.',
132
+ preset, matchTally.evaluated, matches.join(', '))
133
+ }
134
+ }
135
+
102
136
  // Resolve a preset name to an importable module location. Local
103
137
  // files in presetsFolder win; names with no local file fall back to
104
138
  // an npm package named `mikser-io-preset-<name>`, resolved from the
@@ -437,6 +471,8 @@ export function assets(options = {}) {
437
471
  const logger = useLogger()
438
472
  const { presets } = runtime.state.assets
439
473
 
474
+ reportUnmatchedPresets(logger)
475
+
440
476
  let revisions = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
441
477
  for (let revision of revisions) {
442
478
  const [preset] = revision.split(path.sep)
@@ -0,0 +1,69 @@
1
+ // Register build inputs — styles/, js/ — as catalog entities.
2
+ //
3
+ // The gap this fills: a site that bundles assets needs a layout sidecar to
4
+ // read those files. Reading them with `fs` works for a one-shot build and
5
+ // SILENTLY breaks watch — the engine has no dependency on a file it never
6
+ // saw, so editing styles/sections/hero.css rebuilds nothing and the page is
7
+ // stale with nothing saying so. The first build is correct, which is what
8
+ // makes it expensive: the mistake only surfaces later as "why didn't my edit
9
+ // take effect", and the obvious workaround is the broken one.
10
+ //
11
+ // Registered as entities instead, a sidecar reads them with findEntities(),
12
+ // whose queries are recorded into the render's refClosure — so touching one
13
+ // part re-renders the bundle and nothing else, by the same mechanism that
14
+ // re-renders a page when a document it references changes.
15
+ //
16
+ // Deliberately NOT the `files` plugin: these are inputs, not output. Nothing
17
+ // is linked into out/, no meta.url is stamped, and they take no part in
18
+ // ADR-0011 served-path resolution. A stylesheet part is not a thing the site
19
+ // serves; it is a thing the site is built from.
20
+ //
21
+ // Named `sources` rather than `inputs` because `entity.inputs` already means
22
+ // something adjacent but different — bytes an entity's output depends on
23
+ // without them being entities at all (see inputHashOf). Two spellings of
24
+ // "input" meaning two things would be worse than a slightly generic name.
25
+ import path from 'node:path'
26
+ import { useSource } from '../source.js'
27
+
28
+ export function sources(options = {}) {
29
+ const collections = Object.entries(options)
30
+ return (core) => {
31
+ const { useLogger } = core
32
+ if (!collections.length) {
33
+ // No collections is a legitimate config (a flag turned them all
34
+ // off); nothing to register, and nothing to complain about.
35
+ return
36
+ }
37
+ for (const [collection, config] of collections) {
38
+ const {
39
+ folder = collection,
40
+ extensions = ['*'],
41
+ ignore = [],
42
+ // Content is the point — a sidecar bundling CSS needs the
43
+ // bytes, not just the path. Overridable for a collection
44
+ // that only needs to be *known* (an image manifest, say).
45
+ content = true,
46
+ load,
47
+ // Code-shaped, so the extension stays in `id` (two parts of
48
+ // the same name in different languages must not collide) and
49
+ // `name` keeps the folder-relative path without it.
50
+ stripExtensionFromId = false,
51
+ } = config ?? {}
52
+
53
+ useSource(core, {
54
+ collection,
55
+ type: 'source',
56
+ folder,
57
+ extensions,
58
+ ignore,
59
+ content,
60
+ stripExtensionFromId,
61
+ load: load ?? (async () => ({})),
62
+ progress: `${collection.replace(/^./, c => c.toUpperCase())} import`,
63
+ })
64
+ }
65
+ useLogger?.()?.debug('Sources registered: %s', collections.map(([c]) => c).join(', '))
66
+ }
67
+ }
68
+
69
+ export default sources
package/src/source.js CHANGED
@@ -43,7 +43,7 @@ import { globby } from 'globby'
43
43
  import pMap from 'p-map'
44
44
  import runtime from './runtime.js'
45
45
  import { ACTION } from './constants.js'
46
- import { checksum as fileChecksum, junkIgnore } from './utils.js'
46
+ import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
47
47
  import { findById, findEntities, checksumsByCollection } from './catalog.js'
48
48
  import { useDatabase } from './database/index.js'
49
49
 
@@ -74,7 +74,14 @@ const SCAN_CONCURRENCY = 16
74
74
  // no SQL) instead of per-file findById. The single-file chokidar
75
75
  // event handler (no scan context) doesn't pass it and falls back to
76
76
  // findById, which is fine for low-frequency one-off mutations.
77
- export async function gateChecksum(file, id, { reload = false, priorChecksums } = {}) {
77
+ // `bytes`, when given, is content the caller has ALREADY read: the checksum
78
+ // is derived from those exact bytes rather than from a second, independent
79
+ // read. That is the whole point — two reads of a file being written can
80
+ // disagree, and the losing combination (empty content + a checksum correct
81
+ // for the finished file) is permanent, because every later sync then
82
+ // short-circuits on "unchanged".
83
+ export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
84
+ const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
78
85
  const canGate = !reload
79
86
  && !runtime.options.force
80
87
  && !runtime.catalog?.cacheInvalidated
@@ -83,12 +90,12 @@ export async function gateChecksum(file, id, { reload = false, priorChecksums }
83
90
  ? priorChecksums.get(id)
84
91
  : findById(id)?.checksum
85
92
  if (priorChecksum) {
86
- const current = await fileChecksum(file)
93
+ const current = await compute()
87
94
  if (priorChecksum === current) return null
88
95
  return current
89
96
  }
90
97
  }
91
- return await fileChecksum(file)
98
+ return await compute()
92
99
  }
93
100
 
94
101
  // Delete sweep. After a scan, find every catalog entity in `collection`
@@ -263,9 +270,17 @@ export function useSource(core, options) {
263
270
  const prefix = idPrefix ?? `/${collection}`
264
271
  const cap = collection.replace(/^./, c => c.toUpperCase())
265
272
  const progressLabel = progress ?? `${cap} import`
273
+ // A SINGLE extension must not go through brace syntax: `**/*.{css}`
274
+ // matches NOTHING in minimatch/globby — a one-element brace is not
275
+ // expanded — so a source declaring one extension silently imported zero
276
+ // files and reported "Styles loaded: 0" as though the folder were empty.
277
+ // Same shape as the other silent-declaration failures: green build,
278
+ // nothing there.
266
279
  const pattern = extensions.includes('*')
267
280
  ? '**/*'
268
- : `**/*.{${extensions.join(',')}}`
281
+ : extensions.length === 1
282
+ ? `**/*.${extensions[0]}`
283
+ : `**/*.{${extensions.join(',')}}`
269
284
 
270
285
  // Hot-reload — chokidar dispatches into onSync(collection) when
271
286
  // a file inside the folder changes.
@@ -413,7 +428,26 @@ export function useSource(core, options) {
413
428
  : `${prefix}/${relativePath.replace(/\\/g, '/')}`
414
429
  scanned?.add(id)
415
430
 
416
- const chksum = await gateChecksum(file, id, { reload, priorChecksums })
431
+ // When this source stores content, read the file ONCE and let both
432
+ // the checksum and the stored body come from the same bytes. The
433
+ // previous shape hashed the file in gateChecksum and then read it
434
+ // again below — two reads, and a torn write between them persisted
435
+ // an empty body next to a valid checksum, permanently.
436
+ //
437
+ // No extra cost: the gate already read the whole file to hash it.
438
+ // Only for `content` sources; large media goes through files.js,
439
+ // which stores no body and must not be slurped into memory.
440
+ let bytes
441
+ if (content) {
442
+ try {
443
+ bytes = await readFile(file)
444
+ } catch (err) {
445
+ logger.warn('%s read failed for %s: %s', collection, name, err.message)
446
+ return
447
+ }
448
+ }
449
+
450
+ const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
417
451
  if (chksum === null) {
418
452
  if (stats) stats.skipped++
419
453
  return
@@ -430,12 +464,9 @@ export function useSource(core, options) {
430
464
  checksum: chksum,
431
465
  }
432
466
  if (content) {
433
- try {
434
- base.content = await readFile(file, 'utf8')
435
- } catch (err) {
436
- logger.warn('%s read failed for %s: %s', collection, name, err.message)
437
- return
438
- }
467
+ // Decoded from the bytes the checksum was taken over — not
468
+ // re-read. See the gate above.
469
+ base.content = bytes.toString('utf8')
439
470
  }
440
471
  try {
441
472
  const extra = await load({ file, name, relativePath, entity: base })
package/src/utils.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import crypto from 'node:crypto'
2
- import { hashFile, hash } from 'hasha'
3
- import { stat, readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { hashFile } from 'hasha'
4
+ import { stat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
4
5
  import { createRequire } from 'node:module'
5
- import TruncateStream from 'truncate-stream'
6
- import { createReadStream } from 'node:fs'
7
6
  import _ from 'lodash'
8
7
  import { minimatch } from 'minimatch'
9
8
  import path from 'path'
@@ -27,6 +26,18 @@ export function inputHashOf(entity) {
27
26
  return crypto.createHash('sha1').update(JSON.stringify({
28
27
  meta: entity.meta ?? null,
29
28
  content: entity.content ?? null,
29
+ // `inputs` is how a plugin declares bytes that are NOT part of the
30
+ // entity's own content but that its output depends on. Whatever is
31
+ // put here participates in the hash, so a change to it invalidates
32
+ // every consumer through the normal refClosure path.
33
+ //
34
+ // The case that needed it: a layout's `.js` sidecar. It is the
35
+ // entity's data layer, it is not its content, and it was in no hash
36
+ // at all — so editing it re-rendered nothing, silently, in a fresh
37
+ // build. Moving the layout's gate `checksum` was not enough, because
38
+ // an entity that HAS content is hashed on {meta, content} and its
39
+ // checksum is ignored. This is the seam that was missing.
40
+ inputs: entity.inputs ?? null,
30
41
  })).digest('hex')
31
42
  }
32
43
 
@@ -301,17 +312,68 @@ export class AbortError extends Error {
301
312
  }
302
313
  }
303
314
 
315
+ const CHECKSUM_MAX_BYTES = 300 * 1024
316
+
317
+ // Checksum from bytes the CALLER ALREADY HAS — no I/O, and therefore no
318
+ // second read to disagree with the first.
319
+ //
320
+ // The hazard this exists to remove: a plugin that stores content does
321
+ //
322
+ // meta: { body: await readFile(source, 'utf8') },
323
+ // checksum: await checksum(source),
324
+ //
325
+ // which is two independent reads of the same file. A watcher firing on the
326
+ // truncate half of a write gets '' from readFile while checksum(), a moment
327
+ // later, sees the finished file. The entity is stored with an empty body and
328
+ // a checksum that is CORRECT FOR THE FINAL CONTENT — so every later sync
329
+ // short-circuits on "unchanged" and the empty body is permanent. Only
330
+ // --clear recovers it, and nothing anywhere reports a problem.
331
+ //
332
+ // Byte-compatible with checksum(uri) below, deliberately: the two must be
333
+ // interchangeable or swapping a caller over would invalidate its catalog.
334
+ export function checksumOf(content) {
335
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
336
+ if (buf.length < CHECKSUM_MAX_BYTES) {
337
+ return createHash('md5').update(buf).digest('hex')
338
+ }
339
+ const head = createHash('md5').update(buf.subarray(0, CHECKSUM_MAX_BYTES)).digest('hex')
340
+ const tail = createHash('md5').update(buf.subarray(buf.length - CHECKSUM_MAX_BYTES)).digest('hex')
341
+ return `${buf.length}:${head}:${tail}`
342
+ }
343
+
344
+ // Checksum a file by path.
345
+ //
346
+ // Files under 300 KB are hashed whole. Larger ones are hashed at both ends
347
+ // plus their length, which reads 600 KB instead of gigabytes — the point of
348
+ // the truncation is that a 1.4 GB video must not be streamed on every cycle.
349
+ //
350
+ // The TAIL is new. The previous form was `size + md5(first 300KB)`, which
351
+ // silently misses any change beyond byte 307200 that preserves the file's
352
+ // length: the checksum matches, the sync reports "unchanged", and the edit
353
+ // is dropped exactly as permanently as the torn-read case above. Hashing
354
+ // both ends does not make this collision-proof — nothing short of a full
355
+ // hash does — but it turns "any late edit of the same length" into
356
+ // "a late edit that also collides on 128 bits".
304
357
  export async function checksum(uri) {
305
- const maxBytes = 300 * 1024
306
358
  const { size } = await stat(uri)
307
- if (size < maxBytes) {
359
+ if (size < CHECKSUM_MAX_BYTES) {
308
360
  return await hashFile(uri, { algorithm: 'md5' })
309
- } else {
310
- const truncate = new TruncateStream({ maxBytes })
311
- const fileStream = createReadStream(uri)
312
- fileStream.pipe(truncate)
313
- const checksum = size.toString() + ':' + await hash(truncate, { algorithm: 'md5' })
314
- return checksum
361
+ }
362
+ const head = await hashRange(uri, 0, CHECKSUM_MAX_BYTES)
363
+ const tail = await hashRange(uri, size - CHECKSUM_MAX_BYTES, CHECKSUM_MAX_BYTES)
364
+ return `${size}:${head}:${tail}`
365
+ }
366
+
367
+ // md5 of `length` bytes starting at `start`. A positional read, so the
368
+ // bytes in between are never touched.
369
+ async function hashRange(uri, start, length) {
370
+ const handle = await open(uri, 'r')
371
+ try {
372
+ const buf = Buffer.allocUnsafe(length)
373
+ const { bytesRead } = await handle.read(buf, 0, length, start)
374
+ return createHash('md5').update(buf.subarray(0, bytesRead)).digest('hex')
375
+ } finally {
376
+ await handle.close()
315
377
  }
316
378
  }
317
379