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.
- package/README.md +127 -0
- package/bin/snapback2.mjs +36 -0
- package/dist/api.d.ts +20 -0
- package/dist/assets.d.ts +34 -0
- package/dist/client-assets.d.ts +26 -0
- package/dist/client-offline.d.ts +94 -0
- package/dist/client-outbox.d.ts +58 -0
- package/dist/client-upload.d.ts +41 -0
- package/dist/client-wire.d.ts +41 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.mjs +5 -0
- package/dist/client.mjs.map +7 -0
- package/dist/compiler-core.mjs +60 -0
- package/dist/compiler-core.mjs.map +7 -0
- package/dist/compiler-lib.d.ts +71 -0
- package/dist/compiler.mjs +5 -0
- package/dist/compiler.mjs.map +7 -0
- package/dist/drain.d.ts +33 -0
- package/dist/expo/files.d.ts +3 -0
- package/dist/expo/index.d.ts +16 -0
- package/dist/expo/token-store.d.ts +7 -0
- package/dist/expo/witness.d.ts +28 -0
- package/dist/expo/witness.mjs +2 -0
- package/dist/expo/witness.mjs.map +7 -0
- package/dist/expo.d.ts +4 -0
- package/dist/expo.mjs +9 -0
- package/dist/expo.mjs.map +7 -0
- package/dist/guide/auth.md +89 -0
- package/dist/guide/effects.md +52 -0
- package/dist/guide/families.json +446 -0
- package/dist/guide/grammar.md +52 -0
- package/dist/guide/live-query.md +27 -0
- package/dist/guide/offline.md +75 -0
- package/dist/guide/personas.md +15 -0
- package/dist/guide/quarry.md +297 -0
- package/dist/guide/testing.md +57 -0
- package/dist/index.d.ts +362 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +7 -0
- package/dist/offline-protocol.d.ts +180 -0
- package/dist/offline-schema.d.ts +1 -0
- package/dist/offline.d.ts +177 -0
- package/dist/react-core.d.ts +91 -0
- package/dist/react-core.mjs +2 -0
- package/dist/react-core.mjs.map +7 -0
- package/dist/react-native/components.d.ts +23 -0
- package/dist/react-native/files.d.ts +43 -0
- package/dist/react-native/source.d.ts +16 -0
- package/dist/react-native.d.ts +4 -0
- package/dist/react-native.mjs +2 -0
- package/dist/react-native.mjs.map +7 -0
- package/dist/react.d.ts +19 -0
- package/dist/react.mjs +2 -0
- package/dist/react.mjs.map +7 -0
- package/dist/sqlite-test.mjs +1720 -0
- package/dist/sqlite-test.mjs.map +7 -0
- package/dist/sse.d.ts +44 -0
- package/dist/store/byte-cache.d.ts +162 -0
- package/dist/store/canonical.d.ts +2 -0
- package/dist/store/indexeddb.d.ts +23 -0
- package/dist/store/locks.d.ts +11 -0
- package/dist/store/outbox.d.ts +113 -0
- package/dist/store/overlay-retirement.d.ts +17 -0
- package/dist/store/projection.d.ts +11 -0
- package/dist/store/range-store.d.ts +388 -0
- package/dist/store/sqlite-driver.conformance.d.ts +7 -0
- package/dist/store/sqlite-driver.d.ts +16 -0
- package/dist/store/sqlite-expo.d.ts +3 -0
- package/dist/store/sqlite.d.ts +31 -0
- package/dist/templates/chat/expo/App.tsx +29 -0
- package/dist/templates/chat/expo/app.json +11 -0
- package/dist/templates/chat/expo/index.js +3 -0
- package/dist/templates/chat/expo/shared/log.js +92 -0
- package/dist/templates/chat/expo/shared/log.ts +130 -0
- package/dist/templates/chat/expo/shared/offline.js +8 -0
- package/dist/templates/chat/expo/shared/offline.ts +9 -0
- package/dist/templates/chat/expo/src/Chat.tsx +23 -0
- package/dist/templates/chat/expo/src/Composer.tsx +23 -0
- package/dist/templates/chat/expo/src/MediaView.tsx +29 -0
- package/dist/templates/chat/expo/src/Witness.tsx +131 -0
- package/dist/templates/chat/expo/src/screens/Inbox.tsx +9 -0
- package/dist/templates/chat/expo/src/screens/Search.tsx +10 -0
- package/dist/templates/chat/expo/src/screens/Thread.tsx +22 -0
- package/dist/templates/chat/expo/src/witness-state.ts +68 -0
- package/dist/templates/chat/expo/tsconfig.json +5 -0
- package/dist/templates/chat/react/index.html +5 -0
- package/dist/templates/chat/react/shared/log.js +92 -0
- package/dist/templates/chat/react/shared/log.ts +130 -0
- package/dist/templates/chat/react/shared/offline.js +8 -0
- package/dist/templates/chat/react/shared/offline.ts +9 -0
- package/dist/templates/chat/react/src/App.tsx +36 -0
- package/dist/templates/chat/react/src/Composer.tsx +45 -0
- package/dist/templates/chat/react/src/Inbox.tsx +24 -0
- package/dist/templates/chat/react/src/Search.tsx +26 -0
- package/dist/templates/chat/react/src/Thread.tsx +126 -0
- package/dist/templates/chat/react/src/env.d.ts +1 -0
- package/dist/templates/chat/react/src/main.tsx +20 -0
- package/dist/templates/chat/shared/log.js +92 -0
- package/dist/templates/chat/shared/log.ts +130 -0
- package/dist/templates/chat/shared/offline.js +8 -0
- package/dist/templates/chat/shared/offline.ts +9 -0
- package/dist/templates/chat/snapback/deliveries.q +33 -0
- package/dist/templates/chat/snapback/delivery.ts +30 -0
- package/dist/templates/chat/snapback/feeds.q +14 -0
- package/dist/templates/chat/snapback/follows.q +11 -0
- package/dist/templates/chat/snapback/groups.q +40 -0
- package/dist/templates/chat/snapback/messages.q +32 -0
- package/dist/templates/chat/snapback/notifications.q +7 -0
- package/dist/templates/chat/snapback/posts.q +4 -0
- package/dist/templates/chat/snapback/profiles.q +17 -0
- package/dist/templates/chat/snapback/schema.q +136 -0
- package/dist/templates/chat/snapback/seed.ts +45 -0
- package/dist/templates/chat/snapback/tests/chat.test.ts +759 -0
- package/dist/templates/react/index.html +5 -0
- package/dist/templates/react/src/App.tsx +5 -0
- package/dist/templates/react/src/main.tsx +18 -0
- package/dist/templates/todos/snapback/schema.q +11 -0
- package/dist/templates/todos/snapback/seed.ts +10 -0
- package/dist/templates/todos/snapback/tests/todos.test.ts +13 -0
- package/dist/templates/todos/snapback/todos.q +6 -0
- package/dist/test-runner.mjs +8541 -0
- package/dist/test-runner.mjs.map +7 -0
- package/dist/test.d.ts +28 -0
- package/dist/test.mjs +8541 -0
- package/dist/test.mjs.map +7 -0
- package/dist/token-store.d.ts +3 -0
- package/dist/twin-hydrate.d.ts +43 -0
- package/dist/twin.d.ts +70 -0
- package/dist/types.d.ts +410 -0
- package/dist/witness-test.mjs +2 -0
- package/dist/witness-test.mjs.map +7 -0
- 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,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
|
+
});
|