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.
Files changed (46) hide show
  1. package/README.md +378 -153
  2. package/dist/client-r_tnzwqi.d.ts +224 -0
  3. package/dist/client.cjs +1023 -0
  4. package/dist/client.cjs.map +1 -0
  5. package/dist/client.d.cts +6 -0
  6. package/dist/client.d.ts +4 -70
  7. package/dist/client.js +1013 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/core.cjs +232 -0
  10. package/dist/core.cjs.map +1 -0
  11. package/dist/core.d.cts +4 -0
  12. package/dist/core.d.ts +3 -3
  13. package/dist/core.js +222 -1
  14. package/dist/core.js.map +1 -1
  15. package/dist/index.cjs +232 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.cts +157 -0
  18. package/dist/index.d.ts +103 -56
  19. package/dist/index.js +222 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/infra.cjs +623 -0
  22. package/dist/infra.cjs.map +1 -0
  23. package/dist/infra.d.cts +119 -0
  24. package/dist/infra.d.ts +50 -102
  25. package/dist/infra.js +371 -144
  26. package/dist/infra.js.map +1 -1
  27. package/dist/react.cjs +1250 -0
  28. package/dist/react.cjs.map +1 -0
  29. package/dist/react.d.cts +68 -0
  30. package/dist/react.d.ts +68 -3
  31. package/dist/react.js +1237 -1
  32. package/dist/react.js.map +1 -1
  33. package/dist/server.cjs +2351 -0
  34. package/dist/server.cjs.map +1 -0
  35. package/dist/server.d.cts +364 -0
  36. package/dist/server.d.ts +58 -313
  37. package/dist/server.js +1855 -1208
  38. package/dist/server.js.map +1 -1
  39. package/dist/{table-tN3D4Tm-.d.ts → table-BijJ73pX.d.ts} +2 -34
  40. package/dist/{types-ISMJfyIv.d.ts → types-LMLk8JVC.d.ts} +56 -30
  41. package/package.json +79 -41
  42. package/dist/chunk-2KC44Q5I.js +0 -1103
  43. package/dist/chunk-2KC44Q5I.js.map +0 -1
  44. package/dist/chunk-ZAJA4KBE.js +0 -150
  45. package/dist/chunk-ZAJA4KBE.js.map +0 -1
  46. package/dist/react-D_NLHuuO.d.ts +0 -556
package/README.md CHANGED
@@ -1,96 +1,112 @@
1
- # **dynamodb-reactive**
1
+ # dynamodb-reactive
2
2
 
3
- **Tagline:** A Serverless, Reactive tRPC replacement for AWS - unified package.
3
+ Type-safe DynamoDB query subscriptions with HTTP snapshots and versioned JSON Patch updates over WebSockets.
4
4
 
5
- ## **1. Overview**
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
- `dynamodb-reactive` provides type-safe, real-time DynamoDB subscriptions with automatic JSON Patch diffing over WebSockets.
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
- **Peer Dependencies (install as needed):**
13
+ Install optional peers for the entry points you use:
22
14
 
