@outfitter/state 0.1.0-rc.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 ADDED
@@ -0,0 +1,348 @@
1
+ # @outfitter/state
2
+
3
+ Pagination cursor persistence and ephemeral state management for CLI and MCP workflows.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @outfitter/state
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import {
15
+ createCursor,
16
+ createCursorStore,
17
+ createScopedStore,
18
+ advanceCursor,
19
+ } from "@outfitter/state";
20
+
21
+ // Create in-memory cursor store
22
+ const store = createCursorStore();
23
+
24
+ // Create a cursor for pagination
25
+ const cursorResult = createCursor({
26
+ position: 0,
27
+ metadata: { query: "status:open" },
28
+ ttl: 60 * 60 * 1000, // 1 hour expiry
29
+ });
30
+
31
+ if (cursorResult.isOk()) {
32
+ const cursor = cursorResult.value;
33
+ store.set(cursor);
34
+
35
+ // Later: advance cursor position
36
+ const advanced = advanceCursor(cursor, 10);
37
+ store.set(advanced);
38
+ }
39
+ ```
40
+
41
+ ## Cursor Design
42
+
43
+ Cursors are intentionally **opaque** to consumers. They are immutable, frozen objects that encapsulate pagination state.
44
+
45
+ | Property | Type | Description |
46
+ |----------|------|-------------|
47
+ | `id` | `string` | Unique identifier for storage lookup |
48
+ | `position` | `number` | Current offset in the result set |
49
+ | `metadata` | `Record<string, unknown>` | Optional user-defined context |
50
+ | `ttl` | `number` | Time-to-live in milliseconds (optional) |
51
+ | `expiresAt` | `number` | Computed Unix timestamp for expiry (if TTL set) |
52
+ | `createdAt` | `number` | Unix timestamp when cursor was created |
53
+
54
+ ### Why Opaque?
55
+
56
+ Cursors are frozen (`Object.freeze()`) to prevent direct mutation. This design:
57
+
58
+ 1. **Enforces immutability** - Use `advanceCursor()` to create new positions
59
+ 2. **Enables future changes** - Internal representation can evolve without breaking API
60
+ 3. **Prevents corruption** - No accidental modification of cursor state
61
+
62
+ ```typescript
63
+ const result = createCursor({ position: 0 });
64
+ if (result.isOk()) {
65
+ const cursor = result.value;
66
+
67
+ // This throws in strict mode (cursor is frozen)
68
+ cursor.position = 10; // TypeError!
69
+
70
+ // Do this instead
71
+ const advanced = advanceCursor(cursor, 10);
72
+ }
73
+ ```
74
+
75
+ ## Pagination Flow
76
+
77
+ ```
78
+ +-----------------------------------------------------+
79
+ | First Request |
80
+ | 1. Handler receives no cursor |
81
+ | 2. Creates cursor at position 0 |
82
+ | 3. Returns items[0..limit] + cursor.id |
83
+ +-----------------------------------------------------+
84
+ |
85
+ v
86
+ +-----------------------------------------------------+
87
+ | Subsequent Requests |
88
+ | 1. Handler receives cursor.id |
89
+ | 2. Loads cursor from store |
90
+ | 3. Returns items[cursor.position..position+limit] |
91
+ | 4. Advances cursor, saves back to store |
92
+ +-----------------------------------------------------+
93
+ ```
94
+
95
+ ### Example: Paginated Handler
96
+
97
+ ```typescript
98
+ import { createCursor, createCursorStore, advanceCursor } from "@outfitter/state";
99
+ import { Result } from "@outfitter/contracts";
100
+
101
+ const store = createCursorStore();
102
+ const PAGE_SIZE = 20;
103
+
104
+ async function listItems(cursorId?: string) {
105
+ let cursor;
106
+
107
+ if (cursorId) {
108
+ // Load existing cursor
109
+ const cursorResult = store.get(cursorId);
110
+ if (cursorResult.isErr()) {
111
+ return Result.err(cursorResult.error);
112
+ }
113
+ cursor = cursorResult.value;
114
+ } else {
115
+ // Create new cursor at position 0
116
+ const cursorResult = createCursor({
117
+ position: 0,
118
+ ttl: 30 * 60 * 1000, // 30 minutes
119
+ });
120
+ if (cursorResult.isErr()) {
121
+ return Result.err(cursorResult.error);
122
+ }
123
+ cursor = cursorResult.value;
124
+ store.set(cursor);
125
+ }
126
+
127
+ // Fetch items at cursor position
128
+ const items = await fetchItems(cursor.position, PAGE_SIZE);
129
+
130
+ // Advance cursor for next page
131
+ const advanced = advanceCursor(cursor, cursor.position + PAGE_SIZE);
132
+ store.set(advanced);
133
+
134
+ return Result.ok({
135
+ items,
136
+ nextCursor: items.length === PAGE_SIZE ? cursor.id : undefined,
137
+ });
138
+ }
139
+ ```
140
+
141
+ ## State Scoping
142
+
143
+ Isolate cursors by namespace to prevent ID collisions between different contexts:
144
+
145
+ ```typescript
146
+ const store = createCursorStore();
147
+
148
+ // Scoped stores for different contexts
149
+ const issuesStore = createScopedStore(store, "linear:issues");
150
+ const prsStore = createScopedStore(store, "github:prs");
151
+
152
+ // Cursors are isolated - same ID won't conflict
153
+ issuesStore.set(issueCursor); // Stored as "linear:issues:cursor-id"
154
+ prsStore.set(prCursor); // Stored as "github:prs:cursor-id"
155
+
156
+ // Each scope manages its own cursors
157
+ issuesStore.clear(); // Only clears issue cursors
158
+ ```
159
+
160
+ ### Nested Scopes
161
+
162
+ Scopes can be nested for hierarchical organization:
163
+
164
+ ```typescript
165
+ const store = createCursorStore();
166
+ const githubStore = createScopedStore(store, "github");
167
+ const issuesStore = createScopedStore(githubStore, "issues");
168
+ const prsStore = createScopedStore(githubStore, "prs");
169
+
170
+ issuesStore.getScope(); // "github:issues"
171
+ prsStore.getScope(); // "github:prs"
172
+ ```
173
+
174
+ ### Scoped Store Behavior
175
+
176
+ When you retrieve a cursor from a scoped store, the ID is presented without the prefix:
177
+
178
+ ```typescript
179
+ const scoped = createScopedStore(store, "my-scope");
180
+
181
+ const cursor = createCursor({ id: "abc123", position: 0 });
182
+ if (cursor.isOk()) {
183
+ scoped.set(cursor.value);
184
+
185
+ // Underlying store has prefixed ID
186
+ store.list(); // ["my-scope:abc123"]
187
+
188
+ // Scoped store returns clean ID
189
+ scoped.list(); // ["abc123"]
190
+
191
+ // Get returns cursor with clean ID
192
+ const result = scoped.get("abc123");
193
+ if (result.isOk()) {
194
+ result.value.id; // "abc123" (not "my-scope:abc123")
195
+ }
196
+ }
197
+ ```
198
+
199
+ ## Persistent Storage
200
+
201
+ For cursors that need to survive process restarts:
202
+
203
+ ```typescript
204
+ import { createPersistentStore } from "@outfitter/state";
205
+
206
+ // Create store that persists to disk
207
+ const store = await createPersistentStore({
208
+ path: "/path/to/cursors.json",
209
+ });
210
+
211
+ // Use like any cursor store
212
+ store.set(cursor);
213
+
214
+ // Flush to disk before exit
215
+ await store.flush();
216
+
217
+ // Cleanup resources
218
+ store.dispose();
219
+ ```
220
+
221
+ ### Persistence Details
222
+
223
+ - **Format**: JSON file with `{ cursors: Record<string, Cursor> }` structure
224
+ - **Atomic writes**: Uses temp file + rename to prevent corruption
225
+ - **Auto-creates directories**: Parent directories created if they don't exist
226
+ - **Graceful degradation**: Corrupted files result in empty store (no crash)
227
+
228
+ ### Example: Persistent Scoped Store
229
+
230
+ ```typescript
231
+ import { createPersistentStore, createScopedStore } from "@outfitter/state";
232
+
233
+ const persistent = await createPersistentStore({
234
+ path: "~/.config/myapp/cursors.json",
235
+ });
236
+
237
+ const issuesCursors = createScopedStore(persistent, "issues");
238
+ const prsCursors = createScopedStore(persistent, "prs");
239
+
240
+ // Use scoped stores normally
241
+ issuesCursors.set(cursor);
242
+
243
+ // Flush persists all scopes
244
+ await persistent.flush();
245
+ ```
246
+
247
+ ## TTL and Expiration
248
+
249
+ Cursors can have a time-to-live (TTL) for automatic expiration:
250
+
251
+ ```typescript
252
+ // Cursor expires in 1 hour
253
+ const result = createCursor({
254
+ position: 0,
255
+ ttl: 60 * 60 * 1000,
256
+ });
257
+
258
+ if (result.isOk()) {
259
+ const cursor = result.value;
260
+ cursor.ttl; // 3600000
261
+ cursor.expiresAt; // Unix timestamp (e.g., 1706000000000)
262
+ }
263
+ ```
264
+
265
+ ### Expiration Behavior
266
+
267
+ - **`store.get()`**: Returns `NotFoundError` for expired cursors
268
+ - **`store.has()`**: Returns `false` for expired cursors
269
+ - **`isExpired()`**: Check expiration without store lookup
270
+ - **`store.prune()`**: Remove all expired cursors, returns count
271
+
272
+ ```typescript
273
+ import { isExpired } from "@outfitter/state";
274
+
275
+ // Manual expiration check
276
+ if (isExpired(cursor)) {
277
+ console.log("Cursor has expired");
278
+ }
279
+
280
+ // Prune expired cursors periodically
281
+ const prunedCount = store.prune();
282
+ console.log(`Removed ${prunedCount} expired cursors`);
283
+ ```
284
+
285
+ ### Cursors Without TTL
286
+
287
+ Cursors created without a TTL never expire:
288
+
289
+ ```typescript
290
+ const result = createCursor({ position: 0 });
291
+ if (result.isOk()) {
292
+ result.value.ttl; // undefined
293
+ result.value.expiresAt; // undefined
294
+ isExpired(result.value); // always false
295
+ }
296
+ ```
297
+
298
+ ## API Reference
299
+
300
+ ### Functions
301
+
302
+ | Function | Description |
303
+ |----------|-------------|
304
+ | `createCursor(options)` | Create a new immutable pagination cursor |
305
+ | `advanceCursor(cursor, position)` | Create a new cursor with updated position |
306
+ | `isExpired(cursor)` | Check if a cursor has expired |
307
+ | `createCursorStore()` | Create an in-memory cursor store |
308
+ | `createPersistentStore(options)` | Create a disk-backed cursor store |
309
+ | `createScopedStore(store, scope)` | Create a namespace-isolated cursor store |
310
+
311
+ ### Interfaces
312
+
313
+ | Interface | Description |
314
+ |-----------|-------------|
315
+ | `Cursor` | Immutable pagination cursor |
316
+ | `CreateCursorOptions` | Options for `createCursor()` |
317
+ | `CursorStore` | Base interface for cursor stores |
318
+ | `ScopedStore` | Cursor store with namespace isolation |
319
+ | `PersistentStore` | Cursor store with disk persistence |
320
+ | `PersistentStoreOptions` | Options for `createPersistentStore()` |
321
+
322
+ ## Error Handling
323
+
324
+ All functions that can fail return `Result<T, E>` from `@outfitter/contracts`:
325
+
326
+ ```typescript
327
+ import { Result } from "@outfitter/contracts";
328
+
329
+ const result = createCursor({ position: -1 });
330
+
331
+ if (result.isErr()) {
332
+ // ValidationError: Position must be non-negative
333
+ console.error(result.error.message);
334
+ }
335
+
336
+ const getResult = store.get("nonexistent");
337
+
338
+ if (getResult.isErr()) {
339
+ // NotFoundError: Cursor not found: nonexistent
340
+ console.error(getResult.error.message);
341
+ console.log(getResult.error.resourceType); // "cursor"
342
+ console.log(getResult.error.resourceId); // "nonexistent"
343
+ }
344
+ ```
345
+
346
+ ## License
347
+
348
+ MIT