create-qpq-app 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/package.json +2 -2
  2. package/template/docusaurus/docs/actions/core/key-value-store/ask-key-value-store-query.md +1 -0
  3. package/template/docusaurus/docs/actions/core/key-value-store/ask-key-value-store-scan-all-scopes.md +98 -0
  4. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-append-server-event.md +2 -2
  5. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-create.md +2 -2
  6. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +17 -15
  7. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-list.md +15 -10
  8. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-write.md +7 -7
  9. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-generate-asset-upload-url.md +42 -3
  10. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-get-by-id.md +5 -5
  11. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-get-draft.md +4 -4
  12. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-list.md +57 -11
  13. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-soft-delete.md +48 -10
  14. package/template/docusaurus/docs/config/core/key-value-store.md +53 -1
  15. package/template/docusaurus/docs/config/core/queue.md +1 -1
  16. package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -1
  17. package/template/docusaurus/docs/config/features/event-doc-summary.md +7 -6
  18. package/template/docusaurus/docs/config/webserver/migration.md +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-qpq-app",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.13.13",
55
- "quidproquo-tsconfig": "0.1.12"
55
+ "quidproquo-tsconfig": "0.1.14"
56
56
  },
57
57
  "bin": {
58
58
  "create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
@@ -55,6 +55,7 @@ function* askKeyValueStoreQuery<KvsItem>(
55
55
  | `sortAscending` | `boolean` | `true` | Order results by the sort key. `false` returns the newest/highest first. |
56
56
  | `limit` | `number` | – | Maximum number of records to return in this page. |
57
57
  | `nextPageKey` | `string` | – | Opaque cursor from a previous page's `nextPageKey`; pass it to fetch the following page. |
58
+ | `consistentRead` | `boolean` | `false` | Strongly consistent (read-your-own-writes) read. Costs roughly double the read capacity on DynamoDB and cannot be served from a global secondary index. Use it when a caller just wrote and is now querying to decide something (e.g. folding a log right after appending to it); leave it off for ordinary reads. |
58
59
  | `ttlInSeconds` | `number` | – | Accepted but not implemented: no processor currently applies a TTL to query results, so setting it has no effect. |
59
60
  | `scope` | `string` | – | Optional storage scope. The processor composes it into the partition-key conditions, so the query only matches records written under the same scope (used by tenant/scoped features). Requires a string-typed partition key, and the key condition must constrain the partition key. |
60
61
 
@@ -0,0 +1,98 @@
1
+ ---
2
+ title: askKeyValueStoreScanAllScopes
3
+ description: Migration-only scan across every scope in a key-value store, each record tagged with the scope it came from.
4
+ ---
5
+
6
+ # askKeyValueStoreScanAllScopes
7
+
8
+ Reads a page of records from a [key-value store](../../../config/core/key-value-store.md) across **every** storage scope, plus the unscoped partition, pairing each record with the scope it lives under. This deliberately crosses the scope boundary that [askKeyValueStoreScan](./ask-key-value-store-scan.md) and the rest of the KVS layer hold: an ordinary scan excludes scope-composed records so one tenant's request can never read another's.
9
+
10
+ Reach for this only from a migration or an equivalent whole-store operation that needs to rewrite every row regardless of scope — never on a request path. Nothing in the framework calls it.
11
+
12
+ - **Action type:** `KeyValueStoreActionType.ScanAllScopes`
13
+
14
+ ```typescript
15
+ import { askKeyValueStoreScanAllScopes } from 'quidproquo-core';
16
+
17
+ interface User {
18
+ userId: string;
19
+ status: string;
20
+ }
21
+
22
+ export function* askMigrateAllUsers() {
23
+ let nextPageKey: string | undefined;
24
+
25
+ do {
26
+ const page = yield* askKeyValueStoreScanAllScopes<User>('users', undefined, nextPageKey);
27
+
28
+ for (const { scope, item } of page.items) {
29
+ // rewrite item back into the same scope it came from
30
+ }
31
+
32
+ nextPageKey = page.nextPageKey;
33
+ } while (nextPageKey);
34
+ }
35
+ ```
36
+
37
+ ## Signature
38
+
39
+ ```typescript
40
+ function* askKeyValueStoreScanAllScopes<KvsItem>(
41
+ keyValueStoreName: string,
42
+ filterCondition?: KvsQueryOperation,
43
+ nextPageKey?: string,
44
+ options?: KeyValueStoreScanAllScopesOptions,
45
+ ): AskResponse<QpqPagedData<KvsScopedItem<KvsItem>>>;
46
+ ```
47
+
48
+ ## Parameters
49
+
50
+ | Parameter | Type | Description |
51
+ | --- | --- | --- |
52
+ | `keyValueStoreName` | `string` | Name of the store to scan — must match a store declared with [defineKeyValueStore](../../../config/core/key-value-store.md) (or one shared via its `owner` option). |
53
+ | `filterCondition` | `KvsQueryOperation` | Optional filter applied to every scanned record, within every scope. Built with the `kvs*` condition helpers — see [Query conditions](./ask-key-value-store-query.md#query-conditions-kvsqueryoperation). Omit it to return everything. |
54
+ | `nextPageKey` | `string` | Opaque cursor from a previous page's `nextPageKey`; pass it to fetch the following page. |
55
+ | `options` | `KeyValueStoreScanAllScopesOptions` | Optional scan options (see below). |
56
+
57
+ ### `KeyValueStoreScanAllScopesOptions`
58
+
59
+ | Property | Type | Default | Description |
60
+ | --- | --- | --- | --- |
61
+ | `limit` | `number` | – | Accepted but not implemented: no processor currently caps the page size, so setting it has no effect. |
62
+
63
+ ## Returns
64
+
65
+ `QpqPagedData<KvsScopedItem<KvsItem>>` — one page of results:
66
+
67
+ ```typescript
68
+ interface KvsScopedItem<KvsItem> {
69
+ scope?: string; // absent for an unscoped record
70
+ item: KvsItem;
71
+ }
72
+
73
+ interface QpqPagedData<T> {
74
+ items: T[];
75
+ nextPageKey?: string; // present when more pages remain
76
+ }
77
+ ```
78
+
79
+ ## Errors
80
+
81
+ | Error | Meaning |
82
+ | --- | --- |
83
+ | `KeyValueStoreScanAllScopesErrorTypeEnum.ServiceUnavailable` | DynamoDB internal error or throttling. |
84
+ | `KeyValueStoreScanAllScopesErrorTypeEnum.ResourceNotFound` | The underlying table does not exist. |
85
+ | `KeyValueStoreScanAllScopesErrorTypeEnum.StoreNotFound` | The key value store is not declared in the qpq config (misconfiguration, e.g. a wrong name or a missing `defineKeyValueStore`). |
86
+
87
+ Catch errors with `askCatch` — it returns `{ success: true, result }` or `{ success: false, error }`.
88
+
89
+ ## Notes
90
+
91
+ - Drain every page: pagination can advance across scope boundaries as well as within a single scope's records, so stopping after the first page silently skips the rest of the store, not just the rest of one scope.
92
+ - The scope is reported rather than dropped because a caller rewriting records across every tenant in one pass has to know which tenant each belongs to, or a write lands in the wrong partition.
93
+
94
+ ## Related
95
+
96
+ - [defineKeyValueStore](../../../config/core/key-value-store.md) — declares the store being scanned.
97
+ - [askKeyValueStoreScan](./ask-key-value-store-scan.md) — the scoped-safe equivalent for request-path code.
98
+ - [askKeyValueStoreScanAll](./ask-key-value-store-scan-all.md) — drains all pages of a single-scope scan into one array.
@@ -50,11 +50,11 @@ function* askEventDocAppendServerEvent<T>(
50
50
 
51
51
  ## Returns
52
52
 
53
- `AskResponse<EventDocEvent>` — the appended event with its server-stamped metadata (`index`, `createdAt`, `createdBy`, and the generated `clientMessageId`).
53
+ `AskResponse<EventDocEvent>` — the appended event with its server-stamped metadata (`eventId`, `createdAt`, `createdBy`, and the generated `clientMessageId`).
54
54
 
55
55
  ## Notes
56
56
 
57
- - All of [askEventDocEventAppend](./ask-event-doc-event-append.md)'s invariants apply version monotonicity, lifecycle/payload validation, and optimistic-concurrency retry since this is a thin envelope-building wrapper over it. It can therefore throw the same `ErrorTypeEnum.NotFound` / `ErrorTypeEnum.Conflict`.
57
+ - This is a thin envelope-building wrapper over [askEventDocEventAppend](./ask-event-doc-event-append.md), so the same caveat applies: the event is written unconditionally, and version/lifecycle validation is decided later at fold time, not here. A server event that a validator would reject is written but silently skipped by every fold.
58
58
  - A fresh `clientMessageId` is generated on every call, so this path does not participate in client retry dedup — each call is a distinct intended event.
59
59
 
60
60
  ## Related
@@ -64,7 +64,7 @@ A point-in-time snapshot of who produced an event, captured server-side at appen
64
64
 
65
65
  ## askEventDocSeedInitState
66
66
 
67
- Seeds a new document's log with its `INIT_STATE` event at index `0`, carrying the identity (`id`/`code`/`name`). This is the create-only primitive `askEventDocCreate` composes; clients never send `INIT_STATE` themselves. It writes the event and returns it.
67
+ Seeds a new document's log with its `INIT_STATE` event, carrying the identity (`id`/`code`/`name`). This is the create-only primitive `askEventDocCreate` composes; clients never send `INIT_STATE` themselves. It writes the event and returns it.
68
68
 
69
69
  ```typescript
70
70
  function* askEventDocSeedInitState(
@@ -82,7 +82,7 @@ function* askEventDocSeedInitState(
82
82
  | `name` | `string` | The document's name. |
83
83
  | `actor` | `EventDocEventActor` | Who is creating the document. |
84
84
 
85
- **Returns** `EventDocEvent` — the written `INIT_STATE` event, with `payload.metadata.index === 0` and `version === 1`.
85
+ **Returns** `EventDocEvent` — the written `INIT_STATE` event, with a freshly minted sortable `payload.metadata.eventId` and `version === 1`.
86
86
 
87
87
  Use `askEventDocCreate` unless you are building a custom create flow that needs the raw event; `askEventDocSeedInitState` alone writes the log but does **not** derive or persist the summary record.
88
88
 
@@ -1,13 +1,15 @@
1
1
  ---
2
2
  title: askEventDocEventAppend
3
- description: Append a client-authored event to a document's log with dedup, version and lifecycle validation, and optimistic-concurrency retry.
3
+ description: Append a client-authored event to a document's log a single unconditional write with no read, no retry, and no validation.
4
4
  ---
5
5
 
6
6
  # askEventDocEventAppend
7
7
 
8
- Appends a single client-authored event to a document's ordered event stream — the write half of the event-sourcing core. This is where the append-time safety invariants live: idempotent dedup, version monotonicity, lifecycle/payload validation, and optimistic-concurrency retry. After the event is written it also re-derives the queryable summary record so the document's status, version, name, and timestamps stay in sync with the log.
8
+ Appends a single client-authored event to a document's ordered event stream — the write half of the event-sourcing core. The event's id is a sortable id (UUIDv7, minted by [askNewSortableGuid](../../core/guid/ask-new-sortable-guid.md)), so the write needs no allocator and no coordination: it does not read the tail, does not validate, and has no retry loop. Concurrent appends to the same document neither contend nor fail on each other. After the event is written it also re-derives the queryable summary record so the document's status, version, name, and timestamps stay in sync with the log.
9
9
 
10
- - **Built from:** a story composing `askRetry`, `askEventDocEventLast`, `askEventDocEventListAll`, `askEventDocEventWrite`, `askEventDocGetByIdOrThrow`, and (when the collection configures one) an `askInlineFunctionExecute` validator. Not a single action.
10
+ **Validation happens later, at fold time, not here.** Dedup (a repeated `clientMessageId`), version monotonicity, and lifecycle/domain rules are all decided when the log is folded, against the accepted events before the one in question. An event that fails one of those checks is not rejected at append — it is written, then silently skipped by every fold, so the document reads as though it was never sent. That silence is deliberate: clients are expected to validate before they send (the same rules run client-side against the pending buffer), so a skipped event means a client skipped its own pre-flight, not that the append needs to report an error.
11
+
12
+ - **Built from:** `askDateNow`, `askNewSortableGuid`, `askEventDocEventWrite`, and `askEventDocSummaryRederive` (plus, when the collection configures `onPublish`/`onAppend`, `askEventDocGetByIdOrThrow`, `askEventDocEventListAll`, and `askInlineFunctionExecute`). Not a single action.
11
13
  - **Requires the store context** — wrap the call in [askEventDocProvideStore](./ask-event-doc-provide-store.md) (custom routes) or [askEventDocProvideStoreFromGlobals](./ask-event-doc-provide-store.md#askeventdocprovidestorefromglobals) (built-in routes).
12
14
 
13
15
  ```typescript
@@ -28,7 +30,7 @@ export function* appendTitleChange(docId: string) {
28
30
  actor,
29
31
  );
30
32
 
31
- return event.payload.metadata.index;
33
+ return event.payload.metadata.eventId;
32
34
  }
33
35
  ```
34
36
 
@@ -46,20 +48,20 @@ function* askEventDocEventAppend(
46
48
 
47
49
  | Parameter | Type | Description |
48
50
  | --- | --- | --- |
49
- | `modelId` | `string` | The document id whose log the event is appended to. The document must already have an `INIT_STATE` event (created via `askEventDocCreate`), or the append throws `NotFound`. |
51
+ | `modelId` | `string` | The document id whose log the event is appended to. Not checked against an existing `INIT_STATE` at append time — an event appended before `INIT_STATE` exists is simply written and then skipped by every fold, since the reducer has no document to fold it onto. |
50
52
  | `input` | `EventDocEventInput` | The client-authored event envelope — see below. |
51
53
  | `actor` | `EventDocEventActor` | Who authored the event; stamped onto the event as `createdBy`. Usually obtained from [askEventDocResolveActor](./ask-event-doc-resolve-actor.md). |
52
54
 
53
55
  ### `EventDocEventInput`
54
56
 
55
- What the client POSTs to append an event. `modelId` and the server-stamped provenance (`index`, `createdAt`, `createdBy`) are NOT part of it.
57
+ What the client POSTs to append an event. `modelId` and the server-stamped provenance (`eventId`, `createdAt`, `createdBy`) are NOT part of it.
56
58
 
57
59
  | Property | Type | Description |
58
60
  | --- | --- | --- |
59
61
  | `type` | `string` | The effect/event type discriminant (e.g. `SET_NAME`). The reducer folds it by this. |
60
62
  | `payload.data` | `T` | The typed domain data for the event. |
61
- | `payload.metadata.version` | `number` | The schema version the client authored against. Must be `>=` the last event's version — an older version throws `Conflict`. |
62
- | `payload.metadata.clientMessageId` | `string` | A client-generated id used for dedup: if the latest event already carries it, the append is a no-op and returns that event unchanged. |
63
+ | `payload.metadata.version` | `number` | The schema version the client authored against. Expected `>=` the log's highest accepted version so far the fold, not the append, silently skips an older one when it later folds the log. |
64
+ | `payload.metadata.clientMessageId` | `string` | A client-generated id used for dedup: the fold ignores a later event carrying a `clientMessageId` it has already accepted. The append itself does not check this — a retry is written as a new row in the log either way. |
63
65
 
64
66
  ### `EventDocEventActor`
65
67
 
@@ -70,7 +72,7 @@ What the client POSTs to append an event. `modelId` and the server-stamped prove
70
72
 
71
73
  ## Returns
72
74
 
73
- `AskResponse<EventDocEvent>` — the event that now lives in the log, with server-stamped metadata (`index`, `createdAt`, `createdBy`) filled in. On a deduped retry, the pre-existing event is returned unchanged.
75
+ `AskResponse<EventDocEvent>` — the event now written to the log, with server-stamped metadata (`eventId`, `createdAt`, `createdBy`) filled in. Unlike before, this is not conditional on the event surviving validation — a fold may still skip it.
74
76
 
75
77
  ### `EventDocEvent`
76
78
 
@@ -78,15 +80,15 @@ What the client POSTs to append an event. `modelId` and the server-stamped prove
78
80
  | --- | --- | --- |
79
81
  | `type` | `string` | The event type discriminant. |
80
82
  | `payload.data` | `T` | The typed domain data. |
81
- | `payload.metadata` | `EventDocEventMetadata` | Full provenance: `version`, `clientMessageId`, `createdBy`, `createdAt`, and `index` (mirrors the storage sort key). |
83
+ | `payload.metadata` | `EventDocEventMetadata` | Full provenance: `version`, `clientMessageId`, `createdBy`, `createdAt`, and `eventId` (a sortable id — mirrors the storage sort key, sorts lexicographically in creation order). |
82
84
 
83
85
  ## Notes
84
86
 
85
- - **Dedup** is best-effort against the latest event only (until a GSI exists): a retry that re-sends the same `clientMessageId` returns the existing tail event without writing.
86
- - **Validation** always runs against the log folded from prior events. If the collection configured an `eventValidator` inline function it runs that (a complete validator that already composes the reserved lifecycle guard); otherwise it runs `defaultEventDocEventValidator` the same guard with no domain rules. Exactly one validator runs. A rejected event throws `Conflict` with the validator's reason.
87
- - **Concurrency:** the underlying write ([askEventDocEventWrite](./ask-event-doc-event-write.md)) claims the `(modelId, index)` slot conditionally. A losing concurrent writer gets a key-value-store upsert conflictthe only error the internal `askRetry` re-laps on re-reads the tail, and re-runs dedup/validation against fresh state, so concurrent appends serialize onto consecutive indexes. After `MAX_APPEND_ATTEMPTS` (8) lost races it throws `ErrorTypeEnum.Conflict`.
88
- - **Thrown `ErrorTypeEnum` values:** `NotFound` (no `INIT_STATE`), `Conflict` (stale version, failed validation, or exhausted concurrency retries). These are thrown via `askThrowError`, not a per-action error enum.
89
- - After writing, it re-derives and upserts the document's summary record (via `applyEventDocSummaryEvent`) so the queryable view stays consistent with the log.
87
+ - **No dedup, no version check, and no lifecycle/domain validation at append time.** All three are decided when the log is folded (`foldEventDocLog`), against the accepted events before the one in question: a repeated `clientMessageId` is ignored, an event whose version is older than the log's highest accepted version is ignored, and the collection's `eventValidator` (or `defaultEventDocEventValidator` when none is configured) is run there too. A rejected event is skipped silently — the document reads as though it was never written — rather than causing the append to throw.
88
+ - **No read, no retry, no coordination.** The append does not read the tail or the log; it mints a sortable id and writes. Two appends landing in the same millisecond get an arbitrary but stable relative order, which is fine because ordering only has to be stable, not wall-clock-precise.
89
+ - **Write uniqueness** is still enforced by [askEventDocEventWrite](./ask-event-doc-event-write.md)'s conditional (`ifNotExists`) write, but since ids are unique by construction this should never fire in practice a collision surfaces as `KeyValueStoreUpsertErrorTypeEnum.Conflict` and indicates a bug (two writers minting the same id), not ordinary contention, so there is no retry around it.
90
+ - After writing, it calls `askEventDocSummaryRederive`, which re-folds the whole log and re-derives the document's summary record so the queryable view (identity, version history, timestamps) stays in sync this is the one piece of read-model maintenance still on the write path, until a stream projector replaces it.
91
+ - Hooks (`onPublish`/`onAppend`, when the collection configures them) run after the event is durably written; a hook failure propagates so the caller knows the side effect — not the append — failed.
90
92
 
91
93
  ## Related
92
94
 
@@ -5,21 +5,21 @@ description: Read a document's event log — a page of events, the whole log fla
5
5
 
6
6
  # Reading the event log
7
7
 
8
- Three read helpers over a document's event stream. All three resolve the collection's events store from the store context and query it by `pk = modelId`, ascending by log index (except `askEventDocEventLast`, which reads the tail). They are the read side of the event-sourcing core, feeding the fold that reconstructs a document from its events.
8
+ Three read helpers over a document's event stream. All three resolve the collection's events store from the store context and query it by `pk = modelId`, ascending by event id (except `askEventDocEventLast`, which reads the tail). Event ids are sortable ids (UUIDv7) whose string form sorts lexicographically in creation order, so ascending-by-id and ascending-by-creation-time agree. They are the read side of the event-sourcing core, feeding the fold that reconstructs a document from its events.
9
9
 
10
10
  - **Requires the store context** — provide it via [askEventDocProvideStore](./ask-event-doc-provide-store.md) / [askEventDocProvideStoreFromGlobals](./ask-event-doc-provide-store.md#askeventdocprovidestorefromglobals).
11
11
  - **Built from:** [askKeyValueStoreQuery](../../core/key-value-store/ask-key-value-store-query.md) against the events store, plus [askEventDocResolveStore](./ask-event-doc-provide-store.md#askeventdocresolvestore). Not single actions.
12
12
 
13
13
  ## askEventDocEventList
14
14
 
15
- Returns one page of events for a document, oldest first. Supports paging and, via `afterIndex`, fetching only the tail since a known index — an incremental refresh.
15
+ Returns one page of events for a document, oldest first. Supports paging and, via `afterEventId`, fetching only the tail since a known event — an incremental refresh.
16
16
 
17
17
  ```typescript
18
18
  import { askEventDocEventList } from 'quidproquo-features';
19
19
 
20
- export function* refreshSince(docId: string, lastSeenIndex: number) {
21
- const page = yield* askEventDocEventList(docId, { afterIndex: lastSeenIndex });
22
- return page.items; // events with index > lastSeenIndex
20
+ export function* refreshSince(docId: string, lastSeenEventId: string) {
21
+ const page = yield* askEventDocEventList(docId, { afterEventId: lastSeenEventId });
22
+ return page.items; // events after lastSeenEventId
23
23
  }
24
24
  ```
25
25
 
@@ -45,11 +45,12 @@ function* askEventDocEventList(
45
45
  | --- | --- | --- | --- |
46
46
  | `limit` | `number` | (store default) | Max number of events to return in the page. |
47
47
  | `nextPageKey` | `string` | — | Continuation token from a previous page's `nextPageKey`. |
48
- | `afterIndex` | `number` | — | Return only events whose log index is greater than this (exclusive). A sort-key range condition on the events store's primary key — no GSI involved. |
48
+ | `afterEventId` | `string` | — | Return only events whose event id sorts after this one (exclusive). A sort-key range condition on the events store's primary key — no GSI involved. |
49
+ | `consistentRead` | `boolean` | `false` | Strongly consistent read. Needed by a caller that just appended and is now folding to decide something — the default eventually-consistent read can otherwise miss that caller's own most recent event. Costs roughly double the read capacity, so leave it off for ordinary reads. |
49
50
 
50
51
  ### Returns
51
52
 
52
- `AskResponse<QpqPagedData<EventDocEvent>>` — `{ items: EventDocEvent[]; nextPageKey?: string }`. Events are ordered ascending by index; `nextPageKey` is present when more events remain.
53
+ `AskResponse<QpqPagedData<EventDocEvent>>` — `{ items: EventDocEvent[]; nextPageKey?: string }`. Events are ordered ascending by event id (equivalently, creation order); `nextPageKey` is present when more events remain.
53
54
 
54
55
  ## askEventDocEventListAll
55
56
 
@@ -66,7 +67,10 @@ export function* fullHistory(docId: string) {
66
67
  ### Signature
67
68
 
68
69
  ```typescript
69
- function* askEventDocEventListAll(modelId: string): AskResponse<EventDocEvent[]>;
70
+ function* askEventDocEventListAll(
71
+ modelId: string,
72
+ options?: { consistentRead?: boolean },
73
+ ): AskResponse<EventDocEvent[]>;
70
74
  ```
71
75
 
72
76
  ### Parameters
@@ -74,14 +78,15 @@ function* askEventDocEventListAll(modelId: string): AskResponse<EventDocEvent[]>
74
78
  | Parameter | Type | Description |
75
79
  | --- | --- | --- |
76
80
  | `modelId` | `string` | The document id to read the full log for. |
81
+ | `options.consistentRead` | `boolean` | Optional, default `false`. Strongly consistent read — for a caller that just appended and is now folding to decide something, so it does not miss its own most recent event. Leave off for ordinary reads; it doubles the read cost. |
77
82
 
78
83
  ### Returns
79
84
 
80
- `AskResponse<EventDocEvent[]>` — every event for the document, ordered ascending by index. Internally loops [askEventDocEventList](#askeventdoceventlist) until there is no `nextPageKey`.
85
+ `AskResponse<EventDocEvent[]>` — every event for the document, ordered ascending by event id. Internally loops [askEventDocEventList](#askeventdoceventlist) until there is no `nextPageKey`.
81
86
 
82
87
  ## askEventDocEventLast
83
88
 
84
- Returns the newest event in the log, or `null` if the document has no events. Used to assign the next index, dedup, and validate during an append. Relies on numeric sort-key ordering (the dev server sorts numeric sort keys numerically, matching DynamoDB), so it returns the true latest.
89
+ Returns the newest event in the log, or `null` if the document has no events. Relies on string sort-key ordering (a sortable event id's string form sorts lexicographically in creation order, and the dev server sorts string sort keys the same way DynamoDB does), so it returns the true latest.
85
90
 
86
91
  ```typescript
87
92
  import { askEventDocEventLast } from 'quidproquo-features';
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: askEventDocEventWrite
3
- description: Low-level conditional write of a single event to a document's events store, claiming its (modelId, index) slot atomically.
3
+ description: Low-level conditional write of a single event to a document's events store, keyed by its sortable event id.
4
4
  ---
5
5
 
6
6
  # askEventDocEventWrite
7
7
 
8
- The low-level write primitive behind the event log. It persists one already-built [EventDocEvent](./ask-event-doc-event-append.md#eventdocevent) into the collection's events store, keyed by `pk = modelId` / `sk = index`. The write is **conditional** (`ifNotExists`): the `(modelId, index)` slot is claimed atomically, so a concurrent writer that computed the same index gets a conflict instead of silently overwriting the event.
8
+ The low-level write primitive behind the event log. It persists one already-built [EventDocEvent](./ask-event-doc-event-append.md#eventdocevent) into the collection's events store, keyed by `pk = modelId` / `sk = eventId` (a sortable id — UUIDv7 — whose string form sorts lexicographically in creation order). The write is **conditional** (`ifNotExists`), but since ids are minted uniquely (via `askNewSortableGuid`) rather than allocated, this is a cheap uniqueness assertion rather than a contested slot: a `Conflict` here means two writers minted the same id, which should never happen and indicates a bug, not ordinary concurrent-write contention.
9
9
 
10
- Ordering, index assignment, dedup, validation, and the conflict-retry loop all live one layer up in [askEventDocEventAppend](./ask-event-doc-event-append.md) — you almost always want that instead. Call this directly only when you are implementing your own append semantics.
10
+ Id assignment, dedup, and validation all live one layer up in [askEventDocEventAppend](./ask-event-doc-event-append.md) (dedup and validation are actually decided later still, at fold time) — you almost always want that instead. Call this directly only when you are implementing your own append semantics.
11
11
 
12
12
  - **Built from:** [askKeyValueStoreUpsertWithRetry](../../core/key-value-store/ask-key-value-store-upsert-with-retry.md) with `{ ifNotExists: true }`, plus [askEventDocResolveStore](./ask-event-doc-provide-store.md#askeventdocresolvestore) to find the events store name. Not a single action.
13
13
  - **Requires the store context** — provide it via [askEventDocProvideStore](./ask-event-doc-provide-store.md) / [askEventDocProvideStoreFromGlobals](./ask-event-doc-provide-store.md#askeventdocprovidestorefromglobals).
@@ -31,7 +31,7 @@ function* askEventDocEventWrite(modelId: string, event: EventDocEvent): AskRespo
31
31
  | Parameter | Type | Description |
32
32
  | --- | --- | --- |
33
33
  | `modelId` | `string` | The document id — becomes the partition key (`pk`) of the stored event. |
34
- | `event` | `EventDocEvent` | A fully-formed event, including `payload.metadata.index` — the index becomes the sort key (`sk`) and the slot that is claimed conditionally. |
34
+ | `event` | `EventDocEvent` | A fully-formed event, including `payload.metadata.eventId` — the sortable id becomes the sort key (`sk`) and the slot that is claimed conditionally. |
35
35
 
36
36
  ## Returns
37
37
 
@@ -39,12 +39,12 @@ function* askEventDocEventWrite(modelId: string, event: EventDocEvent): AskRespo
39
39
 
40
40
  ## Notes
41
41
 
42
- - The stored shape is `{ pk: modelId, sk: index, data: event }`; the `EventDocStoredEvent` mapping is the only place that knows the key layout, keeping the domain event free of storage concerns.
43
- - Because the write is conditional, a losing concurrent writer surfaces `KeyValueStoreUpsertErrorTypeEnum.Conflict`. [askEventDocEventAppend](./ask-event-doc-event-append.md) is the layer that catches and re-laps on exactly that error.
42
+ - The stored shape is `{ pk: modelId, sk: eventId, data: event }`; the `EventDocStoredEvent` mapping is the only place that knows the key layout, keeping the domain event free of storage concerns.
43
+ - Because the write is conditional, an id collision surfaces `KeyValueStoreUpsertErrorTypeEnum.Conflict`. [askEventDocEventAppend](./ask-event-doc-event-append.md) does not catch or retry on it with sortable ids minted uniquely per append, this is not an expected contention path.
44
44
 
45
45
  ## Related
46
46
 
47
- - [askEventDocEventAppend](./ask-event-doc-event-append.md) — the high-level append that computes the index, validates, and retries around this write.
47
+ - [askEventDocEventAppend](./ask-event-doc-event-append.md) — the high-level append that mints the sortable id and writes through this.
48
48
  - [askEventDocEventList / EventListAll / EventLast](./ask-event-doc-event-list.md) — reading events back.
49
49
  - [askKeyValueStoreUpsertWithRetry](../../core/key-value-store/ask-key-value-store-upsert-with-retry.md) — the underlying conditional upsert.
50
50
  - [askEventDocProvideStore](./ask-event-doc-provide-store.md) — provides the required store context.
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: askEventDocGenerateAssetUploadUrl
3
- description: Manage a document's immutable assets — mint presigned upload/download URLs, or write server-generated bytes directly.
3
+ description: Manage a document's immutable assets and size-aware values — mint presigned upload/download URLs, write server-generated bytes directly, or record a value inline vs as an asset depending on its size.
4
4
  ---
5
5
 
6
6
  # Document assets
7
7
 
8
8
  A document can point at binary **assets** — images, fonts, rendered artifacts — without embedding the bytes. Each asset is stored immutably on the collection's storage drive under `<docId>/assets/<guid>`, and the document records only a reference (the guid) inside a domain event. Because every upload gets a fresh guid, a re-upload is a new blob and prior references stay addressable for history and rollback.
9
9
 
10
- These three helpers cover the two flows: presigned URLs for a browser to upload/download bytes directly, and a server-side write for when the backend already holds the bytes.
10
+ These helpers cover three flows: presigned URLs for a browser to upload/download bytes directly, a server-side write for when the backend already holds the bytes, and a size-aware write that decides between inline and asset storage for values of unpredictable size.
11
11
 
12
12
  - **Requires the store context** — provide it via [askEventDocProvideStore](./ask-event-doc-provide-store.md) / [askEventDocProvideStoreFromGlobals](./ask-event-doc-provide-store.md#askeventdocprovidestorefromglobals). The drive name is resolved from that context.
13
13
  - **Built from:** the core file secure-URL / binary-write actions on the collection's storage drive, plus [askEventDocResolveStore](./ask-event-doc-provide-store.md#askeventdocresolvestore). Not single actions.
@@ -115,10 +115,49 @@ function* askEventDocWriteAsset(
115
115
 
116
116
  `AskResponse<EventDocAssetRef>` — `{ guid, filename, mimetype }`, a first-class reference to record in a domain event so the document points at the bytes without embedding them.
117
117
 
118
+ ## askEventDocWriteValue
119
+
120
+ Records a **value** (data of unpredictable size — a node output, a variable, a trigger input) for an event: inline when it is small enough, or as an asset when it is not. This is the size-aware counterpart to `askEventDocWriteAsset` above, which stays the right choice for genuine files where a reference is always correct.
121
+
122
+ ```typescript
123
+ import { askEventDocWriteValue } from 'quidproquo-features';
124
+
125
+ export function* recordNodeOutput(docId: string, nodeId: string, output: unknown) {
126
+ const ref = yield* askEventDocWriteValue(docId, output, `${nodeId}.json`);
127
+ // ref is either { kind: 'inline', value } or { kind: 'asset', guid, filename, mimetype }
128
+ return ref;
129
+ }
130
+ ```
131
+
132
+ ### Signature
133
+
134
+ ```typescript
135
+ function* askEventDocWriteValue(
136
+ docId: string,
137
+ value: unknown,
138
+ filename: string,
139
+ maxInlineBytes?: number,
140
+ ): AskResponse<EventDocValueRef>;
141
+ ```
142
+
143
+ ### Parameters
144
+
145
+ | Parameter | Type | Description |
146
+ | --- | --- | --- |
147
+ | `docId` | `string` | The document the value belongs to — used for the asset blob key when the value doesn't fit inline. |
148
+ | `value` | `unknown` | The value to record. Serialised with `JSON.stringify`; `undefined` is normalised to `null`. |
149
+ | `filename` | `string` | Filename to use if the value is written as an asset. |
150
+ | `maxInlineBytes` | `number` | Optional, default 4KB. The per-value inline ceiling. A caller recording several values against one event's combined size budget can pass the *remaining* budget so a value that no longer fits falls back to an asset; passing `0` always forces an asset. |
151
+
152
+ ### Returns
153
+
154
+ `AskResponse<EventDocValueRef>` — either `{ kind: 'inline', value }` (the value travelled in the event, nothing to fetch) or `{ kind: 'asset', guid, filename, mimetype }` (fetch it like any other asset reference).
155
+
118
156
  ## Notes
119
157
 
120
- - All three resolve the drive from the store context, so a missing context throws (see [askEventDocResolveStore](./ask-event-doc-provide-store.md#askeventdocresolvestore)).
158
+ - All four resolve the drive from the store context, so a missing context throws (see [askEventDocResolveStore](./ask-event-doc-provide-store.md#askeventdocresolvestore)).
121
159
  - Assets are immutable: guid-named so a re-upload is always a fresh blob; the old one stays addressable for history/rollback. Assets live under `<docId>/assets/<guid>`; derived, disposable artifacts use a sibling `<docId>/runtime/<...>` prefix written elsewhere.
160
+ - On the read side, `isInlineEventDocValueRef`, `readInlineEventDocValueRefs`, and `resolveEventDocValueRef` (all in `quidproquo-features`) are the shared helpers for branching on a ref's `kind` and resolving it against fetched asset snapshots — use them rather than checking `ref.kind` by hand, so every reader agrees on what "available" means.
122
161
 
123
162
  ## Related
124
163
 
@@ -25,8 +25,8 @@ export function* loadArticle(id: string) {
25
25
 
26
26
  An event document is never stored as a mutable blob. Its authoritative state is an **append-only log of events**; the document you read is *derived by folding that log*.
27
27
 
28
- - **Summary record** ([`EventDocSummary`](#the-summary-record)) — the queryable projection folded from the log's identity/lifecycle events (`INIT_STATE`, `SET_CODE`, `SET_NAME`, `PUBLISH`, …). It holds identity (`id`, `code`, `name`), audit fields, and a `versions` array. The document's editable **content** is folded separately (on the client) from the same log; the backend never reduces content.
29
- - **Draft vs published** — the tail (highest) version with no `publishedAt` is the **draft**; a `PUBLISH` event freezes it and starts the next draft. Each version pointer records the `eventIndex` of its last event (its head), so folding events with index that head reconstructs the version's content as it was. `publishedAt` is when a version was published; `effectiveFrom` is when that publish takes effect (used for as-of time-travel).
28
+ - **Summary record** ([`EventDocSummary`](#the-summary-record)) — the queryable projection folded from the log's identity/lifecycle events (`INIT_STATE`, `SET_CODE`, `SET_NAME`, `PUBLISH`, `DELETE`, `RESTORE`, …). It holds identity (`id`, `code`, `name`), audit fields, and a `versions` array. Every field on it is derived from the log — including `deletedAt`, set and cleared by `DELETE`/`RESTORE` events rather than written directly — so the whole record can be dropped and rebuilt from the log at any time. The document's editable **content** is folded separately (on the client) from the same log; the backend never reduces content.
29
+ - **Draft vs published** — the tail (highest) version with no `publishedAt` is the **draft**; a `PUBLISH` event freezes it and starts the next draft. Each version pointer records the `eventId` (a sortable id) of its last event (its head), so folding events whose `eventId` sorts at or before that head reconstructs the version's content as it was. `publishedAt` is when a version was published; `effectiveFrom` is when that publish takes effect (used for as-of time-travel).
30
30
  - **Code** — the caller-chosen, stable business key set at create (via `INIT_STATE`) and editable with `SET_CODE`. It stays constant across versions and is expected unique within the collection (and any owner scope), so you can address a document by `code` instead of its generated `id`.
31
31
 
32
32
  The version-pointer reads ([askEventDocGetDraft, askEventDocGetLatestPublished, askEventDocGetPublishedAsOf, askEventDocPublishedEventsAsOf](./ask-event-doc-get-draft.md)) resolve entries in this model.
@@ -41,7 +41,7 @@ type EventDocSummary = {
41
41
  name: string;
42
42
  createdAt: string; // ISO datetime
43
43
  updatedAt: string; // ISO datetime
44
- deletedAt?: string; // set by soft delete
44
+ deletedAt?: string; // derived from a DELETE event; cleared by RESTORE
45
45
  createdBy: string;
46
46
  updatedBy: string;
47
47
  versions: EventDocVersion[];
@@ -49,7 +49,7 @@ type EventDocSummary = {
49
49
 
50
50
  type EventDocVersion = {
51
51
  version: number;
52
- eventIndex: number; // log index of this version's head event
52
+ eventId: string; // sortable id of this version's head event
53
53
  publishedAt?: string; // unset while it is the tail draft
54
54
  effectiveFrom?: string; // when the publish takes effect (as-of selection)
55
55
  };
@@ -116,6 +116,6 @@ if (outcome.success) {
116
116
  - [askEventDocList](./ask-event-doc-list.md) — read every document in the collection.
117
117
  - [askEventDocGetByCode](./ask-event-doc-get-by-code.md) — look a document up by its business `code` instead of `id`.
118
118
  - [askEventDocGetDraft / …LatestPublished / …PublishedAsOf / …PublishedEventsAsOf](./ask-event-doc-get-draft.md) — resolve a document's versions.
119
- - [askEventDocCreate](./ask-event-doc-create.md) — create a document. [askEventDocSoftDelete](./ask-event-doc-soft-delete.md) — retire one.
119
+ - [askEventDocCreate](./ask-event-doc-create.md) — create a document. [askEventDocSoftDelete / askEventDocRestore](./ask-event-doc-soft-delete.md) — retire a document, or bring it back.
120
120
  - [defineEventDocSummary](../../../config/features/event-doc-summary.md) — declares the store these read from.
121
121
  - [askCatch](../../core/system/ask-catch.md) — handle thrown errors as a result object.
@@ -9,7 +9,7 @@ Resolves the current **draft** version pointer for a document by `id`, or `null`
9
9
 
10
10
  - **Built from:** [askEventDocGetById](./ask-event-doc-get-by-id.md) plus an in-memory version selector. Requires the store context — call it inside `askEventDocProvideStore({ storeName, type }, ...)`, or from a built-in route where the context is already provided.
11
11
 
12
- First, a quick recap of the model (full detail on the [read-by-id page](./ask-event-doc-get-by-id.md#the-event-document-model)): an event document is derived by folding its event log. Each **version** pointer in the summary's `versions` array records the `eventIndex` of its last event (its head) and, once published, its `publishedAt` and `effectiveFrom` times. The tail version with no `publishedAt` is the **draft**; a `PUBLISH` event freezes a version and opens the next draft.
12
+ First, a quick recap of the model (full detail on the [read-by-id page](./ask-event-doc-get-by-id.md#the-event-document-model)): an event document is derived by folding its event log. Each **version** pointer in the summary's `versions` array records the `eventId` of its last event (its head) and, once published, its `publishedAt` and `effectiveFrom` times. The tail version with no `publishedAt` is the **draft**; a `PUBLISH` event freezes a version and opens the next draft.
13
13
 
14
14
  ```typescript
15
15
  import { askEventDocGetDraft } from 'quidproquo-features';
@@ -41,13 +41,13 @@ function* askEventDocGetDraft(id: string): AskResponse<Nullable<EventDocVersion>
41
41
  ```typescript
42
42
  type EventDocVersion = {
43
43
  version: number;
44
- eventIndex: number; // log index of this version's head event
44
+ eventId: string; // sortable id of this version's head event
45
45
  publishedAt?: string; // unset while it is the tail draft
46
46
  effectiveFrom?: string; // when the publish takes effect (as-of selection)
47
47
  };
48
48
  ```
49
49
 
50
- To fold or render a version's content, fold the log's events with index its `eventIndex`.
50
+ To fold or render a version's content, fold the log's events whose `eventId` sorts at or before the version's `eventId`.
51
51
 
52
52
  ---
53
53
 
@@ -85,7 +85,7 @@ function* askEventDocGetPublishedAsOf(
85
85
 
86
86
  ## askEventDocPublishedEventsAsOf
87
87
 
88
- Returns the **events** that make up the version published and *effective* at `clock` — the log truncated at that version's head. It resolves the version from the summary via `effectiveFrom` (when the publish takes effect, so a publish scheduled for the future stays invisible until then), then returns every event with `index <= version.eventIndex`. Fold the returned events to get the published, as-of-`clock` content — the generic backbone of a "render published" flow. (This mirrors `askEventDocGetPublishedAsOf`, which returns the version pointer rather than its events, and keys on `publishedAt` rather than `effectiveFrom`.)
88
+ Returns the **events** that make up the version published and *effective* at `clock` — the log truncated at that version's head. It resolves the version from the summary via `effectiveFrom` (when the publish takes effect, so a publish scheduled for the future stays invisible until then), then returns every event whose `eventId` sorts at or before `version.eventId`. Fold the returned events to get the published, as-of-`clock` content — the generic backbone of a "render published" flow. (This mirrors `askEventDocGetPublishedAsOf`, which returns the version pointer rather than its events, and keys on `publishedAt` rather than `effectiveFrom`.)
89
89
 
90
90
  ```typescript
91
91
  function* askEventDocPublishedEventsAsOf(
@@ -1,13 +1,17 @@
1
1
  ---
2
2
  title: askEventDocList
3
- description: List every event document in the current collection, newest-updated first, hiding soft-deleted rows by default.
3
+ description: List event documents in the current collection, newest-updated first, hiding soft-deleted rows by default — as a full array or one page at a time.
4
4
  ---
5
5
 
6
- # askEventDocList
6
+ # Listing a collection
7
7
 
8
- Lists the summary records of every document in the current collection (all rows sharing the collection's `type`), sorted by `updatedAt` **descending** (most recently updated first). Soft-deleted documents are hidden unless you opt in.
8
+ Two ways to list the summary records of a collection (all rows sharing the collection's `type`), sorted by `updatedAt` **descending** (most recently updated first). Soft-deleted documents are hidden unless you opt in. Both require the store context — call inside `askEventDocProvideStore({ storeName, type }, ...)`, or from a built-in route where the context is already provided.
9
9
 
10
- - **Built from:** a key-value store query over the collection's `type`, filtered and sorted in memory. Requires the store context — call it inside `askEventDocProvideStore({ storeName, type }, ...)`, or from a built-in route where the context is already provided.
10
+ ## askEventDocList
11
+
12
+ Reads the **whole** collection into memory, filters and sorts it, and returns it as a flat array. Fine for a handful of documents; costs read capacity proportional to the whole collection, so it does not scale to a large collection — use [askEventDocListPage](#askeventdoclistpage) for that.
13
+
14
+ - **Built from:** a key-value store query over the collection's `type`, filtered and sorted in memory.
11
15
 
12
16
  ```typescript
13
17
  import { askEventDocList } from 'quidproquo-features';
@@ -18,7 +22,7 @@ export function* listArticles() {
18
22
  }
19
23
  ```
20
24
 
21
- ## Signature
25
+ ### Signature
22
26
 
23
27
  ```typescript
24
28
  function* askEventDocList<T extends EventDocSummary = EventDocSummary>(
@@ -26,9 +30,9 @@ function* askEventDocList<T extends EventDocSummary = EventDocSummary>(
26
30
  ): AskResponse<T[]>;
27
31
  ```
28
32
 
29
- ## Parameters
33
+ ### Parameters
30
34
 
31
- ### `options` — `EventDocListOptions` (optional)
35
+ #### `options` — `EventDocListOptions` (optional)
32
36
 
33
37
  | Property | Type | Default | Description |
34
38
  | --- | --- | --- | --- |
@@ -36,18 +40,60 @@ function* askEventDocList<T extends EventDocSummary = EventDocSummary>(
36
40
 
37
41
  The generic `T` lets callers narrow to a collection-specific extension of [`EventDocSummary`](./ask-event-doc-get-by-id.md#the-summary-record); it defaults to `EventDocSummary`.
38
42
 
39
- ## Returns
43
+ ### Returns
40
44
 
41
45
  `EventDocSummary[]` — the collection's records, sorted by `updatedAt` descending. Empty array when the collection has no (visible) documents.
42
46
 
43
- ## Notes
47
+ ### Notes
44
48
 
45
49
  - Ordering is applied in memory, but the summary store carries a `(type, updatedAt)` index so this stays efficient.
46
50
  - Because there is no secondary index on `code`, the code-based reads ([askEventDocGetByCode](./ask-event-doc-get-by-code.md) and friends) list the collection via this action and filter in memory.
47
51
 
52
+ ## askEventDocListPage
53
+
54
+ Reads **one page** of the collection straight off the store, newest first, without reading or holding the rest of the collection in memory. This is what the built-in `GET {basePath}` list route uses.
55
+
56
+ - **Built from:** [askKeyValueStoreQuery](../../core/key-value-store/ask-key-value-store-query.md) against the summary store, ordered by its `(type, updatedAt)` index (descending), with soft-deleted rows excluded by a query filter.
57
+
58
+ ```typescript
59
+ import { askEventDocListPage } from 'quidproquo-features';
60
+
61
+ export function* listArticlesPage(nextPageKey?: string) {
62
+ return yield* askEventDocListPage({ nextPageKey }); // QpqPagedData<EventDocSummary>
63
+ }
64
+ ```
65
+
66
+ ### Signature
67
+
68
+ ```typescript
69
+ function* askEventDocListPage<T extends EventDocSummary = EventDocSummary>(
70
+ options?: EventDocListPageOptions,
71
+ ): AskResponse<QpqPagedData<T>>;
72
+ ```
73
+
74
+ ### Parameters
75
+
76
+ #### `options` — `EventDocListPageOptions` (optional)
77
+
78
+ | Property | Type | Default | Description |
79
+ | --- | --- | --- | --- |
80
+ | `includeDeleted` | `boolean` | `false` | When `true`, soft-deleted documents are included. When `false` (default), they are excluded by a query filter. |
81
+ | `limit` | `number` | `10` | Maximum number of records to return in this page. |
82
+ | `nextPageKey` | `string` | — | Opaque cursor from a previous page's `nextPageKey`; pass it to fetch the following page. |
83
+
84
+ ### Returns
85
+
86
+ `AskResponse<QpqPagedData<T>>` — `{ items: T[]; nextPageKey?: string }`, one page ordered by `updatedAt` descending.
87
+
88
+ ### Notes
89
+
90
+ - Because the exclusion of soft-deleted rows is a query filter (applied **after** rows are read), a page can come back shorter than `limit` while more pages remain. Page on the presence of `nextPageKey`, never on item count, or documents past a short page are silently hidden.
91
+ - There is no jump-to-page-N or total count without reading the whole collection — the store hands back an opaque "continue from here" cursor, not an offset, so pages are walked rather than addressed.
92
+
48
93
  ## Related
49
94
 
50
95
  - [askEventDocGetById / askEventDocGetByIdOrThrow](./ask-event-doc-get-by-id.md) — read a single document.
51
96
  - [askEventDocGetByCode](./ask-event-doc-get-by-code.md) — find one document by its business `code`.
52
- - [askEventDocSoftDelete](./ask-event-doc-soft-delete.md) — sets the `deletedAt` this action filters on.
53
- - [defineEventDocSummary](../../../config/features/event-doc-summary.md) — declares the store (and its ordering index) this reads from.
97
+ - [askEventDocSoftDelete](./ask-event-doc-soft-delete.md) — sets the `deletedAt` these actions filter on.
98
+ - [askKeyValueStoreQuery](../../core/key-value-store/ask-key-value-store-query.md) — the underlying paged query action.
99
+ - [defineEventDocSummary](../../../config/features/event-doc-summary.md) — declares the store (and its ordering index) these read from.
@@ -1,19 +1,19 @@
1
1
  ---
2
2
  title: askEventDocSoftDelete
3
- description: Soft-delete an event document by stamping deletedAt, keeping its versions and assets intact.
3
+ description: Soft-delete an event document by appending a DELETE event, keeping its versions and assets intact — and restore it with askEventDocRestore.
4
4
  ---
5
5
 
6
6
  # askEventDocSoftDelete
7
7
 
8
- Soft-deletes an event document by stamping `deletedAt` (and refreshing `updatedAt`/`updatedBy`) on its summary record. The document's versions and blob claims stay intact nothing is destroyed — and [askEventDocList](./ask-event-doc-list.md) hides it by default. This is the **public** deletion path. Returns the updated [`EventDocSummary`](./ask-event-doc-get-by-id.md#the-summary-record).
8
+ Soft-deletes an event document by appending a reserved `DELETE` event to its log. `deletedAt` on the summary record is derived from that event by the fold (not written directly), and [askEventDocList](./ask-event-doc-list.md) hides the document by default once it's set. The document's versions and blob claims stay intact — nothing is destroyed. This is the **public** deletion path. Returns the updated [`EventDocSummary`](./ask-event-doc-get-by-id.md#the-summary-record).
9
9
 
10
- - **Built from:** [askEventDocGetByIdOrThrow](./ask-event-doc-get-by-id.md#askeventdocgetbyidorthrow) (loads the record, throwing `NotFound` if missing), then a validated [askEventDocUpsert](./ask-event-doc-create.md#askeventdocupsert). Requires the store context — call it inside `askEventDocProvideStore({ storeName, type }, ...)`.
10
+ - **Built from:** [askEventDocAppendServerEvent](./ask-event-doc-append-server-event.md) (appends the `DELETE` event) then [askEventDocGetByIdOrThrow](./ask-event-doc-get-by-id.md#askeventdocgetbyidorthrow) (re-reads the re-derived record). Requires the store context — call it inside `askEventDocProvideStore({ storeName, type }, ...)`.
11
11
 
12
12
  ```typescript
13
13
  import { askEventDocSoftDelete } from 'quidproquo-features';
14
14
 
15
- export function* archiveArticle(id: string, userId: string) {
16
- const summary = yield* askEventDocSoftDelete(id, userId);
15
+ export function* archiveArticle(id: string, userId: string, schemaVersion: number) {
16
+ const summary = yield* askEventDocSoftDelete(id, userId, schemaVersion);
17
17
  return summary; // deletedAt is now set
18
18
  }
19
19
  ```
@@ -24,6 +24,7 @@ export function* archiveArticle(id: string, userId: string) {
24
24
  function* askEventDocSoftDelete(
25
25
  id: string,
26
26
  updatedBy: string,
27
+ schemaVersion: number,
27
28
  ): AskResponse<EventDocSummary>;
28
29
  ```
29
30
 
@@ -32,19 +33,55 @@ function* askEventDocSoftDelete(
32
33
  | Parameter | Type | Description |
33
34
  | --- | --- | --- |
34
35
  | `id` | `string` | Id of the document to soft-delete. |
35
- | `updatedBy` | `string` | User id to record as having performed the deletion (written to `updatedBy`). |
36
+ | `updatedBy` | `string` | User id to record as having authored the `DELETE` event (used as both `userId` and `userDisplayName` on the event's actor). |
37
+ | `schemaVersion` | `number` | The schema version to stamp the `DELETE` event with, same as any other event — the fold rejects an event authored against an older schema than the log has already reached. |
36
38
 
37
39
  ## Returns
38
40
 
39
- `EventDocSummary` — the updated record with `deletedAt` set to the deletion time.
41
+ `EventDocSummary` — the re-derived record, with `deletedAt` set to the `DELETE` event's time.
40
42
 
41
43
  ## Notes
42
44
 
43
- - Throws `ErrorTypeEnum.NotFound` (from quidproquo-core) when no document exists for `id`.
45
+ - The reserved `DELETE` validator (`requireNotDeleted`) rejects a `DELETE` on an already-deleted document — but rejection happens at fold time, not at append (see [askEventDocEventAppend](./ask-event-doc-event-append.md)), so calling this on an already-deleted document does not throw: the event is written, the fold silently skips it, and the re-derived summary comes back unchanged.
44
46
  - Soft-deleted rows are still returned by [askEventDocGetById](./ask-event-doc-get-by-id.md) (filtering is the caller's concern) and by [askEventDocList](./ask-event-doc-list.md) only when `includeDeleted: true`.
45
47
 
46
48
  ---
47
49
 
50
+ ## askEventDocRestore
51
+
52
+ Undoes a soft delete by appending a reserved `RESTORE` event. The `DELETE` event stays in the log — history is append-only, so the deletion remains auditable — but the fold stops treating the document as deleted from this point on: `deletedAt` is cleared on the re-derived summary record, and the document goes back into default (non-`includeDeleted`) listings. Everything else (versions, blob claims) is untouched, so the document comes back exactly as it was. Returns the updated `EventDocSummary`.
53
+
54
+ ```typescript
55
+ import { askEventDocRestore } from 'quidproquo-features';
56
+
57
+ export function* unarchiveArticle(id: string, userId: string, schemaVersion: number) {
58
+ const summary = yield* askEventDocRestore(id, userId, schemaVersion);
59
+ return summary; // deletedAt is cleared
60
+ }
61
+ ```
62
+
63
+ ### Signature
64
+
65
+ ```typescript
66
+ function* askEventDocRestore(
67
+ id: string,
68
+ updatedBy: string,
69
+ schemaVersion: number,
70
+ ): AskResponse<EventDocSummary>;
71
+ ```
72
+
73
+ | Parameter | Type | Description |
74
+ | --- | --- | --- |
75
+ | `id` | `string` | Id of the document to restore. |
76
+ | `updatedBy` | `string` | User id to record as having authored the `RESTORE` event. |
77
+ | `schemaVersion` | `number` | The schema version to stamp the `RESTORE` event with, for the same reason as `askEventDocSoftDelete`. |
78
+
79
+ **Returns** `EventDocSummary` — the re-derived record, with `deletedAt` cleared.
80
+
81
+ The reserved `RESTORE` validator (`requireDeleted`) rejects a `RESTORE` on a document that isn't currently deleted — again at fold time, not append, so calling this on a non-deleted document does not throw; it's a silent no-op. Requires the store context, built the same way as `askEventDocSoftDelete` above.
82
+
83
+ ---
84
+
48
85
  ## askEventDocDelete
49
86
 
50
87
  **Hard delete** — permanently removes the document's summary row from the store. This is for internal cleanup/admin only; the public lifecycle uses soft delete (above). It does not touch the event log or the asset bucket, so a hard delete of the summary alone leaves orphaned events/assets — use deliberately.
@@ -63,7 +100,8 @@ Requires the store context. Prefer `askEventDocSoftDelete` for anything user-fac
63
100
 
64
101
  ## Related
65
102
 
66
- - [askEventDocGetByIdOrThrow](./ask-event-doc-get-by-id.md#askeventdocgetbyidorthrow) — the load-or-throw this composes.
103
+ - [askEventDocAppendServerEvent](./ask-event-doc-append-server-event.md) — appends the `DELETE`/`RESTORE` event both actions compose.
104
+ - [askEventDocEventAppend](./ask-event-doc-event-append.md) — the underlying append; explains why rejection is silent rather than thrown.
105
+ - [askEventDocGetByIdOrThrow](./ask-event-doc-get-by-id.md#askeventdocgetbyidorthrow) — reads the re-derived record back.
67
106
  - [askEventDocList](./ask-event-doc-list.md) — hides soft-deleted documents by default.
68
107
  - [askEventDocCreate](./ask-event-doc-create.md) — the create counterpart.
69
- - [askDateNow](../../core/date/ask-date-now.md) — supplies the `deletedAt` timestamp.
@@ -56,6 +56,7 @@ Zero or more sort keys. The list is significant:
56
56
  | `ttlAttribute` | `string` | – | Name of a record attribute holding a Unix-epoch (seconds) timestamp. DynamoDB automatically deletes records once that time passes. |
57
57
  | `disablePointInTimeRecovery` | `boolean` | `false` | Point-in-time recovery (35-day continuous backups / restore) is on by default; set this to opt out. |
58
58
  | `encryption` | `boolean` | `false` | Enables customer-managed KMS encryption for the table (the KMS key comes from the service's AWS config). When a customer-managed key isn't configured, AWS-managed encryption is used instead; when `false`, DynamoDB's default provider-managed encryption still applies. |
59
+ | `onStream` | `KvsStreamSettings` | – | Turns on change data capture and runs a story for every insert/modify/remove on the store. See [Change data capture (`onStream`)](#change-data-capture-onstream). |
59
60
 
60
61
  ## Keys (`CompositeKvsKey`)
61
62
 
@@ -98,6 +99,56 @@ defineKeyValueStore('orders', 'orderId', [], {
98
99
 
99
100
  On AWS each index becomes a Global Secondary Index whose name is the index's partition-key attribute (`customerId`, `status` above).
100
101
 
102
+ ## Change data capture (`onStream`)
103
+
104
+ ```typescript
105
+ export type KvsStreamSettings = {
106
+ runtime: QpqFunctionRuntime;
107
+ coalesceByPartitionKey?: boolean;
108
+ batchSize?: number;
109
+ maximumBatchingWindowInSeconds?: number;
110
+ };
111
+ ```
112
+
113
+ Declaring `onStream` puts the table into DynamoDB's `NEW_AND_OLD_IMAGES` stream mode and deploys a handler lambda subscribed to it. Records for a given partition key are always delivered to the handler in order; different partition keys may be processed concurrently. A table nothing subscribes to gets no stream at all, since a stream on it would be pure cost.
114
+
115
+ | Property | Type | Default | Description |
116
+ | --- | --- | --- | --- |
117
+ | `runtime` | `QpqFunctionRuntime` | – (required) | The handler story, usually a relative path string in the form `'/path/to/file::exportedFunctionName'`. Invoked once per record with a `KvsStreamRecord<T>` (exported from `quidproquo-core`). |
118
+ | `coalesceByPartitionKey` | `boolean` | `false` | Collapse each delivered batch down to one record per partition key (the latest), instead of invoking the handler once per record. Off by default, since a generic consumer (audit trail, change notifications) needs to see every change. Turn it on for a projection, where the handler re-derives state from source and only needs to know a key changed. |
119
+ | `batchSize` | `number` | `100` | Records per invocation. DynamoDB streams allow up to 1000. |
120
+ | `maximumBatchingWindowInSeconds` | `number` | – | How long to wait accumulating records before invoking, 0–300 seconds. Trades latency for fewer invocations, and gives `coalesceByPartitionKey` more to collapse. |
121
+
122
+ The handler receives a `KvsStreamRecord<T>`:
123
+
124
+ ```typescript
125
+ export enum KvsStreamEventType {
126
+ Insert = 'Insert',
127
+ Modify = 'Modify',
128
+ Remove = 'Remove',
129
+ }
130
+
131
+ export type KvsStreamRecord<TItem extends object = any> = {
132
+ keyValueStoreName: string;
133
+ eventType: KvsStreamEventType;
134
+ scope?: string; // present when the item was written under a storage scope
135
+ keys: Record<string, unknown>; // always present, including on Remove
136
+ newImage?: TItem; // absent on Remove
137
+ oldImage?: TItem; // absent on Insert
138
+ };
139
+ ```
140
+
141
+ Images are plain objects, already unmarshalled from DynamoDB's wire format and with any storage scope stripped back out of the key values — a handler is ordinary story code and never sees a raw AttributeValue or a composed partition key.
142
+
143
+ ```typescript
144
+ defineKeyValueStore('documents', 'id', [], {
145
+ onStream: {
146
+ runtime: '/entry/kvsStream/onDocumentChanged::onDocumentChanged',
147
+ coalesceByPartitionKey: true,
148
+ },
149
+ });
150
+ ```
151
+
101
152
  ## Examples
102
153
 
103
154
  ```typescript
@@ -132,4 +183,5 @@ export default [
132
183
  - **Query & scan:** [askKeyValueStoreQuery](../../actions/core/key-value-store/ask-key-value-store-query.md) · [askKeyValueStoreScan](../../actions/core/key-value-store/ask-key-value-store-scan.md)
133
184
  - **Write:** [askKeyValueStoreUpsert](../../actions/core/key-value-store/ask-key-value-store-upsert.md) · [askKeyValueStoreUpdate](../../actions/core/key-value-store/ask-key-value-store-update.md) · [askKeyValueStoreDelete](../../actions/core/key-value-store/ask-key-value-store-delete.md)
134
185
  - **AWS tuning:** [defineAwsKmsKey](../config-aws/aws-kms-key.md) (customer-managed encryption key for the `encryption` flag), [defineAwsDyanmoOverrideForKvs](../config-aws/aws-dyanmo-override-for-kvs.md) (back the store with a pre-existing DynamoDB table), and [defineAwsDataStoreRemovalPolicy](../config-aws/aws-data-store-removal-policy.md) (retain vs destroy the table on teardown).
135
- - **AWS implementation:** `QpqCoreKeyValueStoreConstruct` (DynamoDB table, LSIs, GSIs, TTL, PITR, KMS, IAM grants) in `quidproquo-deploy-awscdk`; KVS action processors in `quidproquo-actionprocessor-awslambda`.
186
+ - [defineEventDocSummary](../features/event-doc-summary.md) a real `onStream` consumer: rebuilds a document's summary record from its event log on every change.
187
+ - **AWS implementation:** `QpqCoreKeyValueStoreConstruct` (DynamoDB table, LSIs, GSIs, TTL, PITR, KMS, IAM grants) and `QpqApiCoreKeyValueStoreStreamConstruct` (the `onStream` handler lambda + event source) in `quidproquo-deploy-awscdk`; KVS action processors in `quidproquo-actionprocessor-awslambda`.
@@ -52,7 +52,7 @@ Each key is matched against a delivered message's `type` field. The key may be a
52
52
  | Property | Type | Default | Description |
53
53
  | --- | --- | --- | --- |
54
54
  | `batchSize` | `number` | `0` | Max number of messages delivered to the consumer Lambda per invocation. `0` leaves the SQS default. When set (> 0), the event source uses this batch size. |
55
- | `batchWindowInSeconds` | `number` | `5` | Max time SQS waits to fill a batch before invoking the consumer. Only applied when `batchSize > 0`, and **not** applied to FIFO queues (which don't support a batching window). |
55
+ | `batchWindowInSeconds` | `number` | `0` | Max time SQS waits to fill a batch before invoking the consumer — invoke as soon as a message arrives. Only applied when `batchSize > 0`, and **not** applied to FIFO queues (which don't support a batching window). A queue that wants to accumulate a batch before invoking should set this explicitly. |
56
56
  | `concurrency` | `number` | `1` | Consumer concurrency hint. |
57
57
  | `maxTries` | `number` | `1` | How many times a message is delivered before it is sent to the dead-letter queue (SQS `maxReceiveCount`). |
58
58
  | `ttRetryInSeconds` | `number` | `900` | Retry/visibility timeout in seconds — how long a message stays invisible while being processed before it can be redelivered. Also used as the consumer Lambda timeout. Capped at 900 (15 minutes). |
@@ -31,7 +31,7 @@ All paths are prefixed with the version segment `/v{version}` (default `/v1`):
31
31
 
32
32
  | Method | Path | Purpose |
33
33
  | --- | --- | --- |
34
- | `GET` | `{basePath}` | List the collection's documents. |
34
+ | `GET` | `{basePath}` | List one page of the collection's documents (newest first). Accepts `?limit=` and `?nextPageKey=` and returns `QpqPagedData`. |
35
35
  | `GET` | `{basePath}/{id}` | Get one document's summary record. |
36
36
  | `GET` | `{basePath}/{id}/events` | List a document's event log. |
37
37
  | `GET` | `{basePath}/{id}/render` | Render the document to HTML. **Only mounted when `eventRenderer` is set.** |
@@ -5,15 +5,16 @@ description: Declare the stores for an event-sourced document collection — its
5
5
 
6
6
  # defineEventDocSummary
7
7
 
8
- Declares the **stores** that back an event-document collection, without any routes. It returns a `QPQConfig` (an array of config settings) that expands to three underlying core stores:
8
+ Declares the **stores** that back an event-document collection, without any routes. It returns a `QPQConfig` (an array of config settings) that expands to four underlying core stores:
9
9
 
10
- 1. A **summary key-value store** (partition key `type`, sort key `id`) — the queryable record for each document, derived by folding the identity/lifecycle events of its log. Rows are the [`EventDocSummary`](../../actions/features/event-doc/ask-event-doc-get-by-id.md) shape and carry the version history. A secondary index on `(type, updatedAt)` supports the recently-updated ordering [askEventDocList](../../actions/features/event-doc/ask-event-doc-list.md) returns.
11
- 2. An **append-only events store** (`<storeName>Events`, partition key `pk`, numeric sort key `sk`) — the ordered log every document is folded from. It has **no** secondary index on purpose: the local dev-server query processor can't target one, so all event reads go through the main table.
12
- 3. A **storage drive** (`<storeName>edocs`, lower-cased) — the collection's blob bucket, holding each document's immutable uploaded assets (and later its derived runtime artifacts) under per-document prefixes.
10
+ 1. A **summary key-value store** (partition key `type`, sort key `id`) — the queryable record for each document, derived by folding the identity/lifecycle events of its log. Rows are the [`EventDocSummary`](../../actions/features/event-doc/ask-event-doc-get-by-id.md) shape and carry the version history. A secondary index on `(type, updatedAt)` supports the recently-updated ordering [askEventDocList](../../actions/features/event-doc/ask-event-doc-list.md) returns. It is a pure projection: nothing on the append path writes it directly. The events store below declares an [`onStream`](../core/key-value-store.md#change-data-capture-onstream) handler that rebuilds a document's summary row from its log whenever an event is appended, so the summary is eventually (not immediately) consistent with the log.
11
+ 2. An **append-only events store** (`<storeName>EventLog`, partition key `pk`, string sort key `sk`) — the live ordered log every document is folded from, keyed on a sortable event id (UUIDv7). It has **no** secondary index on purpose: the local dev-server query processor can't target one, so all event reads go through the main table.
12
+ 3. A **legacy events store** (`<storeName>Events`, partition key `pk`, numeric sort key `sk`) — the pre-sortable-id log, kept declared but unread/unwritten at runtime so its data stays reachable until it is migrated into the events store above.
13
+ 4. A **storage drive** (`<storeName>edocs`, lower-cased) — the collection's blob bucket, holding each document's immutable uploaded assets (and later its derived runtime artifacts) under per-document prefixes.
13
14
 
14
- Point-in-time recovery is enabled on both tables.
15
+ Point-in-time recovery is enabled on all three tables.
15
16
 
16
- - **On AWS:** deploys two DynamoDB tables (via [defineKeyValueStore](../core/key-value-store.md)) and one S3 bucket (via [defineStorageDrive](../core/storage-drive.md)). All physical names are derived from `keyValueStoreName`, so a collection needs only that one name.
17
+ - **On AWS:** deploys three DynamoDB tables (via [defineKeyValueStore](../core/key-value-store.md)) and one S3 bucket (via [defineStorageDrive](../core/storage-drive.md)). All physical names are derived from `keyValueStoreName`, so a collection needs only that one name.
17
18
 
18
19
  ```typescript
19
20
  import { defineEventDocSummary } from 'quidproquo-features';
@@ -32,6 +32,10 @@ The generated deploy event fires on each qualifying stack status change:
32
32
  - **On the first (Create) deploy** every migration is recorded as *already run* without executing it — a freshly created service is expected to start from clean [seed](./seed.md) data, so there is nothing to migrate.
33
33
  - **On later (Update) deploys** each migration whose `deployType` matches the stack that changed and that has **not** already been recorded is enqueued on the `qpqMigrations` queue (which runs its story) and then recorded as run. Migrations already recorded are skipped, so re-deploying is safe.
34
34
 
35
+ ## Testing locally
36
+
37
+ Nothing local ever deploys, so a migration never runs on its own while you develop against `go:dev`. Run `qpq migrate` to execute every pending migration against the local dev store, once, through the same queue the deployed path uses. It records what it ran in the same tracking store, so a second call is a no-op until you add a new migration.
38
+
35
39
  ## Signature
36
40
 
37
41
  ```typescript