@mengine/medeo-client 1.0.1-alpha.2 → 1.1.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.
@@ -0,0 +1,58 @@
1
+ import { LoroDoc } from "loro-crdt";
2
+ //#region src/relay/loro-relay-doc.ts
3
+ /**
4
+ * Loro OpLog-relay primitives: the doc shape and update classification an update
5
+ * *host* needs, as opposed to an editing client.
6
+ *
7
+ * A relay never materializes DocState (`docs/concepts/oplog_docstate` §Relay
8
+ * Server) — it keeps a detached `LoroDoc` that validates and accumulates updates
9
+ * and exports diffs by version vector.
10
+ *
11
+ * These live in `medeo-client` rather than in `mengine-server` because two
12
+ * separate hosts must classify identically: the real server (`apps/mengine-server`,
13
+ * both storage implementations) and the in-process test double
14
+ * (`testing/in-memory-mengine-server.ts`) that client-side tests push against. A
15
+ * double with its own verdict logic drifts from the server it stands in for, and
16
+ * the four push outcomes it produces are exactly what those tests assert on — so
17
+ * the classification is shared code, not duplicated code.
18
+ */
19
+ /** Build a fresh detached relay doc (no DocState materialization). */
20
+ function newRelayDoc() {
21
+ const doc = new LoroDoc();
22
+ doc.detach();
23
+ return doc;
24
+ }
25
+ /** Load a detached relay doc from a stored snapshot. */
26
+ function relayDocFromSnapshot(snapshot) {
27
+ const doc = newRelayDoc();
28
+ doc.import(snapshot);
29
+ return doc;
30
+ }
31
+ /** A full-oplog clone (via snapshot round-trip) for non-destructive probing. */
32
+ function cloneRelayDoc(doc) {
33
+ return relayDocFromSnapshot(doc.export({ mode: "snapshot" }));
34
+ }
35
+ /**
36
+ * Classify an incoming update against `doc` without mutating it.
37
+ *
38
+ * Deliberately version-delta based, not `ImportStatus`-shape based: in loro-crdt
39
+ * 1.13.x the JS `import()` returns `success`/`pending` as objects whose ranges
40
+ * are not reliably populated, and a pending (out-of-order) import still buffers
41
+ * the orphan op into the oplog. So this imports into a throwaway snapshot-clone
42
+ * and inspects whether the oplog frontiers advanced — letting the caller import
43
+ * into its canonical doc only on `accepted`, keeping orphan bytes out of the log.
44
+ */
45
+ function classifyUpdate(doc, update) {
46
+ const probe = cloneRelayDoc(doc);
47
+ let status;
48
+ try {
49
+ status = probe.import(update);
50
+ } catch {
51
+ return "corrupt_update";
52
+ }
53
+ if (status?.pending != null) return "missing_dependency";
54
+ if (probe.cmpWithFrontiers(doc.oplogFrontiers()) !== 1) return "duplicate";
55
+ return "accepted";
56
+ }
57
+ //#endregion
58
+ export { newRelayDoc as n, relayDocFromSnapshot as r, classifyUpdate as t };
@@ -0,0 +1,37 @@
1
+ import { LoroDoc } from "loro-crdt";
2
+
3
+ //#region src/relay/loro-relay-doc.d.ts
4
+ /**
5
+ * Loro OpLog-relay primitives: the doc shape and update classification an update
6
+ * *host* needs, as opposed to an editing client.
7
+ *
8
+ * A relay never materializes DocState (`docs/concepts/oplog_docstate` §Relay
9
+ * Server) — it keeps a detached `LoroDoc` that validates and accumulates updates
10
+ * and exports diffs by version vector.
11
+ *
12
+ * These live in `medeo-client` rather than in `mengine-server` because two
13
+ * separate hosts must classify identically: the real server (`apps/mengine-server`,
14
+ * both storage implementations) and the in-process test double
15
+ * (`testing/in-memory-mengine-server.ts`) that client-side tests push against. A
16
+ * double with its own verdict logic drifts from the server it stands in for, and
17
+ * the four push outcomes it produces are exactly what those tests assert on — so
18
+ * the classification is shared code, not duplicated code.
19
+ */
20
+ /** Build a fresh detached relay doc (no DocState materialization). */
21
+ declare function newRelayDoc(): LoroDoc;
22
+ /** Load a detached relay doc from a stored snapshot. */
23
+ declare function relayDocFromSnapshot(snapshot: Uint8Array): LoroDoc;
24
+ type ImportVerdict = 'accepted' | 'duplicate' | 'corrupt_update' | 'missing_dependency';
25
+ /**
26
+ * Classify an incoming update against `doc` without mutating it.
27
+ *
28
+ * Deliberately version-delta based, not `ImportStatus`-shape based: in loro-crdt
29
+ * 1.13.x the JS `import()` returns `success`/`pending` as objects whose ranges
30
+ * are not reliably populated, and a pending (out-of-order) import still buffers
31
+ * the orphan op into the oplog. So this imports into a throwaway snapshot-clone
32
+ * and inspects whether the oplog frontiers advanced — letting the caller import
33
+ * into its canonical doc only on `accepted`, keeping orphan bytes out of the log.
34
+ */
35
+ declare function classifyUpdate(doc: LoroDoc, update: Uint8Array): ImportVerdict;
36
+ //#endregion
37
+ export { type ImportVerdict, classifyUpdate, newRelayDoc, relayDocFromSnapshot };
package/dist/relay.js ADDED
@@ -0,0 +1,2 @@
1
+ import { n as newRelayDoc, r as relayDocFromSnapshot, t as classifyUpdate } from "./loro-relay-doc-ssdYpuef.js";
2
+ export { classifyUpdate, newRelayDoc, relayDocFromSnapshot };
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { c as MirrorVideoDocumentAdapter, it as VideoDraft } from "./index-DRUGsbm2.js";
1
+ import { i as MirrorVideoDocumentAdapter, tt as VideoDraft } from "./index-BUKA3L7o.js";
2
2
 
