mikser-io 9.15.1 → 9.18.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/catalog.js +11 -4
- package/src/config.js +20 -3
- package/src/explain.js +23 -4
- package/src/manifest.js +98 -30
- package/src/plugins/files.js +28 -5
- package/src/plugins.js +15 -1
- package/src/refs.js +146 -23
- package/src/render.js +20 -10
- package/src/report.js +16 -1
- package/src/track.js +19 -8
- package/src/utils.js +93 -9
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -207,16 +207,19 @@ async function applyJournalMutations() {
|
|
|
207
207
|
}
|
|
208
208
|
if (!mutations.length) return
|
|
209
209
|
db.transaction(() => {
|
|
210
|
+
// Two passes, deliberately. indexEntity resolves each $-ref
|
|
211
|
+
// against mikser_entities to record what it bound to, so it has
|
|
212
|
+
// to run after every row this cycle is in place — otherwise a
|
|
213
|
+
// ref to an entity that happens to be upserted later in the same
|
|
214
|
+
// batch binds to nothing, purely because of journal order.
|
|
215
|
+
const toIndex = []
|
|
210
216
|
for (const { operation, entity } of mutations) {
|
|
211
217
|
switch (operation) {
|
|
212
218
|
case OPERATION.CREATE:
|
|
213
219
|
case OPERATION.UPDATE:
|
|
214
220
|
logger.trace('Database %s %s: %s', entity.collection, operation, entity.id)
|
|
215
221
|
stmtUpsert.run(entityToRow(entity))
|
|
216
|
-
|
|
217
|
-
// does delete-then-insert internally so this is
|
|
218
|
-
// idempotent across UPDATE.
|
|
219
|
-
refsIndex?.indexEntity(entity)
|
|
222
|
+
toIndex.push(entity)
|
|
220
223
|
cacheEvict(entity.id) // next read returns fresh data
|
|
221
224
|
break
|
|
222
225
|
case OPERATION.DELETE:
|
|
@@ -227,6 +230,10 @@ async function applyJournalMutations() {
|
|
|
227
230
|
break
|
|
228
231
|
}
|
|
229
232
|
}
|
|
233
|
+
// Static $-ref edges from entity.meta. Refs index does
|
|
234
|
+
// delete-then-insert per source internally, so this stays
|
|
235
|
+
// idempotent across UPDATE.
|
|
236
|
+
for (const entity of toIndex) refsIndex?.indexEntity(entity)
|
|
230
237
|
})
|
|
231
238
|
}
|
|
232
239
|
|
package/src/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { useLogger } from './engine.js'
|
|
|
3
3
|
import { onLoad } from './lifecycle.js'
|
|
4
4
|
import { checksum } from './utils.js'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
6
7
|
|
|
7
8
|
onLoad(async () => {
|
|
8
9
|
const logger = useLogger()
|
|
@@ -29,15 +30,31 @@ onLoad(async () => {
|
|
|
29
30
|
runtime.options.configChecksum = null
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
// Absence is decided by looking for the file, NOT by catching
|
|
34
|
+
// ERR_MODULE_NOT_FOUND from the import.
|
|
35
|
+
//
|
|
36
|
+
// Node raises that same code for "the config file is missing" and for
|
|
37
|
+
// "the config file exists and something IT imports is missing" — a
|
|
38
|
+
// mistyped package name, a renamed local module, a dependency that
|
|
39
|
+
// was never installed. Catching the code swallowed both, so a config
|
|
40
|
+
// with one bad import loaded as `{}` and the build reported "No
|
|
41
|
+
// plugins loaded" and exited 0: a green build with an empty output
|
|
42
|
+
// folder, one line away from having printed the config's path.
|
|
43
|
+
//
|
|
44
|
+
// Every other config failure was already loud — a syntax error or a
|
|
45
|
+
// throw during evaluation both exit 1. Module resolution was the one
|
|
46
|
+
// silent case, so this brings it in line rather than inventing a new
|
|
47
|
+
// policy.
|
|
48
|
+
if (!existsSync(configFile)) {
|
|
49
|
+
logger.debug('No config file at %s — using defaults', configFile)
|
|
50
|
+
} else {
|
|
51
|
+
// No catch: any failure loading a config that EXISTS is fatal.
|
|
33
52
|
const config = await import(configFile)
|
|
34
53
|
if (typeof config.default == 'function') {
|
|
35
54
|
runtime.config = await config.default(runtime)
|
|
36
55
|
} else if (typeof config.default == 'object') {
|
|
37
56
|
runtime.config = config.default
|
|
38
57
|
}
|
|
39
|
-
} catch (err) {
|
|
40
|
-
if (err.code != 'ERR_MODULE_NOT_FOUND') throw err
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
// v8 used to walk `runtime.config.plugins` looking for matching
|
package/src/explain.js
CHANGED
|
@@ -114,7 +114,18 @@ export async function explain(reference) {
|
|
|
114
114
|
refClosure: (snap.refClosure ?? []).map(entry =>
|
|
115
115
|
entry.kind === 'query'
|
|
116
116
|
? { kind: 'query', filter: entry.filter }
|
|
117
|
-
: {
|
|
117
|
+
: {
|
|
118
|
+
kind: entry.kind,
|
|
119
|
+
target: entry.target,
|
|
120
|
+
// What the name actually resolved to. A binding
|
|
121
|
+
// that is absent means the edge is dangling —
|
|
122
|
+
// the single most useful thing to know when a
|
|
123
|
+
// page will not re-render and nobody can say why.
|
|
124
|
+
bound: entry.targetIds?.length ? entry.targetIds
|
|
125
|
+
: entry.targetId ? [entry.targetId]
|
|
126
|
+
: [],
|
|
127
|
+
hash: shortHash(entry.hash),
|
|
128
|
+
}),
|
|
118
129
|
})),
|
|
119
130
|
// What a plain build would do next, stated plainly.
|
|
120
131
|
verdict: source?.error === 'file is gone'
|
|
@@ -182,9 +193,17 @@ export function formatExplain(report) {
|
|
|
182
193
|
const closure = r.refClosure
|
|
183
194
|
row('refClosure', `${closure.length} edge${closure.length === 1 ? '' : 's'}`)
|
|
184
195
|
for (const e of closure) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
196
|
+
if (e.kind === 'query') {
|
|
197
|
+
out.push(` query ${JSON.stringify(e.filter)}`)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
// Show the name asked for and the entity it bound to, since
|
|
201
|
+
// the two can differ (an href or a served path resolves to
|
|
202
|
+
// an id) and a missing binding is itself the diagnosis.
|
|
203
|
+
const bound = e.bound?.length
|
|
204
|
+
? (e.bound.length === 1 && e.bound[0] === e.target ? '' : ` → ${e.bound.join(', ')}`)
|
|
205
|
+
: ' [UNRESOLVED — nothing answers to this name]'
|
|
206
|
+
out.push(` ${e.kind.padEnd(10)} ${e.target}${bound}${e.hash ? ` ${e.hash}` : ''}`)
|
|
188
207
|
}
|
|
189
208
|
}
|
|
190
209
|
|
package/src/manifest.js
CHANGED
|
@@ -96,12 +96,20 @@ function sha1(payload) {
|
|
|
96
96
|
function buildRefClosure(entity, deps) {
|
|
97
97
|
const closure = []
|
|
98
98
|
const seen = new Set()
|
|
99
|
-
|
|
99
|
+
// `targetId`/`targetIds` are the recorded BINDING — which entity the
|
|
100
|
+
// ref actually resolved to. They have to survive into the snapshot:
|
|
101
|
+
// this projection used to keep only {kind, target, hash}, so the
|
|
102
|
+
// binding was written to mikser_refs, dropped here, and skipDecision
|
|
103
|
+
// had nothing but the name to compare. The scheduler found the
|
|
104
|
+
// dependent and the manifest then skipped it.
|
|
105
|
+
function pushTarget(kind, target, hash, targetId, targetIds) {
|
|
100
106
|
if (!target) return
|
|
101
107
|
const key = `${kind}:${target}`
|
|
102
108
|
if (seen.has(key)) return
|
|
103
109
|
seen.add(key)
|
|
104
110
|
const entry = { kind, target }
|
|
111
|
+
if (targetId) entry.targetId = targetId
|
|
112
|
+
if (targetIds?.length) entry.targetIds = targetIds
|
|
105
113
|
if (hash) entry.hash = hash
|
|
106
114
|
closure.push(entry)
|
|
107
115
|
}
|
|
@@ -113,14 +121,26 @@ function buildRefClosure(entity, deps) {
|
|
|
113
121
|
}
|
|
114
122
|
if (entity.meta) {
|
|
115
123
|
for (const { ref } of extractRefs(entity.meta)) {
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
// Resolve through the refs index, which mirrors refFilter's
|
|
125
|
+
// four forms. findById alone — as this used to do — is an
|
|
126
|
+
// exact primary-key read: a $-ref written as a meta.href or
|
|
127
|
+
// a served meta.url path (ADR-0011) resolved to nothing, so
|
|
128
|
+
// the edge was stored with no hash and no binding, and the
|
|
129
|
+
// manifest could not tell the target had changed.
|
|
130
|
+
const ids = runtime.refs?.resolveRefIds?.(ref) ?? (findById(ref) ? [ref] : [])
|
|
131
|
+
const bound = ids.length === 1 ? findById(ids[0]) : null
|
|
132
|
+
pushTarget(
|
|
133
|
+
'ref', ref,
|
|
134
|
+
bound ? inputHashOf(bound) : undefined,
|
|
135
|
+
ids.length === 1 ? ids[0] : undefined,
|
|
136
|
+
ids.length > 1 ? ids : undefined,
|
|
137
|
+
)
|
|
118
138
|
}
|
|
119
139
|
}
|
|
120
140
|
if (Array.isArray(deps)) {
|
|
121
141
|
for (const dep of deps) {
|
|
122
142
|
if (dep.kind === 'query') pushQuery(dep.filter ?? null)
|
|
123
|
-
else pushTarget(dep.kind, dep.target, dep.hash)
|
|
143
|
+
else pushTarget(dep.kind, dep.target, dep.hash, dep.targetId, dep.targetIds)
|
|
124
144
|
}
|
|
125
145
|
}
|
|
126
146
|
return closure
|
|
@@ -286,7 +306,20 @@ export function createManifest(db) {
|
|
|
286
306
|
// ref-changed a $-ref or partial it depends on moved
|
|
287
307
|
// query-matched an entity matching a recorded query mutated
|
|
288
308
|
// cache-disabled meta.cache === false
|
|
309
|
+
// force --force: skip nothing, ask nothing
|
|
289
310
|
skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
|
|
311
|
+
// --force means "ignore what you think you know". THREE gates can
|
|
312
|
+
// stop a render — source.js's import checksum gate, layouts'
|
|
313
|
+
// dispatch filter, and this one — and force reached only the
|
|
314
|
+
// first two. Since this one runs last, a forced build re-imported
|
|
315
|
+
// everything (gated=0), re-dispatched everything, and then
|
|
316
|
+
// dropped all of it here with reason `unchanged`: rendered=0, and
|
|
317
|
+
// a summary that read like a successful build.
|
|
318
|
+
//
|
|
319
|
+
// That made --force useless in exactly the situation it exists
|
|
320
|
+
// for, which is when the invalidation graph is under suspicion —
|
|
321
|
+
// including the advice the preset no-match warning gives.
|
|
322
|
+
if (runtime.options?.force) return { skip: false, reason: 'force' }
|
|
290
323
|
if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
|
|
291
324
|
const snapshot = this.lookup(entity)
|
|
292
325
|
if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
|
|
@@ -303,26 +336,48 @@ export function createManifest(db) {
|
|
|
303
336
|
}
|
|
304
337
|
continue
|
|
305
338
|
}
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
339
|
+
// An edge is checked against every key it could have been
|
|
340
|
+
// hit by: the entity it BOUND to, and the name it asked
|
|
341
|
+
// for. entity.id is always lookupKeys()[0], so the engine's
|
|
342
|
+
// mutation maps are already keyed by id — reading them by
|
|
343
|
+
// targetId needs no change there, and a binding survives
|
|
344
|
+
// the target renaming itself.
|
|
345
|
+
//
|
|
346
|
+
// Both, not the first match, because the two can disagree:
|
|
347
|
+
// the bound entity may have been re-persisted unchanged in
|
|
348
|
+
// the same cycle that a DIFFERENT entity started answering
|
|
349
|
+
// to the same name. Stopping at the binding would compare
|
|
350
|
+
// an unchanged hash and skip, silently ignoring the new
|
|
351
|
+
// claimant. The name key also carries unresolved/forward
|
|
352
|
+
// edges, which is how a link to a not-yet-existing page
|
|
353
|
+
// invalidates once that page appears.
|
|
354
|
+
const keys = [
|
|
355
|
+
...(entry.targetIds ?? []),
|
|
356
|
+
...(entry.targetId ? [entry.targetId] : []),
|
|
357
|
+
entry.target,
|
|
358
|
+
]
|
|
359
|
+
for (const key of keys) {
|
|
360
|
+
if (!mutatedRefs?.has(key)) continue
|
|
361
|
+
// Language scope: if the source has a meta.lang AND the
|
|
362
|
+
// mutation came from a different meta.lang, the ref
|
|
363
|
+
// doesn't actually depend on what changed. A `null` lang
|
|
364
|
+
// in the mutation set means a shared (un-localized)
|
|
365
|
+
// entity touched the same key — that does invalidate
|
|
366
|
+
// language-specific sources because the shared entity
|
|
367
|
+
// is the only variant. Sources without a meta.lang are
|
|
368
|
+
// language-agnostic and accept any mutation lang.
|
|
369
|
+
if (sourceLang) {
|
|
370
|
+
const mutatedLangs = mutatedRefs.get(key)
|
|
371
|
+
if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
319
374
|
}
|
|
375
|
+
if (!entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
376
|
+
const currentHash = currentHashes?.get(key)
|
|
377
|
+
if (currentHash === undefined) continue
|
|
378
|
+
if (currentHash === null) return { skip: false, reason: 'ref-changed' }
|
|
379
|
+
if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
320
380
|
}
|
|
321
|
-
if (!entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
322
|
-
const currentHash = currentHashes?.get(entry.target)
|
|
323
|
-
if (currentHash === undefined) continue
|
|
324
|
-
if (currentHash === null) return { skip: false, reason: 'ref-changed' }
|
|
325
|
-
if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
326
381
|
}
|
|
327
382
|
return { skip: true, reason: 'unchanged' }
|
|
328
383
|
},
|
|
@@ -427,6 +482,7 @@ export function createManifest(db) {
|
|
|
427
482
|
edges.push({
|
|
428
483
|
kind: 'layout',
|
|
429
484
|
target: entity.layout.id,
|
|
485
|
+
targetId: entity.layout.id,
|
|
430
486
|
hash: inputHashOf(entity.layout),
|
|
431
487
|
})
|
|
432
488
|
}
|
|
@@ -436,23 +492,35 @@ export function createManifest(db) {
|
|
|
436
492
|
edges.push({
|
|
437
493
|
kind: 'partial',
|
|
438
494
|
target,
|
|
495
|
+
targetId: partial ? target : undefined,
|
|
439
496
|
hash: partial ? inputHashOf(partial) : undefined,
|
|
440
497
|
})
|
|
441
498
|
}
|
|
442
499
|
}
|
|
443
500
|
if (track?.lookups) {
|
|
444
|
-
for (const target of track.lookups) {
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
501
|
+
for (const [target, ids] of track.lookups) {
|
|
502
|
+
// The lookup helper resolved this already and handed
|
|
503
|
+
// over the ids, so the hash comes from the BOUND
|
|
504
|
+
// entity. Hashing findById(target) instead — as this
|
|
505
|
+
// did — silently produced no hash at all whenever the
|
|
506
|
+
// target was an href or url form, because findById is
|
|
507
|
+
// an exact primary-key read: it resolves neither
|
|
508
|
+
// meta.href nor meta.url nor a stripped extension.
|
|
509
|
+
// Every such edge was hashless, so the manifest could
|
|
510
|
+
// not tell "target moved" from "target changed".
|
|
511
|
+
//
|
|
512
|
+
// No hash still means "nothing resolved", and
|
|
513
|
+
// skipDecision re-renders a hashless edge whose target
|
|
514
|
+
// mutated — which is what should happen when a page
|
|
450
515
|
// that was linked-to-but-missing finally appears.
|
|
451
|
-
const
|
|
516
|
+
const targetIds = [...ids]
|
|
517
|
+
const bound = targetIds.length === 1 ? findById(targetIds[0]) : null
|
|
452
518
|
edges.push({
|
|
453
519
|
kind: 'lookup',
|
|
454
520
|
target,
|
|
455
|
-
|
|
521
|
+
targetId: targetIds.length === 1 ? targetIds[0] : undefined,
|
|
522
|
+
targetIds: targetIds.length > 1 ? targetIds : undefined,
|
|
523
|
+
hash: bound ? inputHashOf(bound) : undefined,
|
|
456
524
|
})
|
|
457
525
|
}
|
|
458
526
|
}
|
package/src/plugins/files.js
CHANGED
|
@@ -86,25 +86,41 @@ export function files(options = {}) {
|
|
|
86
86
|
link: await link(source)
|
|
87
87
|
})
|
|
88
88
|
break
|
|
89
|
-
case ACTION.UPDATE:
|
|
89
|
+
case ACTION.UPDATE: {
|
|
90
90
|
const current = await findEntity({ id })
|
|
91
|
-
|
|
91
|
+
// `checksum` is the source-checksum FUNCTION from the
|
|
92
|
+
// plugin context, not a value. Comparing the stored
|
|
93
|
+
// string against it was never equal, so the guard
|
|
94
|
+
// always passed and every sync re-wrote the entity —
|
|
95
|
+
// `synced = false` was unreachable.
|
|
96
|
+
const currentChecksum = await checksum(source)
|
|
97
|
+
if (current?.checksum != currentChecksum) {
|
|
92
98
|
await updateEntity({
|
|
93
99
|
id,
|
|
94
100
|
uri,
|
|
95
|
-
name
|
|
101
|
+
// `name` — the prefixed form, as CREATE uses.
|
|
102
|
+
// This read `relativePath`, so an update
|
|
103
|
+
// dropped the outputFolder prefix while
|
|
104
|
+
// meta.url two lines down kept it. The assets
|
|
105
|
+
// plugin builds preset destinations from
|
|
106
|
+
// `name`, so a file replaced under watch had
|
|
107
|
+
// its derivatives written somewhere else than
|
|
108
|
+
// the same file freshly imported, and
|
|
109
|
+
// meta.presets recorded the wrong path.
|
|
110
|
+
name,
|
|
96
111
|
collection,
|
|
97
112
|
type,
|
|
98
113
|
format,
|
|
99
114
|
source,
|
|
100
115
|
meta: { url: '/' + name },
|
|
101
|
-
checksum:
|
|
116
|
+
checksum: currentChecksum,
|
|
102
117
|
link: await link(source)
|
|
103
118
|
})
|
|
104
119
|
} else {
|
|
105
120
|
synced = false
|
|
106
121
|
}
|
|
107
122
|
break
|
|
123
|
+
}
|
|
108
124
|
case ACTION.DELETE:
|
|
109
125
|
await removeLink(relativePath)
|
|
110
126
|
await deleteEntity({
|
|
@@ -143,6 +159,13 @@ export function files(options = {}) {
|
|
|
143
159
|
// journal with phantom mutations and triggering downstream
|
|
144
160
|
// re-dispatch of aggregate layouts whose recorded query deps
|
|
145
161
|
// matched the collection.
|
|
162
|
+
// --force (and a wiped catalog) must defeat the gate, the same
|
|
163
|
+
// way source.js's gateChecksum lets them defeat its own. This
|
|
164
|
+
// plugin carries a second, independent gate, and it honoured
|
|
165
|
+
// neither — so no amount of forcing ever re-derived a file's
|
|
166
|
+
// name / meta.url / meta.presets, and a catalog holding bad
|
|
167
|
+
// `files` rows had no repair path short of deleting them.
|
|
168
|
+
const forced = runtime.options.force || runtime.catalog?.cacheInvalidated
|
|
146
169
|
const priorChecksums = checksumsByCollection(collection)
|
|
147
170
|
await pMap(paths, async relativePath => {
|
|
148
171
|
const { uri, source } = await ensureLink(relativePath)
|
|
@@ -159,7 +182,7 @@ export function files(options = {}) {
|
|
|
159
182
|
// the journal stays accurate (mutations = actual changes),
|
|
160
183
|
// and downstream aggregate-layout invalidation isn't fired
|
|
161
184
|
// spuriously.
|
|
162
|
-
if (priorChecksums.get(id) === newChecksum) return
|
|
185
|
+
if (!forced && priorChecksums.get(id) === newChecksum) return
|
|
163
186
|
await createEntity({
|
|
164
187
|
id,
|
|
165
188
|
uri,
|
package/src/plugins.js
CHANGED
|
@@ -87,7 +87,21 @@ onLoad(() => {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
if (!factoryEntries.length && !registeredRenderers && !registeredPostprocessors) {
|
|
90
|
-
|
|
90
|
+
// "No plugins loaded" is a legitimate state for a project with no
|
|
91
|
+
// config at all, and a near-certain mistake for one that HAS a
|
|
92
|
+
// config — the two printed the same line, so a config that
|
|
93
|
+
// produced no plugins looked like a deliberate choice. Say which
|
|
94
|
+
// case this is.
|
|
95
|
+
if (runtime.options.configChecksum) {
|
|
96
|
+
logger.warn(
|
|
97
|
+
'No plugins loaded, but a config was read from %s — ' +
|
|
98
|
+
'it exported no `plugins` array, or the array was empty. ' +
|
|
99
|
+
'Nothing will be built.',
|
|
100
|
+
runtime.options.config,
|
|
101
|
+
)
|
|
102
|
+
} else {
|
|
103
|
+
logger.info('No plugins loaded')
|
|
104
|
+
}
|
|
91
105
|
return
|
|
92
106
|
}
|
|
93
107
|
|
package/src/refs.js
CHANGED
|
@@ -14,7 +14,26 @@
|
|
|
14
14
|
// the engine after each successful render
|
|
15
15
|
// via the track API. field = '' (empty).
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// Every row carries BOTH the question and the answer:
|
|
18
|
+
//
|
|
19
|
+
// target_ref — the string the dependent asked for. May be an id, a
|
|
20
|
+
// meta.href, a meta.url (ADR-0011 served path), or an
|
|
21
|
+
// id with its extension stripped.
|
|
22
|
+
// target_id — the entity it actually resolved to, '' when nothing
|
|
23
|
+
// did. NOT NULL because WITHOUT ROWID requires it of
|
|
24
|
+
// primary-key columns, and it is in the key so one
|
|
25
|
+
// dependent can bind one name to several entities
|
|
26
|
+
// (language variants).
|
|
27
|
+
//
|
|
28
|
+
// Both are load-bearing. Answering "who depends on X?" from target_ref
|
|
29
|
+
// alone means guessing which strings X could have answered to, which
|
|
30
|
+
// fails the moment X changes its name — the recorded string is then
|
|
31
|
+
// derivable from no live state. target_id alone cannot express a
|
|
32
|
+
// forward or dangling reference, nor re-bind when an entity later
|
|
33
|
+
// claims a name nobody could resolve before.
|
|
34
|
+
//
|
|
35
|
+
// Lookups are indexed SELECTs over (target_ref, kind), (target_id) and
|
|
36
|
+
// (source_id):
|
|
18
37
|
//
|
|
19
38
|
// inbound :: "Which entities reference this target_ref?"
|
|
20
39
|
// outbound :: "Which target_refs does this source emit?"
|
|
@@ -47,17 +66,25 @@ import { registerSchema, useDatabase } from './database/index.js'
|
|
|
47
66
|
// who references X). The primary key's leading source_id column
|
|
48
67
|
// already covers forward (everything X references) without a
|
|
49
68
|
// separate index.
|
|
50
|
-
|
|
69
|
+
//
|
|
70
|
+
// Exported so tests build their index over the REAL schema instead of a
|
|
71
|
+
// hand-copied one. The copy in test/unit/refs.test.js silently drifted
|
|
72
|
+
// out of date the moment target_id was added — 25 tests failed with a
|
|
73
|
+
// bare SQLITE_ERROR pointing at nothing in particular.
|
|
74
|
+
export const REFS_SCHEMA = `
|
|
51
75
|
CREATE TABLE IF NOT EXISTS mikser_refs (
|
|
52
76
|
source_id TEXT NOT NULL,
|
|
53
77
|
target_ref TEXT NOT NULL,
|
|
78
|
+
target_id TEXT NOT NULL DEFAULT '',
|
|
54
79
|
kind TEXT NOT NULL,
|
|
55
80
|
field TEXT NOT NULL DEFAULT '',
|
|
56
|
-
PRIMARY KEY (source_id, target_ref, kind, field),
|
|
81
|
+
PRIMARY KEY (source_id, target_ref, kind, field, target_id),
|
|
57
82
|
FOREIGN KEY (source_id) REFERENCES mikser_entities(id) ON DELETE CASCADE
|
|
58
83
|
) WITHOUT ROWID;
|
|
59
84
|
CREATE INDEX IF NOT EXISTS idx_mikser_refs_target ON mikser_refs(target_ref);
|
|
60
|
-
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_mikser_refs_target_id ON mikser_refs(target_id);
|
|
86
|
+
`
|
|
87
|
+
registerSchema('mikser_refs', REFS_SCHEMA)
|
|
61
88
|
|
|
62
89
|
// Build the index handle over the provided sqlite database. Prepares
|
|
63
90
|
// the SQL statements once at construction; subsequent reads/writes
|
|
@@ -82,6 +109,14 @@ export function createIndex(db) {
|
|
|
82
109
|
SELECT DISTINCT source_id FROM mikser_refs
|
|
83
110
|
WHERE target_ref = ?
|
|
84
111
|
`)
|
|
112
|
+
// Identity-direction inbound: everything BOUND to this entity, under
|
|
113
|
+
// whatever name it was asked for. Immune to the target renaming
|
|
114
|
+
// itself, which is the whole point — the binding was recorded when
|
|
115
|
+
// it was made instead of re-derived from the target's current names.
|
|
116
|
+
const stmtInboundByTargetId = db.prepare(`
|
|
117
|
+
SELECT DISTINCT source_id FROM mikser_refs
|
|
118
|
+
WHERE target_id = ? AND target_id != ''
|
|
119
|
+
`)
|
|
85
120
|
const stmtInboundDynamic = db.prepare(`
|
|
86
121
|
SELECT source_id, kind FROM mikser_refs
|
|
87
122
|
WHERE target_ref = ? AND kind != 'ref'
|
|
@@ -109,6 +144,45 @@ export function createIndex(db) {
|
|
|
109
144
|
SELECT COUNT(DISTINCT source_id) AS c FROM mikser_refs WHERE kind != 'ref'
|
|
110
145
|
`)
|
|
111
146
|
|
|
147
|
+
// Synchronous resolution of a ref string to entity ids. Mirrors
|
|
148
|
+
// `refFilter` in utils.js — id, meta.href, meta.url, then
|
|
149
|
+
// id-minus-extension — but as sync SQL, because indexEntity runs
|
|
150
|
+
// inside catalog.applyJournalMutations' transaction and
|
|
151
|
+
// better-sqlite3 transactions are sync-only.
|
|
152
|
+
//
|
|
153
|
+
// Returns every match, not the first: a name shared by language
|
|
154
|
+
// variants binds to all of them, and over-binding only ever means
|
|
155
|
+
// more invalidation, which the manifest's hash check then filters.
|
|
156
|
+
const stmtResolveExact = db.prepare(`
|
|
157
|
+
SELECT id FROM mikser_entities
|
|
158
|
+
WHERE id = ? OR meta_href = ? OR meta_url = ?
|
|
159
|
+
`)
|
|
160
|
+
const stmtResolveExtended = db.prepare(`
|
|
161
|
+
SELECT id FROM mikser_entities WHERE id GLOB ?
|
|
162
|
+
`)
|
|
163
|
+
function resolveRefIds(ref) {
|
|
164
|
+
if (typeof ref !== 'string' || !ref) return []
|
|
165
|
+
const ids = new Set()
|
|
166
|
+
for (const row of stmtResolveExact.all(ref, ref, ref)) ids.add(row.id)
|
|
167
|
+
// The extension-tolerant form. GLOB narrows to `<ref>.something`
|
|
168
|
+
// using the primary key's prefix; the regex then enforces
|
|
169
|
+
// EXACTLY one trailing extension and no further path segment,
|
|
170
|
+
// matching refFilter's `^ref\.[^./]+$`.
|
|
171
|
+
//
|
|
172
|
+
// SQLite's GLOB has no escape syntax, so a ref containing GLOB
|
|
173
|
+
// metacharacters cannot be used as a literal prefix. Skip the
|
|
174
|
+
// narrowing rather than run a pattern that means something else
|
|
175
|
+
// than the caller wrote; the exact forms above still apply, and
|
|
176
|
+
// the name-direction query still covers the edge.
|
|
177
|
+
if (/[*?[\]]/.test(ref)) return [...ids]
|
|
178
|
+
const escaped = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
179
|
+
const oneExtension = new RegExp(`^${escaped}\\.[^./]+$`)
|
|
180
|
+
for (const row of stmtResolveExtended.all(`${ref}.*`)) {
|
|
181
|
+
if (oneExtension.test(row.id)) ids.add(row.id)
|
|
182
|
+
}
|
|
183
|
+
return [...ids]
|
|
184
|
+
}
|
|
185
|
+
|
|
112
186
|
// Write statements
|
|
113
187
|
const stmtClearStaticForSource = db.prepare(`
|
|
114
188
|
DELETE FROM mikser_refs WHERE source_id = ? AND kind = 'ref'
|
|
@@ -117,8 +191,8 @@ export function createIndex(db) {
|
|
|
117
191
|
DELETE FROM mikser_refs WHERE source_id = ? AND kind != 'ref'
|
|
118
192
|
`)
|
|
119
193
|
const stmtInsertEdge = db.prepare(`
|
|
120
|
-
INSERT OR IGNORE INTO mikser_refs (source_id, target_ref, kind, field)
|
|
121
|
-
VALUES (?, ?, ?, ?)
|
|
194
|
+
INSERT OR IGNORE INTO mikser_refs (source_id, target_ref, target_id, kind, field)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?)
|
|
122
196
|
`)
|
|
123
197
|
|
|
124
198
|
// -- Read API ------------------------------------------------------
|
|
@@ -168,25 +242,45 @@ export function createIndex(db) {
|
|
|
168
242
|
// schemas / findRef use. Cycle-safe via the result Set.
|
|
169
243
|
function inverseClosureOf(seeds, getEntityById) {
|
|
170
244
|
const closure = new Set()
|
|
171
|
-
const
|
|
245
|
+
const toWalk = []
|
|
246
|
+
|
|
247
|
+
const enqueue = (id, entity) => {
|
|
248
|
+
if (!id || closure.has(id)) return
|
|
249
|
+
closure.add(id)
|
|
250
|
+
toWalk.push({ id, entity })
|
|
251
|
+
}
|
|
172
252
|
|
|
173
253
|
for (const seed of seeds ?? []) {
|
|
174
254
|
const entity = typeof seed === 'string' ? null : seed
|
|
175
|
-
|
|
176
|
-
if (!id) continue
|
|
177
|
-
if (closure.has(id)) continue
|
|
178
|
-
closure.add(id)
|
|
179
|
-
keysToWalk.push(...lookupKeys(entity ?? { id }))
|
|
255
|
+
enqueue(entity?.id ?? (typeof seed === 'string' ? seed : null), entity)
|
|
180
256
|
}
|
|
181
257
|
|
|
182
|
-
while (
|
|
183
|
-
const
|
|
184
|
-
const referrers =
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
258
|
+
while (toWalk.length > 0) {
|
|
259
|
+
const { id, entity } = toWalk.shift()
|
|
260
|
+
const referrers = new Set()
|
|
261
|
+
|
|
262
|
+
// Identity direction: edges BOUND to this entity. Survives
|
|
263
|
+
// the entity renaming itself, because the binding was
|
|
264
|
+
// recorded at resolve time rather than reconstructed from
|
|
265
|
+
// whatever names it happens to carry now.
|
|
266
|
+
for (const row of stmtInboundByTargetId.all(id)) referrers.add(row.source_id)
|
|
267
|
+
|
|
268
|
+
// Name direction: edges that ASKED for a name this entity
|
|
269
|
+
// answers to. Covers unresolved/forward references, and an
|
|
270
|
+
// entity that has just started answering to a name nobody
|
|
271
|
+
// could resolve before.
|
|
272
|
+
//
|
|
273
|
+
// Union rather than replacement: strictly a superset of the
|
|
274
|
+
// pre-binding behaviour, so nothing that invalidated before
|
|
275
|
+
// can stop invalidating now. Over-approximating here is
|
|
276
|
+
// cheap — manifest.skipDecision still compares hashes and
|
|
277
|
+
// drops anything that did not actually change.
|
|
278
|
+
for (const key of lookupKeys(entity ?? { id })) {
|
|
279
|
+
for (const row of stmtInboundAny.all(key)) referrers.add(row.source_id)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const sourceId of referrers) {
|
|
283
|
+
enqueue(sourceId, getEntityById?.(sourceId))
|
|
190
284
|
}
|
|
191
285
|
}
|
|
192
286
|
|
|
@@ -208,7 +302,18 @@ export function createIndex(db) {
|
|
|
208
302
|
stmtClearStaticForSource.run(entity.id)
|
|
209
303
|
if (!entity.meta) return
|
|
210
304
|
for (const { path, ref } of extractRefs(entity.meta)) {
|
|
211
|
-
|
|
305
|
+
// Resolve now and record what it bound to. An unresolvable
|
|
306
|
+
// ref still gets its row with target_id '' — a forward
|
|
307
|
+
// reference is a real dependency, and the name-direction
|
|
308
|
+
// query is what will find it when the target appears.
|
|
309
|
+
const targetIds = resolveRefIds(ref)
|
|
310
|
+
if (!targetIds.length) {
|
|
311
|
+
stmtInsertEdge.run(entity.id, ref, '', 'ref', path)
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
for (const targetId of targetIds) {
|
|
315
|
+
stmtInsertEdge.run(entity.id, ref, targetId, 'ref', path)
|
|
316
|
+
}
|
|
212
317
|
}
|
|
213
318
|
}
|
|
214
319
|
|
|
@@ -220,9 +325,16 @@ export function createIndex(db) {
|
|
|
220
325
|
db.transaction(() => {
|
|
221
326
|
stmtClearDynamicForSource.run(sourceId)
|
|
222
327
|
if (!edges?.length) return
|
|
223
|
-
for (const { kind, target } of edges) {
|
|
328
|
+
for (const { kind, target, targetId, targetIds } of edges) {
|
|
224
329
|
if (!kind || !target) continue
|
|
225
|
-
|
|
330
|
+
// Render-time edges already know what they resolved to —
|
|
331
|
+
// the lookup helper had the entity in hand — so no
|
|
332
|
+
// re-resolution here. `targetIds` carries the plural
|
|
333
|
+
// case (one name, several language variants).
|
|
334
|
+
const ids = targetIds?.length ? targetIds : targetId ? [targetId] : ['']
|
|
335
|
+
for (const id of ids) {
|
|
336
|
+
stmtInsertEdge.run(sourceId, target, id, kind, '')
|
|
337
|
+
}
|
|
226
338
|
}
|
|
227
339
|
})
|
|
228
340
|
}
|
|
@@ -260,6 +372,12 @@ export function createIndex(db) {
|
|
|
260
372
|
dynamicInboundFor,
|
|
261
373
|
dynamicOutboundFor,
|
|
262
374
|
inverseClosureOf,
|
|
375
|
+
// Synchronous ref -> entity ids, mirroring refFilter's four
|
|
376
|
+
// forms. Exposed because the manifest needs the same binding
|
|
377
|
+
// the index records: hashing a static $-ref with catalog's
|
|
378
|
+
// findById only ever worked for id-form refs, since findById is
|
|
379
|
+
// an exact primary-key read.
|
|
380
|
+
resolveRefIds,
|
|
263
381
|
// Write
|
|
264
382
|
indexEntity,
|
|
265
383
|
replaceDynamic,
|
|
@@ -459,6 +577,11 @@ export function createRefs(db, prebuiltIndex = null) {
|
|
|
459
577
|
|
|
460
578
|
inverseClosureOf: (seeds) => index.inverseClosureOf(seeds, findById),
|
|
461
579
|
|
|
580
|
+
// Resolve a ref string to the entity ids it binds to — the same
|
|
581
|
+
// four forms refFilter uses, synchronously. The manifest needs
|
|
582
|
+
// it to hash a static $-ref correctly.
|
|
583
|
+
resolveRefIds: (ref) => index.resolveRefIds(ref),
|
|
584
|
+
|
|
462
585
|
// Replace dynamic edges for a source — called by the engine
|
|
463
586
|
// after a successful render via the track API.
|
|
464
587
|
replaceDynamic: (sourceId, edges) => index.replaceDynamic(sourceId, edges),
|
package/src/render.js
CHANGED
|
@@ -74,9 +74,16 @@ function lookupHrefViaDb(href) {
|
|
|
74
74
|
// `origin` (runtime.options.url) makes it absolute so static outputs —
|
|
75
75
|
// emails, feeds — carry a whole URL; absent, it stays base-relative.
|
|
76
76
|
// Unresolved refs return unchanged, staying visible like lookupHref.
|
|
77
|
-
function lookupUrlViaDb(ref, preset, origin) {
|
|
77
|
+
function lookupUrlViaDb(ref, preset, origin, track) {
|
|
78
78
|
if (typeof ref !== 'string') return ref
|
|
79
79
|
const row = stmtIdLookup.get(ref)
|
|
80
|
+
// Record the dependency here, where the row is already read, rather
|
|
81
|
+
// than in the wrapper — a second lookup per call would be paid on
|
|
82
|
+
// every image and media reference in a template. stmtIdLookup is an
|
|
83
|
+
// exact-id read, so a hit means the bound entity IS `ref`; a miss
|
|
84
|
+
// records the name with no binding, which is a forward reference and
|
|
85
|
+
// still a real dependency.
|
|
86
|
+
track?.lookup?.(ref, row ? ref : null)
|
|
80
87
|
if (!row) return ref
|
|
81
88
|
const meta = JSON.parse(row.data).meta || {}
|
|
82
89
|
// A template helper called with one arg still receives the renderer's
|
|
@@ -196,19 +203,22 @@ export default async ({ entity, options, config, context, state, logger, port, t
|
|
|
196
203
|
// no edge to check. A sidecar's findEntities() was tracked all along;
|
|
197
204
|
// these two were the asymmetry.
|
|
198
205
|
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
206
|
+
// Both record the name asked for AND what it bound to. The
|
|
207
|
+
// entities are already in the result, so extracting the ids is
|
|
208
|
+
// free — see track.lookup for why both halves are needed.
|
|
202
209
|
lookupHref: (href) => {
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
const result = lookupHrefViaDb(href)
|
|
211
|
+
// Either a bare entity or a { lang: entity } map — see
|
|
212
|
+
// lookupHrefViaDb's contract above.
|
|
213
|
+
const ids = !result ? []
|
|
214
|
+
: typeof result.id === 'string' ? [result.id]
|
|
215
|
+
: Object.values(result).map(e => e?.id).filter(Boolean)
|
|
216
|
+
track?.lookup?.(href, ids)
|
|
217
|
+
return result
|
|
205
218
|
},
|
|
206
219
|
// Resolve a served-entity reference to its deployed URL, absolute
|
|
207
220
|
// when runtime.options.url is set (ADR-0011).
|
|
208
|
-
lookupUrl: (ref, preset) =>
|
|
209
|
-
if (typeof ref === 'string') track?.lookup?.(ref)
|
|
210
|
-
return lookupUrlViaDb(ref, preset, options.url)
|
|
211
|
-
},
|
|
221
|
+
lookupUrl: (ref, preset) => lookupUrlViaDb(ref, preset, options.url, track),
|
|
212
222
|
content() {
|
|
213
223
|
return readFileSync(entity.source, { encoding: 'utf8' })
|
|
214
224
|
},
|
package/src/report.js
CHANGED
|
@@ -13,7 +13,7 @@ import runtime from './runtime.js'
|
|
|
13
13
|
|
|
14
14
|
function store() {
|
|
15
15
|
runtime.state ??= {}
|
|
16
|
-
runtime.state.report ??= { rendered: [], skipped: [], warnings: [], gated: 0 }
|
|
16
|
+
runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], warnings: [], gated: 0 }
|
|
17
17
|
return runtime.state.report
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -33,6 +33,17 @@ export function reportRendered(entity, reason) {
|
|
|
33
33
|
store().rendered.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// A render that RAN and produced bytes identical to what was already on
|
|
37
|
+
// disk. Distinct from both other outcomes and the interesting one of the
|
|
38
|
+
// three: `rendered` means the output moved, `skipped` means the manifest
|
|
39
|
+
// decided not to look, and this means invalidation was coarser than it
|
|
40
|
+
// needed to be. Nothing downstream should have been disturbed, and the
|
|
41
|
+
// count is the measure of how much conservative invalidation costs.
|
|
42
|
+
export function reportUnchanged(entity) {
|
|
43
|
+
if (!runtime.options?.json) return
|
|
44
|
+
store().unchanged.push({ id: entity?.id, destination: entity?.destination ?? null })
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export function reportSkipped(entity, reason) {
|
|
37
48
|
if (!runtime.options?.json) return
|
|
38
49
|
store().skipped.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
|
|
@@ -54,9 +65,13 @@ export function buildReport() {
|
|
|
54
65
|
return {
|
|
55
66
|
rendered: report.rendered,
|
|
56
67
|
skipped: report.skipped,
|
|
68
|
+
unchanged: report.unchanged,
|
|
57
69
|
warnings: report.warnings,
|
|
58
70
|
summary: {
|
|
59
71
|
rendered: report.rendered.length,
|
|
72
|
+
// Of those renders, how many produced bytes identical to
|
|
73
|
+
// what was already on disk — see reportUnchanged.
|
|
74
|
+
unchanged: report.unchanged.length,
|
|
60
75
|
// Renders that were CONSIDERED and skipped by the manifest.
|
|
61
76
|
skipped: report.skipped.length,
|
|
62
77
|
// Entities gated at import because their source was unchanged, so
|
package/src/track.js
CHANGED
|
@@ -60,15 +60,26 @@ export function createTrack({ partial = true, query = true, lookup = true } = {}
|
|
|
60
60
|
// the manifest and never in the refs index — recorded, and still
|
|
61
61
|
// never scheduling a re-render.
|
|
62
62
|
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
63
|
+
// Records BOTH the string asked for and what it resolved to.
|
|
64
|
+
// The string alone cannot survive the target renaming itself;
|
|
65
|
+
// the resolved id alone cannot express a link to a page that
|
|
66
|
+
// does not exist yet. mikser_refs keeps both columns for the
|
|
67
|
+
// same reason, and the two invalidation directions read one
|
|
68
|
+
// each.
|
|
69
|
+
//
|
|
70
|
+
// A name can resolve to several entities — language variants
|
|
71
|
+
// share a meta.href — so the value is a Set of ids, empty when
|
|
72
|
+
// the lookup found nothing.
|
|
73
|
+
const lookups = new Map()
|
|
70
74
|
track.lookups = lookups
|
|
71
|
-
track.lookup = (target) => {
|
|
75
|
+
track.lookup = (target, resolvedIds) => {
|
|
76
|
+
if (!target || typeof target !== 'string') return
|
|
77
|
+
let ids = lookups.get(target)
|
|
78
|
+
if (!ids) lookups.set(target, ids = new Set())
|
|
79
|
+
for (const id of Array.isArray(resolvedIds) ? resolvedIds : resolvedIds ? [resolvedIds] : []) {
|
|
80
|
+
if (id && typeof id === 'string') ids.add(id)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
84
|
if (partial) {
|
|
74
85
|
const partials = new Set()
|
package/src/utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { hashFile } from 'hasha'
|
|
4
|
-
import { stat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
|
|
4
|
+
import { stat, lstat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
|
|
5
5
|
import { createRequire } from 'node:module'
|
|
6
6
|
import _ from 'lodash'
|
|
7
7
|
import { minimatch } from 'minimatch'
|
|
@@ -17,15 +17,26 @@ import runtime from './runtime.js'
|
|
|
17
17
|
// Pure: synchronous, no I/O, no engine state.
|
|
18
18
|
export function inputHashOf(entity) {
|
|
19
19
|
if (!entity) return ''
|
|
20
|
-
// For file-only entities (no meta/content surface) the upstream
|
|
21
|
-
// file-content checksum from `checksum()` above is the authoritative
|
|
22
|
-
// fingerprint and is already computed.
|
|
23
|
-
if (entity.checksum && entity.meta == null && entity.content == null) {
|
|
24
|
-
return crypto.createHash('sha1').update(String(entity.checksum)).digest('hex')
|
|
25
|
-
}
|
|
26
20
|
return crypto.createHash('sha1').update(JSON.stringify({
|
|
27
21
|
meta: entity.meta ?? null,
|
|
28
22
|
content: entity.content ?? null,
|
|
23
|
+
// The bytes' fingerprint, for entities whose content is not in
|
|
24
|
+
// hand. Files are the whole reason: `files()` sets meta.url on
|
|
25
|
+
// every file entity, so `meta` is never null, so the old
|
|
26
|
+
// "file-only entity" special case (meta == null && content ==
|
|
27
|
+
// null) never fired for a real file — and the fall-through
|
|
28
|
+
// hashed {meta, content} with content null. The result was an
|
|
29
|
+
// inputHash that did not move when an image, video or download
|
|
30
|
+
// changed on disk. The file itself is copied rather than
|
|
31
|
+
// rendered, so nothing looked wrong; what broke was every
|
|
32
|
+
// dependent, whose refClosure dep-hash for that file was frozen
|
|
33
|
+
// and whose skipDecision therefore always said "unchanged".
|
|
34
|
+
//
|
|
35
|
+
// Excluded when content IS present: content is then the
|
|
36
|
+
// authoritative copy of the same bytes, and folding in a
|
|
37
|
+
// checksum computed a different way would make the hash depend
|
|
38
|
+
// on how the checksum happens to be derived.
|
|
39
|
+
checksum: entity.content == null ? entity.checksum ?? null : null,
|
|
29
40
|
// `inputs` is how a plugin declares bytes that are NOT part of the
|
|
30
41
|
// entity's own content but that its output depends on. Whatever is
|
|
31
42
|
// put here participates in the hash, so a change to it invalidates
|
|
@@ -41,16 +52,26 @@ export function inputHashOf(entity) {
|
|
|
41
52
|
})).digest('hex')
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
// Canonical lookup variants for an entity — the same
|
|
55
|
+
// Canonical lookup variants for an entity — the same four forms the
|
|
45
56
|
// schemas plugin, refs subscribers, and the catalog's findRef all use
|
|
46
57
|
// to resolve `$author: '/authors/jane'` against an entity at
|
|
47
58
|
// `/documents/authors/jane.yml` with `meta.href: '/authors/jane'`.
|
|
48
59
|
// Pure: synchronous, no I/O.
|
|
60
|
+
//
|
|
61
|
+
// MUST stay in lockstep with `refFilter` below, which is the forward
|
|
62
|
+
// direction of the same relation. `meta.url` used to be missing here
|
|
63
|
+
// while refFilter had it: a `$hero: /hero.txt` ref to a served path
|
|
64
|
+
// (ADR-0011) recorded an edge against `/hero.txt`, but the file entity
|
|
65
|
+
// at `/files/hero.txt` produced keys `['/files/hero.txt',
|
|
66
|
+
// '/files/hero']` — so nothing ever matched the edge and editing the
|
|
67
|
+
// asset invalidated nothing. Every $-ref to an image, video or
|
|
68
|
+
// download was silently non-invalidating.
|
|
49
69
|
export function lookupKeys(entity) {
|
|
50
70
|
const id = entity?.id
|
|
51
71
|
if (!id) return []
|
|
52
72
|
const keys = [id]
|
|
53
73
|
if (entity.meta?.href) keys.push(entity.meta.href)
|
|
74
|
+
if (entity.meta?.url) keys.push(entity.meta.url)
|
|
54
75
|
if (typeof id === 'string') {
|
|
55
76
|
const stripped = id.replace(/\.[^./]+$/, '')
|
|
56
77
|
if (stripped !== id) keys.push(stripped)
|
|
@@ -59,7 +80,7 @@ export function lookupKeys(entity) {
|
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
// Predicate inverse of `lookupKeys`: does `entity` answer to `refValue`
|
|
62
|
-
// via any of the
|
|
83
|
+
// via any of the four canonical forms? Used by anywhere a per-entity
|
|
63
84
|
// JS test of "does this match the ref" is needed without going through
|
|
64
85
|
// the catalog (e.g. testing an in-hand entity).
|
|
65
86
|
//
|
|
@@ -71,6 +92,7 @@ export function matchesRef(entity, refValue) {
|
|
|
71
92
|
if (!entity || typeof refValue !== 'string') return false
|
|
72
93
|
if (entity.id === refValue) return true
|
|
73
94
|
if (entity.meta?.href === refValue) return true
|
|
95
|
+
if (entity.meta?.url === refValue) return true
|
|
74
96
|
if (typeof entity.id === 'string' && entity.id.replace(/\.[^./]+$/, '') === refValue) return true
|
|
75
97
|
return false
|
|
76
98
|
}
|
|
@@ -910,6 +932,68 @@ export function useCollection(runtime, name) {
|
|
|
910
932
|
}
|
|
911
933
|
}
|
|
912
934
|
|
|
935
|
+
// Write `bytes` to `file`, unless the file already holds exactly those
|
|
936
|
+
// bytes. Returns true if it wrote, false if the file was already correct.
|
|
937
|
+
//
|
|
938
|
+
// Invalidation is deliberately conservative: an entity that merely READ
|
|
939
|
+
// another entity re-renders when that one changes, because the engine
|
|
940
|
+
// cannot know which field was read. That is the right default, and it
|
|
941
|
+
// means renders regularly produce byte-identical output. Writing anyway
|
|
942
|
+
// moves mtime, and three things downstream key off the file rather than
|
|
943
|
+
// its contents:
|
|
944
|
+
//
|
|
945
|
+
// - live reload watches the output folder, so editing one photograph
|
|
946
|
+
// reloaded the browser on pages that had not changed
|
|
947
|
+
// - rsync, `aws s3 sync` and most CDN tools compare size plus mtime,
|
|
948
|
+
// so unchanged pages re-upload
|
|
949
|
+
// - `find out -newer` cannot answer "what did this build change?"
|
|
950
|
+
//
|
|
951
|
+
// Doing it here rather than narrowing the dependency edges fixes every
|
|
952
|
+
// conservative-invalidation case at once, and stays correct as the graph
|
|
953
|
+
// gets more precise instead of becoming redundant.
|
|
954
|
+
//
|
|
955
|
+
// Ordering matters: the size check comes first so the common
|
|
956
|
+
// output-really-changed case never pays for a read, and lstat (not stat)
|
|
957
|
+
// because a destination that is currently a SYMLINK has to be replaced
|
|
958
|
+
// by a real file even when the bytes behind it match — the type of the
|
|
959
|
+
// destination is part of the output, not just its contents.
|
|
960
|
+
export async function writeOutput(file, bytes) {
|
|
961
|
+
// Size first, and WITHOUT materialising a buffer: Buffer.byteLength
|
|
962
|
+
// measures a string in place, while Buffer.from copies it (~250µs for
|
|
963
|
+
// a 1MB page, against ~25µs for the lstat). Since a size mismatch is
|
|
964
|
+
// the common outcome on a build that changed something, the cheap
|
|
965
|
+
// path must not pay for the expensive one.
|
|
966
|
+
const size = Buffer.isBuffer(bytes) ? bytes.length : Buffer.byteLength(bytes)
|
|
967
|
+
let identical = false
|
|
968
|
+
try {
|
|
969
|
+
const info = await lstat(file)
|
|
970
|
+
if (info.isFile() && info.size === size) {
|
|
971
|
+
const existing = await readFile(file)
|
|
972
|
+
identical = Buffer.isBuffer(bytes)
|
|
973
|
+
? bytes.equals(existing)
|
|
974
|
+
: existing.equals(Buffer.from(bytes))
|
|
975
|
+
}
|
|
976
|
+
} catch (err) {
|
|
977
|
+
// Missing or unreadable — fall through and write. Anything else
|
|
978
|
+
// is a bug in this function, and swallowing it would look
|
|
979
|
+
// exactly like "the file wasn't there": a missing `lstat` import
|
|
980
|
+
// made every comparison fail open, so the skip silently never
|
|
981
|
+
// happened while the tests still passed.
|
|
982
|
+
if (err.code !== 'ENOENT' && err.code !== 'EACCES') throw err
|
|
983
|
+
}
|
|
984
|
+
if (identical) return false
|
|
985
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
986
|
+
// Unlink first so an existing hard link or symlink at this path is
|
|
987
|
+
// broken rather than written through.
|
|
988
|
+
try {
|
|
989
|
+
await unlink(file)
|
|
990
|
+
} catch { /* not there, or not removable — writeFile will say so */ }
|
|
991
|
+
// Pass the original value through — writeFile encodes a string
|
|
992
|
+
// directly, so converting first would add a copy for nothing.
|
|
993
|
+
await writeFile(file, bytes)
|
|
994
|
+
return true
|
|
995
|
+
}
|
|
996
|
+
|
|
913
997
|
// ── operating-system and file-manager litter ────────────────────────────
|
|
914
998
|
//
|
|
915
999
|
// Exposing a source folder over a network filesystem (mikser-io-webdav) or
|