23
- | Dependency | Required For |
24
- | :--- | :--- |
25
- | `zod` | Schema definitions (required) |
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
- ## **3. Package Exports**
19
+ # dynamodb-reactive/infra
20
+ pnpm add aws-cdk-lib constructs esbuild
21
+ ```
31
22
 
32
- | Import Path | Purpose |
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
- ## **4. Quick Start**
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
- ### **Step 1: Define Your Schema**
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
- ```typescript
46
- import { z } from 'zod';
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
- tableName: 'my-table',
46
+ // This is the physical DynamoDB table name, not a logical CDK ID.
47
+ tableName: 'acme-todos',
51
48
  schema: z.object({
52
- PK: z.string(),
53
- SK: z.string(),
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: 'PK',
60
- sk: '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
- ### **Step 2: Create the Router**
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 { TodoTable } from './schema';
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({}).optional())
79
- .query(async ({ ctx }) => {
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
- .filter((q) => q.eq(TodoTable.field.PK, 'TODO'))
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
- PK: 'TODO',
92
- SK: Date.now().toString(),
93
- id: Date.now().toString(),
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
- ### **Step 3: Create API Handler**
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
- ```typescript
110
- import { createReactiveHandler } from 'dynamodb-reactive/server';
111
- import { appRouter } from './router';
234
+ ## Expose the HTTP handler
112
235
 
113
- export const handler = createReactiveHandler({
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
- dbConfig: { region: 'us-east-1' },
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
- // In Next.js API route:
120
- export async function POST(request: Request) {
121
- const body = await request.json();
122
- const response = await handler.handleRequest('client-id', body);
123
- return Response.json(response);
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
- ### **Step 4: Set Up React Provider**
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
- ```typescript
130
- // app/layout.tsx or providers.tsx
303
+ ```tsx
304
+ // providers.tsx
131
305
  'use client';
132
306
 
133
- import { ReactiveClientProvider } from 'dynamodb-reactive/client';
307
+ import type { ReactNode } from 'react';
308
+ import { ReactiveClientProvider } from 'dynamodb-reactive/react';
134
309
 
135
- export function Providers({ children }: { children: React.ReactNode }) {
310
+ export function Providers({ children }: { children: ReactNode }) {
136
311
  return (
137
312
  <ReactiveClientProvider
138
313
  config={{
139
- url: process.env.NEXT_PUBLIC_WS_URL!,
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
- ### **Step 5: Use React Hooks**
325
+ Use `useQuery` for a reactive query and `useMutation` for a mutation:
150
326
 
151
- ```typescript
327
+ ```tsx
328
+ // todos.tsx
152
329
  'use client';
153
330
 
154
- import type { Todo } from './types';
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
- const handleCreate = async (text: string) => {
171
- await createMutation.mutate({ text });
172
- };
333
+ import type { AppRouter } from './router.js';
173
334
 
174
- const handleToggle = async (id: string) => {
175
- await toggleMutation.mutate({ id });
176
- };
177
-
178
- const handleDelete = async (id: string) => {
179
- await deleteMutation.mutate({ id }); // Removes instantly from UI
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 <div>Loading...</div>;
183
- if (error) return <div>Error: {error.message}</div>;
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
- <div>
187
- <p>WebSocket: {connectionState}</p>
188
- {todos?.map((todo) => (
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
- <input
191
- type="checkbox"
192
- checked={todo.completed}
193
- onChange={() => handleToggle(todo.id)}
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={() => handleDelete(todo.id)}>Delete</button>
387
+ <button onClick={() => void deleteTodo.mutate({ id: todo.id })}>
388
+ Delete
389
+ </button>
197
390
  </div>
198
391
  ))}
199
- </div>
392
+ </section>
200
393
  );
201
394
  }
202
395
  ```
203
396
 
204
- ## **5. Optimistic Updates**
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
- All mutations apply **instant** optimistic updates (before HTTP request):
399
+ Mutation targeting is explicit:
207
400
 
208
- | Update Type | Behavior | Example Input |
209
- | :--- | :--- | :--- |
210
- | `'merge'` | Find item by `input.id`, merge input fields | `{ id: '123', completed: true }` |
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
- **Convention:** `<namespace>.<action>` mutations auto-invalidate `<namespace>.list` subscriptions.
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
- **Example - Instant Toggle:**
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
- **Custom options:**
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
- // For creates (no id), provide optimisticData
235
- const createMutation = client.todos.create.useMutation({
236
- optimisticData: (input) => ({
237
- id: `temp-${Date.now()}`,
238
- ...input,
239
- completed: false,
240
- createdAt: Date.now(),
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
- ## **6. Database Context Methods**
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 `ctx.db` object provides these methods:
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
- | Method | Description |
250
- | :--- | :--- |
251
- | `query(table).filter(...).execute()` | Query with filters |
252
- | `get(table, key)` | Get single item by key |
253
- | `put(table, item)` | Create/replace item |
254
- | `update(table, key, updates)` | Update item fields |
255
- | `delete(table, key)` | Delete item |
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
- ## **7. Requirements**
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
- * Node.js >= 18.0.0
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
- ## **8. License**
487
+ ## License
263
488
 
264
489
  MIT