@lp-labs/temporal-state 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 +58 -2
- package/dist/cjs/index.d.ts +26 -0
- package/dist/cjs/index.js +186 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +26 -0
- package/dist/esm/index.js +181 -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,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lp-labs/temporal-state
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Keep bounded, append-only JSON events per key and query the latest value at any retained timestamp.
|
|
4
|
+
Explicit compaction advances a history watermark while preserving a baseline value at the cutoff.
|
|
5
|
+
|
|
6
|
+
- Zero runtime and peer dependencies; ESM, CommonJS, and TypeScript declarations.
|
|
7
|
+
- Per-key timestamps are nondecreasing; same-time events use append order.
|
|
8
|
+
- In-memory only; no clock, persistence, distributed ordering, or automatic retention timer.
|
|
9
|
+
- [MIT](./LICENSE). Node.js 18+.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @lp-labs/temporal-state
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { createTemporalState } from '@lp-labs/temporal-state';
|
|
21
|
+
|
|
22
|
+
const state = createTemporalState({ maxEvents: 1000, maxKeys: 100 });
|
|
23
|
+
state.set('cart/42', 1_800_000_000_000, { items: 2 });
|
|
24
|
+
state.set('cart/42', 1_800_000_000_500, { items: 3 });
|
|
25
|
+
console.log(state.getAt('cart/42', 1_800_000_000_200)); // { items: 2 }
|
|
26
|
+
console.log(state.snapshotAt(1_800_000_000_500)); // { 'cart/42': { items: 3 } }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
CommonJS: `const { createTemporalState } = require('@lp-labs/temporal-state')`.
|
|
30
|
+
|
|
31
|
+
## API and retention
|
|
32
|
+
|
|
33
|
+
`createTemporalState({ maxEvents?, maxKeys?, maxDepth? })` defaults to 10,000 events, 1,000 keys,
|
|
34
|
+
and 64 nesting levels. `set(key, timestampMs, json)` and `delete(key, timestampMs)` append events.
|
|
35
|
+
Keys contain 1..256 characters and timestamps are non-negative safe integer milliseconds. Capacity
|
|
36
|
+
overflow throws `TemporalStateError` with code `capacity`; records are never silently dropped.
|
|
37
|
+
Appending an earlier event for an existing key throws `out-of-order`.
|
|
38
|
+
|
|
39
|
+
`getAt(key, timestampMs)` returns the cloned value or `undefined` if the key was not present.
|
|
40
|
+
`snapshotAt(timestampMs)` returns a frozen plain record excluding deleted/absent keys.
|
|
41
|
+
`compact(beforeMs)` removes superseded history through the cutoff and returns the number of events
|
|
42
|
+
removed. It preserves the state as of the cutoff (including deletion) as a baseline. Queries strictly
|
|
43
|
+
before the watermark and appends before it throw `pruned`; moving the watermark backward throws
|
|
44
|
+
`conflict`. `snapshot()` reports retained event/key counts, `oldestAvailableAt` and latest event time.
|
|
45
|
+
|
|
46
|
+
## Customization and limitations
|
|
47
|
+
|
|
48
|
+
Choose timestamp units consistently and persist/rebuild events in your application if needed. Values
|
|
49
|
+
are cloned JSON, not arbitrary objects. Storage is process-local and bounded; no automatic expiry,
|
|
50
|
+
global ordering across keys, clock-skew correction, event sourcing integration, or historical query
|
|
51
|
+
before compaction is provided. A caller must decide a safe retention cutoff.
|
|
52
|
+
|
|
53
|
+
## Support
|
|
54
|
+
|
|
55
|
+
If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
|
|
56
|
+
|
|
57
|
+
## Author
|
|
58
|
+
|
|
59
|
+
Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
2
|
+
[key: string]: Json;
|
|
3
|
+
};
|
|
4
|
+
export interface TemporalOptions {
|
|
5
|
+
readonly maxEvents?: number;
|
|
6
|
+
readonly maxKeys?: number;
|
|
7
|
+
readonly maxDepth?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface TemporalSnapshot {
|
|
10
|
+
readonly events: number;
|
|
11
|
+
readonly keys: number;
|
|
12
|
+
readonly oldestAvailableAt: number;
|
|
13
|
+
readonly latestAt?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class TemporalStateError extends Error {
|
|
16
|
+
readonly code: 'invalid' | 'capacity' | 'out-of-order' | 'pruned' | 'conflict';
|
|
17
|
+
constructor(code: 'invalid' | 'capacity' | 'out-of-order' | 'pruned' | 'conflict', message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare function createTemporalState(options?: TemporalOptions): Readonly<{
|
|
20
|
+
set(key: string, at: number, value: unknown): void;
|
|
21
|
+
delete(key: string, at: number): void;
|
|
22
|
+
getAt(key: string, atInput: number): Json | undefined;
|
|
23
|
+
snapshotAt(atInput: number): Readonly<Record<string, Json>>;
|
|
24
|
+
compact(beforeInput: number): number;
|
|
25
|
+
snapshot(): TemporalSnapshot;
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TemporalStateError = void 0;
|
|
4
|
+
exports.createTemporalState = createTemporalState;
|
|
5
|
+
class TemporalStateError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'TemporalStateError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
exports.TemporalStateError = TemporalStateError;
|
|
14
|
+
function cloneJson(value, maxDepth, depth = 0) {
|
|
15
|
+
if (depth > maxDepth)
|
|
16
|
+
throw new TemporalStateError('capacity', 'Value exceeds maxDepth.');
|
|
17
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
18
|
+
return value;
|
|
19
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
20
|
+
return value;
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
if (value.length > 100_000)
|
|
23
|
+
throw new TemporalStateError('capacity', 'Array is too large.');
|
|
24
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
25
|
+
if (key === 'length')
|
|
26
|
+
continue;
|
|
27
|
+
if (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)
|
|
28
|
+
throw new TemporalStateError('invalid', 'JSON arrays cannot have extra properties.');
|
|
29
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
30
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
31
|
+
throw new TemporalStateError('invalid', 'JSON arrays must be dense data properties.');
|
|
32
|
+
}
|
|
33
|
+
const copy = [];
|
|
34
|
+
for (let index = 0; index < value.length; index++) {
|
|
35
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
36
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
37
|
+
throw new TemporalStateError('invalid', 'JSON arrays must be dense data properties.');
|
|
38
|
+
copy.push(cloneJson(descriptor.value, maxDepth, depth + 1));
|
|
39
|
+
}
|
|
40
|
+
return copy;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === 'object' &&
|
|
43
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
|
44
|
+
const copy = {};
|
|
45
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
46
|
+
if (typeof key !== 'string')
|
|
47
|
+
throw new TemporalStateError('invalid', 'JSON records cannot contain symbol keys.');
|
|
48
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
49
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
50
|
+
throw new TemporalStateError('invalid', 'Values must use enumerable data properties.');
|
|
51
|
+
Object.defineProperty(copy, key, {
|
|
52
|
+
value: cloneJson(descriptor.value, maxDepth, depth + 1),
|
|
53
|
+
enumerable: true,
|
|
54
|
+
configurable: true,
|
|
55
|
+
writable: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return copy;
|
|
59
|
+
}
|
|
60
|
+
throw new TemporalStateError('invalid', 'Values must be finite JSON data.');
|
|
61
|
+
}
|
|
62
|
+
function timestamp(at) {
|
|
63
|
+
if (!Number.isSafeInteger(at) || at < 0)
|
|
64
|
+
throw new TemporalStateError('invalid', 'Timestamp must be a non-negative safe integer.');
|
|
65
|
+
return at;
|
|
66
|
+
}
|
|
67
|
+
function createTemporalState(options = {}) {
|
|
68
|
+
const maxEvents = options.maxEvents ?? 10_000;
|
|
69
|
+
const maxKeys = options.maxKeys ?? 1000;
|
|
70
|
+
const maxDepth = options.maxDepth ?? 64;
|
|
71
|
+
if (!Number.isSafeInteger(maxEvents) || maxEvents < 1 || maxEvents > 1_000_000)
|
|
72
|
+
throw new RangeError('maxEvents must be an integer from 1 through 1000000.');
|
|
73
|
+
if (!Number.isSafeInteger(maxKeys) || maxKeys < 1 || maxKeys > 100_000)
|
|
74
|
+
throw new RangeError('maxKeys must be an integer from 1 through 100000.');
|
|
75
|
+
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > 256)
|
|
76
|
+
throw new RangeError('maxDepth must be an integer from 0 through 256.');
|
|
77
|
+
const history = new Map();
|
|
78
|
+
let eventCount = 0;
|
|
79
|
+
let watermark = 0;
|
|
80
|
+
let latestAt;
|
|
81
|
+
const assertKey = (key) => {
|
|
82
|
+
if (typeof key !== 'string' || key.length < 1 || key.length > 256)
|
|
83
|
+
throw new TemporalStateError('invalid', 'Key must contain 1..256 characters.');
|
|
84
|
+
};
|
|
85
|
+
const append = (key, atInput, event) => {
|
|
86
|
+
assertKey(key);
|
|
87
|
+
const at = timestamp(atInput);
|
|
88
|
+
if (at < watermark)
|
|
89
|
+
throw new TemporalStateError('pruned', 'Cannot append before the compaction watermark.');
|
|
90
|
+
const events = history.get(key);
|
|
91
|
+
if (events && at < events[events.length - 1].at)
|
|
92
|
+
throw new TemporalStateError('out-of-order', 'Events for each key must be nondecreasing in time.');
|
|
93
|
+
if (!events && history.size >= maxKeys)
|
|
94
|
+
throw new TemporalStateError('capacity', `Key capacity of ${maxKeys} is full.`);
|
|
95
|
+
if (eventCount >= maxEvents)
|
|
96
|
+
throw new TemporalStateError('capacity', `Event capacity of ${maxEvents} is full.`);
|
|
97
|
+
const next = events ?? [];
|
|
98
|
+
next.push(Object.freeze({ at, ...event }));
|
|
99
|
+
history.set(key, next);
|
|
100
|
+
eventCount++;
|
|
101
|
+
latestAt = Math.max(latestAt ?? at, at);
|
|
102
|
+
};
|
|
103
|
+
const stateAt = (key, at) => {
|
|
104
|
+
const events = history.get(key);
|
|
105
|
+
if (!events)
|
|
106
|
+
return undefined;
|
|
107
|
+
let low = 0;
|
|
108
|
+
let high = events.length - 1;
|
|
109
|
+
let found = -1;
|
|
110
|
+
while (low <= high) {
|
|
111
|
+
const middle = (low + high) >>> 1;
|
|
112
|
+
if (events[middle].at <= at) {
|
|
113
|
+
found = middle;
|
|
114
|
+
low = middle + 1;
|
|
115
|
+
}
|
|
116
|
+
else
|
|
117
|
+
high = middle - 1;
|
|
118
|
+
}
|
|
119
|
+
if (found < 0 || events[found].deleted)
|
|
120
|
+
return undefined;
|
|
121
|
+
return cloneJson(events[found].value, maxDepth);
|
|
122
|
+
};
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
set(key, at, value) {
|
|
125
|
+
append(key, at, { deleted: false, value: cloneJson(value, maxDepth) });
|
|
126
|
+
},
|
|
127
|
+
delete(key, at) {
|
|
128
|
+
append(key, at, { deleted: true });
|
|
129
|
+
},
|
|
130
|
+
getAt(key, atInput) {
|
|
131
|
+
assertKey(key);
|
|
132
|
+
const at = timestamp(atInput);
|
|
133
|
+
if (at < watermark)
|
|
134
|
+
throw new TemporalStateError('pruned', `History before ${watermark} was compacted.`);
|
|
135
|
+
return stateAt(key, at);
|
|
136
|
+
},
|
|
137
|
+
snapshotAt(atInput) {
|
|
138
|
+
const at = timestamp(atInput);
|
|
139
|
+
if (at < watermark)
|
|
140
|
+
throw new TemporalStateError('pruned', `History before ${watermark} was compacted.`);
|
|
141
|
+
const result = {};
|
|
142
|
+
for (const key of history.keys()) {
|
|
143
|
+
const value = stateAt(key, at);
|
|
144
|
+
if (value !== undefined)
|
|
145
|
+
Object.defineProperty(result, key, {
|
|
146
|
+
value,
|
|
147
|
+
enumerable: true,
|
|
148
|
+
configurable: true,
|
|
149
|
+
writable: true,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return Object.freeze(result);
|
|
153
|
+
},
|
|
154
|
+
compact(beforeInput) {
|
|
155
|
+
const before = timestamp(beforeInput);
|
|
156
|
+
if (before < watermark)
|
|
157
|
+
throw new TemporalStateError('conflict', 'Compaction watermark cannot move backward.');
|
|
158
|
+
let removed = 0;
|
|
159
|
+
for (const [key, events] of history) {
|
|
160
|
+
let last = -1;
|
|
161
|
+
while (last + 1 < events.length && events[last + 1].at <= before)
|
|
162
|
+
last++;
|
|
163
|
+
if (last > 0) {
|
|
164
|
+
const baseline = events[last];
|
|
165
|
+
const kept = [Object.freeze({ ...baseline, at: before }), ...events.slice(last + 1)];
|
|
166
|
+
removed += events.length - kept.length;
|
|
167
|
+
history.set(key, kept);
|
|
168
|
+
}
|
|
169
|
+
else if (last === 0 && events[0].at < before) {
|
|
170
|
+
events[0] = Object.freeze({ ...events[0], at: before });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
eventCount -= removed;
|
|
174
|
+
watermark = before;
|
|
175
|
+
return removed;
|
|
176
|
+
},
|
|
177
|
+
snapshot() {
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
events: eventCount,
|
|
180
|
+
keys: history.size,
|
|
181
|
+
oldestAvailableAt: watermark,
|
|
182
|
+
...(latestAt === undefined ? {} : { latestAt }),
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
2
|
+
[key: string]: Json;
|
|
3
|
+
};
|
|
4
|
+
export interface TemporalOptions {
|
|
5
|
+
readonly maxEvents?: number;
|
|
6
|
+
readonly maxKeys?: number;
|
|
7
|
+
readonly maxDepth?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface TemporalSnapshot {
|
|
10
|
+
readonly events: number;
|
|
11
|
+
readonly keys: number;
|
|
12
|
+
readonly oldestAvailableAt: number;
|
|
13
|
+
readonly latestAt?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class TemporalStateError extends Error {
|
|
16
|
+
readonly code: 'invalid' | 'capacity' | 'out-of-order' | 'pruned' | 'conflict';
|
|
17
|
+
constructor(code: 'invalid' | 'capacity' | 'out-of-order' | 'pruned' | 'conflict', message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare function createTemporalState(options?: TemporalOptions): Readonly<{
|
|
20
|
+
set(key: string, at: number, value: unknown): void;
|
|
21
|
+
delete(key: string, at: number): void;
|
|
22
|
+
getAt(key: string, atInput: number): Json | undefined;
|
|
23
|
+
snapshotAt(atInput: number): Readonly<Record<string, Json>>;
|
|
24
|
+
compact(beforeInput: number): number;
|
|
25
|
+
snapshot(): TemporalSnapshot;
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
export class TemporalStateError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = 'TemporalStateError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function cloneJson(value, maxDepth, depth = 0) {
|
|
10
|
+
if (depth > maxDepth)
|
|
11
|
+
throw new TemporalStateError('capacity', 'Value exceeds maxDepth.');
|
|
12
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
13
|
+
return value;
|
|
14
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
15
|
+
return value;
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
if (value.length > 100_000)
|
|
18
|
+
throw new TemporalStateError('capacity', 'Array is too large.');
|
|
19
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
20
|
+
if (key === 'length')
|
|
21
|
+
continue;
|
|
22
|
+
if (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)
|
|
23
|
+
throw new TemporalStateError('invalid', 'JSON arrays cannot have extra properties.');
|
|
24
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
25
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
26
|
+
throw new TemporalStateError('invalid', 'JSON arrays must be dense data properties.');
|
|
27
|
+
}
|
|
28
|
+
const copy = [];
|
|
29
|
+
for (let index = 0; index < value.length; index++) {
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
31
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
32
|
+
throw new TemporalStateError('invalid', 'JSON arrays must be dense data properties.');
|
|
33
|
+
copy.push(cloneJson(descriptor.value, maxDepth, depth + 1));
|
|
34
|
+
}
|
|
35
|
+
return copy;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === 'object' &&
|
|
38
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
|
39
|
+
const copy = {};
|
|
40
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
41
|
+
if (typeof key !== 'string')
|
|
42
|
+
throw new TemporalStateError('invalid', 'JSON records cannot contain symbol keys.');
|
|
43
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
44
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
45
|
+
throw new TemporalStateError('invalid', 'Values must use enumerable data properties.');
|
|
46
|
+
Object.defineProperty(copy, key, {
|
|
47
|
+
value: cloneJson(descriptor.value, maxDepth, depth + 1),
|
|
48
|
+
enumerable: true,
|
|
49
|
+
configurable: true,
|
|
50
|
+
writable: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return copy;
|
|
54
|
+
}
|
|
55
|
+
throw new TemporalStateError('invalid', 'Values must be finite JSON data.');
|
|
56
|
+
}
|
|
57
|
+
function timestamp(at) {
|
|
58
|
+
if (!Number.isSafeInteger(at) || at < 0)
|
|
59
|
+
throw new TemporalStateError('invalid', 'Timestamp must be a non-negative safe integer.');
|
|
60
|
+
return at;
|
|
61
|
+
}
|
|
62
|
+
export function createTemporalState(options = {}) {
|
|
63
|
+
const maxEvents = options.maxEvents ?? 10_000;
|
|
64
|
+
const maxKeys = options.maxKeys ?? 1000;
|
|
65
|
+
const maxDepth = options.maxDepth ?? 64;
|
|
66
|
+
if (!Number.isSafeInteger(maxEvents) || maxEvents < 1 || maxEvents > 1_000_000)
|
|
67
|
+
throw new RangeError('maxEvents must be an integer from 1 through 1000000.');
|
|
68
|
+
if (!Number.isSafeInteger(maxKeys) || maxKeys < 1 || maxKeys > 100_000)
|
|
69
|
+
throw new RangeError('maxKeys must be an integer from 1 through 100000.');
|
|
70
|
+
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > 256)
|
|
71
|
+
throw new RangeError('maxDepth must be an integer from 0 through 256.');
|
|
72
|
+
const history = new Map();
|
|
73
|
+
let eventCount = 0;
|
|
74
|
+
let watermark = 0;
|
|
75
|
+
let latestAt;
|
|
76
|
+
const assertKey = (key) => {
|
|
77
|
+
if (typeof key !== 'string' || key.length < 1 || key.length > 256)
|
|
78
|
+
throw new TemporalStateError('invalid', 'Key must contain 1..256 characters.');
|
|
79
|
+
};
|
|
80
|
+
const append = (key, atInput, event) => {
|
|
81
|
+
assertKey(key);
|
|
82
|
+
const at = timestamp(atInput);
|
|
83
|
+
if (at < watermark)
|
|
84
|
+
throw new TemporalStateError('pruned', 'Cannot append before the compaction watermark.');
|
|
85
|
+
const events = history.get(key);
|
|
86
|
+
if (events && at < events[events.length - 1].at)
|
|
87
|
+
throw new TemporalStateError('out-of-order', 'Events for each key must be nondecreasing in time.');
|
|
88
|
+
if (!events && history.size >= maxKeys)
|
|
89
|
+
throw new TemporalStateError('capacity', `Key capacity of ${maxKeys} is full.`);
|
|
90
|
+
if (eventCount >= maxEvents)
|
|
91
|
+
throw new TemporalStateError('capacity', `Event capacity of ${maxEvents} is full.`);
|
|
92
|
+
const next = events ?? [];
|
|
93
|
+
next.push(Object.freeze({ at, ...event }));
|
|
94
|
+
history.set(key, next);
|
|
95
|
+
eventCount++;
|
|
96
|
+
latestAt = Math.max(latestAt ?? at, at);
|
|
97
|
+
};
|
|
98
|
+
const stateAt = (key, at) => {
|
|
99
|
+
const events = history.get(key);
|
|
100
|
+
if (!events)
|
|
101
|
+
return undefined;
|
|
102
|
+
let low = 0;
|
|
103
|
+
let high = events.length - 1;
|
|
104
|
+
let found = -1;
|
|
105
|
+
while (low <= high) {
|
|
106
|
+
const middle = (low + high) >>> 1;
|
|
107
|
+
if (events[middle].at <= at) {
|
|
108
|
+
found = middle;
|
|
109
|
+
low = middle + 1;
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
high = middle - 1;
|
|
113
|
+
}
|
|
114
|
+
if (found < 0 || events[found].deleted)
|
|
115
|
+
return undefined;
|
|
116
|
+
return cloneJson(events[found].value, maxDepth);
|
|
117
|
+
};
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
set(key, at, value) {
|
|
120
|
+
append(key, at, { deleted: false, value: cloneJson(value, maxDepth) });
|
|
121
|
+
},
|
|
122
|
+
delete(key, at) {
|
|
123
|
+
append(key, at, { deleted: true });
|
|
124
|
+
},
|
|
125
|
+
getAt(key, atInput) {
|
|
126
|
+
assertKey(key);
|
|
127
|
+
const at = timestamp(atInput);
|
|
128
|
+
if (at < watermark)
|
|
129
|
+
throw new TemporalStateError('pruned', `History before ${watermark} was compacted.`);
|
|
130
|
+
return stateAt(key, at);
|
|
131
|
+
},
|
|
132
|
+
snapshotAt(atInput) {
|
|
133
|
+
const at = timestamp(atInput);
|
|
134
|
+
if (at < watermark)
|
|
135
|
+
throw new TemporalStateError('pruned', `History before ${watermark} was compacted.`);
|
|
136
|
+
const result = {};
|
|
137
|
+
for (const key of history.keys()) {
|
|
138
|
+
const value = stateAt(key, at);
|
|
139
|
+
if (value !== undefined)
|
|
140
|
+
Object.defineProperty(result, key, {
|
|
141
|
+
value,
|
|
142
|
+
enumerable: true,
|
|
143
|
+
configurable: true,
|
|
144
|
+
writable: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return Object.freeze(result);
|
|
148
|
+
},
|
|
149
|
+
compact(beforeInput) {
|
|
150
|
+
const before = timestamp(beforeInput);
|
|
151
|
+
if (before < watermark)
|
|
152
|
+
throw new TemporalStateError('conflict', 'Compaction watermark cannot move backward.');
|
|
153
|
+
let removed = 0;
|
|
154
|
+
for (const [key, events] of history) {
|
|
155
|
+
let last = -1;
|
|
156
|
+
while (last + 1 < events.length && events[last + 1].at <= before)
|
|
157
|
+
last++;
|
|
158
|
+
if (last > 0) {
|
|
159
|
+
const baseline = events[last];
|
|
160
|
+
const kept = [Object.freeze({ ...baseline, at: before }), ...events.slice(last + 1)];
|
|
161
|
+
removed += events.length - kept.length;
|
|
162
|
+
history.set(key, kept);
|
|
163
|
+
}
|
|
164
|
+
else if (last === 0 && events[0].at < before) {
|
|
165
|
+
events[0] = Object.freeze({ ...events[0], at: before });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
eventCount -= removed;
|
|
169
|
+
watermark = before;
|
|
170
|
+
return removed;
|
|
171
|
+
},
|
|
172
|
+
snapshot() {
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
events: eventCount,
|
|
175
|
+
keys: history.size,
|
|
176
|
+
oldestAvailableAt: watermark,
|
|
177
|
+
...(latestAt === undefined ? {} : { latestAt }),
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lp-labs/temporal-state",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bounded append-only keyed history with point-in-time queries and explicit compaction.",
|
|
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
|
+
}
|