@jucie.io/state 1.0.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.
@@ -0,0 +1,88 @@
1
+ import {
2
+ MAGIC, VERSION,
3
+ decodeObject,
4
+ calculateChecksum, base64urlDecode,
5
+ readUint16, readUint8, readUint32
6
+ } from './binary.js';
7
+
8
+ // ---------- Binary Capsule Format ----------
9
+ // [ 0..3 ] Magic "JSC2" (Jucie State Capsule v2)
10
+ // [ 4..5 ] u16 version (big-endian) = 2
11
+ // [ 6 ] u8 flags (reserved for future use)
12
+ // [ 7..10 ] u32 checksum (simple checksum)
13
+ // [11..14 ] u32 payloadLength (big-endian)
14
+ // [15.. ] payload bytes (binary encoded data)
15
+
16
+ // ---------- Public API ----------
17
+
18
+ /**
19
+ * Import a binary capsule string created by exportState.
20
+ * Returns decoded data object with metadata.
21
+ * @param {String} capsuleString - Base64url encoded capsule
22
+ * @param {Function} [cb] - Optional callback(data)
23
+ */
24
+ export async function unpack(capsuleString, cb) {
25
+ // 1) Decode
26
+ const capsule = base64urlDecode(capsuleString);
27
+
28
+ // 2) Sanity checks
29
+ if (capsule.length < 15) {
30
+ throw new Error("Invalid capsule: too short");
31
+ }
32
+
33
+ // Magic
34
+ for (let i = 0; i < 4; i++) {
35
+ if (capsule[i] !== MAGIC[i]) throw new Error("Invalid capsule: bad magic");
36
+ }
37
+
38
+ // Version
39
+ const version = readUint16(capsule, 4);
40
+ if (version !== VERSION) {
41
+ throw new Error(`Unsupported capsule version: ${version}`);
42
+ }
43
+
44
+ // Flags (reserved)
45
+ const flags = readUint8(capsule, 6);
46
+
47
+ // Checksum
48
+ const storedChecksum = readUint32(capsule, 7);
49
+
50
+ // Payload length
51
+ const payloadLength = readUint32(capsule, 11);
52
+ if (payloadLength < 0) throw new Error("Invalid capsule: negative payload length");
53
+ if (capsule.length !== 15 + payloadLength) throw new Error("Invalid capsule: length mismatch");
54
+
55
+ const payload = capsule.slice(15);
56
+
57
+ // 3) Verify checksum
58
+ const actualChecksum = calculateChecksum(payload);
59
+ if (storedChecksum !== actualChecksum) {
60
+ throw new Error("Integrity check failed: checksum mismatch");
61
+ }
62
+
63
+ // 4) Decode payload
64
+ let offset = 0;
65
+
66
+ // Decode data
67
+ const dataLength = readUint32(payload, offset);
68
+ offset += 4;
69
+
70
+ if (offset + dataLength > payload.length) {
71
+ throw new Error(`Data length ${dataLength} exceeds payload bounds at offset ${offset}`);
72
+ }
73
+
74
+ const [data] = decodeObject(payload.slice(offset, offset + dataLength));
75
+
76
+ // 5) Call callback if provided
77
+ if (cb) cb(data);
78
+
79
+ return {
80
+ data, // Add 'data' key for generic usage
81
+ meta: {
82
+ version,
83
+ flags,
84
+ bytes: capsule.length,
85
+ checksum: actualChecksum.toString(16),
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,18 @@
1
+ export const GLOBAL_TAG = '*';
2
+ export const STATE_CONTEXT = Symbol('STATE_CONTEXT');
3
+ export const MATCHER = Symbol('MATCHER');
4
+ export const CREATED = 'CREATED';
5
+ export const DELETED = 'DELETED';
6
+ export const UPDATED = 'UPDATED';
7
+
8
+ // Marker type bitflags
9
+ export const MARKER_GLOBAL = 1; // 0b001
10
+ export const MARKER_SINGLE = 2; // 0b010
11
+ export const MARKER_MANY = 4; // 0b100
12
+ export const MARKER_EPHEMERAL = 8; // 0b1000
13
+
14
+ // Comparison result constants
15
+ export const MATCH_EXACT = 0; // Markers are identical
16
+ export const MATCH_PARENT = 1; // controlMarker is child of comparedMarker (parent changed)
17
+ export const MATCH_CHILD = 2; // comparedMarker is child of controlMarker (child changed)
18
+ export const MATCH_NONE = -1; // No relationship
@@ -0,0 +1,94 @@
1
+ import { GLOBAL_TAG, CREATED, DELETED, UPDATED } from './TOKENS.js';
2
+ import { dispatch } from './marker.js';
3
+
4
+ export const OPERATION_INVERSES = {
5
+ [CREATED]: DELETED,
6
+ [DELETED]: CREATED,
7
+ [UPDATED]: UPDATED,
8
+ }
9
+
10
+ /**
11
+ * Determines the operation type based on from/to values
12
+ *
13
+ * @private
14
+ * @param {*} from - Previous value
15
+ * @param {*} to - New value
16
+ * @returns {'created'|'deleted'|'updated'} Operation type
17
+ */
18
+ function determineOperation(to, from) {
19
+ if (from === undefined) return CREATED;
20
+ if (to === undefined) return DELETED;
21
+ return UPDATED;
22
+ }
23
+
24
+ /**
25
+ * Creates a change object with the given parameters
26
+ *
27
+ * @param {string} method - Method that triggered the change
28
+ * @param {*} from - Previous value
29
+ * @param {*} to - New value
30
+ * @returns {Object} Change object
31
+ * @example
32
+ * createChange('set', undefined, 'new value')
33
+ * // => { method: 'set', operation: 'created', from: undefined, to: 'new value' }
34
+ */
35
+ export function createChange(marker, method, to, from) {
36
+ return dispatch(marker, {
37
+ global: () => ({
38
+ path: GLOBAL_TAG,
39
+ method,
40
+ operation: determineOperation(to, from),
41
+ from,
42
+ to
43
+ }),
44
+ path: (marker) => ({
45
+ path: marker.path,
46
+ method,
47
+ operation: determineOperation(to, from),
48
+ from,
49
+ to
50
+ }),
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Inverts a change object for undo operations
56
+ *
57
+ * @param {Object} change - Change object to invert
58
+ * @param {string} change.method - Original method
59
+ * @param {string} change.operation - Original operation
60
+ * @param {*} change.from - Original from value
61
+ * @param {*} change.to - Original to value
62
+ * @returns {Object} Inverted change object
63
+ * @example
64
+ * invertChange({ method: 'set', operation: 'created', from: undefined, to: 'value' })
65
+ * // => { method: 'set', operation: 'deleted', from: 'value', to: undefined }
66
+ */
67
+ export function invertChange(change) {
68
+ const {address, path, method, from, to, operation } = change;
69
+ return {
70
+ address,
71
+ path,
72
+ method,
73
+ to: from,
74
+ from: to,
75
+ operation: invertOperation(operation),
76
+ }
77
+ }
78
+
79
+ export function invertOperation(operation) {
80
+ return OPERATION_INVERSES[operation];
81
+ }
82
+
83
+ /**
84
+ * Checks if a change needs to be inverted based on the method
85
+ *
86
+ * @param {string} method - Method to check
87
+ * @returns {boolean} True if the change should be inverted
88
+ * @example
89
+ * shouldInvertChange('undo') // => true
90
+ * shouldInvertChange('set') // => false
91
+ */
92
+ export function shouldInvertChange(method) {
93
+ return ['undo', 'redo', 'stepBackward', 'stepForward'].includes(method);
94
+ }
@@ -0,0 +1,42 @@
1
+ import { STATE_CONTEXT } from './TOKENS.js';
2
+
3
+ /**
4
+ * Registers the state globally, but only if no state has already been set.
5
+ * @param {object} state
6
+ */
7
+
8
+ export function provideState(state) {
9
+ if (!globalThis[STATE_CONTEXT]) {
10
+ globalThis[STATE_CONTEXT] = state;
11
+ }
12
+ }
13
+
14
+ export function hasState() {
15
+ return globalThis[STATE_CONTEXT] !== undefined;
16
+ }
17
+
18
+ /**
19
+ * Forcefully override the state in the global context.
20
+ * Useful for testing or resetting.
21
+ */
22
+ export function forceState(state) {
23
+ globalThis[STATE_CONTEXT] = state;
24
+ }
25
+
26
+ /**
27
+ * Retrieves the globally registered state.
28
+ */
29
+ export function getState() {
30
+ return globalThis[STATE_CONTEXT] || null;
31
+ }
32
+
33
+ /**
34
+ * Retrieves the globally registered state.
35
+ */
36
+ export function useState() {
37
+ return getState();
38
+ }
39
+
40
+ export function clearState() {
41
+ globalThis[STATE_CONTEXT] = undefined;
42
+ }
@@ -0,0 +1,125 @@
1
+ import { isGlobalPath, dispatch } from './marker.js';
2
+ import { traverse } from './tree/traverse.js';
3
+ import { graft, detach, replace } from './tree/mutate.js';
4
+ import { isPrimitive } from '../utils/isPrimitive.js';
5
+ import { clone } from '../utils/clone.js';
6
+
7
+ function spread(value) {
8
+ if (Array.isArray(value)) {
9
+ return [...value];
10
+ }
11
+ if (typeof value === 'object' && value !== null) {
12
+ return {...value};
13
+ }
14
+ return value;
15
+ }
16
+
17
+ export function get(state, marker, cb) {
18
+ let shouldCallCallback = false;
19
+ try {
20
+ return dispatch(marker, {
21
+ global: () => {
22
+ shouldCallCallback = true;
23
+ return state;
24
+ },
25
+ path: ({path}) => {
26
+ shouldCallCallback = true;
27
+ return traverse(state, path);
28
+ }
29
+ });
30
+ } catch (error) {
31
+ console.error(error);
32
+ return undefined;
33
+ } finally {
34
+ if (shouldCallCallback && cb) {
35
+ cb(marker);
36
+ }
37
+ }
38
+ }
39
+
40
+ export function set(state, marker, value, method, cb) {
41
+ let from;
42
+ let shouldCallCallback = false;
43
+
44
+ try {
45
+ return dispatch(marker, {
46
+ global: () => {
47
+ from = {...state};
48
+ state = replace(state, value);
49
+ shouldCallCallback = true;
50
+ return value;
51
+ },
52
+ path: ({path}) => {
53
+ from = spread(traverse(state, path));
54
+ graft(state, path, value);
55
+ shouldCallCallback = true;
56
+ return value;
57
+ }
58
+ });
59
+ } catch (error) {
60
+ console.error(error);
61
+ return undefined;
62
+ } finally {
63
+ if (shouldCallCallback && cb) {
64
+ cb(marker, method, value, from);
65
+ }
66
+ }
67
+ }
68
+
69
+ export function remove(state, marker, method, cb) {
70
+ let from;
71
+ let shouldCallCallback = false;
72
+ try {
73
+ return dispatch(marker, {
74
+ global: () => {
75
+ from = state;
76
+ state = replace(state, {});
77
+ shouldCallCallback = true;
78
+ return from;
79
+ },
80
+ path: ({path}) => {
81
+ from = detach(state, path);
82
+ shouldCallCallback = true;
83
+ return from;
84
+ }
85
+ });
86
+ } catch (error) {
87
+ console.error(error);
88
+ return undefined;
89
+ } finally {
90
+ if (cb) {
91
+ cb(marker, method, undefined, from);
92
+ }
93
+ }
94
+ }
95
+
96
+ export function update(state, marker, fn, method, cb) {
97
+ let from, to;
98
+ let shouldCallCallback = false;
99
+ try {
100
+ return dispatch(marker, {
101
+ global: () => {
102
+ from = state;
103
+ to = fn(state);
104
+ state = replace(state, to);
105
+ shouldCallCallback = true;
106
+ return to;
107
+ },
108
+ path: ({ path }) => {
109
+ from = isGlobalPath(path) ? state : traverse(state, path);
110
+ const clonePrev = isPrimitive(from) ? from : clone(from);
111
+ to = fn(clonePrev);
112
+ graft(state, path, to);
113
+ shouldCallCallback = true;
114
+ return to;
115
+ }
116
+ });
117
+ } catch (error) {
118
+ console.error(error);
119
+ return undefined;
120
+ } finally {
121
+ if (shouldCallCallback && cb) {
122
+ cb(marker, method, to, from);
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,233 @@
1
+ import { encodePath } from './pathEncoder.js';
2
+ import { GLOBAL_TAG } from './TOKENS.js';
3
+ import {
4
+ MARKER_GLOBAL,
5
+ MARKER_SINGLE,
6
+ MARKER_MANY,
7
+ MARKER_EPHEMERAL,
8
+ MATCH_EXACT,
9
+ MATCH_PARENT,
10
+ MATCH_CHILD,
11
+ MATCH_NONE
12
+ } from './TOKENS.js';
13
+
14
+ // Marker type bitflags
15
+
16
+ // Helper functions to check marker type
17
+ export function isGlobalMarker(marker) {
18
+ return (marker.type & MARKER_GLOBAL) === MARKER_GLOBAL;
19
+ }
20
+
21
+ export function isPathMarker(marker) {
22
+ return (marker.type & MARKER_SINGLE) === MARKER_SINGLE;
23
+ }
24
+
25
+ export function isMarkers(marker) {
26
+ return (marker.type & MARKER_MANY) === MARKER_MANY;
27
+ }
28
+
29
+ export function isEphemeralMarker(marker) {
30
+ return (marker.type & MARKER_EPHEMERAL) === MARKER_EPHEMERAL;
31
+ }
32
+
33
+ export function isGlobalPath(path) {
34
+ return !path || path.length === 0 || path === GLOBAL_TAG || path[0] === GLOBAL_TAG;
35
+ }
36
+
37
+ export function normalizePaths(args = []) {
38
+ const len = args.length;
39
+ if (len === 0) return [0, [] ];
40
+ if (len === 1 && args[0] === GLOBAL_TAG) return [0, [] ];
41
+
42
+ if (Array.isArray(args[0])) {
43
+ return len === 1
44
+ ? [1, [args[0]] ]
45
+ : [len, args ];
46
+ }
47
+
48
+ return [1, [[...args]] ];
49
+ }
50
+
51
+ function pathHasEphemeral(path) {
52
+ if (!Array.isArray(path)) {
53
+ return false;
54
+ }
55
+
56
+ for (let i = 0; i < path.length; i++) {
57
+ const segment = path[i];
58
+ if (typeof segment === 'string' && segment.charCodeAt(0) === 46) { // '.'
59
+ return true;
60
+ }
61
+ }
62
+
63
+ return false;
64
+ }
65
+
66
+ // Marker factory
67
+ export function createMarker(paths = []) {
68
+ if (isGlobalPath(paths)) {
69
+ return {
70
+ address: GLOBAL_TAG,
71
+ isMarker: true,
72
+ length: 0,
73
+ path: [],
74
+ children: null,
75
+ type: MARKER_GLOBAL
76
+ };
77
+ }
78
+
79
+ const [length, normalizedPaths] = normalizePaths(paths);
80
+ const type = length === 1 ? MARKER_SINGLE : MARKER_MANY;
81
+ const path = length === 1 ? normalizedPaths[0] : normalizedPaths;
82
+ const children = length > 1 ? normalizedPaths.map(path => createMarker(path)) : null;
83
+ const isEphemeral = type === MARKER_SINGLE
84
+ ? pathHasEphemeral(path)
85
+ : children.some(child => isEphemeralMarker(child));
86
+
87
+ let markerType = type;
88
+ if (isEphemeral) {
89
+ markerType |= MARKER_EPHEMERAL;
90
+ }
91
+
92
+ return {
93
+ address: type === MARKER_SINGLE ? encodePath(path) : null,
94
+ isMarker: true,
95
+ length,
96
+ path,
97
+ children,
98
+ type: markerType
99
+ };
100
+ }
101
+
102
+ export function createChildMarker(parentMarker, childPaths) {
103
+ if (childPaths.length === 0) {
104
+ return parentMarker;
105
+ }
106
+ // Normalize the child paths
107
+ const [childLength, normalizedChildPaths] = normalizePaths(childPaths);
108
+
109
+ // Handle global marker - just return marker with child paths
110
+ if (isGlobalMarker(parentMarker)) {
111
+ return createMarker(normalizedChildPaths, parentMarker.state);
112
+ }
113
+
114
+ // Handle single path marker
115
+ if (isPathMarker(parentMarker)) {
116
+ if (childLength === 0) {
117
+ // No child paths, return parent as-is
118
+ return parentMarker;
119
+ }
120
+
121
+ if (childLength === 1) {
122
+ // Single child path - append to parent path
123
+ const newPath = [...parentMarker.path, ...normalizedChildPaths[0]];
124
+ return createMarker(newPath, parentMarker.state);
125
+ } else {
126
+ // Multiple child paths - create many markers
127
+ const newPaths = normalizedChildPaths.map(childPath =>
128
+ [...parentMarker.path, ...childPath]
129
+ );
130
+ return createMarker(newPaths, parentMarker.state);
131
+ }
132
+ }
133
+
134
+ // Handle many markers - recursively create child markers for each
135
+ if (isMarkers(parentMarker)) {
136
+ const newMarkers = new Array(parentMarker.length);
137
+ let i = 0;
138
+ while (i < parentMarker.length) {
139
+ newMarkers[i] = createChildMarker(parentMarker.children[i], childPaths);
140
+ i++;
141
+ }
142
+
143
+ // Collect all paths from the new markers
144
+ const allPaths = [];
145
+ i = 0;
146
+ while (i < newMarkers.length) {
147
+ const marker = newMarkers[i];
148
+ if (isPathMarker(marker)) {
149
+ allPaths.push(marker.path);
150
+ } else if (isMarkers(marker)) {
151
+ let j = 0;
152
+ while (j < marker.length) {
153
+ allPaths.push(marker.children[j].path);
154
+ j++;
155
+ }
156
+ }
157
+ i++;
158
+ }
159
+
160
+ return createMarker(allPaths, parentMarker.state);
161
+ }
162
+
163
+ // Fallback - shouldn't reach here
164
+ return parentMarker;
165
+ }
166
+
167
+ export function dispatch(marker, { global, path, ephemeral, error }) {
168
+ try {
169
+ if (!marker.isMarker) return undefined;
170
+ if (isGlobalMarker(marker)) return global ? global(marker) : undefined;
171
+ if (isEphemeralMarker(marker)) return ephemeral ? ephemeral(marker) : path ? path(marker) : undefined;
172
+ if (isPathMarker(marker)) return path ? path(marker) : undefined;
173
+ if (isMarkers(marker)) {
174
+ const results = new Array(marker.length);
175
+ let i = 0;
176
+ while (i < marker.length) {
177
+ const nestedMarker = marker.children[i];
178
+ results[i] = dispatch(nestedMarker, { global, path, ephemeral, error });
179
+ i++;
180
+ }
181
+ return results;
182
+ }
183
+
184
+ return undefined;
185
+ } catch (err) {
186
+ return error ? error(err.message) : undefined;
187
+ }
188
+ }
189
+
190
+ export function compareMarkers(controlMarker, comparedMarker) {
191
+ // Both are global markers or exact address match
192
+ if (isGlobalMarker(controlMarker) && isGlobalMarker(comparedMarker)) {
193
+ return MATCH_EXACT;
194
+ }
195
+
196
+ // If comparedMarker is global, it's always a parent
197
+ if (isGlobalMarker(comparedMarker)) {
198
+ return MATCH_PARENT;
199
+ }
200
+
201
+ // Need addresses to compare
202
+ if (!controlMarker.address || !comparedMarker.address) {
203
+ return MATCH_NONE;
204
+ }
205
+
206
+ // Exact match
207
+ if (controlMarker.address === comparedMarker.address) {
208
+ return MATCH_EXACT;
209
+ }
210
+
211
+ // controlMarker is more nested (child) - parent changed
212
+ if (controlMarker.address.startsWith(comparedMarker.address + '.')) {
213
+ return MATCH_PARENT;
214
+ }
215
+
216
+ // comparedMarker is more nested (child) - child changed
217
+ if (comparedMarker.address.startsWith(controlMarker.address + '.')) {
218
+ return MATCH_CHILD;
219
+ }
220
+
221
+ return MATCH_NONE;
222
+ }
223
+
224
+ export const Marker = {
225
+ compare: compareMarkers,
226
+ create: createMarker,
227
+ createChild: createChildMarker,
228
+ dispatch: dispatch,
229
+ isGlobal: isGlobalMarker,
230
+ isSingle: isPathMarker,
231
+ isMarkers: isMarkers,
232
+ isEphemeral: isEphemeralMarker
233
+ };
@@ -0,0 +1,89 @@
1
+ import { GLOBAL_TAG } from './TOKENS.js';
2
+
3
+ // Fast helpers
4
+ function escapeStr(str) {
5
+ // Order matters: escape ~ first, then .
6
+ let out = "";
7
+ for (let i = 0; i < str.length; i++) {
8
+ const ch = str[i];
9
+ if (ch === "~") out += "~~";
10
+ else if (ch === ".") out += "~d";
11
+ else out += ch;
12
+ }
13
+ // Represent empty strings as ~e to avoid trailing-dot filenames
14
+ return out.length === 0 ? "~e" : out;
15
+ }
16
+
17
+ function unescapeStr(str) {
18
+ let out = "";
19
+ for (let i = 0; i < str.length; i++) {
20
+ const ch = str[i];
21
+ if (ch === "~") {
22
+ const next = str[++i];
23
+ if (next === "~") out += "~";
24
+ else if (next === "d") out += ".";
25
+ else if (next === "e") out += ""; // empty string marker
26
+ else {
27
+ // Unknown escape: treat as literal (robustness)
28
+ out += "~" + (next ?? "");
29
+ }
30
+ } else {
31
+ out += ch;
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function encodePath(segments) {
38
+ // segments: array of strings or integers
39
+ // Produces: filename/URL-safe string like "sfoo.n0.sbaz"
40
+ const parts = new Array(segments.length);
41
+ for (let i = 0; i < segments.length; i++) {
42
+ const v = segments[i];
43
+ if (typeof v === "number" && Number.isInteger(v)) {
44
+ // 'n' tag
45
+ parts[i] = "n" + String(v); // decimal; includes "-" if negative
46
+ } else if (typeof v === "string") {
47
+ // 's' tag
48
+ parts[i] = "s" + escapeStr(v);
49
+ } else {
50
+ // If you need more types, add here (e.g., booleans 'b', floats 'f').
51
+ throw new TypeError(`Unsupported segment type at index ${i}: ${v}`);
52
+ }
53
+ }
54
+ // Use '.' as separator (safe in URLs and filenames; we avoid trailing '.')
55
+ return parts.join(".");
56
+ }
57
+
58
+ export function decodeAddress(address) {
59
+ if (address === GLOBAL_TAG) return GLOBAL_TAG;
60
+ if (address.length === 0) return [];
61
+ const raw = address.split(".");
62
+ const out = new Array(raw.length);
63
+ for (let i = 0; i < raw.length; i++) {
64
+ const token = raw[i];
65
+ if (token.length === 0) {
66
+ // Disallow empty tokens (would imply trailing or double dots)
67
+ throw new Error("Invalid address: empty token");
68
+ }
69
+ const tag = token[0];
70
+ const body = token.slice(1);
71
+ if (tag === "n") {
72
+ // Fast parse (no regex)
73
+ if (body.length === 0 || !/^[-]?\d+$/.test(body)) {
74
+ throw new Error(`Invalid numeric token: "${token}"`);
75
+ }
76
+ const num = Number(body);
77
+ // Ensure it was an integer
78
+ if (!Number.isInteger(num)) {
79
+ throw new Error(`Non-integer numeric token: "${token}"`);
80
+ }
81
+ out[i] = num;
82
+ } else if (tag === "s") {
83
+ out[i] = unescapeStr(body);
84
+ } else {
85
+ throw new Error(`Unknown type tag "${tag}" in token "${token}"`);
86
+ }
87
+ }
88
+ return out;
89
+ }