switchroom 0.19.31 → 0.19.33

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.
@@ -117,19 +117,43 @@ export interface PersistedStatusPin {
117
117
  * re-fail on every boot forever. */
118
118
  export const BOOT_UNPIN_MAX_ATTEMPTS = 5
119
119
 
120
- /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
121
- * pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
122
- * optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
123
- * so the claim is keyed by (chat, thread) and a forum topic can be drained
124
- * after a crash. All load fail-open — an unknown/newer version yields [].
125
- * Every field added since v1 is optional, so the versions are mutually
126
- * readable and a downgrade degrades rather than breaks. */
120
+ /**
121
+ * Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
122
+ * pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
123
+ * optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
124
+ * so the claim is keyed by (chat, thread) and a forum topic can be drained
125
+ * after a crash.
126
+ *
127
+ * ── The bump rule, which the reader depends on (#3957) ───────────────────────
128
+ *
129
+ * EVERY field added since v1 is OPTIONAL, and every future bump must keep that
130
+ * property. That is what makes the versions mutually readable in both
131
+ * directions, and {@link loadStatusPins} now cashes it in: an envelope whose
132
+ * version this build does not know is still READ, row by row, through the same
133
+ * structural validator. Rows it cannot validate are dropped; rows it can are
134
+ * kept.
135
+ *
136
+ * Why that matters more than it looks: this file is the boot cleanup's only
137
+ * record of which messages the gateway pinned. A reader that discarded the
138
+ * whole file on an unfamiliar version turned every pin taken by the newer build
139
+ * into a permanent orphan the moment an operator rolled back — manufacturing
140
+ * exactly the bug this subsystem exists to prevent. Degrading (keep what
141
+ * validates) is strictly safer than failing open to `[]` here, because the
142
+ * fail-open outcome is not "do nothing", it is "forget an obligation".
143
+ *
144
+ * A bump that ever needs to be NON-additive — a field whose absence changes the
145
+ * meaning of a row — must therefore change the FILENAME, not just `v`, so old
146
+ * readers see "no file" rather than misreading rows. Do not quietly break the
147
+ * additive rule and leave `v` to carry it.
148
+ */
127
149
  interface SnapshotEnvelope {
128
- v: 1 | 2 | 3 | 4
150
+ v: number
129
151
  pins: PersistedStatusPin[]
130
152
  }
131
153
 
132
- const SNAPSHOT_VERSIONS = new Set([1, 2, 3, 4])
154
+ /** The version THIS build writes. Reading is deliberately version-tolerant
155
+ * (see above); only the writer is pinned. */
156
+ const SNAPSHOT_VERSION = 4
133
157
 
134
158
  function isPinRow(x: unknown): x is PersistedStatusPin {
135
159
  if (x == null || typeof x !== 'object') return false
@@ -153,6 +177,13 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
153
177
  * file (fail-open to empty: a corrupt snapshot must never crash boot — worst
154
178
  * case an orphaned pin isn't cleaned up this boot, strictly no worse than the
155
179
  * pre-persistence behaviour).
180
+ *
181
+ * An UNRECOGNISED envelope version is NOT malformed and does not fail open
182
+ * (#3957). Any positive-integer `v` with an array of `pins` is read, and each
183
+ * row is kept iff {@link isPinRow} can structurally validate it. A row written
184
+ * by a newer build therefore survives a downgrade instead of being discarded
185
+ * into a permanent orphan — see the {@link SnapshotEnvelope} bump rule for the
186
+ * additive-fields property this relies on.
156
187
  */
157
188
  export function loadStatusPins(
158
189
  path: string,
@@ -173,7 +204,9 @@ export function loadStatusPins(
173
204
  }
174
205
  if (parsed == null || typeof parsed !== 'object') return []
175
206
  const env = parsed as Record<string, unknown>
176
- if (typeof env.v !== 'number' || !SNAPSHOT_VERSIONS.has(env.v) || !Array.isArray(env.pins)) return []
207
+ // Version-TOLERANT, structurally strict: an unknown `v` still yields the rows
208
+ // that validate. Only a shape that is not an envelope at all fails open.
209
+ if (!Number.isInteger(env.v) || (env.v as number) < 1 || !Array.isArray(env.pins)) return []
177
210
  return env.pins.filter(isPinRow)
178
211
  }
179
212
 
@@ -189,7 +222,7 @@ export function persistStatusPins(
189
222
  snapshot: readonly PersistedStatusPin[],
190
223
  log: (line: string) => void = (l) => process.stderr.write(l),
191
224
  ): void {
192
- const env: SnapshotEnvelope = { v: 4, pins: [...snapshot] }
225
+ const env: SnapshotEnvelope = { v: SNAPSHOT_VERSION, pins: [...snapshot] }
193
226
  const tmp = path + '.tmp'
194
227
  try {
195
228
  fs.writeFileSync(tmp, JSON.stringify(env))