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.
- package/package.json +2 -2
- package/template/docusaurus/docs/actions/core/key-value-store/ask-key-value-store-query.md +1 -0
- package/template/docusaurus/docs/actions/core/key-value-store/ask-key-value-store-scan-all-scopes.md +98 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-append-server-event.md +2 -2
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-create.md +2 -2
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +17 -15
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-list.md +15 -10
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-write.md +7 -7
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-generate-asset-upload-url.md +42 -3
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-get-by-id.md +5 -5
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-get-draft.md +4 -4
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-list.md +57 -11
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-soft-delete.md +48 -10
- package/template/docusaurus/docs/config/core/key-value-store.md +53 -1
- package/template/docusaurus/docs/config/core/queue.md +1 -1
- package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -1
- package/template/docusaurus/docs/config/features/event-doc-summary.md +7 -6
- 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.
|
|
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.
|
|
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
|
|
package/template/docusaurus/docs/actions/core/key-value-store/ask-key-value-store-scan-all-scopes.md
ADDED
|
@@ -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.
|
package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-append-server-event.md
CHANGED
|
@@ -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 (`
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (`
|
|
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.
|
|
62
|
-
| `payload.metadata.clientMessageId` | `string` | A client-generated id used for dedup:
|
|
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
|
|
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 `
|
|
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
|
-
- **
|
|
86
|
-
- **
|
|
87
|
-
- **
|
|
88
|
-
-
|
|
89
|
-
-
|
|
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
|
|
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 `
|
|
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,
|
|
21
|
-
const page = yield* askEventDocEventList(docId, {
|
|
22
|
-
return page.items; // events
|
|
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
|
-
| `
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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:
|
|
43
|
-
- Because the write is conditional,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 `
|
|
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; //
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
6
|
+
# Listing a collection
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
### Parameters
|
|
30
34
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
53
|
-
- [
|
|
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
|
|
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
|
|
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:** [
|
|
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
|
|
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
|
|
41
|
+
`EventDocSummary` — the re-derived record, with `deletedAt` set to the `DELETE` event's time.
|
|
40
42
|
|
|
41
43
|
## Notes
|
|
42
44
|
|
|
43
|
-
-
|
|
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
|
-
- [
|
|
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
|
-
-
|
|
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` | `
|
|
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
|
|
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>
|
|
12
|
-
3. A **
|
|
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
|
|
15
|
+
Point-in-time recovery is enabled on all three tables.
|
|
15
16
|
|
|
16
|
-
- **On AWS:** deploys
|
|
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
|