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 +1 -1
- package/src/changeset.js +47 -5
- package/src/explain.js +44 -8
- package/src/manifest/cycle.js +24 -3
- package/src/report.js +23 -3
- package/src/source.js +48 -0
- package/src/utils/entity.js +25 -1
- package/src/utils/index.js +1 -1
- package/src/write.js +5 -2
package/package.json
CHANGED
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
|
-
|
|
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
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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. '
|
package/src/manifest/cycle.js
CHANGED
|
@@ -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.
|
|
253
|
-
//
|
|
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()
|
|
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
|
package/src/utils/entity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/utils/index.js
CHANGED
|
@@ -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
|
-
|
|
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,
|