mikser-io 11.10.3 → 11.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.10.3",
3
+ "version": "11.10.5",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -52,6 +52,12 @@ export const REASON = Object.freeze({
52
52
  RELOAD: 'reload',
53
53
  REBUILD_INTERRUPTED: 'rebuild-interrupted',
54
54
  OUTPUT_MISSING: 'output-missing',
55
+ // A catalog entity with a layout that has no snapshot at all — distinct
56
+ // from NEVER_RENDERED below, which is a gate reporting what it found. This
57
+ // one is an OVERRIDE: nothing would otherwise look at such an entity,
58
+ // because every other mechanism starts from a snapshot. Claimed once per
59
+ // entity per process; see claimUnrendered.
60
+ NEVER_RECORDED: 'never-recorded',
55
61
  // Evidence — each layer's business, named here so the vocabulary is
56
62
  // legible as a whole.
57
63
  UNCHANGED: 'unchanged',
@@ -117,6 +123,49 @@ export function missingOutputIds() {
117
123
 
118
124
  export function forgetMissingOutputs() {
119
125
  cachedMissingOutputs = null
126
+ // `unrenderedOnce` is NOT dropped here. It is per PROCESS, not per cycle:
127
+ // clearing it would recompute the set every cycle and hand back the same
128
+ // un-renderable entity every time, which is the churn the claim-once rule
129
+ // exists to prevent.
130
+ }
131
+
132
+ // Entities with a layout that have never recorded a snapshot.
133
+ //
134
+ // The gap `missingOutputIds` cannot see: it walks SNAPSHOTS, so an entity
135
+ // that never got one is invisible to it, and nothing else looks either — the
136
+ // source gate sees the file unchanged and never journals it again. That is
137
+ // the state a superseded DELETE left behind, and the reason it was permanent:
138
+ // a missing page, green builds forever, and only --audit-output to say so.
139
+ //
140
+ // ONE DISPATCH PER ENTITY, per process, which is the whole safety of it.
141
+ // Membership is CLAIMED rather than read: an id comes out once and is then
142
+ // gone from the set. An entity that legitimately cannot render — a layout
143
+ // that produces no destination is the real case, and `mikser_explain` names
144
+ // it — therefore costs one wasted dispatch rather than one per cycle for the
145
+ // life of the process. Re-dispatching such an entity forever is the failure
146
+ // this net could otherwise become, and it would be quiet: slower builds and
147
+ // nothing to point at.
148
+ //
149
+ // Computed lazily and only once, then dropped alongside the missing-output
150
+ // cache at the end of onFinalize.
151
+ let unrenderedOnce = null
152
+
153
+ export function claimUnrendered(id) {
154
+ if (id === undefined) return false
155
+ if (!unrenderedOnce) {
156
+ // Nothing has ever rendered, so nothing is missing: this is a cold
157
+ // build and every entity is about to be dispatched on its own merits.
158
+ // Without this the whole corpus answers `never-recorded` on a first
159
+ // build — true, useless, and it would take the real reason off every
160
+ // page in `--json`.
161
+ const recorded = runtime.manifest?.size?.() ?? 0
162
+ unrenderedOnce = recorded > 0
163
+ ? new Set(runtime.manifest?.unrenderedIds?.() ?? [])
164
+ : new Set()
165
+ }
166
+ if (!unrenderedOnce.has(id)) return false
167
+ unrenderedOnce.delete(id)
168
+ return true
120
169
  }
121
170
 
122
171
  // What overrides a gate's own evidence, or null when nothing does.
@@ -138,6 +187,10 @@ export function bypassReason({ reload = false, id } = {}) {
138
187
  // this module exists, and this is the first override added since.
139
188
  if (runtime.options?.cacheRebuildInterrupted) return REASON.REBUILD_INTERRUPTED
140
189
  if (id !== undefined && missingOutputIds().has(id)) return REASON.OUTPUT_MISSING
190
+ // Last, and after OUTPUT_MISSING on purpose: an entity with a snapshot
191
+ // whose file went missing is the better-understood case and keeps its own
192
+ // reason, which `--json` callers and the scenario suite already match on.
193
+ if (claimUnrendered(id)) return REASON.NEVER_RECORDED
141
194
  return null
142
195
  }
143
196
 
@@ -53,8 +53,47 @@ onFinalize(async () => {
53
53
  const newDestinationsByParent = new Map()
54
54
  const lostPagination = new Set()
55
55
 
56
- for await (const { entity } of useJournal('Manifest cleanup', [OPERATION.DELETE])) {
57
- deletedIds.push(entity.id)
56
+ // A DELETE only counts if nothing re-created the entity after it.
57
+ //
58
+ // Collected with no regard for supersession, this unlinked a page that
59
+ // had just been written. A file created, deleted and re-created AT THE
60
+ // SAME PATH while one cycle was held open leaves that cycle holding
61
+ // CREATE, DELETE and CREATE for one id — and since 11.10.2 a cancelled
62
+ // cycle's entries are carried rather than dropped, which is what lets
63
+ // the stale DELETE reach the cycle that finally renders the entity. That
64
+ // fix is right; this is the other end of it.
65
+ //
66
+ // The staging below and the `claimedByThisCycle` guard were both written
67
+ // for the RENAME case, where the DELETE carries the OLD id and the RENDER
68
+ // a NEW one. There the new destination is correctly protected. When the
69
+ // id is the SAME, the guard skipped the very destination just written,
70
+ // `stillClaimed` was empty because a first render has no snapshot row
71
+ // yet, and the unlink ran: an empty output directory, no snapshot, no
72
+ // failure, `Rendered: 1` and a green build. Nothing recovered it — the
73
+ // source gate saw the file unchanged, and missingOutputIds() iterates
74
+ // SNAPSHOTS, so an entity that never got one is invisible to it.
75
+ //
76
+ // ORDER, not catalog presence. Catalog presence gives the same answers
77
+ // today, but only because catalog.js's finalize drain happens to be
78
+ // registered before this one (index.js exports catalog.js first), so the
79
+ // row is already gone when cleanup reads it. That is an invisible
80
+ // coupling: reordering two exports would silently restore this bug. The
81
+ // journal knows the answer on its own, and mikser-io-layouts already
82
+ // reconciles the same journal by order when it seeds dispatch.
83
+ const lastDelete = new Map()
84
+ const lastWrite = new Map()
85
+ for await (const { id: seq, operation, entity } of useJournal(
86
+ 'Manifest cleanup', [OPERATION.DELETE, OPERATION.CREATE, OPERATION.UPDATE])) {
87
+ if (!entity?.id) continue
88
+ const seen = operation === OPERATION.DELETE ? lastDelete : lastWrite
89
+ const prior = seen.get(entity.id)
90
+ if (prior === undefined || seq > prior) seen.set(entity.id, seq)
91
+ }
92
+ for (const [id, seq] of lastDelete) {
93
+ // `>` and not `>=`: a render that opts out of the catalog journals its
94
+ // DELETE after the entity's own CREATE, so the DELETE is genuinely
95
+ // later and must win — the rule 2e documents.
96
+ if (seq > (lastWrite.get(id) ?? -1)) deletedIds.push(id)
58
97
  }
59
98
 
60
99
  // Every destination claimed by a render task this cycle — whether it
@@ -65,6 +65,10 @@ import { sha1, describeInputChange, buildRefClosure, buildSnapshot, rowToSnap, s
65
65
 
66
66
 
67
67
  export function createManifest(db) {
68
+ // See unrenderedIds(): prepared on first use, because it is the one
69
+ // statement that reaches outside the manifest's own tables.
70
+ let stmtUnrendered
71
+
68
72
  const {
69
73
  stmtCollisions,
70
74
  stmtClaimants,
@@ -504,6 +508,49 @@ export function createManifest(db) {
504
508
  return [...new Set(stmtAllFailures.all().map(row => row.id))]
505
509
  },
506
510
 
511
+ // Catalog entities that should have rendered and never did.
512
+ //
513
+ // The state a superseded DELETE used to leave behind: a catalog row
514
+ // with a layout, an output written and then unlinked, and NO snapshot
515
+ // — which made it invisible to missingOutputIds(), because that walks
516
+ // snapshots. The source gate saw the file unchanged, so nothing
517
+ // journalled it again either. Permanently missing page, green builds
518
+ // forever, and only --audit-output to say so.
519
+ //
520
+ // Both exclusions are load-bearing:
521
+ // - a snapshot means it HAS rendered, so there is nothing to
522
+ // recover;
523
+ // - a failure means the render was attempted and threw. A failed
524
+ // render writes no snapshot ON PURPOSE, so the last good bytes
525
+ // survive, and the retry path owns that case. Re-dispatching it
526
+ // here would fight the mechanism that exists for it.
527
+ //
528
+ // `meta_layout` is the column the layouts plugin mirrors its match
529
+ // into and carries a partial index, so this is an index scan that
530
+ // finds nothing in a healthy build.
531
+ //
532
+ // PREPARED LAZILY, and that is not a style choice: this is the only
533
+ // statement here that reaches outside the manifest's own tables, and
534
+ // a manifest is legitimately built against a database that holds no
535
+ // catalog — the unit suite does exactly that. Preparing it eagerly
536
+ // threw SQLITE_ERROR at construction and took twenty-one unrelated
537
+ // tests with it.
538
+ unrenderedIds() {
539
+ if (stmtUnrendered === undefined) {
540
+ try {
541
+ stmtUnrendered = db.prepare(`
542
+ SELECT e.id FROM mikser_entities e
543
+ LEFT JOIN mikser_snapshots s ON s.id = e.id
544
+ LEFT JOIN mikser_failures f ON f.id = e.id
545
+ WHERE e.meta_layout IS NOT NULL AND s.id IS NULL AND f.id IS NULL
546
+ `)
547
+ } catch {
548
+ stmtUnrendered = null // no catalog here; nothing to recover
549
+ }
550
+ }
551
+ return stmtUnrendered ? stmtUnrendered.all().map(row => row.id) : []
552
+ },
553
+
507
554
  allFailures() {
508
555
  return stmtAllFailures.all()
509
556
  },