mikser-io 9.15.0 → 9.16.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/explain.js +23 -4
- package/src/manifest.js +93 -22
- package/src/refs.js +146 -23
- package/src/render.js +31 -3
- package/src/track.js +40 -1
- package/src/utils.js +30 -8
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/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
|
|
@@ -303,26 +323,48 @@ export function createManifest(db) {
|
|
|
303
323
|
}
|
|
304
324
|
continue
|
|
305
325
|
}
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
326
|
+
// An edge is checked against every key it could have been
|
|
327
|
+
// hit by: the entity it BOUND to, and the name it asked
|
|
328
|
+
// for. entity.id is always lookupKeys()[0], so the engine's
|
|
329
|
+
// mutation maps are already keyed by id — reading them by
|
|
330
|
+
// targetId needs no change there, and a binding survives
|
|
331
|
+
// the target renaming itself.
|
|
332
|
+
//
|
|
333
|
+
// Both, not the first match, because the two can disagree:
|
|
334
|
+
// the bound entity may have been re-persisted unchanged in
|
|
335
|
+
// the same cycle that a DIFFERENT entity started answering
|
|
336
|
+
// to the same name. Stopping at the binding would compare
|
|
337
|
+
// an unchanged hash and skip, silently ignoring the new
|
|
338
|
+
// claimant. The name key also carries unresolved/forward
|
|
339
|
+
// edges, which is how a link to a not-yet-existing page
|
|
340
|
+
// invalidates once that page appears.
|
|
341
|
+
const keys = [
|
|
342
|
+
...(entry.targetIds ?? []),
|
|
343
|
+
...(entry.targetId ? [entry.targetId] : []),
|
|
344
|
+
entry.target,
|
|
345
|
+
]
|
|
346
|
+
for (const key of keys) {
|
|
347
|
+
if (!mutatedRefs?.has(key)) continue
|
|
348
|
+
// Language scope: if the source has a meta.lang AND the
|
|
349
|
+
// mutation came from a different meta.lang, the ref
|
|
350
|
+
// doesn't actually depend on what changed. A `null` lang
|
|
351
|
+
// in the mutation set means a shared (un-localized)
|
|
352
|
+
// entity touched the same key — that does invalidate
|
|
353
|
+
// language-specific sources because the shared entity
|
|
354
|
+
// is the only variant. Sources without a meta.lang are
|
|
355
|
+
// language-agnostic and accept any mutation lang.
|
|
356
|
+
if (sourceLang) {
|
|
357
|
+
const mutatedLangs = mutatedRefs.get(key)
|
|
358
|
+
if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
319
361
|
}
|
|
362
|
+
if (!entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
363
|
+
const currentHash = currentHashes?.get(key)
|
|
364
|
+
if (currentHash === undefined) continue
|
|
365
|
+
if (currentHash === null) return { skip: false, reason: 'ref-changed' }
|
|
366
|
+
if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
320
367
|
}
|
|
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
368
|
}
|
|
327
369
|
return { skip: true, reason: 'unchanged' }
|
|
328
370
|
},
|
|
@@ -427,6 +469,7 @@ export function createManifest(db) {
|
|
|
427
469
|
edges.push({
|
|
428
470
|
kind: 'layout',
|
|
429
471
|
target: entity.layout.id,
|
|
472
|
+
targetId: entity.layout.id,
|
|
430
473
|
hash: inputHashOf(entity.layout),
|
|
431
474
|
})
|
|
432
475
|
}
|
|
@@ -436,10 +479,38 @@ export function createManifest(db) {
|
|
|
436
479
|
edges.push({
|
|
437
480
|
kind: 'partial',
|
|
438
481
|
target,
|
|
482
|
+
targetId: partial ? target : undefined,
|
|
439
483
|
hash: partial ? inputHashOf(partial) : undefined,
|
|
440
484
|
})
|
|
441
485
|
}
|
|
442
486
|
}
|
|
487
|
+
if (track?.lookups) {
|
|
488
|
+
for (const [target, ids] of track.lookups) {
|
|
489
|
+
// The lookup helper resolved this already and handed
|
|
490
|
+
// over the ids, so the hash comes from the BOUND
|
|
491
|
+
// entity. Hashing findById(target) instead — as this
|
|
492
|
+
// did — silently produced no hash at all whenever the
|
|
493
|
+
// target was an href or url form, because findById is
|
|
494
|
+
// an exact primary-key read: it resolves neither
|
|
495
|
+
// meta.href nor meta.url nor a stripped extension.
|
|
496
|
+
// Every such edge was hashless, so the manifest could
|
|
497
|
+
// not tell "target moved" from "target changed".
|
|
498
|
+
//
|
|
499
|
+
// No hash still means "nothing resolved", and
|
|
500
|
+
// skipDecision re-renders a hashless edge whose target
|
|
501
|
+
// mutated — which is what should happen when a page
|
|
502
|
+
// that was linked-to-but-missing finally appears.
|
|
503
|
+
const targetIds = [...ids]
|
|
504
|
+
const bound = targetIds.length === 1 ? findById(targetIds[0]) : null
|
|
505
|
+
edges.push({
|
|
506
|
+
kind: 'lookup',
|
|
507
|
+
target,
|
|
508
|
+
targetId: targetIds.length === 1 ? targetIds[0] : undefined,
|
|
509
|
+
targetIds: targetIds.length > 1 ? targetIds : undefined,
|
|
510
|
+
hash: bound ? inputHashOf(bound) : undefined,
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
}
|
|
443
514
|
if (track?.queries) {
|
|
444
515
|
for (const filter of track.queries) {
|
|
445
516
|
edges.push({ kind: 'query', filter })
|
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
|
|
@@ -187,10 +194,31 @@ export default async ({ entity, options, config, context, state, logger, port, t
|
|
|
187
194
|
// state.layouts.sitemap map that was serialized per render
|
|
188
195
|
// task. Goes through the same WAL-backed read-only handle
|
|
189
196
|
// every worker shares with the engine writer.
|
|
190
|
-
|
|
197
|
+
// Both lookups RECORD what they were asked for, via track.lookup.
|
|
198
|
+
//
|
|
199
|
+
// They read the catalog directly, and until this they told nobody —
|
|
200
|
+
// so nothing knew a page depended on the page it links to. Renaming
|
|
201
|
+
// a target left every page linking to it pointing at a file that no
|
|
202
|
+
// longer existed, on a green build, because manifest.shouldSkip had
|
|
203
|
+
// no edge to check. A sidecar's findEntities() was tracked all along;
|
|
204
|
+
// these two were the asymmetry.
|
|
205
|
+
//
|
|
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.
|
|
209
|
+
lookupHref: (href) => {
|
|
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
|
|
218
|
+
},
|
|
191
219
|
// Resolve a served-entity reference to its deployed URL, absolute
|
|
192
220
|
// when runtime.options.url is set (ADR-0011).
|
|
193
|
-
lookupUrl: (ref, preset) => lookupUrlViaDb(ref, preset, options.url),
|
|
221
|
+
lookupUrl: (ref, preset) => lookupUrlViaDb(ref, preset, options.url, track),
|
|
194
222
|
content() {
|
|
195
223
|
return readFileSync(entity.source, { encoding: 'utf8' })
|
|
196
224
|
},
|
package/src/track.js
CHANGED
|
@@ -40,8 +40,47 @@ export function filterKey(filter) {
|
|
|
40
40
|
// The returned object exposes `partials: Set<string>` and
|
|
41
41
|
// `queries: Array<filter | null>` directly. Consumers iterate either
|
|
42
42
|
// shape; both are owned by the track for the lifetime of the run.
|
|
43
|
-
export function createTrack({ partial = true, query = true } = {}) {
|
|
43
|
+
export function createTrack({ partial = true, query = true, lookup = true } = {}) {
|
|
44
44
|
const track = {}
|
|
45
|
+
if (lookup) {
|
|
46
|
+
// Lookups a TEMPLATE made by name: runtime.href('/contacts'),
|
|
47
|
+
// runtime.lookupUrl('/media/clip.mp4'). Both read the catalog
|
|
48
|
+
// directly, and until this existed neither told anyone — so nothing
|
|
49
|
+
// recorded that a page depends on the page it links to.
|
|
50
|
+
//
|
|
51
|
+
// Measured consequence: rename contacts.md to contact-us.md and only
|
|
52
|
+
// the renamed page re-renders. Every page linking to it keeps a href
|
|
53
|
+
// pointing at a file that no longer exists, on a green build.
|
|
54
|
+
//
|
|
55
|
+
// The edge kind is 'lookup', NOT 'ref': mikser_refs divides ownership
|
|
56
|
+
// by kind — indexEntity owns kind='ref' (static frontmatter $-refs)
|
|
57
|
+
// and clears it per source, while replaceDynamic owns everything
|
|
58
|
+
// else. Writing these as 'ref' got them inserted by replaceDynamic
|
|
59
|
+
// and then wiped by the next indexEntity, so the edge existed in
|
|
60
|
+
// the manifest and never in the refs index — recorded, and still
|
|
61
|
+
// never scheduling a re-render.
|
|
62
|
+
//
|
|
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()
|
|
74
|
+
track.lookups = lookups
|
|
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
|
+
}
|
|
83
|
+
}
|
|
45
84
|
if (partial) {
|
|
46
85
|
const partials = new Set()
|
|
47
86
|
track.partials = partials
|
package/src/utils.js
CHANGED
|
@@ -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
|
}
|