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,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,33 @@
1
+ query inbox(c: cursor ?):
2
+ page = deliveries[viewer] after c first 50 by byRecipientAt
3
+ return page { *, message: messages[.messageId] }
4
+
5
+ query thread(conversationId: conversations, c: cursor ?):
6
+ require exists memberships[conversationId, viewer] else MEMBERSHIP_REQUIRED
7
+ page = deliveries[viewer, conversationId] after c last 50 by byRecipientConversationAt
8
+ return page { *, message: messages[.messageId] }
9
+
10
+ mutation post(conversationId: conversations, body: text <=4000, viewOnce: bool ?):
11
+ require exists memberships[conversationId, viewer] else NOT_MEMBER
12
+ row = insert messages { conversationId, authorId: viewer, body, viewOnce, at: now }
13
+ for m in memberships[conversationId] first 50 by byConversationPrincipal:
14
+ insert deliveries { recipient: m.principal, conversationId, messageId: row.id, kind: 'message', at: now }
15
+ return { id: row.id }
16
+
17
+ mutation unsend(messageId: messages):
18
+ msg = messages[messageId] ! else NOT_FOUND
19
+ require msg.authorId = viewer else NOT_AUTHOR
20
+ -- @ref LLP 1012#5-retention-revocation-and-the-fence — departed recipients evict the deleted body through the next held-page point absence.
21
+ for m in memberships[msg.conversationId] first 50 by byConversationPrincipal:
22
+ insert deliveries { recipient: m.principal, conversationId: msg.conversationId, messageId, kind: 'removed', at: now }
23
+ delete messages[msg]
24
+ return null
25
+
26
+ mutation edit(messageId: messages, body: text <=4000, baseBody: text <=4000):
27
+ msg = messages[messageId] ! else NOT_FOUND
28
+ require msg.authorId = viewer else NOT_AUTHOR
29
+ require msg.body = baseBody else CONFLICT
30
+ update messages[msg] { body }
31
+ for m in memberships[msg.conversationId] first 50 by byConversationPrincipal:
32
+ insert deliveries { recipient: m.principal, conversationId: msg.conversationId, messageId, kind: 'edit', at: now }
33
+ return null
@@ -0,0 +1,30 @@
1
+ import { effect } from "snapback2";
2
+ import { recordNotified } from "./notifications";
3
+ import { notifications } from "./schema";
4
+
5
+ const baseHeaders = { accept: "application/json" };
6
+ const requestDefaults = {
7
+ method: "POST",
8
+ headers: { ...baseHeaders, "content-type": "application/json" },
9
+ };
10
+
11
+ export const notify = effect({
12
+ grants: ["https://api.example.com"],
13
+ run: async (payload: { to: string; body: string }, { fetch, meta }) => {
14
+ function identity(value: string) { return value; }
15
+ const formatter = { format(value: string) { return identity(value); } };
16
+ const body = [payload.body].map(function keep(value) { return formatter.format(value); })[0];
17
+ try {
18
+ const response = await fetch("https://api.example.com/send", {
19
+ method: requestDefaults.method,
20
+ headers: { ...requestDefaults.headers, "idempotency-key": meta.id },
21
+ body: JSON.stringify({ ...payload, body }),
22
+ });
23
+ if (!response.ok) throw new Error(`provider ${response.status}`);
24
+ return { providerId: (await response.json()).id as string };
25
+ } catch (error) {
26
+ throw error;
27
+ }
28
+ },
29
+ then: recordNotified,
30
+ });
@@ -0,0 +1,14 @@
1
+ shape authorCard = { displayName, handle }
2
+
3
+ query globalFeed(c: cursor ?):
4
+ page = posts[] after c last 20 by byTime
5
+ return page {
6
+ post: .,
7
+ author: profiles[.author] authorCard,
8
+ }
9
+
10
+ query followingFeed(c: cursor ?):
11
+ page = merge posts after c last 20 by byAuthorTime:
12
+ [viewer]
13
+ [f.followed] for f in follows[viewer] first 50 by byFollower
14
+ return page { post: ., author: profiles[.author] authorCard }
@@ -0,0 +1,11 @@
1
+ mutation setFollow(target: principal, following: bool):
2
+ require target != viewer else SELF_FOLLOW
3
+ existing = follows[viewer, target]
4
+ if following:
5
+ if existing != null:
6
+ return existing
7
+ row = insert follows { follower: viewer, followed: target, at: now }
8
+ return { id: row.id, follower: viewer, followed: target, at: row.at }
9
+ if existing != null:
10
+ delete follows[existing]
11
+ return null
@@ -0,0 +1,40 @@
1
+ mutation groupCreate(title: text <=120, members: [principal] <=49):
2
+ require count(members) >= 1 else GROUP_REQUIRES_MEMBER
3
+ conversationId = new
4
+ creatorMembershipId = new
5
+ joinedAt = now
6
+ insert conversations { id: conversationId, title, creatorId: viewer }
7
+ insert memberships { id: creatorMembershipId, conversationId, principal: viewer, role: 'admin', joinedAt }
8
+ for principal in members:
9
+ require principal != viewer else CREATOR_ALREADY_ADMIN
10
+ insert memberships { conversationId, principal, role: 'member', joinedAt }
11
+ return { id: conversationId, creatorMembershipId }
12
+
13
+ mutation memberAdd(conversationId: conversations, principal: principal):
14
+ require exists memberships[conversationId, viewer, 'admin'] else ADMIN_REQUIRED
15
+ require principal != viewer else ADMIN_SELF_RESEAT
16
+ require #memberCounts[conversationId] < 50 else GROUP_FULL
17
+ row = insert memberships { conversationId, principal, role: 'member', joinedAt: now }
18
+ return { id: row.id }
19
+
20
+ mutation memberPromote(conversationId: conversations, membershipId: memberships):
21
+ require exists memberships[conversationId, viewer, 'admin'] else ADMIN_REQUIRED
22
+ membership = memberships[membershipId] ! else MEMBERSHIP_REQUIRED
23
+ require membership.conversationId = conversationId else MEMBERSHIP_SCOPE_REQUIRED
24
+ require membership.role = 'member' else MEMBER_REQUIRED
25
+ update memberships[membership] { role: 'admin' }
26
+ return null
27
+
28
+ mutation memberRemove(conversationId: conversations, membershipId: memberships):
29
+ require exists memberships[conversationId, viewer, 'admin'] else ADMIN_REQUIRED
30
+ membership = memberships[membershipId] ! else MEMBERSHIP_REQUIRED
31
+ require membership.conversationId = conversationId else MEMBERSHIP_SCOPE_REQUIRED
32
+ require membership.principal != viewer else ADMIN_SELF_REMOVE
33
+ delete memberships[membership]
34
+ return null
35
+
36
+ mutation memberLeave(conversationId: conversations, membershipId: memberships):
37
+ membership = memberships[conversationId, viewer] ! else MEMBERSHIP_REQUIRED
38
+ require membership.id = membershipId else MEMBERSHIP_SELF_REQUIRED
39
+ delete memberships[membership]
40
+ return null
@@ -0,0 +1,32 @@
1
+ effect notify from delivery
2
+
3
+ query thread(conversationId: conversations, c: cursor ?):
4
+ require exists memberships[conversationId, viewer] else MEMBERSHIP_REQUIRED
5
+ return messages[conversationId] after c last 50
6
+
7
+ query summary(conversationId: conversations):
8
+ require exists memberships[conversationId, viewer] else MEMBERSHIP_REQUIRED
9
+ return {
10
+ conversation: conversations[conversationId],
11
+ messageCount: messageCounts[conversationId],
12
+ }
13
+
14
+ mutation send(conversationId: conversations, recipientId: profiles, body: text <=4000):
15
+ require exists memberships[conversationId, viewer] else NOT_MEMBER
16
+ recipient = profiles[recipientId] ! else RECIPIENT_UNKNOWN
17
+ require exists memberships[conversationId, recipient.principal] else RECIPIENT_NOT_MEMBER
18
+ row = insert messages { conversationId, authorId: viewer, body, photo: null, clip: null, at: now }
19
+ for m in memberships[conversationId] first 50 by byConversationPrincipal:
20
+ insert deliveries { recipient: m.principal, conversationId, messageId: row.id, kind: 'message', at: now }
21
+ emit notify { to: recipient.principal, body }
22
+ return { id: row.id }
23
+
24
+ mutation sendPhoto(conversationId: conversations, photo: image <=5000000, viewOnce: bool ?):
25
+ require exists memberships[conversationId, viewer] else NOT_MEMBER
26
+ row = insert messages { conversationId, authorId: viewer, body: null, photo, clip: null, viewOnce, at: now }
27
+ return { id: row.id, photo: row.photo }
28
+
29
+ mutation sendClip(conversationId: conversations, clip: video <=50000000, viewOnce: bool ?):
30
+ require exists memberships[conversationId, viewer] else NOT_MEMBER
31
+ row = insert messages { conversationId, authorId: viewer, body: null, photo: null, clip, viewOnce, at: now }
32
+ return { id: row.id, clip: row.clip, duration: clip.duration }
@@ -0,0 +1,7 @@
1
+ mutation recordNotified(outboxId: id, effect: text <=100, result: json <=1000):
2
+ insert notifications {
3
+ outboxId,
4
+ effect,
5
+ providerId: result.providerId,
6
+ }
7
+ return true
@@ -0,0 +1,4 @@
1
+ mutation createPost(body: text <=500):
2
+ require exists profiles[viewer] else PROFILE_REQUIRED
3
+ row = insert posts { author: viewer, body, at: now }
4
+ return { id: row.id, author: viewer, body, at: row.at }
@@ -0,0 +1,17 @@
1
+ query mine():
2
+ return profiles[viewer]
3
+
4
+ query byHandle(handle: text <=20):
5
+ return profiles[handle]
6
+
7
+ mutation createMyProfile(displayName: text <=50, handle: text <=20, avatarUrl: text <=2048 ?, bio: text <=200, location: text <=50, link: text <=2048 ?):
8
+ existing = profiles[viewer]
9
+ if existing != null:
10
+ return existing
11
+ row = insert profiles { principal: viewer, displayName, handle, avatarUrl, bio, location, link }
12
+ return { id: row.id, principal: viewer, displayName, handle, avatarUrl, bio, location, link }
13
+
14
+ mutation updateMyProfile(displayName: text <=50, handle: text <=20, avatarUrl: text <=2048 ?, bio: text <=200, location: text <=50, link: text <=2048 ?):
15
+ existing = profiles[viewer] ! else PROFILE_REQUIRED
16
+ update profiles[existing] { displayName, handle, avatarUrl, bio, location, link }
17
+ return { id: existing.id, principal: viewer, displayName, handle, avatarUrl, bio, location, link }
@@ -0,0 +1,136 @@
1
+ use identity
2
+
3
+ table profiles:
4
+ principal: principal
5
+ displayName: text 1..50
6
+ handle: text 3..20 format handle
7
+ avatarUrl: text <=2048 format url ?
8
+ bio: text <=200
9
+ location: text <=50
10
+ link: text <=2048 format url ?
11
+ unique byPrincipal: principal
12
+ unique byHandle: handle
13
+ immutable principal
14
+ public 'profiles are public'
15
+ insert <- .principal = viewer
16
+ update <- .principal = viewer
17
+ delete <- deny
18
+
19
+ table conversations:
20
+ title: text <=120
21
+ creatorId: principal
22
+ by byCreator: creatorId
23
+ unique byIdCreator: id, creatorId
24
+ read <- exists memberships[.id, viewer]
25
+ insert <- .creatorId = viewer
26
+ update <- deny
27
+ delete <- deny
28
+ after insert <- exists memberships[.id, .creatorId, 'admin']
29
+
30
+ table memberships:
31
+ conversationId: conversations
32
+ principal: principal
33
+ role: enum('member', 'admin')
34
+ joinedAt: time
35
+ unique byConversationPrincipal: conversationId, principal
36
+ by byConversationPrincipalRole: conversationId, principal, role
37
+ by byPrincipalConversation: principal, conversationId
38
+ immutable conversationId, principal, joinedAt
39
+ read <- exists memberships[.conversationId, viewer]
40
+ insert <- (
41
+ exists memberships[.conversationId, viewer, 'admin']
42
+ or (
43
+ .principal = viewer
44
+ and .role = 'admin'
45
+ and created conversations[.conversationId, viewer]
46
+ )
47
+ )
48
+ update (old, next) <- (
49
+ exists memberships[old.conversationId, viewer, 'admin']
50
+ and old.role = 'member'
51
+ and next.role = 'admin'
52
+ )
53
+ delete <- (
54
+ .principal = viewer
55
+ or exists memberships[.conversationId, viewer, 'admin']
56
+ )
57
+
58
+ table messages:
59
+ conversationId: conversations
60
+ authorId: principal
61
+ body: text <=4000 ?
62
+ photo: image <=5000000 ?
63
+ clip: video <=50000000 ?
64
+ viewOnce: bool ?
65
+ at: time
66
+ by byConvTime: conversationId, at, id
67
+ retain delivered-history
68
+ read <- exists memberships[.conversationId, viewer]
69
+ insert <- (
70
+ .authorId = viewer
71
+ and exists memberships[.conversationId, viewer]
72
+ )
73
+ update <- .authorId = viewer
74
+ delete <- .authorId = viewer
75
+
76
+ -- @ref LLP 1012#3-the-model-on-one-page-and-the-program-shape — delivery metadata stays visible only to its recipient.
77
+ table deliveries:
78
+ recipient: principal
79
+ conversationId: conversations
80
+ messageId: messages
81
+ kind: enum('message', 'edit', 'removed', 'expired')
82
+ at: time
83
+ by byRecipientAt: recipient, at, id
84
+ by byRecipientConversationAt: recipient, conversationId, at, id
85
+ immutable recipient, conversationId, messageId, kind, at
86
+ read <- .recipient = viewer
87
+ insert <- exists memberships[.conversationId, viewer]
88
+ update <- deny
89
+ delete <- deny
90
+
91
+ table posts:
92
+ author: principal
93
+ body: text 1..500
94
+ at: time
95
+ by byTime: at, id
96
+ by byAuthorTime: author, at, id
97
+ immutable author, at
98
+ public 'posts are globally readable product content'
99
+ insert <- .author = viewer
100
+ update <- .author = viewer
101
+ delete <- .author = viewer
102
+ after insert <- exists profiles[.author]
103
+
104
+ table follows:
105
+ follower: principal
106
+ followed: principal
107
+ at: time
108
+ unique byFollowerFollowed: follower, followed
109
+ by byFollower: follower, followed
110
+ by byFollowed: followed, follower
111
+ immutable follower, followed, at
112
+ public 'follows are public product-selection relationships'
113
+ insert <- .follower = viewer
114
+ update <- deny
115
+ delete <- .follower = viewer
116
+
117
+ table notifications:
118
+ outboxId: id
119
+ effect: text <=100
120
+ providerId: text <=200
121
+ unique byOutbox: outboxId
122
+ read <- deny
123
+ insert <- system
124
+ update <- system
125
+ delete <- deny
126
+
127
+ maintain messageCounts = count messages by conversationId:
128
+ read <- exists memberships[.conversationId, viewer]
129
+
130
+ channel typing(conversationId: conversations):
131
+ publish <- exists memberships[conversationId, viewer]
132
+ subscribe <- exists memberships[conversationId, viewer]
133
+ ttl 4s
134
+
135
+ maintain memberCounts = count memberships by conversationId:
136
+ read <- exists memberships[.conversationId, viewer]
@@ -0,0 +1,45 @@
1
+ import { seed } from "snapback2";
2
+
3
+ export default seed({
4
+ personas: ["alice", "bob", "charlie"],
5
+ rows: {
6
+ profiles: [
7
+ { id: "profile-alice", principal: "dev:alice", displayName: "Alice", handle: "alice", avatarUrl: "https://example.com/alice.png", bio: "", location: "", link: null },
8
+ { id: "profile-bob", principal: "dev:bob", displayName: "Bob", handle: "bob", avatarUrl: "https://example.com/bob.png", bio: "", location: "", link: null },
9
+ ],
10
+ conversations: [
11
+ { id: "conversation-1", title: "General", creatorId: "dev:alice" },
12
+ { id: "conversation-2", title: "Side room", creatorId: "dev:alice" },
13
+ ],
14
+ memberships: [
15
+ { id: "membership-1", conversationId: "conversation-1", principal: "dev:alice", role: "admin", joinedAt: 0 },
16
+ { id: "membership-2", conversationId: "conversation-1", principal: "dev:bob", role: "member", joinedAt: 0 },
17
+ { id: "membership-3", conversationId: "conversation-2", principal: "dev:alice", role: "admin", joinedAt: 0 },
18
+ { id: "membership-4", conversationId: "conversation-2", principal: "dev:charlie", role: "member", joinedAt: 0 },
19
+ ],
20
+ messages: [
21
+ // Keep the disjoint room first so derived invalidation probes can commit
22
+ // the recipe's messageId-only edit/unsend mutations outside General.
23
+ { id: "message-2", conversationId: "conversation-2", authorId: "dev:alice", body: "derived test", at: 1 },
24
+ { id: "message-1", conversationId: "conversation-1", authorId: "dev:alice", body: "Welcome", at: 1 },
25
+ ],
26
+ deliveries: [
27
+ { id: "delivery-1-alice", recipient: "dev:alice", conversationId: "conversation-1", messageId: "message-1", kind: "message", at: 1 },
28
+ { id: "delivery-1-bob", recipient: "dev:bob", conversationId: "conversation-1", messageId: "message-1", kind: "message", at: 1 },
29
+ { id: "delivery-2-alice", recipient: "dev:alice", conversationId: "conversation-2", messageId: "message-2", kind: "message", at: 1 },
30
+ { id: "delivery-2-charlie", recipient: "dev:charlie", conversationId: "conversation-2", messageId: "message-2", kind: "message", at: 1 },
31
+ ],
32
+ posts: [
33
+ { id: "post-alice", author: "dev:alice", body: "Hello from Alice", at: 1 },
34
+ { id: "post-bob", author: "dev:bob", body: "Hello from Bob", at: 2 },
35
+ ],
36
+ follows: [
37
+ { id: "follow-bob-alice", follower: "dev:bob", followed: "dev:alice", at: 1 },
38
+ ],
39
+ messageCounts: [],
40
+ memberCounts: [],
41
+ notifications: [
42
+ { id: "notification-1", outboxId: "seed-outbox", effect: "notify", providerId: "seed-provider" },
43
+ ],
44
+ },
45
+ });