Parcourir la source

fix json patch (#78)

Chris Tate il y a 5 mois
Parent
commit
1eb6212dd7

+ 19 - 5
apps/web/app/(main)/docs/api/core/page.tsx

@@ -273,8 +273,8 @@ compiler.reset();`}</Code>
       </p>
       <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
 
-const jsonl = \`{"op":"set","path":"/root","value":{}}
-{"op":"set","path":"/root/type","value":"Card"}\`;
+const jsonl = \`{"op":"add","path":"/root","value":{}}
+{"op":"add","path":"/root/type","value":"Card"}\`;
 
 const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
 
@@ -285,22 +285,36 @@ const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
 } from '@json-render/core';
 
 // Parse a single line
-const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
+const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
 
 // Apply patch to object (mutates in place)
 const obj = {};
 applySpecStreamPatch(obj, patch);`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Fully compliant with{" "}
+        <a
+          href="https://datatracker.ietf.org/doc/html/rfc6902"
+          className="underline"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          RFC 6902
+        </a>
+        :
+      </p>
       <Code lang="typescript">{`interface SpecStreamLine {
-  op: 'set' | 'add' | 'replace' | 'remove';
+  op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
   path: string;
-  value?: unknown;
+  value?: unknown;  // Required for add, replace, test
+  from?: string;    // Required for move, copy
 }
 
 interface SpecStreamCompiler<T> {
   push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
   getResult(): T;
+  getPatches(): SpecStreamLine[];
   reset(): void;
 }`}</Code>
 

+ 37 - 16
apps/web/app/(main)/docs/streaming/page.tsx

@@ -18,10 +18,10 @@ export default function StreamingPage() {
         format where each line is a JSON patch operation that progressively
         builds your spec:
       </p>
-      <Code lang="json">{`{"op":"set","path":"/root","value":"root"}
-{"op":"set","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
-{"op":"set","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
-{"op":"set","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
+      <Code lang="json">{`{"op":"add","path":"/root","value":"root"}
+{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
+{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
+{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
       <p className="text-sm text-muted-foreground mb-4">
@@ -43,26 +43,47 @@ function App() {
   });
 }`}</Code>
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Patch Operations (RFC 6902)
+      </h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Supported operations:
+        SpecStream uses{" "}
+        <a
+          href="https://datatracker.ietf.org/doc/html/rfc6902"
+          className="underline"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          RFC 6902 JSON Patch
+        </a>{" "}
+        operations:
       </p>
       <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
         <li>
-          <code className="text-foreground">set</code> — Set the value at a path
-          (creates if needed)
+          <code className="text-foreground">add</code> — Add a value at a path
+          (creates or replaces for objects, inserts for arrays)
+        </li>
+        <li>
+          <code className="text-foreground">remove</code> — Remove the value at
+          a path
+        </li>
+        <li>
+          <code className="text-foreground">replace</code> — Replace an existing
+          value at a path
         </li>
         <li>
-          <code className="text-foreground">add</code> — Add to an array at a
-          path
+          <code className="text-foreground">move</code> — Move a value from one
+          path to another (requires{" "}
+          <code className="text-foreground">from</code>)
         </li>
         <li>
-          <code className="text-foreground">replace</code> — Replace value at a
-          path
+          <code className="text-foreground">copy</code> — Copy a value from one
+          path to another (requires{" "}
+          <code className="text-foreground">from</code>)
         </li>
         <li>
-          <code className="text-foreground">remove</code> — Remove value at a
-          path
+          <code className="text-foreground">test</code> — Assert that a value at
+          a path equals the given value
         </li>
       </ul>
 
@@ -170,8 +191,8 @@ async function processStream(reader: ReadableStreamDefaultReader) {
       </p>
       <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
 
-const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
-{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
+const jsonl = \`{"op":"add","path":"/root","value":{"type":"Card"}}
+{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`;
 
 const spec = compileSpecStream<MySpec>(jsonl);
 // { root: { type: "Card", props: { title: "Hello" } } }`}</Code>

+ 3 - 2
apps/web/app/api/generate/route.ts

@@ -62,8 +62,9 @@ USER REQUEST: ${sanitizedPrompt}
 
 IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
 - To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
-- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
-- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
+- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
+- To remove an element: {"op":"remove","path":"/elements/old-key"}
+- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
 - To add children: update the parent element with new children array
 
 DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;

+ 1 - 1
apps/web/components/demo.tsx

@@ -35,7 +35,7 @@ const SIMULATION_STAGES: SimulationStage[] = [
         },
       },
     },
-    stream: '{"op":"set","path":"/root","value":"card"}',
+    stream: '{"op":"add","path":"/root","value":"card"}',
   },
   {
     tree: {

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
examples/remotion/tsconfig.tsbuildinfo


+ 8 - 6
packages/core/README.md

@@ -116,14 +116,16 @@ while (streaming) {
 const finalSpec = compiler.getResult();
 ```
 
-SpecStream format (each line is a JSON patch):
+SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch):
 
 ```jsonl
-{"op":"set","path":"/root/type","value":"Card"}
-{"op":"set","path":"/root/props","value":{"title":"Hello"}}
-{"op":"set","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
+{"op":"add","path":"/root/type","value":"Card"}
+{"op":"add","path":"/root/props","value":{"title":"Hello"}}
+{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
 ```
 
+All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
+
 ### Low-Level Utilities
 
 ```typescript
@@ -134,8 +136,8 @@ import {
 } from "@json-render/core";
 
 // Parse a single line
-const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
-// { op: "set", path: "/root", value: {} }
+const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
+// { op: "add", path: "/root", value: {} }
 
 // Apply a patch to an object
 const obj = {};

+ 4 - 4
packages/core/src/catalog.ts

@@ -493,8 +493,8 @@ export function generateSystemPrompt<
   }
 
   // Output format
-  lines.push("OUTPUT FORMAT (JSONL):");
-  lines.push('{"op":"set","path":"/root","value":"element-key"}');
+  lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
+  lines.push('{"op":"add","path":"/root","value":"element-key"}');
   lines.push(
     '{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
   );
@@ -504,8 +504,8 @@ export function generateSystemPrompt<
   // Rules
   lines.push("RULES:");
   const baseRules = [
-    "First line sets /root to root element key",
-    "Add elements with /elements/{key}",
+    'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
+    'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
     "Remove elements with op:remove - also update the parent's children array to exclude the removed key",
     "Children array contains string keys, not objects",
     "Parent first, then children",

+ 3 - 1
packages/core/src/index.ts

@@ -28,8 +28,10 @@ export {
   resolveDynamicValue,
   getByPath,
   setByPath,
+  addByPath,
+  removeByPath,
   findFormValue,
-  // SpecStream - streaming format for building specs
+  // SpecStream - streaming format for building specs (RFC 6902)
   parseSpecStreamLine,
   applySpecStreamPatch,
   compileSpecStream,

+ 6 - 6
packages/core/src/schema.ts

@@ -545,10 +545,10 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
   lines.push("");
   lines.push("Example output (each line is a separate JSON object):");
   lines.push("");
-  lines.push(`{"op":"set","path":"/root","value":"card-1"}
-{"op":"set","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
-{"op":"set","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
-{"op":"set","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
+  lines.push(`{"op":"add","path":"/root","value":"card-1"}
+{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
+{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
+{"op":"add","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
   lines.push("");
 
   // Components section
@@ -591,8 +591,8 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
   lines.push("RULES:");
   const baseRules = [
     "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
-    'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
-    'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
+    'First line sets root: {"op":"add","path":"/root","value":"<root-key>"}',
+    'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
     "ONLY use components listed above",
     "Each element value needs: type, props, children (array of child keys)",
     "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",

+ 435 - 1
packages/core/src/types.test.ts

@@ -1,5 +1,14 @@
 import { describe, it, expect } from "vitest";
-import { resolveDynamicValue, getByPath, setByPath } from "./types";
+import {
+  resolveDynamicValue,
+  getByPath,
+  setByPath,
+  addByPath,
+  removeByPath,
+  applySpecStreamPatch,
+  compileSpecStream,
+  createSpecStreamCompiler,
+} from "./types";
 
 describe("getByPath", () => {
   it("gets nested values with JSON pointer paths", () => {
@@ -123,3 +132,428 @@ describe("resolveDynamicValue", () => {
     ).toBeUndefined();
   });
 });
+
+// =============================================================================
+// JSON Pointer (RFC 6901) escaping
+// =============================================================================
+
+describe("JSON Pointer escaping (RFC 6901)", () => {
+  it("getByPath unescapes ~1 to /", () => {
+    const data = { "a/b": { c: 42 } };
+    expect(getByPath(data, "/a~1b/c")).toBe(42);
+  });
+
+  it("getByPath unescapes ~0 to ~", () => {
+    const data = { "a~b": 99 };
+    expect(getByPath(data, "/a~0b")).toBe(99);
+  });
+
+  it("getByPath handles combined ~0 and ~1 escaping", () => {
+    const data = { "a~/b": "found" };
+    expect(getByPath(data, "/a~0~1b")).toBe("found");
+  });
+
+  it("setByPath unescapes ~1 to / in keys", () => {
+    const data: Record<string, unknown> = {};
+    setByPath(data, "/a~1b", "value");
+    expect(data["a/b"]).toBe("value");
+  });
+
+  it("setByPath unescapes ~0 to ~ in keys", () => {
+    const data: Record<string, unknown> = {};
+    setByPath(data, "/a~0b", "value");
+    expect(data["a~b"]).toBe("value");
+  });
+
+  it("getByPath reads array elements properly", () => {
+    const data = { items: [10, 20, 30] };
+    expect(getByPath(data, "/items/0")).toBe(10);
+    expect(getByPath(data, "/items/2")).toBe(30);
+  });
+});
+
+// =============================================================================
+// addByPath (RFC 6902 "add" semantics)
+// =============================================================================
+
+describe("addByPath", () => {
+  it("adds a property to an object", () => {
+    const data: Record<string, unknown> = { a: 1 };
+    addByPath(data, "/b", 2);
+    expect(data.b).toBe(2);
+  });
+
+  it("replaces an existing object property (add semantics)", () => {
+    const data: Record<string, unknown> = { a: 1 };
+    addByPath(data, "/a", 99);
+    expect(data.a).toBe(99);
+  });
+
+  it("inserts into an array at a specific index", () => {
+    const data: Record<string, unknown> = { arr: [1, 2, 3] };
+    addByPath(data, "/arr/1", 99);
+    expect(data.arr).toEqual([1, 99, 2, 3]);
+  });
+
+  it("appends to an array with - (end-of-array)", () => {
+    const data: Record<string, unknown> = { arr: [1, 2] };
+    addByPath(data, "/arr/-", 3);
+    expect(data.arr).toEqual([1, 2, 3]);
+  });
+
+  it("inserts at index 0 of an array", () => {
+    const data: Record<string, unknown> = { arr: ["b", "c"] };
+    addByPath(data, "/arr/0", "a");
+    expect(data.arr).toEqual(["a", "b", "c"]);
+  });
+
+  it("creates intermediate objects", () => {
+    const data: Record<string, unknown> = {};
+    addByPath(data, "/x/y/z", "deep");
+    expect(
+      ((data.x as Record<string, unknown>).y as Record<string, unknown>).z,
+    ).toBe("deep");
+  });
+});
+
+// =============================================================================
+// removeByPath (RFC 6902 "remove" semantics)
+// =============================================================================
+
+describe("removeByPath", () => {
+  it("deletes an object property", () => {
+    const data: Record<string, unknown> = { a: 1, b: 2 };
+    removeByPath(data, "/a");
+    expect(data).toEqual({ b: 2 });
+    expect("a" in data).toBe(false);
+  });
+
+  it("removes an element from an array by index", () => {
+    const data: Record<string, unknown> = { arr: [1, 2, 3] };
+    removeByPath(data, "/arr/1");
+    expect(data.arr).toEqual([1, 3]);
+  });
+
+  it("removes nested properties", () => {
+    const data: Record<string, unknown> = { user: { name: "John", age: 30 } };
+    removeByPath(data, "/user/age");
+    expect(data.user).toEqual({ name: "John" });
+  });
+
+  it("is a no-op for non-existent paths", () => {
+    const data: Record<string, unknown> = { a: 1 };
+    removeByPath(data, "/b/c/d");
+    expect(data).toEqual({ a: 1 });
+  });
+});
+
+// =============================================================================
+// applySpecStreamPatch - RFC 6902 operations
+// =============================================================================
+
+describe("applySpecStreamPatch", () => {
+  describe("add operation", () => {
+    it("adds a new object property", () => {
+      const obj: Record<string, unknown> = {};
+      applySpecStreamPatch(obj, { op: "add", path: "/name", value: "Alice" });
+      expect(obj.name).toBe("Alice");
+    });
+
+    it("adds nested properties creating intermediates", () => {
+      const obj: Record<string, unknown> = {};
+      applySpecStreamPatch(obj, {
+        op: "add",
+        path: "/user/name",
+        value: "Bob",
+      });
+      expect((obj.user as Record<string, unknown>).name).toBe("Bob");
+    });
+
+    it("inserts into array at index", () => {
+      const obj: Record<string, unknown> = { items: [1, 3] };
+      applySpecStreamPatch(obj, { op: "add", path: "/items/1", value: 2 });
+      expect(obj.items).toEqual([1, 2, 3]);
+    });
+
+    it("appends to array with -", () => {
+      const obj: Record<string, unknown> = { items: [1, 2] };
+      applySpecStreamPatch(obj, { op: "add", path: "/items/-", value: 3 });
+      expect(obj.items).toEqual([1, 2, 3]);
+    });
+  });
+
+  describe("remove operation", () => {
+    it("removes an object property", () => {
+      const obj: Record<string, unknown> = { name: "Alice", age: 30 };
+      applySpecStreamPatch(obj, { op: "remove", path: "/age" });
+      expect(obj).toEqual({ name: "Alice" });
+      expect("age" in obj).toBe(false);
+    });
+
+    it("removes from array by index", () => {
+      const obj: Record<string, unknown> = { items: ["a", "b", "c"] };
+      applySpecStreamPatch(obj, { op: "remove", path: "/items/1" });
+      expect(obj.items).toEqual(["a", "c"]);
+    });
+  });
+
+  describe("replace operation", () => {
+    it("replaces an existing value", () => {
+      const obj: Record<string, unknown> = { name: "Alice" };
+      applySpecStreamPatch(obj, {
+        op: "replace",
+        path: "/name",
+        value: "Bob",
+      });
+      expect(obj.name).toBe("Bob");
+    });
+
+    it("replaces nested values", () => {
+      const obj: Record<string, unknown> = { user: { name: "Alice" } };
+      applySpecStreamPatch(obj, {
+        op: "replace",
+        path: "/user/name",
+        value: "Bob",
+      });
+      expect((obj.user as Record<string, unknown>).name).toBe("Bob");
+    });
+  });
+
+  describe("move operation", () => {
+    it("moves a value from one path to another", () => {
+      const obj: Record<string, unknown> = { a: 1, b: 2 };
+      applySpecStreamPatch(obj, { op: "move", path: "/c", from: "/a" });
+      expect(obj).toEqual({ b: 2, c: 1 });
+      expect("a" in obj).toBe(false);
+    });
+
+    it("moves nested values", () => {
+      const obj: Record<string, unknown> = {
+        source: { val: 42 },
+        target: {},
+      };
+      applySpecStreamPatch(obj, {
+        op: "move",
+        path: "/target/val",
+        from: "/source/val",
+      });
+      expect((obj.target as Record<string, unknown>).val).toBe(42);
+      expect("val" in (obj.source as Record<string, unknown>)).toBe(false);
+    });
+
+    it("is a no-op if from is missing", () => {
+      const obj: Record<string, unknown> = { a: 1 };
+      applySpecStreamPatch(obj, { op: "move", path: "/b" });
+      expect(obj).toEqual({ a: 1 });
+    });
+  });
+
+  describe("copy operation", () => {
+    it("copies a value from one path to another", () => {
+      const obj: Record<string, unknown> = { a: 1 };
+      applySpecStreamPatch(obj, { op: "copy", path: "/b", from: "/a" });
+      expect(obj).toEqual({ a: 1, b: 1 });
+    });
+
+    it("copies complex values", () => {
+      const obj: Record<string, unknown> = {
+        source: { nested: { value: 42 } },
+      };
+      applySpecStreamPatch(obj, {
+        op: "copy",
+        path: "/dest",
+        from: "/source/nested",
+      });
+      expect(obj.dest).toEqual({ value: 42 });
+      // Source should still exist
+      expect((obj.source as Record<string, unknown>).nested).toEqual({
+        value: 42,
+      });
+    });
+
+    it("is a no-op if from is missing", () => {
+      const obj: Record<string, unknown> = { a: 1 };
+      applySpecStreamPatch(obj, { op: "copy", path: "/b" });
+      expect(obj).toEqual({ a: 1 });
+    });
+  });
+
+  describe("test operation", () => {
+    it("succeeds when values match", () => {
+      const obj: Record<string, unknown> = { name: "Alice" };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/name",
+          value: "Alice",
+        }),
+      ).not.toThrow();
+    });
+
+    it("succeeds for matching objects", () => {
+      const obj: Record<string, unknown> = { user: { name: "Alice", age: 30 } };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/user",
+          value: { name: "Alice", age: 30 },
+        }),
+      ).not.toThrow();
+    });
+
+    it("succeeds for matching arrays", () => {
+      const obj: Record<string, unknown> = { items: [1, 2, 3] };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/items",
+          value: [1, 2, 3],
+        }),
+      ).not.toThrow();
+    });
+
+    it("throws when values do not match", () => {
+      const obj: Record<string, unknown> = { name: "Alice" };
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/name",
+          value: "Bob",
+        }),
+      ).toThrow('Test operation failed: value at "/name" does not match');
+    });
+
+    it("throws when path does not exist", () => {
+      const obj: Record<string, unknown> = {};
+      expect(() =>
+        applySpecStreamPatch(obj, {
+          op: "test",
+          path: "/missing",
+          value: "anything",
+        }),
+      ).toThrow();
+    });
+  });
+});
+
+// =============================================================================
+// compileSpecStream
+// =============================================================================
+
+describe("compileSpecStream", () => {
+  it("compiles a series of add patches", () => {
+    const stream = `{"op":"add","path":"/name","value":"Alice"}
+{"op":"add","path":"/age","value":30}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ name: "Alice", age: 30 });
+  });
+
+  it("handles remove operations", () => {
+    const stream = `{"op":"add","path":"/a","value":1}
+{"op":"add","path":"/b","value":2}
+{"op":"remove","path":"/a"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ b: 2 });
+    expect("a" in result).toBe(false);
+  });
+
+  it("handles replace operations", () => {
+    const stream = `{"op":"add","path":"/name","value":"Alice"}
+{"op":"replace","path":"/name","value":"Bob"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ name: "Bob" });
+  });
+
+  it("handles move operations", () => {
+    const stream = `{"op":"add","path":"/old","value":"data"}
+{"op":"move","path":"/new","from":"/old"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ new: "data" });
+  });
+
+  it("handles copy operations", () => {
+    const stream = `{"op":"add","path":"/original","value":"data"}
+{"op":"copy","path":"/duplicate","from":"/original"}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ original: "data", duplicate: "data" });
+  });
+
+  it("skips empty lines", () => {
+    const stream = `{"op":"add","path":"/a","value":1}
+
+{"op":"add","path":"/b","value":2}`;
+    const result = compileSpecStream(stream);
+    expect(result).toEqual({ a: 1, b: 2 });
+  });
+
+  it("uses initial value", () => {
+    const stream = `{"op":"add","path":"/b","value":2}`;
+    const result = compileSpecStream(stream, { a: 1 });
+    expect(result).toEqual({ a: 1, b: 2 });
+  });
+});
+
+// =============================================================================
+// createSpecStreamCompiler
+// =============================================================================
+
+describe("createSpecStreamCompiler", () => {
+  it("processes chunks incrementally", () => {
+    const compiler = createSpecStreamCompiler();
+    const { result, newPatches } = compiler.push(
+      '{"op":"add","path":"/name","value":"Alice"}\n',
+    );
+    expect(newPatches).toHaveLength(1);
+    expect(result).toEqual({ name: "Alice" });
+  });
+
+  it("handles partial lines across chunks", () => {
+    const compiler = createSpecStreamCompiler();
+
+    // First chunk is incomplete
+    const r1 = compiler.push('{"op":"add","path":"/na');
+    expect(r1.newPatches).toHaveLength(0);
+
+    // Complete the line
+    const r2 = compiler.push('me","value":"Alice"}\n');
+    expect(r2.newPatches).toHaveLength(1);
+    expect(r2.result).toEqual({ name: "Alice" });
+  });
+
+  it("processes remaining buffer on getResult", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/name","value":"Alice"}');
+    // No newline, so not processed yet
+    const result = compiler.getResult();
+    expect(result).toEqual({ name: "Alice" });
+  });
+
+  it("resets to clean state", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/name","value":"Alice"}\n');
+    compiler.reset();
+    const result = compiler.getResult();
+    expect(result).toEqual({});
+  });
+
+  it("tracks all applied patches", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/a","value":1}\n');
+    compiler.push('{"op":"add","path":"/b","value":2}\n');
+    const patches = compiler.getPatches();
+    expect(patches).toHaveLength(2);
+    expect(patches[0]!.op).toBe("add");
+    expect(patches[1]!.op).toBe("add");
+  });
+
+  it("handles all RFC 6902 operations", () => {
+    const compiler = createSpecStreamCompiler();
+    compiler.push('{"op":"add","path":"/x","value":1}\n');
+    compiler.push('{"op":"add","path":"/y","value":2}\n');
+    compiler.push('{"op":"move","path":"/z","from":"/x"}\n');
+    compiler.push('{"op":"copy","path":"/w","from":"/y"}\n');
+    compiler.push('{"op":"remove","path":"/y"}\n');
+    const result = compiler.getResult();
+    expect(result).toEqual({ z: 1, w: 2 });
+  });
+});

+ 192 - 22
packages/core/src/types.ts

@@ -136,17 +136,20 @@ export type ComponentSchema = z.ZodType<Record<string, unknown>>;
 export type ValidationMode = "strict" | "warn" | "ignore";
 
 /**
- * JSON patch operation types
+ * JSON patch operation types (RFC 6902)
  */
-export type PatchOp = "add" | "remove" | "replace" | "set";
+export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test";
 
 /**
- * JSON patch operation
+ * JSON patch operation (RFC 6902)
  */
 export interface JsonPatch {
   op: PatchOp;
   path: string;
+  /** Required for add, replace, test */
   value?: unknown;
+  /** Required for move, copy (source location) */
+  from?: string;
 }
 
 /**
@@ -168,16 +171,30 @@ export function resolveDynamicValue<T>(
 }
 
 /**
- * Get a value from an object by JSON Pointer path
+ * Unescape a JSON Pointer token per RFC 6901 Section 4.
+ * ~1 is decoded to / and ~0 is decoded to ~ (order matters).
+ */
+function unescapeJsonPointer(token: string): string {
+  return token.replace(/~1/g, "/").replace(/~0/g, "~");
+}
+
+/**
+ * Parse a JSON Pointer path into unescaped segments.
+ */
+function parseJsonPointer(path: string): string[] {
+  const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
+  return raw.map(unescapeJsonPointer);
+}
+
+/**
+ * Get a value from an object by JSON Pointer path (RFC 6901)
  */
 export function getByPath(obj: unknown, path: string): unknown {
   if (!path || path === "/") {
     return obj;
   }
 
-  const segments = path.startsWith("/")
-    ? path.slice(1).split("/")
-    : path.split("/");
+  const segments = parseJsonPointer(path);
 
   let current: unknown = obj;
 
@@ -186,7 +203,10 @@ export function getByPath(obj: unknown, path: string): unknown {
       return undefined;
     }
 
-    if (typeof current === "object") {
+    if (Array.isArray(current)) {
+      const index = parseInt(segment, 10);
+      current = current[index];
+    } else if (typeof current === "object") {
       current = (current as Record<string, unknown>)[segment];
     } else {
       return undefined;
@@ -204,7 +224,7 @@ function isNumericIndex(str: string): boolean {
 }
 
 /**
- * Set a value in an object by JSON Pointer path.
+ * Set a value in an object by JSON Pointer path (RFC 6901).
  * Automatically creates arrays when the path segment is a numeric index.
  */
 export function setByPath(
@@ -212,9 +232,7 @@ export function setByPath(
   path: string,
   value: unknown,
 ): void {
-  const segments = path.startsWith("/")
-    ? path.slice(1).split("/")
-    : path.split("/");
+  const segments = parseJsonPointer(path);
 
   if (segments.length === 0) return;
 
@@ -224,7 +242,8 @@ export function setByPath(
     const segment = segments[i]!;
     const nextSegment = segments[i + 1];
     const nextIsNumeric =
-      nextSegment !== undefined && isNumericIndex(nextSegment);
+      nextSegment !== undefined &&
+      (isNumericIndex(nextSegment) || nextSegment === "-");
 
     if (Array.isArray(current)) {
       const index = parseInt(segment, 10);
@@ -242,13 +261,131 @@ export function setByPath(
 
   const lastSegment = segments[segments.length - 1]!;
   if (Array.isArray(current)) {
-    const index = parseInt(lastSegment, 10);
-    current[index] = value;
+    if (lastSegment === "-") {
+      current.push(value);
+    } else {
+      const index = parseInt(lastSegment, 10);
+      current[index] = value;
+    }
   } else {
     current[lastSegment] = value;
   }
 }
 
+/**
+ * Add a value per RFC 6902 "add" semantics.
+ * For objects: create-or-replace the member.
+ * For arrays: insert before the given index, or append if "-".
+ */
+export function addByPath(
+  obj: Record<string, unknown>,
+  path: string,
+  value: unknown,
+): void {
+  const segments = parseJsonPointer(path);
+
+  if (segments.length === 0) return;
+
+  let current: Record<string, unknown> | unknown[] = obj;
+
+  for (let i = 0; i < segments.length - 1; i++) {
+    const segment = segments[i]!;
+    const nextSegment = segments[i + 1];
+    const nextIsNumeric =
+      nextSegment !== undefined &&
+      (isNumericIndex(nextSegment) || nextSegment === "-");
+
+    if (Array.isArray(current)) {
+      const index = parseInt(segment, 10);
+      if (current[index] === undefined || typeof current[index] !== "object") {
+        current[index] = nextIsNumeric ? [] : {};
+      }
+      current = current[index] as Record<string, unknown> | unknown[];
+    } else {
+      if (!(segment in current) || typeof current[segment] !== "object") {
+        current[segment] = nextIsNumeric ? [] : {};
+      }
+      current = current[segment] as Record<string, unknown> | unknown[];
+    }
+  }
+
+  const lastSegment = segments[segments.length - 1]!;
+  if (Array.isArray(current)) {
+    if (lastSegment === "-") {
+      current.push(value);
+    } else {
+      const index = parseInt(lastSegment, 10);
+      current.splice(index, 0, value);
+    }
+  } else {
+    current[lastSegment] = value;
+  }
+}
+
+/**
+ * Remove a value per RFC 6902 "remove" semantics.
+ * For objects: delete the property.
+ * For arrays: splice out the element at the given index.
+ */
+export function removeByPath(obj: Record<string, unknown>, path: string): void {
+  const segments = parseJsonPointer(path);
+
+  if (segments.length === 0) return;
+
+  let current: Record<string, unknown> | unknown[] = obj;
+
+  for (let i = 0; i < segments.length - 1; i++) {
+    const segment = segments[i]!;
+
+    if (Array.isArray(current)) {
+      const index = parseInt(segment, 10);
+      if (current[index] === undefined || typeof current[index] !== "object") {
+        return; // path does not exist
+      }
+      current = current[index] as Record<string, unknown> | unknown[];
+    } else {
+      if (!(segment in current) || typeof current[segment] !== "object") {
+        return; // path does not exist
+      }
+      current = current[segment] as Record<string, unknown> | unknown[];
+    }
+  }
+
+  const lastSegment = segments[segments.length - 1]!;
+  if (Array.isArray(current)) {
+    const index = parseInt(lastSegment, 10);
+    if (index >= 0 && index < current.length) {
+      current.splice(index, 1);
+    }
+  } else {
+    delete current[lastSegment];
+  }
+}
+
+/**
+ * Deep equality check for RFC 6902 "test" operation.
+ */
+function deepEqual(a: unknown, b: unknown): boolean {
+  if (a === b) return true;
+  if (a === null || b === null) return false;
+  if (typeof a !== typeof b) return false;
+  if (typeof a !== "object") return false;
+
+  if (Array.isArray(a)) {
+    if (!Array.isArray(b)) return false;
+    if (a.length !== b.length) return false;
+    return a.every((item, i) => deepEqual(item, b[i]));
+  }
+
+  const aObj = a as Record<string, unknown>;
+  const bObj = b as Record<string, unknown>;
+  const aKeys = Object.keys(aObj);
+  const bKeys = Object.keys(bObj);
+
+  if (aKeys.length !== bKeys.length) return false;
+  return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
+}
+
 /**
  * Find a form value from params and/or data.
  * Useful in action handlers to locate form input values regardless of path format.
@@ -345,17 +482,50 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null {
 }
 
 /**
- * Apply a single SpecStream patch to an object.
+ * Apply a single RFC 6902 JSON Patch operation to an object.
  * Mutates the object in place.
+ *
+ * Supports all six RFC 6902 operations: add, remove, replace, move, copy, test.
+ *
+ * @throws {Error} If a "test" operation fails (value mismatch).
  */
 export function applySpecStreamPatch<T extends Record<string, unknown>>(
   obj: T,
   patch: SpecStreamLine,
 ): T {
-  if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
-    setByPath(obj, patch.path, patch.value);
-  } else if (patch.op === "remove") {
-    setByPath(obj, patch.path, undefined);
+  switch (patch.op) {
+    case "add":
+      addByPath(obj, patch.path, patch.value);
+      break;
+    case "replace":
+      // RFC 6902: target must exist. For streaming tolerance we set regardless.
+      setByPath(obj, patch.path, patch.value);
+      break;
+    case "remove":
+      removeByPath(obj, patch.path);
+      break;
+    case "move": {
+      if (!patch.from) break;
+      const moveValue = getByPath(obj, patch.from);
+      removeByPath(obj, patch.from);
+      addByPath(obj, patch.path, moveValue);
+      break;
+    }
+    case "copy": {
+      if (!patch.from) break;
+      const copyValue = getByPath(obj, patch.from);
+      addByPath(obj, patch.path, copyValue);
+      break;
+    }
+    case "test": {
+      const actual = getByPath(obj, patch.path);
+      if (!deepEqual(actual, patch.value)) {
+        throw new Error(
+          `Test operation failed: value at "${patch.path}" does not match`,
+        );
+      }
+      break;
+    }
   }
   return obj;
 }
@@ -365,8 +535,8 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
  * Each line should be a patch operation.
  *
  * @example
- * const stream = `{"op":"set","path":"/name","value":"Alice"}
- * {"op":"set","path":"/age","value":30}`;
+ * const stream = `{"op":"add","path":"/name","value":"Alice"}
+ * {"op":"add","path":"/age","value":30}`;
  * const result = compileSpecStream(stream);
  * // { name: "Alice", age: 30 }
  */

+ 94 - 41
packages/react/src/hooks.ts

@@ -7,7 +7,12 @@ import type {
   FlatElement,
   JsonPatch,
 } from "@json-render/core";
-import { setByPath } from "@json-render/core";
+import {
+  setByPath,
+  getByPath,
+  addByPath,
+  removeByPath,
+} from "@json-render/core";
 
 /**
  * Parse a single JSON patch line
@@ -25,56 +30,104 @@ function parsePatchLine(line: string): JsonPatch | null {
 }
 
 /**
- * Apply a JSON patch to the current spec
+ * Set a value at a spec path (for add/replace operations).
+ */
+function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
+  if (path === "/root") {
+    newSpec.root = value as string;
+    return;
+  }
+
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+
+    if (pathParts.length === 1) {
+      newSpec.elements[elementKey] = value as UIElement;
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const propPath = "/" + pathParts.slice(1).join("/");
+        const newElement = { ...element };
+        setByPath(
+          newElement as unknown as Record<string, unknown>,
+          propPath,
+          value,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+/**
+ * Remove a value at a spec path.
+ */
+function removeSpecValue(newSpec: Spec, path: string): void {
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+
+    if (pathParts.length === 1) {
+      const { [elementKey]: _, ...rest } = newSpec.elements;
+      newSpec.elements = rest;
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const propPath = "/" + pathParts.slice(1).join("/");
+        const newElement = { ...element };
+        removeByPath(
+          newElement as unknown as Record<string, unknown>,
+          propPath,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+/**
+ * Get a value at a spec path.
+ */
+function getSpecValue(spec: Spec, path: string): unknown {
+  if (path === "/root") return spec.root;
+  return getByPath(spec as unknown as Record<string, unknown>, path);
+}
+
+/**
+ * Apply an RFC 6902 JSON patch to the current spec.
+ * Supports add, remove, replace, move, copy, and test operations.
  */
 function applyPatch(spec: Spec, patch: JsonPatch): Spec {
   const newSpec = { ...spec, elements: { ...spec.elements } };
 
   switch (patch.op) {
-    case "set":
     case "add":
     case "replace": {
-      // Handle root path
-      if (patch.path === "/root") {
-        newSpec.root = patch.value as string;
-        return newSpec;
-      }
-
-      // Handle elements paths
-      if (patch.path.startsWith("/elements/")) {
-        const pathParts = patch.path.slice("/elements/".length).split("/");
-        const elementKey = pathParts[0];
-
-        if (!elementKey) return newSpec;
-
-        if (pathParts.length === 1) {
-          // Setting entire element
-          newSpec.elements[elementKey] = patch.value as UIElement;
-        } else {
-          // Setting property of element
-          const element = newSpec.elements[elementKey];
-          if (element) {
-            const propPath = "/" + pathParts.slice(1).join("/");
-            const newElement = { ...element };
-            setByPath(
-              newElement as unknown as Record<string, unknown>,
-              propPath,
-              patch.value,
-            );
-            newSpec.elements[elementKey] = newElement;
-          }
-        }
-      }
+      setSpecValue(newSpec, patch.path, patch.value);
       break;
     }
     case "remove": {
-      if (patch.path.startsWith("/elements/")) {
-        const elementKey = patch.path.slice("/elements/".length).split("/")[0];
-        if (elementKey) {
-          const { [elementKey]: _, ...rest } = newSpec.elements;
-          newSpec.elements = rest;
-        }
-      }
+      removeSpecValue(newSpec, patch.path);
+      break;
+    }
+    case "move": {
+      if (!patch.from) break;
+      const moveValue = getSpecValue(newSpec, patch.from);
+      removeSpecValue(newSpec, patch.from);
+      setSpecValue(newSpec, patch.path, moveValue);
+      break;
+    }
+    case "copy": {
+      if (!patch.from) break;
+      const copyValue = getSpecValue(newSpec, patch.from);
+      setSpecValue(newSpec, patch.path, copyValue);
+      break;
+    }
+    case "test": {
+      // test is a no-op for rendering purposes (validation only)
       break;
     }
   }

+ 10 - 9
packages/remotion/src/schema.ts

@@ -25,11 +25,12 @@ function remotionPromptTemplate(context: PromptContext): string {
   lines.push("");
   lines.push("Example output (each line is a separate JSON object):");
   lines.push("");
-  lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
-{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
-{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
-{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
-{"op":"set","path":"/audio","value":{"tracks":[]}}`);
+  lines.push(`{"op":"add","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
+{"op":"add","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
+{"op":"add","path":"/clips","value":[]}
+{"op":"add","path":"/clips/-","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
+{"op":"add","path":"/clips/-","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
+{"op":"add","path":"/audio","value":{"tracks":[]}}`);
   lines.push("");
 
   // Components
@@ -107,10 +108,10 @@ function remotionPromptTemplate(context: PromptContext): string {
   lines.push("RULES:");
   const baseRules = [
     "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
-    "First set /composition with {id, fps:30, width:1920, height:1080, durationInFrames}",
-    "Then set /tracks array with video/overlay tracks",
-    "Then set each clip: /clips/0, /clips/1, etc.",
-    "Finally set /audio with {tracks:[]}",
+    'First add /composition with {id, fps:30, width:1920, height:1080, durationInFrames}: {"op":"add","path":"/composition","value":{...}}',
+    'Then add /tracks array with video/overlay tracks: {"op":"add","path":"/tracks","value":[...]}',
+    'Then add each clip by appending to the array: {"op":"add","path":"/clips/-","value":{...}}',
+    'Finally add /audio with {tracks:[]}: {"op":"add","path":"/audio","value":{...}}',
     "ONLY use components listed above",
     "fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
     'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff