snapback2 0.0.1

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.
Files changed (132) hide show
  1. package/README.md +127 -0
  2. package/bin/snapback2.mjs +36 -0
  3. package/dist/api.d.ts +20 -0
  4. package/dist/assets.d.ts +34 -0
  5. package/dist/client-assets.d.ts +26 -0
  6. package/dist/client-offline.d.ts +94 -0
  7. package/dist/client-outbox.d.ts +58 -0
  8. package/dist/client-upload.d.ts +41 -0
  9. package/dist/client-wire.d.ts +41 -0
  10. package/dist/client.d.ts +12 -0
  11. package/dist/client.mjs +5 -0
  12. package/dist/client.mjs.map +7 -0
  13. package/dist/compiler-core.mjs +60 -0
  14. package/dist/compiler-core.mjs.map +7 -0
  15. package/dist/compiler-lib.d.ts +71 -0
  16. package/dist/compiler.mjs +5 -0
  17. package/dist/compiler.mjs.map +7 -0
  18. package/dist/drain.d.ts +33 -0
  19. package/dist/expo/files.d.ts +3 -0
  20. package/dist/expo/index.d.ts +16 -0
  21. package/dist/expo/token-store.d.ts +7 -0
  22. package/dist/expo/witness.d.ts +28 -0
  23. package/dist/expo/witness.mjs +2 -0
  24. package/dist/expo/witness.mjs.map +7 -0
  25. package/dist/expo.d.ts +4 -0
  26. package/dist/expo.mjs +9 -0
  27. package/dist/expo.mjs.map +7 -0
  28. package/dist/guide/auth.md +89 -0
  29. package/dist/guide/effects.md +52 -0
  30. package/dist/guide/families.json +446 -0
  31. package/dist/guide/grammar.md +52 -0
  32. package/dist/guide/live-query.md +27 -0
  33. package/dist/guide/offline.md +75 -0
  34. package/dist/guide/personas.md +15 -0
  35. package/dist/guide/quarry.md +297 -0
  36. package/dist/guide/testing.md +57 -0
  37. package/dist/index.d.ts +362 -0
  38. package/dist/index.mjs +2 -0
  39. package/dist/index.mjs.map +7 -0
  40. package/dist/offline-protocol.d.ts +180 -0
  41. package/dist/offline-schema.d.ts +1 -0
  42. package/dist/offline.d.ts +177 -0
  43. package/dist/react-core.d.ts +91 -0
  44. package/dist/react-core.mjs +2 -0
  45. package/dist/react-core.mjs.map +7 -0
  46. package/dist/react-native/components.d.ts +23 -0
  47. package/dist/react-native/files.d.ts +43 -0
  48. package/dist/react-native/source.d.ts +16 -0
  49. package/dist/react-native.d.ts +4 -0
  50. package/dist/react-native.mjs +2 -0
  51. package/dist/react-native.mjs.map +7 -0
  52. package/dist/react.d.ts +19 -0
  53. package/dist/react.mjs +2 -0
  54. package/dist/react.mjs.map +7 -0
  55. package/dist/sqlite-test.mjs +1720 -0
  56. package/dist/sqlite-test.mjs.map +7 -0
  57. package/dist/sse.d.ts +44 -0
  58. package/dist/store/byte-cache.d.ts +162 -0
  59. package/dist/store/canonical.d.ts +2 -0
  60. package/dist/store/indexeddb.d.ts +23 -0
  61. package/dist/store/locks.d.ts +11 -0
  62. package/dist/store/outbox.d.ts +113 -0
  63. package/dist/store/overlay-retirement.d.ts +17 -0
  64. package/dist/store/projection.d.ts +11 -0
  65. package/dist/store/range-store.d.ts +388 -0
  66. package/dist/store/sqlite-driver.conformance.d.ts +7 -0
  67. package/dist/store/sqlite-driver.d.ts +16 -0
  68. package/dist/store/sqlite-expo.d.ts +3 -0
  69. package/dist/store/sqlite.d.ts +31 -0
  70. package/dist/templates/chat/expo/App.tsx +29 -0
  71. package/dist/templates/chat/expo/app.json +11 -0
  72. package/dist/templates/chat/expo/index.js +3 -0
  73. package/dist/templates/chat/expo/shared/log.js +92 -0
  74. package/dist/templates/chat/expo/shared/log.ts +130 -0
  75. package/dist/templates/chat/expo/shared/offline.js +8 -0
  76. package/dist/templates/chat/expo/shared/offline.ts +9 -0
  77. package/dist/templates/chat/expo/src/Chat.tsx +23 -0
  78. package/dist/templates/chat/expo/src/Composer.tsx +23 -0
  79. package/dist/templates/chat/expo/src/MediaView.tsx +29 -0
  80. package/dist/templates/chat/expo/src/Witness.tsx +131 -0
  81. package/dist/templates/chat/expo/src/screens/Inbox.tsx +9 -0
  82. package/dist/templates/chat/expo/src/screens/Search.tsx +10 -0
  83. package/dist/templates/chat/expo/src/screens/Thread.tsx +22 -0
  84. package/dist/templates/chat/expo/src/witness-state.ts +68 -0
  85. package/dist/templates/chat/expo/tsconfig.json +5 -0
  86. package/dist/templates/chat/react/index.html +5 -0
  87. package/dist/templates/chat/react/shared/log.js +92 -0
  88. package/dist/templates/chat/react/shared/log.ts +130 -0
  89. package/dist/templates/chat/react/shared/offline.js +8 -0
  90. package/dist/templates/chat/react/shared/offline.ts +9 -0
  91. package/dist/templates/chat/react/src/App.tsx +36 -0
  92. package/dist/templates/chat/react/src/Composer.tsx +45 -0
  93. package/dist/templates/chat/react/src/Inbox.tsx +24 -0
  94. package/dist/templates/chat/react/src/Search.tsx +26 -0
  95. package/dist/templates/chat/react/src/Thread.tsx +126 -0
  96. package/dist/templates/chat/react/src/env.d.ts +1 -0
  97. package/dist/templates/chat/react/src/main.tsx +20 -0
  98. package/dist/templates/chat/shared/log.js +92 -0
  99. package/dist/templates/chat/shared/log.ts +130 -0
  100. package/dist/templates/chat/shared/offline.js +8 -0
  101. package/dist/templates/chat/shared/offline.ts +9 -0
  102. package/dist/templates/chat/snapback/deliveries.q +33 -0
  103. package/dist/templates/chat/snapback/delivery.ts +30 -0
  104. package/dist/templates/chat/snapback/feeds.q +14 -0
  105. package/dist/templates/chat/snapback/follows.q +11 -0
  106. package/dist/templates/chat/snapback/groups.q +40 -0
  107. package/dist/templates/chat/snapback/messages.q +32 -0
  108. package/dist/templates/chat/snapback/notifications.q +7 -0
  109. package/dist/templates/chat/snapback/posts.q +4 -0
  110. package/dist/templates/chat/snapback/profiles.q +17 -0
  111. package/dist/templates/chat/snapback/schema.q +136 -0
  112. package/dist/templates/chat/snapback/seed.ts +45 -0
  113. package/dist/templates/chat/snapback/tests/chat.test.ts +759 -0
  114. package/dist/templates/react/index.html +5 -0
  115. package/dist/templates/react/src/App.tsx +5 -0
  116. package/dist/templates/react/src/main.tsx +18 -0
  117. package/dist/templates/todos/snapback/schema.q +11 -0
  118. package/dist/templates/todos/snapback/seed.ts +10 -0
  119. package/dist/templates/todos/snapback/tests/todos.test.ts +13 -0
  120. package/dist/templates/todos/snapback/todos.q +6 -0
  121. package/dist/test-runner.mjs +8541 -0
  122. package/dist/test-runner.mjs.map +7 -0
  123. package/dist/test.d.ts +28 -0
  124. package/dist/test.mjs +8541 -0
  125. package/dist/test.mjs.map +7 -0
  126. package/dist/token-store.d.ts +3 -0
  127. package/dist/twin-hydrate.d.ts +43 -0
  128. package/dist/twin.d.ts +70 -0
  129. package/dist/types.d.ts +410 -0
  130. package/dist/witness-test.mjs +2 -0
  131. package/dist/witness-test.mjs.map +7 -0
  132. package/package.json +117 -0
