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.
- package/dist/migrations.js +14 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +9 -0
- package/payload/package.json +1 -1
- package/payload/skills/zz-meridian/references/live.md +29 -0
- package/payload/skills/zz-meridian/references/update.md +1 -0
- package/payload/src/data/live-stream.ts +29 -0
- package/payload/src/lib/live.ts +11 -0
- package/payload/tests/live-client.test.tsx +19 -0
package/dist/migrations.js
CHANGED
|
@@ -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.
|
|
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",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -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
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.31.
|
|
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) => {
|
package/payload/src/lib/live.ts
CHANGED
|
@@ -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']);
|