dynamodb-reactive 0.1.22 → 0.2.1
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/README.md +378 -153
- package/dist/client-r_tnzwqi.d.ts +224 -0
- package/dist/client.cjs +1023 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +6 -0
- package/dist/client.d.ts +4 -70
- package/dist/client.js +1013 -1
- package/dist/client.js.map +1 -1
- package/dist/core.cjs +232 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +4 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +222 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +232 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +157 -0
- package/dist/index.d.ts +103 -56
- package/dist/index.js +222 -1
- package/dist/index.js.map +1 -1
- package/dist/infra.cjs +623 -0
- package/dist/infra.cjs.map +1 -0
- package/dist/infra.d.cts +119 -0
- package/dist/infra.d.ts +50 -102
- package/dist/infra.js +371 -144
- package/dist/infra.js.map +1 -1
- package/dist/react.cjs +1250 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +68 -0
- package/dist/react.d.ts +68 -3
- package/dist/react.js +1237 -1
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +2351 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +364 -0
- package/dist/server.d.ts +58 -313
- package/dist/server.js +1855 -1208
- package/dist/server.js.map +1 -1
- package/dist/{table-tN3D4Tm-.d.ts → table-BijJ73pX.d.ts} +2 -34
- package/dist/{types-ISMJfyIv.d.ts → types-LMLk8JVC.d.ts} +56 -30
- package/package.json +79 -41
- package/dist/chunk-2KC44Q5I.js +0 -1103
- package/dist/chunk-2KC44Q5I.js.map +0 -1
- package/dist/chunk-ZAJA4KBE.js +0 -150
- package/dist/chunk-ZAJA4KBE.js.map +0 -1
- package/dist/react-D_NLHuuO.d.ts +0 -556
package/README.md
CHANGED
|
@@ -1,96 +1,112 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dynamodb-reactive
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Type-safe DynamoDB query subscriptions with HTTP snapshots and versioned JSON Patch updates over WebSockets.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The application server owns authentication, router execution, and mutations. A router-agnostic Lambda consumes DynamoDB Streams and replays validated query metadata; it does not bundle or invoke application resolvers.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
**Key Features:**
|
|
10
|
-
|
|
11
|
-
* **Full TypeScript Support:** End-to-end type safety from schema definition to React hooks.
|
|
12
|
-
|
|
13
|
-
## **2. Installation**
|
|
7
|
+
## Requirements and installation
|
|
14
8
|
|
|
15
9
|
```bash
|
|
16
|
-
npm install dynamodb-reactive zod
|
|
17
|
-
# or
|
|
18
10
|
pnpm add dynamodb-reactive zod
|
|
19
11
|
```
|
|
20
12
|
|
|
21
|
-
|
|
13
|
+
Install optional peers for the entry points you use:
|
|
22
14
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
| `react` | React hooks |
|
|
27
|
-
| `aws-cdk-lib` | Infrastructure |
|
|
28
|
-
| `constructs` | Infrastructure |
|
|
15
|
+
```bash
|
|
16
|
+
# dynamodb-reactive/react
|
|
17
|
+
pnpm add react
|
|
29
18
|
|
|
30
|
-
|
|
19
|
+
# dynamodb-reactive/infra
|
|
20
|
+
pnpm add aws-cdk-lib constructs esbuild
|
|
21
|
+
```
|
|
31
22
|
|
|
32
|
-
|
|
33
|
-
| :--- | :--- |
|
|
34
|
-
| `dynamodb-reactive` | Core exports (DynamoTable, schemas) |
|
|
35
|
-
| `dynamodb-reactive/core` | Core table definitions |
|
|
36
|
-
| `dynamodb-reactive/server` | Server runtime (Router, handlers) |
|
|
37
|
-
| `dynamodb-reactive/client` | Frontend client |
|
|
38
|
-
| `dynamodb-reactive/react` | React hooks |
|
|
39
|
-
| `dynamodb-reactive/infra` | CDK constructs |
|
|
23
|
+
`zod` is always required. `react` is required only by `dynamodb-reactive/react`. `aws-cdk-lib`, `constructs`, and `esbuild` are required only by `dynamodb-reactive/infra`.
|
|
40
24
|
|
|
41
|
-
|
|
25
|
+
Every public entry point supplies ESM (`.js` and `.d.ts`) and CommonJS (`.cjs` and `.d.cts`) conditions, so both `import` and `require()` consumers are supported:
|
|
42
26
|
|
|
43
|
-
|
|
27
|
+
| Import | Contents |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `dynamodb-reactive` | Core table and schema exports |
|
|
30
|
+
| `dynamodb-reactive/core` | Same core API, explicitly named |
|
|
31
|
+
| `dynamodb-reactive/server` | Routers, database context, and HTTP/stream handlers |
|
|
32
|
+
| `dynamodb-reactive/client` | React-free client, WebSocket manager, and query-key helpers |
|
|
33
|
+
| `dynamodb-reactive/react` | Provider and React hooks |
|
|
34
|
+
| `dynamodb-reactive/infra` | `ReactiveEngine` and its configuration types |
|
|
44
35
|
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
React code must import the provider and hooks from `dynamodb-reactive/react`. Importing `dynamodb-reactive/client` does not load React.
|
|
37
|
+
|
|
38
|
+
## Define a table
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// todo-table.ts
|
|
47
42
|
import { DynamoTable } from 'dynamodb-reactive/core';
|
|
43
|
+
import { z } from 'zod';
|
|
48
44
|
|
|
49
45
|
export const TodoTable = new DynamoTable({
|
|
50
|
-
|
|
46
|
+
// This is the physical DynamoDB table name, not a logical CDK ID.
|
|
47
|
+
tableName: 'acme-todos',
|
|
51
48
|
schema: z.object({
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
pk: z.string(),
|
|
50
|
+
sk: z.string(),
|
|
51
|
+
ownerId: z.string(),
|
|
54
52
|
id: z.string(),
|
|
55
53
|
text: z.string(),
|
|
56
54
|
completed: z.boolean(),
|
|
57
55
|
createdAt: z.number(),
|
|
58
56
|
}),
|
|
59
|
-
pk: '
|
|
60
|
-
sk: '
|
|
57
|
+
pk: 'pk',
|
|
58
|
+
sk: 'sk',
|
|
59
|
+
indexes: {
|
|
60
|
+
byOwner: { name: 'byOwner', pk: 'ownerId', sk: 'createdAt' },
|
|
61
|
+
},
|
|
61
62
|
});
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
`DynamoTable` is a runtime/type descriptor used for validation and query planning. It does not create a DynamoDB table or GSI. Provision a physical table separately, with an explicit `tableName` exactly equal to `TodoTable.tableName` and indexes matching the descriptor.
|
|
66
|
+
|
|
67
|
+
## Define the router
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// router.ts
|
|
71
|
+
import { randomUUID } from 'node:crypto';
|
|
65
72
|
|
|
66
|
-
```typescript
|
|
67
|
-
import { z } from 'zod';
|
|
68
73
|
import { initReactive } from 'dynamodb-reactive/server';
|
|
69
|
-
import {
|
|
74
|
+
import { z } from 'zod';
|
|
75
|
+
|
|
76
|
+
import { TodoTable } from './todo-table.js';
|
|
77
|
+
|
|
78
|
+
export interface AppContext {
|
|
79
|
+
principalId: string;
|
|
80
|
+
}
|
|
70
81
|
|
|
71
|
-
type AppContext = Record<string, unknown>;
|
|
72
82
|
const t = initReactive<AppContext>();
|
|
73
83
|
|
|
74
84
|
export const appRouter = t.router({
|
|
75
85
|
todos: {
|
|
76
|
-
// Query procedure
|
|
77
86
|
list: t.procedure
|
|
78
|
-
.input(z.object({
|
|
79
|
-
.query(
|
|
87
|
+
.input(z.object({ ownerId: z.string() }))
|
|
88
|
+
.query(({ ctx, input }) => {
|
|
89
|
+
if (input.ownerId !== ctx.principalId) {
|
|
90
|
+
throw new Error('Forbidden');
|
|
91
|
+
}
|
|
80
92
|
return ctx.db
|
|
81
93
|
.query(TodoTable)
|
|
82
|
-
.
|
|
94
|
+
.useIndex('byOwner')
|
|
95
|
+
.filter((q) => q.eq(TodoTable.field.ownerId, input.ownerId))
|
|
96
|
+
.sortDescending()
|
|
97
|
+
.take(100)
|
|
83
98
|
.execute();
|
|
84
99
|
}),
|
|
85
100
|
|
|
86
|
-
// Mutation procedure
|
|
87
101
|
create: t.procedure
|
|
88
|
-
.input(z.object({ text: z.string() }))
|
|
102
|
+
.input(z.object({ text: z.string().min(1) }))
|
|
89
103
|
.mutation(async ({ ctx, input }) => {
|
|
104
|
+
const id = randomUUID();
|
|
90
105
|
const item = {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
pk: `USER#${ctx.principalId}`,
|
|
107
|
+
sk: `TODO#${id}`,
|
|
108
|
+
ownerId: ctx.principalId,
|
|
109
|
+
id,
|
|
94
110
|
text: input.text,
|
|
95
111
|
completed: false,
|
|
96
112
|
createdAt: Date.now(),
|
|
@@ -98,46 +114,206 @@ export const appRouter = t.router({
|
|
|
98
114
|
await ctx.db.put(TodoTable, item);
|
|
99
115
|
return item;
|
|
100
116
|
}),
|
|
117
|
+
|
|
118
|
+
toggle: t.procedure
|
|
119
|
+
.input(z.object({ id: z.string(), completed: z.boolean() }))
|
|
120
|
+
.mutation(({ ctx, input }) =>
|
|
121
|
+
ctx.db.update(
|
|
122
|
+
TodoTable,
|
|
123
|
+
{ pk: `USER#${ctx.principalId}`, sk: `TODO#${input.id}` },
|
|
124
|
+
{ completed: input.completed },
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
|
|
128
|
+
delete: t.procedure
|
|
129
|
+
.input(z.object({ id: z.string() }))
|
|
130
|
+
.mutation(async ({ ctx, input }) => {
|
|
131
|
+
await ctx.db.delete(TodoTable, {
|
|
132
|
+
pk: `USER#${ctx.principalId}`,
|
|
133
|
+
sk: `TODO#${input.id}`,
|
|
134
|
+
});
|
|
135
|
+
return { id: input.id };
|
|
136
|
+
}),
|
|
101
137
|
},
|
|
102
138
|
});
|
|
103
139
|
|
|
104
140
|
export type AppRouter = typeof appRouter;
|
|
105
141
|
```
|
|
106
142
|
|
|
107
|
-
###
|
|
143
|
+
### Subscription query contract
|
|
144
|
+
|
|
145
|
+
A query procedure used as a subscription must:
|
|
146
|
+
|
|
147
|
+
- execute exactly one `ctx.db.query(...)`;
|
|
148
|
+
- call `.execute()`;
|
|
149
|
+
- return that array unchanged.
|
|
150
|
+
|
|
151
|
+
Mapping, filtering, wrapping, or otherwise transforming the result is rejected. Multiple queries and `ctx.db.get()` are also rejected for subscriptions. Use `handler.query(...)` for one-time reads, transform data in client presentation code, or redesign the stored model/query when one replayable query cannot express the subscription. Mutations are separate procedures and may use `get`, `put`, `update`, and `delete`.
|
|
152
|
+
|
|
153
|
+
The query builder supports `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `beginsWith`, `contains`, `and`, `or`, and `not`. Exact primitive equality and nonempty `beginsWith` prefixes up to 64 characters produce targeted dependencies when the complete filter is safely trackable. Unfiltered queries, ranges, `contains`, `not`, null equality, long prefixes, and other unsafe plans fall back to table-wide wildcard invalidation. This costs more replays but preserves correctness.
|
|
154
|
+
|
|
155
|
+
Replay uses strongly consistent PartiQL reads against the base table. `useIndex()` records sparse-index membership with `attribute_exists` checks and selects that index's sort key; it does not replay against the eventually consistent GSI itself. Sorting requires the base table or selected index to have a sort key. A sorted query reads all pages, sorts by that key, and then applies `.take(limit)`; an unsorted limited query can stop once enough items have been read and has no stable order.
|
|
156
|
+
|
|
157
|
+
## Provision AWS resources
|
|
158
|
+
|
|
159
|
+
Create the application table yourself and bind it to `ReactiveEngine`:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// stack.ts
|
|
163
|
+
import * as cdk from 'aws-cdk-lib';
|
|
164
|
+
import * as authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
|
|
165
|
+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
|
166
|
+
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
167
|
+
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
168
|
+
import { Construct } from 'constructs';
|
|
169
|
+
import { ReactiveEngine } from 'dynamodb-reactive/infra';
|
|
170
|
+
|
|
171
|
+
import { TodoTable } from './todo-table.js';
|
|
172
|
+
|
|
173
|
+
export class AppStack extends cdk.Stack {
|
|
174
|
+
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
|
175
|
+
super(scope, id, props);
|
|
176
|
+
|
|
177
|
+
const table = new dynamodb.Table(this, 'Todos', {
|
|
178
|
+
tableName: TodoTable.tableName,
|
|
179
|
+
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
|
180
|
+
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
|
|
181
|
+
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
|
|
182
|
+
});
|
|
183
|
+
table.addGlobalSecondaryIndex({
|
|
184
|
+
indexName: TodoTable.indexes.byOwner.name,
|
|
185
|
+
partitionKey: {
|
|
186
|
+
name: TodoTable.indexes.byOwner.pk,
|
|
187
|
+
type: dynamodb.AttributeType.STRING,
|
|
188
|
+
},
|
|
189
|
+
sortKey: {
|
|
190
|
+
name: TodoTable.indexes.byOwner.sk,
|
|
191
|
+
type: dynamodb.AttributeType.NUMBER,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const authorizerFunction = new lambda.Function(this, 'ConnectAuth', {
|
|
196
|
+
runtime: lambda.Runtime.NODEJS_24_X,
|
|
197
|
+
handler: 'index.handler',
|
|
198
|
+
code: lambda.Code.fromAsset('dist/connect-authorizer'),
|
|
199
|
+
});
|
|
200
|
+
const connectAuthorizer = new authorizers.WebSocketLambdaAuthorizer(
|
|
201
|
+
'ConnectAuthorizer',
|
|
202
|
+
authorizerFunction,
|
|
203
|
+
{ identitySource: ['route.request.querystring.token'] },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const engine = new ReactiveEngine(this, 'Reactive', {
|
|
207
|
+
resourcePrefix: 'acme-prod',
|
|
208
|
+
tables: [{ definition: TodoTable, table }],
|
|
209
|
+
connectAuthorizer,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const appRole = new iam.Role(this, 'ApplicationRole', {
|
|
213
|
+
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
214
|
+
});
|
|
215
|
+
engine.grantApplicationAccess(appRole);
|
|
216
|
+
|
|
217
|
+
new cdk.CfnOutput(this, 'ClientWebSocketUrl', {
|
|
218
|
+
value: engine.webSocketUrl,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The authorizer must return an authorizer context containing the stable principal identifier that the HTTP handler will compare. `ReactiveEngine` requires a real `connectAuthorizer` by default. `allowUnauthenticatedConnections: true` is an explicit alternative intended only for local or disposable development.
|
|
225
|
+
|
|
226
|
+
The application role running `createReactiveHandler` needs the system-table and application-table permissions installed by `engine.grantApplicationAccess(appRole)`. The engine's generated stream Lambda is router-agnostic and receives only the table access it needs.
|
|
227
|
+
|
|
228
|
+
`dynamodb-reactive/infra` intentionally exposes only `ReactiveEngine`, `ReactiveEngineProps`, and `ReactiveTableBinding`. User tables are supplied through the constructor's `tables` bindings; stream mappings and system tables are managed by the engine rather than separate public helpers.
|
|
229
|
+
|
|
230
|
+
System tables use `RemovalPolicy.RETAIN` by default. Removing or replacing a stack therefore leaves fixed-name tables behind; importing, manually deleting, or choosing `RemovalPolicy.DESTROY` for a disposable environment is necessary before recreating the same prefix. Run CDK synth/deploy from the application package root where both `dynamodb-reactive` and `esbuild` are resolvable: the construct writes its generated entry under `<process.cwd()>/cdk.out/.generated` and `NodejsFunction` bundles it from there. The construct emits a WebSocket URL output; the explicit output above gives it an application-specific name.
|
|
231
|
+
|
|
232
|
+
Version 2 provisions `<prefix>-ReactiveConnectionsV2`, `<prefix>-ReactiveDependenciesV2`, and `<prefix>-ReactiveConnectionQueriesV2`. These are deliberately new tables: 0.1 subscription state is incompatible and is not read or migrated.
|
|
108
233
|
|
|
109
|
-
|
|
110
|
-
import { createReactiveHandler } from 'dynamodb-reactive/server';
|
|
111
|
-
import { appRouter } from './router';
|
|
234
|
+
## Expose the HTTP handler
|
|
112
235
|
|
|
113
|
-
|
|
236
|
+
The HTTP principal must own the WebSocket connection named in the request. The connect Lambda stores `requestContext.authorizer` on the connection record; `authorizeConnection` must compare it with independently authenticated HTTP context.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
// reactive-api.ts
|
|
240
|
+
import {
|
|
241
|
+
createReactiveHandler,
|
|
242
|
+
type ReactiveRequest,
|
|
243
|
+
} from 'dynamodb-reactive/server';
|
|
244
|
+
import { z } from 'zod';
|
|
245
|
+
|
|
246
|
+
import { authenticateBearer } from './auth.js';
|
|
247
|
+
import { appRouter, type AppContext } from './router.js';
|
|
248
|
+
|
|
249
|
+
const RESOURCE_PREFIX = 'acme-prod';
|
|
250
|
+
|
|
251
|
+
export const reactive = createReactiveHandler<AppContext, typeof appRouter.definition>({
|
|
114
252
|
router: appRouter,
|
|
115
|
-
|
|
116
|
-
getContext: async () => ({
|
|
253
|
+
tablePrefix: RESOURCE_PREFIX,
|
|
254
|
+
getContext: async ({ headers }) => ({
|
|
255
|
+
principalId: await authenticateBearer(headers?.authorization),
|
|
256
|
+
}),
|
|
257
|
+
authorizeConnection: ({ context, connection }) =>
|
|
258
|
+
typeof connection.context?.principalId === 'string' &&
|
|
259
|
+
connection.context.principalId === context.principalId,
|
|
117
260
|
});
|
|
118
261
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
262
|
+
const requestSchema: z.ZodType<ReactiveRequest> = z.discriminatedUnion('type', [
|
|
263
|
+
z.object({
|
|
264
|
+
type: z.literal('subscribe'),
|
|
265
|
+
connectionId: z.string(),
|
|
266
|
+
subscriptionId: z.string(),
|
|
267
|
+
generation: z.number().int().nonnegative(),
|
|
268
|
+
registrationId: z.string(),
|
|
269
|
+
path: z.string(),
|
|
270
|
+
input: z.unknown(),
|
|
271
|
+
}),
|
|
272
|
+
z.object({
|
|
273
|
+
type: z.literal('unsubscribe'),
|
|
274
|
+
connectionId: z.string(),
|
|
275
|
+
subscriptionId: z.string(),
|
|
276
|
+
generation: z.number().int().nonnegative(),
|
|
277
|
+
registrationId: z.string(),
|
|
278
|
+
}),
|
|
279
|
+
z.object({
|
|
280
|
+
type: z.literal('call'),
|
|
281
|
+
connectionId: z.string(),
|
|
282
|
+
path: z.string(),
|
|
283
|
+
input: z.unknown(),
|
|
284
|
+
}),
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
export async function POST(request: Request): Promise<Response> {
|
|
288
|
+
const body = requestSchema.parse(await request.json());
|
|
289
|
+
const headers = Object.fromEntries(request.headers.entries());
|
|
290
|
+
const result = await reactive.handleRequest(body, headers);
|
|
291
|
+
return Response.json(result);
|
|
124
292
|
}
|
|
125
293
|
```
|
|
126
294
|
|
|
127
|
-
|
|
295
|
+
`getContext` and `authorizeConnection` are required. Validate untrusted JSON at the framework boundary before calling `handleRequest(body, headers)`. The server performs additional key and procedure validation, but its TypeScript type is not an HTTP parser. `tablePrefix` must exactly equal the CDK `resourcePrefix`, or the application and engine will use different system tables.
|
|
296
|
+
|
|
297
|
+
`registerConnection` and `unregisterConnection` are exposed for custom/non-CDK transports that manage their own connection lifecycle. API Gateway resources created by `ReactiveEngine` already invoke connect/disconnect handlers, so application HTTP routes should not call them.
|
|
298
|
+
|
|
299
|
+
## React
|
|
300
|
+
|
|
301
|
+
The provider owns connection setup and teardown. Supply both the deployed WebSocket URL and the HTTP route:
|
|
128
302
|
|
|
129
|
-
```
|
|
130
|
-
//
|
|
303
|
+
```tsx
|
|
304
|
+
// providers.tsx
|
|
131
305
|
'use client';
|
|
132
306
|
|
|
133
|
-
import {
|
|
307
|
+
import type { ReactNode } from 'react';
|
|
308
|
+
import { ReactiveClientProvider } from 'dynamodb-reactive/react';
|
|
134
309
|
|
|
135
|
-
export function Providers({ children }: { children:
|
|
310
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
136
311
|
return (
|
|
137
312
|
<ReactiveClientProvider
|
|
138
313
|
config={{
|
|
139
|
-
url: process.env.
|
|
314
|
+
url: process.env.NEXT_PUBLIC_REACTIVE_WS_URL!,
|
|
140
315
|
httpUrl: '/api/reactive',
|
|
316
|
+
auth: () => window.localStorage.getItem('access-token') ?? '',
|
|
141
317
|
}}
|
|
142
318
|
>
|
|
143
319
|
{children}
|
|
@@ -146,119 +322,168 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|
|
146
322
|
}
|
|
147
323
|
```
|
|
148
324
|
|
|
149
|
-
|
|
325
|
+
Use `useQuery` for a reactive query and `useMutation` for a mutation:
|
|
150
326
|
|
|
151
|
-
```
|
|
327
|
+
```tsx
|
|
328
|
+
// todos.tsx
|
|
152
329
|
'use client';
|
|
153
330
|
|
|
154
|
-
import
|
|
155
|
-
import type { AppRouter } from './router';
|
|
156
|
-
import { useClient, useConnectionState } from 'dynamodb-reactive/client';
|
|
157
|
-
|
|
158
|
-
export function TodoList() {
|
|
159
|
-
const connectionState = useConnectionState();
|
|
160
|
-
const client = useClient<AppRouter>();
|
|
161
|
-
|
|
162
|
-
// Subscribe to todos - receives real-time updates via WebSocket
|
|
163
|
-
const { data: todos, loading, error } = client.todos.list.useQuery({});
|
|
164
|
-
|
|
165
|
-
// Mutations with automatic optimistic updates
|
|
166
|
-
const createMutation = client.todos.create.useMutation();
|
|
167
|
-
const toggleMutation = client.todos.toggle.useMutation();
|
|
168
|
-
const deleteMutation = client.todos.delete.useMutation();
|
|
331
|
+
import { useClient, useConnectionState } from 'dynamodb-reactive/react';
|
|
169
332
|
|
|
170
|
-
|
|
171
|
-
await createMutation.mutate({ text });
|
|
172
|
-
};
|
|
333
|
+
import type { AppRouter } from './router.js';
|
|
173
334
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
335
|
+
export function Todos({ ownerId }: { ownerId: string }) {
|
|
336
|
+
const client = useClient<AppRouter>();
|
|
337
|
+
const connection = useConnectionState();
|
|
338
|
+
const todos = client.todos.list.useQuery({ ownerId });
|
|
339
|
+
const target = { path: 'todos.list', input: { ownerId } };
|
|
340
|
+
|
|
341
|
+
const createTodo = client.todos.create.useMutation({
|
|
342
|
+
invalidates: target,
|
|
343
|
+
optimisticData: (input) => {
|
|
344
|
+
const id = `temporary-${Date.now()}`;
|
|
345
|
+
return {
|
|
346
|
+
pk: `USER#${ownerId}`,
|
|
347
|
+
sk: `TODO#${id}`,
|
|
348
|
+
ownerId,
|
|
349
|
+
id,
|
|
350
|
+
text: input.text,
|
|
351
|
+
completed: false,
|
|
352
|
+
createdAt: Date.now(),
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
const toggleTodo = client.todos.toggle.useMutation({
|
|
357
|
+
invalidates: target,
|
|
358
|
+
optimisticUpdate: 'merge',
|
|
359
|
+
});
|
|
360
|
+
const deleteTodo = client.todos.delete.useMutation({
|
|
361
|
+
invalidates: target,
|
|
362
|
+
optimisticUpdate: 'remove',
|
|
363
|
+
});
|
|
181
364
|
|
|
182
|
-
if (loading) return <
|
|
183
|
-
if (error) return <
|
|
365
|
+
if (todos.loading && !todos.data) return <p>Loading...</p>;
|
|
366
|
+
if (todos.error) return <p>{todos.error.message}</p>;
|
|
184
367
|
|
|
185
368
|
return (
|
|
186
|
-
<
|
|
187
|
-
<p>
|
|
188
|
-
{
|
|
369
|
+
<section aria-busy={todos.loading || todos.stale}>
|
|
370
|
+
<p>Connection: {connection}</p>
|
|
371
|
+
<button onClick={() => void createTodo.mutate({ text: 'New todo' })}>
|
|
372
|
+
Add
|
|
373
|
+
</button>
|
|
374
|
+
{todos.data?.map((todo) => (
|
|
189
375
|
<div key={todo.id}>
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
376
|
+
<button
|
|
377
|
+
onClick={() =>
|
|
378
|
+
void toggleTodo.mutate({
|
|
379
|
+
id: todo.id,
|
|
380
|
+
completed: !todo.completed,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
>
|
|
384
|
+
{todo.completed ? 'Undo' : 'Complete'}
|
|
385
|
+
</button>
|
|
195
386
|
<span>{todo.text}</span>
|
|
196
|
-
<button onClick={() =>
|
|
387
|
+
<button onClick={() => void deleteTodo.mutate({ id: todo.id })}>
|
|
388
|
+
Delete
|
|
389
|
+
</button>
|
|
197
390
|
</div>
|
|
198
391
|
))}
|
|
199
|
-
</
|
|
392
|
+
</section>
|
|
200
393
|
);
|
|
201
394
|
}
|
|
202
395
|
```
|
|
203
396
|
|
|
204
|
-
|
|
397
|
+
`useQuery` returns `data`, `loading`, `stale`, `error`, `disabled`, `refetch`, and `setData`. Existing data may remain visible while `stale` and `loading` are true during reconnect or resync. `useMutation` returns `mutate`, `data`, `loading`, `error`, `disabled`, and `reset`.
|
|
205
398
|
|
|
206
|
-
|
|
399
|
+
Mutation targeting is explicit:
|
|
207
400
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
| `'remove'` | Remove item by `input.id` | `{ id: '123' }` |
|
|
212
|
-
| Custom function | Apply custom logic | `(data, input) => [...]` |
|
|
401
|
+
- `invalidates: 'todos.list'` targets every active query with that path.
|
|
402
|
+
- `invalidates: { path: 'todos.list', input: { ownerId } }` is a `QueryTarget` for exactly that canonical path/input pair.
|
|
403
|
+
- `queryKey` targets one exact canonical query key, obtained from an imperative subscription's `.key` or `getQueryKey(path, input)` from `dynamodb-reactive/client`. It takes precedence over `invalidates`.
|
|
213
404
|
|
|
214
|
-
|
|
405
|
+
`optimisticUpdate: 'merge'` merges input/result fields into array items with the same `id`; `'remove'` removes that `id`; a function can implement other array changes. `optimisticData` supplies a temporary create result. Optimistic layers are rolled back on failure and reconciled with a fresh authoritative snapshot after success.
|
|
215
406
|
|
|
216
|
-
|
|
217
|
-
```typescript
|
|
218
|
-
// For instant optimistic updates, include the fields you want to change
|
|
219
|
-
const handleToggle = async (todo: Todo) => {
|
|
220
|
-
await updateMutation.mutate({
|
|
221
|
-
id: todo.id,
|
|
222
|
-
completed: !todo.completed // Include new value for instant UI update
|
|
223
|
-
});
|
|
224
|
-
};
|
|
225
|
-
```
|
|
407
|
+
Equal `(path, input)` pairs share one canonical subscription and cache entry even when object keys were inserted in different orders. Leases are reference-counted, and the server registration is removed only after the final observer unsubscribes.
|
|
226
408
|
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
const mutation = client.todos.update.useMutation({
|
|
230
|
-
invalidates: 'todos.list', // Override auto-detection
|
|
231
|
-
optimisticUpdate: 'merge', // 'merge' | 'remove' | custom function
|
|
232
|
-
});
|
|
409
|
+
## React-free client
|
|
233
410
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
411
|
+
The raw typed client uses imperative `useSubscription` and `mutate` methods. Connect it before operations and disconnect it when its owner is disposed:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import { createReactiveClient } from 'dynamodb-reactive/client';
|
|
415
|
+
|
|
416
|
+
import type { AppRouter } from './router.js';
|
|
417
|
+
|
|
418
|
+
const client = createReactiveClient<AppRouter>({
|
|
419
|
+
url: 'wss://example.execute-api.us-east-1.amazonaws.com/prod',
|
|
420
|
+
httpUrl: 'https://app.example.com/api/reactive',
|
|
421
|
+
auth: async () => 'short-lived-access-token',
|
|
242
422
|
});
|
|
423
|
+
|
|
424
|
+
await client.connect();
|
|
425
|
+
const subscription = client.todos.list.useSubscription(
|
|
426
|
+
{ ownerId: 'user-123' },
|
|
427
|
+
{
|
|
428
|
+
onData: (items) => {
|
|
429
|
+
document.body.dataset.todoCount = String(items.length);
|
|
430
|
+
},
|
|
431
|
+
onError: (error) => {
|
|
432
|
+
document.body.dataset.reactiveError = error.message;
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
await client.todos.toggle.mutate(
|
|
439
|
+
{ id: 'todo-123', completed: true },
|
|
440
|
+
{
|
|
441
|
+
queryKey: subscription.key,
|
|
442
|
+
optimisticUpdate: 'merge',
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
} finally {
|
|
446
|
+
subscription.unsubscribe();
|
|
447
|
+
client.disconnect();
|
|
448
|
+
}
|
|
243
449
|
```
|
|
244
450
|
|
|
245
|
-
|
|
451
|
+
The returned subscription exposes live getters for `data`, `loading`, `stale`, and `error`, plus `key`, `unsubscribe()`, and `refetch()`.
|
|
452
|
+
|
|
453
|
+
## Delivery model
|
|
246
454
|
|
|
247
|
-
The
|
|
455
|
+
1. The client establishes a WebSocket and receives its API Gateway connection ID.
|
|
456
|
+
2. A subscribe request goes over HTTP. The application authenticates the HTTP caller, verifies connection ownership, runs the one allowed query to capture metadata, establishes a strong baseline replay, and returns a versioned HTTP snapshot.
|
|
457
|
+
3. DynamoDB Streams emit `NEW_AND_OLD_IMAGES`. The generic Lambda finds exact/prefix dependencies plus wildcard subscriptions, replays validated metadata with strong base-table reads, persists the new result/version and a pending delivery, then sends a versioned WebSocket patch.
|
|
458
|
+
4. The client buffers patches that arrive before their snapshot, applies contiguous versions, ignores duplicate message IDs/stale versions, and requests a new HTTP snapshot for gaps, invalid patches, or a server `resync` instruction.
|
|
248
459
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
460
|
+
Pending delivery is durable until a send completes, so retries are at least once. A send followed by a failed clear can produce a duplicate; clients must keep duplicate handling enabled. Patch messages larger than 30 KiB become a small `resync` instruction rather than a WebSocket payload. Serialized subscription state is limited to 300 KiB.
|
|
461
|
+
|
|
462
|
+
Connection and subscription records receive three-hour TTLs. Each authorized HTTP operation refreshes the connection lease; registration and re-registration renew the query and dependency records. The browser sends a WebSocket heartbeat every five minutes, but API Gateway can still close idle or maximum-age connections; the client reconnects and re-registers active subscriptions. DynamoDB TTL removes expired records asynchronously and is cleanup, not an authorization or immediate-deletion mechanism. HTTP ownership checks explicitly enforce connection status and lease expiry.
|
|
463
|
+
|
|
464
|
+
## Security
|
|
465
|
+
|
|
466
|
+
- Authenticate `$connect`; unauthenticated mode is an explicit development choice.
|
|
467
|
+
- Authenticate every HTTP request independently and compare its principal with the stored WebSocket authorizer principal in `authorizeConnection`.
|
|
468
|
+
- The `auth` client option sends `Authorization: Bearer ...` over HTTP and appends `token=...` to the WebSocket URL because browser WebSockets cannot set arbitrary headers. Use TLS, short-lived tokens, and infrastructure/logging that redacts query strings, or use a secure cookie/custom transport where appropriate.
|
|
469
|
+
- Do not log authorization headers, tokens, authorizer context, full request inputs, or sensitive DynamoDB items. Redact errors before forwarding them to untrusted clients where necessary.
|
|
470
|
+
- The stream Lambda validates versioned query metadata and cannot invoke arbitrary router code. Keep procedures and application authorization in the HTTP runtime.
|
|
471
|
+
- Use `grantApplicationAccess` for the application runtime and retain least-privilege roles; do not grant broad account-wide DynamoDB or API Gateway permissions.
|
|
472
|
+
|
|
473
|
+
## Verification and release checks
|
|
474
|
+
|
|
475
|
+
From the repository root:
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
pnpm verify
|
|
479
|
+
pnpm package:check
|
|
480
|
+
pnpm package:smoke
|
|
481
|
+
```
|
|
256
482
|
|
|
257
|
-
|
|
483
|
+
`pnpm verify` runs lint, the workspace typecheck, dual-format build, unit/CDK tests, artifact checks, and package consumer smokes. `package:check` packs the public artifact and checks its contents, export conditions, `publint`, and Are The Types Wrong. `package:smoke` installs the tarball into runtime and TypeScript/React/CDK consumers and executes them.
|
|
258
484
|
|
|
259
|
-
|
|
260
|
-
* TypeScript >= 5.3.0
|
|
485
|
+
Pull-request and release CI run source verification on Node 24. Release CI also verifies the tag/version and npm availability, packs one exact tarball, reruns artifact checks and clean-consumer smokes against that tarball, and publishes it with provenance. Generated AWS Lambda functions use the Node.js 24 runtime.
|
|
261
486
|
|
|
262
|
-
##
|
|
487
|
+
## License
|
|
263
488
|
|
|
264
489
|
MIT
|