snapback4 0.0.1 → 0.0.2

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 CHANGED
@@ -1,21 +1,366 @@
1
1
  # snapback4
2
2
 
3
- The client for Snapback 4, one of two parallel candidates for the successor
4
- to Snapback 2 (see LLP 3000 in the repository). Version 0.0.x is week one:
5
- this package carries the **contract** every screen is written against, a
6
- **labelled mock client** so an application can be written before the
7
- mechanism exists, and the **React hooks** over the contract. Nothing here
8
- talks to a server yet; the replica, the outbox and the interpreter arrive in
9
- weeks two and three.
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
- import { isReady, type Read, type Write } from "snapback4/contract";
13
- import { createMockClient, page } from "snapback4/mock";
14
- import { SnapbackProvider, useQuery, useMutation } from "snapback4/react";
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
- The contract fits on an index card. A read is `{ data, complete, fresh,
18
- since?, next? } | { loading } | { denied }`; a row this device changed and
19
- the server has not confirmed carries `pending: true`; a write is `pending |
20
- sent | failed(why)`; the app's link is `online | offline | signed-out`.
21
- Everything else is the runtime's business.
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.
@@ -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
+ }
@@ -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;