160 lines
10 KiB
Markdown
160 lines
10 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: qrcode (qrcode-batch-generator)
|
|
|
|
**Package**: `@suaveplan/qrcode`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: BatchQrCodeGenerator with per-item failure isolation
|
|
|
|
The package SHALL expose `generateBatch(requests, options?): Promise<BatchQrCodeSummary>` and `BatchQrCodeGenerator.generate()`. The function SHALL produce one `BatchQrCodeResult` per input request in input order. A failure on any single request — encoder rejection, Reed-Solomon overflow, or any thrown exception inside the per-item path — SHALL NOT short-circuit the batch; the failure SHALL surface as `{ ok: false, error: QRCodeError }` for that index while every other request continues to generate. Successful items SHALL surface as `{ ok: true, result: QRCodeResult }`. The summary SHALL include `total`, `succeeded`, `failed`, `durationMs` (from injected `Clock`), and the full `results` array.
|
|
|
|
#### Scenario: every request succeeds
|
|
|
|
- **WHEN** `generateBatch` is called with five valid requests at error-correction `M`
|
|
- **THEN** `summary.total === 5` AND `summary.succeeded === 5` AND `summary.failed === 0` AND every `results[i].ok === true` AND `results[i].result.errorCorrectionLevel === "M"`
|
|
|
|
#### Scenario: one request exceeds capacity — others still succeed
|
|
|
|
- **WHEN** `generateBatch` is called with three short valid requests and one request whose value exceeds the version-10 byte capacity at error-correction `H`
|
|
- **THEN** `summary.succeeded === 3` AND `summary.failed === 1` AND `results[3].ok === false` AND `results[3].error` is a `QRCodeError` with code `"QRCODE.DATA_TOO_LARGE"` (or whatever capacity-overflow code `QRCODE_CODES` defines) AND the three short results are present and valid
|
|
|
|
#### Scenario: input order is preserved by generateBatch
|
|
|
|
- **WHEN** `generateBatch` is called with `concurrency: 8` over 16 requests
|
|
- **THEN** `results[i].index === i` for every `i` AND `results.length === 16` regardless of completion order
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.test.ts::"generateBatch returns one result per input in input order"` AND `src/core/batch/batch-generator.test.ts::"generateBatch isolates per-item failures and continues"` AND `src/core/batch/batch-generator.test.ts::"generateBatch preserves input order under concurrency 8"`
|
|
|
|
---
|
|
|
|
### Requirement: generateBatchAsync yields in completion order
|
|
|
|
The package SHALL expose `generateBatchAsync(requests, options?): AsyncGenerator<BatchQrCodeResult>` and `BatchQrCodeGenerator.stream()`. The generator SHALL yield one `BatchQrCodeResult` per input request, in completion order (not input order). The generator SHALL honour `concurrency` strictly (no more than `concurrency` requests in-flight at once) and SHALL honour `signal` (subsequent `next()` after abort rejects with `BARCODE.BATCH_ABORTED`).
|
|
|
|
#### Scenario: completion-order yield
|
|
|
|
- **WHEN** `generateBatchAsync` is iterated over four requests where the third has artificially-delayed encoding (via `concurrency: 4`)
|
|
- **THEN** the slow item is yielded last AND every yielded result still carries the original `index` field matching its position in the input array
|
|
|
|
#### Scenario: concurrency throttles in-flight requests
|
|
|
|
- **WHEN** `generateBatchAsync` is iterated with `concurrency: 2` and an `onItem` spy that records the count of currently in-flight items
|
|
- **THEN** the recorded peak in-flight count never exceeds 2
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.test.ts::"generateBatchAsync yields in completion order"` AND `src/core/batch/batch-generator.test.ts::"generateBatchAsync throttles in-flight requests to concurrency"`
|
|
|
|
---
|
|
|
|
### Requirement: Concurrency control with safe clamping
|
|
|
|
The package SHALL accept an optional `concurrency` option on `BatchQrCodeOptions`. Default for `generateBatch` SHALL be `1` (sequential). Default for `generateBatchAsync` SHALL be `4` (matching legacy). Both SHALL clamp the value to the inclusive range `[1, 64]`.
|
|
|
|
#### Scenario: generateBatch defaults to sequential
|
|
|
|
- **WHEN** `generateBatch(requests)` is called without `options.concurrency`
|
|
- **THEN** at most one QR code is being generated at any time
|
|
|
|
#### Scenario: concurrency clamps to upper bound
|
|
|
|
- **WHEN** `generateBatch(requests, { concurrency: 1000 })` is called
|
|
- **THEN** the internal worker pool is sized at 64 (verified by counting active in-flight promises via `onItem`) AND no error is thrown
|
|
|
|
#### Scenario: concurrency clamps to lower bound
|
|
|
|
- **WHEN** `generateBatch(requests, { concurrency: 0 })` is called
|
|
- **THEN** execution is sequential (effective concurrency 1) AND no error is thrown
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.test.ts::"generateBatch defaults to sequential execution"` AND `src/core/batch/batch-generator.test.ts::"generateBatch clamps concurrency above 64 to 64"` AND `src/core/batch/batch-generator.test.ts::"generateBatch clamps concurrency below 1 to 1"`
|
|
|
|
---
|
|
|
|
### Requirement: AbortSignal cancels the batch
|
|
|
|
The package SHALL accept an optional `AbortSignal` on `BatchQrCodeOptions`. For `generateBatch`, when the signal fires the returned promise SHALL reject with a `QRCodeError` whose `code === "QRCODE.BATCH_ABORTED"`. For `generateBatchAsync`, when the signal fires the next `await iterator.next()` SHALL reject with the same coded error. In-flight per-item generations MAY complete or be discarded; queued items SHALL NOT be started. The error SHALL carry the original `AbortSignal.reason` as `cause` if present.
|
|
|
|
#### Scenario: pre-fired signal aborts immediately
|
|
|
|
- **WHEN** `generateBatch(requests, { signal })` is called with an already-aborted `AbortSignal`
|
|
- **THEN** the returned promise rejects with `QRCodeError` AND `error.code === "QRCODE.BATCH_ABORTED"` AND no item is generated
|
|
|
|
#### Scenario: signal fires mid-stream
|
|
|
|
- **WHEN** `generateBatchAsync(requests, { signal })` is iterated for 10 yields, then `signal.abort()` is called
|
|
- **THEN** the next `iterator.next()` rejects with `QRCodeError` of code `"QRCODE.BATCH_ABORTED"` AND no further items are generated
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.test.ts::"generateBatch rejects immediately when signal is pre-aborted"` AND `src/core/batch/batch-generator.test.ts::"generateBatchAsync rejects next() after signal fires mid-stream"` AND `src/core/batch/batch-generator.spec-conformance.test.ts::"abort path emits QRCODE.BATCH_ABORTED with chained cause"`
|
|
|
|
---
|
|
|
|
### Requirement: onItem progress callback emits coded results
|
|
|
|
The package SHALL accept an optional `onItem: (result: BatchQrCodeResult) => void` callback on `BatchQrCodeOptions`. The callback SHALL fire exactly once per completed request. For `generateBatch` the callback fires in completion order; for `generateBatchAsync` the callback fires immediately before each `yield`. A throw from `onItem` SHALL NOT abort the batch.
|
|
|
|
#### Scenario: onItem fires once per request
|
|
|
|
- **WHEN** `generateBatch` is called with 20 requests and an `onItem` spy
|
|
- **THEN** the spy is called 20 times AND every emitted result has the same identity as the corresponding `summary.results[i]`
|
|
|
|
#### Scenario: onItem callback throwing does not break the batch
|
|
|
|
- **WHEN** the `onItem` callback throws for one item
|
|
- **THEN** the batch still resolves normally AND the throw is captured into the OTel span as a non-fatal event
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.test.ts::"onItem fires exactly once per completed request"` AND `src/core/batch/batch-generator.spec-conformance.test.ts::"onItem callback errors are isolated from the batch"`
|
|
|
|
---
|
|
|
|
### Requirement: Deterministic durationMs via injectable Clock
|
|
|
|
The package SHALL accept an injectable `Clock` (`@suaveplan/types::Clock`) on the `BatchQrCodeGenerator` constructor. `durationMs` on `BatchQrCodeSummary` SHALL be computed as `clock.now() - startedAt`. The free `generateBatch` and `generateBatchAsync` functions SHALL use a default real clock; tests SHALL be able to inject a deterministic clock for stable assertions.
|
|
|
|
#### Scenario: injected clock yields deterministic duration
|
|
|
|
- **WHEN** a `BatchQrCodeGenerator` is constructed with a `FakeClock` that advances by 11 ms after each `now()` call, and `generate` is invoked over 4 requests
|
|
- **THEN** `summary.durationMs === 11` (start-to-finish span of one tick)
|
|
|
|
#### Scenario: no module-scope Date.now / performance.now
|
|
|
|
- **WHEN** the `batch-generator.ts` source is grepped for `Date.now(`, `performance.now(`, `Math.random(`
|
|
- **THEN** zero matches are found at module scope
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.spec-conformance.test.ts::"injected FakeClock yields deterministic durationMs"` AND `src/core/batch/batch-generator.spec-conformance.test.ts::"batch-generator source has no module-scope Date.now or Math.random"`
|
|
|
|
---
|
|
|
|
### Requirement: QRCODE.BATCH_ABORTED registered in QRCODE_CODES
|
|
|
|
The package SHALL register `QRCODE.BATCH_ABORTED` in `QRCODE_CODES` (the stable error-code registry). The code SHALL be the only error thrown from the abort path; per-item failures continue to use existing codes (capacity overflow, mode-mismatch, etc.).
|
|
|
|
#### Scenario: code is registered
|
|
|
|
- **WHEN** `QRCODE_CODES` is enumerated
|
|
- **THEN** the array includes `"QRCODE.BATCH_ABORTED"`
|
|
|
|
#### Scenario: code is the documented type
|
|
|
|
- **WHEN** the `QRCodeCode` exported type is inspected
|
|
- **THEN** `"QRCODE.BATCH_ABORTED"` is one of its members (verified by `expectTypeOf<"QRCODE.BATCH_ABORTED">().toMatchTypeOf<QRCodeCode>()`)
|
|
|
|
**Verified by:** `src/errors/qrcode-error.test.ts::"QRCODE_CODES includes BATCH_ABORTED"` AND `src/core/batch/batch-generator.spec-conformance.test.ts::"QRCodeCode union includes BATCH_ABORTED"`
|
|
|
|
---
|
|
|
|
### Requirement: ./batch subpath export and tree-shakeability
|
|
|
|
The package SHALL expose `./batch` as a tree-shakeable subpath in `package.json` `exports`, mapping to `dist/core/batch/batch-generator.js`. Importing only `generateBatch` SHALL NOT pull in the entire `core/encoder` and `core/reed-solomon` graph for callers that don't use them transitively.
|
|
|
|
#### Scenario: subpath resolves to the same function as the root export
|
|
|
|
- **WHEN** a consumer imports `{ generateBatch } from "@suaveplan/qrcode/batch"` and `{ generateBatch as fromRoot } from "@suaveplan/qrcode"`
|
|
- **THEN** the two references are identity-equal
|
|
|
|
**Verified by:** `src/core/batch/batch-generator.test.ts::"./batch subpath and root export resolve to the same function"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|