snapback4 0.0.1 → 0.0.3
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 +360 -15
- package/bin/snapback4.js +24 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +90 -0
- package/dist/client.d.ts +18 -0
- package/dist/client.js +262 -0
- package/dist/local.d.ts +34 -0
- package/dist/local.js +358 -0
- package/dist/replica/cursor.d.ts +15 -0
- package/dist/replica/cursor.js +81 -0
- package/dist/replica/index.d.ts +3 -0
- package/dist/replica/index.js +6 -0
- package/dist/replica/interpreter.d.ts +106 -0
- package/dist/replica/interpreter.js +906 -0
- package/dist/replica/key.d.ts +9 -0
- package/dist/replica/key.js +107 -0
- package/dist/replica/replica.d.ts +58 -0
- package/dist/replica/replica.js +147 -0
- package/dist/replica/sqlite.d.ts +29 -0
- package/dist/replica/sqlite.js +151 -0
- package/dist/replica/store.d.ts +91 -0
- package/dist/replica/store.js +359 -0
- package/package.json +29 -6
package/README.md
CHANGED
|
@@ -1,21 +1,366 @@
|
|
|
1
1
|
# snapback4
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
The scale-to-zero backend with a partition on every device. One binary
|
|
4
|
+
compiles a schema written in Quarry and serves it; one client holds the
|
|
5
|
+
viewer's partition on the device and runs the same queries there. This
|
|
6
|
+
package carries both: the `snapback4` CLI (the Rust binary, installed for
|
|
7
|
+
your platform as an optional dependency) and the TypeScript client.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install snapback4 # the client, and the CLI for darwin-arm64
|
|
11
|
+
npx snapback4 init # writes snapback/schema.q
|
|
12
|
+
npx snapback4 check # compiles it; writes snapback/generated/api.ts
|
|
13
|
+
npx snapback4 dev # serves it on 127.0.0.1:4400, re-adopting on every save
|
|
14
|
+
npx snapback4 guide # the grammar on one screen; `guide <keyword>` for a card
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Everything below is the whole published surface. There is no other
|
|
18
|
+
documentation; the diagnostics carry the rest (every refusal names its
|
|
19
|
+
code, its site and a rewrite, and `snapback4 why <CODE>` explains it).
|
|
20
|
+
|
|
21
|
+
## 1. The shape of an app
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
my-app/
|
|
25
|
+
snapback/schema.q the backend: tables, rules, sync, queries, mutations
|
|
26
|
+
snapback/generated/api.ts written by `check`; never edited, never committed
|
|
27
|
+
src/ your client: React on the web, React Native on Expo
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The backend is one file (more `.q` files beside it hold modules; their
|
|
31
|
+
operations are named `<module>.<op>`). The client imports `api` from the
|
|
32
|
+
generated file and talks to the server through one client object. There
|
|
33
|
+
are no resolvers, no migrations to write, no subscriptions to manage: a
|
|
34
|
+
table's `sync` line says which devices hold its rows, and the client's
|
|
35
|
+
reads run over what the device holds.
|
|
36
|
+
|
|
37
|
+
## 2. Quarry
|
|
38
|
+
|
|
39
|
+
Comments start with `--`. Blocks indent with two spaces. A file starts
|
|
40
|
+
with directives, then tables, maintains, queries and mutations.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
use identity -- guests and password accounts (§6)
|
|
44
|
+
use personas alice, bob, carol -- a development cast (§5)
|
|
45
|
+
|
|
46
|
+
table conversations:
|
|
47
|
+
title: text <=120
|
|
48
|
+
creatorId: principal
|
|
49
|
+
read <- exists memberships[.id, viewer]
|
|
50
|
+
insert <- .creatorId = viewer
|
|
51
|
+
update <- deny
|
|
52
|
+
delete <- deny
|
|
53
|
+
after insert <- exists memberships[.id, .creatorId, 'admin']
|
|
54
|
+
sync to memberships[.id]
|
|
55
|
+
|
|
56
|
+
table memberships:
|
|
57
|
+
conversationId: conversations
|
|
58
|
+
principal: principal
|
|
59
|
+
role: enum('member', 'admin')
|
|
60
|
+
joinedAt: time
|
|
61
|
+
unique byConversationPrincipal: conversationId, principal
|
|
62
|
+
by byConversationPrincipalRole: conversationId, principal, role
|
|
63
|
+
by byPrincipalConversation: principal, conversationId, id
|
|
64
|
+
cap 50 by conversationId
|
|
65
|
+
immutable conversationId, principal, joinedAt
|
|
66
|
+
read <- exists memberships[.conversationId, viewer]
|
|
67
|
+
insert <- exists memberships[.conversationId, viewer, 'admin']
|
|
68
|
+
delete <- .principal = viewer or exists memberships[.conversationId, viewer, 'admin']
|
|
69
|
+
sync to memberships[.conversationId]
|
|
70
|
+
|
|
71
|
+
table messages:
|
|
72
|
+
conversationId: conversations
|
|
73
|
+
authorId: principal
|
|
74
|
+
body: text <=4000 ?
|
|
75
|
+
editedAt: time ?
|
|
76
|
+
at: time
|
|
77
|
+
by byConvTime: conversationId, at, id
|
|
78
|
+
immutable conversationId, authorId
|
|
79
|
+
read <- exists memberships[.conversationId, viewer]
|
|
80
|
+
insert <- .authorId = viewer and exists memberships[.conversationId, viewer]
|
|
81
|
+
update <- .authorId = viewer
|
|
82
|
+
delete <- .authorId = viewer
|
|
83
|
+
retain delivered-history
|
|
84
|
+
sync to memberships[.conversationId] last 500 by byConvTime
|
|
85
|
+
|
|
86
|
+
maintain messageCounts: count messages by conversationId
|
|
87
|
+
read <- exists memberships[.conversationId, viewer]
|
|
88
|
+
|
|
89
|
+
query thread(conversationId: conversations, c: cursor ?):
|
|
90
|
+
require exists memberships[conversationId, viewer] else MEMBERSHIP_REQUIRED
|
|
91
|
+
return messages[conversationId] after c last 50 by byConvTime
|
|
92
|
+
|
|
93
|
+
query inbox():
|
|
94
|
+
return memberships[viewer] first 50 by byPrincipalConversation
|
|
95
|
+
{ conversationId, conversation: conversations[.conversationId], count: messageCounts[.conversationId] }
|
|
96
|
+
|
|
97
|
+
mutation send(conversationId: conversations, body: text <=4000):
|
|
98
|
+
require exists memberships[conversationId, viewer] else NOT_MEMBER
|
|
99
|
+
row = insert messages { conversationId, authorId: viewer, body, at: now }
|
|
100
|
+
return { id: row.id }
|
|
101
|
+
|
|
102
|
+
mutation edit(messageId: messages, body: text <=4000):
|
|
103
|
+
msg = messages[messageId] ! else NOT_FOUND
|
|
104
|
+
require msg.authorId = viewer else NOT_AUTHOR
|
|
105
|
+
update messages[msg] { body, editedAt: now }
|
|
106
|
+
return null
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Tables
|
|
110
|
+
|
|
111
|
+
- Every table has an implicit `id` and a unique `byId` index.
|
|
112
|
+
- **Types:** `principal`, `id`, `<table>` (a reference to that table's
|
|
113
|
+
id), `int`, `time` (milliseconds), `bool`, `decimal <scale>`,
|
|
114
|
+
`money <CUR> [scale]`, `text <=N`, `text M..N [format handle|url]`,
|
|
115
|
+
`enum('a', 'b')`, `json <=N`, `bytes <=N`, `[type] <=N` (a list);
|
|
116
|
+
`?` after a type makes the column optional (null when absent).
|
|
117
|
+
- **Indexes:** `by name: cols` orders rows; `unique name: cols` also
|
|
118
|
+
constrains them. A scan needs an index whose leading columns are the
|
|
119
|
+
scan's prefix; a page's order is the index's remaining columns.
|
|
120
|
+
- **Constraints:** `cap N by cols` (at most N rows per group),
|
|
121
|
+
`immutable cols`, `after insert <- exists t[..]` (a row that must
|
|
122
|
+
exist by the end of the mutation that inserted this one).
|
|
123
|
+
- **Rules:** `read`, `insert`, `update`, `delete`, each `<-` one
|
|
124
|
+
expression over the row (`.col`), `viewer`, and literals: `allow`,
|
|
125
|
+
`deny`, `public 'reason'`, `.col = viewer`, `.col != 'x'`,
|
|
126
|
+
`exists t[.a, viewer, 'literal']`, `created t[..]` (insert rules only:
|
|
127
|
+
true when this mutation inserted the probed row), `and`, `or`, `not`.
|
|
128
|
+
`update (old, next) <- old.x = next.x` names both images. An `exists`
|
|
129
|
+
probe needs an index over exactly its key columns; `by index` names
|
|
130
|
+
one when two match.
|
|
131
|
+
- **Sync** (§3) and **retain**: `retain until-revoked` (the default: a
|
|
132
|
+
device drops a group's rows when it leaves the group) or
|
|
133
|
+
`retain delivered-history` (it keeps what it was given).
|
|
134
|
+
|
|
135
|
+
### Maintains
|
|
136
|
+
|
|
137
|
+
`maintain counts: count messages by conversationId` (or `sum messages.x
|
|
138
|
+
by ...`) keeps an aggregate table the engine writes: columns are the `by`
|
|
139
|
+
columns and `value`, the id is the group's canonical key, programs read it
|
|
140
|
+
by group (`counts[conversationId]`, `counts[.conversationId]` in a shape).
|
|
141
|
+
It takes its own `read` rule and inherits the source's `sync` when it
|
|
142
|
+
groups by the audience columns.
|
|
143
|
+
|
|
144
|
+
### Queries and mutations
|
|
145
|
+
|
|
146
|
+
- **Arguments** are typed like columns; `c: cursor ?` is an opaque page
|
|
147
|
+
cursor. An omitted optional argument reads as null.
|
|
148
|
+
- **Reads:** `t[key]` (one row by a unique key, null if absent or
|
|
149
|
+
hidden), `t[key] ! else CODE` (or refuse), `exists t[key]`,
|
|
150
|
+
`t[prefix] first N by index` (ascending), `t[prefix] after c last N by
|
|
151
|
+
index` (descending, from a cursor), `merge t first N by index:` with
|
|
152
|
+
`[lane]` lines (one ordered page over several prefixes), `walk t[seed]
|
|
153
|
+
up .parentId depth 8`.
|
|
154
|
+
- **Shapes:** `{ *, author: users[.authorId], count: counts[.conversationId] }`
|
|
155
|
+
after a page or a row; `.` is the row; `*` spreads its columns; a point
|
|
156
|
+
read inside a shape is null for a hidden row. `shape card = { .. }`
|
|
157
|
+
names one to reuse.
|
|
158
|
+
- **Writes:** `insert t { fields }` (returns the row; the id is minted
|
|
159
|
+
unless given), `insert t { .. } if absent` (null on a unique clash),
|
|
160
|
+
`update t[key] { patch }` (`if .col = expected` makes it conditional;
|
|
161
|
+
`NOT_FOUND` when the row is missing), `upsert t[key] { fields }`,
|
|
162
|
+
`delete t[key]`. A final write is the implicit return; bind it to use
|
|
163
|
+
it (`row = insert ..`). Writes check the rule, unique indexes, caps,
|
|
164
|
+
immutables and text formats; any refusal rolls the whole mutation back.
|
|
165
|
+
- **Statements:** `x = expr`, `require cond else CODE`, `if:` / `else:`,
|
|
166
|
+
`for x in page:`, `return`. Values: `viewer`, `now`, `new` (an id),
|
|
167
|
+
`count(page)`, `sum(page, .x)`, `slice`, `min`, `max`, `a ?? b`.
|
|
168
|
+
`=` and `!=` are total (null equals null); `<` and friends need
|
|
169
|
+
present operands — narrow with `if x != null:` or `x ! else CODE`.
|
|
170
|
+
- **Refusals** a program raises with `require .. else CODE` reach the
|
|
171
|
+
client as `{ code: "CODE", family: "rule", .. }`.
|
|
172
|
+
|
|
173
|
+
### The meter
|
|
174
|
+
|
|
175
|
+
Nothing is priced at compile time. As a program runs, the meter counts
|
|
176
|
+
examined rows, index entries, rule probes, steps, heap bytes, output
|
|
177
|
+
bytes and rows written against ceilings (8,192 rows examined, 8,192
|
|
178
|
+
probes, 1,000,000 steps, 1 MiB output, 1,024 rows written per mutation).
|
|
179
|
+
A query past a ceiling returns what it found with `complete: false`; a
|
|
180
|
+
mutation aborts with `E_BOUND` naming the ceiling and the site.
|
|
181
|
+
`snapback4 check --cost` prints each site's structural class;
|
|
182
|
+
`snapback4 test` prints the numbers on your data.
|
|
183
|
+
|
|
184
|
+
A page whose read rule the prefix does not decide (the rule reads a
|
|
185
|
+
column the scan does not pin) is never `complete`: the device cannot tell
|
|
186
|
+
"no more" from "more you may not see". Scan by an index that leads with
|
|
187
|
+
the rule's columns, or read the page on the device (§4), where the
|
|
188
|
+
partition holds exactly what the viewer may read.
|
|
189
|
+
|
|
190
|
+
## 3. Sync: the viewer's partition is the unit of sync
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
sync public [last N by index] everyone holds the same rows
|
|
194
|
+
sync to .col [last N by index] each row reaches the principal in .col
|
|
195
|
+
sync to t[.a, .b] [last N by index] each row reaches every principal with a
|
|
196
|
+
row in t whose (a, b) match
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The read rule must be exactly the audience — `exists t[.a, .b, viewer]`
|
|
200
|
+
or `.col = viewer` — so a device never holds a row it may not read, and
|
|
201
|
+
never misses one it may. A second disjunct or an extra conjunct is a
|
|
202
|
+
compile error with the rewrite. The target table needs a unique index
|
|
203
|
+
over `(a, b, principal)` and an index leading with `(principal, a, b)`.
|
|
204
|
+
The horizon `last N by index` bounds what a device acquires per group;
|
|
205
|
+
older rows are online-only, and a device's read past them says
|
|
206
|
+
`complete: false`. Joining a group delivers its rows; leaving it delivers
|
|
207
|
+
one scope tombstone, and the table's `retain` says what the device keeps.
|
|
208
|
+
A table without `sync` is online-only: its queries run on the server.
|
|
209
|
+
|
|
210
|
+
## 4. The client
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { createLocalClient } from "snapback4/local"; // the local-first tier
|
|
214
|
+
import { createClient } from "snapback4/client"; // the online tier (no store)
|
|
215
|
+
import { api } from "../snapback/generated/api";
|
|
216
|
+
|
|
217
|
+
const client = await createLocalClient({ url: "http://127.0.0.1:4400", persona: "alice" });
|
|
218
|
+
const thread = await client.query(api.thread, { conversationId });
|
|
219
|
+
const write = await client.mutate(api.send, { conversationId, body: "hi" });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The local-first client opens the device's replica (IndexedDB on the web,
|
|
223
|
+
memory with `store: "memory"`, SQLite on Expo — §7), syncs the viewer's
|
|
224
|
+
partition, runs every query locally over it, queues every mutation
|
|
225
|
+
durably and predicts its rows, and follows the server's stream for the
|
|
226
|
+
rest. Its options: `url`; one of `persona` (development), `session` (from
|
|
227
|
+
`snapback4/auth`, kept and restored by the client), or `guest: true`
|
|
228
|
+
(mint one when none is kept); `store`; `sessionStore`; `fetch`;
|
|
229
|
+
`waitSeconds`. It has `query`, `observe`, `mutate`, `link()`, `onLink`,
|
|
230
|
+
`viewer()`, `session()`, `signOut()`, `sync()`, `replica`, `close()`. The
|
|
231
|
+
online client takes `url` with `persona`, `session` or `token`, and has
|
|
232
|
+
the same card without a store.
|
|
233
|
+
|
|
234
|
+
### The card
|
|
235
|
+
|
|
236
|
+
Every word a screen reads is on the card (`snapback4/contract`):
|
|
10
237
|
|
|
11
238
|
```ts
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
239
|
+
type Read<T> =
|
|
240
|
+
| { data: T; complete: boolean; fresh: boolean; since?: number; next?: Cursor | null }
|
|
241
|
+
| { loading: true }
|
|
242
|
+
| { denied: Refusal };
|
|
243
|
+
type Write = { state: "pending"; id } | { state: "sent"; id; seq } | { state: "failed"; id; why: Refusal };
|
|
244
|
+
type Refusal = { code: string; family: "input" | "schema" | "auth" | "rule" | "constraint" | "bound" | "op" | "link" | "generation"; message: string; site?: string; rewrite?: string; guide?: string; retryable?: boolean };
|
|
15
245
|
```
|
|
16
246
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
247
|
+
`complete` says the page is whole; `fresh` says the device has everything
|
|
248
|
+
the server had at the last sync (`since` is the last sync's time when it
|
|
249
|
+
does not); `next` is the cursor for the next page (pass it as the query's
|
|
250
|
+
`cursor ?` argument). A row this device wrote and the server has not yet
|
|
251
|
+
confirmed carries `pending: true`. A write is `pending` while the link is
|
|
252
|
+
down (it lands when it returns), `sent` with the server's sequence, or
|
|
253
|
+
`failed` with the refusal and its predicted rows withdrawn. `isReady`,
|
|
254
|
+
`isLoading`, `isDenied` narrow a `Read`.
|
|
255
|
+
|
|
256
|
+
### React
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
import { SnapbackProvider, useQuery, usePage, useMutation, useLink, useViewer } from "snapback4/react";
|
|
260
|
+
|
|
261
|
+
<SnapbackProvider client={client}>...</SnapbackProvider>
|
|
262
|
+
const inbox = useQuery(api.inbox, {}); // Read<Rows["memberships"][]>
|
|
263
|
+
const { items, hasMore, loadMore, read } = usePage(api.thread, { conversationId });
|
|
264
|
+
const send = useMutation(api.send); // send.run(args) → Write; send.inFlight
|
|
265
|
+
const link = useLink(); // "online" | "offline"
|
|
266
|
+
const viewer = useViewer(); // the principal, or null
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`useQuery` observes: it re-renders when a sync or a local prediction
|
|
270
|
+
touches the query's tables. `usePage` follows `next` across pages.
|
|
271
|
+
|
|
272
|
+
### Sign-in (`snapback4/auth`)
|
|
273
|
+
|
|
274
|
+
`guest(url)`, `signup(url, email, password)`, `login(url, email,
|
|
275
|
+
password)` return `{ ok: true, session }` or `{ ok: false, why }`;
|
|
276
|
+
`logout(url, token)` ends one. A session is `{ principal, kind, token,
|
|
277
|
+
expiresAt }`; pass it to `createLocalClient({ url, session })` once and
|
|
278
|
+
the client keeps it (`localStorage` on the web, memory otherwise, or the
|
|
279
|
+
`sessionStore` you pass — three methods, `expo-secure-store` fits). The
|
|
280
|
+
schema opts in with `use identity` (guests and password accounts),
|
|
281
|
+
`use identity guests` or `use identity password`. `snapback4 dev` also
|
|
282
|
+
admits personas; `snapback4 serve` (a release) admits tokens only.
|
|
283
|
+
|
|
284
|
+
### The mock (`snapback4/mock`)
|
|
285
|
+
|
|
286
|
+
`createMockClient({ viewer, tables, ops })` is a client with the same
|
|
287
|
+
card over in-memory tables, for component tests; `setLink("offline")`
|
|
288
|
+
flips the link.
|
|
289
|
+
|
|
290
|
+
## 5. Development
|
|
291
|
+
|
|
292
|
+
- **`snapback4 dev [--port N] [--no-watch]`** serves `snapback/*.q` on
|
|
293
|
+
`127.0.0.1:4400`, recompiling and re-adopting on every save. Personas
|
|
294
|
+
(`use personas ..`) sign requests from the same machine with the
|
|
295
|
+
`x-snapback-persona: alice` header (the client's `persona: "alice"`),
|
|
296
|
+
acting as `dev:alice`.
|
|
297
|
+
- **`POST /seed`** (dev only, same machine) takes `[{ "table": "t",
|
|
298
|
+
"row": { "id": "..", .. } }, ..]`, writes the rows as the system
|
|
299
|
+
principal in one commit (rules bypassed; validation, constraints,
|
|
300
|
+
aggregates and sync apply) and answers `{ "seeded": N }`.
|
|
301
|
+
- **`snapback4 run <op> '<json args>' --as alice [--json]`** runs one
|
|
302
|
+
operation against the local store and prints the result and the meter.
|
|
303
|
+
- **`snapback4 test [--fixture dir] [--as persona]`** loads a fixture
|
|
304
|
+
(one `<table>.jsonl` per table), runs every query, and prints the
|
|
305
|
+
meters beside the ceilings.
|
|
306
|
+
- **`snapback4 check [--cost] [--json]`** compiles, writes
|
|
307
|
+
`snapback/generated/api.ts` (`Rows`, `Ops`, `api`), and prints every
|
|
308
|
+
diagnostic with its site and rewrite; `--cost` adds each site's
|
|
309
|
+
structural class.
|
|
310
|
+
- **`snapback4 why <E_CODE | op | table>`**, **`data <table>`**,
|
|
311
|
+
**`doctor`**, **`guide [keyword]`** (cards: rules, sync, writes, reads,
|
|
312
|
+
shapes, bounds, types, personas, refusals).
|
|
313
|
+
|
|
314
|
+
### The wire
|
|
315
|
+
|
|
316
|
+
The client is the intended surface; the wire is plain JSON for anything
|
|
317
|
+
else. `POST /q/<op>` with `{ "args": {..} }` answers `{ data, complete,
|
|
318
|
+
seq, next }` or `{ denied }`; `POST /m/<op>` with `{ "id", "args",
|
|
319
|
+
"newIds" }` answers `{ state: "sent", seq, result }` or `{ state:
|
|
320
|
+
"failed", why }` (a client-minted `id` makes the write apply at most
|
|
321
|
+
once); `GET /sync?from=W` streams the partition; `GET /changes?since=S`
|
|
322
|
+
long-polls for commits; `GET /schema` is the compiled backend;
|
|
323
|
+
`POST /auth/guest|signup|login|logout`. Authorization: `x-snapback-persona`
|
|
324
|
+
(dev, same machine) or `Authorization: Bearer <token>`.
|
|
325
|
+
|
|
326
|
+
## 6. Release
|
|
327
|
+
|
|
328
|
+
- **`snapback4 deploy <dir> [--allow-destructive]`** compiles the project,
|
|
329
|
+
classifies the change against the current release (additive, a rule
|
|
330
|
+
change, narrowing, destructive) and refuses a destructive one without
|
|
331
|
+
the flag, writes the release under `<dir>/releases/N`, installs the
|
|
332
|
+
schema into the directory's one store, and points `current` at it.
|
|
333
|
+
Every device re-derives its partition on the next sync; a write queued
|
|
334
|
+
under the old schema that no longer fits is refused, typed.
|
|
335
|
+
- **`snapback4 rollback <dir>`** moves `current` back.
|
|
336
|
+
- **`snapback4 serve <dir> [--port N] [--bind H]`** runs the current
|
|
337
|
+
release on the shared store, tokens only. TLS is a proxy's job.
|
|
338
|
+
|
|
339
|
+
## 7. Expo
|
|
340
|
+
|
|
341
|
+
The same client runs on React Native with SQLite as the store:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import * as SQLite from "expo-sqlite";
|
|
345
|
+
import { createLocalClient } from "snapback4/local";
|
|
346
|
+
import { expoSqliteDriver, openSqlite } from "snapback4/replica";
|
|
347
|
+
|
|
348
|
+
const client = await createLocalClient({
|
|
349
|
+
url, guest: true,
|
|
350
|
+
store: { open: async (name, schema) => openSqlite(expoSqliteDriver(await SQLite.openDatabaseAsync(`${name}.db`)), schema) },
|
|
351
|
+
sessionStore: { get: SecureStore.getItemAsync, set: SecureStore.setItemAsync, delete: SecureStore.deleteItemAsync },
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`snapback4/react` works unchanged. (`node:sqlite` fits the same driver
|
|
356
|
+
through `nodeSqliteDriver`.)
|
|
357
|
+
|
|
358
|
+
## 8. Not in this release
|
|
359
|
+
|
|
360
|
+
Word search (`tokens()` and word-prefix indexes are parsed, not served),
|
|
361
|
+
scheduled jobs, effects (server-side side effects to third parties),
|
|
362
|
+
assets (`image`/`video` columns type-check and store metadata; there is
|
|
363
|
+
no upload or serving path), platform binaries other than darwin-arm64
|
|
364
|
+
(build from source with `cargo build --release -p snapback4` and set
|
|
365
|
+
`SNAPBACK4_BIN`). If the package cannot express something, the
|
|
366
|
+
diagnostic names the term; record the gap and move on.
|
package/bin/snapback4.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx snapback4 <verb>`: the Rust binary, found in the platform package
|
|
3
|
+
// installed beside this one, or named by SNAPBACK4_BIN, or built in this
|
|
4
|
+
// repository. The JavaScript here only finds and runs it.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const platform = `snapback4-${process.platform}-${process.arch}`;
|
|
14
|
+
const candidates = [];
|
|
15
|
+
if (process.env.SNAPBACK4_BIN) candidates.push(resolve(process.env.SNAPBACK4_BIN));
|
|
16
|
+
try { candidates.push(join(dirname(require.resolve(`${platform}/package.json`)), "snapback4")); } catch { /* not installed for this platform */ }
|
|
17
|
+
candidates.push(resolve(here, "../../../target/release/snapback4"), resolve(here, "../../../target/debug/snapback4"));
|
|
18
|
+
const binary = candidates.find((path) => existsSync(path));
|
|
19
|
+
if (!binary) {
|
|
20
|
+
console.error(`snapback4: no binary for ${process.platform}-${process.arch}. Packages exist for darwin-arm64; elsewhere, build with \`cargo build --release -p snapback4\` and set SNAPBACK4_BIN.`);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
24
|
+
process.exit(result.status ?? 1);
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Refusal } from "./contract.ts";
|
|
2
|
+
export interface Session {
|
|
3
|
+
readonly principal: string;
|
|
4
|
+
readonly kind: "guest" | "user";
|
|
5
|
+
readonly token: string;
|
|
6
|
+
readonly expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
export type SignIn = {
|
|
9
|
+
readonly ok: true;
|
|
10
|
+
readonly session: Session;
|
|
11
|
+
} | {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly why: Refusal;
|
|
14
|
+
};
|
|
15
|
+
/** A guest principal, when the schema says `use identity guests`. */
|
|
16
|
+
export declare function guest(url: string, doFetch?: typeof fetch): Promise<SignIn>;
|
|
17
|
+
/** A new password account, when the schema says `use identity password`. */
|
|
18
|
+
export declare function signup(url: string, email: string, password: string, doFetch?: typeof fetch): Promise<SignIn>;
|
|
19
|
+
export declare function login(url: string, email: string, password: string, doFetch?: typeof fetch): Promise<SignIn>;
|
|
20
|
+
/** Where a device keeps its session between starts: `localStorage` on the
|
|
21
|
+
* web, memory elsewhere, or what the app passes (`expo-secure-store` fits
|
|
22
|
+
* behind three one-line methods). The client restores the session itself:
|
|
23
|
+
* an app calls `createLocalClient({ url })` on a cold start and gets the
|
|
24
|
+
* same principal back. */
|
|
25
|
+
export interface SessionStore {
|
|
26
|
+
get(key: string): Promise<string | null>;
|
|
27
|
+
set(key: string, value: string): Promise<void>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare const defaultSessionStore: SessionStore;
|
|
31
|
+
/** A session store that forgets on exit, for tests and one-off scripts. */
|
|
32
|
+
export declare function memorySessionStore(): SessionStore;
|
|
33
|
+
export declare const sessionKey: (url: string) => string;
|
|
34
|
+
/** The session kept for a server, if it has not expired. */
|
|
35
|
+
export declare function restoreSession(url: string, store?: SessionStore): Promise<Session | null>;
|
|
36
|
+
export declare function keepSession(url: string, session: Session, store?: SessionStore): Promise<void>;
|
|
37
|
+
export declare function forgetSession(url: string, store?: SessionStore): Promise<void>;
|
|
38
|
+
/** End a session on the server; the token stops resolving. */
|
|
39
|
+
export declare function logout(url: string, token: string, doFetch?: typeof fetch): Promise<void>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Sign-in (H0): a guest session, or a password account. Each returns a
|
|
2
|
+
// bearer token the client sends as `authorization`; the token is the
|
|
3
|
+
// device's to keep (in memory, or wherever the app keeps secrets). A
|
|
4
|
+
// deployed server refuses development personas, so this is the only door.
|
|
5
|
+
async function post(url, path, body, doFetch) {
|
|
6
|
+
try {
|
|
7
|
+
const response = await doFetch(`${url.replace(/\/$/, "")}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
8
|
+
const json = (await response.json());
|
|
9
|
+
if (json.session)
|
|
10
|
+
return { ok: true, session: json.session };
|
|
11
|
+
return { ok: false, why: json.denied ?? { code: "E_AUTH", family: "auth", message: "sign-in was refused" } };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { ok: false, why: { code: "E_OFFLINE", family: "link", message: "the server is unreachable", retryable: true } };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A guest principal, when the schema says `use identity guests`. */
|
|
18
|
+
export function guest(url, doFetch = globalThis.fetch.bind(globalThis)) {
|
|
19
|
+
return post(url, "/auth/guest", {}, doFetch);
|
|
20
|
+
}
|
|
21
|
+
/** A new password account, when the schema says `use identity password`. */
|
|
22
|
+
export function signup(url, email, password, doFetch = globalThis.fetch.bind(globalThis)) {
|
|
23
|
+
return post(url, "/auth/signup", { email, password }, doFetch);
|
|
24
|
+
}
|
|
25
|
+
export function login(url, email, password, doFetch = globalThis.fetch.bind(globalThis)) {
|
|
26
|
+
return post(url, "/auth/login", { email, password }, doFetch);
|
|
27
|
+
}
|
|
28
|
+
const kept = new Map();
|
|
29
|
+
export const defaultSessionStore = {
|
|
30
|
+
async get(key) {
|
|
31
|
+
try {
|
|
32
|
+
return globalThis.localStorage?.getItem(key) ?? kept.get(key) ?? null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return kept.get(key) ?? null;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
async set(key, value) {
|
|
39
|
+
kept.set(key, value);
|
|
40
|
+
try {
|
|
41
|
+
globalThis.localStorage?.setItem(key, value);
|
|
42
|
+
}
|
|
43
|
+
catch { /* memory keeps it for this run */ }
|
|
44
|
+
},
|
|
45
|
+
async delete(key) {
|
|
46
|
+
kept.delete(key);
|
|
47
|
+
try {
|
|
48
|
+
globalThis.localStorage?.removeItem(key);
|
|
49
|
+
}
|
|
50
|
+
catch { /* nothing to forget there */ }
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
/** A session store that forgets on exit, for tests and one-off scripts. */
|
|
54
|
+
export function memorySessionStore() {
|
|
55
|
+
const m = new Map();
|
|
56
|
+
return { async get(k) { return m.get(k) ?? null; }, async set(k, v) { m.set(k, v); }, async delete(k) { m.delete(k); } };
|
|
57
|
+
}
|
|
58
|
+
export const sessionKey = (url) => `snapback4:${url.replace(/\/$/, "")}:session`;
|
|
59
|
+
/** The session kept for a server, if it has not expired. */
|
|
60
|
+
export async function restoreSession(url, store = defaultSessionStore) {
|
|
61
|
+
const raw = await store.get(sessionKey(url));
|
|
62
|
+
if (!raw)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const session = JSON.parse(raw);
|
|
66
|
+
if (typeof session.token !== "string" || typeof session.principal !== "string")
|
|
67
|
+
return null;
|
|
68
|
+
if (typeof session.expiresAt === "number" && session.expiresAt <= Date.now())
|
|
69
|
+
return null;
|
|
70
|
+
return session;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function keepSession(url, session, store = defaultSessionStore) {
|
|
77
|
+
await store.set(sessionKey(url), JSON.stringify(session));
|
|
78
|
+
}
|
|
79
|
+
export async function forgetSession(url, store = defaultSessionStore) {
|
|
80
|
+
await store.delete(sessionKey(url));
|
|
81
|
+
}
|
|
82
|
+
/** End a session on the server; the token stops resolving. */
|
|
83
|
+
export async function logout(url, token, doFetch = globalThis.fetch.bind(globalThis)) {
|
|
84
|
+
try {
|
|
85
|
+
await doFetch(`${url.replace(/\/$/, "")}/auth/logout`, { method: "POST", headers: { authorization: `Bearer ${token}` } });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Offline: the token expires on its own.
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Client } from "./contract.ts";
|
|
2
|
+
import type { Session } from "./auth.ts";
|
|
3
|
+
export interface ClientOptions {
|
|
4
|
+
/** The server, e.g. `http://127.0.0.1:4400`. */
|
|
5
|
+
readonly url: string;
|
|
6
|
+
/** A development persona (loopback only): `alice` acts as `dev:alice`. */
|
|
7
|
+
readonly persona?: string;
|
|
8
|
+
/** A session from `guest()`, `login()` or `signup()` (deployed servers). */
|
|
9
|
+
readonly session?: Session;
|
|
10
|
+
/** A bare bearer token, when the app keeps sessions itself. */
|
|
11
|
+
readonly token?: string;
|
|
12
|
+
readonly fetch?: typeof fetch;
|
|
13
|
+
/** How long a change poll waits at the server, in seconds. */
|
|
14
|
+
readonly waitSeconds?: number;
|
|
15
|
+
}
|
|
16
|
+
/** A time-ordered 128-bit id: 48 bits of milliseconds, 80 bits of entropy, 26 base32 digits. */
|
|
17
|
+
export declare function mintId(now?: number): string;
|
|
18
|
+
export declare function createClient(options: ClientOptions): Client;
|