@lp-labs/task-checkpoint 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +64 -2
- package/dist/cjs/index.d.ts +26 -0
- package/dist/cjs/index.js +152 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +26 -0
- package/dist/esm/index.js +146 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Libin Prasanth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lp-labs/task-checkpoint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Canonical JSON checkpoint serialization, corruption checks, and validation primitives. This
|
|
4
|
+
package is a serialization/checksum core only; it does not persist, schedule, or resume tasks.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @lp-labs/task-checkpoint
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
ES2022 ESM and CommonJS builds include TypeScript declarations.
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import {
|
|
18
|
+
createTaskCheckpoint,
|
|
19
|
+
parseTaskCheckpoint,
|
|
20
|
+
serializeTaskCheckpoint,
|
|
21
|
+
} from '@lp-labs/task-checkpoint';
|
|
22
|
+
|
|
23
|
+
const checkpoint = createTaskCheckpoint('job-17', 3, { completed: ['a'], remaining: 2 });
|
|
24
|
+
const encoded = serializeTaskCheckpoint(checkpoint);
|
|
25
|
+
const restored = parseTaskCheckpoint(encoded);
|
|
26
|
+
if (restored.state.remaining !== 2) throw new Error('Checkpoint did not round-trip');
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## API and customization
|
|
30
|
+
|
|
31
|
+
- `createTaskCheckpoint(taskId, revision, state, options?)` copies and canonicalizes JSON state,
|
|
32
|
+
sorts object keys, and calculates its checksum.
|
|
33
|
+
- `serializeTaskCheckpoint(checkpoint, options?)` validates its format and checksum before returning
|
|
34
|
+
JSON text.
|
|
35
|
+
- `parseTaskCheckpoint<T>(text, options?)` parses, bounds, and validates serialized input.
|
|
36
|
+
- `validateTaskCheckpoint<T>(input, options?)` validates an already-parsed value and returns a
|
|
37
|
+
detached checkpoint.
|
|
38
|
+
- `CheckpointLimits` options are `maxBytes` (default 1 MiB), `maxDepth` (64), and `maxNodes`
|
|
39
|
+
(100,000). Limits can be customized per operation.
|
|
40
|
+
|
|
41
|
+
Task IDs must be 1–256 characters; revisions are non-negative safe integers. State is plain JSON:
|
|
42
|
+
null, booleans, strings, finite numbers, arrays, and plain objects. Cycles, undefined, functions,
|
|
43
|
+
non-finite numbers, and class instances are rejected. Object keys are sorted so equivalent
|
|
44
|
+
unordered records produce the same checksum.
|
|
45
|
+
|
|
46
|
+
## Integrity and scope
|
|
47
|
+
|
|
48
|
+
The checksum is a deterministic 64-bit FNV-1a corruption check, not a cryptographic signature,
|
|
49
|
+
authentication mechanism, or protection against malicious modification. Treat serialized input as
|
|
50
|
+
untrusted and apply application-level trust checks. JSON numbers retain JavaScript number semantics.
|
|
51
|
+
No storage, compression, encryption, schema migration, task execution, or durability is provided.
|
|
52
|
+
|
|
53
|
+
## Exports
|
|
54
|
+
|
|
55
|
+
Runtime exports: `createTaskCheckpoint`, `serializeTaskCheckpoint`, `parseTaskCheckpoint`,
|
|
56
|
+
`validateTaskCheckpoint`.
|
|
57
|
+
TypeScript-only exports: `CheckpointValue`, `TaskCheckpoint`, `CheckpointLimits`.
|
|
58
|
+
|
|
59
|
+
## Support
|
|
60
|
+
|
|
61
|
+
If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
|
|
62
|
+
|
|
63
|
+
## Author
|
|
64
|
+
|
|
65
|
+
Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
declare const FORMAT = "lp-task-checkpoint";
|
|
2
|
+
declare const FORMAT_VERSION = 1;
|
|
3
|
+
export type CheckpointValue = null | boolean | number | string | CheckpointValue[] | {
|
|
4
|
+
[key: string]: CheckpointValue;
|
|
5
|
+
};
|
|
6
|
+
export interface TaskCheckpoint<T extends CheckpointValue = CheckpointValue> {
|
|
7
|
+
readonly format: typeof FORMAT;
|
|
8
|
+
readonly version: typeof FORMAT_VERSION;
|
|
9
|
+
readonly taskId: string;
|
|
10
|
+
readonly revision: number;
|
|
11
|
+
readonly checksum: string;
|
|
12
|
+
readonly state: T;
|
|
13
|
+
}
|
|
14
|
+
export interface CheckpointLimits {
|
|
15
|
+
readonly maxBytes?: number;
|
|
16
|
+
readonly maxDepth?: number;
|
|
17
|
+
readonly maxNodes?: number;
|
|
18
|
+
}
|
|
19
|
+
interface CheckpointOptions {
|
|
20
|
+
readonly limits?: CheckpointLimits;
|
|
21
|
+
}
|
|
22
|
+
export declare function createTaskCheckpoint<T extends CheckpointValue>(taskId: string, revision: number, state: T, options?: CheckpointOptions): TaskCheckpoint<T>;
|
|
23
|
+
export declare function serializeTaskCheckpoint<T extends CheckpointValue>(checkpoint: TaskCheckpoint<T>, options?: CheckpointOptions): string;
|
|
24
|
+
export declare function validateTaskCheckpoint<T extends CheckpointValue>(input: unknown, options?: CheckpointOptions): TaskCheckpoint<T>;
|
|
25
|
+
export declare function parseTaskCheckpoint<T extends CheckpointValue = CheckpointValue>(serialized: string, options?: CheckpointOptions): TaskCheckpoint<T>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTaskCheckpoint = createTaskCheckpoint;
|
|
4
|
+
exports.serializeTaskCheckpoint = serializeTaskCheckpoint;
|
|
5
|
+
exports.validateTaskCheckpoint = validateTaskCheckpoint;
|
|
6
|
+
exports.parseTaskCheckpoint = parseTaskCheckpoint;
|
|
7
|
+
const FORMAT = 'lp-task-checkpoint';
|
|
8
|
+
const FORMAT_VERSION = 1;
|
|
9
|
+
const DEFAULT_MAX_BYTES = 1_048_576;
|
|
10
|
+
const DEFAULT_MAX_DEPTH = 64;
|
|
11
|
+
const DEFAULT_MAX_NODES = 100_000;
|
|
12
|
+
function limitsOf(limits = {}) {
|
|
13
|
+
const result = {
|
|
14
|
+
maxBytes: limits.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
15
|
+
maxDepth: limits.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
16
|
+
maxNodes: limits.maxNodes ?? DEFAULT_MAX_NODES,
|
|
17
|
+
};
|
|
18
|
+
if (!Number.isSafeInteger(result.maxBytes) ||
|
|
19
|
+
result.maxBytes < 1 ||
|
|
20
|
+
!Number.isSafeInteger(result.maxDepth) ||
|
|
21
|
+
result.maxDepth < 0 ||
|
|
22
|
+
!Number.isSafeInteger(result.maxNodes) ||
|
|
23
|
+
result.maxNodes < 1) {
|
|
24
|
+
throw new RangeError('Checkpoint limits must be positive safe integers (depth may be zero)');
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function canonicalize(value, maxDepth, maxNodes) {
|
|
29
|
+
let nodes = 0;
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const visit = (item, depth) => {
|
|
32
|
+
nodes++;
|
|
33
|
+
if (nodes > maxNodes)
|
|
34
|
+
throw new RangeError('Checkpoint exceeds the configured node limit');
|
|
35
|
+
if (depth > maxDepth)
|
|
36
|
+
throw new RangeError('Checkpoint exceeds the configured depth limit');
|
|
37
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean')
|
|
38
|
+
return item;
|
|
39
|
+
if (typeof item === 'number' && Number.isFinite(item))
|
|
40
|
+
return item;
|
|
41
|
+
if (Array.isArray(item)) {
|
|
42
|
+
if (seen.has(item))
|
|
43
|
+
throw new TypeError('Checkpoint state cannot contain cycles');
|
|
44
|
+
seen.add(item);
|
|
45
|
+
const copy = Array.from(item, (entry) => visit(entry, depth + 1));
|
|
46
|
+
seen.delete(item);
|
|
47
|
+
return copy;
|
|
48
|
+
}
|
|
49
|
+
if (typeof item === 'object' && Object.getPrototypeOf(item) === Object.prototype) {
|
|
50
|
+
if (seen.has(item))
|
|
51
|
+
throw new TypeError('Checkpoint state cannot contain cycles');
|
|
52
|
+
seen.add(item);
|
|
53
|
+
const copy = {};
|
|
54
|
+
if (Object.getOwnPropertySymbols(item).length > 0) {
|
|
55
|
+
throw new TypeError('Checkpoint state cannot contain symbol keys');
|
|
56
|
+
}
|
|
57
|
+
for (const key of Object.keys(item).sort()) {
|
|
58
|
+
const entry = item[key];
|
|
59
|
+
if (entry === undefined)
|
|
60
|
+
throw new TypeError('Checkpoint state cannot contain undefined');
|
|
61
|
+
Object.defineProperty(copy, key, {
|
|
62
|
+
value: visit(entry, depth + 1),
|
|
63
|
+
enumerable: true,
|
|
64
|
+
configurable: true,
|
|
65
|
+
writable: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
seen.delete(item);
|
|
69
|
+
return copy;
|
|
70
|
+
}
|
|
71
|
+
throw new TypeError('Checkpoint state must contain only finite JSON values and plain objects');
|
|
72
|
+
};
|
|
73
|
+
return visit(value, 0);
|
|
74
|
+
}
|
|
75
|
+
function checksum(text) {
|
|
76
|
+
let hash = 0xcbf29ce484222325n;
|
|
77
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
78
|
+
hash ^= BigInt(byte);
|
|
79
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
80
|
+
}
|
|
81
|
+
return hash.toString(16).padStart(16, '0');
|
|
82
|
+
}
|
|
83
|
+
function checksumInput(taskId, revision, state) {
|
|
84
|
+
return JSON.stringify([FORMAT, FORMAT_VERSION, taskId, revision, state]);
|
|
85
|
+
}
|
|
86
|
+
function validateIdAndRevision(taskId, revision) {
|
|
87
|
+
if (typeof taskId !== 'string' || taskId.length === 0 || taskId.length > 256) {
|
|
88
|
+
throw new TypeError('taskId must be a non-empty string of at most 256 characters');
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isSafeInteger(revision) || revision < 0) {
|
|
91
|
+
throw new RangeError('revision must be a non-negative safe integer');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function createTaskCheckpoint(taskId, revision, state, options = {}) {
|
|
95
|
+
validateIdAndRevision(taskId, revision);
|
|
96
|
+
const limits = limitsOf(options.limits);
|
|
97
|
+
const copy = canonicalize(state, limits.maxDepth, limits.maxNodes);
|
|
98
|
+
const result = {
|
|
99
|
+
format: FORMAT,
|
|
100
|
+
version: FORMAT_VERSION,
|
|
101
|
+
taskId,
|
|
102
|
+
revision,
|
|
103
|
+
checksum: checksum(checksumInput(taskId, revision, copy)),
|
|
104
|
+
state: copy,
|
|
105
|
+
};
|
|
106
|
+
if (new TextEncoder().encode(JSON.stringify(result)).byteLength > limits.maxBytes) {
|
|
107
|
+
throw new RangeError('Checkpoint exceeds the configured byte limit');
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function serializeTaskCheckpoint(checkpoint, options = {}) {
|
|
112
|
+
const validated = validateTaskCheckpoint(checkpoint, options);
|
|
113
|
+
return JSON.stringify(validated);
|
|
114
|
+
}
|
|
115
|
+
function validateTaskCheckpoint(input, options = {}) {
|
|
116
|
+
if (typeof input !== 'object' ||
|
|
117
|
+
input === null ||
|
|
118
|
+
Array.isArray(input) ||
|
|
119
|
+
Object.getPrototypeOf(input) !== Object.prototype) {
|
|
120
|
+
throw new TypeError('Checkpoint must be a plain object');
|
|
121
|
+
}
|
|
122
|
+
const value = input;
|
|
123
|
+
const allowedKeys = new Set(['format', 'version', 'taskId', 'revision', 'checksum', 'state']);
|
|
124
|
+
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
|
125
|
+
throw new TypeError('Checkpoint contains unknown fields');
|
|
126
|
+
}
|
|
127
|
+
if (value.format !== FORMAT || value.version !== FORMAT_VERSION) {
|
|
128
|
+
throw new TypeError('Unsupported checkpoint format or version');
|
|
129
|
+
}
|
|
130
|
+
if (typeof value.checksum !== 'string')
|
|
131
|
+
throw new TypeError('Checkpoint checksum is missing');
|
|
132
|
+
const checkpoint = createTaskCheckpoint(value.taskId, value.revision, value.state, options);
|
|
133
|
+
if (value.checksum !== checkpoint.checksum)
|
|
134
|
+
throw new TypeError('Checkpoint checksum mismatch');
|
|
135
|
+
return checkpoint;
|
|
136
|
+
}
|
|
137
|
+
function parseTaskCheckpoint(serialized, options = {}) {
|
|
138
|
+
const maxBytes = limitsOf(options.limits).maxBytes;
|
|
139
|
+
if (typeof serialized !== 'string')
|
|
140
|
+
throw new TypeError('Serialized checkpoint must be a string');
|
|
141
|
+
if (new TextEncoder().encode(serialized).byteLength > maxBytes) {
|
|
142
|
+
throw new RangeError('Checkpoint exceeds the configured byte limit');
|
|
143
|
+
}
|
|
144
|
+
let input;
|
|
145
|
+
try {
|
|
146
|
+
input = JSON.parse(serialized);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new TypeError('Serialized checkpoint is not valid JSON');
|
|
150
|
+
}
|
|
151
|
+
return validateTaskCheckpoint(input, options);
|
|
152
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
declare const FORMAT = "lp-task-checkpoint";
|
|
2
|
+
declare const FORMAT_VERSION = 1;
|
|
3
|
+
export type CheckpointValue = null | boolean | number | string | CheckpointValue[] | {
|
|
4
|
+
[key: string]: CheckpointValue;
|
|
5
|
+
};
|
|
6
|
+
export interface TaskCheckpoint<T extends CheckpointValue = CheckpointValue> {
|
|
7
|
+
readonly format: typeof FORMAT;
|
|
8
|
+
readonly version: typeof FORMAT_VERSION;
|
|
9
|
+
readonly taskId: string;
|
|
10
|
+
readonly revision: number;
|
|
11
|
+
readonly checksum: string;
|
|
12
|
+
readonly state: T;
|
|
13
|
+
}
|
|
14
|
+
export interface CheckpointLimits {
|
|
15
|
+
readonly maxBytes?: number;
|
|
16
|
+
readonly maxDepth?: number;
|
|
17
|
+
readonly maxNodes?: number;
|
|
18
|
+
}
|
|
19
|
+
interface CheckpointOptions {
|
|
20
|
+
readonly limits?: CheckpointLimits;
|
|
21
|
+
}
|
|
22
|
+
export declare function createTaskCheckpoint<T extends CheckpointValue>(taskId: string, revision: number, state: T, options?: CheckpointOptions): TaskCheckpoint<T>;
|
|
23
|
+
export declare function serializeTaskCheckpoint<T extends CheckpointValue>(checkpoint: TaskCheckpoint<T>, options?: CheckpointOptions): string;
|
|
24
|
+
export declare function validateTaskCheckpoint<T extends CheckpointValue>(input: unknown, options?: CheckpointOptions): TaskCheckpoint<T>;
|
|
25
|
+
export declare function parseTaskCheckpoint<T extends CheckpointValue = CheckpointValue>(serialized: string, options?: CheckpointOptions): TaskCheckpoint<T>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const FORMAT = 'lp-task-checkpoint';
|
|
2
|
+
const FORMAT_VERSION = 1;
|
|
3
|
+
const DEFAULT_MAX_BYTES = 1_048_576;
|
|
4
|
+
const DEFAULT_MAX_DEPTH = 64;
|
|
5
|
+
const DEFAULT_MAX_NODES = 100_000;
|
|
6
|
+
function limitsOf(limits = {}) {
|
|
7
|
+
const result = {
|
|
8
|
+
maxBytes: limits.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
9
|
+
maxDepth: limits.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
10
|
+
maxNodes: limits.maxNodes ?? DEFAULT_MAX_NODES,
|
|
11
|
+
};
|
|
12
|
+
if (!Number.isSafeInteger(result.maxBytes) ||
|
|
13
|
+
result.maxBytes < 1 ||
|
|
14
|
+
!Number.isSafeInteger(result.maxDepth) ||
|
|
15
|
+
result.maxDepth < 0 ||
|
|
16
|
+
!Number.isSafeInteger(result.maxNodes) ||
|
|
17
|
+
result.maxNodes < 1) {
|
|
18
|
+
throw new RangeError('Checkpoint limits must be positive safe integers (depth may be zero)');
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
function canonicalize(value, maxDepth, maxNodes) {
|
|
23
|
+
let nodes = 0;
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const visit = (item, depth) => {
|
|
26
|
+
nodes++;
|
|
27
|
+
if (nodes > maxNodes)
|
|
28
|
+
throw new RangeError('Checkpoint exceeds the configured node limit');
|
|
29
|
+
if (depth > maxDepth)
|
|
30
|
+
throw new RangeError('Checkpoint exceeds the configured depth limit');
|
|
31
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean')
|
|
32
|
+
return item;
|
|
33
|
+
if (typeof item === 'number' && Number.isFinite(item))
|
|
34
|
+
return item;
|
|
35
|
+
if (Array.isArray(item)) {
|
|
36
|
+
if (seen.has(item))
|
|
37
|
+
throw new TypeError('Checkpoint state cannot contain cycles');
|
|
38
|
+
seen.add(item);
|
|
39
|
+
const copy = Array.from(item, (entry) => visit(entry, depth + 1));
|
|
40
|
+
seen.delete(item);
|
|
41
|
+
return copy;
|
|
42
|
+
}
|
|
43
|
+
if (typeof item === 'object' && Object.getPrototypeOf(item) === Object.prototype) {
|
|
44
|
+
if (seen.has(item))
|
|
45
|
+
throw new TypeError('Checkpoint state cannot contain cycles');
|
|
46
|
+
seen.add(item);
|
|
47
|
+
const copy = {};
|
|
48
|
+
if (Object.getOwnPropertySymbols(item).length > 0) {
|
|
49
|
+
throw new TypeError('Checkpoint state cannot contain symbol keys');
|
|
50
|
+
}
|
|
51
|
+
for (const key of Object.keys(item).sort()) {
|
|
52
|
+
const entry = item[key];
|
|
53
|
+
if (entry === undefined)
|
|
54
|
+
throw new TypeError('Checkpoint state cannot contain undefined');
|
|
55
|
+
Object.defineProperty(copy, key, {
|
|
56
|
+
value: visit(entry, depth + 1),
|
|
57
|
+
enumerable: true,
|
|
58
|
+
configurable: true,
|
|
59
|
+
writable: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
seen.delete(item);
|
|
63
|
+
return copy;
|
|
64
|
+
}
|
|
65
|
+
throw new TypeError('Checkpoint state must contain only finite JSON values and plain objects');
|
|
66
|
+
};
|
|
67
|
+
return visit(value, 0);
|
|
68
|
+
}
|
|
69
|
+
function checksum(text) {
|
|
70
|
+
let hash = 0xcbf29ce484222325n;
|
|
71
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
72
|
+
hash ^= BigInt(byte);
|
|
73
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
74
|
+
}
|
|
75
|
+
return hash.toString(16).padStart(16, '0');
|
|
76
|
+
}
|
|
77
|
+
function checksumInput(taskId, revision, state) {
|
|
78
|
+
return JSON.stringify([FORMAT, FORMAT_VERSION, taskId, revision, state]);
|
|
79
|
+
}
|
|
80
|
+
function validateIdAndRevision(taskId, revision) {
|
|
81
|
+
if (typeof taskId !== 'string' || taskId.length === 0 || taskId.length > 256) {
|
|
82
|
+
throw new TypeError('taskId must be a non-empty string of at most 256 characters');
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isSafeInteger(revision) || revision < 0) {
|
|
85
|
+
throw new RangeError('revision must be a non-negative safe integer');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function createTaskCheckpoint(taskId, revision, state, options = {}) {
|
|
89
|
+
validateIdAndRevision(taskId, revision);
|
|
90
|
+
const limits = limitsOf(options.limits);
|
|
91
|
+
const copy = canonicalize(state, limits.maxDepth, limits.maxNodes);
|
|
92
|
+
const result = {
|
|
93
|
+
format: FORMAT,
|
|
94
|
+
version: FORMAT_VERSION,
|
|
95
|
+
taskId,
|
|
96
|
+
revision,
|
|
97
|
+
checksum: checksum(checksumInput(taskId, revision, copy)),
|
|
98
|
+
state: copy,
|
|
99
|
+
};
|
|
100
|
+
if (new TextEncoder().encode(JSON.stringify(result)).byteLength > limits.maxBytes) {
|
|
101
|
+
throw new RangeError('Checkpoint exceeds the configured byte limit');
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
export function serializeTaskCheckpoint(checkpoint, options = {}) {
|
|
106
|
+
const validated = validateTaskCheckpoint(checkpoint, options);
|
|
107
|
+
return JSON.stringify(validated);
|
|
108
|
+
}
|
|
109
|
+
export function validateTaskCheckpoint(input, options = {}) {
|
|
110
|
+
if (typeof input !== 'object' ||
|
|
111
|
+
input === null ||
|
|
112
|
+
Array.isArray(input) ||
|
|
113
|
+
Object.getPrototypeOf(input) !== Object.prototype) {
|
|
114
|
+
throw new TypeError('Checkpoint must be a plain object');
|
|
115
|
+
}
|
|
116
|
+
const value = input;
|
|
117
|
+
const allowedKeys = new Set(['format', 'version', 'taskId', 'revision', 'checksum', 'state']);
|
|
118
|
+
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
|
119
|
+
throw new TypeError('Checkpoint contains unknown fields');
|
|
120
|
+
}
|
|
121
|
+
if (value.format !== FORMAT || value.version !== FORMAT_VERSION) {
|
|
122
|
+
throw new TypeError('Unsupported checkpoint format or version');
|
|
123
|
+
}
|
|
124
|
+
if (typeof value.checksum !== 'string')
|
|
125
|
+
throw new TypeError('Checkpoint checksum is missing');
|
|
126
|
+
const checkpoint = createTaskCheckpoint(value.taskId, value.revision, value.state, options);
|
|
127
|
+
if (value.checksum !== checkpoint.checksum)
|
|
128
|
+
throw new TypeError('Checkpoint checksum mismatch');
|
|
129
|
+
return checkpoint;
|
|
130
|
+
}
|
|
131
|
+
export function parseTaskCheckpoint(serialized, options = {}) {
|
|
132
|
+
const maxBytes = limitsOf(options.limits).maxBytes;
|
|
133
|
+
if (typeof serialized !== 'string')
|
|
134
|
+
throw new TypeError('Serialized checkpoint must be a string');
|
|
135
|
+
if (new TextEncoder().encode(serialized).byteLength > maxBytes) {
|
|
136
|
+
throw new RangeError('Checkpoint exceeds the configured byte limit');
|
|
137
|
+
}
|
|
138
|
+
let input;
|
|
139
|
+
try {
|
|
140
|
+
input = JSON.parse(serialized);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new TypeError('Serialized checkpoint is not valid JSON');
|
|
144
|
+
}
|
|
145
|
+
return validateTaskCheckpoint(input, options);
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lp-labs/task-checkpoint",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Canonical bounded JSON task checkpoint serialization, checksum, and validation without persistence.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/cjs/index.js",
|
|
8
|
+
"module": "./dist/esm/index.js",
|
|
9
|
+
"types": "./dist/esm/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/esm/index.d.ts",
|
|
14
|
+
"default": "./dist/esm/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/cjs/index.d.ts",
|
|
18
|
+
"default": "./dist/cjs/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node scripts/build.mjs",
|
|
34
|
+
"typecheck": "npm run build && node scripts/check-types.mjs",
|
|
35
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
36
|
+
"format": "prettier --write src scripts tests *.json README.md",
|
|
37
|
+
"format:check": "prettier --check src scripts tests *.json README.md",
|
|
38
|
+
"prepack": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"prettier": "^3.6.2"
|
|
43
|
+
},
|
|
44
|
+
"prettier": {
|
|
45
|
+
"singleQuote": true,
|
|
46
|
+
"semi": true,
|
|
47
|
+
"trailingComma": "all",
|
|
48
|
+
"printWidth": 100,
|
|
49
|
+
"endOfLine": "lf"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
},
|
|
55
|
+
"author": {
|
|
56
|
+
"name": "Libin Prasanth",
|
|
57
|
+
"url": "https://www.linkedin.com/in/libinprasanth/"
|
|
58
|
+
},
|
|
59
|
+
"funding": {
|
|
60
|
+
"type": "paypal",
|
|
61
|
+
"url": "https://www.paypal.me/LibinPrasanth"
|
|
62
|
+
}
|
|
63
|
+
}
|