@outfitter/state 0.2.4 → 0.2.5
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/dist/index.d.ts +4 -619
- package/dist/index.js +3 -409
- package/dist/internal/cursor.d.ts +3 -0
- package/dist/internal/cursor.js +15 -0
- package/dist/internal/pagination.d.ts +3 -0
- package/dist/internal/pagination.js +59 -0
- package/dist/internal/stores.d.ts +3 -0
- package/dist/internal/stores.js +208 -0
- package/dist/internal/types.d.ts +2 -0
- package/dist/internal/types.js +1 -0
- package/dist/shared/@outfitter/state-3n2wh1yq.js +154 -0
- package/dist/shared/@outfitter/state-6h22x3e6.d.ts +264 -0
- package/dist/shared/@outfitter/state-9q2yv8dy.d.ts +133 -0
- package/dist/shared/@outfitter/state-cgc92xms.d.ts +139 -0
- package/dist/shared/@outfitter/state-y5wh3wpn.d.ts +139 -0
- package/package.json +4 -4
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/state/src/internal/cursor.ts
|
|
3
|
+
import { Result, ValidationError } from "@outfitter/contracts";
|
|
4
|
+
function createCursor(options) {
|
|
5
|
+
if (options.position < 0) {
|
|
6
|
+
return Result.err(new ValidationError({
|
|
7
|
+
message: "Position must be non-negative",
|
|
8
|
+
field: "position"
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
const createdAt = Date.now();
|
|
12
|
+
const id = options.id ?? crypto.randomUUID();
|
|
13
|
+
const cursor = Object.freeze({
|
|
14
|
+
id,
|
|
15
|
+
position: options.position,
|
|
16
|
+
createdAt,
|
|
17
|
+
...options.metadata !== undefined && { metadata: options.metadata },
|
|
18
|
+
...options.ttl !== undefined && { ttl: options.ttl },
|
|
19
|
+
...options.ttl !== undefined && { expiresAt: createdAt + options.ttl }
|
|
20
|
+
});
|
|
21
|
+
return Result.ok(cursor);
|
|
22
|
+
}
|
|
23
|
+
function advanceCursor(cursor, newPosition) {
|
|
24
|
+
const newCursor = Object.freeze({
|
|
25
|
+
id: cursor.id,
|
|
26
|
+
position: newPosition,
|
|
27
|
+
createdAt: cursor.createdAt,
|
|
28
|
+
...cursor.metadata !== undefined && { metadata: cursor.metadata },
|
|
29
|
+
...cursor.ttl !== undefined && { ttl: cursor.ttl },
|
|
30
|
+
...cursor.expiresAt !== undefined && { expiresAt: cursor.expiresAt }
|
|
31
|
+
});
|
|
32
|
+
return newCursor;
|
|
33
|
+
}
|
|
34
|
+
function isExpired(cursor) {
|
|
35
|
+
if (cursor.expiresAt === undefined) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return Date.now() > cursor.expiresAt;
|
|
39
|
+
}
|
|
40
|
+
function toBase64Url(base64) {
|
|
41
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
42
|
+
}
|
|
43
|
+
var base64Encoder = new TextEncoder;
|
|
44
|
+
var base64Decoder = new TextDecoder;
|
|
45
|
+
function toBase64(value) {
|
|
46
|
+
const bytes = base64Encoder.encode(value);
|
|
47
|
+
let binary = "";
|
|
48
|
+
for (const byte of bytes) {
|
|
49
|
+
binary += String.fromCharCode(byte);
|
|
50
|
+
}
|
|
51
|
+
return btoa(binary);
|
|
52
|
+
}
|
|
53
|
+
function fromBase64(base64) {
|
|
54
|
+
const binary = atob(base64);
|
|
55
|
+
const bytes = new Uint8Array(binary.length);
|
|
56
|
+
for (let i = 0;i < binary.length; i += 1) {
|
|
57
|
+
bytes[i] = binary.charCodeAt(i);
|
|
58
|
+
}
|
|
59
|
+
return base64Decoder.decode(bytes);
|
|
60
|
+
}
|
|
61
|
+
function fromBase64Url(base64Url) {
|
|
62
|
+
let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
63
|
+
const padLength = (4 - base64.length % 4) % 4;
|
|
64
|
+
base64 += "=".repeat(padLength);
|
|
65
|
+
return base64;
|
|
66
|
+
}
|
|
67
|
+
function encodeCursor(cursor) {
|
|
68
|
+
const json = JSON.stringify(cursor);
|
|
69
|
+
const base64 = toBase64(json);
|
|
70
|
+
return toBase64Url(base64);
|
|
71
|
+
}
|
|
72
|
+
function decodeCursor(encoded) {
|
|
73
|
+
let json;
|
|
74
|
+
try {
|
|
75
|
+
const base64 = fromBase64Url(encoded);
|
|
76
|
+
json = fromBase64(base64);
|
|
77
|
+
} catch {
|
|
78
|
+
return Result.err(new ValidationError({
|
|
79
|
+
message: "Invalid cursor: failed to decode base64",
|
|
80
|
+
field: "cursor"
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
let data;
|
|
84
|
+
try {
|
|
85
|
+
data = JSON.parse(json);
|
|
86
|
+
} catch {
|
|
87
|
+
return Result.err(new ValidationError({
|
|
88
|
+
message: "Invalid cursor: failed to parse JSON",
|
|
89
|
+
field: "cursor"
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
if (typeof data !== "object" || data === null) {
|
|
93
|
+
return Result.err(new ValidationError({
|
|
94
|
+
message: "Invalid cursor: expected object",
|
|
95
|
+
field: "cursor"
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
const obj = data;
|
|
99
|
+
if (typeof obj.id !== "string") {
|
|
100
|
+
return Result.err(new ValidationError({
|
|
101
|
+
message: "Invalid cursor: missing or invalid 'id' field",
|
|
102
|
+
field: "cursor.id"
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
if (typeof obj.position !== "number") {
|
|
106
|
+
return Result.err(new ValidationError({
|
|
107
|
+
message: "Invalid cursor: missing or invalid 'position' field",
|
|
108
|
+
field: "cursor.position"
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
if (obj.position < 0) {
|
|
112
|
+
return Result.err(new ValidationError({
|
|
113
|
+
message: "Invalid cursor: position must be non-negative",
|
|
114
|
+
field: "cursor.position"
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
if (typeof obj.createdAt !== "number") {
|
|
118
|
+
return Result.err(new ValidationError({
|
|
119
|
+
message: "Invalid cursor: missing or invalid 'createdAt' field",
|
|
120
|
+
field: "cursor.createdAt"
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
if (obj.metadata !== undefined && (typeof obj.metadata !== "object" || obj.metadata === null)) {
|
|
124
|
+
return Result.err(new ValidationError({
|
|
125
|
+
message: "Invalid cursor: 'metadata' must be an object",
|
|
126
|
+
field: "cursor.metadata"
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
if (obj.ttl !== undefined && typeof obj.ttl !== "number") {
|
|
130
|
+
return Result.err(new ValidationError({
|
|
131
|
+
message: "Invalid cursor: 'ttl' must be a number",
|
|
132
|
+
field: "cursor.ttl"
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
if (obj.expiresAt !== undefined && typeof obj.expiresAt !== "number") {
|
|
136
|
+
return Result.err(new ValidationError({
|
|
137
|
+
message: "Invalid cursor: 'expiresAt' must be a number",
|
|
138
|
+
field: "cursor.expiresAt"
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
const cursor = Object.freeze({
|
|
142
|
+
id: obj.id,
|
|
143
|
+
position: obj.position,
|
|
144
|
+
createdAt: obj.createdAt,
|
|
145
|
+
...obj.metadata !== undefined && {
|
|
146
|
+
metadata: obj.metadata
|
|
147
|
+
},
|
|
148
|
+
...obj.ttl !== undefined && { ttl: obj.ttl },
|
|
149
|
+
...obj.expiresAt !== undefined && { expiresAt: obj.expiresAt }
|
|
150
|
+
});
|
|
151
|
+
return Result.ok(cursor);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { createCursor, advanceCursor, isExpired, encodeCursor, decodeCursor };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { NotFoundError, Result } from "@outfitter/contracts";
|
|
2
|
+
/**
|
|
3
|
+
* A pagination cursor representing a position in a result set.
|
|
4
|
+
*
|
|
5
|
+
* Cursors are immutable (frozen) objects that encapsulate pagination state.
|
|
6
|
+
* They are intentionally opaque to prevent direct manipulation - use
|
|
7
|
+
* {@link advanceCursor} to create a new cursor with an updated position.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const result = createCursor({
|
|
12
|
+
* position: 0,
|
|
13
|
+
* metadata: { query: "status:open" },
|
|
14
|
+
* ttl: 3600000, // 1 hour
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* if (result.isOk()) {
|
|
18
|
+
* const cursor = result.value;
|
|
19
|
+
* console.log(cursor.id); // UUID
|
|
20
|
+
* console.log(cursor.position); // 0
|
|
21
|
+
* console.log(cursor.expiresAt); // Unix timestamp
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
interface Cursor {
|
|
26
|
+
/** Unix timestamp (ms) when this cursor was created */
|
|
27
|
+
readonly createdAt: number;
|
|
28
|
+
/** Unix timestamp (ms) when this cursor expires (computed from createdAt + ttl) */
|
|
29
|
+
readonly expiresAt?: number;
|
|
30
|
+
/** Unique identifier for this cursor (UUID format) */
|
|
31
|
+
readonly id: string;
|
|
32
|
+
/** Optional user-defined metadata associated with this cursor */
|
|
33
|
+
readonly metadata?: Record<string, unknown>;
|
|
34
|
+
/** Current position/offset in the result set (zero-based) */
|
|
35
|
+
readonly position: number;
|
|
36
|
+
/** Time-to-live in milliseconds (optional, omitted if cursor never expires) */
|
|
37
|
+
readonly ttl?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Options for creating a pagination cursor.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* // Minimal options (ID auto-generated, no TTL)
|
|
45
|
+
* const opts1: CreateCursorOptions = { position: 0 };
|
|
46
|
+
*
|
|
47
|
+
* // Full options with custom ID, metadata, and TTL
|
|
48
|
+
* const opts2: CreateCursorOptions = {
|
|
49
|
+
* id: "my-cursor-id",
|
|
50
|
+
* position: 50,
|
|
51
|
+
* metadata: { query: "status:open", pageSize: 25 },
|
|
52
|
+
* ttl: 30 * 60 * 1000, // 30 minutes
|
|
53
|
+
* };
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
interface CreateCursorOptions {
|
|
57
|
+
/** Custom cursor ID (UUID generated if not provided) */
|
|
58
|
+
id?: string;
|
|
59
|
+
/** User-defined metadata to associate with the cursor */
|
|
60
|
+
metadata?: Record<string, unknown>;
|
|
61
|
+
/** Starting position in the result set (must be non-negative) */
|
|
62
|
+
position: number;
|
|
63
|
+
/** Time-to-live in milliseconds (cursor never expires if omitted) */
|
|
64
|
+
ttl?: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A store for managing pagination cursors.
|
|
68
|
+
*
|
|
69
|
+
* Cursor stores handle storage, retrieval, and expiration of cursors.
|
|
70
|
+
* Expired cursors are automatically excluded from `get()` and `has()` operations.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* const store = createCursorStore();
|
|
75
|
+
*
|
|
76
|
+
* // Store a cursor
|
|
77
|
+
* const cursor = createCursor({ position: 0 });
|
|
78
|
+
* if (cursor.isOk()) {
|
|
79
|
+
* store.set(cursor.value);
|
|
80
|
+
* }
|
|
81
|
+
*
|
|
82
|
+
* // Retrieve by ID
|
|
83
|
+
* const result = store.get("cursor-id");
|
|
84
|
+
* if (result.isOk()) {
|
|
85
|
+
* console.log(result.value.position);
|
|
86
|
+
* }
|
|
87
|
+
*
|
|
88
|
+
* // Cleanup expired cursors
|
|
89
|
+
* const pruned = store.prune();
|
|
90
|
+
* console.log(`Removed ${pruned} expired cursors`);
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
interface CursorStore {
|
|
94
|
+
/**
|
|
95
|
+
* Remove all cursors from the store.
|
|
96
|
+
*/
|
|
97
|
+
clear(): void;
|
|
98
|
+
/**
|
|
99
|
+
* Delete a cursor by ID.
|
|
100
|
+
* @param id - The cursor ID to delete (no-op if not found)
|
|
101
|
+
*/
|
|
102
|
+
delete(id: string): void;
|
|
103
|
+
/**
|
|
104
|
+
* Retrieve a cursor by ID.
|
|
105
|
+
* @param id - The cursor ID to look up
|
|
106
|
+
* @returns Result with cursor or NotFoundError (also returned for expired cursors)
|
|
107
|
+
*/
|
|
108
|
+
get(id: string): Result<Cursor, InstanceType<typeof NotFoundError>>;
|
|
109
|
+
/**
|
|
110
|
+
* Check if a cursor exists and is not expired.
|
|
111
|
+
* @param id - The cursor ID to check
|
|
112
|
+
* @returns True if cursor exists and is valid, false otherwise
|
|
113
|
+
*/
|
|
114
|
+
has(id: string): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* List all cursor IDs in the store (including expired).
|
|
117
|
+
* @returns Array of cursor IDs
|
|
118
|
+
*/
|
|
119
|
+
list(): string[];
|
|
120
|
+
/**
|
|
121
|
+
* Remove all expired cursors from the store.
|
|
122
|
+
* @returns Number of cursors that were pruned
|
|
123
|
+
*/
|
|
124
|
+
prune(): number;
|
|
125
|
+
/**
|
|
126
|
+
* Save or update a cursor in the store.
|
|
127
|
+
* @param cursor - The cursor to store (replaces existing if same ID)
|
|
128
|
+
*/
|
|
129
|
+
set(cursor: Cursor): void;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A cursor store with namespace isolation.
|
|
133
|
+
*
|
|
134
|
+
* Scoped stores prefix all cursor IDs with the scope name, preventing
|
|
135
|
+
* collisions between different contexts (e.g., "issues" vs "pull-requests").
|
|
136
|
+
*
|
|
137
|
+
* Scopes can be nested: creating a scoped store from another scoped store
|
|
138
|
+
* produces IDs like "parent:child:cursor-id".
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* const store = createCursorStore();
|
|
143
|
+
* const issueStore = createScopedStore(store, "issues");
|
|
144
|
+
* const prStore = createScopedStore(store, "prs");
|
|
145
|
+
*
|
|
146
|
+
* // These don't conflict - different namespaces
|
|
147
|
+
* issueStore.set(cursor1); // Stored as "issues:abc123"
|
|
148
|
+
* prStore.set(cursor2); // Stored as "prs:abc123"
|
|
149
|
+
*
|
|
150
|
+
* // Clear only affects the scope
|
|
151
|
+
* issueStore.clear(); // Only clears issue cursors
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
interface ScopedStore extends CursorStore {
|
|
155
|
+
/**
|
|
156
|
+
* Get the full scope path for this store.
|
|
157
|
+
* @returns Scope string (e.g., "parent:child" for nested scopes)
|
|
158
|
+
*/
|
|
159
|
+
getScope(): string;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Options for creating a persistent cursor store.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* const options: PersistentStoreOptions = {
|
|
167
|
+
* path: "/home/user/.config/myapp/cursors.json",
|
|
168
|
+
* };
|
|
169
|
+
*
|
|
170
|
+
* const store = await createPersistentStore(options);
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
interface PersistentStoreOptions {
|
|
174
|
+
/** Absolute file path for cursor persistence (JSON format) */
|
|
175
|
+
path: string;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A cursor store that persists to disk and survives process restarts.
|
|
179
|
+
*
|
|
180
|
+
* Persistent stores use atomic writes (temp file + rename) to prevent
|
|
181
|
+
* corruption. They automatically load existing data on initialization
|
|
182
|
+
* and handle corrupted files gracefully by starting empty.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```typescript
|
|
186
|
+
* const store = await createPersistentStore({
|
|
187
|
+
* path: "~/.config/myapp/cursors.json",
|
|
188
|
+
* });
|
|
189
|
+
*
|
|
190
|
+
* // Use like any cursor store
|
|
191
|
+
* store.set(cursor);
|
|
192
|
+
*
|
|
193
|
+
* // Flush to disk before exit
|
|
194
|
+
* await store.flush();
|
|
195
|
+
*
|
|
196
|
+
* // Cleanup resources
|
|
197
|
+
* store.dispose();
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
interface PersistentStore extends CursorStore {
|
|
201
|
+
/**
|
|
202
|
+
* Dispose of the store and cleanup resources.
|
|
203
|
+
* Call this when the store is no longer needed.
|
|
204
|
+
*/
|
|
205
|
+
dispose(): void;
|
|
206
|
+
/**
|
|
207
|
+
* Flush all in-memory cursors to disk.
|
|
208
|
+
* Uses atomic write (temp file + rename) to prevent corruption.
|
|
209
|
+
* @returns Promise that resolves when write is complete
|
|
210
|
+
*/
|
|
211
|
+
flush(): Promise<void>;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A simple cursor store for pagination operations.
|
|
215
|
+
*
|
|
216
|
+
* This is a simplified interface compared to {@link CursorStore}, designed
|
|
217
|
+
* specifically for pagination helpers. It returns `null` instead of errors
|
|
218
|
+
* for missing cursors, making pagination code more straightforward.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```typescript
|
|
222
|
+
* const store = createPaginationStore();
|
|
223
|
+
*
|
|
224
|
+
* // Store a cursor
|
|
225
|
+
* store.set("my-cursor", cursor);
|
|
226
|
+
*
|
|
227
|
+
* // Retrieve (returns null if not found)
|
|
228
|
+
* const cursor = store.get("my-cursor");
|
|
229
|
+
* if (cursor) {
|
|
230
|
+
* console.log(cursor.position);
|
|
231
|
+
* }
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
interface PaginationStore {
|
|
235
|
+
/**
|
|
236
|
+
* Delete a cursor by ID.
|
|
237
|
+
* @param id - The cursor ID to delete
|
|
238
|
+
*/
|
|
239
|
+
delete(id: string): void;
|
|
240
|
+
/**
|
|
241
|
+
* Get a cursor by ID.
|
|
242
|
+
* @param id - The cursor ID to look up
|
|
243
|
+
* @returns The cursor if found, null otherwise
|
|
244
|
+
*/
|
|
245
|
+
get(id: string): Cursor | null;
|
|
246
|
+
/**
|
|
247
|
+
* Store a cursor by ID.
|
|
248
|
+
* @param id - The ID to store under
|
|
249
|
+
* @param cursor - The cursor to store
|
|
250
|
+
*/
|
|
251
|
+
set(id: string, cursor: Cursor): void;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Result of a pagination operation.
|
|
255
|
+
*
|
|
256
|
+
* @typeParam T - The type of items being paginated
|
|
257
|
+
*/
|
|
258
|
+
interface PaginationResult<T> {
|
|
259
|
+
/** The cursor for the next page, or null if this is the last page */
|
|
260
|
+
nextCursor: Cursor | null;
|
|
261
|
+
/** The items in the current page */
|
|
262
|
+
page: T[];
|
|
263
|
+
}
|
|
264
|
+
export { Cursor, CreateCursorOptions, CursorStore, ScopedStore, PersistentStoreOptions, PersistentStore, PaginationStore, PaginationResult };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { CreateCursorOptions, Cursor } from "./state-6h22x3e6.js";
|
|
2
|
+
import { Result, ValidationError } from "@outfitter/contracts";
|
|
3
|
+
/**
|
|
4
|
+
* Create a new pagination cursor.
|
|
5
|
+
*
|
|
6
|
+
* Cursors are immutable and frozen. To update position, use {@link advanceCursor}.
|
|
7
|
+
* If no ID is provided, a UUID is generated automatically.
|
|
8
|
+
*
|
|
9
|
+
* @param options - Cursor creation options including position, optional ID, metadata, and TTL
|
|
10
|
+
* @returns Result containing frozen Cursor or ValidationError if position is negative
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* // Basic cursor at position 0
|
|
15
|
+
* const result = createCursor({ position: 0 });
|
|
16
|
+
*
|
|
17
|
+
* // Cursor with metadata and TTL
|
|
18
|
+
* const result = createCursor({
|
|
19
|
+
* position: 0,
|
|
20
|
+
* metadata: { filter: "active" },
|
|
21
|
+
* ttl: 3600000, // 1 hour
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* if (result.isOk()) {
|
|
25
|
+
* store.set(result.value);
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
declare function createCursor(options: CreateCursorOptions): Result<Cursor, InstanceType<typeof ValidationError>>;
|
|
30
|
+
/**
|
|
31
|
+
* Advance a cursor to a new position, returning a new immutable cursor.
|
|
32
|
+
*
|
|
33
|
+
* The original cursor is not modified. All properties (ID, metadata, TTL,
|
|
34
|
+
* expiresAt, createdAt) are preserved in the new cursor.
|
|
35
|
+
*
|
|
36
|
+
* @param cursor - The cursor to advance (not modified)
|
|
37
|
+
* @param newPosition - The new position value (typically cursor.position + pageSize)
|
|
38
|
+
* @returns A new frozen Cursor with the updated position
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const cursor = createCursor({ position: 0 });
|
|
43
|
+
* if (cursor.isOk()) {
|
|
44
|
+
* // Advance by page size of 25
|
|
45
|
+
* const nextPage = advanceCursor(cursor.value, cursor.value.position + 25);
|
|
46
|
+
*
|
|
47
|
+
* console.log(cursor.value.position); // 0 (unchanged)
|
|
48
|
+
* console.log(nextPage.position); // 25
|
|
49
|
+
*
|
|
50
|
+
* // Store the advanced cursor
|
|
51
|
+
* store.set(nextPage);
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
declare function advanceCursor(cursor: Cursor, newPosition: number): Cursor;
|
|
56
|
+
/**
|
|
57
|
+
* Check if a cursor has expired based on its TTL.
|
|
58
|
+
*
|
|
59
|
+
* Cursors without a TTL (no `expiresAt` property) never expire and
|
|
60
|
+
* this function will always return `false` for them.
|
|
61
|
+
*
|
|
62
|
+
* @param cursor - The cursor to check for expiration
|
|
63
|
+
* @returns `true` if cursor has expired, `false` if still valid or has no TTL
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const cursor = createCursor({ position: 0, ttl: 1000 }); // 1 second TTL
|
|
68
|
+
*
|
|
69
|
+
* if (cursor.isOk()) {
|
|
70
|
+
* console.log(isExpired(cursor.value)); // false (just created)
|
|
71
|
+
*
|
|
72
|
+
* // Wait 2 seconds...
|
|
73
|
+
* await new Promise(resolve => setTimeout(resolve, 2000));
|
|
74
|
+
*
|
|
75
|
+
* console.log(isExpired(cursor.value)); // true (expired)
|
|
76
|
+
* }
|
|
77
|
+
*
|
|
78
|
+
* // Cursors without TTL never expire
|
|
79
|
+
* const eternal = createCursor({ position: 0 });
|
|
80
|
+
* if (eternal.isOk()) {
|
|
81
|
+
* console.log(isExpired(eternal.value)); // always false
|
|
82
|
+
* }
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function isExpired(cursor: Cursor): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Encodes a cursor to an opaque URL-safe string.
|
|
88
|
+
*
|
|
89
|
+
* The internal structure is hidden from consumers. The cursor is serialized
|
|
90
|
+
* to JSON and then encoded using URL-safe base64 (RFC 4648 Section 5).
|
|
91
|
+
*
|
|
92
|
+
* @param cursor - Cursor to encode
|
|
93
|
+
* @returns URL-safe base64 encoded string (no +, /, or = characters)
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* const cursor = createCursor({ position: 0, metadata: { query: "status:open" } });
|
|
98
|
+
* if (cursor.isOk()) {
|
|
99
|
+
* const encoded = encodeCursor(cursor.value);
|
|
100
|
+
* // encoded is a URL-safe string like "eyJpZCI6IjEyMzQ..."
|
|
101
|
+
*
|
|
102
|
+
* // Can be safely used in URLs, query params, headers
|
|
103
|
+
* const url = `/api/items?cursor=${encoded}`;
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
declare function encodeCursor(cursor: Cursor): string;
|
|
108
|
+
/**
|
|
109
|
+
* Decodes an opaque cursor string back to a Cursor.
|
|
110
|
+
*
|
|
111
|
+
* Validates the structure after decoding, ensuring all required fields
|
|
112
|
+
* are present and have correct types.
|
|
113
|
+
*
|
|
114
|
+
* @param encoded - URL-safe base64 encoded cursor
|
|
115
|
+
* @returns Result with decoded Cursor or ValidationError if invalid
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* // Successful decode
|
|
120
|
+
* const result = decodeCursor(encodedString);
|
|
121
|
+
* if (result.isOk()) {
|
|
122
|
+
* console.log(result.value.position);
|
|
123
|
+
* }
|
|
124
|
+
*
|
|
125
|
+
* // Handle invalid input
|
|
126
|
+
* const invalid = decodeCursor("not-a-valid-cursor");
|
|
127
|
+
* if (invalid.isErr()) {
|
|
128
|
+
* console.log(invalid.error.message); // "Invalid cursor: ..."
|
|
129
|
+
* }
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare function decodeCursor(encoded: string): Result<Cursor, InstanceType<typeof ValidationError>>;
|
|
133
|
+
export { createCursor, advanceCursor, isExpired, encodeCursor, decodeCursor };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { CursorStore, PersistentStore, PersistentStoreOptions, ScopedStore } from "./state-6h22x3e6.js";
|
|
2
|
+
/**
|
|
3
|
+
* Create an in-memory cursor store.
|
|
4
|
+
*
|
|
5
|
+
* The store automatically handles expiration: `get()` and `has()` return
|
|
6
|
+
* not-found/false for expired cursors. Use `prune()` to remove expired
|
|
7
|
+
* cursors from memory.
|
|
8
|
+
*
|
|
9
|
+
* @returns A new cursor store implementing both CursorStore and ScopedStore interfaces
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const store = createCursorStore();
|
|
14
|
+
*
|
|
15
|
+
* // Create and store a cursor
|
|
16
|
+
* const cursor = createCursor({
|
|
17
|
+
* position: 0,
|
|
18
|
+
* metadata: { query: "status:open" },
|
|
19
|
+
* ttl: 3600000, // 1 hour
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* if (cursor.isOk()) {
|
|
23
|
+
* store.set(cursor.value);
|
|
24
|
+
*
|
|
25
|
+
* // Retrieve later
|
|
26
|
+
* const result = store.get(cursor.value.id);
|
|
27
|
+
* if (result.isOk()) {
|
|
28
|
+
* console.log(result.value.position);
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* // List all cursors
|
|
33
|
+
* console.log(store.list()); // ["cursor-id", ...]
|
|
34
|
+
*
|
|
35
|
+
* // Cleanup expired
|
|
36
|
+
* const pruned = store.prune();
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
declare function createCursorStore(): CursorStore & ScopedStore;
|
|
40
|
+
/**
|
|
41
|
+
* Create a persistent cursor store that saves to disk.
|
|
42
|
+
*
|
|
43
|
+
* The store loads existing cursors from the file on initialization.
|
|
44
|
+
* Changes are kept in memory until `flush()` is called. Uses atomic
|
|
45
|
+
* writes (temp file + rename) to prevent corruption.
|
|
46
|
+
*
|
|
47
|
+
* If the file is corrupted or invalid JSON, the store starts empty
|
|
48
|
+
* rather than throwing an error.
|
|
49
|
+
*
|
|
50
|
+
* @param options - Persistence options including the file path
|
|
51
|
+
* @returns Promise resolving to a PersistentStore
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* // Create persistent store
|
|
56
|
+
* const store = await createPersistentStore({
|
|
57
|
+
* path: "/home/user/.config/myapp/cursors.json",
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* // Use like any cursor store
|
|
61
|
+
* const cursor = createCursor({ position: 0 });
|
|
62
|
+
* if (cursor.isOk()) {
|
|
63
|
+
* store.set(cursor.value);
|
|
64
|
+
* }
|
|
65
|
+
*
|
|
66
|
+
* // Flush to disk (call before process exit)
|
|
67
|
+
* await store.flush();
|
|
68
|
+
*
|
|
69
|
+
* // Cleanup when done
|
|
70
|
+
* store.dispose();
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* // Combine with scoped stores for organized persistence
|
|
76
|
+
* const persistent = await createPersistentStore({
|
|
77
|
+
* path: "~/.config/myapp/cursors.json",
|
|
78
|
+
* });
|
|
79
|
+
*
|
|
80
|
+
* const issuesCursors = createScopedStore(persistent, "issues");
|
|
81
|
+
* const prsCursors = createScopedStore(persistent, "prs");
|
|
82
|
+
*
|
|
83
|
+
* // All scopes share the same persistence file
|
|
84
|
+
* await persistent.flush();
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
declare function createPersistentStore(options: PersistentStoreOptions): Promise<PersistentStore>;
|
|
88
|
+
/**
|
|
89
|
+
* Create a scoped cursor store with namespace isolation.
|
|
90
|
+
*
|
|
91
|
+
* Scoped stores prefix all cursor IDs with the scope name, preventing
|
|
92
|
+
* collisions between different contexts (e.g., "issues" vs "pull-requests").
|
|
93
|
+
*
|
|
94
|
+
* Scopes can be nested: `createScopedStore(scopedStore, "child")` creates
|
|
95
|
+
* IDs like "parent:child:cursor-id".
|
|
96
|
+
*
|
|
97
|
+
* When retrieving cursors, the scope prefix is automatically stripped,
|
|
98
|
+
* so consumers see clean IDs without the namespace prefix.
|
|
99
|
+
*
|
|
100
|
+
* @param store - Parent store to scope (CursorStore or another ScopedStore for nesting)
|
|
101
|
+
* @param scope - Namespace for this scope (will be prefixed to all cursor IDs)
|
|
102
|
+
* @returns ScopedStore with isolated cursor management
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* const store = createCursorStore();
|
|
107
|
+
* const issueStore = createScopedStore(store, "issues");
|
|
108
|
+
* const prStore = createScopedStore(store, "prs");
|
|
109
|
+
*
|
|
110
|
+
* // These don't conflict - different namespaces
|
|
111
|
+
* issueStore.set(cursor1); // Stored as "issues:abc123"
|
|
112
|
+
* prStore.set(cursor2); // Stored as "prs:abc123"
|
|
113
|
+
*
|
|
114
|
+
* // Retrieved cursors have clean IDs
|
|
115
|
+
* const result = issueStore.get("abc123");
|
|
116
|
+
* if (result.isOk()) {
|
|
117
|
+
* result.value.id; // "abc123" (not "issues:abc123")
|
|
118
|
+
* }
|
|
119
|
+
*
|
|
120
|
+
* // Clear only affects the scope
|
|
121
|
+
* issueStore.clear(); // Only clears issue cursors
|
|
122
|
+
* prStore.list(); // PR cursors still exist
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* // Nested scopes for hierarchical organization
|
|
128
|
+
* const store = createCursorStore();
|
|
129
|
+
* const githubStore = createScopedStore(store, "github");
|
|
130
|
+
* const issuesStore = createScopedStore(githubStore, "issues");
|
|
131
|
+
*
|
|
132
|
+
* issuesStore.getScope(); // "github:issues"
|
|
133
|
+
*
|
|
134
|
+
* // Cursor stored as "github:issues:cursor-id"
|
|
135
|
+
* issuesStore.set(cursor);
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
declare function createScopedStore(store: CursorStore | ScopedStore, scope: string): ScopedStore;
|
|
139
|
+
export { createCursorStore, createPersistentStore, createScopedStore };
|