@aiquants/daily-report 0.31.0 → 0.32.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 +76 -0
- package/README.md +105 -71
- package/dist/client.d.mts +23 -15
- package/dist/client.d.ts +23 -15
- package/dist/client.js +4 -4
- package/dist/client.mjs +4 -4
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +40 -22
- package/dist/server.d.ts +40 -22
- package/dist/server.js +4 -4
- package/dist/server.mjs +4 -4
- package/package.json +3 -3
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.11.
|
|
29
|
+
The peer floor of `@aiquants/virtualscroll` is **3.11.5**: install 3.11.5 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).
|
|
@@ -109,7 +109,7 @@ The standalone build bundles every JSX utility (and maps the shadcn tokens), but
|
|
|
109
109
|
## Required database schema
|
|
110
110
|
|
|
111
111
|
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 `
|
|
112
|
+
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
113
|
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
114
|
|
|
115
115
|
Existing apps can inject their own drizzle models (structural typing — see `DailyReportTables`). Greenfield projects can generate definitions:
|
|
@@ -140,6 +140,11 @@ export const dailyReportServer = createDailyReportServer({
|
|
|
140
140
|
],
|
|
141
141
|
draftLabelNames: ["Draft", "Work in Progress"], // Draft label names (can specify a single string or array of candidates)
|
|
142
142
|
enableDevCacheClear: import.meta.env.DEV, // Optional: allow the dev-only `intent=clearCache` (default false → 400)
|
|
143
|
+
attachments: { // Optional: attachment delivery (see Attachments); omit it for a deployment without attachments
|
|
144
|
+
idCodec: attachmentIdCodec, // Attachment id obfuscation (an instance separate from the user-id codec)
|
|
145
|
+
read: readAttachment, // DailyReportReadAttachment
|
|
146
|
+
thumbnails: { renderer: thumbnailRenderer }, // Optional: image previews (createSharpThumbnailRenderer(sharp))
|
|
147
|
+
},
|
|
143
148
|
})
|
|
144
149
|
```
|
|
145
150
|
|
|
@@ -163,7 +168,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
163
168
|
**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
169
|
|
|
165
170
|
- **`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 —
|
|
171
|
+
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
172
|
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
173
|
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
174
|
- **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 +177,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
172
177
|
- **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
178
|
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
179
|
|
|
175
|
-
**Request values**: the API route and the action read every business
|
|
180
|
+
**Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh` and the action's 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
181
|
|
|
177
182
|
- **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
183
|
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 +185,18 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
180
185
|
- **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
186
|
`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
187
|
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
|
-
|
|
188
|
+
- **`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.
|
|
189
|
+
- **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).
|
|
190
|
+
- **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 without reading the body, and a body without a declared length (chunked) or with a wrong one stops being read where the count of bytes passes the cap: both are 413 `{"error":"Form too large"}`, with no error log line.
|
|
191
|
+
The refusals 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.
|
|
192
|
+
The cap is the only bound of a report's body and a comment's text, which have no ceiling of their own.
|
|
193
|
+
- **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.
|
|
194
|
+
- **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`), 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.
|
|
195
|
+
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.
|
|
196
|
+
- **`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`).
|
|
197
|
+
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.
|
|
198
|
+
- **`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`.
|
|
199
|
+
- **`addComment`**: `content` is a non-empty text (`Content required` otherwise). **`deleteComment`**: `commentId` (**Ids** above).
|
|
185
200
|
|
|
186
201
|
### DI ports
|
|
187
202
|
|
|
@@ -193,13 +208,13 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
193
208
|
- `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
209
|
- `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
210
|
- `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
|
|
211
|
+
- `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).
|
|
212
|
+
- `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, **Request values** in [Server wiring](#server-wiring-di)).
|
|
213
|
+
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
|
|
199
214
|
|
|
200
215
|
**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
216
|
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]
|
|
217
|
+
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
218
|
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
219
|
|
|
205
220
|
### Request isolation
|
|
@@ -310,7 +325,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
310
325
|
| --- | --- | --- |
|
|
311
326
|
| inline | `{apiBasePath}/attachment/{token}` | The original (shown by the browser for inline-safe types) |
|
|
312
327
|
| download | `...?download=1` | The original with `Content-Disposition: attachment` |
|
|
313
|
-
| thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when
|
|
328
|
+
| thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when the `attachments` block carries `thumbnails` |
|
|
314
329
|
|
|
315
330
|
- **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
331
|
- **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 +339,36 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
324
339
|
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
340
|
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
341
|
|
|
327
|
-
**Configuration
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
|
331
|
-
|
|
|
332
|
-
| `
|
|
333
|
-
| `
|
|
334
|
-
| `
|
|
335
|
-
| `
|
|
336
|
-
| `
|
|
337
|
-
| `
|
|
338
|
-
| `
|
|
339
|
-
| `
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
- **
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
342
|
+
**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.
|
|
343
|
+
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).
|
|
344
|
+
|
|
345
|
+
| Path | Default | Meaning |
|
|
346
|
+
| --- | --- | --- |
|
|
347
|
+
| `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. |
|
|
348
|
+
| `attachments.read` | required in the block | Reads the original bytes (`DailyReportReadAttachment`, **Read port** below). |
|
|
349
|
+
| `attachments.maxBytes` | 32 MiB | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
|
|
350
|
+
| `attachments.rateLimitPerMinute` | 60 | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
|
|
351
|
+
| `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. |
|
|
352
|
+
| `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). |
|
|
353
|
+
| `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. |
|
|
354
|
+
| `attachments.thumbnails.concurrency` | 2 | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
|
|
355
|
+
| `attachments.thumbnails.cacheBytes` | 8 MiB | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
|
|
356
|
+
|
|
357
|
+
- **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.
|
|
358
|
+
- **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.
|
|
359
|
+
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`.
|
|
360
|
+
- **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.
|
|
361
|
+
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`.
|
|
362
|
+
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.
|
|
363
|
+
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"`.
|
|
364
|
+
- **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`,
|
|
365
|
+
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`).
|
|
366
|
+
A host that still passes them fails at startup instead of silently losing its attachments; `CHANGELOG.md` maps every key to its path.
|
|
367
|
+
- **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
368
|
- **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
369
|
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 `
|
|
370
|
+
- **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.
|
|
371
|
+
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
372
|
|
|
354
373
|
**Read port**
|
|
355
374
|
|
|
@@ -382,7 +401,7 @@ type DailyReportReadAttachment = (
|
|
|
382
401
|
|
|
383
402
|
`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
403
|
- **`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
|
|
404
|
+
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
405
|
- **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
406
|
- 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
407
|
- 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 +530,8 @@ type DailyReportAttachmentThumbnailRenderer = {
|
|
|
511
530
|
|
|
512
531
|
- `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
532
|
**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]
|
|
533
|
+
- `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)
|
|
534
|
+
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
535
|
- `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
536
|
- `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
537
|
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 +611,70 @@ export const thumbnailRenderer = createSharpThumbnailRenderer(sharp)
|
|
|
592
611
|
|
|
593
612
|
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
613
|
|
|
595
|
-
Example — the wiring (the
|
|
614
|
+
Example — the wiring (one `attachments` block with the codec, the read port, the per-process concurrency of both routes and the thumbnails):
|
|
596
615
|
|
|
597
616
|
```ts examples/attachment-wiring.ts
|
|
598
617
|
/**
|
|
599
618
|
* Example wiring of attachment delivery into `createDailyReportServer` (not shipped; type-checked by `pnpm run typecheck:examples`).
|
|
600
619
|
* 添付配信を `createDailyReportServer` へ結線する例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
601
620
|
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
* マウントし、サムネイルも同じルートを使う。
|
|
621
|
+
* Attachment delivery is one optional block, `attachments`: the id codec and the read port it always carries, the size limit and the
|
|
622
|
+
* original route's tuning, and the optional `thumbnails` with their renderer and tuning. Omitted values take their defaults, and the
|
|
623
|
+
* tuning applies per process. Mount `attachment.loader` of the result on the host's own byte-serving route
|
|
624
|
+
* (`{apiBasePath}/attachment/{token}`); thumbnails use the same route.
|
|
625
|
+
* 添付配信は任意の 1 つのブロック `attachments` にまとまる。ブロックが必ず持つ ID コーデックと読み取りポート、大きさの上限と原本の経路の調整値、
|
|
626
|
+
* 描画ポートと調整値を持つ任意の `thumbnails`。省いた値は既定値になり、調整値はプロセス単位で効く。結果の `attachment.loader` は
|
|
627
|
+
* ホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) にマウントし、サムネイルも同じルートを使う。
|
|
610
628
|
*/
|
|
611
629
|
import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServer, type DailyReportServerConfig } from "@aiquants/daily-report/server"
|
|
612
630
|
import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
|
|
613
631
|
import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
|
|
614
632
|
|
|
615
633
|
/**
|
|
616
|
-
* What the host supplies: its server configuration without the attachment
|
|
617
|
-
* ホストが渡すもの:
|
|
634
|
+
* What the host supplies: its server configuration without the attachment block, what the read port is built from, and the per-process concurrency.
|
|
635
|
+
* ホストが渡すもの: 添付のブロックを除いたサーバー設定と、読み取りポートの材料と、プロセス単位の同時実行の上限。
|
|
618
636
|
*/
|
|
619
637
|
export type AttachmentWiring = {
|
|
620
|
-
/** The rest of the server configuration
|
|
621
|
-
base: Omit<DailyReportServerConfig, "
|
|
638
|
+
/** The rest of the server configuration. 添付のブロックを除いたサーバー設定の残り。 */
|
|
639
|
+
base: Omit<DailyReportServerConfig, "attachments">
|
|
622
640
|
/** Attachment id obfuscation (an instance separate from the user-id codec). 添付 ID の難読化 (ユーザー ID のコーデックとは別のインスタンス)。 */
|
|
623
641
|
idCodec: DailyReportIdCodec
|
|
624
642
|
/** Storage client and its read settings. ストレージクライアントとその読み取り設定。 */
|
|
625
643
|
storage: { store: ObjectStore; settings: ObjectStoreReadSettings }
|
|
644
|
+
/** Simultaneous original reads and thumbnail generations per process (a host running several workers divides its budget by their count). プロセス単位の原本の読み取りとサムネイルの生成の同時実行の上限 (複数のワーカーを動かすホストは、予算をその数で割る)。 */
|
|
645
|
+
concurrency: { originals: number; thumbnails: number }
|
|
626
646
|
}
|
|
627
647
|
|
|
628
648
|
/**
|
|
629
649
|
* Creates the daily-report server with attachment delivery and thumbnails enabled.
|
|
630
650
|
* 添付配信とサムネイルを有効にした日報サーバーを作る処理。
|
|
631
651
|
*
|
|
632
|
-
* @param wiring Base configuration and the
|
|
652
|
+
* @param wiring Base configuration, the attachment dependencies and the per-process concurrency. 基本設定・添付の依存・プロセス単位の同時実行の上限。
|
|
633
653
|
* @returns The server (`DailyReportServer`); mount its `attachment.loader` on the attachment route. サーバー (`DailyReportServer`。`attachment.loader` を添付のルートへマウントする)。
|
|
634
|
-
* @throws {RangeError} When a
|
|
654
|
+
* @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
655
|
*/
|
|
636
|
-
export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): DailyReportServer =>
|
|
656
|
+
export const createDailyReportServerWithAttachments = ({ base, idCodec, storage, concurrency }: AttachmentWiring): DailyReportServer =>
|
|
637
657
|
createDailyReportServer({
|
|
638
658
|
...base,
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
659
|
+
attachments: {
|
|
660
|
+
idCodec,
|
|
661
|
+
read: createObjectStoreReadAttachment(storage.store, storage.settings),
|
|
662
|
+
concurrency: concurrency.originals,
|
|
663
|
+
thumbnails: { renderer: thumbnailRenderer, concurrency: concurrency.thumbnails },
|
|
664
|
+
},
|
|
642
665
|
})
|
|
643
666
|
```
|
|
644
667
|
|
|
645
|
-
**`hasThumbnail`**: every `DailyReportAttachmentSummary` carries a required `hasThumbnail: boolean`. It is `true` only when
|
|
668
|
+
**`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
669
|
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
670
|
The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
648
671
|
|
|
649
672
|
**Thumbnail endpoint (`?thumbnail=tile`)**
|
|
650
673
|
|
|
651
674
|
- **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 →
|
|
675
|
+
- **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
676
|
→ 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 = `
|
|
677
|
+
- **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
678
|
- 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
679
|
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
680
|
- 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 +686,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
663
686
|
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
687
|
- **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
688
|
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 `
|
|
689
|
+
- **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
690
|
**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
691
|
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
692
|
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 +701,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
678
701
|
| `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
|
|
679
702
|
| Client load timeout | 75 s (the sum) | the load counts as one failure |
|
|
680
703
|
|
|
681
|
-
- **Cache**: an LRU bounded by `
|
|
704
|
+
- **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
705
|
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
706
|
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
707
|
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 +714,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
691
714
|
| 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
715
|
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
693
716
|
| 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 |
|
|
717
|
+
| 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
718
|
| 405 | Any method other than GET (`Allow: GET`) |
|
|
696
719
|
| 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
720
|
| 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 +726,7 @@ Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Contr
|
|
|
703
726
|
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
727
|
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
728
|
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
|
|
729
|
+
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
730
|
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
731
|
|
|
709
732
|
**Log levels** (both deliveries; the line formats are fixed):
|
|
@@ -775,6 +798,8 @@ The first load of a report that is not cached starts inside the hook's effect, w
|
|
|
775
798
|
|
|
776
799
|
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
800
|
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)).
|
|
801
|
+
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.
|
|
802
|
+
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
803
|
|
|
779
804
|
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
805
|
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.
|
|
@@ -782,9 +807,11 @@ The client splits the stream into lines as the chunks arrive, examining each dec
|
|
|
782
807
|
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
808
|
`DailyReportPage` subscribes via `useDailyReportIdsStream`, rendering 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).
|
|
784
809
|
|
|
785
|
-
- **Rows follow the stream by its changes.** Every items publish of the session carries
|
|
810
|
+
- **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).
|
|
811
|
+
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
812
|
`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
|
|
813
|
+
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).
|
|
814
|
+
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
815
|
- **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
816
|
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
817
|
There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
|
|
@@ -1045,9 +1072,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1045
1072
|
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
1073
|
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
1074
|
Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
|
|
1075
|
+
The header's total count and the ids stream's counters ease 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` they show the new value in the same render and ask for no animation frame (`useAnimatedNumber`, which reads the preference with `useReducedMotion`).
|
|
1048
1076
|
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
1077
|
(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
|
|
1078
|
+
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`,
|
|
1079
|
+
and so that only the files of an allowlist with a written reason ask for an animation frame: the counters' 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
1080
|
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
1081
|
- **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
1082
|
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**).
|
|
@@ -1067,6 +1096,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1067
1096
|
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
1097
|
- **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.
|
|
1069
1098
|
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.
|
|
1099
|
+
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.
|
|
1100
|
+
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
1101
|
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
1102
|
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
1103
|
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.
|
|
@@ -1490,6 +1521,8 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1490
1521
|
loading the ids and opening the stream. `null` means "position unknown" (empty stream, no redis, read failure): the
|
|
1491
1522
|
hook then opens without a cursor and the server starts from the newest entry at connect time.
|
|
1492
1523
|
- **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.
|
|
1524
|
+
- **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.
|
|
1525
|
+
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.
|
|
1493
1526
|
- **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
1527
|
|
|
1495
1528
|
## API Surface (Summary)
|
|
@@ -1503,16 +1536,17 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1503
1536
|
- Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
|
|
1504
1537
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
1505
1538
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
|
|
1506
|
-
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (
|
|
1507
|
-
|
|
1539
|
+
- 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` /
|
|
1540
|
+
`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).
|
|
1541
|
+
- 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
1542
|
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
|
|
1509
1543
|
- 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
1544
|
- 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
1545
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1512
|
-
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1546
|
+
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1513
1547
|
- 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
1548
|
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1515
1549
|
- 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` / `ExternalReportFields`,
|
|
1516
|
-
and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
1550
|
+
and for attachments `DailyReportAttachmentsConfig` / `DailyReportAttachmentThumbnailsConfig` (the `attachments` block and its `thumbnails`) / `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
1517
1551
|
|
|
1518
1552
|
MIT
|
package/dist/client.d.mts
CHANGED
|
@@ -307,26 +307,16 @@ type UseDailyReportSseConnectionOptions = {
|
|
|
307
307
|
};
|
|
308
308
|
declare const useDailyReportSseConnection: ({ isSseEnabled, onMessage }: UseDailyReportSseConnectionOptions) => DailyReportSseConnectionStatus;
|
|
309
309
|
type ReportBusinessDate = DailyReportDetail["date"];
|
|
310
|
-
type
|
|
311
|
-
user?: DailyReportUser;
|
|
312
|
-
pendingAddCommentIds: React.MutableRefObject<Map<number, Map<number, string>>>;
|
|
313
|
-
pendingDeleteCommentIds: React.MutableRefObject<Map<number, Set<number>>>;
|
|
314
|
-
pendingStarUpdates: React.MutableRefObject<Map<number, boolean>>;
|
|
315
|
-
pendingReadUpdates: React.MutableRefObject<Map<number, boolean>>;
|
|
316
|
-
resolvedIdMap: React.MutableRefObject<Map<number, number>>;
|
|
317
|
-
version: number;
|
|
318
|
-
items: DailyReportItem[];
|
|
319
|
-
isSseEnabled: boolean;
|
|
310
|
+
type DailyReportActionFunctions = {
|
|
320
311
|
toggleSse: () => void;
|
|
321
|
-
sseStatus: DailyReportSseConnectionStatus;
|
|
322
312
|
addComment: (reportHubId: number, content: string, businessDate: ReportBusinessDate) => Promise<void>;
|
|
323
313
|
deleteComment: (reportHubId: number, commentId: number, businessDate: ReportBusinessDate) => Promise<void>;
|
|
324
314
|
toggleStar: (reportHubId: number, isStarred: boolean, businessDate: ReportBusinessDate) => Promise<void>;
|
|
325
315
|
toggleRead: (reportHubId: number, isRead: boolean, businessDate: ReportBusinessDate) => Promise<void>;
|
|
326
316
|
createReport: (businessDate: string) => Promise<number>;
|
|
327
317
|
updateReport: (reportHubId: number, data: {
|
|
328
|
-
title
|
|
329
|
-
content
|
|
318
|
+
title: string;
|
|
319
|
+
content: string;
|
|
330
320
|
}, businessDate: ReportBusinessDate) => Promise<void>;
|
|
331
321
|
publishReport: (reportHubId: number, businessDate: ReportBusinessDate) => Promise<void>;
|
|
332
322
|
deleteReport: (reportHubId: number, businessDate: ReportBusinessDate) => Promise<void>;
|
|
@@ -334,8 +324,26 @@ type DailyReportActionContextType = {
|
|
|
334
324
|
refetchData: () => Promise<void>;
|
|
335
325
|
clearCacheAndRefetch: () => Promise<void>;
|
|
336
326
|
};
|
|
327
|
+
type DailyReportActionLedgers = {
|
|
328
|
+
pendingAddCommentIds: React.MutableRefObject<Map<number, Map<number, string>>>;
|
|
329
|
+
pendingDeleteCommentIds: React.MutableRefObject<Map<number, Set<number>>>;
|
|
330
|
+
pendingStarUpdates: React.MutableRefObject<Map<number, boolean>>;
|
|
331
|
+
pendingReadUpdates: React.MutableRefObject<Map<number, boolean>>;
|
|
332
|
+
resolvedIdMap: React.MutableRefObject<Map<number, number>>;
|
|
333
|
+
};
|
|
334
|
+
type DailyReportActions = DailyReportActionFunctions & DailyReportActionLedgers & {
|
|
335
|
+
user?: DailyReportUser;
|
|
336
|
+
};
|
|
337
|
+
type DailyReportActionState = {
|
|
338
|
+
version: number;
|
|
339
|
+
items: DailyReportItem[];
|
|
340
|
+
isSseEnabled: boolean;
|
|
341
|
+
sseStatus: DailyReportSseConnectionStatus;
|
|
342
|
+
};
|
|
343
|
+
type DailyReportActionContextType = DailyReportActions & DailyReportActionState;
|
|
337
344
|
declare global {
|
|
338
|
-
var
|
|
345
|
+
var __DailyReportActionsContext: Context<DailyReportActions | null> | undefined;
|
|
346
|
+
var __DailyReportActionStateContext: Context<DailyReportActionState | null> | undefined;
|
|
339
347
|
}
|
|
340
348
|
declare const useDailyReportActionContext: () => DailyReportActionContextType;
|
|
341
349
|
declare const DailyReportActionProvider: ({ children, user, userId }: {
|
|
@@ -394,7 +402,6 @@ type DailyReportIdsStreamItemsChange = {
|
|
|
394
402
|
};
|
|
395
403
|
type DailyReportIdsStreamPhase = "idle" | "streaming" | "retrying" | "complete" | "failed" | "auth-required";
|
|
396
404
|
type DailyReportIdsStreamState = {
|
|
397
|
-
items: DailyReportItem[];
|
|
398
405
|
loadedCount: number;
|
|
399
406
|
itemsRevision: number;
|
|
400
407
|
itemsChanges: readonly DailyReportIdsStreamItemsChange[];
|
|
@@ -460,6 +467,7 @@ declare class DailyReportIdsStreamClient {
|
|
|
460
467
|
applyExternalUpsert(item: DailyReportItem): void;
|
|
461
468
|
applyExternalRemoval(reportHubId: number): void;
|
|
462
469
|
has(reportHubId: number): boolean;
|
|
470
|
+
readItems(): DailyReportItem[];
|
|
463
471
|
dispose(): void;
|
|
464
472
|
private runStreamLoop;
|
|
465
473
|
private pruneUnseenItems;
|