zz-meridian 0.31.0 → 0.31.1

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.
@@ -556,7 +556,21 @@ export const RELEASE_MIGRATIONS = [
556
556
  instructions: 'The release\'s `app/layout.tsx` shows `app.description` as the document\'s description — the line a search engine and a link preview put under the name — where it used to write a sentence of the template\'s own. `src/app.config.ts` is yours and an update does not touch it, so the key is missing there until you add it. Add `description: \'<one sentence about your product>\',` to the `app` object beside `name`. A project that keeps its own `app/layout.tsx` needs the key all the same, because the layout a project brings over from the release reads it.',
557
557
  checks: ['gate'],
558
558
  },
559
+ {
560
+ id: 'live-stream-shutdown',
561
+ since: '0.31.1',
562
+ applies: liveStreamKeptOnStop,
563
+ summary: 'The live stream ends every open stream when the server is told to stop, so tabs reconnect to the server that replaces it.',
564
+ instructions: '`src/data/live-stream.ts` is yours, so an update does not touch it, and the copy you have never ends a stream. A server told to stop (`next start` on SIGTERM or SIGINT, as a deploy or a restart does) closes its port and waits for its open connections: the old process stays up holding every tab\'s stream while the new one serves the port, so no tab hears a change hint again and every change waits for its next safety poll. Bring the block that begins `Every stream still open` over from `references/live.md` (the `open` set, `endLiveStreams` and `endOnShutdown`), call `open.delete(close)` first in the stream\'s `close`, and `endOnShutdown(close)` after the stream listens for its abort. A project that serves its stream some other way ends its streams on the signal the same way.',
565
+ checks: ['gate'],
566
+ },
559
567
  ];
568
+ /** 0.31.1: a live stream starter that does not end its streams when the server is told to stop. */
569
+ function liveStreamKeptOnStop(root) {
570
+ const rel = 'src/data/live-stream.ts';
571
+ const body = text(root, rel);
572
+ return body !== null && /export function liveStream\b/.test(body) && !/endLiveStreamsOnSignal/.test(body) ? [rel] : null;
573
+ }
560
574
  /** The `app` object of a project's configuration, as `src/app.config.ts` writes it. */
561
575
  const APP_OBJECT = /^export const app = \{[\s\S]*?^\} as const;$/m;
