zz-meridian 0.29.0 → 0.30.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 (86) hide show
  1. package/dist/adopt.js +41 -3
  2. package/dist/files.js +31 -11
  3. package/dist/migrations.js +27 -0
  4. package/dist/session.js +20 -4
  5. package/package.json +1 -1
  6. package/payload/.env.example +8 -0
  7. package/payload/AGENTS.md +1 -1
  8. package/payload/CHANGELOG.md +38 -0
  9. package/payload/README.md +35 -0
  10. package/payload/app/(dashboard)/(home)/page.tsx +2 -2
  11. package/payload/app/(dashboard)/business/page.tsx +2 -2
  12. package/payload/app/(dashboard)/keys/README.md +1 -1
  13. package/payload/app/(dashboard)/members/README.md +1 -1
  14. package/payload/app/(dashboard)/members/actions.ts +4 -2
  15. package/payload/app/(dashboard)/requests/[id]/actions.ts +4 -2
  16. package/payload/app/(dashboard)/requests/[id]/page.tsx +7 -5
  17. package/payload/app/(dashboard)/settings/actions.ts +3 -1
  18. package/payload/app/(dashboard)/settings/page.tsx +8 -6
  19. package/payload/app/(dashboard)/usage/page.tsx +2 -2
  20. package/payload/app/api/export/requests/route.ts +11 -4
  21. package/payload/app/embed/proposal/view.tsx +20 -5
  22. package/payload/app/icon.ts +6 -4
  23. package/payload/app/layout.tsx +1 -1
  24. package/payload/app/sign-in/actions.ts +1 -1
  25. package/payload/app/sign-in/gated-panel.tsx +1 -1
  26. package/payload/app/system/(atlas)/[section]/[id]/page.tsx +6 -3
  27. package/payload/decisions/0010-the-adopter-contract.md +4 -1
  28. package/payload/docs/agents.md +1 -1
  29. package/payload/docs/distribution.md +5 -2
  30. package/payload/docs/testing.md +3 -3
  31. package/payload/package.json +1 -1
  32. package/payload/scripts/audit.ts +21 -1
  33. package/payload/scripts/brand.ts +10 -4
  34. package/payload/scripts/interactions.ts +76 -61
  35. package/payload/scripts/keyboard.ts +19 -3
  36. package/payload/scripts/lib/atlas.ts +0 -4
  37. package/payload/scripts/lib/layer-rules.ts +8 -18
  38. package/payload/scripts/lib/settle.ts +4 -1
  39. package/payload/scripts/verify.ts +2 -3
  40. package/payload/skills/zz-meridian/SKILL.md +11 -6
  41. package/payload/skills/zz-meridian/references/agents.md +27 -14
  42. package/payload/skills/zz-meridian/references/cache.md +1 -1
  43. package/payload/skills/zz-meridian/references/customize.md +5 -4
  44. package/payload/skills/zz-meridian/references/existing-project.md +3 -1
  45. package/payload/skills/zz-meridian/references/update.md +1 -1
  46. package/payload/src/app.config.ts +2 -0
  47. package/payload/src/components/charts/trend-chart/index.tsx +5 -2
  48. package/payload/src/components/patterns/appearance-menu/index.tsx +17 -16
  49. package/payload/src/components/patterns/assistant/index.tsx +8 -2
  50. package/payload/src/components/patterns/incident-card/index.tsx +2 -1
  51. package/payload/src/components/patterns/metric-tile/README.md +2 -0
  52. package/payload/src/components/patterns/metric-tile/index.tsx +14 -3
  53. package/payload/src/components/patterns/metric-tile/preview.tsx +12 -2
  54. package/payload/src/components/patterns/proposal/index.tsx +6 -2
  55. package/payload/src/components/patterns/shell-tools/index.tsx +2 -1
  56. package/payload/src/components/ui/checkbox/index.tsx +6 -0
  57. package/payload/src/components/ui/copy-field/index.tsx +4 -2
  58. package/payload/src/components/ui/pagination/index.tsx +3 -1
  59. package/payload/src/components/ui/progress/index.tsx +6 -2
  60. package/payload/src/components/ui/radio-group/index.tsx +31 -0
  61. package/payload/src/components/ui/switch/index.tsx +5 -0
  62. package/payload/src/components/ui/toast/index.tsx +3 -1
  63. package/payload/src/data/member-mutations.ts +5 -1
  64. package/payload/src/lib/assistant/tools.ts +4 -1
  65. package/payload/src/lib/host.ts +7 -2
  66. package/payload/src/styles/base.css +3 -0
  67. package/payload/src/system/card-stage.tsx +2 -7
  68. package/payload/src/system/content.ts +13 -1
  69. package/payload/src/system/hero.tsx +7 -5
  70. package/payload/src/system/page-stage.tsx +8 -3
  71. package/payload/src/system/specimen.tsx +1 -1
  72. package/payload/src/system/token-view.tsx +7 -4
  73. package/payload/src/views/customer.tsx +5 -2
  74. package/payload/src/views/members.tsx +17 -1
  75. package/payload/src/views/now.tsx +1 -1
  76. package/payload/src/views/request.tsx +19 -4
  77. package/payload/src/views/settings.tsx +14 -19
  78. package/payload/tests/action-needs.test.tsx +6 -2
  79. package/payload/tests/agents.test.tsx +29 -0
  80. package/payload/tests/assistant-safety.test.ts +17 -3
  81. package/payload/tests/atlas-layers.test.ts +13 -0
  82. package/payload/tests/data-access.test.ts +5 -1
  83. package/payload/tests/metric-tile-pointed-empty.test.tsx +44 -0
  84. package/payload/tests/radio-group.test.tsx +35 -0
  85. package/payload/tests/ticket-owner.test.ts +9 -0
  86. package/payload/tokens/accent.jade.tokens.json +0 -1