3
3
  //#region src/testing/in-memory-mengine-server.d.ts
4
4
  /**
@@ -29,6 +29,21 @@ declare class InMemoryMengineServer {
29
29
  private handleSnapshot;
30
30
  private handleBootstrap;
31
31
  private handleSync;
32
+ /**
33
+ * Push one update, classifying it exactly as the real server does.
34
+ *
35
+ * This shares `classifyUpdate` with `apps/mengine-server` rather than assuming
36
+ * success, because the four push verdicts are the contract clients are written
37
+ * against: a double that always acks makes `duplicate` / `rejected` untestable
38
+ * above the storage layer, so an ack-waiter or a doc handle could mishandle
39
+ * them and still show green. It also mirrors the real server's two subtler
40
+ * behaviors — a non-accepted update is NOT imported (orphan bytes stay out of
41
+ * the log), and `duplicate` / `rejected` report the version from *before* the
42
+ * push, since nothing was appended.
43
+ *
44
+ * Status codes match the wire contract: `missing_dependency` is 409 (retryable
45
+ * after catch-up), `corrupt_update` is 422 (never retryable).
46
+ */
32
47
  private handlePush;
33
48
  private handleEvents;
34
49
  }
package/dist/testing.js CHANGED
@@ -1,4 +1,5 @@
1
- import { B as bytesToBase64, N as VIDEO_DOCUMENT_SCHEMA_VERSION, o as createMirrorVideoDocumentAdapter, z as base64ToBytes } from "./document-DgffKwRw.js";
1
+ import { L as base64ToBytes, M as VIDEO_DOCUMENT_SCHEMA_VERSION, R as bytesToBase64, r as createMirrorVideoDocumentAdapter } from "./document-C98vSu7J.js";
2
+ import { t as classifyUpdate } from "./loro-relay-doc-ssdYpuef.js";
2
3
  import { LoroDoc, VersionVector, encodeFrontiers } from "loro-crdt";