@@ -0,0 +1,759 @@
1
+ import { api, type ImageAsset } from "snapback2/client";
2
+ import { journey } from "snapback2/test";
3
+
4
+ function bytes(...parts: Uint8Array[]): Uint8Array {
5
+ const output = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
6
+ let offset = 0;
7
+ for (const part of parts) { output.set(part, offset); offset += part.length; }
8
+ return output;
9
+ }
10
+
11
+ function ascii(value: string): Uint8Array {
12
+ return new TextEncoder().encode(value);
13
+ }
14
+
15
+ function uint32(value: number): Uint8Array {
16
+ const output = new Uint8Array(4);
17
+ new DataView(output.buffer).setUint32(0, value);
18
+ return output;
19
+ }
20
+
21
+ function int32At(output: Uint8Array, offset: number, value: number): void {
22
+ new DataView(output.buffer).setInt32(offset, value);
23
+ }
24
+
25
+ function base64(value: Uint8Array): string {
26
+ let binary = "";
27
+ for (const byte of value) binary += String.fromCharCode(byte);
28
+ return btoa(binary);
29
+ }
30
+
31
+ function atom(kind: string, ...payload: Uint8Array[]): Uint8Array {
32
+ const content = bytes(...payload);
33
+ return bytes(uint32(content.length + 8), ascii(kind), content);
34
+ }
35
+
36
+ // A structural MP4 synthesized in-process keeps the acceptance journey tiny
37
+ // while exercising the same bounded ISO-BMFF walker as an uploaded camera clip.
38
+ function synthesizedMp4(): Uint8Array {
39
+ const ftyp = atom("ftyp", ascii("isom"), uint32(0), ascii("isom"), ascii("mp42"));
40
+ const mvhdPayload = new Uint8Array(100);
41
+ new DataView(mvhdPayload.buffer).setUint32(12, 1_000);
42
+ new DataView(mvhdPayload.buffer).setUint32(16, 2_000);
43
+ const tkhdPayload = new Uint8Array(84);
44
+ int32At(tkhdPayload, 40, 0x0001_0000);
45
+ int32At(tkhdPayload, 56, 0x0001_0000);
46
+ int32At(tkhdPayload, 72, 0x4000_0000);
47
+ new DataView(tkhdPayload.buffer).setUint32(76, 2 << 16);
48
+ new DataView(tkhdPayload.buffer).setUint32(80, 2 << 16);
49
+ const hdlrPayload = new Uint8Array(24);
50
+ hdlrPayload.set(ascii("vide"), 8);
51
+ const track = atom("trak", atom("tkhd", tkhdPayload), atom("mdia", atom("hdlr", hdlrPayload)));
52
+ return bytes(ftyp, atom("moov", atom("mvhd", mvhdPayload), track));
53
+ }
54
+
55
+ // The template's smallest valid JPEG: one baseline MCU, advertised as 8×8.
56
+ function fixtureJpeg(): Uint8Array {
57
+ const jpeg = Uint8Array.from(atob("/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAACAAIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDzSiiiv18+FP/Z"), (value) => value.charCodeAt(0));
58
+ const validJpeg = bytes(jpeg.slice(0, 155), new Uint8Array([40, 40, 40]), jpeg.slice(155));
59
+ // The encoded fixture is one baseline MCU; advertise its padded 8×8 display extent.
60
+ validJpeg[164] = 8;
61
+ validJpeg[166] = 8;
62
+ return validJpeg;
63
+ }
64
+
65
+
66
+ type LogRow = { kind?: string; messageId?: string; message?: { id?: string; body?: string } | null };
67
+
68
+ function bodies(rows: unknown): string[] {
69
+ return Array.isArray(rows)
70
+ ? rows.map((row: LogRow) => row.message?.body).filter((body): body is string => typeof body === "string")
71
+ : [];
72
+ }
73
+
74
+ journey("alice posts to the room and a non-member cannot", async ({ as }) => {
75
+ const alice = as("alice");
76
+ const charlie = as("charlie");
77
+ const before = await alice.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
78
+ if ((before.state !== "complete" && before.state !== "capped") || !Array.isArray(before.data)) throw new Error("thread did not return rows");
79
+ const sent = await alice.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "hello bob" });
80
+ if (sent.state !== "committed") throw new Error(`post was ${sent.state}`);
81
+ const after = await alice.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
82
+ if (!("data" in after) || !after.data?.some((row: LogRow) => row.message?.body === "hello bob")) throw new Error("posted message was not readable through the log");
83
+ const rejected = await charlie.mutation(api.deliveries.post, {
84
+ conversationId: "conversation-1",
85
+ body: "must not cross the membership boundary",
86
+ });
87
+ if (rejected.state !== "rejected") throw new Error("a non-member posted into a private room");
88
+ const sideAfter = await alice.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
89
+ if (sideAfter.state !== "complete") throw new Error(`thread became ${sideAfter.state}`);
90
+ if (sideAfter.seq !== after.seq) throw new Error("rejected post committed message or delivery work");
91
+ if (bodies(sideAfter.data).includes("must not cross the membership boundary")) {
92
+ throw new Error("rejected post persisted its message body");
93
+ }
94
+ charlie.close();
95
+ });
96
+
97
+ journey("bob replies and alice's live log thread updates", async ({ as }) => {
98
+ const alice = as("alice");
99
+ const bob = as("bob");
100
+ const thread = alice.subscribe(
101
+ api.deliveries.thread,
102
+ { conversationId: "conversation-1", c: null },
103
+ () => {},
104
+ );
105
+ try {
106
+ await thread.until((delivery) => delivery.state === "complete");
107
+ const sent = await bob.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "hello alice" });
108
+ if (sent.state !== "committed") throw new Error(`reply was ${sent.state}`);
109
+ await thread.until((delivery) => "data" in delivery && bodies(delivery.data).includes("hello alice"));
110
+ } finally {
111
+ thread.close();
112
+ }
113
+ });
114
+
115
+ journey("alice sends and reads back a JPEG photo and synthesized MP4 clip", async ({ as }) => {
116
+ const alice = as("alice");
117
+ const charlie = as("charlie");
118
+ try {
119
+ const validJpeg = fixtureJpeg();
120
+ const photoSent = await alice.mutation(api.messages.sendPhoto, {
121
+ conversationId: "conversation-1",
122
+ photo: {
123
+ uri: `data:image/jpeg;base64,${base64(validJpeg)}`,
124
+ fileSize: validJpeg.byteLength,
125
+ mimeType: "image/jpeg",
126
+ },
127
+ });
128
+ if (photoSent.state !== "committed") throw new Error(`photo send was ${photoSent.state}`);
129
+ const clipSent = await alice.mutation(api.messages.sendClip, {
130
+ conversationId: "conversation-1",
131
+ clip: new File([synthesizedMp4().buffer as ArrayBuffer], "clip.mp4", { type: "video/mp4" }),
132
+ });
133
+ if (clipSent.state !== "committed") throw new Error(`clip send was ${clipSent.state}`);
134
+ if (clipSent.data.duration !== 2_000) throw new Error(`clip duration was ${clipSent.data.duration}`);
135
+
136
+ const orphan = await charlie.upload(new File(
137
+ [validJpeg.buffer as ArrayBuffer],
138
+ "orphan-after-rejection.jpg",
139
+ { type: "image/jpeg" },
140
+ ), { kind: "image" });
141
+ const rejected = await charlie.mutation(api.messages.sendPhoto, {
142
+ conversationId: "conversation-1",
143
+ photo: orphan,
144
+ });
145
+ if (rejected.state !== "rejected") throw new Error("asset mutation unexpectedly accepted an outsider");
146
+ // The successful upload remains unclaimed, so the server's locator-null
147
+ // reclamation pass can collect this orphan (LLP 1006 §9).
148
+
149
+ const thread = await alice.query(api.messages.thread, { conversationId: "conversation-1" });
150
+ if (!("data" in thread) || !Array.isArray(thread.data)) throw new Error("asset thread did not return rows");
151
+ const photo = thread.data.find((row) => row.id === photoSent.data.id)?.photo;
152
+ const clip = thread.data.find((row) => row.id === clipSent.data.id)?.clip;
153
+ if (!photo || photo.width !== 8 || photo.height !== 8) throw new Error("JPEG record was not readable");
154
+ if (!clip || clip.width !== 2 || clip.height !== 2 || clip.duration !== 2_000) {
155
+ throw new Error("MP4 record was not readable");
156
+ }
157
+ const photoUrl = await alice.assetUrl(photo);
158
+ const clipUrl = await alice.assetUrl(clip, { carrier: true });
159
+ if (!photoUrl || !clipUrl) throw new Error("asset delivery URLs were absent");
160
+ if (new URL(photoUrl).searchParams.has("s")) throw new Error("photo URL carried a session token");
161
+ if (!new URL(clipUrl).searchParams.has("s")) throw new Error("private clip URL lacked its persona carrier");
162
+ if ((await alice.assetBlob(photoUrl)).size === 0) throw new Error("JPEG delivery was empty");
163
+ const clipResponse = await fetch(clipUrl);
164
+ if (!clipResponse.ok || (await clipResponse.blob()).size === 0) throw new Error("carried MP4 delivery was empty");
165
+ } finally {
166
+ alice.close();
167
+ charlie.close();
168
+ }
169
+ });
170
+
171
+ journey("profile ownership, uniqueness, and live public reads", async ({ as }) => {
172
+ const alice = as("alice");
173
+ const bob = as("bob");
174
+ const charlie = as("charlie");
175
+ const profile = bob.subscribe(api.profiles.byHandle, { handle: "alice" }, () => {});
176
+ try {
177
+ await profile.until((delivery) =>
178
+ delivery.state === "complete"
179
+ && "data" in delivery
180
+ && delivery.data?.displayName === "Alice"
181
+ );
182
+ const updated = await alice.mutation(api.profiles.updateMyProfile, {
183
+ displayName: "Alice Updated",
184
+ handle: "alice",
185
+ avatarUrl: "https://example.com/alice.png",
186
+ bio: "building with Snapback",
187
+ location: "",
188
+ });
189
+ if (updated.state !== "committed") throw new Error(`profile update was ${updated.state}`);
190
+ await profile.until((delivery) =>
191
+ delivery.state === "complete"
192
+ && "data" in delivery
193
+ && delivery.data?.displayName === "Alice Updated"
194
+ && delivery.data?.principal === "dev:alice"
195
+ );
196
+ const duplicate = await charlie.mutation(api.profiles.createMyProfile, {
197
+ displayName: "Charlie",
198
+ handle: "bob",
199
+ bio: "",
200
+ location: "",
201
+ });
202
+ if (duplicate.state !== "rejected") throw new Error("duplicate public handle was admitted");
203
+ } finally {
204
+ profile.close();
205
+ alice.close();
206
+ bob.close();
207
+ charlie.close();
208
+ }
209
+ });
210
+
211
+ journey("unfollow changes Following selection but not Global authorization", async ({ as }) => {
212
+ const alice = as("alice");
213
+ const bob = as("bob");
214
+ const alicePrincipal = (await alice.auth.me()).viewer;
215
+ const following = bob.subscribe(api.feeds.followingFeed, { c: null }, () => {});
216
+ try {
217
+ const before = await following.until((delivery) =>
218
+ delivery.state === "complete"
219
+ && "data" in delivery
220
+ && delivery.data.some((item: { post?: { id?: string } }) => item.post?.id === "post-alice")
221
+ );
222
+ if (before.state !== "complete") throw new Error("Following did not become complete");
223
+ const beforeSeq = before.seq;
224
+ const unfollowed = await bob.mutation(api.follows.setFollow, {
225
+ target: alicePrincipal,
226
+ following: false,
227
+ });
228
+ if (unfollowed.state !== "committed") throw new Error(`unfollow was ${unfollowed.state}`);
229
+ await following.until((delivery) =>
230
+ delivery.state === "complete"
231
+ && delivery.seq > beforeSeq
232
+ && "data" in delivery
233
+ && !delivery.data.some((item: { post?: { id?: string } }) => item.post?.id === "post-alice")
234
+ );
235
+ const global = await bob.query(api.feeds.globalFeed, { c: null });
236
+ if ((global.state !== "complete" && global.state !== "capped")
237
+ || !global.data.some((item: { post?: { id?: string } }) => item.post?.id === "post-alice")) {
238
+ throw new Error("unfollow incorrectly revoked a globally readable post");
239
+ }
240
+ const restored = await bob.mutation(api.follows.setFollow, {
241
+ target: alicePrincipal,
242
+ following: true,
243
+ });
244
+ if (restored.state !== "committed") throw new Error(`follow restore was ${restored.state}`);
245
+ } finally {
246
+ following.close();
247
+ alice.close();
248
+ bob.close();
249
+ }
250
+ });
251
+
252
+ journey("group creation bootstraps one creator admin atomically", async ({ as }) => {
253
+ const alice = as("alice");
254
+ const bob = as("bob");
255
+ const charlie = as("charlie");
256
+ try {
257
+ const alicePrincipal = (await alice.auth.me()).viewer;
258
+ const bobPrincipal = (await bob.auth.me()).viewer;
259
+ const charliePrincipal = (await charlie.auth.me()).viewer;
260
+ const tooSmall = await alice.mutation(api.groups.groupCreate, {
261
+ title: "Nobody else",
262
+ members: [],
263
+ });
264
+ if (tooSmall.state !== "rejected") throw new Error("one-person group was admitted");
265
+ const duplicate = await alice.mutation(api.groups.groupCreate, {
266
+ title: "Duplicate seats",
267
+ members: [bobPrincipal, bobPrincipal],
268
+ });
269
+ if (duplicate.state !== "rejected") throw new Error("duplicate group seats committed partially");
270
+ const created = await alice.mutation(api.groups.groupCreate, {
271
+ title: "Project room",
272
+ members: [bobPrincipal],
273
+ });
274
+ if (created.state !== "committed") throw new Error(`group creation was ${created.state}`);
275
+ const selfReseat = await alice.mutation(api.groups.memberAdd, {
276
+ conversationId: created.data.id,
277
+ principal: alicePrincipal,
278
+ });
279
+ if (selfReseat.state !== "rejected") throw new Error("creator acquired a second seat");
280
+ const added = await alice.mutation(api.groups.memberAdd, {
281
+ conversationId: created.data.id,
282
+ principal: charliePrincipal,
283
+ });
284
+ if (added.state !== "committed") throw new Error("creator admin could not add a member");
285
+ const left = await alice.mutation(api.groups.memberLeave, {
286
+ conversationId: created.data.id,
287
+ membershipId: created.data.creatorMembershipId,
288
+ });
289
+ if (left.state !== "committed") throw new Error("sole admin could not leave explicitly");
290
+ const rebootstrap = await alice.mutation(api.groups.memberAdd, {
291
+ conversationId: created.data.id,
292
+ principal: alicePrincipal,
293
+ });
294
+ if (rebootstrap.state !== "rejected") {
295
+ throw new Error("departed creator reused bootstrap authority in an adminless group");
296
+ }
297
+ } finally {
298
+ alice.close();
299
+ bob.close();
300
+ charlie.close();
301
+ }
302
+ });
303
+
304
+ journey("conversation metadata stays inside membership", async ({ as }) => {
305
+ const bob = as("bob");
306
+ const charlie = as("charlie");
307
+ try {
308
+ const member = await bob.query(api.messages.summary, { conversationId: "conversation-1" });
309
+ if (!("data" in member) || member.data?.conversation?.title !== "General") {
310
+ throw new Error("member could not read the conversation shell");
311
+ }
312
+ if (typeof member.data.messageCount?.value !== "number") {
313
+ throw new Error("member could not read the maintained message count");
314
+ }
315
+
316
+ const outsider = await charlie.query(api.messages.summary, { conversationId: "conversation-1" });
317
+ if (outsider.state !== "denied") throw new Error("conversation metadata query admitted an outsider");
318
+ } finally {
319
+ bob.close();
320
+ charlie.close();
321
+ }
322
+ });
323
+
324
+ journey("scoped roles reject non-admin and stale membership authority", async ({ as }) => {
325
+ const alice = as("alice");
326
+ const bob = as("bob");
327
+ const charlie = as("charlie");
328
+ try {
329
+ const bobPrincipal = (await bob.auth.me()).viewer;
330
+ const charliePrincipal = (await charlie.auth.me()).viewer;
331
+ const deniedAdd = await bob.mutation(api.groups.memberAdd, {
332
+ conversationId: "conversation-1",
333
+ principal: charliePrincipal,
334
+ });
335
+ if (deniedAdd.state !== "rejected") throw new Error("non-admin added a member");
336
+ const deniedRemove = await bob.mutation(api.groups.memberRemove, {
337
+ conversationId: "conversation-1",
338
+ membershipId: "membership-1",
339
+ });
340
+ if (deniedRemove.state !== "rejected") throw new Error("non-admin removed a member");
341
+ const crossScopePromote = await alice.mutation(api.groups.memberPromote, {
342
+ conversationId: "conversation-1",
343
+ membershipId: "membership-4",
344
+ });
345
+ if (crossScopePromote.state !== "rejected") throw new Error("promote trusted a caller-selected scope");
346
+ const crossScopeRemove = await alice.mutation(api.groups.memberRemove, {
347
+ conversationId: "conversation-1",
348
+ membershipId: "membership-4",
349
+ });
350
+ if (crossScopeRemove.state !== "rejected") throw new Error("remove trusted a caller-selected scope");
351
+ const charlieEscalated = await charlie.mutation(api.groups.memberAdd, {
352
+ conversationId: "conversation-2",
353
+ principal: bobPrincipal,
354
+ });
355
+ if (charlieEscalated.state !== "rejected") throw new Error("cross-scope promote changed the target role");
356
+ const sideThread = await charlie.query(api.messages.thread, { conversationId: "conversation-2", c: null });
357
+ if (sideThread.state !== "complete") throw new Error("cross-scope remove deleted the target seat");
358
+ const selfRemove = await alice.mutation(api.groups.memberRemove, {
359
+ conversationId: "conversation-1",
360
+ membershipId: "membership-1",
361
+ });
362
+ if (selfRemove.state !== "rejected") throw new Error("admin bypassed the named leave path");
363
+ const added = await alice.mutation(api.groups.memberAdd, {
364
+ conversationId: "conversation-1",
365
+ principal: charliePrincipal,
366
+ });
367
+ if (added.state !== "committed") throw new Error(`admin add was ${added.state}`);
368
+ const promoted = await alice.mutation(api.groups.memberPromote, {
369
+ conversationId: "conversation-1",
370
+ membershipId: added.data.id,
371
+ });
372
+ if (promoted.state !== "committed") throw new Error(`admin promote was ${promoted.state}`);
373
+ const removed = await charlie.mutation(api.groups.memberRemove, {
374
+ conversationId: "conversation-1",
375
+ membershipId: "membership-2",
376
+ });
377
+ if (removed.state !== "committed") throw new Error(`admin removal was ${removed.state}`);
378
+ const deniedThread = await bob.query(api.messages.thread, { conversationId: "conversation-1", c: null });
379
+ if (deniedThread.state !== "denied") throw new Error("removed member retained thread access");
380
+ const replacement = await alice.mutation(api.groups.memberAdd, {
381
+ conversationId: "conversation-1",
382
+ principal: bobPrincipal,
383
+ });
384
+ if (replacement.state !== "committed") throw new Error("member could not be re-added");
385
+ const charlieLeft = await charlie.mutation(api.groups.memberLeave, {
386
+ conversationId: "conversation-1",
387
+ membershipId: added.data.id,
388
+ });
389
+ if (charlieLeft.state !== "committed") throw new Error("member could not leave");
390
+ const staleReplay = await alice.mutation(api.groups.memberPromote, {
391
+ conversationId: "conversation-1",
392
+ membershipId: "membership-2",
393
+ });
394
+ if (staleReplay.state !== "rejected") throw new Error("stale membership id retargeted its replacement");
395
+ const bobStillMember = await bob.mutation(api.groups.memberAdd, {
396
+ conversationId: "conversation-1",
397
+ principal: charliePrincipal,
398
+ });
399
+ if (bobStillMember.state !== "rejected") throw new Error("stale promote changed the successor seat");
400
+ } finally {
401
+ alice.close();
402
+ bob.close();
403
+ charlie.close();
404
+ }
405
+ });
406
+
407
+ journey("offline drain removes an unsent body and survives a cold restart", async ({ as }) => {
408
+ const alice = as("alice", {
409
+ store: "file",
410
+ onTombstone: (item) => item.table === "deliveries"
411
+ && (item.row.kind === "removed" || item.row.kind === "expired")
412
+ && typeof item.row.messageId === "string"
413
+ ? [{ table: "messages", id: item.row.messageId }]
414
+ : [],
415
+ });
416
+ const drain = alice.subscribe(
417
+ api.deliveries.inbox,
418
+ { c: null },
419
+ () => undefined,
420
+ { drain: true },
421
+ );
422
+ await drain.until((value) =>
423
+ (value.state === "complete" || value.state === "capped") && value.caughtUp === true);
424
+ await alice.offline();
425
+ const owner = as("alice");
426
+ const unsent = await owner.mutation(api.deliveries.unsend, { messageId: "message-1" });
427
+ if (unsent.state !== "committed") throw new Error(`unsend was ${unsent.state}`);
428
+ owner.close();
429
+ await alice.online();
430
+ await drain.until((value) => "data" in value
431
+ && Array.isArray(value.data)
432
+ && value.data.some((row: { kind?: string; messageId?: string }) =>
433
+ row.kind === "removed" && row.messageId === "message-1"));
434
+ drain.close();
435
+ await alice.offline();
436
+ const cold = await alice.restart();
437
+ const held = await cold.query(api.deliveries.inbox, { c: null });
438
+ if ((held.state !== "complete" && held.state !== "capped" && held.state !== "partial")
439
+ || !("connection" in held) || held.connection !== "offline" || !Array.isArray(held.data)) {
440
+ throw new Error("cold offline inbox did not render its held answer");
441
+ }
442
+ if (JSON.stringify(held.data).includes("Welcome")) {
443
+ throw new Error("unsent message body survived in the cold held inbox");
444
+ }
445
+ });
446
+
447
+ // @ref LLP 1012#9-asset-bytes-on-the-device — bytes render offline under the row's
448
+ // lease, a viewed view-once photo never renders again, and an unsent row's bytes
449
+ // leave with the row on resume (lane O4, the web half of §10's witness).
450
+ journey("viewed photos render from the byte cache offline; a view-once photo and an unsent row do not", async ({ as }) => {
451
+ let viewOnceId: string | undefined;
452
+ const alice = as("alice", {
453
+ store: "file",
454
+ onTombstone: (item) => item.table === "deliveries"
455
+ && (item.row.kind === "removed" || item.row.kind === "expired")
456
+ && typeof item.row.messageId === "string"
457
+ ? [{ table: "messages", id: item.row.messageId }]
458
+ : [],
459
+ viewOnce: (table, row) => table === "messages" && row.id === viewOnceId,
460
+ });
461
+ const owner = as("alice");
462
+ try {
463
+ const validJpeg = fixtureJpeg();
464
+ const sent: Array<{ id: string; photo: ImageAsset }> = [];
465
+ for (let index = 0; index < 3; index += 1) {
466
+ const result = await owner.mutation(api.messages.sendPhoto, {
467
+ conversationId: "conversation-1",
468
+ photo: {
469
+ uri: `data:image/jpeg;base64,${base64(validJpeg)}`,
470
+ fileSize: validJpeg.byteLength,
471
+ mimeType: "image/jpeg",
472
+ },
473
+ });
474
+ if (result.state !== "committed") throw new Error(`photo ${index} send was ${result.state}`);
475
+ if (!result.data.photo) throw new Error(`photo ${index} record was absent`);
476
+ sent.push({ id: result.data.id, photo: result.data.photo });
477
+ }
478
+ viewOnceId = sent[2]!.id;
479
+ const thread = alice.subscribe(api.messages.thread, { conversationId: "conversation-1", c: null }, () => undefined);
480
+ await thread.until((delivery) => (delivery.state === "complete" || delivery.state === "capped")
481
+ && "data" in delivery
482
+ && Array.isArray(delivery.data)
483
+ && sent.every((message) => delivery.data.some((row: { id?: string }) => row?.id === message.id)));
484
+ thread.close();
485
+ const urls: string[] = [];
486
+ for (const message of sent) {
487
+ const url = await alice.assetUrl(message.photo, { width: message.photo.width });
488
+ if (!url) throw new Error("photo delivery URL was absent");
489
+ // Viewing fills the byte cache; the third row is view-once and is marked viewed instead.
490
+ const viewing = {};
491
+ if ((await alice.assetBlob(url, { viewing })).size === 0) throw new Error("photo delivery was empty");
492
+ urls.push(url);
493
+ }
494
+ await alice.offline();
495
+ const cold = await alice.restart();
496
+ for (const url of urls.slice(0, 2)) {
497
+ if ((await cold.assetBlob(url)).size === 0) throw new Error("a viewed photo did not render from the byte cache offline");
498
+ }
499
+ let viewedRefused = false;
500
+ try {
501
+ await cold.assetBlob(urls[2]!);
502
+ } catch (error) {
503
+ viewedRefused = (error as { code?: string }).code === "E_ASSET_VIEWED";
504
+ }
505
+ if (!viewedRefused) throw new Error("a viewed view-once photo rendered again after the restart");
506
+ const held = await cold.query(api.messages.thread, { conversationId: "conversation-1", c: null });
507
+ if (!("data" in held) || !Array.isArray(held.data) || held.data.some((row: { id?: string } | null) => row?.id === viewOnceId)) {
508
+ throw new Error("a viewed view-once row survived in the held thread");
509
+ }
510
+ const mounted = cold.subscribe(api.messages.thread, { conversationId: "conversation-1", c: null }, () => undefined);
511
+ await mounted.until((delivery) => "data" in delivery && Array.isArray(delivery.data));
512
+ const unsent = await owner.mutation(api.deliveries.unsend, { messageId: sent[0]!.id });
513
+ if (unsent.state !== "committed") throw new Error(`unsend was ${unsent.state}`);
514
+ await cold.online();
515
+ await mounted.until((delivery) => delivery.live === true
516
+ && "data" in delivery
517
+ && Array.isArray(delivery.data)
518
+ && !delivery.data.some((row: { id?: string } | null) => row?.id === sent[0]!.id));
519
+ mounted.close();
520
+ await cold.offline();
521
+ let gone = false;
522
+ try {
523
+ await cold.assetBlob(urls[0]!);
524
+ } catch (error) {
525
+ gone = (error as { code?: string }).code === "E_NETWORK";
526
+ }
527
+ if (!gone) throw new Error("an unsent row's bytes survived the resume");
528
+ if ((await cold.assetBlob(urls[1]!)).size === 0) throw new Error("the kept photo lost its bytes on resume");
529
+ } finally {
530
+ owner.close();
531
+ }
532
+ });
533
+
534
+ const tombstones = (item: { table: string; row: Record<string, unknown> }) =>
535
+ item.table === "deliveries"
536
+ && (item.row.kind === "removed" || item.row.kind === "expired")
537
+ && typeof item.row.messageId === "string"
538
+ ? [{ table: "messages", id: item.row.messageId }]
539
+ : [];
540
+
541
+ // @ref LLP 1012#10-carriers-the-fast-loop-and-the-witness — the send step:
542
+ // queued offline, on screen across a cold start, replayed in order, and a 503
543
+ // after the commit is never a double send.
544
+ journey("offline sends queue, survive a restart, replay in order, and a 503 after a commit is exactly once", async ({ as }) => {
545
+ const alice = as("alice", { store: "file", onTombstone: tombstones });
546
+ const drain = alice.subscribe(api.deliveries.inbox, { c: null }, () => undefined, { drain: true });
547
+ await drain.until((value) => (value.state === "complete" || value.state === "capped") && value.caughtUp === true);
548
+ drain.close();
549
+ await alice.offline();
550
+ const first = await alice.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "queued one" });
551
+ const second = await alice.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "queued two" });
552
+ if (first.state !== "queued" || second.state !== "queued") throw new Error(`offline sends were ${first.state} and ${second.state}`);
553
+ const cold = await alice.restart();
554
+ const queued = await cold.outbox.entries();
555
+ if (queued.map((entry) => `${entry.intent}:${entry.state}`).join(" ") !== `${first.intent}:queued ${second.intent}:queued`) {
556
+ throw new Error(`the outbox did not survive the restart in order: ${JSON.stringify(queued.map((entry) => [entry.intent, entry.state]))}`);
557
+ }
558
+ cold.failNextResponse("/api/mutation", 503);
559
+ await cold.online();
560
+ await cold.settled();
561
+ const afterFault = await cold.outbox.entries();
562
+ if (afterFault.some((entry) => entry.state !== "queued")) throw new Error("a 503 after the commit settled an intent");
563
+ await cold.outbox.drain();
564
+ const settled = await cold.outbox.entries();
565
+ if (settled.map((entry) => entry.state).join() !== "committed,committed") {
566
+ throw new Error(`replay settled as ${settled.map((entry) => entry.state).join()}`);
567
+ }
568
+ const tail = cold.subscribe(api.deliveries.inbox, { c: null }, () => undefined, { drain: true });
569
+ await tail.until((value) => (value.state === "complete" || value.state === "capped") && value.caughtUp === true);
570
+ const thread = await cold.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
571
+ if (!("data" in thread) || !Array.isArray(thread.data)) throw new Error("thread did not return rows");
572
+ const rows = thread.data.filter((row: LogRow) => row.message?.body === "queued one" || row.message?.body === "queued two");
573
+ if (rows.length !== 2) throw new Error(`expected exactly one row per send, saw ${rows.length}`);
574
+ // `last 50` pages newest-first: the second send precedes the first.
575
+ if (rows[0]?.message?.id !== second.ids[0] || rows[1]?.message?.id !== first.ids[0]) {
576
+ throw new Error("server rows do not carry the pinned ids in send order");
577
+ }
578
+ await tail.until((value) => "data" in value && bodies(value.data).includes("queued two"));
579
+ tail.close();
580
+ });
581
+
582
+ journey("offline edits reject stale reads by trace, and past-fence edits by the program CONFLICT", async ({ as }) => {
583
+ const alice = as("alice", { store: "file", onTombstone: tombstones });
584
+ const drain = alice.subscribe(api.deliveries.inbox, { c: null }, () => undefined, { drain: true });
585
+ await drain.until((value) => (value.state === "complete" || value.state === "capped") && value.caughtUp === true);
586
+ const posted = await alice.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "original" });
587
+ if (posted.state !== "committed") throw new Error(`post was ${posted.state}`);
588
+ const second = await alice.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "left alone" });
589
+ if (second.state !== "committed") throw new Error(`second post was ${second.state}`);
590
+ await drain.until((value) => "data" in value && bodies(value.data).includes("original") && bodies(value.data).includes("left alone"));
591
+ await alice.offline();
592
+ const edit = await alice.mutation(api.deliveries.edit, { messageId: posted.data.id, body: "mine", baseBody: "original" });
593
+ if (edit.state !== "queued") throw new Error(`offline edit was ${edit.state}`);
594
+ // A second message nobody else touches: the same edit with a trace that still holds commits.
595
+ const untouched = await alice.mutation(api.deliveries.edit, { messageId: second.data.id, body: "left alone, edited", baseBody: "left alone" });
596
+ if (untouched.state !== "queued") throw new Error(`the second offline edit was ${untouched.state}`);
597
+ const queued = await alice.outbox.entries();
598
+ for (const item of queued) {
599
+ // @ref LLP 1012#7-writes-offline — the twin traced the row it read: the point with the wire digest.
600
+ const point = item.trace?.points.find((candidate) => candidate.table === "messages" && candidate.index === "byId");
601
+ if (item.predicted !== true || !point || !/^[0-9a-f]{64}$/.test(point.digest ?? "")) {
602
+ throw new Error(`the offline edit was queued without its trace: ${JSON.stringify(item.trace)}`);
603
+ }
604
+ }
605
+ const owner = as("alice");
606
+ const theirs = await owner.mutation(api.deliveries.edit, { messageId: posted.data.id, body: "theirs", baseBody: "original" });
607
+ if (theirs.state !== "committed") throw new Error(`the owner's edit was ${theirs.state}`);
608
+ owner.close();
609
+ await alice.online();
610
+ await alice.settled();
611
+ const entry = (await alice.outbox.entries()).find((item) => item.intent === edit.intent);
612
+ if (entry?.state !== "rejected") throw new Error(`the stale edit settled as ${entry?.state}`);
613
+ const response = entry.response as { code?: string; reason?: string; site?: string };
614
+ // Trace validation names the moved read before the program's own `CONFLICT` can (LLP 1012 §7).
615
+ if (response.reason !== "conflict" || response.code !== "E_OFFLINE_CONFLICT" || typeof response.site !== "string") {
616
+ throw new Error(`the stale edit was refused for another reason: ${JSON.stringify(response)}`);
617
+ }
618
+ const matching = (await alice.outbox.entries()).find((item) => item.intent === untouched.intent);
619
+ if (matching?.state !== "committed") throw new Error(`the matching edit settled as ${matching?.state}: ${JSON.stringify(matching?.response)}`);
620
+ await drain.until((value) => "data" in value && bodies(value.data).includes("theirs"));
621
+ const thread = await alice.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
622
+ if (!("data" in thread) || !bodies(thread.data).includes("theirs") || bodies(thread.data).includes("mine")) {
623
+ throw new Error("the conflict did not leave the owner's edit in place");
624
+ }
625
+ // Past the default seven-day fence the twin does not run and there is no
626
+ // trace OCC. The certified edit's compare-and-set must reject the stale base.
627
+ const originalNow = Date.now;
628
+ const pastFenceNow = originalNow() + 8 * 24 * 60 * 60 * 1000;
629
+ try {
630
+ await alice.offline();
631
+ Date.now = () => pastFenceNow;
632
+ const late = await alice.mutation(api.deliveries.edit, { messageId: posted.data.id, body: "late edit", baseBody: "original" });
633
+ if (late.state !== "queued") throw new Error(`past-fence edit was ${late.state}`);
634
+ const pending = (await alice.outbox.entries()).find((item) => item.intent === late.intent);
635
+ if (pending?.predicted !== false || pending.reason !== "fence"
636
+ || pending.trace?.points.length !== 0 || pending.trace?.verdicts.length !== 0) {
637
+ throw new Error(`past-fence edit was not marked unpredicted with an empty trace: ${JSON.stringify(pending)}`);
638
+ }
639
+ await alice.online();
640
+ await alice.settled();
641
+ const terminal = (await alice.outbox.entries()).find((item) => item.intent === late.intent);
642
+ const refusal = terminal?.response as { code?: string; reason?: string };
643
+ if (terminal?.state !== "rejected" || refusal.code !== "CONFLICT" || refusal.reason === "conflict") {
644
+ throw new Error(`past-fence edit must fail the program's CONFLICT, not trace OCC: ${JSON.stringify(terminal)}`);
645
+ }
646
+ } finally {
647
+ Date.now = originalNow;
648
+ }
649
+ drain.close();
650
+ });
651
+
652
+ // @ref LLP 1012#6-reads-offline — the acceptance only the twin can pass: a
653
+ // conversation the viewer never opened online renders from the drained log
654
+ // through `thread`, and a send queued offline appears inside that thread's
655
+ // result as predicted, in order, then settles under the same pinned id.
656
+ journey("a never-opened chat renders offline from the drained log and an offline send is predicted into it in order", async ({ as }) => {
657
+ const bob = as("bob", { store: "file", onTombstone: tombstones });
658
+ const drain = bob.subscribe(api.deliveries.inbox, { c: null }, () => undefined, { drain: true });
659
+ await drain.until((value) => (value.state === "complete" || value.state === "capped") && value.caughtUp === true);
660
+ drain.close();
661
+ await bob.offline();
662
+ const local = await bob.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
663
+ if (local.state !== "complete" || !("connection" in local) || local.connection !== "offline" || !Array.isArray(local.data)) {
664
+ throw new Error(`the never-opened thread was ${local.state} offline${"reason" in local ? ` (${String(local.reason)})` : ""}`);
665
+ }
666
+ const sent = await bob.mutation(api.deliveries.post, { conversationId: "conversation-1", body: "from the train" });
667
+ if (sent.state !== "queued") throw new Error(`offline send was ${sent.state}`);
668
+ const [entry] = await bob.outbox.entries();
669
+ if (entry?.predicted !== true) throw new Error("the send was queued without a prediction");
670
+ if (!entry.trace?.points.some((point) => point.table === "memberships" && typeof point.digest === "string")) {
671
+ throw new Error(`the trace lacks the membership read: ${JSON.stringify(entry.trace)}`);
672
+ }
673
+ // @ref LLP 1012#7-writes-offline — `maintain messageCounts` is the server's write: the prediction names what it omits.
674
+ if (JSON.stringify(entry.omitted) !== JSON.stringify(["messageCounts"])) {
675
+ throw new Error(`the prediction did not name the omitted aggregate: ${JSON.stringify(entry.omitted)}`);
676
+ }
677
+ const predicted = await bob.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
678
+ if (!("data" in predicted) || !Array.isArray(predicted.data)) throw new Error("the predicted thread did not return rows");
679
+ // `last 50` pages newest-first: the queued send leads.
680
+ if (predicted.data[0]?.message?.id !== sent.ids[0] || predicted.data[0]?.message?.body !== "from the train") {
681
+ throw new Error(`the queued send is not inside the thread in order: ${JSON.stringify(bodies(predicted.data))}`);
682
+ }
683
+ if (!("predicted" in predicted) || !predicted.predicted?.includes(sent.intent)) throw new Error("the thread did not mark the send predicted");
684
+ await bob.online();
685
+ await bob.settled();
686
+ const settled = (await bob.outbox.entries()).find((item) => item.intent === sent.intent);
687
+ if (settled?.state !== "committed") throw new Error(`the predicted send settled as ${settled?.state}: ${JSON.stringify(settled?.response)}`);
688
+ const online = await bob.query(api.deliveries.thread, { conversationId: "conversation-1", c: null });
689
+ if (!("data" in online) || !Array.isArray(online.data)) throw new Error("the online thread did not return rows");
690
+ if (online.data[0]?.message?.id !== sent.ids[0]) throw new Error("the server row does not carry the pinned id the prediction used");
691
+ // The offline replay before the send was the server's answer: same rows in the same order, the send aside.
692
+ const replayed = JSON.stringify(local.data.map((row: LogRow) => [row.messageId, row.message?.body]));
693
+ const served = JSON.stringify(online.data.slice(1).map((row: LogRow) => [row.messageId, row.message?.body]));
694
+ if (replayed !== served) throw new Error(`the offline thread differed from the server's: ${replayed} vs ${served}`);
695
+ });
696
+
697
+ // @ref LLP 1012#7-writes-offline — fan-out beyond 1:1: the twin mints the
698
+ // viewer's own delivery row under a pinned id the server does not use (the
699
+ // server iterates every member), so the overlay is superseded by seq and
700
+ // table, never by id: one bubble while queued, the message id stable, no
701
+ // duplicate delivery row after settle, and every other member delivered once.
702
+ journey("a send queued offline into a three-member group shows one bubble, keeps its message id, leaves no duplicate delivery, and reaches both other members once", async ({ as }) => {
703
+ const alice = as("alice");
704
+ const charlie = as("charlie");
705
+ const bob = as("bob", { store: "file", onTombstone: tombstones });
706
+ try {
707
+ const bobPrincipal = (await bob.auth.me()).viewer;
708
+ const charliePrincipal = (await charlie.auth.me()).viewer;
709
+ const trio = await alice.mutation(api.groups.groupCreate, { title: "Trio", members: [bobPrincipal, charliePrincipal] });
710
+ if (trio.state !== "committed") throw new Error(`group creation was ${trio.state}`);
711
+ const conversationId = trio.data.id;
712
+ // A first message lands a delivery in bob's log, whose read rule pools his membership: the send below is predictable.
713
+ const opener = await alice.mutation(api.deliveries.post, { conversationId, body: "welcome to the trio" });
714
+ if (opener.state !== "committed") throw new Error(`the opener was ${opener.state}`);
715
+ const drain = bob.subscribe(api.deliveries.inbox, { c: null }, () => undefined, { drain: true });
716
+ await drain.until((value) => (value.state === "complete" || value.state === "capped") && value.caughtUp === true
717
+ && "data" in value && bodies(value.data).includes("welcome to the trio"));
718
+ drain.close();
719
+ await bob.offline();
720
+ const sent = await bob.mutation(api.deliveries.post, { conversationId, body: "to the trio" });
721
+ if (sent.state !== "queued") throw new Error(`the offline send was ${sent.state}`);
722
+ const entry = (await bob.outbox.entries()).find((item) => item.intent === sent.intent);
723
+ if (entry?.predicted !== true) throw new Error("the group send was queued without a prediction");
724
+ if (JSON.stringify(entry.omitted) !== JSON.stringify(["messageCounts"])) throw new Error(`omitted was ${JSON.stringify(entry.omitted)}`);
725
+ const queued = await bob.query(api.deliveries.thread, { conversationId, c: null });
726
+ if (!("data" in queued) || !Array.isArray(queued.data)) throw new Error("the predicted thread did not return rows");
727
+ const bubbles = queued.data.filter((row: LogRow) => row.message?.id === sent.ids[0]);
728
+ if (bubbles.length !== 1 || bubbles[0]?.message?.body !== "to the trio") throw new Error(`expected one bubble while queued, saw ${bubbles.length}`);
729
+ await bob.online();
730
+ await bob.settled();
731
+ const settled = (await bob.outbox.entries()).find((item) => item.intent === sent.intent);
732
+ if (settled?.state !== "committed") throw new Error(`the group send settled as ${settled?.state}: ${JSON.stringify(settled?.response)}`);
733
+ const response = settled.response as { data?: { id?: string } };
734
+ if (response.data?.id !== sent.ids[0]) throw new Error("the server minted the message under another id than the prediction");
735
+ const served = await bob.query(api.deliveries.thread, { conversationId, c: null });
736
+ if (!("data" in served) || !Array.isArray(served.data)) throw new Error("the online thread did not return rows");
737
+ const rows = served.data.filter((row: LogRow) => row.messageId === sent.ids[0]);
738
+ if (rows.length !== 1 || rows[0]?.message?.id !== sent.ids[0]) throw new Error(`expected exactly one delivery row for the send online, saw ${rows.length}`);
739
+ // Offline again: the twin's overlay left with the settlement's tail, so the server's row is the only one.
740
+ await bob.offline();
741
+ const replayed = await bob.query(api.deliveries.thread, { conversationId, c: null });
742
+ if (!("data" in replayed) || !Array.isArray(replayed.data)) throw new Error("the offline thread did not return rows");
743
+ const duplicates = replayed.data.filter((row: LogRow) => row.messageId === sent.ids[0]);
744
+ if (duplicates.length !== 1) throw new Error(`the prediction's delivery row survived beside the server's: ${duplicates.length} rows`);
745
+ if ("predicted" in replayed && replayed.predicted?.includes(sent.intent)) throw new Error("a settled send is still marked predicted");
746
+ await bob.online();
747
+ for (const [name, member] of [["alice", alice], ["charlie", charlie]] as const) {
748
+ const inbox = await member.query(api.deliveries.inbox, { c: null });
749
+ if (!("data" in inbox) || !Array.isArray(inbox.data)) throw new Error(`${name}'s inbox did not return rows`);
750
+ const delivered = inbox.data.filter((row: LogRow) => row.messageId === sent.ids[0]);
751
+ if (delivered.length !== 1 || delivered[0]?.message?.body !== "to the trio") {
752
+ throw new Error(`${name} received ${delivered.length} deliveries of the group send`);
753
+ }
754
+ }
755
+ } finally {
756
+ alice.close();
757
+ charlie.close();
758
+ }
759
+ });