@aiquants/daily-report 0.31.0 → 0.33.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.
- package/CHANGELOG.md +121 -0
- package/README.md +154 -83
- package/dist/client.d.mts +38 -18
- package/dist/client.d.ts +38 -18
- package/dist/client.js +4 -4
- package/dist/client.mjs +4 -4
- package/dist/ids-stream-CtlszCHr.d.ts +32 -0
- package/dist/ids-stream-CyK2upgD.d.mts +32 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +63 -34
- package/dist/server.d.ts +63 -34
- package/dist/server.js +4 -4
- package/dist/server.mjs +4 -4
- package/dist/{ids-stream-BR5RSj5u.d.ts → sse-schema-DcOgQr_O.d.mts} +685 -643
- package/dist/{ids-stream-DvB2h1dj.d.mts → sse-schema-DcOgQr_O.d.ts} +685 -643
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +5 -4
- package/dist/types-DkERw8NE.d.mts +0 -74
- package/dist/types-DkERw8NE.d.ts +0 -74
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
`@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
|
|
29
|
-
The peer floor of `@aiquants/virtualscroll` is **3.
|
|
29
|
+
The peer floor of `@aiquants/virtualscroll` is **3.13.0**: install 3.13.0 or later.
|
|
30
30
|
Both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
|
|
31
31
|
see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
|
|
32
32
|
`DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes `align: "nearest"` since 3.11.0, and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
|
|
@@ -42,7 +42,10 @@ An `.mjs` or `.css` target of `exports` without an entry, an entry for a file th
|
|
|
42
42
|
Every `publish:*` script measures the build that `pnpm run verify` ends with (verify's last steps are the build and the bundle check) and does not build again: right after verify it runs `node scripts/check-bundle-size.mjs --write`, then `node scripts/check-bundle-size.mjs --exact`, before the leak check (which reads the same build) and the version bump.
|
|
43
43
|
`--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build after review.
|
|
44
44
|
So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
|
|
45
|
-
`pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
|
|
45
|
+
`pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the changed-lines coverage gate, the examples, the build and, last, the bundle check.
|
|
46
|
+
The changed-lines coverage gate (`scripts/check-changed-lines-coverage.mjs`, `pnpm run check:changed-lines-coverage`) reads the coverage run and the lines added to `src` since the package's latest release tag (`<package name>@<version>`, resolved from `package.json`; untracked files count as added):
|
|
47
|
+
an added line inside a statement that no spec executed fails unless `scripts/changed-lines-coverage-exemptions.json` names it with its text and the reason, and an exemption that covers no such line is stale and fails (exit 0 / 1, and 2 when nothing is proven), so new code cannot hide behind a file's floor.
|
|
48
|
+
The build fails on any esbuild warning (`scripts/lib/build-warnings.mjs`), and the CommonJS bundles replace `import.meta.hot` with `undefined`, so they carry no `import.meta` while the ESM bundles keep the hot-module clean-up for the host's Vite.
|
|
46
49
|
The unit tests include the repository's publish leak guard over the markdown the package ships (`src/shipped-markdown-leaks.spec.ts`: every markdown file `package.json` `files` ships — `README.md`, `CHANGELOG.md` and any shipped docs — packed with `package.json` into a tarball and checked by the guard itself, with its own rules), so an internal name in them fails verify instead of stopping a publish; the publish paths still run the guard on the packed tarball, `dist` included.
|
|
47
50
|
Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
|
|
48
51
|
Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
|
|
@@ -109,7 +112,7 @@ The standalone build bundles every JSX utility (and maps the shadcn tokens), but
|
|
|
109
112
|
## Required database schema
|
|
110
113
|
|
|
111
114
|
Seven tables: `DailyReportHub` (core/cross-source), `DailyReportInternal` (in-app content), `DailyReportComment`, `DailyReportLabel`, `DailyReportHub_Label`, `DailyReportUserStatus` (read status & stars), and the optional `DailyReportAttachment` (attachment metadata — omit it and the attachment feature degrades gracefully: `attachments` stays an empty array and the attachment endpoint returns 404 for every token).
|
|
112
|
-
Attachment delivery additionally requires the `
|
|
115
|
+
Attachment delivery additionally requires the `attachments` block (`DailyReportAttachmentsConfig`), which always carries the id codec and the read port: a key of the service configuration (`DailyReportServiceConfig`), which `createDailyReportServer` accepts because `DailyReportServerConfig` extends the service configuration; see [Attachments](#attachments).
|
|
113
116
|
The factory then exposes `dailyReportServer.attachment.loader`, which the host app must mount on its own byte-serving route (e.g. `daily_report.api.attachment.$token` — a route separate from the `:endpoint` JSON router, whose fixed `Content-Type: application/json` + CSP headers are incompatible with byte delivery). The route must name its parameter `token`: a route without it is a configuration error that every request answers with 500 and an `error` log line (see **Thumbnail endpoint** below). The same route and loader also serve thumbnails (`?thumbnail=tile`); no extra route is needed.
|
|
114
117
|
|
|
115
118
|
Existing apps can inject their own drizzle models (structural typing — see `DailyReportTables`). Greenfield projects can generate definitions:
|
|
@@ -140,6 +143,11 @@ export const dailyReportServer = createDailyReportServer({
|
|
|
140
143
|
],
|
|
141
144
|
draftLabelNames: ["Draft", "Work in Progress"], // Draft label names (can specify a single string or array of candidates)
|
|
142
145
|
enableDevCacheClear: import.meta.env.DEV, // Optional: allow the dev-only `intent=clearCache` (default false → 400)
|
|
146
|
+
attachments: { // Optional: attachment delivery (see Attachments); omit it for a deployment without attachments
|
|
147
|
+
idCodec: attachmentIdCodec, // Attachment id obfuscation (an instance separate from the user-id codec)
|
|
148
|
+
read: readAttachment, // DailyReportReadAttachment
|
|
149
|
+
thumbnails: { renderer: thumbnailRenderer }, // Optional: image previews (createSharpThumbnailRenderer(sharp))
|
|
150
|
+
},
|
|
143
151
|
})
|
|
144
152
|
```
|
|
145
153
|
|
|
@@ -163,7 +171,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
163
171
|
**Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`) answer with a body that streams until the client leaves. The package keeps that body from starting, or from being held, for a request nobody reads as a stream, so the two routes need no guard of their own:
|
|
164
172
|
|
|
165
173
|
- **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
|
|
166
|
-
Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame) and the viewer's visibility —
|
|
174
|
+
Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame), the ids stream's `forceRefresh` (400 for a value other than `true` / `false`) and the viewer's visibility —
|
|
167
175
|
but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
|
|
168
176
|
React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
|
|
169
177
|
- **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
|
|
@@ -172,7 +180,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
172
180
|
- **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`.
|
|
173
181
|
The endpoint table (`API_ENDPOINT_BODY_KINDS` in `src/server/api-endpoint.ts`) states once what kind of body each endpoint answers, and the methods follow from it (`API_ENDPOINT_METHODS`): `GET` for a JSON body (`report` and `business-date`, whose ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for a streaming body (the ids stream). The API route's refusal of data requests above follows from the same table.
|
|
174
182
|
|
|
175
|
-
**Request values**: the API route and the action read every business
|
|
183
|
+
**Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh`, the action's echo id and the rest of its payload — with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
|
|
176
184
|
|
|
177
185
|
- **Business dates** (`parseBusinessDateKey`): `YYYY-MM-DD`, or `YYYY/MM/DD` with the same separator twice (read as the same day), naming a day of the calendar from 0001-01-01 to 9999-12-31, the range of the database's `date` column.
|
|
178
186
|
Anything else — surrounding whitespace, a time stamp (`2026-10-06T00:00:00.000Z`), a date written as text, a day off the calendar (`2026-02-30`, `2026-13-40`, month 0), the year 0 — is refused: the `business-date` endpoint answers `{"error":{"message":"Invalid business date"}}` (also when `businessDate` is missing), and the action `{"error":"Invalid businessDate"}` for every intent that sends one, `create` included.
|
|
@@ -180,8 +188,30 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
180
188
|
- **Ids** (`parseCanonicalPositiveId`): `reportHubId` (the `report` endpoint, and every intent but `create` and `clearCache`) and `commentId` (`deleteComment`) are only the canonical decimal form of a positive safe integer: digits without a leading zero, from 1 to 2^53 − 1.
|
|
181
189
|
`1e3`, `12abc`, ` 7 `, `0x10`, `1.9`, `-3`, `0` and `9007199254740993` (2^53 + 1, which would round to its neighbour and could name another row) are `Invalid reportHubId` / `Invalid commentId`.
|
|
182
190
|
The ids stream's resume cursor follows the same rule, and its business date must be the canonical `YYYY-MM-DD` the server wrote (a malformed cursor is 400 `Invalid cursor`); an attachment token that decodes to anything but a positive safe integer is 400 `Invalid attachment token`.
|
|
183
|
-
-
|
|
184
|
-
|
|
191
|
+
- **`forceRefresh`** (`parseForceRefreshParam`; the `business-date`, `report` and `ids-stream` endpoints): absent reads as `false`, and only `true` and `false` are accepted. Anything else — `yes`, `TRUE`, `1`, the empty value — is `{"error":{"message":"Invalid forceRefresh"}}`; the ids stream answers it before its `HEAD` short-circuit.
|
|
192
|
+
- **The echo id** (`clientTempId`; every intent but `clearCache`): the client makes one id per operation, sends it with the action and recognizes its own operation by it in the answer and in the SSE event. The action accepts exactly the two forms the package's client makes (`parseClientTempId`, server entry, which returns the branded type `DailyReportClientTempId`):
|
|
193
|
+
a lowercase UUID (`crypto.randomUUID()`: 8-4-4-4-12 lowercase hexadecimal digits) and a negative decimal integer without a leading zero of at most 16 digits (`String(-1 * Date.now())`, the optimistic temporary id of a report or a comment). A missing or empty id is `clientTempId required`;
|
|
194
|
+
anything else — an uppercase UUID, `-0`, `tmp-1`, surrounding whitespace, 37 characters, a text of 1 MiB — is `Invalid clientTempId`, before any service call.
|
|
195
|
+
The server writes the id into the persistent SSE event of every intent and echoes it in the answer, so the two forms keep the id from deciding an event's size; the SSE message schema reads every event's id against the same pattern (`DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` in `src/shared/client-temp-id.ts`), so an entry with any other id is dropped on delivery (fail-closed).
|
|
196
|
+
The service's write methods — `setStarStatus`, `setReadStatus`, `addComment`, `deleteComment`, `createDailyReport`, `updateDailyReport`, `publishDailyReport` and `deleteDailyReport` — take only a `DailyReportClientTempId`, which only `parseClientTempId` produces, so a host that calls the service directly (an integration test, a batch) reads its id through the parser first and cannot publish an event that delivery would drop.
|
|
197
|
+
- **The action's form**: the action reads the body with a cap and parses the whole form before it resolves the internal user, the viewer's visibility or any intent (`readActionCommand` in `src/server/action-form.ts`, the only code that reads the form; the intents run on the parsed command and never see the form).
|
|
198
|
+
- **Rate**: before the body is read, each request spends one token of its viewer's bucket, keyed by the external user id `authenticate` returns: `ACTION_RATE_LIMIT_PER_MINUTE` = 600 per viewer and per handler factory (so per worker process), refilled in proportion to the time over one minute (`ACTION_RATE_WINDOW_MS`). The bucket starts full, so a burst of up to 600 passes at once.
|
|
199
|
+
An empty bucket answers 429 `{"error":"Too many requests"}` with `Retry-After: 60` (one window, after which the bucket is full again); it reads no body and calls no service. The bound is the client's own fastest flow with headroom: a side pane auto-reads the report it shows after 500 ms, so one pane sends at most 60,000 ÷ 500 = 120 `toggleRead` a minute, and 600 is five such panes; stars, comments and saves come at the pace of a person.
|
|
200
|
+
The refusals are recorded per viewer in windows of the rate window at `warn` (through the shared bucket of `src/server/rate-limit.ts`, which the attachment paths use too): the window's first refusal as `429 action reason=rate_limit`, and the rest counted into one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> route=action` line written by the window's own timer at its end. No line names the user.
|
|
201
|
+
- **Size**: the body is read up to `ACTION_FORM_MAX_BYTES` (1 MiB, 1,048,576 bytes). A declared `Content-Length` above it is answered 413 `{"error":"Form too large"}` without reading the body.
|
|
202
|
+
A body without a declared length (chunked) or with a wrong one stops being read where the count of bytes passes the cap, and the request's body is cancelled. On Node adapters the cancel destroys the request's socket (`@react-router/node`'s `createReadableStreamFromReadable`, for one), so the sender gets a closed connection, not the 413: delivering the 413 would mean reading the rest of an upload of unbounded size, which is the cost the cap exists to avoid. Neither refusal writes an error line.
|
|
203
|
+
Both are recorded per handler factory in windows of 60 s (`ACTION_FORM_REFUSAL_LOG_WINDOW_MS`) at `warn`: the window's first refusal as `413 action reason=form_too_large measured=<declared|counted> limit=1048576`, and, when the window counted more, one `form_too_large_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> measured=declared:<n>,counted:<n>` written by the window's own timer at its end.
|
|
204
|
+
The cap is the only bound of a report's body and a comment's text, which have no ceiling of their own.
|
|
205
|
+
- **Text only**: a body that is no form (no form media type, a broken multipart body, a body cut short) and a form with a file part anywhere are `{"error":"Invalid form"}`, without an error log line.
|
|
206
|
+
- **Order**: the business date, the operation timestamp (`operationTimestamp`: the canonical decimal form of a non-negative safe integer, else `Invalid operationTimestamp`), then — for every intent but `clearCache`, which reads nothing more — `clientTempId` (`clientTempId required` when it is missing or empty, then `Invalid clientTempId` for any other form; **The echo id** above), for `create` the business date's presence (`businessDate required`; `create` never reads `reportHubId`), `reportHubId`, and then the intent's own values below, or `Invalid intent` for a name that is no intent.
|
|
207
|
+
A `clearCache` that `enableDevCacheClear` does not open is `Invalid intent` as well, also before the user lookup. A star or read toggle echoes the timestamp as it was sent, `null` when none was sent.
|
|
208
|
+
- **`update`**: `title` and `content` change only the fields that are sent. A field that is not sent keeps its stored value, and the empty text clears the field, which is stored as NULL (one rule, the service's `storedText`).
|
|
209
|
+
The title is at most the length the injected title column declares, in UTF-16 code units (`service.titleMaxLength`, read from `tables.hub.title`: 200 for `defineDailyReportSchema`'s `nvarchar(200)`, which counts code units as `String.length` does, so a surrogate pair takes 2); a longer one is `Invalid title`. The package's client always sends both fields as typed.
|
|
210
|
+
- **`toggleStar` / `toggleRead`**: `isStarred` / `isRead` is exactly `true` or `false` (`parseWireBoolean`). Anything else — `TRUE`, `1`, `yes`, the empty text — is `Invalid isStarred` / `Invalid isRead`, and a missing one is `isStarred required` / `isRead required`.
|
|
211
|
+
- **`addComment`**: `content` is a non-empty text (`Content required` otherwise). **`deleteComment`**: `commentId` (**Ids** above).
|
|
212
|
+
- **One wire contract for the action and the JSON endpoints** (`src/shared/action-wire.ts` and `src/shared/api-endpoints.ts`, shared by the server and the package's client; neither side restates a name): the intents (`DAILY_REPORT_ACTION_INTENTS`, from which the server's form reading and the client's operations derive), the form's field names, the command of each intent with its form encoding (`encodeActionCommand`, which the server's parsing reads back as the same command), the endpoint and query-parameter names, and the 200 answer of each intent (`ActionResult`).
|
|
213
|
+
Every answer carries its `intent` (`deleteComment`'s too) and the report's id as the decimal text the server sends, beside the echo id and the intent's own values (the created or published report, the toggled statuses, the posted comment, the deleted comment's id); a refusal is `{"error":"<message>"}`.
|
|
214
|
+
The package's client reads an answer with the strict `parseActionResult` of the intent it sent — a field of another type, a number where the text id belongs or another intent's answer fails the action, nothing is converted — and reads the `report` and `business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`); a failure rejects with an `Error`.
|
|
185
215
|
|
|
186
216
|
### DI ports
|
|
187
217
|
|
|
@@ -193,13 +223,13 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
193
223
|
- `draftLabelName` / `draftLabelNames` — Database label names representing draft states (single string or array of candidates like `["Draft", "Work in Progress"]`). Used for server-side cross-user visibility filtering (hiding drafts from other users) and `isDraft` evaluation.
|
|
194
224
|
- `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
|
|
195
225
|
- `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the action answers `400` (`Invalid intent`) and never calls `service.clearCache()`. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
|
|
196
|
-
- `
|
|
197
|
-
- `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments))
|
|
198
|
-
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath
|
|
226
|
+
- `attachments` — Optional attachment delivery block (`DailyReportAttachmentsConfig`), owned by the service: the id codec and the read port it always carries, the size limit and the original route's tuning, and the optional `thumbnails` with the renderer port and its tuning. See [Attachments](#attachments).
|
|
227
|
+
- `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments)), `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)) and `[DailyReportAction]` from `warn` (the record of the action's 413 and 429, **Request values** in [Server wiring](#server-wiring-di)).
|
|
228
|
+
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
|
|
199
229
|
|
|
200
230
|
**Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
|
|
201
231
|
A rejected value — a wrong type included, also a port or a function given with the wrong type — is a `RangeError` whose message ends with `; got <value>` (a string as JSON; a number, a boolean, `null` or `undefined` as written; an array as `array`; anything else only its `typeof`). An unknown key is a `RangeError` that lists the known keys as JSON strings: `<path> must be one of "<key>", "<key>", …; got "<unknown key>"`. A missing (`undefined`) function or port is a `TypeError`, without `; got`.
|
|
202
|
-
For example `[daily-report]
|
|
232
|
+
For example `[daily-report] attachments.concurrency must be an integer >= 1; got "2"`, `[daily-report] config key must be one of "apiBasePath", "ssePath", …; got "title"` and `[daily-report] attachments.read must be injected when attachments is given`.
|
|
203
233
|
The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configKeyError`, `configPortError`) are the only code that constructs a `RangeError` or a `TypeError`, so every such message has the prefix and the form: `src/host-facing-errors.spec.ts` reads the sources' syntax tree (specs and test helpers aside) and fails on a construction anywhere else.
|
|
204
234
|
|
|
205
235
|
### Request isolation
|
|
@@ -310,7 +340,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
310
340
|
| --- | --- | --- |
|
|
311
341
|
| inline | `{apiBasePath}/attachment/{token}` | The original (shown by the browser for inline-safe types) |
|
|
312
342
|
| download | `...?download=1` | The original with `Content-Disposition: attachment` |
|
|
313
|
-
| thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when
|
|
343
|
+
| thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when the `attachments` block carries `thumbnails` |
|
|
314
344
|
|
|
315
345
|
- **Build and parse the URL with the shared codec** (root entry `@aiquants/daily-report`). `buildDailyReportAttachmentUrl(apiBasePath, token, request)` takes `{ kind: "inline" }`, `{ kind: "download" }` or `{ kind: "thumbnail", variant }` (the `DailyReportAttachmentDeliveryRequest` union), and `parseDailyReportAttachmentQuery(searchParams)` reads a query back into the same request or into `{ kind: "invalid", reason: "thumbnail_variant" | "download_value" | "conflict" }` (`DailyReportAttachmentInvalidQuery`). The parameter names are private to the codec.
|
|
316
346
|
- **Variants** form the closed, frozen list `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` (root entry). It has one variant, `tile`: the box 480 × 320 a preview fits in, twice the largest tile frame of 240 × 160 CSS px. `isDailyReportAttachmentThumbnailVariant(value)` accepts the list's own keys only, exactly (`Tile`, `" tile"` and `toString` are not variants).
|
|
@@ -324,32 +354,36 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
324
354
|
The three headers come from one set (`ATTACHMENT_RESPONSE_SECURITY_HEADERS`) that every path building an attachment response spreads last — the failure builder `attachmentFailureResponse`, which every JSON failure goes through, and the 200 and 304 of both deliveries — so no status the route answers can lose them. `Content-Security-Policy: default-src 'none'; sandbox` stays on the two content 200s.
|
|
325
355
|
The one request whose answer the route does not control is React Router's single-fetch data request (`<token>.data`): React Router would read the original into memory, re-encode it and keep only `Set-Cookie` of its headers (no `Cache-Control`, Content Security Policy, `Content-Disposition` or any of the three), so the route refuses it before anything runs, with a 404 that carries no attachment content ([Request isolation](#request-isolation)).
|
|
326
356
|
|
|
327
|
-
**Configuration
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
|
331
|
-
|
|
|
332
|
-
| `
|
|
333
|
-
| `
|
|
334
|
-
| `
|
|
335
|
-
| `
|
|
336
|
-
| `
|
|
337
|
-
| `
|
|
338
|
-
| `
|
|
339
|
-
| `
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
- **
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
357
|
+
**Configuration**: one optional block, `attachments` (`DailyReportAttachmentsConfig`), a key of the service configuration (so of `createDailyReportServer`'s too). Without it the deployment has no attachments: the detail's `attachments` is always `[]` and the attachment route answers 404 for every delivery.
|
|
358
|
+
Its `thumbnails` (`DailyReportAttachmentThumbnailsConfig`) is optional too: without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original).
|
|
359
|
+
|
|
360
|
+
| Path | Default | Meaning |
|
|
361
|
+
| --- | --- | --- |
|
|
362
|
+
| `attachments.idCodec` | required in the block | Obfuscates attachment ids (`DailyReportIdCodec`). Use an instance separate from the user-id codec, or the token of a user id in a response would read as an attachment token. |
|
|
363
|
+
| `attachments.read` | required in the block | Reads the original bytes (`DailyReportReadAttachment`, **Read port** below). |
|
|
364
|
+
| `attachments.maxBytes` | 32 MiB | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
|
|
365
|
+
| `attachments.rateLimitPerMinute` | 60 | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
|
|
366
|
+
| `attachments.concurrency` | 4 | Simultaneous original reads. A slot is held while the original is referenced (below: until its body has been handed over, the client cancels it, or the client stops reading for 60 s), and one viewer holds at most half of the slots, rounded up (⌈n / 2⌉: 2 of the default 4). A request that finds no free slot, or whose viewer already holds that share, fails fast with 503 (`reason=concurrency`, `Retry-After: 5`). Integer ≥ 1. |
|
|
367
|
+
| `attachments.thumbnails.renderer` | required in `thumbnails` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created (**Thumbnail renderer port** below). |
|
|
368
|
+
| `attachments.thumbnails.rateLimitPerMinute` | 120 | Thumbnail generations per user per minute, in a bucket separate from originals; revalidations (requests with `If-None-Match`) pay from a second bucket of ten times this value. Both admit before authorization, and an empty bucket answers 429 at once; a revalidation that does not end in a 304 also pays a generation token, right after authorization (see **Rate admission** below). Integer ≥ 1. |
|
|
369
|
+
| `attachments.thumbnails.concurrency` | 2 | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
|
|
370
|
+
| `attachments.thumbnails.cacheBytes` | 8 MiB | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
|
|
371
|
+
|
|
372
|
+
- **The block admits only the wirings that work.** The codec and the read port are required members of the block, and the renderer and the thumbnail tuning exist only inside `thumbnails`, so the type admits exactly three wirings: no block (no attachments), a block without `thumbnails` (originals only) and a block with `thumbnails` (originals and thumbnails). Every tuning value sits next to the port it tunes, and no combination of keys can be given that does nothing.
|
|
373
|
+
- **The service is the only owner of the delivery configuration.** `createDailyReportService` (and so `createDailyReportServer`) resolves the block once into the frozen `service.attachmentDelivery` (`DailyReportAttachmentDelivery`: `{ idCodec, read, maxBytes, rateLimitPerMinute, concurrency, thumbnails }` with every default resolved, `thumbnails` being `{ renderer, rateLimitPerMinute, concurrency, cacheBytes }` or `null`; `null` without the block), and the handlers read only that object.
|
|
374
|
+
The `hasThumbnail` flag in the lists and the endpoint's behaviour therefore always come from the same values. `DailyReportHandlersConfig` has no attachment key; a hand-written service stand-in passed to `createDailyReportHandlers` provides `attachmentDelivery` in the resolved shape, or `null`.
|
|
375
|
+
- **Values from outside the type check are validated once, at creation** (a host written in JavaScript, a configuration read from a file), by the convention under **Configuration errors**, each message naming the nested path.
|
|
376
|
+
A missing port is a `TypeError`: `[daily-report] attachments.idCodec must be injected when attachments is given`, `[daily-report] attachments.read must be injected when attachments is given` and `[daily-report] attachments.thumbnails.renderer must be injected when attachments.thumbnails is given`.
|
|
377
|
+
A rejected value is a `RangeError`: a block or a `thumbnails` that is not an object (`null` included), a codec without `encode` and `decode` functions, a read port that is not a function, a renderer of the wrong shape (**Thumbnail renderer port** below), and a number.
|
|
378
|
+
Only an omitted number (`undefined`) takes the default; any other value that is not a safe integer at or above the minimum, such as `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"`, is refused: `[daily-report] attachments.thumbnails.cacheBytes must be an integer >= 0; got -1`, `[daily-report] attachments.concurrency must be an integer >= 1; got "2"`.
|
|
379
|
+
- **No attachment key outside the block.** A configuration that carries one of the nine attachment keys of the flat layout — `attachmentIdCodec`, `readAttachment`, `attachmentMaxBytes`, `attachmentRateLimitPerMinute`, `attachmentConcurrency`, `attachmentThumbnailRenderer`, `attachmentThumbnailRateLimitPerMinute`, `attachmentThumbnailConcurrency` or `attachmentThumbnailCacheBytes` —, even with the value `undefined`,
|
|
380
|
+
stops `createDailyReportService`, `createDailyReportHandlers` and `createDailyReportServer` with a `TypeError` that names the path that takes its value (`[daily-report] attachments.concurrency must be given in place of the flat key attachmentConcurrency`).
|
|
381
|
+
A host that still passes them fails at startup instead of silently losing its attachments; `CHANGELOG.md` maps every key to its path.
|
|
382
|
+
- **Handler tuning is per process.** Rate buckets, gates and the cache live in the handler factory's closure and are not shared across workers; the effective container-wide limit is the configured value times the number of workers. The handler factory builds them from `service.attachmentDelivery`, and builds no thumbnail bucket, gate or cache without `thumbnails`.
|
|
349
383
|
- **An original's body is handed over slice by slice.** A `GET` of an original sends copies of 256 KiB slices of the bytes, one per read of the host's writer (the body queues nothing ahead of the writer), and keeps its concurrency slot exactly as long as it references the original: the slot is released when the last slice has been handed over, when the client cancels the body, or when the writer has not asked for the next slice for 60 s.
|
|
350
384
|
That idle deadline restarts on every read, so it bounds the time to send one slice, not the transfer (a reader slower than about 35 kbit/s, or one that stopped reading, cannot keep a slot and the original); when it passes, the body ends with an error and the rest of the original is dropped. A `HEAD` of an original holds its slot only until its answer is built.
|
|
351
|
-
- **Heap estimate per process**: while transfers progress, originals hold at most `
|
|
352
|
-
Thumbnails add `
|
|
385
|
+
- **Heap estimate per process**: while transfers progress, originals hold at most `attachments.concurrency × attachments.maxBytes` plus the one slice each response has handed to the host's writer, and a response stalled past the idle deadline holds only that one slice (its slot and the original are released); one viewer holds at most ⌈`attachments.concurrency` / 2⌉ of the slots, so with two slots or more a viewer who stops reading several downloads cannot take every slot from the others.
|
|
386
|
+
Thumbnails add `attachments.thumbnails.concurrency × (attachments.maxBytes + the renderer's decode memory)`, plus `attachments.thumbnails.cacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
|
|
353
387
|
|
|
354
388
|
**Read port**
|
|
355
389
|
|
|
@@ -368,6 +402,7 @@ type DailyReportReadAttachment = (
|
|
|
368
402
|
- Never throw; return a typed failure. `filePath` stays on the server.
|
|
369
403
|
- **`size` is the size of the whole object.** With `head: true` read metadata only and declare it: it is the HEAD's `Content-Length`, which must match a GET's (RFC 9110 §9.3.2), and a HEAD whose read declares no `size` answers without `Content-Length` rather than claim 0 for an object that has a body.
|
|
370
404
|
A GET's `Content-Length` is the length of the `bytes` it sends, so a read that declares `size` declares exactly `bytes.byteLength`: any other value — a port that measures the size in a separate call can see another version of the object — is a port contract violation, answered 500 and logged at `error` as `reason=port_contract code=size_mismatch`, with nothing sent and no missing- or present-object record.
|
|
405
|
+
GET and HEAD share one bound as well: a GET read whose bytes exceed `maxBytes` is the violation `code=over_max_bytes`, and a HEAD read, which carries no bytes, is checked by the `size` it declares — a declared size above `maxBytes` is the same 500 with the same `error` line, without `Content-Length`, so no HEAD answers 200 for an object whose GET ends in 500.
|
|
371
406
|
- **Failure reasons** form one closed union (`DailyReportAttachmentFailure`), and each has the status the handlers answer with; `code` is the raw storage code for the log line and never decides a status. A reason outside the union — which a host outside the type check can return — is a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=failure_reason`, never guessed into another status.
|
|
372
407
|
|
|
373
408
|
| Reason | When | Original | Thumbnail |
|
|
@@ -382,7 +417,7 @@ type DailyReportReadAttachment = (
|
|
|
382
417
|
|
|
383
418
|
`busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing. The requests that joined that generation receive the same failure, and the next request reads again.
|
|
384
419
|
- **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests waiting for the same content share one read, made with the first requester's `principal`, and the outcome is cached for every authorized viewer of that attachment.
|
|
385
|
-
So a host whose storage enforces per-principal ACLs must not
|
|
420
|
+
So a host whose storage enforces per-principal ACLs must not configure `attachments.thumbnails` (one requester's read and its cached outcome would reach authorized viewers outside that ACL), and `denied` is only for a refusal that is the same for every requester (a path outside the sandbox, a permission the service's own credentials lack).
|
|
386
421
|
- **The `bytes` of `ok: true` are the complete object, at most `maxBytes` long.** A read that ends early must fail (`unavailable`), and an object over `maxBytes` must fail as `too_large` without being read. Because an object can change between its size check and its read, the server checks what a read returns:
|
|
387
422
|
- bytes over `maxBytes` are a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=over_max_bytes`, and nothing is sent, rendered, cached or recorded;
|
|
388
423
|
- a thumbnail generation compares what it observed with the size the attachment row declares (`fileSize`, which the content identity names): the length it read, or for a `too_large` only that the object is over `maxBytes`.
|
|
@@ -511,8 +546,8 @@ type DailyReportAttachmentThumbnailRenderer = {
|
|
|
511
546
|
|
|
512
547
|
- `id` names the version of your rendering pipeline, for example `sharp-<version>/vips-<version>/webp-q75-e4/flatten-#ffffff/v1` (the id of the package's sharp renderer below, with the versions read from `sharp.versions` at run time), so a library update never leaves the id stale.
|
|
513
548
|
**Change it whenever the output bytes change** — output format, quality, effort, library version, or how you implement the contract. The id is part of the content identity and therefore of the ETag: a new id makes both the in-process cache and browser revalidation (304) stop reusing old previews. If you change the output without changing the id, browsers keep the old preview through 304s.
|
|
514
|
-
- `createDailyReportService` / `createDailyReportServer` check the port when they are created, in this order, by the convention under **Configuration errors**: a port that is not an object (`null` included)
|
|
515
|
-
throws `RangeError` (`[daily-report]
|
|
549
|
+
- `createDailyReportService` / `createDailyReportServer` check the port (`attachments.thumbnails.renderer`) when they are created, in this order, by the convention under **Configuration errors**: a missing port throws `TypeError` (`[daily-report] attachments.thumbnails.renderer must be injected when attachments.thumbnails is given`), a port that is not an object (`null` included)
|
|
550
|
+
throws `RangeError` (`[daily-report] attachments.thumbnails.renderer must be an object with id and render; got null`), a missing `render` throws `TypeError` (`[daily-report] attachments.thumbnails.renderer.render must be a function`), a `render` that is given but is not a function throws `RangeError` (`…render must be a function; got "sharp"`), and an `id` that is not a string with non-whitespace content throws `RangeError` (`[daily-report] attachments.thumbnails.renderer.id must be a non-blank string naming the rendering pipeline version; got ""`).
|
|
516
551
|
- `render`: fit the image inside `maxWidth` × `maxHeight` — the box of the requested variant — keeping the aspect ratio (never enlarge), apply the EXIF orientation, drop metadata, flatten transparency onto white, bound decode memory before decoding (a pixel count alone does not: a 16-bit sample takes twice the bytes of an 8-bit one), start no decoder other than the one for `format`, and never throw.
|
|
517
552
|
- `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`. A render cut short by the signal must never answer `unsupported`: an `unsupported` that settles after the generation stopped waiting, at the deadline or on the abort, is recorded as a content-determined outcome (below).
|
|
518
553
|
A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. A renderer that cannot stop (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles (`render_overrun ms=<elapsed>`; a render that has still not settled at twice its budget is logged once as `render_stuck ms=<elapsed>`).
|
|
@@ -592,66 +627,70 @@ export const thumbnailRenderer = createSharpThumbnailRenderer(sharp)
|
|
|
592
627
|
|
|
593
628
|
Reference values for sizing, not guarantees (one development host: sharp 0.35.5, libvips 8.18.7, Node 24, one libvips thread per image; p50 of warm runs): a 1920 × 1080 PNG screenshot about 35 ms, a 1280 × 800 PNG about 27 ms, a 12 MP JPEG about 79 ms, a 12 MP PNG about 200 ms with a process peak of about 194 MiB. A baseline JPEG is cheap because libvips shrinks it while decoding (a progressive one fills its coefficient buffer first, above); PNG cost grows with the pixel count.
|
|
594
629
|
|
|
595
|
-
Example — the wiring (the
|
|
630
|
+
Example — the wiring (one `attachments` block with the codec, the read port, the per-process concurrency of both routes and the thumbnails):
|
|
596
631
|
|
|
597
632
|
```ts examples/attachment-wiring.ts
|
|
598
633
|
/**
|
|
599
634
|
* Example wiring of attachment delivery into `createDailyReportServer` (not shipped; type-checked by `pnpm run typecheck:examples`).
|
|
600
635
|
* 添付配信を `createDailyReportServer` へ結線する例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
601
636
|
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
* マウントし、サムネイルも同じルートを使う。
|
|
637
|
+
* Attachment delivery is one optional block, `attachments`: the id codec and the read port it always carries, the size limit and the
|
|
638
|
+
* original route's tuning, and the optional `thumbnails` with their renderer and tuning. Omitted values take their defaults, and the
|
|
639
|
+
* tuning applies per process. Mount `attachment.loader` of the result on the host's own byte-serving route
|
|
640
|
+
* (`{apiBasePath}/attachment/{token}`); thumbnails use the same route.
|
|
641
|
+
* 添付配信は任意の 1 つのブロック `attachments` にまとまる。ブロックが必ず持つ ID コーデックと読み取りポート、大きさの上限と原本の経路の調整値、
|
|
642
|
+
* 描画ポートと調整値を持つ任意の `thumbnails`。省いた値は既定値になり、調整値はプロセス単位で効く。結果の `attachment.loader` は
|
|
643
|
+
* ホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) にマウントし、サムネイルも同じルートを使う。
|
|
610
644
|
*/
|
|
611
645
|
import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServer, type DailyReportServerConfig } from "@aiquants/daily-report/server"
|
|
612
646
|
import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
|
|
613
647
|
import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
|
|
614
648
|
|
|
615
649
|
/**
|
|
616
|
-
* What the host supplies: its server configuration without the attachment
|
|
617
|
-
* ホストが渡すもの:
|
|
650
|
+
* What the host supplies: its server configuration without the attachment block, what the read port is built from, and the per-process concurrency.
|
|
651
|
+
* ホストが渡すもの: 添付のブロックを除いたサーバー設定と、読み取りポートの材料と、プロセス単位の同時実行の上限。
|
|
618
652
|
*/
|
|
619
653
|
export type AttachmentWiring = {
|
|
620
|
-
/** The rest of the server configuration
|
|
621
|
-
base: Omit<DailyReportServerConfig, "
|
|
654
|
+
/** The rest of the server configuration. 添付のブロックを除いたサーバー設定の残り。 */
|
|
655
|
+
base: Omit<DailyReportServerConfig, "attachments">
|
|
622
656
|
/** Attachment id obfuscation (an instance separate from the user-id codec). 添付 ID の難読化 (ユーザー ID のコーデックとは別のインスタンス)。 */
|
|
623
657
|
idCodec: DailyReportIdCodec
|
|
624
658
|
/** Storage client and its read settings. ストレージクライアントとその読み取り設定。 */
|
|
625
659
|
storage: { store: ObjectStore; settings: ObjectStoreReadSettings }
|
|
660
|
+
/** Simultaneous original reads and thumbnail generations per process (a host running several workers divides its budget by their count). プロセス単位の原本の読み取りとサムネイルの生成の同時実行の上限 (複数のワーカーを動かすホストは、予算をその数で割る)。 */
|
|
661
|
+
concurrency: { originals: number; thumbnails: number }
|
|
626
662
|
}
|
|
627
663
|
|
|
628
664
|
/**
|
|
629
665
|
* Creates the daily-report server with attachment delivery and thumbnails enabled.
|
|
630
666
|
* 添付配信とサムネイルを有効にした日報サーバーを作る処理。
|
|
631
667
|
*
|
|
632
|
-
* @param wiring Base configuration and the
|
|
668
|
+
* @param wiring Base configuration, the attachment dependencies and the per-process concurrency. 基本設定・添付の依存・プロセス単位の同時実行の上限。
|
|
633
669
|
* @returns The server (`DailyReportServer`); mount its `attachment.loader` on the attachment route. サーバー (`DailyReportServer`。`attachment.loader` を添付のルートへマウントする)。
|
|
634
|
-
* @throws {RangeError} When a
|
|
670
|
+
* @throws {RangeError} When a concurrency is not an integer of at least 1 (the message names its path, such as `attachments.thumbnails.concurrency`). 同時実行の上限が 1 以上の整数でないとき (メッセージは `attachments.thumbnails.concurrency` などのパスを名乗る)。
|
|
635
671
|
*/
|
|
636
|
-
export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): DailyReportServer =>
|
|
672
|
+
export const createDailyReportServerWithAttachments = ({ base, idCodec, storage, concurrency }: AttachmentWiring): DailyReportServer =>
|
|
637
673
|
createDailyReportServer({
|
|
638
674
|
...base,
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
675
|
+
attachments: {
|
|
676
|
+
idCodec,
|
|
677
|
+
read: createObjectStoreReadAttachment(storage.store, storage.settings),
|
|
678
|
+
concurrency: concurrency.originals,
|
|
679
|
+
thumbnails: { renderer: thumbnailRenderer, concurrency: concurrency.thumbnails },
|
|
680
|
+
},
|
|
642
681
|
})
|
|
643
682
|
```
|
|
644
683
|
|
|
645
|
-
**`hasThumbnail`**: every `DailyReportAttachmentSummary` carries a required `hasThumbnail: boolean`. It is `true` only when
|
|
684
|
+
**`hasThumbnail`**: every `DailyReportAttachmentSummary` carries a required `hasThumbnail: boolean`. It is `true` only when the `attachments` block carries `thumbnails`, the declared `fileType` is exactly one of `image/png`, `image/jpeg`, `image/gif` or `image/webp` (exact match: `IMAGE/PNG`, parameters and surrounding whitespace do not qualify), the known `fileSize` is at most `attachments.maxBytes` (an unknown size qualifies; the read port's `too_large` enforces the bound), and `state` is not `"absent"`.
|
|
646
685
|
On the SSE wire an attachment entry without `hasThumbnail` (published before the field existed) is accepted as `false` (`.default(false)`); a non-boolean value is rejected.
|
|
647
686
|
The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
648
687
|
|
|
649
688
|
**Thumbnail endpoint (`?thumbnail=tile`)**
|
|
650
689
|
|
|
651
690
|
- **GET only**: `HEAD` and every other method answer 405 with `Allow: GET` (checked right after the query). A HEAD response would need a generated body to report the same `Content-Length` as GET.
|
|
652
|
-
- **Order**: request isolation (a React Router single-fetch data request, `<token>.data`, is the 404 before it; 403 for a request from another origin's page, a navigation included; [Request isolation](#request-isolation)) → authenticate → query (400) → method →
|
|
691
|
+
- **Order**: request isolation (a React Router single-fetch data request, `<token>.data`, is the 404 before it; 403 for a request from another origin's page, a navigation included; [Request isolation](#request-isolation)) → authenticate → query (400) → method → the `attachments` block (404 without it) → token → viewer → its `thumbnails` (404 without them) → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
|
|
653
692
|
→ authorization (`getAttachmentForUser`, the SQL predicate) → eligibility, identity and ETag → `If-None-Match` (304) → the generation token of a revalidation (below) → not visible / not eligible (404) → cache → generation. The cache, joining a generation and the 304 all come **after** authorization, so a cached preview is never returned to a viewer who cannot see the attachment.
|
|
654
|
-
- **Rate admission uses two buckets per user and process**. Every token is taken synchronously — before the first `await` on admission, right after authorization's last `await` otherwise — so concurrent requests can never spend one token twice. With L = `
|
|
693
|
+
- **Rate admission uses two buckets per user and process**. Every token is taken synchronously — before the first `await` on admission, right after authorization's last `await` otherwise — so concurrent requests can never spend one token twice. With L = `attachments.thumbnails.rateLimitPerMinute`:
|
|
655
694
|
- The **generation bucket** holds L tokens and refills L per minute. Every answer except a matching 304 needs one of its tokens. A request without `If-None-Match` can never be a 304, so it spends its token on admission or is answered 429 (`reason=rate_limit`) before authorization.
|
|
656
695
|
A request that fails authorization (`not_visible`) or goes on to generate (a cache miss) keeps the whole token spent; a cache hit and a not-eligible 404 give back all but a tenth of it, the tenth paying for the authorization query. A refund refills the bucket first and never lifts it above L.
|
|
657
696
|
- The **revalidation bucket** holds 10 L tokens (L ÷ the tenth) and refills 10 L per minute. A request with `If-None-Match` spends one of them on admission or is answered 429 before authorization (`reason=revalidation_rate_limit`); it holds no generation token while it waits for authorization.
|
|
@@ -663,7 +702,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
663
702
|
A generation's outcome carries this identity only when what it read matched the row's size (**verified**; see **Read port** above); an **unverified** outcome gets no validator and is never cached.
|
|
664
703
|
- **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
|
|
665
704
|
An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
|
|
666
|
-
- **Generation**: a process-local gate with `
|
|
705
|
+
- **Generation**: a process-local gate with `attachments.thumbnails.concurrency` slots and a FIFO wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`). A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation; the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
|
|
667
706
|
**One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
|
|
668
707
|
The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
|
|
669
708
|
The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue, and a port that honours the signal settles early and hands the slot to the next queued generation. A read that settles after the abort is discarded before its result is looked at (see **Read port** above) and nothing is rendered; a render that is running at the abort is kept as above.
|
|
@@ -678,7 +717,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
678
717
|
| `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
|
|
679
718
|
| Client load timeout | 75 s (the sum) | the load counts as one failure |
|
|
680
719
|
|
|
681
|
-
- **Cache**: an LRU bounded by `
|
|
720
|
+
- **Cache**: an LRU bounded by `attachments.thumbnails.cacheBytes` and 1,024 entries (each entry weighs its body plus 256 bytes). A preview is stored as an owned copy sized exactly to its body, so a view into a larger buffer never keeps that buffer alive, and later writes to the port's buffer do not change the cached preview.
|
|
682
721
|
It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch and the port's `unsupported`), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
|
|
683
722
|
A `too_large` conclusion is cached only when the row's recorded size is over the limit, which eligibility already answers with the not-eligible 404 before any read; so the endpoint never caches a `too_large`: on a row whose known size is within the limit it is unverified, and on a row without a recorded size it is unrecorded (answered with the 404 with `Cache-Control: no-store`, and read again on the next view; see **Read port** above).
|
|
684
723
|
It never stores unverified or unrecorded outcomes, `failed`, the answer of a stage deadline (504 `read_timeout`, 502 `render_timeout`), the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
|
|
@@ -691,7 +730,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
691
730
|
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `X-Frame-Options: SAMEORIGIN`, `Set-Cookie`) |
|
|
692
731
|
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
693
732
|
| 401 / 403 | Not authenticated / no internal user, or (403, before authentication, the query and the method) a request from another origin's page that the isolation refuses ([Request isolation](#request-isolation): it serves no thumbnail to another origin, not even to a navigation) |
|
|
694
|
-
| 404 |
|
|
733
|
+
| 404 | No `attachments` block (both deliveries) or no `attachments.thumbnails`, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path`, and (before authentication) a React Router single-fetch data request (`<token>.data`) — one identical body for all |
|
|
695
734
|
| 405 | Any method other than GET (`Allow: GET`) |
|
|
696
735
|
| 429 | An empty generation bucket (`reason=rate_limit`) or revalidation bucket (`reason=revalidation_rate_limit`), or a revalidation that is not a matching 304 when no generation token is left after authorization (`reason=rate_limit`) (`Retry-After: 60`) |
|
|
697
736
|
| 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
|
|
@@ -703,7 +742,7 @@ Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Contr
|
|
|
703
742
|
Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`, and the 404 line of an unrecorded `too_large` with `size=unrecorded`.
|
|
704
743
|
Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
|
|
705
744
|
A route that mounts `attachment.loader` without a `token` route parameter is the host's configuration error, on both deliveries: right after the port check the loader throws `[daily-report] params.token must be passed by the route that mounts attachment.loader; declare a "token" route parameter ({apiBasePath}/attachment/{token})`, which that catch logs and answers 500, without decoding an empty token or querying the database.
|
|
706
|
-
Rejections up to the
|
|
745
|
+
Rejections up to the thumbnails check (401 / 400 / 405 / the 404 of a deployment without the `attachments` block or its `thumbnails` / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines per 60-s window, [Request isolation](#request-isolation)), and the isolation's 404 of a `.data` request is not logged.
|
|
707
746
|
The package never writes the file path itself; a `port_exception` line includes the port's own error message verbatim, so keep paths out of your port's error messages.
|
|
708
747
|
|
|
709
748
|
**Log levels** (both deliveries; the line formats are fixed):
|
|
@@ -712,11 +751,11 @@ The package never writes the file path itself; a `port_exception` line includes
|
|
|
712
751
|
| --- | --- |
|
|
713
752
|
| `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
|
|
714
753
|
| `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
|
|
715
|
-
| `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
|
|
754
|
+
| `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` and an original's `HEAD` that declares a size over it (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
|
|
716
755
|
|
|
717
756
|
`error` is left to failures of the server itself, so an alert on `error` does not fire on user traffic or on upstream storage states.
|
|
718
757
|
|
|
719
|
-
**429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of `
|
|
758
|
+
**429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of its own rate window (`ATTACHMENT_RATE_WINDOW_MS`, 60 s, also the 429's `Retry-After`; one bucket, `createRateLimitBucket` in `src/server/rate-limit.ts`, serves these paths and the action), kept apart from the buckets themselves.
|
|
720
759
|
A viewer's refusal with no open window writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line at once and opens the window; later refusals in the window are only counted, and the window's own timer writes, when it counted any, one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path) and closes the window. The 429 of a revalidation refused after authorization counts in the generation bucket's window.
|
|
721
760
|
A viewer therefore writes at most two lines per bucket and window, whatever its request rate or the refill; the count is written at the window's end, also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 users) loses none of it.
|
|
722
761
|
|
|
@@ -775,16 +814,25 @@ The first load of a report that is not cached starts inside the hook's effect, w
|
|
|
775
814
|
|
|
776
815
|
Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route them into your own toast/notification system, or omit it to use the package's built-in `role="alert"` banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the banner work.
|
|
777
816
|
The provider takes no items: it derives its rows from the module-resident ids stream session (below), so establish the session first — `createDailyReportClientLoader` in the route, or `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })` on mount, as `DailyReportPage` does — and mount one provider per user (`key={userId}`), which also starts the new user with an empty editing store (**Editing** in [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)).
|
|
817
|
+
The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes only with the viewing user — and hands out its state apart: the rows (`items`), the version that announces a change of the ledgers, and the SSE state (`isSseEnabled`, `sseStatus`). Each function calls the implementation of the last committed render, so a function kept from an earlier render does what the current one does.
|
|
818
|
+
The package's rows, List cards, DetailList rows and report cards, the side pane's content and the edit form read only the actions, so an ids stream publish, an SSE message or a change of the connection status re-renders none of them; of a report's parts, a version bump re-renders only its comment section (`ReportComments`). `useDailyReportActionContext()` returns the actions and the state as one value, the same value on a render where the state did not change; its caller re-renders whenever the state changes.
|
|
778
819
|
|
|
779
820
|
The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
|
|
780
821
|
The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
|
|
781
822
|
`createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
|
|
782
823
|
If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
|
|
783
|
-
`DailyReportPage`
|
|
824
|
+
`DailyReportPage` renders the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
|
|
825
|
+
|
|
826
|
+
- **An items publish re-renders only what it changes, behind the keys.** The package's own components read the session through narrow views (internal), each of which re-renders its reader only when one of its fields changes: the page root and the SSE connection read the status (the phase, the background revalidation, the last error, the SSE anchor and whether any report has arrived), the progress badge the phase, the revalidation and the two counts, and the loaded screen's set size the phase and the declared total.
|
|
827
|
+
`DailyReportActionProvider` follows the list itself through one subscription that re-renders it inside `startTransition` when the published revision or the scan's settledness changes, and reads the revision, the latest changes, the list's accessor and the settledness from the session in the same render, so they always agree.
|
|
828
|
+
The rows derivation and the views' row count therefore render at transition priority: a key pressed while the stream delivers commits before the publish's render, and an items publish re-renders neither the page root nor the SSE connection's host (`useSyncExternalStore`, which the public hook below uses, renders its update at the sync priority even inside `startTransition`, so it cannot carry the transition).
|
|
829
|
+
The public `useDailyReportIdsStream` keeps its contract — the whole state on every publish — so a host component that calls it re-renders on every publish.
|
|
784
830
|
|
|
785
|
-
- **Rows follow the stream by its changes.** Every items publish of the session carries
|
|
831
|
+
- **Rows follow the stream by its changes.** Every items publish of the session carries its revision (`itemsRevision`, never reused by another session), the number of reports it holds (`loadedCount`) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
|
|
832
|
+
The list itself is not part of a publish, so a publish costs the size of its change, never the list's length; a reader that needs the whole list builds it when it reads (`DailyReportIdsStreamClient.readItems()`: the list as of the last publish, with the changes still waiting in the coalescing window taken back).
|
|
786
833
|
`DailyReportActionProvider` keeps its rows in the canonical order (business date descending, then id descending, reports without a date last) and applies only the changes since the revision it last derived, folded into one however many publishes a render receives: rows that arrive after the old last row cost one comparison and a native concatenation (a publish of Δ such rows touches at most Δ + 1 rows, the stream client included), rows that arrive out of order are merged in by binary search, a changed row is replaced in place (⌈log2 n⌉ touches), and only a removal filters the list once.
|
|
787
|
-
It
|
|
834
|
+
It reads the whole list (once) and derives from it only on its first render, after the session is replaced, when it falls more than 32 publishes behind, and after the development cache clear drops the tombstones (which brings back the reports they hid).
|
|
835
|
+
A report the user deleted carries a deletion mark until a settled scan lacks it, and a marked report is never shown, also when the stream's net change for it is a value change (a deletion and a relay inside one coalescing window, or a change derived after the mark). A row the provider shows before the stream holds it — the optimistic row of a report being created, a report the user created, a deletion the server refused and rolled back — stays until the stream confirms it, or until a settled scan published after it lacks it; the optimistic row goes when its creation settles.
|
|
788
836
|
- **Publishes coalesce.** Chunks and every relay into the session — the SSE `report-create`, `report-publish` and `report-delete`, and the user's own creations and deletions — share one 50 ms window: the first change after a quiet window publishes at once, the changes crowded into the window publish once at its end, and the completion of a scan publishes what is pending without waiting.
|
|
789
837
|
A burst of SSE events therefore reaches the list in one publish, at most 50 ms after the first; the user's own creation and deletion show at once all the same, because the provider adds or removes that row itself. The window is measured on a monotonic clock (`performance.now()`), so a wall clock set back does not lengthen it.
|
|
790
838
|
There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
|
|
@@ -797,8 +845,10 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
797
845
|
Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
|
|
798
846
|
- **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is 4 px at the top and the sides and none at the bottom, so the view's box ends exactly at the page's bottom edge.
|
|
799
847
|
- **One page frame and one column for every screen**: the loading screen, the load-error screen and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
|
|
800
|
-
- **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip
|
|
801
|
-
|
|
848
|
+
- **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`, its children in block flow): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
|
|
849
|
+
The root's layout and paint are not contained: either would make the root the containing block of the mobile overlay, a fixed-position descendant, and as a flex item of its column the root could not be a relayout boundary anyway (Chromium makes no flex or grid item one, contained or not).
|
|
850
|
+
- **The view's body is the relayout boundary** (`VIEW_BODY_CLASS_NAME`: a column flex container at `height: 100%` of the root, with `contain: size layout style`): it fills the root exactly, and since the root lays it out in block flow it is not a flex item, so with its size and layout contained it is a relayout boundary. Everything the view lays out — the list column, the panel group and the side pane, the scroll bar — sits inside it, so a change there (an ids stream publish that changes the row count or the scroll bar's thumb) lays out from the body, never from the document.
|
|
851
|
+
The List's mobile overlay (a fixed-position `<dialog>`) is rendered directly under the root, outside the body: layout containment would make the body the containing block of its fixed descendants, and the dialog, which stays mounted outside the top layer while it slides out after `close()`, keeps the viewport as its containing block. Paint is not contained (the root clips).
|
|
802
852
|
- **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view takes its root's border-box block size in layout px from one `ResizeObserver` of the root's own window, observing the root alone (`box: "border-box"`; `useViewBoxHeight`). Every value, the first included, is the delivery's `borderBoxSize[0].blockSize`: the layout effect only starts the observation, and no code reads a size from the DOM.
|
|
803
853
|
The value is `null` until the first delivery, and the view renders no body until then, so no row is ever drawn at a guessed height (the List also waits for its row slot, below). The platform delivers the first observation in the rendering update after the observation starts, after layout and before paint, and that one delivery is committed at once (`flushSync`), so the view's content is drawn before the same frame paints; later deliveries only set React state, which React renders after the delivery.
|
|
804
854
|
A delivery reads and writes nothing in the DOM, and a value equal to the last one written is not written again.
|
|
@@ -904,8 +954,11 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
904
954
|
An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
|
|
905
955
|
The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
|
|
906
956
|
Only the anchor's scroller holds the full handle (the end-to-end test handle gets the read-only part and the scroller, so its one position change, `revealIndex`, records the anchor like a key; see [Test hooks](#test-hooks)), and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
|
|
907
|
-
- **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change,
|
|
908
|
-
Both views pass the anchor's `handleScrollAdjust` there
|
|
957
|
+
- **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, the second stage of a compensation the pane had clamped, and the pane's clamp of the position to a smaller maximum when the content shrinks under the view (`cause: "clamp"`, 3.13.0) — are reported synchronously, once each change is complete, through its `onScrollAdjust` (since 3.9.0).
|
|
958
|
+
Both views pass the anchor's `handleScrollAdjust` there. For every cause but the clamp it records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
|
|
959
|
+
The clamp is the list change itself, seen from the scroll pane: rows before the window deleted near the end of the list shrink the content, and the pane moves the position to the new maximum inside the commit of that change, before the anchor's restoring layout effect.
|
|
960
|
+
The anchor is kept as recorded and only the position it was recorded at moves by the clamp's `delta`, so the restore does not count the clamp as a scroll of the user and puts the anchored report back at its offset, 42 px above the end or exactly at it alike
|
|
961
|
+
(re-recording there would read the clamped position against the previous list's rows, name another report and leave the view displaced by up to the last row's height).
|
|
909
962
|
- **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range, once per frame. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and restores from there: it neither undoes the user's scroll nor shifts the content by a row.
|
|
910
963
|
Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's scroll), whatever the list's length or the position.
|
|
911
964
|
An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the focus inside the rows — where it was. At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
|
|
@@ -1045,9 +1098,14 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1045
1098
|
Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
|
|
1046
1099
|
A touch or pen flick starts no glide under `prefers-reduced-motion: reduce` either: `VirtualScroll` continues a flick with a scripted `requestAnimationFrame` glide (for up to 10 s with the views' fast-scroll options), so each view reads the preference of its own window (`useReducedMotion`, which follows a change from the next render and is `false` on the server) and passes inertia options whose start threshold no velocity reaches (`inertiaOptionsFor(true)`); the content still follows the finger 1:1 while it drags and stops when it is released.
|
|
1047
1100
|
Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
|
|
1101
|
+
The header's total count eases to a new value over 250 ms only while the window of the element that shows the number does not prefer reduced motion; under `prefers-reduced-motion: reduce` it shows the new value in the same render and asks for no animation frame (`useAnimatedNumber`, which reads the preference with `useReducedMotion`).
|
|
1102
|
+
The ids stream's progress badge shows the published counts as they are, with no easing in either case: publishes arrive at most every 50 ms, so a 250 ms ease would never settle and would only change the text on every frame.
|
|
1103
|
+
Both counters write their text into a contained box (`CounterText` in `src/client/ui/counter-text.tsx`): an invisible sizer that holds the widest text at the current number of digits reserves the box's width (with tabular digits every number of that many digits fits it, and the sizer changes only when the number of digits does),
|
|
1104
|
+
and the shown text sits over the sizer in a box with `contain: size layout style`, a relayout boundary (`COUNTER_BOX_CLASS_NAME`, `COUNTER_SIZER_CLASS_NAME`, `COUNTER_TEXT_CLASS_NAME`). A change of the text lays out that box alone, never the document, and neither the badge's width nor the header moves; only the shown text is read out.
|
|
1048
1105
|
The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
|
|
1049
1106
|
(the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
|
|
1050
|
-
While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts
|
|
1107
|
+
While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`,
|
|
1108
|
+
and so that only the files of an allowlist with a written reason ask for an animation frame: the header total's easing, which must read the reduced-motion preference, and two uses that move nothing (the held key's one move per frame and the attachment grid's track width handed to the next frame), so script-driven interpolation can only be written behind that preference.
|
|
1051
1109
|
That check is lexical — the stylesheets and the literals of the scripts. What actually moves is checked in the browser by the host app's reduced-motion end-to-end guard: under `prefers-reduced-motion: reduce`, after each step of a pointer and a touch tour of both views (the tap-scroll circle pressed and dragged, a card hovered, a flick, the mobile overlay opened and closed, a thumbnail loaded), no animation or transition of a motion property runs in the view, including what `VirtualScroll` renders.
|
|
1052
1110
|
- **Spacing**: every spacing the package writes is 4 (within a group), 8 (between parts) or 16 (between items), and every box length it writes sits on the 4 px grid, line boxes included: one-line text such as each List preview line has a 20 px line box (`leading-5`, the preview's lines 8 px apart at a 28 px pitch) and the reading text of the side pane, the DetailList content and the comments a 24 px one (`leading-6`); no line height is proportional to the font size.
|
|
1053
1111
|
These are the values at the default 16 px root: lengths written in rem (type, line boxes, the card's content, most spacing) scale with the root font size, while the CSS-pixel quantities of the state indicators — G and the lengths built on it — are written in px and keep their values at every root (see **Row gutter G** and **List slot P**).
|
|
@@ -1065,8 +1123,10 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1065
1123
|
A host that wants the same whole-pixel strokes on the top and bottom edges at the quarter ratios 1.25, 1.5 and 1.75 puts the edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`), since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px).
|
|
1066
1124
|
The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of the unit; the bottom-aligned surface then sits exactly G from the view's end.
|
|
1067
1125
|
In any other window, and at other ratios (browser zoom such as 0.9, 1.1 or 1.33), it sits at least G and less than G + 1 device pixel from the end (**G-symmetric frame** in [View height](#view-height-host-layout)).
|
|
1068
|
-
- **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
|
|
1126
|
+
- **Side pane** (List, desktop layout): the panel group fills the view's body (which fills the view's root), and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
|
|
1069
1127
|
The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
|
|
1128
|
+
Two pointer targets there fall short of WCAG 2.5.8 (a target of at least 24 × 24 px, or one whose 24 px circle meets no other target and no other such circle): the resize handle's 10 px hit area, centred in the 16 px channel 3 px from the scroll bar and 3 px from the pane, and the view's 8 px scroll bar.
|
|
1129
|
+
The centres of their 24 px circles are 12 px apart (the handle's on the panel boundary, the bar's 12 px before it), so the circles intersect and the spacing exception does not apply either. Both keep the specified frame — the 2G channel and the 8 px scroll bar of both views — and no wider construction has been decided yet.
|
|
1070
1130
|
The side-pane panel is at least 200 px wide (`SIDE_PANE_PANEL_MIN_WIDTH_PX`): the frame around its centred selection prompt — 2G = 16 (the pane sits G inside its panel), the card surface's insets 2 × 16 and the placeholder panel's insets 2 × 12 (`PLACEHOLDER_PANEL_INSET_PX`), 72 in all — plus the prompt's longest phrase at the default 16 px root, 「選択してください。」 (9 full-width glyphs of `text-sm`, 9 × 0.875 rem = 126 px), is 198, rounded up to the 4 px lattice.
|
|
1071
1131
|
At the default root the ja prompt therefore breaks only between phrases and leaves no one-glyph line; at a larger root its phrases widen and the prompt breaks inside one without crossing its frame (**Line breaks of wrapping labels** below). The List panel keeps its own minimum of 160 px, and the two minimums and the 10 px handle (370 px) fit in the List's two-column minimum of 460 px.
|
|
1072
1132
|
The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
|
|
@@ -1449,9 +1509,13 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
|
|
|
1449
1509
|
|
|
1450
1510
|
1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
|
|
1451
1511
|
2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
|
|
1512
|
+
The reader reads each live entry once per process and hands every subscriber the same frozen value (`StreamEntry`: `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `dailyReportSseMessageSchema` — the validated message (`parsed`), the JSON value as published (`published`, with the server-only fields) and its text (`text`) — or `null` when the field is absent, is not JSON or fails the schema (the last two logged once at `error`; such an entry is written to no connection — fail-closed — while the reader's own read position moves past it).
|
|
1513
|
+
Each connection only filters that value (recipient, source-type visibility, comment redaction) and never changes it, and the frame texts of the variants that differ from the published text — without `recipientRawUserId`, and with the embedded report's comments emptied — are built at most once per message and shared by every connection that writes them, so a live entry costs one parse in proportion to its size plus a filter per connection, not one parse per connection. A host's own reader keeps the same contract: it reads an entry once and hands the same value to every subscriber.
|
|
1452
1514
|
3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry only `SSE_RESPONSE_HEADERS` plus the forwarded `Set-Cookie` (no `Connection` or other hop-by-hop header, which HTTP/2 forbids). `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type visibility, comment redaction).
|
|
1453
1515
|
4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
|
|
1454
1516
|
The action context correlates optimistic updates with SSE echoes using `clientTempId`, and exposes the connection status as `sseStatus` (`DailyReportSseConnectionStatus`: the reopening status, or `{ kind: "resyncing" }` while a `resync-required` waits for a fresh ids anchor).
|
|
1517
|
+
The action context ignores the SSE echo of its own action for 30 s after sending it, holds a message about a report that is not cached yet for up to 30 s (when the report's cache is written within that time the held messages are handled once, by the last committed render's handler; otherwise they are dropped),
|
|
1518
|
+
and keeps a created report locked against older background answers for 1 s; each is a deadline on the monotonic clock read when it matters, with no timer, so nothing runs after the provider unmounts (`src/client/deferred-callback-scope.spec.ts` lists every timer, frame, listener and observer the client arms, each with its reason).
|
|
1455
1519
|
|
|
1456
1520
|
### SSE wire contract
|
|
1457
1521
|
|
|
@@ -1479,7 +1543,9 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1479
1543
|
When the tail cannot be read, `ready()` rejects and the failed read is not kept, so the next call reads again: the connection that waited for it ends (`producer-failed`, the row above) and the client reopens it, with its cursor when it has one, so the catch-up covers everything since; and a run that cannot fix its position fails like any other failing run (every subscriber's `onError`). A host with its own SSE handler awaits `ready()` the same way and closes the connection when it rejects.
|
|
1480
1544
|
The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
|
|
1481
1545
|
Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
|
|
1482
|
-
- **
|
|
1546
|
+
- **Reads within a byte budget**: Redis bounds an `XREAD` or an `XRANGE` only by a count of entries, so the count of both comes from one byte budget (`src/server/payload-limits.ts`): `SSE_READ_BUDGET_BYTES` (64 MiB) divided by the largest event, `SSE_EVENT_MAX_BYTES` (**Event size** below), rounded down and at least 1 — `SSE_READ_ENTRY_COUNT` = 10.
|
|
1547
|
+
The shared reader's live `XREAD` and every catch-up page (`DAILY_REPORT_SSE_CATCH_UP_BATCH`, exported) read that many entries, so one read brings at most 64 MiB into the process, also when every entry is as large as an event can be. A catch-up reads its pages per connection; a catch-up of 1,000 small events takes 113 round trips (one check of the oldest entry and 112 pages, each new page reading 9 entries after its start).
|
|
1548
|
+
- **Catch-up**: entries after the cursor are read in pages of `DAILY_REPORT_SSE_CATCH_UP_BATCH` (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
|
|
1483
1549
|
- **Ids on the wire only increase**: live entries that arrive during the catch-up are queued, not written. The catch-up stops in front of the first queued entry (the fan-out reader delivers every entry after it in order), then the queue is written in id order and later live entries are written as they arrive. Writing a live entry first would let a disconnect move the client's resume position past catch-up entries it never received, and an older `report-update` would overwrite a newer one on the client.
|
|
1484
1550
|
- **Heartbeat**: `startSseHeartbeat` writes a jittered `retry:` (2,000–10,000 ms), an immediate named `event: heartbeat` (`data: {"serverTime":<ms>}`, no `id:`), then one every 15 s (`DAILY_REPORT_SSE_HEARTBEAT_MS`). Named events are ignored by `onmessage`, so old bundles are unaffected.
|
|
1485
1551
|
- **Stream anchor (`streamAnchor`)**: the ids stream carries the newest SSE entry id on its first authoritative line
|
|
@@ -1490,6 +1556,9 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1490
1556
|
loading the ids and opening the stream. `null` means "position unknown" (empty stream, no redis, read failure): the
|
|
1491
1557
|
hook then opens without a cursor and the server starts from the newest entry at connect time.
|
|
1492
1558
|
- **Delivery is idempotent**: an anchor can be up to one ids-cache TTL old; replaying from it is safe because `report-*` messages upsert and `comment-add` is matched by comment id.
|
|
1559
|
+
- **Event size**: the service writes an event to the stream only when its JSON is at most `SSE_EVENT_MAX_BYTES` (6,356,992 bytes, `src/server/payload-limits.ts`): the action's form cap (1 MiB, **Request values**) at the largest growth `JSON.stringify` can give a text of the form (6 times: a control character, one raw byte in a multipart body, becomes the escape `\u00XX`), plus 64 KiB for the rest of the event.
|
|
1560
|
+
So no text an accepted action carries can push its event over the bound. A larger event — only a `report-create`, `report-update` or `report-publish` of a report whose accumulated detail (its body and all its comments) exceeds about 6 MiB — is not written, and the service logs `[SSE] Event too large (<operation>): <bytes> bytes > 6356992; not published` at `warn`; the caches were invalidated before, so viewers read the change on their next load.
|
|
1561
|
+
The persistent stream itself is bounded by a count of entries, not by bytes: at most `streamMaxLen` (10,000 by default, trimmed with `MAXLEN ~`) events of at most `SSE_EVENT_MAX_BYTES` each, about 59.2 GiB in the worst case, while a status update is under 200 bytes and a report event carries one report's detail. One read of it is bounded by the byte budget above; bounding the stream's own bytes needs thin events (the type, the ids and a version, with the detail read again through the ETag'd endpoints), which is a change of the client's protocol.
|
|
1493
1562
|
- **A resync that gives up is retried**: after `resync-required` the hook asks the ids session for a rescan (`resyncDailyReportIdsStream()`) and does not reconnect until an anchor newer than the one it had arrives. When that rescan gives up (the ids phase stays `complete` with an `error`, which happens after repeated failures during an outage), the hook asks again after a full-jitter backoff from 2 s to 30 s while SSE is enabled, until an anchor arrives. An initial ids scan that ends in `failed` is left to the page's manual retry.
|
|
1494
1563
|
|
|
1495
1564
|
## API Surface (Summary)
|
|
@@ -1503,16 +1572,18 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1503
1572
|
- Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
|
|
1504
1573
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
1505
1574
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
|
|
1506
|
-
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (
|
|
1507
|
-
|
|
1575
|
+
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (the provider's actions — functions that keep their identity while the provider is mounted, the ledgers and `user` — and its state: `items`, `version`, `isSseEnabled`, `sseStatus`; `updateReport` takes both `title` and `content`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` /
|
|
1576
|
+
`useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `loadedCount`, `streamAnchor`, and `itemsRevision` / `itemsChanges`, the revision of the session's list and its latest net changes; the list itself is not part of the state).
|
|
1577
|
+
- The ids stream: `DailyReportIdsStreamClient` (`resync()`, `has(reportHubId)`, `readItems()` — the list as of the last publish, built when it is read; its `now` option is the monotonic clock of the publish window) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
|
|
1508
1578
|
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
|
|
1509
1579
|
- Layout: `DAILY_REPORT_LAYOUT_LATTICE_PX`, the 4 px lattice unit a host sizes its bars on (see **G-symmetric frame**); it is the only public layout value. The row geometry is not public: it follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
|
|
1510
1580
|
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`, and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` (see [Test hooks](#test-hooks)).
|
|
1511
1581
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1512
|
-
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1582
|
+
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1513
1583
|
- Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
1514
|
-
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1515
|
-
-
|
|
1516
|
-
|
|
1584
|
+
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` (the entries of one catch-up page, derived from the read byte budget) / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1585
|
+
- Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the only id the service's write methods take; **The echo id** in [Server wiring](#server-wiring-di)).
|
|
1586
|
+
- Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` (an entry as the reader hands it to every subscriber: its id and its message read once, or `null`) / `ExternalReportFields`,
|
|
1587
|
+
and for attachments `DailyReportAttachmentsConfig` / `DailyReportAttachmentThumbnailsConfig` (the `attachments` block and its `thumbnails`) / `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
1517
1588
|
|
|
1518
1589
|
MIT
|