@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "1.1.0",
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.2"
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
- "format": "biome format --write .",
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
+ })
@@ -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
- draft.items.delete(0, 1)
246
- draft.items.insert(2, valueToMove)
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
+ }