@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/dist/index.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
+
for (let key of __getOwnPropNames(mod))
|
|
11
|
+
if (!__hasOwnProp.call(to, key))
|
|
12
|
+
__defProp(to, key, {
|
|
13
|
+
get: () => mod[key],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
NotFoundError,
|
|
25
|
+
Result,
|
|
26
|
+
ValidationError
|
|
27
|
+
} from "@outfitter/contracts";
|
|
28
|
+
function createCursor(options) {
|
|
29
|
+
if (options.position < 0) {
|
|
30
|
+
return Result.err(new ValidationError({
|
|
31
|
+
message: "Position must be non-negative",
|
|
32
|
+
field: "position"
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
const createdAt = Date.now();
|
|
36
|
+
const id = options.id ?? crypto.randomUUID();
|
|
37
|
+
const cursor = Object.freeze({
|
|
38
|
+
id,
|
|
39
|
+
position: options.position,
|
|
40
|
+
createdAt,
|
|
41
|
+
...options.metadata !== undefined && { metadata: options.metadata },
|
|
42
|
+
...options.ttl !== undefined && { ttl: options.ttl },
|
|
43
|
+
...options.ttl !== undefined && { expiresAt: createdAt + options.ttl }
|
|
44
|
+
});
|
|
45
|
+
return Result.ok(cursor);
|
|
46
|
+
}
|
|
47
|
+
function advanceCursor(cursor, newPosition) {
|
|
48
|
+
const newCursor = Object.freeze({
|
|
49
|
+
id: cursor.id,
|
|
50
|
+
position: newPosition,
|
|
51
|
+
createdAt: cursor.createdAt,
|
|
52
|
+
...cursor.metadata !== undefined && { metadata: cursor.metadata },
|
|
53
|
+
...cursor.ttl !== undefined && { ttl: cursor.ttl },
|
|
54
|
+
...cursor.expiresAt !== undefined && { expiresAt: cursor.expiresAt }
|
|
55
|
+
});
|
|
56
|
+
return newCursor;
|
|
57
|
+
}
|
|
58
|
+
function isExpired(cursor) {
|
|
59
|
+
if (cursor.expiresAt === undefined) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return Date.now() > cursor.expiresAt;
|
|
63
|
+
}
|
|
64
|
+
function toBase64Url(base64) {
|
|
65
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
66
|
+
}
|
|
67
|
+
var base64Encoder = new TextEncoder;
|
|
68
|
+
var base64Decoder = new TextDecoder;
|
|
69
|
+
function toBase64(value) {
|
|
70
|
+
const bytes = base64Encoder.encode(value);
|
|
71
|
+
let binary = "";
|
|
72
|
+
for (const byte of bytes) {
|
|
73
|
+
binary += String.fromCharCode(byte);
|
|
74
|
+
}
|
|
75
|
+
return btoa(binary);
|
|
76
|
+
}
|
|
77
|
+
function fromBase64(base64) {
|
|
78
|
+
const binary = atob(base64);
|
|
79
|
+
const bytes = new Uint8Array(binary.length);
|
|
80
|
+
for (let i = 0;i < binary.length; i += 1) {
|
|
81
|
+
bytes[i] = binary.charCodeAt(i);
|
|
82
|
+
}
|
|
83
|
+
return base64Decoder.decode(bytes);
|
|
84
|
+
}
|
|
85
|
+
function fromBase64Url(base64Url) {
|
|
86
|
+
let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
87
|
+
const padLength = (4 - base64.length % 4) % 4;
|
|
88
|
+
base64 += "=".repeat(padLength);
|
|
89
|
+
return base64;
|
|
90
|
+
}
|
|
91
|
+
function encodeCursor(cursor) {
|
|
92
|
+
const json = JSON.stringify(cursor);
|
|
93
|
+
const base64 = toBase64(json);
|
|
94
|
+
return toBase64Url(base64);
|
|
95
|
+
}
|
|
96
|
+
function decodeCursor(encoded) {
|
|
97
|
+
let json;
|
|
98
|
+
try {
|
|
99
|
+
const base64 = fromBase64Url(encoded);
|
|
100
|
+
json = fromBase64(base64);
|
|
101
|
+
} catch {
|
|
102
|
+
return Result.err(new ValidationError({
|
|
103
|
+
message: "Invalid cursor: failed to decode base64",
|
|
104
|
+
field: "cursor"
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
let data;
|
|
108
|
+
try {
|
|
109
|
+
data = JSON.parse(json);
|
|
110
|
+
} catch {
|
|
111
|
+
return Result.err(new ValidationError({
|
|
112
|
+
message: "Invalid cursor: failed to parse JSON",
|
|
113
|
+
field: "cursor"
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
if (typeof data !== "object" || data === null) {
|
|
117
|
+
return Result.err(new ValidationError({
|
|
118
|
+
message: "Invalid cursor: expected object",
|
|
119
|
+
field: "cursor"
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
const obj = data;
|
|
123
|
+
if (typeof obj.id !== "string") {
|
|
124
|
+
return Result.err(new ValidationError({
|
|
125
|
+
message: "Invalid cursor: missing or invalid 'id' field",
|
|
126
|
+
field: "cursor.id"
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
if (typeof obj.position !== "number") {
|
|
130
|
+
return Result.err(new ValidationError({
|
|
131
|
+
message: "Invalid cursor: missing or invalid 'position' field",
|
|
132
|
+
field: "cursor.position"
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
if (obj.position < 0) {
|
|
136
|
+
return Result.err(new ValidationError({
|
|
137
|
+
message: "Invalid cursor: position must be non-negative",
|
|
138
|
+
field: "cursor.position"
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
if (typeof obj.createdAt !== "number") {
|
|
142
|
+
return Result.err(new ValidationError({
|
|
143
|
+
message: "Invalid cursor: missing or invalid 'createdAt' field",
|
|
144
|
+
field: "cursor.createdAt"
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
if (obj.metadata !== undefined && (typeof obj.metadata !== "object" || obj.metadata === null)) {
|
|
148
|
+
return Result.err(new ValidationError({
|
|
149
|
+
message: "Invalid cursor: 'metadata' must be an object",
|
|
150
|
+
field: "cursor.metadata"
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
if (obj.ttl !== undefined && typeof obj.ttl !== "number") {
|
|
154
|
+
return Result.err(new ValidationError({
|
|
155
|
+
message: "Invalid cursor: 'ttl' must be a number",
|
|
156
|
+
field: "cursor.ttl"
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
if (obj.expiresAt !== undefined && typeof obj.expiresAt !== "number") {
|
|
160
|
+
return Result.err(new ValidationError({
|
|
161
|
+
message: "Invalid cursor: 'expiresAt' must be a number",
|
|
162
|
+
field: "cursor.expiresAt"
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
const cursor = Object.freeze({
|
|
166
|
+
id: obj.id,
|
|
167
|
+
position: obj.position,
|
|
168
|
+
createdAt: obj.createdAt,
|
|
169
|
+
...obj.metadata !== undefined && {
|
|
170
|
+
metadata: obj.metadata
|
|
171
|
+
},
|
|
172
|
+
...obj.ttl !== undefined && { ttl: obj.ttl },
|
|
173
|
+
...obj.expiresAt !== undefined && { expiresAt: obj.expiresAt }
|
|
174
|
+
});
|
|
175
|
+
return Result.ok(cursor);
|
|
176
|
+
}
|
|
177
|
+
function createCursorStore() {
|
|
178
|
+
const cursors = new Map;
|
|
179
|
+
return {
|
|
180
|
+
set(cursor) {
|
|
181
|
+
cursors.set(cursor.id, cursor);
|
|
182
|
+
},
|
|
183
|
+
get(id) {
|
|
184
|
+
const cursor = cursors.get(id);
|
|
185
|
+
if (cursor === undefined) {
|
|
186
|
+
return Result.err(new NotFoundError({
|
|
187
|
+
message: `Cursor not found: ${id}`,
|
|
188
|
+
resourceType: "cursor",
|
|
189
|
+
resourceId: id
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
if (isExpired(cursor)) {
|
|
193
|
+
return Result.err(new NotFoundError({
|
|
194
|
+
message: `Cursor expired: ${id}`,
|
|
195
|
+
resourceType: "cursor",
|
|
196
|
+
resourceId: id
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
return Result.ok(cursor);
|
|
200
|
+
},
|
|
201
|
+
has(id) {
|
|
202
|
+
const cursor = cursors.get(id);
|
|
203
|
+
if (cursor === undefined) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return !isExpired(cursor);
|
|
207
|
+
},
|
|
208
|
+
delete(id) {
|
|
209
|
+
cursors.delete(id);
|
|
210
|
+
},
|
|
211
|
+
clear() {
|
|
212
|
+
cursors.clear();
|
|
213
|
+
},
|
|
214
|
+
list() {
|
|
215
|
+
return Array.from(cursors.keys());
|
|
216
|
+
},
|
|
217
|
+
prune() {
|
|
218
|
+
let count = 0;
|
|
219
|
+
for (const [id, cursor] of cursors) {
|
|
220
|
+
if (isExpired(cursor)) {
|
|
221
|
+
cursors.delete(id);
|
|
222
|
+
count++;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
},
|
|
227
|
+
getScope() {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function createPersistentStore(options) {
|
|
233
|
+
const { path: storagePath } = options;
|
|
234
|
+
const cursors = new Map;
|
|
235
|
+
if (existsSync(storagePath)) {
|
|
236
|
+
try {
|
|
237
|
+
const content = await Bun.file(storagePath).text();
|
|
238
|
+
const data = JSON.parse(content);
|
|
239
|
+
if (data.cursors && typeof data.cursors === "object") {
|
|
240
|
+
for (const [id, cursor] of Object.entries(data.cursors)) {
|
|
241
|
+
cursors.set(id, cursor);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
const flush = async () => {
|
|
247
|
+
const dir = dirname(storagePath);
|
|
248
|
+
if (!existsSync(dir)) {
|
|
249
|
+
mkdirSync(dir, { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
const data = {
|
|
252
|
+
cursors: Object.fromEntries(cursors)
|
|
253
|
+
};
|
|
254
|
+
const tempPath = `${storagePath}.tmp.${Date.now()}`;
|
|
255
|
+
const content = JSON.stringify(data, null, 2);
|
|
256
|
+
try {
|
|
257
|
+
writeFileSync(tempPath, content, { encoding: "utf-8" });
|
|
258
|
+
renameSync(tempPath, storagePath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
try {
|
|
261
|
+
const { unlinkSync } = await import("node:fs");
|
|
262
|
+
unlinkSync(tempPath);
|
|
263
|
+
} catch {}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const dispose = () => {};
|
|
268
|
+
return {
|
|
269
|
+
set(cursor) {
|
|
270
|
+
cursors.set(cursor.id, cursor);
|
|
271
|
+
},
|
|
272
|
+
get(id) {
|
|
273
|
+
const cursor = cursors.get(id);
|
|
274
|
+
if (cursor === undefined) {
|
|
275
|
+
return Result.err(new NotFoundError({
|
|
276
|
+
message: `Cursor not found: ${id}`,
|
|
277
|
+
resourceType: "cursor",
|
|
278
|
+
resourceId: id
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
if (isExpired(cursor)) {
|
|
282
|
+
return Result.err(new NotFoundError({
|
|
283
|
+
message: `Cursor expired: ${id}`,
|
|
284
|
+
resourceType: "cursor",
|
|
285
|
+
resourceId: id
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
return Result.ok(cursor);
|
|
289
|
+
},
|
|
290
|
+
has(id) {
|
|
291
|
+
const cursor = cursors.get(id);
|
|
292
|
+
if (cursor === undefined) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
return !isExpired(cursor);
|
|
296
|
+
},
|
|
297
|
+
delete(id) {
|
|
298
|
+
cursors.delete(id);
|
|
299
|
+
},
|
|
300
|
+
clear() {
|
|
301
|
+
cursors.clear();
|
|
302
|
+
},
|
|
303
|
+
list() {
|
|
304
|
+
return Array.from(cursors.keys());
|
|
305
|
+
},
|
|
306
|
+
prune() {
|
|
307
|
+
let count = 0;
|
|
308
|
+
for (const [id, cursor] of cursors) {
|
|
309
|
+
if (isExpired(cursor)) {
|
|
310
|
+
cursors.delete(id);
|
|
311
|
+
count++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return count;
|
|
315
|
+
},
|
|
316
|
+
flush,
|
|
317
|
+
dispose
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function createScopedStore(store, scope) {
|
|
321
|
+
const parentScope = "getScope" in store ? store.getScope() : "";
|
|
322
|
+
const fullScope = parentScope ? `${parentScope}:${scope}` : scope;
|
|
323
|
+
const prefix = `${fullScope}:`;
|
|
324
|
+
return {
|
|
325
|
+
set(cursor) {
|
|
326
|
+
const scopedCursor = Object.freeze({
|
|
327
|
+
...cursor,
|
|
328
|
+
id: `${prefix}${cursor.id}`
|
|
329
|
+
});
|
|
330
|
+
store.set(scopedCursor);
|
|
331
|
+
},
|
|
332
|
+
get(id) {
|
|
333
|
+
const result = store.get(`${prefix}${id}`);
|
|
334
|
+
if (result.isErr()) {
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
const cursor = result.value;
|
|
338
|
+
return Result.ok(Object.freeze({
|
|
339
|
+
...cursor,
|
|
340
|
+
id: cursor.id.slice(prefix.length)
|
|
341
|
+
}));
|
|
342
|
+
},
|
|
343
|
+
has(id) {
|
|
344
|
+
return store.has(`${prefix}${id}`);
|
|
345
|
+
},
|
|
346
|
+
delete(id) {
|
|
347
|
+
store.delete(`${prefix}${id}`);
|
|
348
|
+
},
|
|
349
|
+
clear() {
|
|
350
|
+
const ids = store.list().filter((id) => id.startsWith(prefix));
|
|
351
|
+
for (const id of ids) {
|
|
352
|
+
store.delete(id);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
list() {
|
|
356
|
+
return store.list().filter((id) => id.startsWith(prefix)).map((id) => id.slice(prefix.length));
|
|
357
|
+
},
|
|
358
|
+
prune() {
|
|
359
|
+
return store.prune();
|
|
360
|
+
},
|
|
361
|
+
getScope() {
|
|
362
|
+
return fullScope;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
var DEFAULT_PAGE_LIMIT = 25;
|
|
367
|
+
var defaultPaginationStore = null;
|
|
368
|
+
function getDefaultPaginationStore() {
|
|
369
|
+
if (defaultPaginationStore === null) {
|
|
370
|
+
defaultPaginationStore = createPaginationStore();
|
|
371
|
+
}
|
|
372
|
+
return defaultPaginationStore;
|
|
373
|
+
}
|
|
374
|
+
function createPaginationStore() {
|
|
375
|
+
const cursors = new Map;
|
|
376
|
+
return {
|
|
377
|
+
get(id) {
|
|
378
|
+
return cursors.get(id) ?? null;
|
|
379
|
+
},
|
|
380
|
+
set(id, cursor) {
|
|
381
|
+
cursors.set(id, cursor);
|
|
382
|
+
},
|
|
383
|
+
delete(id) {
|
|
384
|
+
cursors.delete(id);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function paginate(items, cursor) {
|
|
389
|
+
const offset = cursor.position;
|
|
390
|
+
const rawLimit = cursor.metadata?.limit;
|
|
391
|
+
const limit = typeof rawLimit === "number" && Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : DEFAULT_PAGE_LIMIT;
|
|
392
|
+
const page = items.slice(offset, offset + limit);
|
|
393
|
+
const nextPosition = offset + page.length;
|
|
394
|
+
if (nextPosition >= items.length) {
|
|
395
|
+
return { page, nextCursor: null };
|
|
396
|
+
}
|
|
397
|
+
const nextCursor = advanceCursor(cursor, nextPosition);
|
|
398
|
+
return { page, nextCursor };
|
|
399
|
+
}
|
|
400
|
+
function loadCursor(id, store) {
|
|
401
|
+
const effectiveStore = store ?? getDefaultPaginationStore();
|
|
402
|
+
const cursor = effectiveStore.get(id);
|
|
403
|
+
return Result.ok(cursor);
|
|
404
|
+
}
|
|
405
|
+
function saveCursor(cursor, store) {
|
|
406
|
+
const effectiveStore = store ?? getDefaultPaginationStore();
|
|
407
|
+
effectiveStore.set(cursor.id, cursor);
|
|
408
|
+
return Result.ok(undefined);
|
|
409
|
+
}
|
|
410
|
+
export {
|
|
411
|
+
saveCursor,
|
|
412
|
+
paginate,
|
|
413
|
+
loadCursor,
|
|
414
|
+
isExpired,
|
|
415
|
+
getDefaultPaginationStore,
|
|
416
|
+
encodeCursor,
|
|
417
|
+
decodeCursor,
|
|
418
|
+
createScopedStore,
|
|
419
|
+
createPersistentStore,
|
|
420
|
+
createPaginationStore,
|
|
421
|
+
createCursorStore,
|
|
422
|
+
createCursor,
|
|
423
|
+
advanceCursor,
|
|
424
|
+
DEFAULT_PAGE_LIMIT
|
|
425
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@outfitter/state",
|
|
3
|
+
"description": "Pagination cursor persistence and state management for Outfitter",
|
|
4
|
+
"version": "0.1.0-rc.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "bunup --filter @outfitter/state",
|
|
23
|
+
"lint": "biome lint ./src",
|
|
24
|
+
"lint:fix": "biome lint --write ./src",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"clean": "rm -rf dist"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@outfitter/contracts": "workspace:*",
|
|
31
|
+
"@outfitter/types": "workspace:*"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "latest",
|
|
35
|
+
"typescript": "^5.8.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"outfitter",
|
|
39
|
+
"state",
|
|
40
|
+
"pagination",
|
|
41
|
+
"cursor",
|
|
42
|
+
"typescript"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/outfitter-dev/outfitter.git",
|
|
48
|
+
"directory": "packages/state"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|