dsh-instruction-memory 1.4.0 → 1.4.2

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/lib/index.js CHANGED
@@ -30,7 +30,7 @@ import { readFileSync, watch } from 'node:fs'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import { isIP } from 'node:net'
32
32
  import { homedir, hostname } from 'node:os'
33
- import { basename, dirname, isAbsolute, join } from 'node:path'
33
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
  import {
36
36
  FEEDBACK_ENV,
@@ -93,6 +93,15 @@ const MAX_SETS = 20
93
93
  const MAX_SET_NAME = 30
94
94
  /** Rolling history snapshots (F14): how many are kept PER writing device. */
95
95
  const MAX_HISTORY = 20
96
+ /**
97
+ * Byte ceiling for one device's snapshots (F14). The count alone bounded
98
+ * nothing in practice: at the 200 × 6,000-character cap a single snapshot is
99
+ * ~3.6 MB, so twenty of them were ~72 MB per device — and a custom backup
100
+ * directory inside a cloud-synced folder meant that much upload traffic for
101
+ * one checkbox toggle. Newest snapshots win; the newest is always kept even
102
+ * when it alone exceeds the budget.
103
+ */
104
+ const MAX_HISTORY_BYTES = 32 * 1024 * 1024
96
105
  /**
97
106
  * F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
98
107
  * far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
@@ -165,7 +174,14 @@ const PLUGIN_VERSION = (() => {
165
174
  */
166
175
  export function resolveStorePath(env = process.env, home = homedir()) {
167
176
  const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
168
- const root = configured !== '' ? configured : join(home, '.dsh')
177
+ // A relative override (`DSH_HOME=./data`) is resolved ONCE against the
178
+ // process cwd. Without this the store path stayed relative for the whole
179
+ // process: the settings page promised an absolute path (README), the
180
+ // watcher's dirname() became '.', and the "same file" identity depended on
181
+ // how the path was spelled. Resolving does not make a relative override
182
+ // cwd-independent — it cannot be — but it pins one absolute location for
183
+ // this process and keeps every displayed path absolute.
184
+ const root = configured !== '' ? resolve(configured) : join(home, '.dsh')
169
185
  return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
170
186
  }
171
187
 
@@ -512,6 +528,41 @@ export function exportFileName(epoch) {
512
528
  return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
513
529
  }
514
530
 
531
+ /**
532
+ * Which snapshots survive retention, for ONE writing device.
533
+ *
534
+ * Two ceilings apply together: the count (MAX_HISTORY) and a byte budget
535
+ * (MAX_HISTORY_BYTES). Counting alone bounded nothing at the top end — see the
536
+ * constant's note — while a byte budget alone would let a flood of tiny saves
537
+ * grow the directory without limit. The newest snapshot is always kept, even
538
+ * when it alone exceeds the byte budget: a history that prunes its own latest
539
+ * version is worse than a large one.
540
+ *
541
+ * Pure and exported so the policy is unit-testable without writing 32 MB.
542
+ *
543
+ * @param {{ name: string, size: number }[]} items ascending by name (chronological)
544
+ * @returns {{ keep: string[], remove: string[] }} `remove` is oldest-first
545
+ */
546
+ export function planHistoryPrune(items, maxCount = MAX_HISTORY, maxBytes = MAX_HISTORY_BYTES) {
547
+ const list = (Array.isArray(items) ? items : [])
548
+ .filter((item) => item !== null && typeof item === 'object' && typeof item.name === 'string' && item.name !== '')
549
+ const keep = []
550
+ let bytes = 0
551
+ for (let index = list.length - 1; index >= 0; index -= 1) {
552
+ const size = Number.isFinite(list[index].size) ? Math.max(0, list[index].size) : 0
553
+ // keep.length === 0 keeps the newest unconditionally (see above).
554
+ if (keep.length === 0 || (keep.length < maxCount && bytes + size <= maxBytes)) {
555
+ keep.push(list[index].name)
556
+ bytes += size
557
+ }
558
+ }
559
+ const kept = new Set(keep)
560
+ return {
561
+ keep,
562
+ remove: list.filter((item) => !kept.has(item.name)).map((item) => item.name),
563
+ }
564
+ }
565
+
515
566
  /**
516
567
  * Which fields of a raw entry had to be cut to fit the stored limits.
517
568
  *
@@ -748,6 +799,28 @@ function normalizeData(raw) {
748
799
  return loaded
749
800
  }
750
801
 
802
+ /**
803
+ * The scalar half of the store shape, in ONE place.
804
+ *
805
+ * The boot state, normalizeData, persistableData, cloneData and snapshot() all
806
+ * spell these fields out. A new field added to four of them and forgotten in
807
+ * the fifth is dropped on save, lost on rollback, or invisible to the panel —
808
+ * the boot state's own comment already warns about exactly that drift. The
809
+ * array halves stay explicit because each caller transforms them differently
810
+ * (copy, filter to the active set, or map to {id,name}).
811
+ */
812
+ const storeScalars = (data) => ({
813
+ version: FORMAT_VERSION,
814
+ rev: typeof data.rev === 'number' && Number.isFinite(data.rev) && data.rev > 0 ? data.rev : 0,
815
+ enabled: data.enabled === true,
816
+ disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
817
+ ? data.disabledUntil
818
+ : 0,
819
+ budgetChars: clampBudget(data.budgetChars),
820
+ backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
821
+ activeSetId: data.activeSetId,
822
+ })
823
+
751
824
  /**
752
825
  * A structural copy of the store, shallow per entry.
753
826
  *
@@ -759,15 +832,7 @@ function normalizeData(raw) {
759
832
  */
760
833
  function cloneData(data) {
761
834
  return {
762
- version: FORMAT_VERSION,
763
- rev: data.rev || 0,
764
- enabled: data.enabled === true,
765
- disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
766
- ? data.disabledUntil
767
- : 0,
768
- budgetChars: data.budgetChars,
769
- backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
770
- activeSetId: data.activeSetId,
835
+ ...storeScalars(data),
771
836
  sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
772
837
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
773
838
  profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
@@ -1290,17 +1355,19 @@ export function apply(ctx) {
1290
1355
 
1291
1356
  const state = {
1292
1357
  data: {
1293
- version: FORMAT_VERSION,
1294
- rev: 0,
1295
- enabled: true,
1296
- // Must match normalizeData's output shape KEY FOR KEY: the watcher's
1297
- // self-write detection compares JSON strings, and a missing key here
1298
- // made every fresh process's first save look like an external change
1299
- // (one redundant reload + frame).
1300
- disabledUntil: 0,
1301
- budgetChars: DEFAULT_BUDGET,
1302
- backupDir: '',
1303
- activeSetId: DEFAULT_SET_ID,
1358
+ // Scalars come from the shared shape (see storeScalars) so this literal
1359
+ // cannot drift from normalizeData / persistableData / snapshot. The key
1360
+ // ORDER still matters: the watcher's self-write detection compares JSON
1361
+ // strings, and a missing or reordered key made every fresh process's
1362
+ // first save look like an external change (one redundant reload + frame).
1363
+ ...storeScalars({
1364
+ rev: 0,
1365
+ enabled: true,
1366
+ disabledUntil: 0,
1367
+ budgetChars: DEFAULT_BUDGET,
1368
+ backupDir: '',
1369
+ activeSetId: DEFAULT_SET_ID,
1370
+ }),
1304
1371
  sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
1305
1372
  entries: [],
1306
1373
  profiles: [],
@@ -1525,7 +1592,20 @@ export function apply(ctx) {
1525
1592
  const wanted = sectionText() !== ''
1526
1593
  if (wanted && sectionDispose === null) {
1527
1594
  try {
1528
- sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: assembledSectionText })
1595
+ // `interpolate: false` is load-bearing, not a hint. The host runs every
1596
+ // section through `{{variable}}` interpolation by DEFAULT and THROWS on
1597
+ // a reference it cannot resolve (`unknown prompt variable "{{x}}" in
1598
+ // section "instruction-memory"`). Instruction memory is user prose — an
1599
+ // nginx snippet (`proxy_pass {{upstream}};`), a Vue/Jinja/Handlebars
1600
+ // template or any `{{…}}` pair — so the default would abort the whole
1601
+ // agent turn at renderPrompt(). This section is literal text and never
1602
+ // references a host variable, so opt out explicitly.
1603
+ sectionDispose = systemPrompt.section({
1604
+ name: SECTION_NAME,
1605
+ order: SECTION_ORDER,
1606
+ text: assembledSectionText,
1607
+ interpolate: false,
1608
+ })
1529
1609
  state.sectionRegistered = true
1530
1610
  state.sectionError = null
1531
1611
  logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
@@ -1576,10 +1656,12 @@ export function apply(ctx) {
1576
1656
  /* ---------------- external-change watching (SSE) ---------------- */
1577
1657
 
1578
1658
  /**
1579
- * Live subscribers to the settings page's event stream. Each entry maps a
1580
- * stream's `send(payload)` writer to its own teardown, so a write that fails
1581
- * (the socket went away) can dispose THAT stream instead of just dropping
1582
- * it from the broadcast set while its response stays open.
1659
+ * Live subscribers to the settings page's event stream. Each entry is
1660
+ * `{ cleanup, lite }`: `cleanup` is that stream's own teardown, so a write
1661
+ * that fails (the socket went away) can dispose THAT stream instead of just
1662
+ * dropping it from the broadcast set while its response stays open. `lite`
1663
+ * records whether the subscriber asked for lightweight frames (see
1664
+ * pushSnapshot).
1583
1665
  */
1584
1666
  const sseStreams = new Map()
1585
1667
  let storeWatcher = null
@@ -1587,14 +1669,25 @@ export function apply(ctx) {
1587
1669
 
1588
1670
  const pushSnapshot = () => {
1589
1671
  if (disposed || sseStreams.size === 0) return
1590
- const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
1591
- for (const [send, cleanup] of Array.from(sseStreams)) {
1672
+ // A lite stream only needs to know that the store moved: it fetches the
1673
+ // snapshot through POST `state` when the rev it sees differs from the one
1674
+ // on screen. The full frame is built LAZILY, so a page whose subscribers
1675
+ // are all lite (the current client) never pays for serialising the whole
1676
+ // store — up to ~3.4 MB at the 200 × 6,000-character cap — on every save.
1677
+ const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
1678
+ let fullPayload = null
1679
+ for (const [send, entry] of Array.from(sseStreams)) {
1592
1680
  try {
1593
- send(payload)
1681
+ if (entry.lite === true) {
1682
+ send(litePayload)
1683
+ } else {
1684
+ if (fullPayload === null) fullPayload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
1685
+ send(fullPayload)
1686
+ }
1594
1687
  } catch {
1595
1688
  // The response is dead: run its full teardown (end + unsubscribe),
1596
1689
  // not just a set delete that leaves the stream half-registered.
1597
- cleanup()
1690
+ entry.cleanup()
1598
1691
  }
1599
1692
  }
1600
1693
  }
@@ -1674,9 +1767,9 @@ export function apply(ctx) {
1674
1767
  }
1675
1768
 
1676
1769
  const disposeStreams = () => {
1677
- for (const cleanup of Array.from(sseStreams.values())) {
1770
+ for (const entry of Array.from(sseStreams.values())) {
1678
1771
  try {
1679
- cleanup()
1772
+ entry.cleanup()
1680
1773
  } catch {
1681
1774
  // The response is already gone.
1682
1775
  }
@@ -1698,15 +1791,7 @@ export function apply(ctx) {
1698
1791
  */
1699
1792
  const persistableData = () => ({
1700
1793
  $schema: SCHEMA_REF,
1701
- version: FORMAT_VERSION,
1702
- rev: state.data.rev || 0,
1703
- enabled: state.data.enabled === true,
1704
- disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
1705
- ? state.data.disabledUntil
1706
- : 0,
1707
- budgetChars: clampBudget(state.data.budgetChars),
1708
- backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
1709
- activeSetId: state.data.activeSetId,
1794
+ ...storeScalars(state.data),
1710
1795
  sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
1711
1796
  entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
1712
1797
  profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
@@ -1766,6 +1851,14 @@ export function apply(ctx) {
1766
1851
  const token = randomUUID()
1767
1852
  const deadline = Date.now() + LOCK_WAIT_MS
1768
1853
  for (;;) {
1854
+ // The deadline is checked at the TOP of every attempt, not only on the
1855
+ // "someone else holds it" branch. It used to sit below the stale-lock
1856
+ // branch, which `continue`d after a failed unlink — so a stale marker
1857
+ // that could not be deleted (a Windows handle without delete sharing, a
1858
+ // read-only volume) spun here forever, and every later disk operation
1859
+ // queued behind a save that would never return. Waiting is always
1860
+ // bounded; proceeding unlocked is the documented fallback.
1861
+ if (Date.now() >= deadline) return null
1769
1862
  try {
1770
1863
  await writeFile(lockPath, token, { flag: 'wx' })
1771
1864
  return { path: lockPath, token }
@@ -1776,10 +1869,10 @@ export function apply(ctx) {
1776
1869
  // Crashed-holder debris. It may be recreated by a live process
1777
1870
  // between this unlink and the next wx attempt — that case simply
1778
1871
  // loops and re-evaluates freshness instead of forcing its way in.
1872
+ // A failed unlink must not become a tight loop, so every attempt
1873
+ // still sleeps below.
1779
1874
  await unlink(lockPath).catch(() => {})
1780
- continue
1781
1875
  }
1782
- if (Date.now() >= deadline) return null
1783
1876
  await new Promise((resolve) => setTimeout(resolve, 50))
1784
1877
  }
1785
1878
  }
@@ -1845,11 +1938,14 @@ export function apply(ctx) {
1845
1938
  let lastHistoryText = null
1846
1939
 
1847
1940
  /**
1848
- * Enforce the per-device retention limit. Grouping by the device tag inside
1849
- * the filename is deliberate: two machines sharing one backup folder (the
1850
- * documented F18 setup) each keep MAX_HISTORY of their OWN snapshots, so one
1851
- * device can never prune the other's history away. Files that do not match
1852
- * our naming pattern are never touched.
1941
+ * Enforce the per-device retention policy: the count and byte ceilings from
1942
+ * planHistoryPrune(). Grouping by the device tag inside the filename is
1943
+ * deliberate: two machines sharing one backup folder (the documented F18
1944
+ * setup) each keep their OWN budget, so one device can never prune the
1945
+ * other's history away. Files that do not match our naming pattern are never
1946
+ * touched. Sizes come from stat() rather than a maintained index because the
1947
+ * directory is shared with other processes and a stale index would prune the
1948
+ * wrong files.
1853
1949
  */
1854
1950
  const pruneHistory = async (dir) => {
1855
1951
  const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
@@ -1865,15 +1961,17 @@ export function apply(ctx) {
1865
1961
  for (const bucket of byDevice.values()) {
1866
1962
  // Fixed-width UTC stamp first => lexicographic order is chronological.
1867
1963
  bucket.sort()
1868
- const excess = bucket.length - MAX_HISTORY
1869
- for (let index = 0; index < bucket.length; index += 1) {
1870
- if (index < excess) {
1871
- await unlink(join(dir, bucket[index])).catch(() => {})
1872
- removed += 1
1873
- } else {
1874
- kept += 1
1875
- }
1964
+ const sized = []
1965
+ for (const name of bucket) {
1966
+ const info = await stat(join(dir, name)).catch(() => null)
1967
+ sized.push({ name, size: info === null ? 0 : info.size })
1968
+ }
1969
+ const plan = planHistoryPrune(sized)
1970
+ for (const name of plan.remove) {
1971
+ await unlink(join(dir, name)).catch(() => {})
1972
+ removed += 1
1876
1973
  }
1974
+ kept += plan.keep.length
1877
1975
  }
1878
1976
  return { removed, kept }
1879
1977
  }
@@ -2346,15 +2444,7 @@ export function apply(ctx) {
2346
2444
  const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
2347
2445
  return {
2348
2446
  data: {
2349
- version: FORMAT_VERSION,
2350
- rev: state.data.rev || 0,
2351
- enabled: state.data.enabled === true,
2352
- disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
2353
- ? state.data.disabledUntil
2354
- : 0,
2355
- budgetChars: clampBudget(state.data.budgetChars),
2356
- backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
2357
- activeSetId: state.data.activeSetId,
2447
+ ...storeScalars(state.data),
2358
2448
  sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
2359
2449
  entries: activeEntries.map((entry) => ({ ...entry })),
2360
2450
  profiles: activeProfiles.map((profile) => ({ ...profile })),
@@ -2466,6 +2556,16 @@ export function apply(ctx) {
2466
2556
  minBudget: MIN_BUDGET,
2467
2557
  maxBudget: MAX_BUDGET,
2468
2558
  maxEntries: MAX_ENTRIES,
2559
+ // Per-field ceilings. The editor used to hard-code 120/6000/200 and
2560
+ // 8/24 for tags, so changing a limit here left the panel silently
2561
+ // disagreeing with the Host (it would accept text the loader then
2562
+ // truncated, with a note the user could not have predicted). Every
2563
+ // limit the panel enforces is published here instead.
2564
+ maxTitle: MAX_TITLE,
2565
+ maxContent: MAX_CONTENT,
2566
+ maxWhen: MAX_WHEN,
2567
+ maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
2568
+ maxTagLength: MAX_TAG_LENGTH,
2469
2569
  maxProfiles: MAX_PROFILES,
2470
2570
  maxProfileName: MAX_PROFILE_NAME,
2471
2571
  maxProfileContent: MAX_PROFILE_CONTENT,
@@ -3713,16 +3813,29 @@ export function apply(ctx) {
3713
3813
  return { ok: false, message: '反馈发送过于频繁,请 ' + waitSeconds + ' 秒后再试' }
3714
3814
  }
3715
3815
 
3816
+ // Reserve the window BEFORE the awaits below. Checking the timestamp here and
3817
+ // writing it only after a successful send straddled them: two concurrent
3818
+ // requests both passed the check and both delivered. A send that never
3819
+ // happens releases the reservation, so a genuine failure can be retried at
3820
+ // once.
3821
+ const previousSentAt = feedbackLastSentAt
3822
+ feedbackLastSentAt = now
3823
+ const releaseReservation = () => {
3824
+ if (feedbackLastSentAt === now) feedbackLastSentAt = previousSentAt
3825
+ }
3826
+
3716
3827
  const configPath = feedbackConfigPath()
3717
3828
  let file = null
3718
3829
  try {
3719
3830
  file = await readFeedbackConfigFile(configPath)
3720
3831
  } catch (err) {
3832
+ releaseReservation()
3721
3833
  logInfo('feedback config unusable: ' + describeError(err))
3722
3834
  return { ok: false, message: describeError(err) }
3723
3835
  }
3724
3836
  const readiness = feedbackConfigFrom({ env: process.env, file, configPath })
3725
3837
  if (!readiness.ok) {
3838
+ releaseReservation()
3726
3839
  logDebug('feedback not configured: ' + readiness.message)
3727
3840
  return { ok: false, message: readiness.message }
3728
3841
  }
@@ -3741,8 +3854,10 @@ export function apply(ctx) {
3741
3854
  // back, so both UI text and the log keep it masked.
3742
3855
  const maskAddresses = (text) => {
3743
3856
  let out = String(text)
3744
- for (const address of [readiness.config.to, readiness.config.from]) {
3745
- if (address !== '') out = out.split(address).join('[邮箱已隐藏]')
3857
+ // The SMTP account is masked too: a relay that differs from From/To is
3858
+ // echoed by some servers inside their rejection text.
3859
+ for (const address of [readiness.config.to, readiness.config.from, readiness.config.smtp.user]) {
3860
+ if (typeof address === 'string' && address !== '') out = out.split(address).join('[邮箱已隐藏]')
3746
3861
  }
3747
3862
  return out
3748
3863
  }
@@ -3764,6 +3879,7 @@ export function apply(ctx) {
3764
3879
  // The failure reason is the only actionable thing a maintainer can act
3765
3880
  // on; it never contains the password (see mailer.js), and the configured
3766
3881
  // mailboxes are masked, so logging it is safe and makes reports debuggable.
3882
+ releaseReservation()
3767
3883
  const reason = maskAddresses(describeError(err))
3768
3884
  logInfo('feedback send failed: ' + reason)
3769
3885
  return { ok: false, message: '反馈发送失败:' + reason + '(详细原因已写入宿主日志)' }
@@ -3871,6 +3987,30 @@ export function apply(ctx) {
3871
3987
 
3872
3988
  let routeDispose = null
3873
3989
 
3990
+ /**
3991
+ * Does this stream subscriber want lightweight frames?
3992
+ *
3993
+ * `?lite=1` is additive on purpose: a client that does not ask (every build
3994
+ * before this one) keeps receiving the bare snapshot exactly as documented,
3995
+ * so an old client against a new Host is unchanged. A lite subscriber gets
3996
+ * `{ rev, lite: true }` and fetches the snapshot itself when the rev differs
3997
+ * — which is the difference between pushing ~3.4 MB per save (maximal store)
3998
+ * and pushing ~40 bytes.
3999
+ *
4000
+ * `req.url` is not part of the harness contract, so anything unparseable
4001
+ * degrades to the legacy full frame rather than throwing.
4002
+ */
4003
+ const isLiteStreamRequest = (req) => {
4004
+ const url = req !== null && typeof req === 'object' && typeof req.url === 'string' ? req.url : ''
4005
+ const mark = url.indexOf('?')
4006
+ if (mark === -1) return false
4007
+ try {
4008
+ return new URLSearchParams(url.slice(mark + 1)).get('lite') === '1'
4009
+ } catch {
4010
+ return false
4011
+ }
4012
+ }
4013
+
3874
4014
  /**
3875
4015
  * The live-update stream for the settings page (GET + `Accept:
3876
4016
  * text/event-stream`, i.e. exactly what an EventSource sends).
@@ -3881,7 +4021,8 @@ export function apply(ctx) {
3881
4021
  * (someone navigating to the URL) keeps the old 405.
3882
4022
  *
3883
4023
  * Frames carry the bare snapshot — the same shape `state` answers with —
3884
- * which the Client's normalizer already accepts.
4024
+ * which the Client's normalizer already accepts. A subscriber that appends
4025
+ * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
3885
4026
  */
3886
4027
  const handleEventStream = (req, res) => {
3887
4028
  const verdict = requestOriginVerdict(req.headers, guardHost)
@@ -3899,6 +4040,7 @@ export function apply(ctx) {
3899
4040
  // Ask the browser to retry politely if the stream drops (e.g. the host was
3900
4041
  // restarted) instead of hammering the route.
3901
4042
  res.write('retry: 5000\n\n')
4043
+ const lite = isLiteStreamRequest(req)
3902
4044
  const send = (payload) => { res.write(payload) }
3903
4045
  let closed = false
3904
4046
  const cleanup = () => {
@@ -3910,7 +4052,7 @@ export function apply(ctx) {
3910
4052
  // The response is already gone.
3911
4053
  }
3912
4054
  }
3913
- sseStreams.set(send, cleanup)
4055
+ sseStreams.set(send, { cleanup, lite })
3914
4056
  req.on('close', cleanup)
3915
4057
  req.on('error', cleanup)
3916
4058
  res.on('error', cleanup)
@@ -3918,11 +4060,14 @@ export function apply(ctx) {
3918
4060
  // instead of waiting for the next change. It must wait for boot: a stream
3919
4061
  // opened while the initial disk read is still in flight would otherwise be
3920
4062
  // handed the empty boot state as its first frame, flashing "no
3921
- // instructions" before the loaded store arrives.
4063
+ // instructions" before the loaded store arrives. A lite subscriber gets
4064
+ // only the current rev and pulls the snapshot itself when it is behind.
3922
4065
  const sendConnectFrame = () => {
3923
4066
  if (closed) return
3924
4067
  try {
3925
- send('data: ' + JSON.stringify(snapshot()) + '\n\n')
4068
+ send(lite
4069
+ ? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
4070
+ : 'data: ' + JSON.stringify(snapshot()) + '\n\n')
3926
4071
  } catch {
3927
4072
  cleanup()
3928
4073
  }