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,3 @@
1
+ import { registerRootComponent } from 'expo';
2
+ import App from './App';
3
+ registerRootComponent(App);
@@ -0,0 +1,92 @@
1
+ function conversations(rows) {
2
+ const byConversation = /* @__PURE__ */ new Map();
3
+ for (const row of rows) {
4
+ const current = byConversation.get(row.conversationId) ?? {
5
+ conversationId: row.conversationId,
6
+ latestAt: row.at,
7
+ latestBody: void 0,
8
+ messages: 0
9
+ };
10
+ if (row.kind === "message") current.messages += 1;
11
+ if (row.at >= current.latestAt) {
12
+ current.latestAt = row.at;
13
+ current.latestBody = row.message?.body ?? current.latestBody;
14
+ }
15
+ byConversation.set(row.conversationId, current);
16
+ }
17
+ return [...byConversation.values()].sort((a, b) => b.latestAt - a.latestAt);
18
+ }
19
+ function postArgs(entry) {
20
+ const args = entry.args;
21
+ return entry.op === "deliveries.post" && args && typeof args.conversationId === "string" && typeof args.body === "string" ? { conversationId: args.conversationId, body: args.body } : void 0;
22
+ }
23
+ function editArgs(entry) {
24
+ const args = entry.args;
25
+ return entry.op === "deliveries.edit" && args && typeof args.messageId === "string" && typeof args.body === "string" ? { messageId: args.messageId, body: args.body, baseBody: args.baseBody ?? "" } : void 0;
26
+ }
27
+ function intentLabel(entry) {
28
+ switch (entry.state) {
29
+ case "queued":
30
+ return entry.reason === "fence" ? "Will be checked by the server" : "Sending\u2026";
31
+ case "committed":
32
+ return "Sent";
33
+ case "expired":
34
+ return "Expired";
35
+ case "rejected": {
36
+ const response = entry.response;
37
+ if (response?.reason === "conflict" || response?.code === "CONFLICT") return "Not sent \xB7 Message changed";
38
+ return `Not sent \xB7 ${response?.rewrite ?? response?.message ?? response?.code ?? "refused"}`;
39
+ }
40
+ }
41
+ }
42
+ function threadBubbles(rows, entries, conversationId) {
43
+ const hidden = new Set(rows.filter((row) => row.kind === "removed" || row.kind === "expired").map((row) => row.messageId));
44
+ const edits = new Map(entries.map((entry) => [editArgs(entry)?.messageId, entry]).filter(([id]) => id !== void 0));
45
+ const seen = /* @__PURE__ */ new Set();
46
+ const bubbles = [];
47
+ const pending = new Map(entries.filter((entry) => entry.state === "queued" && postArgs(entry) !== void 0).map((entry) => [entry.ids[0] ?? entry.intent, entry]));
48
+ const chronological = [...rows].sort((a, b) => a.at - b.at || (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0));
49
+ for (const row of chronological) {
50
+ if (row.kind !== "message" || !row.message || hidden.has(row.messageId) || seen.has(row.messageId)) continue;
51
+ seen.add(row.messageId);
52
+ const predicted = pending.get(row.messageId);
53
+ if (predicted) {
54
+ bubbles.push({ key: row.messageId, kind: "pending", entry: predicted, body: postArgs(predicted).body });
55
+ continue;
56
+ }
57
+ const edit = edits.get(row.messageId);
58
+ bubbles.push({ key: row.messageId, kind: "row", message: row.message, at: row.at, ...edit ? { edit } : {} });
59
+ }
60
+ for (const entry of entries) {
61
+ const args = postArgs(entry);
62
+ if (!args || args.conversationId !== conversationId) continue;
63
+ const key = entry.ids[0] ?? entry.intent;
64
+ if (seen.has(key)) continue;
65
+ bubbles.push({ key, kind: "pending", entry, body: args.body });
66
+ }
67
+ return bubbles;
68
+ }
69
+ function settledByRows(rows, entries) {
70
+ const held = new Set(rows.map((row) => row.messageId));
71
+ return entries.filter((entry) => entry.state === "committed" && postArgs(entry) !== void 0 && held.has(entry.ids[0] ?? ""));
72
+ }
73
+ function searchHeld(rows, term) {
74
+ const needle = term.trim().toLowerCase();
75
+ if (!needle) return [];
76
+ const seen = /* @__PURE__ */ new Set();
77
+ return rows.filter((row) => {
78
+ const body = row.message?.body;
79
+ if (row.kind !== "message" || !body || seen.has(row.messageId) || !body.toLowerCase().includes(needle)) return false;
80
+ seen.add(row.messageId);
81
+ return true;
82
+ });
83
+ }
84
+ export {
85
+ conversations,
86
+ editArgs,
87
+ intentLabel,
88
+ postArgs,
89
+ searchHeld,
90
+ settledByRows,
91
+ threadBubbles
92
+ };
@@ -0,0 +1,130 @@
1
+ // The delivery log is the app's source of truth on the device (LLP 1012 §3):
2
+ // every screen is a pure function over held log rows plus the durable outbox.
3
+ import type { Api, OutboxEntry } from "snapback2/client";
4
+
5
+ type ResultOf<T> = T extends { readonly __result?: infer Result } ? NonNullable<Result> : never;
6
+ export type LogRow = ResultOf<Api["deliveries"]["inbox"]>[number];
7
+ export type MessageRow = NonNullable<LogRow["message"]>;
8
+
9
+ export interface ConversationSummary {
10
+ conversationId: string;
11
+ latestAt: number;
12
+ latestBody: string | undefined;
13
+ messages: number;
14
+ }
15
+
16
+ /** One line per conversation, newest activity first, from the held log. */
17
+ export function conversations(rows: readonly LogRow[]): ConversationSummary[] {
18
+ const byConversation = new Map<string, ConversationSummary>();
19
+ for (const row of rows) {
20
+ const current = byConversation.get(row.conversationId) ?? {
21
+ conversationId: row.conversationId,
22
+ latestAt: row.at,
23
+ latestBody: undefined,
24
+ messages: 0,
25
+ };
26
+ if (row.kind === "message") current.messages += 1;
27
+ if (row.at >= current.latestAt) {
28
+ current.latestAt = row.at;
29
+ current.latestBody = row.message?.body ?? current.latestBody;
30
+ }
31
+ byConversation.set(row.conversationId, current);
32
+ }
33
+ return [...byConversation.values()].sort((a, b) => b.latestAt - a.latestAt);
34
+ }
35
+
36
+ export type PostArgs = { conversationId: string; body: string };
37
+ export type EditArgs = { messageId: string; body: string; baseBody: string };
38
+
39
+ export function postArgs(entry: OutboxEntry): PostArgs | undefined {
40
+ const args = entry.args as Partial<PostArgs> | null;
41
+ return entry.op === "deliveries.post" && args && typeof args.conversationId === "string" && typeof args.body === "string"
42
+ ? { conversationId: args.conversationId, body: args.body }
43
+ : undefined;
44
+ }
45
+
46
+ export function editArgs(entry: OutboxEntry): EditArgs | undefined {
47
+ const args = entry.args as Partial<EditArgs> | null;
48
+ return entry.op === "deliveries.edit" && args && typeof args.messageId === "string" && typeof args.body === "string"
49
+ ? { messageId: args.messageId, body: args.body, baseBody: args.baseBody ?? "" }
50
+ : undefined;
51
+ }
52
+
53
+ /** The label a settled or queued intent shows; never a client clock. */
54
+ export function intentLabel(entry: OutboxEntry): string {
55
+ switch (entry.state) {
56
+ case "queued": return entry.reason === "fence" ? "Will be checked by the server" : "Sending…";
57
+ case "committed": return "Sent";
58
+ case "expired": return "Expired";
59
+ case "rejected": {
60
+ const response = entry.response as { reason?: string; code?: string; rewrite?: string; message?: string } | undefined;
61
+ // @ref LLP 1012#7-writes-offline — a trace conflict, or the recipe's own
62
+ // compare-and-set (`require msg.body = baseBody else CONFLICT`), reads the same.
63
+ if (response?.reason === "conflict" || response?.code === "CONFLICT") return "Not sent · Message changed";
64
+ return `Not sent · ${response?.rewrite ?? response?.message ?? response?.code ?? "refused"}`;
65
+ }
66
+ }
67
+ }
68
+
69
+ export type Bubble =
70
+ | { key: string; kind: "row"; message: MessageRow; at: number; edit?: OutboxEntry }
71
+ | { key: string; kind: "pending"; entry: OutboxEntry; body: string };
72
+
73
+ /**
74
+ * The thread as bubbles: one per message id from the log (tombstoned ids
75
+ * hidden), then the outbox's intents for this conversation. A pending post is
76
+ * keyed by its first pinned id, the id the server row will carry, so the same
77
+ * bubble becomes the row on `committed` and nothing jumps.
78
+ */
79
+ export function threadBubbles(rows: readonly LogRow[], entries: readonly OutboxEntry[], conversationId: string): Bubble[] {
80
+ const hidden = new Set(rows.filter((row) => row.kind === "removed" || row.kind === "expired").map((row) => row.messageId));
81
+ const edits = new Map(entries.map((entry) => [editArgs(entry)?.messageId, entry] as const).filter(([id]) => id !== undefined));
82
+ const seen = new Set<string>();
83
+ const bubbles: Bubble[] = [];
84
+ // @ref LLP 1012#7-writes-offline — a queued send the twin predicted is inside
85
+ // the log's rows under its pinned id; it is still the pending bubble, with
86
+ // `args.body` and "Sending…" and no time, until the server's row settles it.
87
+ const pending = new Map(entries
88
+ .filter((entry) => entry.state === "queued" && postArgs(entry) !== undefined)
89
+ .map((entry) => [entry.ids[0] ?? entry.intent, entry] as const));
90
+ // The log's thread page is newest-first (`last 50`); a thread reads oldest-first.
91
+ const chronological = [...rows].sort((a, b) => a.at - b.at || (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0));
92
+ for (const row of chronological) {
93
+ if (row.kind !== "message" || !row.message || hidden.has(row.messageId) || seen.has(row.messageId)) continue;
94
+ seen.add(row.messageId);
95
+ const predicted = pending.get(row.messageId);
96
+ if (predicted) {
97
+ bubbles.push({ key: row.messageId, kind: "pending", entry: predicted, body: postArgs(predicted)!.body });
98
+ continue;
99
+ }
100
+ const edit = edits.get(row.messageId);
101
+ bubbles.push({ key: row.messageId, kind: "row", message: row.message, at: row.at, ...(edit ? { edit } : {}) });
102
+ }
103
+ for (const entry of entries) {
104
+ const args = postArgs(entry);
105
+ if (!args || args.conversationId !== conversationId) continue;
106
+ const key = entry.ids[0] ?? entry.intent;
107
+ if (seen.has(key)) continue;
108
+ bubbles.push({ key, kind: "pending", entry, body: args.body });
109
+ }
110
+ return bubbles;
111
+ }
112
+
113
+ /** Committed posts whose server row is now held can leave the outbox. */
114
+ export function settledByRows(rows: readonly LogRow[], entries: readonly OutboxEntry[]): OutboxEntry[] {
115
+ const held = new Set(rows.map((row) => row.messageId));
116
+ return entries.filter((entry) => entry.state === "committed" && postArgs(entry) !== undefined && held.has(entry.ids[0] ?? ""));
117
+ }
118
+
119
+ /** Offline search is a filter over the downloaded log (LLP 1012 §6); it finds nothing evicted. */
120
+ export function searchHeld(rows: readonly LogRow[], term: string): LogRow[] {
121
+ const needle = term.trim().toLowerCase();
122
+ if (!needle) return [];
123
+ const seen = new Set<string>();
124
+ return rows.filter((row) => {
125
+ const body = row.message?.body;
126
+ if (row.kind !== "message" || !body || seen.has(row.messageId) || !body.toLowerCase().includes(needle)) return false;
127
+ seen.add(row.messageId);
128
+ return true;
129
+ });
130
+ }
@@ -0,0 +1,8 @@
1
+ const chatOffline = {
2
+ onTombstone: (item) => item.table === "deliveries" && (item.row.kind === "removed" || item.row.kind === "expired") && typeof item.row.messageId === "string" ? [{ table: "messages", id: item.row.messageId }] : [],
3
+ // Optional on the recipe's message row; absent/null is ordinary media.
4
+ viewOnce: (table, row) => table === "messages" && row.viewOnce === true
5
+ };
6
+ export {
7
+ chatOffline
8
+ };
@@ -0,0 +1,9 @@
1
+ type StoredRow = { table: string; row: Record<string, unknown> };
2
+
3
+ export const chatOffline = {
4
+ onTombstone: (item: StoredRow) =>
5
+ item.table === "deliveries" && (item.row.kind === "removed" || item.row.kind === "expired")
6
+ && typeof item.row.messageId === "string" ? [{ table: "messages", id: item.row.messageId }] : [],
7
+ // Optional on the recipe's message row; absent/null is ordinary media.
8
+ viewOnce: (table: string, row: Record<string, unknown>) => table === "messages" && row.viewOnce === true,
9
+ };
@@ -0,0 +1,23 @@
1
+ import React, { useState } from "react";
2
+ import { SafeAreaView, ScrollView, Text, Button } from "react-native";
3
+ import { api } from "snapback2/client";
4
+ import { useConnection, useQuery } from "snapback2/react-native";
5
+ import type { LogRow } from "../shared/log.js";
6
+ import { Inbox } from "./screens/Inbox";
7
+ import { Thread } from "./screens/Thread";
8
+ import { Search } from "./screens/Search";
9
+
10
+ export function Chat() {
11
+ const connection = useConnection();
12
+ const inbox = useQuery(api.deliveries.inbox, { c: null }, { drain: true });
13
+ const rows: LogRow[] = "data" in inbox && Array.isArray(inbox.data) ? inbox.data : [];
14
+ const [selected, select] = useState<string>();
15
+ return <SafeAreaView style={{ flex: 1, backgroundColor: "#f7f7fb" }}>
16
+ <Text style={{ fontSize: 24, padding: 16 }}>Snapback Chat · {connection}</Text>
17
+ {selected ? <><Button title="Inbox" onPress={() => select(undefined)} /><Thread conversationId={selected} /></>
18
+ : <ScrollView contentContainerStyle={{ padding: 16 }}>
19
+ <Search rows={rows} onOpen={select} /><Inbox rows={rows} onSelect={select} />
20
+ {inbox.state === "refused" ? <Text>{inbox.refusal.rewrite ?? inbox.refusal.message}</Text> : null}
21
+ </ScrollView>}
22
+ </SafeAreaView>;
23
+ }
@@ -0,0 +1,23 @@
1
+ import React, { useState } from "react";
2
+ import { Button, Text, TextInput, View } from "react-native";
3
+ import { api, isSnapbackRefusal } from "snapback2/client";
4
+ import { useMutation, useOutbox } from "snapback2/react-native";
5
+ export function Composer({ conversationId }: { conversationId: string }) {
6
+ const post = useMutation(api.deliveries.post);
7
+ const { fence } = useOutbox();
8
+ const [body, setBody] = useState("");
9
+ const [note, setNote] = useState("");
10
+ const send = async () => {
11
+ if (!body.trim() || !post.ready) return;
12
+ try {
13
+ const result = await post.run({ conversationId, body: body.trim() });
14
+ if (result.state === "rejected") setNote(result.rewrite ?? result.message);
15
+ else { setBody(""); setNote(result.state === "queued" ? "Queued until the next sync." : ""); }
16
+ } catch (error) { setNote(isSnapbackRefusal(error) ? error.envelope.rewrite ?? error.message : String(error)); }
17
+ };
18
+ return <View style={{ padding: 16 }}><TextInput accessibilityLabel="Message" placeholder="Message" value={body} onChangeText={setBody} multiline />
19
+ <Button title="Send" disabled={!post.ready || post.inFlight || !body.trim()} onPress={() => { void send(); }} />
20
+ {!post.ready ? <Text>Sending opens once the log has downloaded.</Text> : fence === "past" ? <Text>Queued until the next sync.</Text> : null}
21
+ {note ? <Text accessibilityRole="alert">{note}</Text> : null}
22
+ </View>;
23
+ }
@@ -0,0 +1,29 @@
1
+ import React, { useCallback } from "react";
2
+ import type { AssetRecord } from "snapback2/client";
3
+ import { AssetImage, AssetVideo } from "snapback2/react-native";
4
+
5
+ export interface Media {
6
+ asset: AssetRecord; nonce: number; once: boolean; hidden?: boolean;
7
+ loaded: boolean; uri?: string; resolve(value: unknown): void;
8
+ }
9
+ /** The witness observes the source actually passed to the native renderer. */
10
+ export function MediaView({ media }: { media: Media }) {
11
+ const source = useCallback((uri: string | undefined, settled: boolean) => {
12
+ media.uri = uri;
13
+ if (media.hidden && settled) media.resolve({ loaded: media.loaded, uri });
14
+ }, [media]);
15
+ const loaded = useCallback((loadedUri?: string) => {
16
+ const uri = media.uri;
17
+ if (!uri) { media.resolve({ loaded: false }); return; }
18
+ if (loadedUri && loadedUri !== uri) return; // a late event from the prior memory source
19
+ media.loaded = true;
20
+ if (media.once ? uri.startsWith("data:") : uri.startsWith("file://"))
21
+ media.resolve({ loaded: true, uri, ...( "duration" in media.asset ? { playing: true } : {}) });
22
+ }, [media]);
23
+ const refusal = useCallback((error: { envelope: unknown }) => media.resolve({ loaded: false, refusal: error.envelope }), [media]);
24
+ return "duration" in media.asset
25
+ ? <AssetVideo asset={media.asset} width={64} autoPlay={!media.hidden} testID="witness-media"
26
+ onSourceChange={source} onPlaybackStart={loaded} onRefusal={refusal} />
27
+ : <AssetImage asset={media.asset} width={64} testID="witness-media"
28
+ onSourceChange={source} onLoad={(event) => loaded(event?.nativeEvent?.source?.uri)} onRefusal={refusal} />;
29
+ }
@@ -0,0 +1,131 @@
1
+ import { twinLastDecline } from "snapback2/expo/witness";
2
+ import React, { useEffect, useState } from "react";
3
+ import { Text, View } from "react-native";
4
+ import { api, SnapbackRefusal, type AssetRecord, type ImageAsset, type RangeStore, type SnapbackClient, type Subscription } from "snapback2/client";
5
+ import { createExpoClient, expoSqliteStore, expoSqliteDriver, SnapbackProvider } from "snapback2/expo";
6
+ import { MediaView, type Media } from "./MediaView";
7
+ import { witnessControl, runSqliteDriverConformance, type WitnessHooks } from "snapback2/expo/witness";
8
+ import { chatOffline } from "../shared/offline.js";
9
+ import { searchHeld, threadBubbles } from "../shared/log.js";
10
+ import { configuration, persist, databaseState, markerSwap, listFiles, DATABASE } from "./witness-state";
11
+ import { Chat } from "./Chat";
12
+
13
+ export default function Witness({ url, controlUrl }: { url: string; controlUrl: string }) {
14
+ const [client, setClient] = useState<SnapbackClient>();
15
+ const [media, setMedia] = useState<Media>();
16
+ const [error, setError] = useState<string>();
17
+ useEffect(() => {
18
+ let live: SnapbackClient | undefined, store: RangeStore | undefined, selected = "conversation-1";
19
+ let subscriptions: Subscription<unknown>[] = [];
20
+ let currentMedia: Media | undefined, viewNonce = 0;
21
+ const dispose = async () => { for (const sub of subscriptions) sub.close(); subscriptions = []; await live?.close(); live = undefined; setClient(undefined); };
22
+ const construct = async (hooks: WitnessHooks, args: Record<string, unknown>) => {
23
+ await dispose();
24
+ if (args.fresh === true) {
25
+ const initial = expoSqliteStore(DATABASE)();
26
+ try { await initial.wipe("dev:alice"); } finally { await initial.close?.(); }
27
+ await persist({ name: "alice", offline: false, fault: "none" });
28
+ }
29
+ if (typeof args.name === "string") await persist({ name: args.name });
30
+ if (Array.isArray(args.once) || Array.isArray(args.assets)) await persist({
31
+ ...(Array.isArray(args.once) ? { once: args.once as string[] } : {}),
32
+ ...(Array.isArray(args.assets) ? { assets: args.assets as AssetRecord[] } : {}),
33
+ });
34
+ const config = await configuration();
35
+ store = expoSqliteStore(DATABASE, { beforeCommit: hooks.beforeCommit })();
36
+ if (config.fault === "carrier") store.open = async () => { throw new SnapbackRefusal({
37
+ code: "E_OFFLINE_CARRIER", family: "offline", site: "witness construct", message: "persisted witness carrier fault",
38
+ rewrite: "send fault(none), then construct", guide: "offline",
39
+ }); };
40
+ live = createExpoClient({ url, as: config.name, startOffline: config.offline,
41
+ offline: { ...chatOffline, store: () => store!, viewOnce: (table, row) => chatOffline.viewOnce(table, row) || (table === "messages" && config.once.includes(String(row.id))) } });
42
+ try { await live.ready; setClient(live); setError(undefined); return live; }
43
+ catch (error) { setError(String(error)); throw error; }
44
+ };
45
+ const control = witnessControl({ construct, persist,
46
+ async command(client, verb, args) {
47
+ // The carrier can be read before any client constructs: the harness inspects a
48
+ // reopened database before the app mounts anything that writes to it.
49
+ if (verb === "stores") return { stores: await databaseState(), files: listFiles() };
50
+ if (!client || !store) throw new Error("construct first");
51
+ if (verb === "drain") {
52
+ const sub = client.subscribe(api.deliveries.inbox, { c: null }, () => {}, { drain: true });
53
+ subscriptions.push(sub);
54
+ return sub.until((value) => "caughtUp" in value && value.caughtUp === true);
55
+ }
56
+ if (verb === "open") {
57
+ selected = String(args.conversationId);
58
+ const sub = client.subscribe(api.deliveries.thread, { conversationId: selected, c: null }, () => {});
59
+ subscriptions.push(sub);
60
+ await sub.until((value) => value.state === "complete");
61
+ // Media mutations predate the delivery recipe; drain this named read to
62
+ // hold their row leases too (messages.sendPhoto has no delivery fanout).
63
+ await client.query(api.messages.thread, { conversationId: selected, c: null });
64
+ return client.query(api.deliveries.thread, { conversationId: selected, c: null });
65
+ }
66
+ if (verb === "send") return client.mutation(api.deliveries.post, {
67
+ conversationId: typeof args.conversationId === "string" ? args.conversationId : selected, body: String(args.body),
68
+ });
69
+ if (verb === "view") {
70
+ const config = await configuration();
71
+ const asset = config.assets.find((asset) => asset.id === args.assetId) as AssetRecord | undefined;
72
+ if (!asset) throw new Error("unknown witness asset");
73
+ const once = Boolean(await client.assetCache?.viewOnce(asset.id) || await client.assetCache?.viewed(asset.id));
74
+ const result = await new Promise<Record<string, unknown>>((resolve) => {
75
+ currentMedia = { asset, nonce: ++viewNonce, once, hidden: args.hidden === true,
76
+ loaded: false, resolve: (value) => resolve(value as Record<string, unknown>) };
77
+ setMedia(currentMedia);
78
+ });
79
+ if (!once && result.loaded === true) {
80
+ // The ordinary source may first load from memory; acknowledge only
81
+ // the materialized file, after the bridge's directory reports it.
82
+ // Compare paths, not spellings: the bridge's URI percent-encodes the partition
83
+ // (`dev%3Aalice`) while the directory listing may not.
84
+ const path = (uri: string) => decodeURIComponent(uri).replace(/\/+$/, "");
85
+ const served = path(String(result.uri));
86
+ const deadline = Date.now() + 10_000;
87
+ while (!listFiles().some((file) => path(file) === served)) {
88
+ if (Date.now() >= deadline) throw new Error(`ordinary media did not materialize a file: served ${result.uri}; listed ${JSON.stringify(listFiles().slice(0, 6))}`);
89
+ await new Promise((resolve) => setTimeout(resolve, 10));
90
+ }
91
+ }
92
+ return { ...result, viewed: await client.assetCache?.viewed(asset.id),
93
+ files: listFiles().filter((file) => file.includes(asset.id)) };
94
+ }
95
+
96
+ if (verb === "swap") {
97
+ if (args.phase !== "pre" && args.phase !== "post") throw new Error("swap needs pre or post");
98
+ await markerSwap(store, args.phase, await client.outbox.entries());
99
+ return { phase: args.phase };
100
+ }
101
+ if (verb === "conformance") return runSqliteDriverConformance(() => expoSqliteDriver("snapback2-conformance.db"));
102
+ if (verb === "settled") {
103
+ // A retryable refusal stops the replay and keeps the intent queued; the supported
104
+ // retry is an explicit drain of the same envelope, then settled again (LLP 1012 §7).
105
+ await client.outbox.settled();
106
+ if ((await client.outbox.entries()).some((entry) => entry.state === "queued")) {
107
+ await client.outbox.drain();
108
+ await client.outbox.settled();
109
+ }
110
+ const entries = await client.outbox.entries();
111
+ if (entries.some((entry) => entry.state === "queued")) throw new Error("outbox did not settle");
112
+ return entries;
113
+ }
114
+ if (verb === "state") {
115
+ const answer = typeof args.conversationId === "string"
116
+ ? await client.query(api.deliveries.thread, { conversationId: args.conversationId, c: null }) : undefined;
117
+ const entries = await client.outbox.entries();
118
+ const rows = answer && "data" in answer && Array.isArray(answer.data) ? answer.data : [];
119
+ return { snapshot: client.snapshot, coverage: await store.coverage(), outbox: entries,
120
+ bubbles: typeof args.conversationId === "string" ? threadBubbles(rows, entries, args.conversationId) : [],
121
+ search: typeof args.search === "string" ? searchHeld(rows, args.search) : [],
122
+ answer, twin: twinLastDecline(), stores: await databaseState(), files: listFiles(), media: currentMedia ? { loaded: currentMedia.loaded, uri: currentMedia.uri } : null };
123
+ }
124
+ throw new Error(`unknown witness command ${verb}`);
125
+ },
126
+ }, controlUrl);
127
+ return () => { control.close(); void dispose(); };
128
+ }, [url, controlUrl]);
129
+ return client ? <SnapbackProvider client={client}><View style={{ flex: 1 }}><Chat />{media ? <MediaView key={media.nonce} media={media} /> : null}</View></SnapbackProvider>
130
+ : <Text>{error ?? "Witness connected; waiting for construct."}</Text>;
131
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import { conversations, type LogRow } from "../../shared/log.js";
4
+ export function Inbox({ rows, onSelect }: { rows: readonly LogRow[]; onSelect(id: string): void }) {
5
+ return <View accessibilityLabel="Inbox">{conversations(rows).map((item) =>
6
+ <Pressable key={item.conversationId} onPress={() => onSelect(item.conversationId)} style={{ paddingVertical: 16 }}>
7
+ <Text style={{ fontWeight: "600" }}>{item.conversationId}</Text><Text>{item.latestBody ?? `${item.messages} messages`}</Text>
8
+ </Pressable>)}{rows.length === 0 ? <Text>No downloaded messages yet.</Text> : null}</View>;
9
+ }
@@ -0,0 +1,10 @@
1
+ import React, { useState } from "react";
2
+ import { Pressable, Text, TextInput, View } from "react-native";
3
+ import { searchHeld, type LogRow } from "../../shared/log.js";
4
+ export function Search({ rows, onOpen }: { rows: readonly LogRow[]; onOpen(id: string): void }) {
5
+ const [term, setTerm] = useState("");
6
+ return <View><TextInput accessibilityLabel="Search downloaded messages" placeholder="Search" value={term} onChangeText={setTerm} />
7
+ {term ? <Text>in downloaded messages</Text> : null}
8
+ {searchHeld(rows, term).map((row) => <Pressable key={row.messageId} onPress={() => onOpen(row.conversationId)}><Text>{row.message?.body}</Text></Pressable>)}
9
+ </View>;
10
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { ScrollView, Text, View } from "react-native";
3
+ import { api } from "snapback2/client";
4
+ import { AssetImage, AssetVideo, useOutbox, useQuery } from "snapback2/react-native";
5
+ import { intentLabel, threadBubbles, type LogRow } from "../../shared/log.js";
6
+ import { Composer } from "../Composer";
7
+ export function Thread({ conversationId }: { conversationId: string }) {
8
+ const result = useQuery(api.deliveries.thread, { conversationId, c: null });
9
+ const { entries } = useOutbox();
10
+ const rows: LogRow[] = "data" in result && Array.isArray(result.data) ? result.data : [];
11
+ return <View style={{ flex: 1 }}><ScrollView contentContainerStyle={{ padding: 16 }}>
12
+ {result.state === "partial" ? <Text>Some messages are not downloaded.</Text> : null}
13
+ {result.state === "refused" ? <Text>{result.refusal.rewrite ?? result.refusal.message}</Text> : null}
14
+ {threadBubbles(rows, entries, conversationId).map((bubble) => <View key={bubble.key} testID={`message-${bubble.key}`} style={{ padding: 12, marginVertical: 4, backgroundColor: "#e7edff", borderRadius: 12 }}>
15
+ {bubble.kind === "pending" ? <><Text>{bubble.body}</Text><Text>{intentLabel(bubble.entry)}</Text></>
16
+ : <><Text>{bubble.message.body}</Text>
17
+ {bubble.message.photo ? <AssetImage asset={bubble.message.photo} width={240} /> : null}
18
+ {bubble.message.clip ? <AssetVideo asset={bubble.message.clip} width={240} /> : null}
19
+ <Text>{new Date(bubble.at).toLocaleTimeString()}</Text></>}
20
+ </View>)}
21
+ </ScrollView><Composer conversationId={conversationId} /></View>;
22
+ }
@@ -0,0 +1,68 @@
1
+ import { Directory, File, Paths } from "expo-file-system";
2
+ import { expoSqliteDriver } from "snapback2/expo";
3
+ import type { AssetRecord, OutboxEntry, RangeStore, StoreSwap } from "snapback2/client";
4
+
5
+ export const DATABASE = "snapback2-witness.db";
6
+ export interface Configuration { offline: boolean; fault: "carrier" | "none"; name: string; once: string[]; assets: AssetRecord[] }
7
+ const configDirectory = () => new Directory(Paths.document, "snapback2-witness-config");
8
+ const defaultConfiguration = (): Configuration => ({ offline: false, fault: "none", name: "alice", once: [], assets: [] });
9
+ function versions(): File[] {
10
+ const directory = configDirectory();
11
+ return directory.exists ? directory.list().filter((file): file is File => file instanceof File && /\/\d{16}\.json$/.test(file.uri)).sort((a, b) => a.uri.localeCompare(b.uri)) : [];
12
+ }
13
+ export async function configuration(): Promise<Configuration> {
14
+ const file = versions().at(-1);
15
+ return file ? JSON.parse(await file.text()) : defaultConfiguration();
16
+ }
17
+ let persistTail: Promise<unknown> = Promise.resolve();
18
+ export function persist(patch: Partial<Configuration>): Promise<void> {
19
+ const write = persistTail.then(async () => {
20
+ const directory = configDirectory(); directory.create({ intermediates: true, idempotent: true });
21
+ const previous = versions();
22
+ const last = previous.at(-1);
23
+ const sequence = last ? Number(last.uri.match(/(\d{16})\.json$/)![1]) + 1 : 1;
24
+ const temp = new File(directory, "next.tmp");
25
+ temp.write(JSON.stringify({ ...await configuration(), ...patch }));
26
+ // Expo File.move cannot replace an existing destination atomically. Rename
27
+ // into a fresh numbered leaf; readers choose the latest committed leaf.
28
+ temp.move(new File(directory, `${String(sequence).padStart(16, "0")}.json`));
29
+ for (const old of previous) old.delete();
30
+ });
31
+ persistTail = write.catch(() => undefined);
32
+ return write;
33
+ }
34
+ export function listFiles(directory = new Directory(Paths.cache, "snapback2")): string[] {
35
+ if (!directory.exists) return [];
36
+ return directory.list().flatMap((item) => item instanceof Directory ? listFiles(item) : [item.uri]);
37
+ }
38
+ export async function databaseState(): Promise<Record<string, unknown>> {
39
+ const driver = expoSqliteDriver(DATABASE);
40
+ const result: Record<string, unknown> = {};
41
+ try {
42
+ for (const table of ["state", "rows", "answers", "outbox", "bytes"]) {
43
+ const rows = await driver.all<Record<string, unknown>>(`SELECT * FROM ${table} ORDER BY partition${table === "state" ? "" : ", key"}`);
44
+ result[table] = rows.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) =>
45
+ [key, value instanceof Uint8Array ? Array.from(value) : value])));
46
+ }
47
+ return result;
48
+ } finally { await driver.close(); }
49
+ }
50
+ const markerId = "s2id-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-0000000000000001";
51
+ /** A dev-only swap touching every persisted projection with distinct pre/post markers. */
52
+ export async function markerSwap(store: RangeStore, phase: "pre" | "post", entries: OutboxEntry[]): Promise<void> {
53
+ if (!entries[0]) throw new Error("enqueue both messages before the atomicity marker");
54
+ const byte = phase === "pre" ? 11 : 22;
55
+ const swap: StoreSwap = {
56
+ // The markers are schema-valid rows of real tables: the twin hydrates every held row
57
+ // through the schema and treats a row of an unknown table as corruption (`hydrating`),
58
+ // which is the fail-closed answer the spec wants — so a witness marker must look like
59
+ // the app's own data. A messages row in a conversation no member ever opens.
60
+ deliveries: [{ op: "deliveries.thread", args: { conversationId: "witness-marker", c: null }, seq: phase === "pre" ? 1 : 2, state: "complete", next: null,
61
+ data: { $row: { table: "messages", id: "witness-marker" } },
62
+ rows: [{ table: "messages", id: "witness-marker", row: { id: "witness-marker", conversationId: "witness-marker", authorId: "witness", body: phase, photo: { id: markerId, width: 1, height: 1 }, at: 1 } }],
63
+ receipt: { ranges: [], absences: [], verdicts: [] } }],
64
+ outbox: { put: [{ ...entries[0], intent: "__witness", state: "committed", op: "deliveries.post", args: { conversationId: "witness-marker", body: phase }, predicted: false }] },
65
+ bytes: { put: [{ assetId: markerId, width: null, type: `witness/${phase}`, bytes: new Uint8Array([byte]) }] },
66
+ };
67
+ await store.commit(swap);
68
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": { "strict": true, "skipLibCheck": true },
4
+ "include": ["App.tsx", "src", "shared", ".snapback/build/api.d.ts"]
5
+ }
@@ -0,0 +1,5 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Snapback 2 chat</title></head>
4
+ <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
5
+ </html>