@abide/abide 0.100.0 → 0.102.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/abide.ts +227 -163
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +10 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -56
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +124 -39
  53. package/src/server/answerSocket.ts +141 -48
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +48 -14
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +144 -183
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +81 -39
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/GLOBAL_GETS.ts +9 -3
  107. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  108. package/src/shared/RPC_SERVER.ts +2 -2
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +1 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/shared/menuItem.ts +3 -3
  119. package/src/shared/reactive/Cursor.ts +3 -9
  120. package/src/shared/reactive/Keyed.ts +4 -3
  121. package/src/shared/reactive/NotLanded.ts +1 -1
  122. package/src/shared/reactive/Reactive.ts +81 -60
  123. package/src/shared/reactive/argsContract.ts +4 -4
  124. package/src/shared/reactive/canonicalKey.ts +4 -4
  125. package/src/shared/reactive/chunksOfStream.ts +6 -0
  126. package/src/shared/reactive/index.ts +1 -1
  127. package/src/shared/reactive/recordWrites.ts +103 -5
  128. package/src/shared/reactive/structural.ts +1 -1
  129. package/src/shared/reactive/tracking.ts +155 -21
  130. package/src/shared/reactive/watch.ts +10 -6
  131. package/src/shared/refuse.ts +1 -1
  132. package/src/shared/routePattern.ts +82 -46
  133. package/src/shared/rpc.ts +47 -13
  134. package/src/shared/rpcRequest.ts +27 -3
  135. package/src/shared/url.ts +4 -44
  136. package/src/shared/writePath.ts +1 -1
  137. package/src/ui/LAYER_NAMES.ts +1 -1
  138. package/src/ui/List.ts +3 -3
  139. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  140. package/src/ui/Mismatch.ts +1 -1
  141. package/src/ui/RawRun.ts +1 -1
  142. package/src/ui/Router.ts +38 -31
  143. package/src/ui/Scope.ts +1 -1
  144. package/src/ui/TryRegion.ts +2 -2
  145. package/src/ui/applyHead.ts +33 -0
  146. package/src/ui/index.ts +1 -1
  147. package/src/ui/menuBridge.ts +1 -1
  148. package/src/ui/moveNode.ts +1 -1
  149. package/src/ui/reconcile.ts +2 -2
  150. package/src/ui/reportMenu.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -5,8 +5,9 @@
5
5
  //
6
6
  // ONLY ARRAYS AND PLAIN OBJECTS ARE WRAPPED. A `Map`, a `Set` or a class instance keeps internal
7
7
  // slots a proxy cannot stand in for — `Map.prototype.set` refuses a proxy as its receiver — so one
8
- // reached is marked written the moment it is read, path and all. That over-reports, and an
9
- // over-report costs a row remade where an under-report shows a stale one.
8
+ // reached is marked written the moment it is read, path and all, and each object a `Map` or a `Set`
9
+ // hands out is marked as it leaves (`heldView`). That over-reports, and an over-report costs a row
10
+ // remade where an under-report shows a stale one.
10
11
  //
11
12
  // 18.11 — AND WHERE A SINK IS HANDED IN, each write is kept as data too, as JSON Patch (RFC 6902) at
12
13
  // the JSON Pointer it was reached by: a member set is `add`, an index `replace` (or `add` one past
@@ -49,7 +50,15 @@ export function recordWrites<Value>(
49
50
  sink?: { ops: PatchOp[]; lost: boolean },
50
51
  ): void {
51
52
  if (!isWrappable(root)) {
52
- mutate(root)
53
+ // A root no proxy can stand in for is written as a whole: what the callback reads out of it
54
+ // reaches the rows that show it with no proxy to say so.
55
+ if (root === null || typeof root !== 'object') {
56
+ mutate(root)
57
+ return
58
+ }
59
+ PATCHES.epoch += 1
60
+ PATCHES.written.set(root, PATCHES.epoch)
61
+ mutate(heldView(root, PATCHES.epoch) as Value)
53
62
  return
54
63
  }
55
64
  PATCHES.epoch += 1
@@ -217,9 +226,10 @@ export function recordWrites<Value>(
217
226
  }
218
227
  if (entry.proxy !== null) return entry.proxy
219
228
  mark(value)
229
+ entry.view ??= heldView(value, epoch)
220
230
  // Reached, and outside what a proxy can watch: whatever it does next is not kept.
221
231
  if (sink !== undefined) sink.lost = true
222
- return value
232
+ return entry.view
223
233
  },
