@loro-extended/change 1.1.0 → 2.0.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/README.md +135 -56
- package/dist/index.d.ts +192 -94
- package/dist/index.js +271 -77
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/any-shape.test.ts +164 -0
- package/src/change.test.ts +4 -2
- package/src/derive-placeholder.ts +8 -0
- package/src/index.ts +13 -2
- package/src/overlay.ts +10 -0
- package/src/path-builder.ts +131 -0
- package/src/path-compiler.ts +64 -0
- package/src/path-evaluator.ts +76 -0
- package/src/path-selector.test.ts +322 -0
- package/src/path-selector.ts +131 -0
- package/src/readonly.test.ts +5 -4
- package/src/shape.ts +120 -5
- package/src/typed-refs/base.ts +6 -0
- package/src/typed-refs/counter.test.ts +2 -1
- package/src/typed-refs/doc.ts +13 -2
- package/src/typed-refs/json-compatibility.test.ts +27 -0
- package/src/typed-refs/list-base.ts +1 -1
- package/src/typed-refs/list.test.ts +1 -1
- package/src/typed-refs/list.ts +5 -2
- package/src/typed-refs/movable-list.test.ts +1 -1
- package/src/typed-refs/movable-list.ts +2 -2
- package/src/typed-refs/record.ts +11 -2
- package/src/typed-refs/struct.ts +9 -0
- package/src/typed-refs/tree.ts +6 -0
- package/src/typed-refs/utils.ts +13 -0
- package/src/validation.ts +9 -0
- package/src/presence-interface.ts +0 -52
- package/src/typed-presence.ts +0 -96
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loro-extended/change",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"./src/*": "./src/*"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"loro-crdt": "^1.10.
|
|
24
|
+
"loro-crdt": "^1.10.3"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"tsup": "^8.5.0",
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsup",
|
|
34
|
-
"
|
|
35
|
-
"lint": "biome check --write .",
|
|
34
|
+
"check": "biome check --write .",
|
|
36
35
|
"test": "vitest",
|
|
37
36
|
"typecheck": "tsc --noEmit --skipLibCheck"
|
|
38
37
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { deriveShapePlaceholder } from "./derive-placeholder.js"
|
|
3
|
+
import { mergeValue } from "./overlay.js"
|
|
4
|
+
import { Shape } from "./shape.js"
|
|
5
|
+
import type { Infer } from "./types.js"
|
|
6
|
+
|
|
7
|
+
describe("Shape.any()", () => {
|
|
8
|
+
it("creates an AnyContainerShape", () => {
|
|
9
|
+
const shape = Shape.any()
|
|
10
|
+
expect(shape._type).toBe("any")
|
|
11
|
+
expect(shape._placeholder).toBeUndefined()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("can be used in a doc shape", () => {
|
|
15
|
+
const docShape = Shape.doc({
|
|
16
|
+
content: Shape.any(),
|
|
17
|
+
})
|
|
18
|
+
expect(docShape.shapes.content._type).toBe("any")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("derives undefined placeholder for any container", () => {
|
|
22
|
+
const shape = Shape.any()
|
|
23
|
+
const placeholder = deriveShapePlaceholder(shape)
|
|
24
|
+
expect(placeholder).toBeUndefined()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("mergeValue returns CRDT value as-is for any container", () => {
|
|
28
|
+
const shape = Shape.any()
|
|
29
|
+
const crdtValue = { nested: { data: "test" } }
|
|
30
|
+
const result = mergeValue(shape, crdtValue, undefined)
|
|
31
|
+
expect(result).toBe(crdtValue)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("type inference produces unknown for any container", () => {
|
|
35
|
+
const docShape = Shape.doc({
|
|
36
|
+
content: Shape.any(),
|
|
37
|
+
})
|
|
38
|
+
// Type test: Infer<typeof docShape> should have content: unknown
|
|
39
|
+
type DocType = Infer<typeof docShape>
|
|
40
|
+
const _typeCheck: DocType = { content: "anything" }
|
|
41
|
+
const _typeCheck2: DocType = { content: { nested: true } }
|
|
42
|
+
const _typeCheck3: DocType = { content: 123 }
|
|
43
|
+
expect(true).toBe(true) // Type-level test
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("Shape.plain.any()", () => {
|
|
48
|
+
it("creates an AnyValueShape", () => {
|
|
49
|
+
const shape = Shape.plain.any()
|
|
50
|
+
expect(shape._type).toBe("value")
|
|
51
|
+
expect(shape.valueType).toBe("any")
|
|
52
|
+
expect(shape._placeholder).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("can be used in a struct value shape", () => {
|
|
56
|
+
const presenceShape = Shape.plain.struct({
|
|
57
|
+
metadata: Shape.plain.any(),
|
|
58
|
+
})
|
|
59
|
+
expect(presenceShape.shape.metadata.valueType).toBe("any")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("derives undefined placeholder for any value", () => {
|
|
63
|
+
const shape = Shape.plain.any()
|
|
64
|
+
const placeholder = deriveShapePlaceholder(shape)
|
|
65
|
+
expect(placeholder).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("mergeValue returns CRDT value as-is for any value", () => {
|
|
69
|
+
const shape = Shape.plain.any()
|
|
70
|
+
const crdtValue = { anything: "goes" }
|
|
71
|
+
const result = mergeValue(shape, crdtValue, undefined)
|
|
72
|
+
expect(result).toBe(crdtValue)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("Shape.plain.bytes()", () => {
|
|
77
|
+
it("creates a Uint8ArrayValueShape", () => {
|
|
78
|
+
const shape = Shape.plain.bytes()
|
|
79
|
+
expect(shape._type).toBe("value")
|
|
80
|
+
expect(shape.valueType).toBe("uint8array")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("is equivalent to Shape.plain.uint8Array()", () => {
|
|
84
|
+
const bytesShape = Shape.plain.bytes()
|
|
85
|
+
const uint8ArrayShape = Shape.plain.uint8Array()
|
|
86
|
+
expect(bytesShape._type).toBe(uint8ArrayShape._type)
|
|
87
|
+
expect(bytesShape.valueType).toBe(uint8ArrayShape.valueType)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("supports .nullable()", () => {
|
|
91
|
+
const shape = Shape.plain.bytes().nullable()
|
|
92
|
+
expect(shape.valueType).toBe("union")
|
|
93
|
+
expect(shape.shapes[0].valueType).toBe("null")
|
|
94
|
+
expect(shape.shapes[1].valueType).toBe("uint8array")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("can be used for cursor presence data", () => {
|
|
98
|
+
const CursorPresenceShape = Shape.plain.struct({
|
|
99
|
+
anchor: Shape.plain.bytes().nullable(),
|
|
100
|
+
focus: Shape.plain.bytes().nullable(),
|
|
101
|
+
user: Shape.plain
|
|
102
|
+
.struct({
|
|
103
|
+
name: Shape.plain.string(),
|
|
104
|
+
color: Shape.plain.string(),
|
|
105
|
+
})
|
|
106
|
+
.nullable(),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Type test: should compile
|
|
110
|
+
type CursorPresence = Infer<typeof CursorPresenceShape>
|
|
111
|
+
const _typeCheck: CursorPresence = {
|
|
112
|
+
anchor: new Uint8Array([1, 2, 3]),
|
|
113
|
+
focus: null,
|
|
114
|
+
user: { name: "Alice", color: "#ff0000" },
|
|
115
|
+
}
|
|
116
|
+
expect(CursorPresenceShape.shape.anchor.valueType).toBe("union")
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("Shape.plain.uint8Array().nullable()", () => {
|
|
121
|
+
it("supports .nullable()", () => {
|
|
122
|
+
const shape = Shape.plain.uint8Array().nullable()
|
|
123
|
+
expect(shape.valueType).toBe("union")
|
|
124
|
+
expect(shape.shapes[0].valueType).toBe("null")
|
|
125
|
+
expect(shape.shapes[1].valueType).toBe("uint8array")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("Integration: loro-prosemirror style usage", () => {
|
|
130
|
+
it("allows typed presence with untyped document", () => {
|
|
131
|
+
// This is the target API for loro-prosemirror integration
|
|
132
|
+
const ProseMirrorDocShape = Shape.doc({
|
|
133
|
+
doc: Shape.any(), // loro-prosemirror manages this
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const CursorPresenceShape = Shape.plain.struct({
|
|
137
|
+
anchor: Shape.plain.bytes().nullable(),
|
|
138
|
+
focus: Shape.plain.bytes().nullable(),
|
|
139
|
+
user: Shape.plain
|
|
140
|
+
.struct({
|
|
141
|
+
name: Shape.plain.string(),
|
|
142
|
+
color: Shape.plain.string(),
|
|
143
|
+
})
|
|
144
|
+
.nullable(),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Type tests
|
|
148
|
+
type DocType = Infer<typeof ProseMirrorDocShape>
|
|
149
|
+
type PresenceType = Infer<typeof CursorPresenceShape>
|
|
150
|
+
|
|
151
|
+
// Document content is unknown (we opted out)
|
|
152
|
+
const _docTypeCheck: DocType = { doc: "anything" }
|
|
153
|
+
|
|
154
|
+
// Presence is fully typed
|
|
155
|
+
const _presenceTypeCheck: PresenceType = {
|
|
156
|
+
anchor: new Uint8Array([1, 2, 3]),
|
|
157
|
+
focus: null,
|
|
158
|
+
user: { name: "Alice", color: "#ff0000" },
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
expect(ProseMirrorDocShape.shapes.doc._type).toBe("any")
|
|
162
|
+
expect(CursorPresenceShape.shape.anchor.valueType).toBe("union")
|
|
163
|
+
})
|
|
164
|
+
})
|
package/src/change.test.ts
CHANGED
|
@@ -242,8 +242,10 @@ describe("CRDT Operations", () => {
|
|
|
242
242
|
// Test move operation: move first item to the end
|
|
243
243
|
const result = change(typedDoc, draft => {
|
|
244
244
|
const valueToMove = draft.items.get(0)
|
|
245
|
-
|
|
246
|
-
|
|
245
|
+
if (valueToMove !== undefined) {
|
|
246
|
+
draft.items.delete(0, 1)
|
|
247
|
+
draft.items.insert(2, valueToMove)
|
|
248
|
+
}
|
|
247
249
|
}).toJSON()
|
|
248
250
|
|
|
249
251
|
expect(result.items).toEqual(["second", "third", "first"])
|
|
@@ -27,6 +27,10 @@ export function derivePlaceholder<T extends DocShape>(
|
|
|
27
27
|
*/
|
|
28
28
|
export function deriveShapePlaceholder(shape: ContainerOrValueShape): unknown {
|
|
29
29
|
switch (shape._type) {
|
|
30
|
+
// Any container - no placeholder (undefined)
|
|
31
|
+
case "any":
|
|
32
|
+
return undefined
|
|
33
|
+
|
|
30
34
|
// Leaf containers - use _placeholder directly
|
|
31
35
|
case "text":
|
|
32
36
|
return shape._placeholder
|
|
@@ -60,6 +64,10 @@ export function deriveShapePlaceholder(shape: ContainerOrValueShape): unknown {
|
|
|
60
64
|
|
|
61
65
|
function deriveValueShapePlaceholder(shape: ValueShape): unknown {
|
|
62
66
|
switch (shape.valueType) {
|
|
67
|
+
// Any value - no placeholder (undefined)
|
|
68
|
+
case "any":
|
|
69
|
+
return undefined
|
|
70
|
+
|
|
63
71
|
// Leaf values - use _placeholder directly
|
|
64
72
|
case "string":
|
|
65
73
|
return shape._placeholder
|
package/src/index.ts
CHANGED
|
@@ -7,9 +7,21 @@ export {
|
|
|
7
7
|
// Functional helpers (recommended API)
|
|
8
8
|
export { change, getLoroDoc } from "./functional-helpers.js"
|
|
9
9
|
export { mergeValue, overlayPlaceholder } from "./overlay.js"
|
|
10
|
+
// Path selector DSL exports
|
|
11
|
+
export { createPathBuilder } from "./path-builder.js"
|
|
12
|
+
export { compileToJsonPath, hasWildcard } from "./path-compiler.js"
|
|
13
|
+
export { evaluatePath, evaluatePathOnValue } from "./path-evaluator.js"
|
|
14
|
+
export type {
|
|
15
|
+
PathBuilder,
|
|
16
|
+
PathNode,
|
|
17
|
+
PathSegment,
|
|
18
|
+
PathSelector,
|
|
19
|
+
} from "./path-selector.js"
|
|
10
20
|
export { createPlaceholderProxy } from "./placeholder-proxy.js"
|
|
11
|
-
export type { ObjectValue, PresenceInterface } from "./presence-interface.js"
|
|
12
21
|
export type {
|
|
22
|
+
// Escape hatch shapes for untyped integration
|
|
23
|
+
AnyContainerShape,
|
|
24
|
+
AnyValueShape,
|
|
13
25
|
ArrayValueShape,
|
|
14
26
|
ContainerOrValueShape,
|
|
15
27
|
ContainerShape,
|
|
@@ -44,7 +56,6 @@ export type {
|
|
|
44
56
|
export { Shape } from "./shape.js"
|
|
45
57
|
export type { TypedDoc } from "./typed-doc.js"
|
|
46
58
|
export { createTypedDoc } from "./typed-doc.js"
|
|
47
|
-
export { TypedPresence } from "./typed-presence.js"
|
|
48
59
|
export type {
|
|
49
60
|
// Type inference - Infer<T> is the recommended unified helper
|
|
50
61
|
Infer,
|
package/src/overlay.ts
CHANGED
|
@@ -50,6 +50,16 @@ export function mergeValue<Shape extends ContainerShape | ValueShape>(
|
|
|
50
50
|
crdtValue: Value,
|
|
51
51
|
placeholderValue: Value,
|
|
52
52
|
): Value {
|
|
53
|
+
// For "any" shapes, just return the CRDT value as-is (no placeholder merging)
|
|
54
|
+
if (shape._type === "any") {
|
|
55
|
+
return crdtValue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// For "any" value shapes, just return the CRDT value as-is
|
|
59
|
+
if (shape._type === "value" && (shape as any).valueType === "any") {
|
|
60
|
+
return crdtValue
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
if (crdtValue === undefined && placeholderValue === undefined) {
|
|
54
64
|
throw new Error("either crdt or placeholder value must be defined")
|
|
55
65
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Path Builder Factory
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Runtime implementation of the path builder that creates PathSelector objects
|
|
6
|
+
// with proper segments for JSONPath compilation.
|
|
7
|
+
|
|
8
|
+
import type { PathBuilder, PathSegment, PathSelector } from "./path-selector.js"
|
|
9
|
+
import type { ContainerOrValueShape, DocShape } from "./shape.js"
|
|
10
|
+
|
|
11
|
+
function createPathSelector<T>(segments: PathSegment[]): PathSelector<T> {
|
|
12
|
+
return {
|
|
13
|
+
__resultType: undefined as unknown as T,
|
|
14
|
+
__segments: segments,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createPathNode(
|
|
19
|
+
shape: ContainerOrValueShape,
|
|
20
|
+
segments: PathSegment[],
|
|
21
|
+
): unknown {
|
|
22
|
+
const selector = createPathSelector(segments)
|
|
23
|
+
|
|
24
|
+
// Terminal shapes (text, counter, value)
|
|
25
|
+
if (shape._type === "text" || shape._type === "counter") {
|
|
26
|
+
return selector
|
|
27
|
+
}
|
|
28
|
+
if (shape._type === "value") {
|
|
29
|
+
return selector
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// List/MovableList
|
|
33
|
+
if (shape._type === "list" || shape._type === "movableList") {
|
|
34
|
+
return Object.assign(selector, {
|
|
35
|
+
get $each() {
|
|
36
|
+
return createPathNode(shape.shape, [...segments, { type: "each" }])
|
|
37
|
+
},
|
|
38
|
+
$at(index: number) {
|
|
39
|
+
return createPathNode(shape.shape, [
|
|
40
|
+
...segments,
|
|
41
|
+
{ type: "index", index },
|
|
42
|
+
])
|
|
43
|
+
},
|
|
44
|
+
get $first() {
|
|
45
|
+
return createPathNode(shape.shape, [
|
|
46
|
+
...segments,
|
|
47
|
+
{ type: "index", index: 0 },
|
|
48
|
+
])
|
|
49
|
+
},
|
|
50
|
+
get $last() {
|
|
51
|
+
return createPathNode(shape.shape, [
|
|
52
|
+
...segments,
|
|
53
|
+
{ type: "index", index: -1 },
|
|
54
|
+
])
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Struct (fixed keys)
|
|
60
|
+
if (shape._type === "struct") {
|
|
61
|
+
const props: Record<string, unknown> = {}
|
|
62
|
+
for (const key in shape.shapes) {
|
|
63
|
+
Object.defineProperty(props, key, {
|
|
64
|
+
get() {
|
|
65
|
+
return createPathNode(shape.shapes[key], [
|
|
66
|
+
...segments,
|
|
67
|
+
{ type: "property", key },
|
|
68
|
+
])
|
|
69
|
+
},
|
|
70
|
+
enumerable: true,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
return Object.assign(selector, props)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Record (dynamic keys)
|
|
77
|
+
if (shape._type === "record") {
|
|
78
|
+
return Object.assign(selector, {
|
|
79
|
+
get $each() {
|
|
80
|
+
return createPathNode(shape.shape, [...segments, { type: "each" }])
|
|
81
|
+
},
|
|
82
|
+
$key(key: string) {
|
|
83
|
+
return createPathNode(shape.shape, [...segments, { type: "key", key }])
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return selector
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates a path builder for a given document shape.
|
|
93
|
+
*
|
|
94
|
+
* The path builder provides a type-safe DSL for selecting paths within
|
|
95
|
+
* a document. The resulting PathSelector can be compiled to a JSONPath
|
|
96
|
+
* string for use with subscribeJsonpath.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* const docShape = Shape.doc({
|
|
101
|
+
* books: Shape.list(Shape.struct({
|
|
102
|
+
* title: Shape.text(),
|
|
103
|
+
* price: Shape.plain.number(),
|
|
104
|
+
* })),
|
|
105
|
+
* })
|
|
106
|
+
*
|
|
107
|
+
* const builder = createPathBuilder(docShape)
|
|
108
|
+
* const selector = builder.books.$each.title
|
|
109
|
+
* // selector.__segments = [
|
|
110
|
+
* // { type: "property", key: "books" },
|
|
111
|
+
* // { type: "each" },
|
|
112
|
+
* // { type: "property", key: "title" }
|
|
113
|
+
* // ]
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export function createPathBuilder<D extends DocShape>(
|
|
117
|
+
docShape: D,
|
|
118
|
+
): PathBuilder<D> {
|
|
119
|
+
const builder: Record<string, unknown> = {}
|
|
120
|
+
|
|
121
|
+
for (const key in docShape.shapes) {
|
|
122
|
+
Object.defineProperty(builder, key, {
|
|
123
|
+
get() {
|
|
124
|
+
return createPathNode(docShape.shapes[key], [{ type: "property", key }])
|
|
125
|
+
},
|
|
126
|
+
enumerable: true,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return builder as PathBuilder<D>
|
|
131
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// JSONPath Compiler
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Compiles PathSelector segments to JSONPath strings for use with
|
|
6
|
+
// subscribeJsonpath.
|
|
7
|
+
|
|
8
|
+
import type { PathSegment } from "./path-selector.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compiles path segments to a JSONPath string.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const segments = [
|
|
16
|
+
* { type: "property", key: "books" },
|
|
17
|
+
* { type: "each" },
|
|
18
|
+
* { type: "property", key: "title" }
|
|
19
|
+
* ]
|
|
20
|
+
* compileToJsonPath(segments) // => '$.books[*].title'
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function compileToJsonPath(segments: PathSegment[]): string {
|
|
24
|
+
let path = "$"
|
|
25
|
+
|
|
26
|
+
for (const segment of segments) {
|
|
27
|
+
switch (segment.type) {
|
|
28
|
+
case "property":
|
|
29
|
+
// Use dot notation for simple identifiers, bracket notation for safety
|
|
30
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(segment.key)) {
|
|
31
|
+
path += `.${segment.key}`
|
|
32
|
+
} else {
|
|
33
|
+
path += `["${escapeJsonPathKey(segment.key)}"]`
|
|
34
|
+
}
|
|
35
|
+
break
|
|
36
|
+
case "each":
|
|
37
|
+
path += "[*]"
|
|
38
|
+
break
|
|
39
|
+
case "index":
|
|
40
|
+
path += `[${segment.index}]`
|
|
41
|
+
break
|
|
42
|
+
case "key":
|
|
43
|
+
path += `["${escapeJsonPathKey(segment.key)}"]`
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return path
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Escapes special characters in a JSONPath key.
|
|
53
|
+
*/
|
|
54
|
+
function escapeJsonPathKey(key: string): string {
|
|
55
|
+
return key.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if the path contains any wildcard segments.
|
|
60
|
+
* Paths with wildcards need deep equality checking for change detection.
|
|
61
|
+
*/
|
|
62
|
+
export function hasWildcard(segments: PathSegment[]): boolean {
|
|
63
|
+
return segments.some(s => s.type === "each")
|
|
64
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Path Evaluator
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Evaluates a path selector against a TypedDoc to get the current value.
|
|
6
|
+
// This is used for:
|
|
7
|
+
// 1. Establishing the initial previousValue baseline
|
|
8
|
+
// 2. Getting the current value when subscribeJsonpath fires
|
|
9
|
+
// 3. Deep equality comparison to filter false positives
|
|
10
|
+
|
|
11
|
+
import type { PathSegment, PathSelector } from "./path-selector.js"
|
|
12
|
+
import type { DocShape } from "./shape.js"
|
|
13
|
+
import type { TypedDoc } from "./typed-doc.js"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Evaluate a path selector against a TypedDoc to get the current value.
|
|
17
|
+
* Returns the value(s) at the path, properly typed.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const selector = builder.books.$each.title
|
|
22
|
+
* const titles = evaluatePath(doc, selector)
|
|
23
|
+
* // titles: string[]
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function evaluatePath<D extends DocShape, T>(
|
|
27
|
+
doc: TypedDoc<D>,
|
|
28
|
+
selector: PathSelector<T>,
|
|
29
|
+
): T {
|
|
30
|
+
const json = doc.$.toJSON()
|
|
31
|
+
return evaluatePathOnValue(json, selector.__segments) as T
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate path segments against a plain JavaScript value.
|
|
36
|
+
* This is the core recursive evaluation logic.
|
|
37
|
+
*/
|
|
38
|
+
export function evaluatePathOnValue(
|
|
39
|
+
value: unknown,
|
|
40
|
+
segments: PathSegment[],
|
|
41
|
+
): unknown {
|
|
42
|
+
if (segments.length === 0) {
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [segment, ...rest] = segments
|
|
47
|
+
|
|
48
|
+
switch (segment.type) {
|
|
49
|
+
case "property":
|
|
50
|
+
case "key":
|
|
51
|
+
if (value == null) return undefined
|
|
52
|
+
if (typeof value !== "object") return undefined
|
|
53
|
+
return evaluatePathOnValue(
|
|
54
|
+
(value as Record<string, unknown>)[segment.key],
|
|
55
|
+
rest,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
case "index": {
|
|
59
|
+
if (!Array.isArray(value)) return undefined
|
|
60
|
+
// Handle negative indices: -1 = last, -2 = second-to-last, etc.
|
|
61
|
+
const index =
|
|
62
|
+
segment.index < 0 ? value.length + segment.index : segment.index
|
|
63
|
+
if (index < 0 || index >= value.length) return undefined
|
|
64
|
+
return evaluatePathOnValue(value[index], rest)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case "each":
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.map(item => evaluatePathOnValue(item, rest))
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === "object" && value !== null) {
|
|
72
|
+
return Object.values(value).map(item => evaluatePathOnValue(item, rest))
|
|
73
|
+
}
|
|
74
|
+
return []
|
|
75
|
+
}
|
|
76
|
+
}
|