Skip to content

Commit f1dfabb

Browse files
authored
feat(core): expose commit metadata options in setState (#51)
1 parent b61a98b commit f1dfabb

File tree

5 files changed

+208
-20
lines changed

5 files changed

+208
-20
lines changed

api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Contents
4242
- Mutate a draft: `(draft: InferType<S>) => void`
4343
- Return a new object: `(prev: Readonly<InferInputType<S>>) => InferInputType<S>`
4444
- Shallow partial: `Partial<InferInputType<S>>`
45-
- `options?: { tags?: string | string[] }` — tags surface in subscriber metadata
45+
- `options?: { tags?: string | string[]; origin?: string; timestamp?: number; message?: string }` — tags surface in subscriber metadata; commit metadata (`origin`, `timestamp`, `message`) is forwarded to the underlying Loro commit
4646
- `subscribe((state, metadata) => void): () => void`
4747
- `metadata: { direction: SyncDirection; tags?: string[] }`
4848
- Returns an unsubscribe function
@@ -55,7 +55,7 @@ Contents
5555
- `FROM_LORO` — changes applied from the Loro document
5656
- `TO_LORO` — changes produced by `setState`
5757
- `BIDIRECTIONAL` — manual/initial sync context
58-
- Mirror ignores events with origin `"to-loro"` to prevent feedback loops.
58+
- Mirror suppresses document events emitted during its own `setState` commits to prevent feedback loops; provide `origin`, `timestamp`, or `message` when you need to tag those commits.
5959
- Initial state precedence: defaults (from schema) → `doc` snapshot (normalized) → hinted shapes from `initialState` (no writes to Loro).
6060
- Trees: mirror state uses `{ id: string; data: object; children: Node[] }`. Loro tree `meta` is normalized to `data`.
6161
- `$cid` on maps: Mirror injects a read-only `$cid` field into every LoroMap shape in state. It equals the Loro container ID, is not written back to Loro, and is ignored by diffs.

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Trees are advanced usage; see Advanced: Trees at the end.
8282
- Methods:
8383
- getState(): Current state
8484
- setState(updater | partial, options?): Mutate a draft or return a new object. Runs synchronously so downstream logic can immediately read the latest state.
85-
- options: `{ tags?: string | string[] }` (surfaces in subscriber metadata)
85+
- options: `{ tags?: string | string[]; origin?: string; timestamp?: number; message?: string }` — tags surface in subscriber metadata; commit metadata is forwarded to the underlying Loro commit.
8686
- subscribe((state, metadata) => void): Subscribe; returns unsubscribe
8787
- metadata: `{ direction: FROM_LORO | TO_LORO; tags?: string[] }`
8888
- dispose(): Remove all subscriptions

packages/core/src/core/mirror.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,19 @@ export interface SetStateOptions {
239239
* Tags can be used for tracking the source of changes or grouping related changes
240240
*/
241241
tags?: string[] | string;
242+
/**
243+
* Optional origin metadata forwarded to the underlying Loro commit.
244+
* Useful when callers need to tag commits with application-specific provenance.
245+
*/
246+
origin?: string;
247+
/**
248+
* Optional timestamp forwarded to the underlying Loro commit metadata.
249+
*/
250+
timestamp?: number;
251+
/**
252+
* Optional message forwarded to the underlying Loro commit metadata.
253+
*/
254+
message?: string;
242255
}
243256

244257
type ContainerRegistry = Map<
@@ -573,7 +586,7 @@ export class Mirror<S extends SchemaType> {
573586
* Handle events from the LoroDoc
574587
*/
575588
private handleLoroEvent = (event: LoroEventBatch) => {
576-
if (event.origin === "to-loro") return;
589+
if (this.syncing) return;
577590
this.syncing = true;
578591
try {
579592
// Pre-register any containers referenced in this batch
@@ -713,7 +726,10 @@ export class Mirror<S extends SchemaType> {
713726
/**
714727
* Update Loro based on state changes
715728
*/
716-
private updateLoro(newState: InferType<S>) {
729+
private updateLoro(
730+
newState: InferType<S>,
731+
options?: SetStateOptions,
732+
) {
717733
if (this.syncing) return;
718734

719735
this.syncing = true;
@@ -730,7 +746,7 @@ export class Mirror<S extends SchemaType> {
730746
this.options?.inferOptions,
731747
);
732748
// Apply the changes to the Loro document (and stamp any pending-state metadata like $cid)
733-
this.applyChangesToLoro(changes, newState);
749+
this.applyChangesToLoro(changes, newState, options);
734750
} finally {
735751
this.syncing = false;
736752
}
@@ -739,7 +755,11 @@ export class Mirror<S extends SchemaType> {
739755
/**
740756
* Apply a set of changes to the Loro document
741757
*/
742-
private applyChangesToLoro(changes: Change[], pendingState?: InferType<S>) {
758+
private applyChangesToLoro(
759+
changes: Change[],
760+
pendingState?: InferType<S>,
761+
options?: SetStateOptions,
762+
) {
743763
// Group changes by container for batch processing
744764
const changesByContainer = new Map<ContainerID | "", Change[]>();
745765

@@ -777,7 +797,31 @@ export class Mirror<S extends SchemaType> {
777797
}
778798
// Only commit if we actually applied any changes
779799
if (changes.length > 0) {
780-
this.doc.commit({ origin: "to-loro" });
800+
let commitOptions: Parameters<LoroDoc["commit"]>[0];
801+
if (options) {
802+
const commitMeta: {
803+
origin?: string;
804+
timestamp?: number;
805+
message?: string;
806+
} = {};
807+
if (options.origin !== undefined) {
808+
commitMeta.origin = options.origin;
809+
}
810+
if (options.timestamp !== undefined) {
811+
commitMeta.timestamp = options.timestamp;
812+
}
813+
if (options.message !== undefined) {
814+
commitMeta.message = options.message;
815+
}
816+
if (
817+
commitMeta.origin !== undefined ||
818+
commitMeta.timestamp !== undefined ||
819+
commitMeta.message !== undefined
820+
) {
821+
commitOptions = commitMeta;
822+
}
823+
}
824+
this.doc.commit(commitOptions);
781825
}
782826
}
783827

@@ -1767,7 +1811,7 @@ export class Mirror<S extends SchemaType> {
17671811
// Update Loro based on new state
17681812
// Refresh in-memory state from Doc to capture assigned IDs (e.g., TreeIDs)
17691813
// and any canonical normalization (like Tree meta->data mapping).
1770-
this.updateLoro(newState);
1814+
this.updateLoro(newState, options);
17711815
this.state = newState;
17721816
const shouldCheck = this.options.checkStateConsistency;
17731817
if (shouldCheck) {

packages/core/tests/mirror-tree.test.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
22
/* eslint-disable unicorn/consistent-function-scoping */
33
import { describe, it, expect } from "vitest";
4-
import { LoroDoc, LoroText } from "loro-crdt";
4+
import { LoroDoc, LoroText, type LoroEventBatch } from "loro-crdt";
55
import { Mirror } from "../src/core/mirror";
66
import { applyEventBatchToState } from "../src/core/loroEventApply";
77
import { schema } from "../src/schema";
@@ -728,7 +728,7 @@ describe("LoroTree integration", () => {
728728
await tick();
729729
expect(m.getState().tree[0].data.tags).toEqual(["mid", "y"]);
730730
});
731-
it("FROM_LORO: ignores own origin 'to-loro' events to avoid feedback", async () => {
731+
it("FROM_LORO: ignores mirror-produced events to avoid feedback", async () => {
732732
const doc = new LoroDoc();
733733
const s = schema({
734734
tree: schema.LoroTree(schema.LoroMap({ title: schema.String() })),
@@ -743,7 +743,7 @@ describe("LoroTree integration", () => {
743743
} as any);
744744
await tick();
745745

746-
// Only TO_LORO notification should be recorded (FROM_LORO ignored due to origin)
746+
// Only TO_LORO notification should be recorded (FROM_LORO ignored because we suppress local commits)
747747
expect(directions.filter((d) => d === "TO_LORO").length).toBe(1);
748748
expect(directions.filter((d) => d === "FROM_LORO").length).toBe(0);
749749
});
@@ -1040,16 +1040,11 @@ describe("LoroTree integration", () => {
10401040
true,
10411041
]);
10421042

1043-
// Collect only the next TO_LORO event's tree diffs
1043+
// Collect only the tree diffs from the reorder commit
10441044
let lastTreeOps: any[] = [];
1045+
const batches: LoroEventBatch[] = [];
10451046
const unsub = doc.subscribe((batch) => {
1046-
// Mirror commits with origin "to-loro" for setState updates
1047-
if (batch.origin !== "to-loro") return;
1048-
for (const e of batch.events) {
1049-
if (e.diff.type === "tree") {
1050-
lastTreeOps.push(...e.diff.diff);
1051-
}
1052-
}
1047+
batches.push(batch);
10531048
});
10541049

10551050
// New state: reorder to C, A, B (by ids) – expect only moves, not full delete+create
@@ -1061,6 +1056,16 @@ describe("LoroTree integration", () => {
10611056
await tick();
10621057
unsub();
10631058

1059+
const lastBatch = batches[batches.length - 1];
1060+
expect(lastBatch).toBeDefined();
1061+
if (lastBatch) {
1062+
for (const e of lastBatch.events) {
1063+
if (e.diff.type === "tree") {
1064+
lastTreeOps.push(...e.diff.diff);
1065+
}
1066+
}
1067+
}
1068+
10641069
// Validate we only saw move operations (no full rebuild)
10651070
expect(lastTreeOps.length).toBeGreaterThan(0);
10661071
expect(lastTreeOps.every((op) => op.action === "move")).toBe(true);

packages/core/tests/mirror.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,145 @@ describe("Mirror - State Consistency", () => {
1717
doc = new LoroDoc();
1818
});
1919

20+
it("forwards commit metadata through setState options", async () => {
21+
const metaSchema = schema({
22+
root: schema.LoroMap({
23+
value: schema.String(),
24+
}),
25+
});
26+
27+
const commitSpy = vi.spyOn(doc, "commit");
28+
const mirror = new Mirror({
29+
doc,
30+
schema: metaSchema,
31+
});
32+
33+
commitSpy.mockClear();
34+
35+
const timestamp = Date.now();
36+
const message = "update-from-ui";
37+
const origin = "ui-panel";
38+
39+
mirror.setState(
40+
{
41+
root: {
42+
value: "next",
43+
},
44+
} as any,
45+
{ origin, timestamp, message },
46+
);
47+
48+
await waitForSync();
49+
50+
expect(commitSpy).toHaveBeenCalledTimes(1);
51+
expect(commitSpy.mock.calls[0][0]).toEqual({
52+
origin,
53+
timestamp,
54+
message,
55+
});
56+
57+
commitSpy.mockRestore();
58+
mirror.dispose();
59+
});
60+
61+
it("forwards partial commit metadata when only timestamp is provided", async () => {
62+
const metaSchema = schema({
63+
root: schema.LoroMap({
64+
value: schema.String(),
65+
}),
66+
});
67+
68+
const commitSpy = vi.spyOn(doc, "commit");
69+
const mirror = new Mirror({
70+
doc,
71+
schema: metaSchema,
72+
});
73+
74+
commitSpy.mockClear();
75+
76+
const timestamp = Date.now();
77+
mirror.setState(
78+
{
79+
root: {
80+
value: "time-only",
81+
},
82+
} as any,
83+
{ timestamp },
84+
);
85+
86+
await waitForSync();
87+
88+
expect(commitSpy).toHaveBeenCalledTimes(1);
89+
expect(commitSpy.mock.calls[0][0]).toEqual({ timestamp });
90+
91+
commitSpy.mockRestore();
92+
mirror.dispose();
93+
});
94+
95+
it("forwards partial commit metadata when only message is provided", async () => {
96+
const metaSchema = schema({
97+
root: schema.LoroMap({
98+
value: schema.String(),
99+
}),
100+
});
101+
102+
const commitSpy = vi.spyOn(doc, "commit");
103+
const mirror = new Mirror({
104+
doc,
105+
schema: metaSchema,
106+
});
107+
108+
commitSpy.mockClear();
109+
110+
const message = "note";
111+
mirror.setState(
112+
{
113+
root: {
114+
value: "message-only",
115+
},
116+
} as any,
117+
{ message },
118+
);
119+
120+
await waitForSync();
121+
122+
expect(commitSpy).toHaveBeenCalledTimes(1);
123+
expect(commitSpy.mock.calls[0][0]).toEqual({ message });
124+
125+
commitSpy.mockRestore();
126+
mirror.dispose();
127+
});
128+
129+
it("omits commit metadata when options are not provided", async () => {
130+
const metaSchema = schema({
131+
root: schema.LoroMap({
132+
value: schema.String(),
133+
}),
134+
});
135+
136+
const commitSpy = vi.spyOn(doc, "commit");
137+
const mirror = new Mirror({
138+
doc,
139+
schema: metaSchema,
140+
});
141+
142+
commitSpy.mockClear();
143+
144+
mirror.setState({
145+
root: {
146+
value: "no-options",
147+
},
148+
} as any);
149+
150+
await waitForSync();
151+
152+
expect(commitSpy).toHaveBeenCalledTimes(1);
153+
expect(commitSpy.mock.calls[0][0]).toBeUndefined();
154+
155+
commitSpy.mockRestore();
156+
mirror.dispose();
157+
});
158+
20159
it("syncs initial state from LoroDoc correctly", async () => {
21160
// Set up initial Loro state
22161
const todoMap = doc.getMap("todos");

0 commit comments

Comments
 (0)