3
4
  //#region src/testing/in-memory-mengine-server.ts
4
5
  /**
@@ -35,10 +36,6 @@ var InMemoryMengineServer = class {
35
36
  if (path.startsWith("sync") && method === "GET") return this.handleSync(url);
36
37
  if (path === "updates" && method === "POST") return this.handlePush(init);
37
38
  if (path === "events" && method === "GET") return this.handleEvents(init);
38
- if (path === "audit" && method === "GET") return Response.json({ entries: this.updates.map((u) => ({
39
- ...u.meta,
40
- update_seq: u.updateSeq
41
- })) });
42
39
  return new Response("not found", { status: 404 });
43
40
  };
44
41
  parsePath(url) {
@@ -85,6 +82,21 @@ var InMemoryMengineServer = class {
85
82
  server_vv: bytesToBase64(this.doc.oplogVersion().encode())
86
83
  });
87
84
  }
85
+ /**
86
+ * Push one update, classifying it exactly as the real server does.
87
+ *
88
+ * This shares `classifyUpdate` with `apps/mengine-server` rather than assuming
89
+ * success, because the four push verdicts are the contract clients are written
90
+ * against: a double that always acks makes `duplicate` / `rejected` untestable
91
+ * above the storage layer, so an ack-waiter or a doc handle could mishandle
92
+ * them and still show green. It also mirrors the real server's two subtler
93
+ * behaviors — a non-accepted update is NOT imported (orphan bytes stay out of
94
+ * the log), and `duplicate` / `rejected` report the version from *before* the
95
+ * push, since nothing was appended.
96
+ *
97
+ * Status codes match the wire contract: `missing_dependency` is 409 (retryable
98
+ * after catch-up), `corrupt_update` is 422 (never retryable).
99
+ */
88
100
  async handlePush(init) {
89
101
  const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
90
102
  if (body.update == null) return Response.json({
@@ -94,6 +106,25 @@ var InMemoryMengineServer = class {
94
106
  }, { status: 400 });
95
107
  const data = base64ToBytes(body.update);
96
108
  const baseVV = this.doc.oplogVersion();
109
+ const serverVersion = this.version();
110
+ const verdict = classifyUpdate(this.doc, data);
111
+ if (verdict === "corrupt_update") return Response.json({
112
+ kind: "rejected",
113
+ code: "corrupt_update",
114
+ message: "update failed to decode",
115
+ server_version: serverVersion
116
+ }, { status: 422 });
117
+ if (verdict === "missing_dependency") return Response.json({
118
+ kind: "rejected",
119
+ code: "missing_dependency",
120
+ message: "update depends on operations that are not in the server log",
121
+ server_version: serverVersion
122
+ }, { status: 409 });
123
+ if (verdict === "duplicate") return Response.json({
124
+ kind: "duplicate",
125
+ update_seq: null,
126
+ version: serverVersion
127
+ });
97
128
  this.doc.import(data);
98
129
  this.hasSnapshot = true;
99
130
  this.seq += 1;
@@ -161,6 +192,7 @@ function extractMeta(doc, baseVV) {
161
192
  semantic_op: null,
162
193
  payload: null,
163
194
  intent: null,
195
+ actor: null,
164
196
  message: null,
165
197
  parse_error: false,
166
198
  peer: "0",
@@ -171,11 +203,12 @@ function extractMeta(doc, baseVV) {
171
203
  };
172
204
  const [counterPart, peerPart] = change.id.split("@");
173
205
  const message = change.msg ?? change.message ?? null;
174
- const { semantic_op, payload, intent, parse_error } = parseMessage(message);
206
+ const { semantic_op, payload, intent, actor, parse_error } = parseMessage(message);
175
207
  return {
176
208
  semantic_op,
177
209
  payload,
178
210
  intent,
211
+ actor,
179
212
  message,
180
213
  parse_error,
181
214
  peer: peers[Number(peerPart)] ?? peerPart ?? "0",
@@ -185,11 +218,17 @@ function extractMeta(doc, baseVV) {
185
218
  frontiers
186
219
  };
187
220
  }
221
+ /**
222
+ * Mirrors `apps/mengine-server`'s `parseMessage` / `parseActor`. Kept in step
223
+ * deliberately: this double is what harness tests push against, so a divergence
224
+ * here would let them assert metadata the real server never produces.
225
+ */
188
226
  function parseMessage(message) {
189
227
  if (message == null || message === "") return {
190
228
  semantic_op: null,
191
229
  payload: null,
192
230
  intent: null,
231
+ actor: null,
193
232
  parse_error: false
194
233
  };
195
234
  try {
@@ -198,12 +237,14 @@ function parseMessage(message) {
198
237
  semantic_op: null,
199
238
  payload: null,
200
239
  intent: null,
240
+ actor: null,
201
241
  parse_error: true
202
242
  };
203
243
  return {
204
244
  semantic_op: typeof parsed.semantic_op === "string" ? parsed.semantic_op : null,
205
245
  payload: parsed.payload ?? null,
206
246
  intent: typeof parsed.intent === "string" ? parsed.intent : null,
247
+ actor: parseActor(parsed.actor),
207
248
  parse_error: false
208
249
  };
209
250
  } catch {
@@ -211,10 +252,21 @@ function parseMessage(message) {
211
252
  semantic_op: null,
212
253
  payload: null,
213
254
  intent: null,
255
+ actor: null,
214
256
  parse_error: true
215
257
  };
216
258
  }
217
259
  }
260
+ /** A malformed `actor` yields null for the author alone, never `parse_error`. */
261
+ function parseActor(actor) {
262
+ if (actor == null || typeof actor !== "object") return null;
263
+ const { user_id: userId, role } = actor;
264
+ if (typeof userId !== "string" || userId === "" || typeof role !== "string" || role === "") return null;
265
+ return {
266
+ user_id: userId,
267
+ role
268
+ };
269
+ }
218
270
  //#endregion
219
271
  //#region src/testing/index.ts
220
272
  const ACTOR_TEST = "1001";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mengine/medeo-client",
3
- "version": "1.0.1-alpha.2",
3
+ "version": "1.1.0",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,7 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": "./dist/index.js",
17
- "./schemas": "./dist/schemas.js",
17
+ "./relay": "./dist/relay.js",
18
18
  "./testing": "./dist/testing.js",
19
19
  "./package.json": "./package.json"
20
20
  },
@@ -23,18 +23,16 @@
23
23
  "registry": "https://registry.npmjs.org/"
24
24
  },
25
25
  "dependencies": {
26
- "immer": "^10.2.0",
27
26
  "loro-mirror": "^2.2.0",
28
27
  "zod": "^4.4.3",
29
- "@mengine/storage": "1.0.1-alpha.2",
30
- "@mengine/utils": "1.0.1-alpha.2",
31
- "@mengine/sync": "1.0.1-alpha.2"
28
+ "@mengine/storage": "1.1.0",
29
+ "@mengine/sync": "1.1.0",
30
+ "@mengine/utils": "1.1.0"
32
31
  },
33
32
  "devDependencies": {
34
33
  "@types/node": "^25.9.1",
35
34
  "@typescript/native-preview": "7.0.0-dev.20260521.1",
36
35
  "loro-crdt": "^1.13.6",
37
- "tsx": "^4.22.3",
38
36
  "typescript": "^6.0.3",
39
37
  "vite-plugin-wasm": "^3.6.0",
40
38
  "vite-plus": "^0.1.23",