@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 +348 -0
- package/dist/index.d.ts +667 -0
- package/dist/index.js +425 -0
- package/package.json +53 -0
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
|