@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.
- package/LICENSE +61 -0
- package/core/README.md +635 -0
- package/dist/Plugin.js +36 -0
- package/dist/State.d.ts +44 -0
- package/dist/State.js +243 -0
- package/dist/admin/binary.js +90 -0
- package/dist/admin/buffer.js +174 -0
- package/dist/admin/pack.js +67 -0
- package/dist/admin/unpack.js +88 -0
- package/dist/lib/TOKENS.js +18 -0
- package/dist/lib/change.js +94 -0
- package/dist/lib/global.js +42 -0
- package/dist/lib/gsru.js +125 -0
- package/dist/lib/marker.js +233 -0
- package/dist/lib/pathEncoder.js +89 -0
- package/dist/lib/tree/mutate.js +193 -0
- package/dist/lib/tree/seek.js +66 -0
- package/dist/lib/tree/traverse.js +38 -0
- package/dist/main.js +5 -0
- package/dist/main.js.map +7 -0
- package/dist/plugins/history.js +2 -0
- package/dist/plugins/history.js.map +7 -0
- package/dist/plugins/matcher.js +2 -0
- package/dist/plugins/matcher.js.map +7 -0
- package/dist/plugins/on-change.js +2 -0
- package/dist/plugins/on-change.js.map +7 -0
- package/dist/utils/clone.js +7 -0
- package/dist/utils/convertStringToExpression.js +23 -0
- package/dist/utils/convertStringToFunction.js +17 -0
- package/dist/utils/defer.js +24 -0
- package/dist/utils/isAsync.js +4 -0
- package/dist/utils/isPrimitive.js +12 -0
- package/dist/utils/isPromise.js +1 -0
- package/dist/utils/nextIdleTick.js +23 -0
- package/package.json +81 -0
- package/plugins/history/README.md +320 -0
- package/plugins/matcher/README.md +402 -0
- package/plugins/on-change/README.md +444 -0
|
@@ -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
|
+
}
|
package/dist/lib/gsru.js
ADDED
|
@@ -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
|
+
}
|