562
576
  /** 0.30.0: the release's layout reads `app.description`, and a project's own configuration has no such key. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,15 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.31.1] · 2026-10-27
6
+
7
+ ### Fixed
8
+
9
+ - **A server that is stopped lets go of its live streams.** `next start` told to stop (SIGTERM or SIGINT, as a deploy or a restart does) closes its port and then waits for its open connections, and a live stream never ended of itself. So the old process stayed up holding every tab's stream while the new one served the port: no tab heard a change hint again, and every change waited for the tab's next safety poll (the weekly live check: `a server restart: B did not show a write made after the restart within 5000 ms`). `liveStream` now ends every open stream when the process is told to stop; each tab reconnects to the new server within about a second and hears hints again (a write after a restart reached the other tab in 540 ms, where it took 2.9 s). Where nothing else listens for the signal, the process still stops as it did.
10
+ - **What a team does.** `src/data/live-stream.ts` is yours, so an update does not replace it: run `update`, which reports `migration:live-stream-shutdown` for a copy that never ends its streams, and bring the block that begins `Every stream still open` over from `references/live.md`.
11
+ - **Doing nothing.** Nothing fails; a deploy or restart keeps the old server up while a tab is open, and changes reach other tabs only at their next poll until they reload.
12
+ - **A live tab tries its stream again as soon as the server answers after it did not.** A tab that had given its stream up for polling tried it again only on every fifth poll. A poll that answers after one that failed (the server is back) now opens the stream at once, so hints resume; a stream a proxy blocks, whose polls never fail, is still tried on every fifth poll.
13
+
5
14
  ## [0.31.0] · 2026-10-26
6
15
 
7
16
  ### New
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -115,6 +115,33 @@ const MAX_QUEUED = 100;
115
115
 
116
116
  let noted = false;
117
117
 
118
+ /**
119
+ * Every stream still open. A server told to stop (`next start` on SIGTERM or SIGINT) closes its port and then waits for
120
+ * its open connections, and a stream never ends of itself: the old process kept every tab's stream while the new one
121
+ * served the port, so no tab heard a hint again until its next poll. Ending them lets the old process go and each tab
122
+ * connect to the new one.
123
+ */
124
+ const open = new Set<() => void>();
125
+ let watching = false;
126
+
127
+ /** Ends every open stream; each tab reconnects (the stream's `retry`), to whichever server then holds the port. */
128
+ function endLiveStreams() {
129
+ for (const close of [...open]) close();
130
+ }
131
+
132
+ function endOnShutdown(close: () => void) {
133
+ open.add(close);
134
+ if (watching || typeof process === 'undefined' || typeof process.once !== 'function') return;
135
+ watching = true;
136
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
137
+ process.once(sig, function endLiveStreamsOnSignal() {
138
+ endLiveStreams();
139
+ // A listener takes the place of the signal's default: where nothing else listens, the process stops as it would have.
140
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
141
+ });
142
+ }
143
+ }
144
+
118
145
  type LiveStreamOptions = {
119
146
  /** The requested names, already authorized once; the first frame greets with what moved. */
120
147
  names: string[];
@@ -141,6 +168,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
141
168
  function close() {
142
169
  if (closed) return;
143
170
  closed = true;
171
+ open.delete(close);
144
172
  clearInterval(timer);
145
173
  for (const off of unsubscribe) off();
146
174
  unsubscribe.length = 0;
@@ -195,6 +223,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
195
223
  controller = c;
196
224
  if (signal.aborted) return close();
197
225
  signal.addEventListener('abort', close);
226
+ endOnShutdown(close);
198
227
  // Subscribe first, then compare: a write after the page's read either moves a revision the greeting names or
199
228
  // arrives as a hint.
200
229
  collections.forEach((collection, i) => {
@@ -191,6 +191,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
191
191
  | `no-data-mark` (0.19.0) | a test of the team's importing the Column chart and expecting a `.bg-fill-track` in a period with no data, such as `optional:tests/column-chart-no-data.test.tsx` as the template wrote it | a `null` period draws a dashed hairline on the baseline since 0.19.0, not a track the height of the scale, which was the tallest mark on the chart: bring `optional:tests/column-chart-no-data.test.tsx` over from the release as it has it, and find `[data-no-data-mark]` where a test of your own found `.bg-fill-track` or `[data-track]`; checked by the gate |
192
192
  | `table-grid` (0.26.0) | a file of the team's importing the Table or the DataTable that expects a `grow` column's `@min-[672px]:min-w-64`, such as `optional:tests/table-cut-cell.test.tsx` as the template wrote it, or passes a `colSpan` to a `TableCell` | a table is a grid since 0.26.0, its spare shared equally between the gaps, and a `grow` column's floor is `min-w-40` at every width: bring `optional:tests/table-cut-cell.test.tsx` over from the release as it has it, expect `min-w-40` alone where a test of your own expected the wider floor, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`, since a cell takes one track; checked by the gate |
193
193
  | `four-layers` (0.28.0) | a file of the team's importing a module that has left `optional:src/views/` or `optional:src/data/` for `src/logic/` — the migration names the file and, in its instruction, every path the module left — or importing `afterStart` from `src/components/patterns/incident-card` or `summarise` from `src/components/patterns/status-list`, which no longer export them | thirty-eight modules are in `src/logic/` since 0.28.0, thirty-six of them from `optional:src/views/`, and each import is rewritten to the path the module has: `afterStart` is `src/logic/after-start.ts` and `summarise` is `src/logic/summarise.ts`, and those two must move because the cards are Meridian's and the update replaces them, while every path under `optional:src/views/` and `optional:src/data/` is the team's and stays as it is; a created project brings the release's `src/logic/` over with the pages that import it; the standard the layers come from is `data-layers.md`; checked by the gate and a build |
194
+ | `live-stream-shutdown` (0.31.1) | `optional:src/data/live-stream.ts` that never ends its streams when the server is told to stop | a stopped `next start` closes its port and waits for its open connections, so the old process kept every tab's stream while the new one served the port and no tab heard a hint again until its next poll: bring the block that begins `Every stream still open` over from `live.md`, delete the stream from the open set first in its `close`, and register it after it listens for its abort; checked by the gate |
194
195
 
195
196
  A created project that never changed the template's pages brings the release's version of each affected file over, with
196
197
  the files that arrived with them: the view contexts `optional:src/logic/home-context.ts` and its siblings, the view
@@ -10,6 +10,33 @@ const MAX_QUEUED = 100;
10
10
 
11
11
  let noted = false;
12
12
 
13
+ /**
14
+ * Every stream still open. A server told to stop (`next start` on SIGTERM or SIGINT) closes its port and then waits for
15
+ * its open connections, and a stream never ends of itself: the old process kept every tab's stream while the new one
16
+ * served the port, so no tab heard a hint again until its next poll. Ending them lets the old process go and each tab
17
+ * connect to the new one.
18
+ */
19
+ const open = new Set<() => void>();
20
+ let watching = false;
21
+
22
+ /** Ends every open stream; each tab reconnects (the stream's `retry`), to whichever server then holds the port. */
23
+ function endLiveStreams() {
24
+ for (const close of [...open]) close();
25
+ }
26
+
27
+ function endOnShutdown(close: () => void) {
28
+ open.add(close);
29
+ if (watching || typeof process === 'undefined' || typeof process.once !== 'function') return;
30
+ watching = true;
31
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
32
+ process.once(sig, function endLiveStreamsOnSignal() {
33
+ endLiveStreams();
34
+ // A listener takes the place of the signal's default: where nothing else listens, the process stops as it would have.
35
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
36
+ });
37
+ }
38
+ }
39
+
13
40
  type LiveStreamOptions = {
14
41
  /** The requested names, already authorized once; the first frame greets with what moved. */
15
42
  names: string[];
@@ -36,6 +63,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
36
63
  function close() {
37
64
  if (closed) return;
38
65
  closed = true;
66
+ open.delete(close);
39
67
  clearInterval(timer);
40
68
  for (const off of unsubscribe) off();
41
69
  unsubscribe.length = 0;
@@ -90,6 +118,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
90
118
  controller = c;
91
119
  if (signal.aborted) return close();
92
120
  signal.addEventListener('abort', close);
121
+ endOnShutdown(close);
93
122
  // Subscribe first, then compare: a write after the page's read either moves a revision the greeting names or
94
123
  // arrives as a hint.
95
124
  collections.forEach((collection, i) => {
@@ -56,6 +56,8 @@ export function createLiveClient({ refresh, endpoint = '/api/live', pollMs = 300
56
56
  let disposed = false;
57
57
  let paused = false;
58
58
  let polling = false;
59
+ /** The last refresh failed for a reason other than access: the server did not answer. */
60
+ let unreachable = false;
59
61
  let state: LiveState | null = null;
60
62
  /** Bumped whenever results in flight must stop counting: the page hid, or the client ended. */
61
63
  let epoch = 0;
@@ -122,9 +124,18 @@ export function createLiveClient({ refresh, endpoint = '/api/live', pollMs = 300
122
124
  if (full) synced = true;
123
125
  if (synced && opened) emit('live');
124
126
  else if (polling) emit('polling');
127
+ // A server that answers again after it did not (a restart, a deploy) is tried for its stream at once, not on the
128
+ // fifth poll: until then its hints are lost and every change waits for a poll. A refresh that never failed does
129
+ // not count, so a stream a proxy blocks is still tried only on every fifth poll.
130
+ if (unreachable && polling) {
131
+ attempts = MAX_ATTEMPTS - 1;
132
+ connect();
133
+ }
134
+ unreachable = false;
125
135
  }
126
136
  } catch (e) {
127
137
  if (mine === epoch && !disposed) {
138
+ if (!isAuthError(e)) unreachable = true;
128
139
  if (isAuthError(e)) {
129
140
  paused = true;
130
141
  closeSource();
@@ -158,6 +158,25 @@ describe('the live client', () => {
158
158
  expect(FakeSource.all.length).toBe(opened + 1);
159
159
  c.dispose();
160
160
  });
161
+ it('tries the stream at once when a poll answers after one that did not, as a restarted server does', async () => {
162
+ let down = false;
163
+ const refresh = vi.fn(async () => { if (down) throw new Error('network'); });
164
+ const { c, states } = client(refresh, 1000);
165
+ c.setNames(['members']);
166
+ for (let i = 0; i < 3; i++) { live().at(-1)!.fail(); await flush(1100); }
167
+ expect(states.at(-1)).toBe('polling');
168
+ down = true;
169
+ await flush(1000);
170
+ const opened = FakeSource.all.length;
171
+ down = false;
172
+ await flush(1000);
173
+ // The poll that answered opened a stream; without the rule the next try would wait for the fifth poll.
174
+ expect(FakeSource.all.length).toBe(opened + 1);
175
+ live().at(-1)!.open();
176
+ await flush(600);
177
+ expect(states.at(-1)).toBe('live');
178
+ c.dispose();
179
+ });
161
180
  it('pauses on an authorization failure and goes stale on any other', async () => {
162
181
  const denied = client(async () => { throw Object.assign(new Error('no'), { status: 403 }); });
163
182
  denied.c.setNames(['members']);