224
234
  set(held, property, value) {
225
235
  mark(held)
@@ -363,7 +373,7 @@ function pointerOf(path: (string | number)[]): string {
363
373
  /**
364
374
  * The JSON Patch an array method makes, read off the array BEFORE it runs.
365
375
  *
366
- * A `splice`'s start and count are normalised the way the method does, so the `remove`s and `add`s
376
+ * A `splice`'s start and count are normalized the way the method does, so the `remove`s and `add`s
367
377
  * name the indices it touches.
368
378
  */
369
379
  function opsOfCall(
@@ -421,10 +431,13 @@ class Reached {
421
431
  * Written in this patch, directly or below — `PATCHES.written` holds this patch's epoch for it.
422
432
  */
423
433
  marked: boolean
434
+ /** What one no proxy can stand in for is handed out as: `heldView` of it. */
435
+ view: object | undefined
424
436
  constructor(proxy: object | null, parent: object | Parents | null) {
425
437
  this.proxy = proxy
426
438
  this.parent = parent
427
439
  this.marked = false
440
+ this.view = undefined
428
441
  }
429
442
  }
430
443
 
@@ -463,6 +476,91 @@ class Parents {
463
476
  }
464
477
  }
465
478
 
479
+ /**
480
+ * `container` as the callback is handed it: a `Map` or a `Set` that marks written each object key,
481
+ * value or member it hands out, and anything else as it is.
482
+ *
483
+ * A row over `[...map.values()]` reads one of them, and a write the callback makes to it through
484
+ * `map.get(id)` reaches it with nothing on the way to record it — so each one taken out is taken as
485
+ * written. AS IT LEAVES rather than all of them up front, which would make a patch of one entry cost
486
+ * the whole container. One level down: a row reads its item.
487
+ */
488
+ function heldView(container: object, epoch: number): object {
489
+ if (!(container instanceof Map) && !(container instanceof Set))
490
+ return container
491
+ return new Proxy(container, new HeldHandler(epoch))
492
+ }
493
+
494
+ // `Map`'s and `Set`'s own methods run on the container itself, which is the receiver their internal
495
+ // slots answer to; the ones that hand an object out mark it first.
496
+ class HeldHandler {
497
+ epoch: number
498
+ constructor(epoch: number) {
499
+ this.epoch = epoch
500
+ }
501
+ get(
502
+ held: Map<unknown, unknown> | Set<unknown>,
503
+ property: string | symbol,
504
+ ): unknown {
505
+ if (property === TARGET) return held
506
+ const value = Reflect.get(held, property, held)
507
+ if (typeof value !== 'function') return value
508
+ const epoch = this.epoch
509
+ if (property === 'get')
510
+ return (key: unknown) =>
511
+ markedOut((held as Map<unknown, unknown>).get(key), epoch)
512
+ if (property === 'forEach')
513
+ return (
514
+ each: (value: unknown, key: unknown, set: unknown) => void,
515
+ self?: unknown,
516
+ ) => {
517
+ for (const [key, member] of held.entries())
518
+ each.call(
519
+ self,
520
+ markedOut(member, epoch),
521
+ markedOut(key, epoch),
522
+ held,
523
+ )
524
+ }
525
+ if (
526
+ property === 'values' ||
527
+ property === 'keys' ||
528
+ property === 'entries' ||
529
+ property === Symbol.iterator
530
+ )
531
+ // A Map's own iterator is its entries; a Set's is its values.
532
+ return () =>
533
+ markedEach(
534
+ (value as () => Iterator<unknown>).call(held),
535
+ property === 'entries' ||
536
+ (property === Symbol.iterator && held instanceof Map),
537
+ epoch,
538
+ )
539
+ return (value as (...args: unknown[]) => unknown).bind(held)
540
+ }
541
+ }
542
+
543
+ function* markedEach(
544
+ each: Iterator<unknown>,
545
+ pairs: boolean,
546
+ epoch: number,
547
+ ): Generator<unknown> {
548
+ for (let next = each.next(); next.done !== true; next = each.next()) {
549
+ if (pairs) {
550
+ const [key, value] = next.value as [unknown, unknown]
551
+ markedOut(key, epoch)
552
+ markedOut(value, epoch)
553
+ } else markedOut(next.value, epoch)
554
+ yield next.value
555
+ }
556
+ }
557
+
558
+ function markedOut<Value>(value: Value, epoch: number): Value {
559
+ if (typeof value === 'object' && value !== null)
560
+ PATCHES.written.set(value, epoch)
561
+ return value
562
+ }
563
+
466
564
  /** Whether a proxy can stand in for `value`: an array or a plain object. */
467
565
  function isWrappable(value: unknown): value is object {
468
566
  if (value === null || typeof value !== 'object') return false
@@ -2,7 +2,7 @@
2
2
  * The by-value comparator an app passes as `identity`: deep equality over arrays and plain objects.
3
3
  *
4
4
  * Not the default: the default is the reference, and this is what a value whose producer
5
- * RE-MATERIALISES declares — `reactive(() => getInvoice({ id }), { identity: structural })`. A
5
+ * RE-MATERIALIZES declares — `reactive(() => getInvoice({ id }), { identity: structural })`. A
6
6
  * fresh-but-equal payload waking every reader is why it exists at all; what it stops being is the
7
7
  * price every other value pays.
8
8
  *
@@ -87,6 +87,11 @@ export type Subscriber = {
87
87
  * the identity gate that exists to stop exactly that cannot see a production nobody minted.
88
88
  */
89
89
  probeSources: Tracked[]
90
+ /**
91
+ * How far into `probeSources` this run has re-asked — `cursor` for the probe edge, and see
92
+ * `trackProbe` for why it is not the same protocol.
93
+ */
94
+ probeCursor: number
90
95
  /**
91
96
  * How far into `sources` this run has matched.
92
97
  *
@@ -111,8 +116,22 @@ export type Subscriber = {
111
116
 
112
117
  export type Tracked = {
113
118
  readers: Subscriber[]
119
+ /** How many of `readers` are disposed and not yet swept out — see `dropFrom`. */
120
+ deadReaders: number
114
121
  /** Who asked about this value's probes. Apart from `readers` — see `probeSources`. */
115
122
  probeReaders: Subscriber[]
123
+ /** `deadReaders` for `probeReaders`. */
124
+ deadProbeReaders: number
125
+ }
126
+
127
+ /** A node that is nothing but its edges — a probe signal with no value behind it. */
128
+ export function newTracked(): Tracked {
129
+ return {
130
+ readers: [],
131
+ deadReaders: 0,
132
+ probeReaders: [],
133
+ deadProbeReaders: 0,
134
+ }
116
135
  }
117
136
 
118
137
  /** The reader whose run is on the stack, or `null` where reads subscribe to nothing. */
@@ -144,6 +163,7 @@ export function newSubscriber(
144
163
  return {
145
164
  sources,
146
165
  probeSources: [],
166
+ probeCursor: 0,
147
167
  cursor: 0,
148
168
  seen: null,
149
169
  seenThrough: 0,
@@ -167,8 +187,9 @@ export function newSubscriber(
167
187
  * of the loop beside this one is what CLAUDE.md's sibling-seam rule refuses.
168
188
  */
169
189
  export function dropReader(source: Tracked, reader: Subscriber): void {
170
- removeFrom(source.readers, reader)
171
- if (source.readers.length === 0) unread(source)
190
+ const readers = source.readers
191
+ source.deadReaders = dropFrom(readers, reader, source.deadReaders)
192
+ if (readers.length === 0) unread(source)
172
193
  }
173
194
 
174
195
  /**
@@ -218,40 +239,102 @@ export function untracked<First, Second, Result>(
218
239
  * rather than on a value.
219
240
  */
220
241
  export function dropProbeReader(source: Tracked, reader: Subscriber): void {
221
- removeFrom(source.probeReaders, reader)
222
- if (source.probeReaders.length === 0) unread(source)
242
+ const readers = source.probeReaders
243
+ source.deadProbeReaders = dropFrom(readers, reader, source.deadProbeReaders)
244
+ if (readers.length === 0) unread(source)
223
245
  }
224
246
 
225
- function removeFrom(readers: Subscriber[], reader: Subscriber): void {
247
+ /**
248
+ * Takes `reader` off `readers`, of which `dead` are disposed, and returns how many are now.
249
+ *
250
+ * A DISPOSED READER IS LEFT WHERE IT IS AND COUNTED, rather than found. Finding it is a scan of
251
+ * every reader of the value, and a teardown drops them all — n rows reading one page value, torn
252
+ * down, paid n²/2 compares, and a keyed replace is the worst order there is: the new rows are
253
+ * pushed behind the old ones the scan walks past. 10,000 rows went 16-39 ms to 0.2-0.4. Every
254
+ * walk over a reader list skips `!alive`, so a dead slot costs a walk one test until the
255
+ * sweep below takes it out.
256
+ *
257
+ * SWEPT ONCE HALF THE LIST IS DEAD, which keeps a walk to at most twice the live readers and the
258
+ * sweep amortized O(1) a drop — and keeps the invariant every `length` test leans on: a list with
259
+ * entries has a LIVE one, since a list that is all dead is at least half dead. HALF, NOT MORE THAN
260
+ * HALF: a keyed replace ends on exactly half, n old rows dead behind n new ones, and a sweep that
261
+ * waited for one more drop kept the old rows — and through each one's `notify`, its scope and its
262
+ * nodes — reachable until some unrelated reader left. An over-count — a
263
+ * reader dropped twice — only sweeps early, and the sweep recounts from zero.
264
+ *
265
+ * A LIVE reader still scans: that is a run's own truncation or an orphan's release, a reader
266
+ * that may subscribe again, so it cannot be left to read as dead.
267
+ */
268
+ function dropFrom(
269
+ readers: Subscriber[],
270
+ reader: Subscriber,
271
+ dead: number,
272
+ ): number {
273
+ if (!reader.alive) dead += 1
274
+ else purge(readers, reader)
275
+ if (dead * 2 < readers.length) return dead
276
+ let kept = 0
226
277
  for (let index = 0; index < readers.length; index += 1) {
227
- if (readers[index] !== reader) continue
228
- readers[index] = readers[readers.length - 1] as Subscriber
229
- readers.pop()
230
- return
278
+ const survivor = readers[index] as Subscriber
279
+ if (survivor.alive) {
280
+ readers[kept] = survivor
281
+ kept += 1
282
+ }
231
283
  }
284
+ readers.length = kept
285
+ return 0
232
286
  }
233
287
 
234
288
  /**
235
289
  * Subscribes `reader` to `source`'s probes, once per run.
236
290
  *
237
- * A PROBE EDGE IS REBUILT PER RUN rather than cursored. A body reads few probes where it reads
238
- * many values, and a guard that stops being taken must stop waking — so the list is dropped at
239
- * the start of a run and re-made by the reads that happen.
291
+ * A PROBE EDGE IS CURSORED, and kept in the order this run asked. `probeSources[0..probeCursor)`
292
+ * is what this run has asked so far; a source asked again is moved to the cursor rather than
293
+ * dropped and re-pushed, so a run that asks what its last one did touches no source's list at
294
+ * all. It used to be dropped at the start of every run and re-made — which put a scan of the
295
+ * SOURCE'S probe readers behind every rerun, and n guards over one shared `ready()` woken by one
296
+ * flip paid n²: 10,000 went 39 ms to 0.4. A guard that stops being taken still stops waking:
297
+ * what the run did not re-ask is past the cursor at its end, and `truncateProbes` drops it.
298
+ *
299
+ * NOT `track`'s protocol, because the list is a SET in no particular order where `sources` is
300
+ * positional: a re-ask out of order is a swap, never a divergence, and a body asks one to three.
240
301
  */
241
302
  export function trackProbe(source: Tracked, reader: Subscriber): void {
242
303
  const asked = reader.probeSources
243
- for (let index = 0; index < asked.length; index += 1)
304
+ const at = reader.probeCursor
305
+ if (asked[at] === source) {
306
+ reader.probeCursor = at + 1
307
+ return
308
+ }
309
+ for (let index = 0; index < at; index += 1)
244
310
  if (asked[index] === source) return
245
- asked.push(source)
246
- source.probeReaders.push(reader)
311
+ let found = asked.length
312
+ for (let index = at + 1; index < asked.length; index += 1)
313
+ if (asked[index] === source) {
314
+ found = index
315
+ break
316
+ }
317
+ if (found === asked.length) {
318
+ asked.push(source)
319
+ source.probeReaders.push(reader)
320
+ }
321
+ asked[found] = asked[at] as Tracked
322
+ asked[at] = source
323
+ reader.probeCursor = at + 1
247
324
  }
248
325
 
249
- /** Drops every probe subscription `reader` holds. */
250
- export function detachProbes(reader: Subscriber): void {
326
+ /** Drops the probe subscriptions `reader`'s run did not re-ask — the tail past its cursor. */
327
+ export function truncateProbes(reader: Subscriber): void {
251
328
  const asked = reader.probeSources
252
- for (let index = 0; index < asked.length; index += 1)
329
+ for (let index = reader.probeCursor; index < asked.length; index += 1)
253
330
  dropProbeReader(asked[index] as Tracked, reader)
254
- asked.length = 0
331
+ asked.length = reader.probeCursor
332
+ }
333
+
334
+ /** Drops every probe subscription `reader` holds. */
335
+ export function detachProbes(reader: Subscriber): void {
336
+ reader.probeCursor = 0
337
+ truncateProbes(reader)
255
338
  }
256
339
 
257
340
  // How far `kept * dropped` must outgrow `kept + dropped` before `truncate` builds a `Set` over the
@@ -278,8 +361,9 @@ const SET_OVER_SCAN = 20
278
361
  * `SET_OVER_SCAN`): at 1000/999 the scan is one probe per dropped slot and building the Set
279
362
  * doubled the flip, so the two are priced against each other instead.
280
363
  *
281
- * A duplicate inside the DROPPED tail needs no test — `removeFrom` takes one occurrence and the
282
- * second call finds none.
364
+ * A duplicate inside the DROPPED tail needs no test — `dropFrom` takes one occurrence and the
365
+ * second call finds none, or for a disposed reader counts one dead too many, which only sweeps
366
+ * early.
283
367
  *
284
368
  * Exported because a tracked run's end is exactly this and nothing more: the TEST that there is a
285
369
  * tail is the caller's — two field loads, on the rare arm.
@@ -416,3 +500,53 @@ export function detach(reader: Subscriber): void {
416
500
  truncate(reader)
417
501
  if (reader.probeSources.length !== 0) detachProbes(reader)
418
502
  }
503
+
504
+ /**
505
+ * Lets go of every subscription `reader` holds as a DISPOSAL — counted dead where it stands
506
+ * (`dropFrom`) rather than scanned for — while keeping its lists, so `revive` can find what it left.
507
+ *
508
+ * FOR A RELEASED ORPHAN (12.14): n rows each deriving off one page value, torn down, scanned the
509
+ * page value's readers once a row. The orphan may be read again, which is what `revive` is for —
510
+ * paid by the read that comes back, which is rare, rather than by the release, which is per row.
511
+ */
512
+ export function tombstone(reader: Subscriber): void {
513
+ reader.alive = false
514
+ const sources = reader.sources
515
+ for (let index = 0; index < sources.length; index += 1)
516
+ dropReader(sources[index] as Tracked, reader)
517
+ reader.cursor = 0
518
+ const asked = reader.probeSources
519
+ for (let index = 0; index < asked.length; index += 1)
520
+ dropProbeReader(asked[index] as Tracked, reader)
521
+ reader.probeCursor = 0
522
+ }
523
+
524
+ /**
525
+ * Takes a `tombstone`d reader's dead entries out of whatever list has not swept them yet, and
526
+ * makes it live with nothing subscribed — the state a fresh reader starts its run in.
527
+ */
528
+ export function revive(reader: Subscriber): void {
529
+ reader.alive = true
530
+ const sources = reader.sources
531
+ for (let index = 0; index < sources.length; index += 1) {
532
+ const source = sources[index] as Tracked
533
+ if (purge(source.readers, reader)) source.deadReaders -= 1
534
+ }
535
+ sources.length = 0
536
+ const asked = reader.probeSources
537
+ for (let index = 0; index < asked.length; index += 1) {
538
+ const source = asked[index] as Tracked
539
+ if (purge(source.probeReaders, reader)) source.deadProbeReaders -= 1
540
+ }
541
+ asked.length = 0
542
+ }
543
+
544
+ function purge(readers: Subscriber[], reader: Subscriber): boolean {
545
+ for (let index = 0; index < readers.length; index += 1) {
546
+ if (readers[index] !== reader) continue
547
+ readers[index] = readers[readers.length - 1] as Subscriber
548
+ readers.pop()
549
+ return true
550
+ }
551
+ return false
552
+ }
@@ -20,12 +20,12 @@ import type { Reactive } from './Reactive.ts'
20
20
  import { currentScope, runInScope } from './scoping.ts'
21
21
  import {
22
22
  detach,
23
- detachProbes,
24
23
  newSubscriber,
25
24
  type Subscriber,
26
25
  TRACKING,
27
26
  type Tracked,
28
27
  truncate,
28
+ truncateProbes,
29
29
  untracked,
30
30
  } from './tracking.ts'
31
31
 
@@ -203,7 +203,7 @@ export function watchOver(
203
203
  // UNTRACKED, AND THE CLEAR IS WHAT MAKES IT SO. The sources are given rather
204
204
  // than discovered, but the effect still RUNS wherever it was reached from — a
205
205
  // `s.watch` opened inside a `watch` had its reads attributed to the OUTER
206
- // reader, which then re-ran on a value it never read. A mirror is the same
206
+ // reader, which then reran on a value it never read. A mirror is the same
207
207
  // shape one turn up: it runs inside the mark walk, and 4.7 has the `transform`
208
208
  // it calls there running untracked.
209
209
  const previous = TRACKING.reader
@@ -219,7 +219,7 @@ export function watchOver(
219
219
  // subscribed to, so its landing is what runs this again.
220
220
  if (thrown instanceof NotLanded) return
221
221
  // 12.8: one that throws once usually throws every run, and a watch
222
- // re-running into the same throw is a loop with a log line per iteration.
222
+ // rerunning into the same throw is a loop with a log line per iteration.
223
223
  reader.alive = false
224
224
  detach(reader)
225
225
  effectThrew(thrown)
@@ -242,13 +242,13 @@ export function watchOver(
242
242
  }
243
243
 
244
244
  /**
245
- * The bare form: runs `effect` as a tracked context, re-running when anything it read changes.
245
+ * The bare form: runs `effect` as a tracked context, rerunning when anything it read changes.
246
246
  *
247
247
  * UNOWNED — the caller holds the stop. `watch` is this handed to its owners (`owned`); a template
248
248
  * binding's `Scope` and a `{#try}`'s retry hold their own, and call this direct.
249
249
  *
250
250
  * A SECOND BODY BESIDE `watchOver` ON PURPOSE, and it reads as the duplication it is: the two
251
- * differ by the cursor reset, the probe rebuild and the tail trim, and the rest is the same lines.
251
+ * differ by the cursor resets and the tail trims, and the rest is the same lines.
252
252
  * ONE BODY WITH A `tracked` FLAG WAS BUILT AND MEASURED, under V8 against this pair, interleaved
253
253
  * four runs a side: `write a value that changed` — one bound watch — went 15.6 ns to 21.4 in every
254
254
  * run, and `mount 50 readers` +12% (+7% under JSC). Three flag tests cannot cost 6 ns. The likely
@@ -276,7 +276,7 @@ export function watchTracked(effect: Effect): Disposer {
276
276
  const previous = TRACKING.reader
277
277
  TRACKING.reader = reader
278
278
  reader.cursor = 0
279
- if (reader.probeSources.length !== 0) detachProbes(reader)
279
+ reader.probeCursor = 0
280
280
  reader.running = true
281
281
  try {
282
282
  WORK.wakes += 1
@@ -285,6 +285,8 @@ export function watchTracked(effect: Effect): Disposer {
285
285
  reader.running = false
286
286
  TRACKING.reader = previous
287
287
  if (reader.cursor < reader.sources.length) truncate(reader)
288
+ if (reader.probeCursor < reader.probeSources.length)
289
+ truncateProbes(reader)
288
290
  // The abort reaches here too: an effect that read a value which has
289
291
  // not landed is not an effect that failed, it is one whose turn has
290
292
  // not come. The read subscribed before it threw, so the value landing
@@ -299,6 +301,8 @@ export function watchTracked(effect: Effect): Disposer {
299
301
  reader.running = false
300
302
  TRACKING.reader = previous
301
303
  if (reader.cursor < reader.sources.length) truncate(reader)
304
+ if (reader.probeCursor < reader.probeSources.length)
305
+ truncateProbes(reader)
302
306
  }
303
307
  reader.notify()
304
308
  return (): void => {
@@ -15,7 +15,7 @@ import type { JsonValue } from './JsonValue.ts'
15
15
  import { namedLogger } from './log.ts'
16
16
  import { type Found, runSchema, type Schema } from './Schema.ts'
17
17
 
18
- /** The `abide:refuse` logger (28.9) — 15.13 and 15.15's namespace. */
18
+ /** The `abide:refuse` logger (28.8) — 15.13 and 15.15's namespace. */
19
19
  const REFUSE_LOG = namedLogger('abide:refuse')
20
20
 
21
21
  /**