package/dist/adopt.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { VERSION, brandArgs, effectiveBrand, inside, installArgs, installSkill, jsoncExtras, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
11
+ import { VERSION, brandArgs, effectiveBrand, inside, installArgs, installSkill, jsoncExtras, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, skillPaths, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
12
12
  import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
13
13
  import { FIXED, adoptSetOf, managedPaths } from './ownership.js';
14
14
  /** The template's dependencies a dashboard built from these files needs. */
@@ -92,8 +92,15 @@ export function projectConfig(template, pages) {
92
92
  .replace(' workspace?: WorkspaceId;', '')
93
93
  .replace(/export const nav: NavGroup\[\] = \[[\s\S]*?\n\];/, `/** Written by zz-meridian adopt from the project's pages: give each its own icon and group them. */\nexport const nav: NavGroup[] = [\n {\n items: [\n${items}\n ],\n },\n];`);
94
94
  }
95
- function verifyConfig() {
96
- return readPayload('scripts/verify.config.ts').replace(/const config: VerifyConfig = \{[\s\S]*?\n\};/, `const config: VerifyConfig = {
95
+ /**
96
+ * `scripts/verify.config.ts` as a project gets it. The sample's persona rows are what used `EVERY_ROUTE`, so replacing the
97
+ * config object leaves the constant declared and never read — which the gate's own lint fails, in the project adopt just
98
+ * wrote, 0.7 s after the other steps passed. Exported so a test can hold the rewrite to that.
99
+ */
100
+ export function verifyConfig() {
101
+ return readPayload('scripts/verify.config.ts')
102
+ .replace(/^const EVERY_ROUTE = \[[^\n]*\n/m, '')
103
+ .replace(/const config: VerifyConfig = \{[\s\S]*?\n\};/, `const config: VerifyConfig = {
97
104
  // One detail page per state worth seeing (a normal record, a failed one, a missing one), with ids from your data.
98
105
  detailRoutes: [],
99
106
  // What a route a persona may not open draws: the product's choice in src/app.config.ts.
@@ -123,6 +130,18 @@ export default defineConfig({
123
130
  });
124
131
  `;
125
132
  }
133
+ /**
134
+ * The ESLint configuration a project already has, or `null`. A flat `eslint.config.*` is what ESLint 9 reads; an
135
+ * `.eslintrc.*` is the legacy form it no longer loads. A function rather than a block inside `adopt` because the
136
+ * question is the project's alone — does it have one, and is it the form the installed ESLint reads — and a test can put
137
+ * a file in a folder and ask it, where running the whole `adopt` would need the payload a checkout has only after a build.
138
+ */
139
+ export function eslintConfigOf(root) {
140
+ const found = ['eslint.config.ts', 'eslint.config.mts', 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
141
+ '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc', '.eslintrc.yml', '.eslintrc.yaml']
142
+ .find((f) => fs.existsSync(path.join(root, f)));
143
+ return found ? { file: found, legacy: /^\.eslintrc/.test(found) } : null;
144
+ }
126
145
  /**
127
146
  * What `adopt` writes for a team's `tsconfig.json`, and what it must say about it: the file Meridian's code needs (its
128
147
  * scripts import with `.ts` extensions, its code is ES2022, `@meridian/*` reaches its files), the team's own kept beside
@@ -224,6 +243,14 @@ https://github.com/zhixuan312/zz-meridian/blob/master/skills/zz-meridian/referen
224
243
  // Meridian's scripts are ES modules; a project whose package.json is not "type": "module" gets that for scripts/ only.
225
244
  if (pkg.type !== 'module')
226
245
  own('scripts/package.json', '{\n "type": "module"\n}\n');
246
+ // The agent skill is copied in at the end, after every other write. Checking it here is what stops `adopt` replacing a
247
+ // team's own edited copy of it: the skill was written unconditionally, so an edited `SKILL.md` in the project was lost
248
+ // without a word and the command still exited 0. It is checked against the same rule as every other file.
249
+ for (const { rel, payload } of skillPaths()) {
250
+ const abs = path.join(root, rel);
251
+ if (fs.existsSync(abs) && !fs.readFileSync(abs).equals(Buffer.from(payloadBytes(payload))))
252
+ conflicts.push(rel);
253
+ }
227
254
  if (conflicts.length) {
228
255
  return fail(`these files already exist with other content, so nothing was written:\n${conflicts.map((c) => ` ${c}`).join('\n')}
229
256
  Rename or move them, then run adopt again.`);
@@ -300,6 +327,17 @@ Rename or move them, then run adopt again.`);
300
327
  writes.set('vitest.config.ts', vitestConfig(atAlias));
301
328
  else
302
329
  notes.push('kept your vitest config: add tests/setup.ts to its setupFiles and passWithNoTests for the gate');
330
+ // The gate's lint step runs `eslint .`, and a project with no configuration fails it 0.7 s in with ESLint's own message
331
+ // about a missing file — which says nothing about Meridian. Measured on a project created by create-next-app with
332
+ // --no-eslint and then adopted: every other gate step passed and the lint was the only red.
333
+ const eslint = eslintConfigOf(root);
334
+ if (!eslint) {
335
+ writes.set('eslint.config.ts', readPayload('eslint.config.ts'));
336
+ notes.push('added eslint.config.ts: the gate runs the lint, and this project had no ESLint configuration');
337
+ }
338
+ else if (eslint.legacy) {
339
+ notes.push(`kept ${eslint.file}, which ESLint 9 no longer reads: the gate's lint fails until it becomes an eslint.config.mjs`);
340
+ }
303
341
  if (!fs.readdirSync(path.join(root, dir)).some((f) => /^(icon|favicon)\./.test(f)))
304
342
  writes.set(`${dir}/icon.ts`, exactImports(`${dir}/icon.ts`, readPayload('app/icon.ts'), known));
305
343
  const ignore = path.join(root, '.gitignore');
package/dist/files.js CHANGED
@@ -17,10 +17,25 @@ export const VERSION = JSON.parse(fs.readFileSync(path.resolve(path.dirname(file
17
17
  */
18
18
  export const shippedPath = (rel) => (rel === '.gitignore' ? 'gitignore' : rel);
19
19
  export const projectPath = (rel) => (rel === 'gitignore' ? '.gitignore' : rel);
20
+ /**
21
+ * The message a checkout gets when it asks for the payload and nobody has built it. `cli/payload/` is a build output, so
22
+ * a fresh clone has none, and without this the failure was a bare `ENOENT` on whichever file the reader wanted first, or
23
+ * — worse — an empty list, which a test read as "the template has no skill files".
24
+ */
25
+ const NOT_BUILT = 'cli/payload/ has not been built: run `node cli/scripts/build-payload.ts` (or `--worktree` to build what the working tree holds) and run this again.';
20
26
  /** A payload file's bytes, by its project path. */
21
- export const payloadBytes = (rel) => fs.readFileSync(path.join(PAYLOAD, shippedPath(rel)));
27
+ export function payloadBytes(rel) {
28
+ if (!fs.existsSync(PAYLOAD))
29
+ throw new Error(NOT_BUILT);
30
+ const abs = path.join(PAYLOAD, shippedPath(rel));
31
+ if (!fs.existsSync(abs))
32
+ throw new Error(`${rel} is not in cli/payload/, which was built before that file was added or left it out: run \`node cli/scripts/build-payload.ts\` again.`);
33
+ return fs.readFileSync(abs);
34
+ }
22
35
  /** Every file under a payload folder, as project paths. Symbolic links are never followed. */
23
36
  export function payloadFiles(dir = '') {
37
+ if (!fs.existsSync(PAYLOAD))
38
+ throw new Error(NOT_BUILT);
24
39
  const out = [];
25
40
  const walk = (rel) => {
26
41
  for (const e of fs.readdirSync(path.join(PAYLOAD, rel), { withFileTypes: true })) {
@@ -153,17 +168,22 @@ export function writeManifest(root, m) {
153
168
  const sorted = Object.fromEntries(Object.entries(m.files).sort(([a], [b]) => a.localeCompare(b)));
154
169
  writeIn(root, '.meridian/manifest.json', JSON.stringify({ ...m, files: sorted }, null, 2) + '\n');
155
170
  }
156
- /** The agent skill, into the folders each agent reads in a repository: Codex (.agents) and Claude Code (.claude). */
157
- export function installSkill(root, record) {
171
+ /**
172
+ * Where the skill goes: the folders each agent reads in a repository, Codex (`.agents`) and Claude Code (`.claude`), and
173
+ * the file in the payload each is written from. The same list is what `adopt` checks before it writes anything, so a
174
+ * team's own copy is never overwritten behind their back.
175
+ */
176
+ export function skillPaths() {
158
177
  const files = payloadFiles('skills/zz-meridian');
159
- for (const base of ['.agents/skills/zz-meridian', '.claude/skills/zz-meridian']) {
160
- for (const f of files) {
161
- const rel = `${base}/${f.slice('skills/zz-meridian/'.length)}`;
162
- const content = payloadBytes(f);
163
- writeIn(root, rel, content);
164
- if (record)
165
- record[rel] = sha256(content);
166
- }
178
+ return ['.agents/skills/zz-meridian', '.claude/skills/zz-meridian'].flatMap((base) => files.map((f) => ({ rel: `${base}/${f.slice('skills/zz-meridian/'.length)}`, payload: f })));
179
+ }
180
+ /** The agent skill, into the folders each agent reads in a repository. */
181
+ export function installSkill(root, record) {
182
+ for (const { rel, payload } of skillPaths()) {
183
+ const content = payloadBytes(payload);
184
+ writeIn(root, rel, content);
185
+ if (record)
186
+ record[rel] = sha256(content);
167
187
  }
168
188
  }
169
189
  /** Brand flags passed through to scripts/brand.ts, in the order it reads them. */
@@ -540,7 +540,34 @@ export const RELEASE_MIGRATIONS = [
540
540
  instructions: `Read "The four layers" and "The path an existing product follows" in references/data-layers.md. Thirty-eight modules moved into the logic layer, and each import of one in your own \`src/views/\` producers and pages names the path it left: ${MOVED_LIST}. Rewrite each import to the path the module has now, or move your own module beside the work it does. Two symbols left a card of Meridian's, and both must be rewritten because the update replaces the card: \`afterStart\`, which the incident card exported, is \`@/logic/after-start\`, and \`summarise\`, which the status list re-exported, is \`@/logic/summarise\`. Every other path your own files name is still there, so a project that imported neither symbol keeps building as it was. The six layer rules stay off until your own \`scripts/check.local.ts\` exports \`fourLayers = true\`; turn them on once your figures have moved, and the gate and the build are what show the work is done.`,
541
541
  checks: GATE_AND_BUILD,
542
542
  },
543
+ {
544
+ id: 'direct-query-escape',
545
+ since: '0.30.0',
546
+ applies: (root) => matching(root, (rel, src) => /^(?:app|src\/(?:logic|views|lib))\//.test(rel) && SOURCE.test(rel) && /\.query\s*\(/.test(src.replace(/\/\/[^\n]*/g, '')) && !/allow-no-direct-query:/.test(src)),
547
+ summary: 'A direct `.query(` call is allowed only where the call states its own escape: `// allow-no-direct-query: <why>`, on that line.',
548
+ instructions: 'Since 0.30.0 `node scripts/check.ts` reports every `.query(` call in `app/`, `src/logic/`, `src/views/` and `src/lib/`, and keeps the run passing only where the call\'s own line carries the escape the check prints — `// allow-no-direct-query: read() cannot serve this — <why>`. Before this release the rule allowed five sites of the template\'s by line number, so a file of your own that read a collection directly passed as long as the call sat on one of those lines; that list is gone, because a comment added above a call moved it off the line and turned a passing project red for no reason. Read every file named above. Where the read has to be direct — a server action reading the record it is about to change, a streamed export, the sign-in resolving a person before an `AccessScope` exists — state the reason on that line in the form the check prints. Everywhere else, read through `read()`, `readAll()` or `aggregate()` from `@/data/read`, which ask the caller\'s grant.',
549
+ checks: ['gate'],
550
+ },
551
+ {
552
+ id: 'app-description',
553
+ since: '0.30.0',
554
+ applies: appDescriptionMissing,
555
+ summary: 'The product describes itself in one sentence: `description` in the `app` object of `src/app.config.ts`.',
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
+ checks: ['gate'],
558
+ },
543
559
  ];
560
+ /** The `app` object of a project's configuration, as `src/app.config.ts` writes it. */
561
+ const APP_OBJECT = /^export const app = \{[\s\S]*?^\} as const;$/m;
562
+ /** 0.30.0: the release's layout reads `app.description`, and a project's own configuration has no such key. */
563
+ function appDescriptionMissing(root) {
564
+ const rel = 'src/app.config.ts';
565
+ const body = text(root, rel);
566
+ if (body === null)
567
+ return null;
568
+ const object = APP_OBJECT.exec(body)?.[0];
569
+ return object && !/\bdescription\s*:/.test(object) ? [rel] : null;
570
+ }
544
571
  /**
545
572
  * A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
546
573
  * keeps `unobserve() {}` out of it: a fake that answers the callback and also stubs `unobserve` is the release's own
package/dist/session.js CHANGED
@@ -69,12 +69,28 @@ const pidAlive = (pid) => {
69
69
  return e.code === 'EPERM';
70
70
  }
71
71
  };
72
+ /**
73
+ * The session folders under `.meridian/update/`, oldest name first. Only folders: the same folder holds whatever else a
74
+ * person's tools leave there — Finder's `.DS_Store`, an editor's swap file — and one of those read as a session is a
75
+ * version that does not exist. `loadSession` filtered for that and this did not, so a stray file made `status` report a
76
+ * session of version `.DS_Store` and made `update` prescribe `npx zz-meridian@.DS_Store update --resume`, which then
77
+ * refused, leaving no way forward. One list, read the same way by both.
78
+ */
79
+ export function sessionDirs(root) {
80
+ const base = path.join(root, '.meridian', 'update');
81
+ if (!fs.existsSync(base))
82
+ return [];
83
+ return fs.readdirSync(base).filter((n) => { try {
84
+ return fs.lstatSync(path.join(base, n)).isDirectory();
85
+ }
86
+ catch {
87
+ return false;
88
+ } }).sort();
89
+ }
72
90
  /** The phase of the one session folder under `.meridian/update/`, or a note when it cannot be read. */
73
91
  function sessionState(root) {
74
92
  const base = path.join(root, '.meridian', 'update');
75
- if (!fs.existsSync(base))
76
- return null;
77
- const [first] = fs.readdirSync(base).sort();
93
+ const [first] = sessionDirs(root);
78
94
  if (!first)
79
95
  return null;
80
96
  let phase = 'unreadable';
@@ -506,7 +522,7 @@ function serializeManifest(m) {
506
522
  */
507
523
  function loadSession(ctx) {
508
524
  const base = path.join(ctx.root, '.meridian', 'update');
509
- const names = fs.existsSync(base) ? fs.readdirSync(base).filter((n) => fs.lstatSync(path.join(base, n)).isDirectory()).sort() : [];
525
+ const names = sessionDirs(ctx.root);
510
526
  if (names.length === 0)
511
527
  return 'there is no update session in .meridian/update/; start one with npx zz-meridian@latest update';
512
528
  if (names.length > 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
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",
@@ -14,3 +14,11 @@
14
14
 
15
15
  # The provider's address. Required for "openai-compatible"; optional for "anthropic".
16
16
  # ASSISTANT_BASE_URL=
17
+
18
+ # The demo gate: see src/lib/demo-gate.ts. The Docker image (Dockerfile) refuses to start without a password, and the
19
+ # product is ungated without one, so this is the whole of "run the demo". It is server-only and read on every request.
20
+ # DEMO_PASSWORD=
21
+
22
+ # What the session cookie is signed with. Left unset it is derived from DEMO_PASSWORD, so a session survives a restart
23
+ # either way; set it to sign everyone out when the password changes, or to keep a session across a password change.
24
+ # DEMO_SECRET=
package/payload/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <!-- BEGIN:nextjs-agent-rules -->
2
2
 
3
- # This is NOT the Next.js you know
3
+ ## This is NOT the Next.js you know
4
4
 
5
5
  This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6
6
 
@@ -2,6 +2,44 @@
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.30.0] · 2026-10-25
6
+
7
+ ### Breaking
8
+
9
+ - **A direct `.query(` call states its own escape, on the line it is on.** `noDirectQuery` allowed five sites of the template's by absolute line number, and each was wrong the moment a comment above it changed length — this release moved `src/lib/assistant/tools.ts`'s call from 132 to 135 with an unrelated fix. The list is gone: the rule reports every `.query(` call in `app/`, `src/logic/`, `src/views/` and `src/lib/`, and keeps the run passing only where the call's own line carries `// allow-no-direct-query: read() cannot serve this — <why>`, the escape the check prints.
10
+ - **What a team does.** Run `update`; it reports `migration:direct-query-escape` for each of your files that queries directly. Where the read must be direct — a server action reading the record it is about to change, a streamed export, the sign-in resolving a person before an `AccessScope` exists — put the escape on that line with the reason. Everywhere else read through `read()`, `readAll()` or `aggregate()` from `@/data/read`, which ask the caller's grant.
11
+ - **Doing nothing.** Your own file that queries directly fails `node scripts/check.ts` on the next `pnpm gate`, in the check's own words, naming the line. Nothing else changes: the rule governs the same four folders it did, and a project with no direct read at all is unaffected.
12
+ - **`src/app.config.ts` gains `description`, the product's one sentence.** The release's `app/layout.tsx` shows `app.description` where it wrote a sentence of the template's own, and that layout is the one a project brings over when it takes the release's pages. The configuration is yours, and an update does not touch it.
13
+ - **What a team does.** Run `update`; it reports `migration:app-description` for your configuration. Add `description: '<one sentence about your product>',` to the `app` object beside `name`.
14
+ - **Doing nothing.** A project that keeps its own `app/layout.tsx` sees no failure, and the key costs it nothing. A project that brings the release's layout over fails `pnpm gate` on the type check — `Property 'description' does not exist` — until the key is there.
15
+
16
+ ### New
17
+
18
+ - **The accent picker is a radio group.** `AccentSwatches` in `@/components/ui/radio-group` is one row of swatches built on the radio primitive: the row is one Tab stop, the arrow keys move the choice, and the chosen swatch is announced as chosen. Settings and the Atlas's card stage both draw it, where three hand-built rows of `role="radio"` buttons each drew their own — every swatch a stop, and an arrow key that did nothing. The appearance menu keeps pressed buttons (`role="group"` with `aria-pressed`), because inside a menu the arrow keys belong to the menu.
19
+ - **`app.description` names the product in a sentence.** It is the line a search engine and a link preview show under the name, and it sits in `src/app.config.ts` beside `name`, `workspace` and `accent` instead of being written into `app/layout.tsx`. Both files are yours, so a project that has already set its own sentence keeps it; `update` reports `migration:app-description` where the configuration has no such key (see **Breaking**).
20
+ - **The README carries the prompt that models a product's data first.** One paragraph to hand the agent before the build sentence: the collections with their key, tenant, hidden, page-only and derived fields; the day and the time zone it is counted in; the money and its currency; each figure with its unit, its window and a definition a stranger could apply to a row; the grants in the `resource:operation` form; the three questions a page must be able to answer; and what an agent may propose against what stays the page's own. It asks for one table the build reads, so a wrong rendering can be told from a wrong model.
21
+
22
+ ### Fixed
23
+
24
+ - **An approval a guard refused is still yours to send.** A tool call's id was marked used before the change ran, so a change that threw — the grant withdrawn between the approval and the run, a record that no longer fits — wrote nothing and still answered "This change was already applied." to the second try. The id is released when the change throws.
25
+ - **A screen never says a thing was copied before it was.** Four Copy controls on a request's page raised "copied" without waiting for the clipboard, on the page whose whole purpose is handing somebody an exact command or id. One helper awaits the write and says what to do when the browser refuses it.
26
+ - **A page is told the assistant is on only where the route serves it.** `scripts/lib/settle.ts` read every answer but 404 from `/api/assistant` as "on", so a page behind a refused session waited its whole bound for a slot that was never coming.
27
+ - **`brand` refuses a contrast run that measured nothing.** A `scripts/contrast.ts` that crashed printed no summary, and its empty output read as "no failures": the accent shipped unchecked. A hue still failing after the eighth step stops the command too.
28
+ - **The check states the direct read's escape on the call's own line.** `noDirectQuery` allowed five sites by absolute line number, and each was wrong the moment a comment above it changed length. The five state `// allow-no-direct-query: read() cannot serve this — …` where the call is, the escape the check already prints, so the rule no longer has a list to fall behind.
29
+ - **A walk goes on after a route that stops answering.** `scripts/interactions.ts` and `scripts/keyboard.ts` ended the suite at the first page that stopped answering, losing the evidence for every route after it. Each route is walked in a guard of its own, as `scripts/audit.ts` measures each route.
30
+ - **The Atlas reads its entries once per request, not twice.** The walk reads 73 specifications and a request-rendered Atlas page asked for them from the sidebar and again from the article. React's `cache` is the request's scope, so one read serves both and an edited README is still read again on the next request.
31
+ - **An Atlas page whose view tool threw is drawn without the tool's output.** A `Suspense` covers a read that is pending, not one that rejected, so one failing tool took the whole entry page.
32
+ - **`verify` names `--shard` among the options it takes**, and a shard is passed with `--perf` alone: it cannot be combined with `--full` or `--timed`, which `docs/testing.md` now says where it said "beside the other tiers".
33
+ - **The request page asks its reads at once.** The record, the grant behind Replay and the endpoints were three waits in a row; only the customer's address depends on the record.
34
+ - **The tab icon reads the accent the product picks.** `app/icon.ts` took the resolver's default, which is the shipped preset only until a product changes one of the two by hand.
35
+ - **Three routes lose a `Suspense` that wraps nothing**, and the product's one-line description moves into its config (above).
36
+ - **Seven components, each corrected where a reader named the defect.** A copy field's label sat on an element that takes no accessible name; a progress bar with a drawn label was unnamed; a toast raised before `Toaster` mounted was never shown; two incident updates stamped in one millisecond collided; the pager drew for a list that fits its page; two chart memos omitted the geometry they draw with; and a checkbox's and a switch's `className` are documented for the element they style.
37
+ - **Two timers outlived what set them.** The front door's sweep cleared its start timeout and left the interval running, and a host request held its timer for the whole bound after its answer arrived.
38
+ - **The skill's copy of the agent guide is the template's guide again.** It had drifted by four passages — the `data-assistant="omit"` rule, the 409 a thread answers when the session changed hands, the questions a view may offer, and how `pnpm verify --full` walks the assistant — and nothing compared the two. `tests/cli-skill-agents-sync.test.ts` does now, and `update` replaces an unedited copy with this one.
39
+ - **The skill and the docs name the paths the code has.** A prepared query beside a view is `src/logic/<view>-explore.ts` and a view's context file `src/logic/*-context.ts`, both moved in 0.28.0 while the skill still said `src/views/`; `assistantTools` takes `(collections, writer, guard, views?, explore?)`; `brand` is run through the package, not as `node scripts/brand.ts`.
40
+ - **`.env.example` names `DEMO_PASSWORD` and `DEMO_SECRET`**, and `AGENTS.md`'s generated block starts at the level Next writes it, so `next dev` stops re-creating the file as a change.
41
+ - **Three things nothing read are gone**: `scripts/lib/atlas.ts`'s `hasAtlas`, `verify`'s unused `reportSuite` return, and jade's empty `light: {}` token override.
42
+
5
43
  ## [0.29.0] · 2026-10-25
6
44
 
7
45
  ### New
package/payload/README.md CHANGED
@@ -118,6 +118,41 @@ call a live API, say so in the sentence: `verify --full` presses every control,
118
118
  fake API first. The package (`cli/`, decision 0009) copies files and nothing depends on it afterwards; its commands are
119
119
  in `cli/README.md`.
120
120
 
121
+ ## Model your data before a page is built
122
+
123
+ A Meridian page is only as good as the model under it: a figure needs a definition, a unit and a window; a table needs a
124
+ key and a grant; an agent needs to know what it may count and what it may only propose. One prompt settles that before
125
+ anything is drawn, and its answer outlives the build — it is the file the next person reads.
126
+
127
+ Paste this into the same agent, before the build sentence:
128
+
129
+ > Model my data for Meridian, before any page is built. Read this folder first — a schema, a migration, an OpenAPI file
130
+ > or an existing data layer answers most of this — and ask me only what the folder does not say, in one batch of at most
131
+ > twelve questions. Then write the model into `docs/brief.md` (the file `adopt` and `create` leave for it), answering:
132
+ >
133
+ > 1. **Collections.** Each one's name, its key, the tenant it belongs to, every field with its type, and which of them a
134
+ > reader may not see, which only a page may write, and which are derived rather than stored. The contract is the one
135
+ > `src/lib/collection.ts` declares.
136
+ > 2. **The day.** Which field says when a record happened, and what a day means for it — the time zone the product
137
+ > counts in, so "today" and "the last 7 complete days" mean one thing on every figure.
138
+ > 3. **Money.** Which fields are amounts, in which currency, and whether one row can be in a different currency from the
139
+ > next.
140
+ > 4. **Figures.** For every figure the dashboard will show: its name, its unit, what it changes against (the window
141
+ > before, a target, a baseline), and its definition in one sentence a stranger could apply to a row.
142
+ > 5. **Grants.** What each role may read and write, in the `resource:operation` form `src/data/access.ts` asks, and which
143
+ > collection each grant names. Say what a person with the smallest role still has to see for the product to make
144
+ > sense — that is what decides which views they get.
145
+ > 6. **Questions.** For each page: the three questions someone on that page would ask that the page's own text does not
146
+ > answer — what must be countable, groupable or comparable, and over which collections.
147
+ > 7. **Writes.** What an agent may propose on each page, and what stays the page's own — the fields a change must never
148
+ > set.
149
+ >
150
+ > Where the answer is a choice, give me the two or three options with the figure each produces, and recommend one. End
151
+ > with one table: figure · definition · source · unit · window. The build reads that table, not the prose above it.
152
+
153
+ Then give the build sentence above. The agent reads the brief first, and every figure on the page it builds is one the
154
+ brief defines — which is also how you tell a wrong rendering from a wrong model.
155
+
121
156
  ## Privacy and feedback
122
157
 
123
158
  Meridian takes nothing from you. The package, the template and the skill have no telemetry, no analytics and no
@@ -4,7 +4,7 @@ import { ExportButton } from '@/components/patterns/export-button';
4
4
  import { Freshness } from '@/components/patterns/freshness';
5
5
  import { PeriodSelect } from '@/components/patterns/period-select';
6
6
  import { HomeBody } from '@/views/home';
7
- import { PeriodActionsRoom, PeriodRoom } from '@/views/period-actions';
7
+ import { PeriodActionsRoom } from '@/views/period-actions';
8
8
  import { gate, gatedMetadata } from '@/data/access';
9
9
  import { RefusalScreen } from '@/views/refusal';
10
10
  import { DEMO_NOW, DEMO_UPDATED_AT } from '@/data/sample';
@@ -83,7 +83,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
83
83
  const { data } = await readHome(searchParams);
84
84
  return (
85
85
  <>
86
- <Suspense fallback={<PeriodRoom />}><PeriodSelect value={data.period} /></Suspense>
86
+ <PeriodSelect value={data.period} />
87
87
  <ExportButton rows={dayRows(data.series)} filename={`home-${data.period}.csv`} noun="days" />
88
88
  </>
89
89
  );
@@ -9,7 +9,7 @@ import { Freshness } from '@/components/patterns/freshness';
9
9
  import { Busy, LeadRoom } from '@/components/patterns/page-loading';
10
10
  import { PeriodSelect } from '@/components/patterns/period-select';
11
11
  import { parsePeriod } from '@/lib/period';
12
- import { PeriodActionsRoom, PeriodRoom } from '@/views/period-actions';
12
+ import { PeriodActionsRoom } from '@/views/period-actions';
13
13
  import { dayRows } from '@/logic/home-context';
14
14
  import { RevenueBody } from '@/views/revenue';
15
15
  import { revenueTool } from '@/logic/tools';
@@ -75,7 +75,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
75
75
  const { data } = await readPeriod(period);
76
76
  return (
77
77
  <>
78
- <Suspense fallback={<PeriodRoom />}><PeriodSelect value={period} /></Suspense>
78
+ <PeriodSelect value={period} />
79
79
  <ExportButton rows={dayRows(data.days?.series ?? [])} filename={`revenue-${period}.csv`} noun="days" />
80
80
  </>
81
81
  );
@@ -32,7 +32,7 @@ The body (rows 1 and 2 and the line on rotating a key under them, under the mast
32
32
 
33
33
  ## Data
34
34
 
35
- The keys are the `keys` collection (`src/data/collections.ts`), read through `read('keys')` on every request. Create and Revoke are server actions (`actions.ts`) that write the caller's own collection (`collectionFor(scope, 'keys')`), then the view refreshes the route. A rejected create is said inside the sheet; a rejected revoke shows a critical toast and puts the row back. The secret is generated on the server and returned once, to the banner, which is the only place it is ever shown. Its Copy field is marked `data-assistant="omit"`, so a question asked or an Ask pressed while the banner is up sends the page text without it: the assistant reads that a key was created, never its value. The collection stores the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret, so the table shows the hint and nothing in the browser or the cache can reveal or copy a listed key. To replace a key, create a new one and revoke the old.
35
+ The keys are the `keys` collection (`src/data/collections.ts`), read through `readAll('keys')` on every request. Create and Revoke are server actions (`actions.ts`) that write the caller's own collection (`collectionFor(scope, 'keys')`), then the view refreshes the route. A rejected create is said inside the sheet; a rejected revoke shows a critical toast and puts the row back. The secret is generated on the server and returned once, to the banner, which is the only place it is ever shown. Its Copy field is marked `data-assistant="omit"`, so a question asked or an Ask pressed while the banner is up sends the page text without it: the assistant reads that a key was created, never its value. The collection stores the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret, so the table shows the hint and nothing in the browser or the cache can reveal or copy a listed key. To replace a key, create a new one and revoke the old.
36
36
 
37
37
  ## Embed view
38
38
 
@@ -35,7 +35,7 @@ The body (rows 1 and 2) is the records template, `Records` from `src/components/
35
35
 
36
36
  ## Data
37
37
 
38
- The `members` collection in `src/data/collections.ts`, read through `read('members')` at request time, so the person's grant is asked on the path every page reads by. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status), `members.remove` and `assignRole`; the view refreshes the page after each and keeps no copy of the rows. Last active is relative to the data's clock.
38
+ The `members` collection in `src/data/collections.ts`, read through `readAll('members', { sort: { field: 'joined', dir: 'desc' } })` at request time, so the person's grant is asked on the path every page reads by. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status), `members.remove` and `assignRole`; the view refreshes the page after each and keeps no copy of the rows. Last active is relative to the data's clock.
39
39
 
40
40
  Every one of those writes passes `src/data/member-mutations.ts`, which re-resolves the caller, checks the operation's grant and refuses a change or removal of an Owner without `workspace:update`, one's own role or add-ons, suspending or removing oneself, and any change that would leave no active Owner. The page reads that boundary per row, once, and is handed only what it allows: `memberAccess(id)` gives the row's booleans, its role choices, its add-on choices and its reason, and `invitableRoles()` gives the roles an invitation may carry. The page decides nothing from a role of its own. A role or add-on change is one `assignRole` write, with its Activity line in the same step; the matrix in row 2 asks `whoCan` over the same `GRANTS` the boundary uses.
41
41
 
@@ -2,16 +2,18 @@
2
2
 
3
3
  import { clock } from '@/data/collections';
4
4
  import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
5
- import { assignMemberRole, REFUSALS } from '@/data/member-mutations';
5
+ import { assignMemberRole, MemberRefusal, REFUSALS } from '@/data/member-mutations';
6
6
  import type { AnyCollection } from '@/lib/collection';
7
7
  import { dayOf } from '@/lib/format-date';
8
8
  import type { Result } from '@/views/members';
9
9
  import { STATUSES, TEAMS, type Member } from '@/data/sample';
10
10
  import { MAIN_ROLES, type AddOn, type MainRole } from '@/data/roles';
11
11
 
12
+ // A refusal the boundary wrote for the person is shown as it is; anything else that threw — a collection that rejected a
13
+ // shape, a connection that dropped — is not theirs to read, and says only that it did not go through.
12
14
  const fail = (e: unknown): Result => ({
13
15
  ok: false,
14
- error: e instanceof Unauthenticated ? REFUSALS.signIn : e instanceof Error ? e.message : 'The change did not go through.',
16
+ error: e instanceof Unauthenticated ? REFUSALS.signIn : e instanceof MemberRefusal && e.message ? e.message : 'The change did not go through.',
15
17
  });
16
18
 
17
19
  /**
@@ -20,7 +20,7 @@ export async function replayRequest(id: string): Promise<ReplayResult> {
20
20
  const scope = await resolveAccess();
21
21
  if (!(await can(scope, 'requests', 'create'))) return { ok: false, error: 'You do not have permission to replay requests.' };
22
22
  const requests = collectionFor(scope, 'requests');
23
- const original = (await requests.query({ where: [{ field: 'id', op: 'eq', value: id }] })).rows[0] as RequestRow | undefined; // read() cannot serve this: a server action reads the record it is about to change, uncached
23
+ const original = (await requests.query({ where: [{ field: 'id', op: 'eq', value: id }] })).rows[0] as RequestRow | undefined; // allow-no-direct-query: read() cannot serve this — a server action reads the record it is about to change, uncached
24
24
  if (!original) return { ok: false, error: `No request with id ${id}.` };
25
25
  const status = original.method === 'POST' ? 201 : 200;
26
26
  const replay = await requests.create!({
@@ -31,6 +31,8 @@ export async function replayRequest(id: string): Promise<ReplayResult> {
31
31
  updateTag(collectionTag(scope.tenantId, 'requests'));
32
32
  return { ok: true, id: String(replay.id), status };
33
33
  } catch (e) {
34
- return { ok: false, error: e instanceof Unauthenticated ? 'Sign in again to replay this request.' : e instanceof Error ? e.message : 'The request was not replayed.' };
34
+ // Only the answer written for the person: a collection that rejected the record says why in its own words, which are
35
+ // not theirs to read, so anything but the missing session is a sentence this action chose.
36
+ return { ok: false, error: e instanceof Unauthenticated ? 'Sign in again to replay this request.' : 'The request was not replayed.' };
35
37
  }
36
38
  }
@@ -52,13 +52,15 @@ export default async function RequestPage({ params }: { params: Promise<{ id: st
52
52
  const { id } = await params;
53
53
  const denied = await gate('request');
54
54
  if (denied) return RefusalScreen(denied);
55
- const { request: r, observedAt } = await byId(id);
56
- // Rendered here rather than thrown, so the screen is in the first HTML (see MissingPage).
57
- if (!r) return <MissingPage />;
55
+ // Everything the page needs that does not depend on the record is asked beside it, in one wait: the record, the
56
+ // grant behind Replay and the endpoints that give the route its p95 were three waits in a row before.
57
+ //
58
58
  // After the gate: what this person may do with the record, so Replay is drawn only where it may be used. A Viewer
59
59
  // holds `requests:read` without `requests:create`, so it is withheld for them and nothing is drawn in its place.
60
- const mayReplay = await may(ACTIONS['replay-request'].needs);
61
- const [endpoints, customerHref] = await Promise.all([readEndpoints(), customerPage(r.customer)]);
60
+ const [{ request: r, observedAt }, mayReplay, endpoints] = await Promise.all([byId(id), may(ACTIONS['replay-request'].needs), readEndpoints()]);
61
+ // Rendered here rather than thrown, so the screen is in the first HTML (see MissingPage).
62
+ if (!r) return <MissingPage />;
63
+ const customerHref = await customerPage(r.customer);
62
64
  const route = endpoints.find((e) => e.method === r.method && e.route === r.route);
63
65
  return <RequestView request={r} trace={traceOf(r)} payloads={payloadsOf(r)} routeP95={route?.p95 ?? null} now={observedAt} mayReplay={mayReplay} replay={replayRequest} customerHref={customerHref} />;
64
66
  }
@@ -9,11 +9,13 @@ import type { Result } from '@/views/members';
9
9
  /** The name a Settings save carries; the address is not editable here. */
10
10
  export type WorkspaceInput = { name: string };
11
11
 
12
+ // The refusals this action chose are shown as they are; a write that threw anything else — a collection rejecting a
13
+ // shape, a connection dropping — is not the person's to read, and says only that it did not go through.
12
14
  const fail = (e: unknown): Result => ({
13
15
  ok: false,
14
16
  error: e instanceof Unauthenticated ? 'Sign in again to make this change.'
15
17
  : e instanceof AccessDenied ? 'You do not have permission to make this change.'
16
- : e instanceof Error ? e.message : 'The change did not go through.',
18
+ : 'The change did not go through.',
17
19
  });
18
20
 
19
21
  /**
@@ -29,15 +29,17 @@ async function workspaceMay(): Promise<SettingsMay> {
29
29
  /**
30
30
  * The workspace record, through `read()` like every other collection, so the `workspace:*` grants the role table freezes
31
31
  * are asked on the one path. It is read only for someone who may read it, and a policy that does not bind the collection
32
- * leaves the form on its own defaults rather than failing the page: Settings renders for everyone.
32
+ * leaves the form on its own defaults rather than failing the page: Settings renders for everyone. A read that *threw*
33
+ * is a different answer from a collection that holds no row, and says so, so the form is never handed the template's
34
+ * name where the workspace's could not be read.
33
35
  */
34
- async function workspaceRecord(): Promise<WorkspaceValues | null> {
36
+ async function workspaceRecord(): Promise<{ values: WorkspaceValues | null; unreadable: boolean }> {
35
37
  try {
36
38
  const { rows } = await read('workspace');
37
39
  const row = rows[0];
38
- return row ? { name: String(row.name), slug: String(row.slug) } : null;
40
+ return { values: row ? { name: String(row.name), slug: String(row.slug) } : null, unreadable: false };
39
41
  } catch {
40
- return null;
42
+ return { values: null, unreadable: true };
41
43
  }
42
44
  }
43
45
 
@@ -49,7 +51,7 @@ export default async function SettingsPage() {
49
51
  // The flags are computed here, on the server, because the view is a client module and never asks the policy itself;
50
52
  // the workspace record and the action that saves it are handed over together, so the section edits what the page read.
51
53
  const permissions = await workspaceMay();
52
- const workspace = permissions.workspaceRead ? await workspaceRecord() : null;
54
+ const workspace = permissions.workspaceRead ? await workspaceRecord() : { values: null, unreadable: false };
53
55
  return (
54
56
  <PageFrame
55
57
  width="form"
@@ -57,7 +59,7 @@ export default async function SettingsPage() {
57
59
  title="Settings"
58
60
  description={`The workspace, how ${app.name} looks, and what assistants may do.`}
59
61
  >
60
- <SettingsBody may={permissions} workspace={workspace} save={saveWorkspace} />
62
+ <SettingsBody may={permissions} workspace={workspace.values} unreadable={workspace.unreadable} save={saveWorkspace} />
61
63
  </PageFrame>
62
64
  );
63
65
  }
@@ -9,7 +9,7 @@ import { Freshness } from '@/components/patterns/freshness';
9
9
  import { Busy, LeadRoom } from '@/components/patterns/page-loading';
10
10
  import { PeriodSelect } from '@/components/patterns/period-select';
11
11
  import { parsePeriod } from '@/lib/period';
12
- import { PeriodActionsRoom, PeriodRoom } from '@/views/period-actions';
12
+ import { PeriodActionsRoom } from '@/views/period-actions';
13
13
  import { dayRows } from '@/logic/home-context';
14
14
  import { UsageBody } from '@/views/usage';
15
15
  import { usageTool } from '@/logic/tools';
@@ -82,7 +82,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
82
82
  const { data } = await readPeriod(period, day);
83
83
  return (
84
84
  <>
85
- <Suspense fallback={<PeriodRoom />}><PeriodSelect value={period} /></Suspense>
85
+ <PeriodSelect value={period} />
86
86
  <ExportButton rows={dayRows(data.days?.series ?? [])} filename={`usage-${period}.csv`} noun="days" />
87
87
  </>
88
88
  );
@@ -35,12 +35,19 @@ export async function GET(request: Request): Promise<Response> {
35
35
  return;
36
36
  }
37
37
  try {
38
- const batch = await collection.query({ where: query.where, sort: query.sort, limit: BATCH, offset }); // read() cannot serve this: a streamed export must not be cached
38
+ const batch = await collection.query({ where: query.where, sort: query.sort, limit: BATCH, offset }); // allow-no-direct-query: read() cannot serve this — a streamed export must not be cached
39
39
  total = batch.total;
40
- const lines = batch.rows.map((r) => csvRow(COLUMNS, line(r)));
41
40
  const head = offset === 0 ? csvHeader(COLUMNS) + '\r\n' : '';
42
- offset += BATCH;
43
- controller.enqueue(encoder.encode(head + lines.map((l) => l + '\r\n').join('')));
41
+ const lines = batch.rows.map((r) => csvRow(COLUMNS, line(r)));
42
+ // How far the stream has come is what the collection answered, not what it was asked for: a collection that
43
+ // answers fewer rows than the batch asked for would otherwise leave a gap between the last row written and the
44
+ // next one read, and the export would silently miss them.
45
+ offset += batch.rows.length;
46
+ if (head) controller.enqueue(encoder.encode(head));
47
+ if (lines.length) controller.enqueue(encoder.encode(lines.map((l) => l + '\r\n').join('')));
48
+ // Nothing back means the export is over, whatever the total says, so a collection that counts rows it will not
49
+ // return cannot ask the stream for the same batch forever.
50
+ if (!lines.length && !cancelled) controller.close();
44
51
  } catch (e) {
45
52
  controller.error(e);
46
53
  }