snapback4 0.0.15 → 0.1.0
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 +71 -11
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +42 -0
- package/dist/client.js +11 -0
- package/dist/contract.d.ts +41 -0
- package/dist/local.js +12 -0
- package/dist/mock.js +8 -0
- package/dist/react.d.ts +17 -1
- package/dist/react.js +31 -0
- package/dist/replica/interpreter.d.ts +4 -0
- package/dist/replica/interpreter.js +19 -5
- package/dist/replica/store.d.ts +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -112,8 +112,9 @@ mutation edit(messageId: messages, body: text <=4000):
|
|
|
112
112
|
- **Types:** `principal`, `id`, `<table>` (a reference to that table's
|
|
113
113
|
id), `int`, `time` (milliseconds), `bool`, `decimal <scale>`,
|
|
114
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
|
-
|
|
115
|
+
`enum('a', 'b')`, `json <=N`, `bytes <=N`, `[type] <=N` (a list),
|
|
116
|
+
`image <=N [once]` and `video <=N` (an upload the row holds — §4,
|
|
117
|
+
assets); `?` after a type makes the column optional (null when absent).
|
|
117
118
|
- **Indexes:** `by name: cols` orders rows; `unique name: cols` also
|
|
118
119
|
constrains them. A scan needs an index whose leading columns are the
|
|
119
120
|
scan's prefix; a page's order is the index's remaining columns.
|
|
@@ -132,6 +133,12 @@ mutation edit(messageId: messages, body: text <=4000):
|
|
|
132
133
|
- **Sync** (§3) and **retain**: `retain until-revoked` (the default: a
|
|
133
134
|
device drops a group's rows when it leaves the group) or
|
|
134
135
|
`retain delivered-history` (it keeps what it was given).
|
|
136
|
+
- **Expire:** `expire at .expiresAt` (a `time ?` column with an index
|
|
137
|
+
leading with it, `by byExpiresAt: expiresAt`). A row past that time is
|
|
138
|
+
absent to every reader on every host from that moment, before any rule;
|
|
139
|
+
the server sweeps it within seconds and the deletion streams to each
|
|
140
|
+
device holding it. A null time never expires. Disappearing messages
|
|
141
|
+
are `expiresAt: now + ttl if ttl` on insert; nothing else to write.
|
|
135
142
|
|
|
136
143
|
### Maintains
|
|
137
144
|
|
|
@@ -172,6 +179,32 @@ groups by the audience columns.
|
|
|
172
179
|
- **Refusals** a program raises with `require .. else CODE` reach the
|
|
173
180
|
client as `{ code: "CODE", family: "rule", .. }`.
|
|
174
181
|
|
|
182
|
+
### Jobs and schedules
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
job digest(periodStart: time, periodEnd: time):
|
|
186
|
+
chunk p in profiles first 100 by byId:
|
|
187
|
+
... a mutation's body with p bound; reads and writes as the system ...
|
|
188
|
+
schedule daily every 1d at 08:00: digest -- or: every 2m
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A job is the server's own work: a sequence of sweeps, each `chunk x in
|
|
192
|
+
table[prefix] first N by index:` walking one index a page per commit as
|
|
193
|
+
the system principal (every rule passes; validation, constraints,
|
|
194
|
+
aggregates and partitions apply) under the job ceilings. The cursor
|
|
195
|
+
commits with the rows, so a chunk is wholly done or not started and a
|
|
196
|
+
restart resumes at the next one; the index's columns are `id` or
|
|
197
|
+
`immutable`, so nothing moves under the cursor, and rows inserted past
|
|
198
|
+
the key captured when the sweep began are not visited. `break` ends the
|
|
199
|
+
sweep, `return` the job; a refused chunk parks the job with its refusal
|
|
200
|
+
(`snapback4 doctor` lists runs). No client calls a job. A schedule fires
|
|
201
|
+
it once per period boundary of the server's clock (`every 2m`, `every
|
|
202
|
+
1h`, `every 1d at 08:00`), exactly once per boundary across restarts,
|
|
203
|
+
passing `periodStart` and `periodEnd` when the job declares them (and
|
|
204
|
+
nothing else); a schedule first seen fires from its next boundary.
|
|
205
|
+
`snapback4 run <job> '{..}'` runs one here and now. Jobs are not on
|
|
206
|
+
`api.ts`.
|
|
207
|
+
|
|
175
208
|
### The meter
|
|
176
209
|
|
|
177
210
|
Nothing is priced at compile time. As a program runs, the meter counts
|
|
@@ -284,6 +317,31 @@ const viewer = useViewer(); // the principal, or null
|
|
|
284
317
|
`useQuery` observes: it re-renders when a sync or a local prediction
|
|
285
318
|
touches the query's tables. `usePage` follows `next` across pages.
|
|
286
319
|
|
|
320
|
+
### Assets
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
const uploaded = await client.upload(file); // Blob | ArrayBuffer | Uint8Array
|
|
324
|
+
if (uploaded.ok) await client.mutate(api.sendPhoto, { conversationId, photo: { id: uploaded.asset.id } });
|
|
325
|
+
const photo = useAsset(message.photo?.id); // { status: "loading" } | { status: "ready", url, type } | { status: "denied", why }
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
An `image <=N` or `video <=N` column holds an upload: the client sends
|
|
329
|
+
the bytes first (`upload` → `{ id, type, bytes, width, height,
|
|
330
|
+
duration }`; the server sniffs JPEG, PNG, WebP and MP4 and refuses the
|
|
331
|
+
rest with `E_ASSET_TYPE`, and anything over the largest column's bound
|
|
332
|
+
with `E_ASSET_TOO_LARGE`), then places `{ id }` in the mutation; the row
|
|
333
|
+
carries the facts the server established. One asset, one home: your own
|
|
334
|
+
upload, placed once (`E_ASSET_UNKNOWN`, `E_ASSET_PLACED`). Serving is
|
|
335
|
+
the home row's read rule: `client.asset(id)` fetches the bytes under the
|
|
336
|
+
viewer's credentials and returns a blob URL (`useAsset` in React;
|
|
337
|
+
`client.assetSource(id)` gives a URI and headers for native image and
|
|
338
|
+
video views); a row that is deleted, whose column is cleared, that
|
|
339
|
+
expired, or that the viewer may not read answers `E_ASSET_UNKNOWN`. A
|
|
340
|
+
column declared `image <=N once` serves one view per principal: the
|
|
341
|
+
second is `E_ASSET_VIEWED`, and the bytes are never cached anywhere
|
|
342
|
+
(the sender is never counted). Uploads are online only; there is
|
|
343
|
+
nothing to queue.
|
|
344
|
+
|
|
287
345
|
### Sign-in (`snapback4/auth`)
|
|
288
346
|
|
|
289
347
|
`guest(url)`, `signup(url, email, password)`, `login(url, email,
|
|
@@ -332,8 +390,8 @@ flips the link.
|
|
|
332
390
|
diagnostic with its site and rewrite; `--cost` adds each site's
|
|
333
391
|
structural class.
|
|
334
392
|
- **`snapback4 why <E_CODE | op | table>`**, **`data <table>`**,
|
|
335
|
-
**`doctor`**, **`guide [keyword]`** (cards: rules, sync,
|
|
336
|
-
shapes, bounds, types, personas, refusals).
|
|
393
|
+
**`doctor`**, **`guide [keyword]`** (cards: rules, sync, expire, assets,
|
|
394
|
+
jobs, writes, reads, shapes, bounds, types, personas, refusals).
|
|
337
395
|
|
|
338
396
|
### The wire
|
|
339
397
|
|
|
@@ -343,7 +401,9 @@ seq, next }` or `{ denied }`; `POST /m/<op>` with `{ "id", "args",
|
|
|
343
401
|
"newIds" }` answers `{ state: "sent", seq, result }` or `{ state:
|
|
344
402
|
"failed", why }` (a client-minted `id` makes the write apply at most
|
|
345
403
|
once); `GET /sync?from=W` streams the partition; `GET /changes?since=S`
|
|
346
|
-
long-polls for commits; `
|
|
404
|
+
long-polls for commits; `POST /assets` (the file's bytes as the body)
|
|
405
|
+
answers `{ asset }`, `GET /assets/<id>` serves them (404 hidden or gone,
|
|
406
|
+
410 viewed; `Range` works); `GET /schema` is the compiled backend;
|
|
347
407
|
`POST /auth/guest` (`{}`), `POST /auth/signup` and `/auth/login`
|
|
348
408
|
(`{ "email", "password" }`) answer `{ "session": { principal, kind, token,
|
|
349
409
|
expiresAt } }` or `{ "denied": Refusal }`; `POST /auth/logout` with the
|
|
@@ -384,10 +444,10 @@ through `nodeSqliteDriver`.)
|
|
|
384
444
|
|
|
385
445
|
## 8. Not in this release
|
|
386
446
|
|
|
387
|
-
Word search (`tokens()` and word-prefix indexes are parsed, not served
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
(build from source with `cargo build --release -p snapback4`
|
|
392
|
-
`SNAPBACK4_BIN`). If the package cannot express something, the
|
|
447
|
+
Word search (`tokens()` and word-prefix indexes are parsed, not served:
|
|
448
|
+
search over synced rows on the device, or by a prefix index), effects
|
|
449
|
+
(server-side side effects to third parties: write the fact to a table
|
|
450
|
+
and let a job or a reader act on it), platform binaries other than
|
|
451
|
+
darwin-arm64 (build from source with `cargo build --release -p snapback4`
|
|
452
|
+
and set `SNAPBACK4_BIN`). If the package cannot express something, the
|
|
393
453
|
diagnostic names the term; record the gap and move on.
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AssetRead, Uploaded } from "./contract.ts";
|
|
2
|
+
export type Bytes = Blob | ArrayBuffer | Uint8Array;
|
|
3
|
+
export declare function uploadAsset(base: string, headers: Record<string, string>, doFetch: typeof fetch, bytes: Bytes): Promise<Uploaded>;
|
|
4
|
+
/** Where the bytes are and what to send to get them: for hosts whose image
|
|
5
|
+
* and video views take a URI with headers (React Native). */
|
|
6
|
+
export declare function assetSource(base: string, headers: Record<string, string>, id: string): {
|
|
7
|
+
uri: string;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
export declare function fetchAsset(base: string, headers: Record<string, string>, doFetch: typeof fetch, id: string): Promise<AssetRead>;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Assets on the client: `upload` posts the bytes and returns the facts the
|
|
2
|
+
// server established; `asset` fetches the bytes under the viewer's own
|
|
3
|
+
// credentials and hands back a blob URL, so a screen never puts a token in
|
|
4
|
+
// an <img src> and a one-view photo is never durably cached anywhere
|
|
5
|
+
// (LLP 1006 carried into Snapback 4).
|
|
6
|
+
const offline = () => ({ code: "E_OFFLINE", family: "link", message: "the server is unreachable", retryable: true });
|
|
7
|
+
export async function uploadAsset(base, headers, doFetch, bytes) {
|
|
8
|
+
const { "content-type": _json, ...auth } = headers;
|
|
9
|
+
const body = bytes instanceof Uint8Array ? new Blob([bytes]) : bytes;
|
|
10
|
+
try {
|
|
11
|
+
const response = await doFetch(`${base}/assets`, { method: "POST", headers: { ...auth, "content-type": "application/octet-stream" }, body });
|
|
12
|
+
const json = (await response.json());
|
|
13
|
+
if (json.asset)
|
|
14
|
+
return { ok: true, asset: json.asset };
|
|
15
|
+
return { ok: false, why: json.denied ?? { code: "E_STORE", family: "store", message: "the server answered without an asset", retryable: true } };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { ok: false, why: offline() };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Where the bytes are and what to send to get them: for hosts whose image
|
|
22
|
+
* and video views take a URI with headers (React Native). */
|
|
23
|
+
export function assetSource(base, headers, id) {
|
|
24
|
+
const { "content-type": _json, ...auth } = headers;
|
|
25
|
+
return { uri: `${base}/assets/${encodeURIComponent(id)}`, headers: auth };
|
|
26
|
+
}
|
|
27
|
+
export async function fetchAsset(base, headers, doFetch, id) {
|
|
28
|
+
const source = assetSource(base, headers, id);
|
|
29
|
+
try {
|
|
30
|
+
const response = await doFetch(source.uri, { headers: source.headers });
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const json = (await response.json().catch(() => null));
|
|
33
|
+
return { denied: json?.denied ?? { code: response.status === 410 ? "E_ASSET_VIEWED" : "E_ASSET_UNKNOWN", family: "asset", message: response.status === 410 ? "this photo was for one view, and it was viewed" : "no asset is served under that id to this viewer" } };
|
|
34
|
+
}
|
|
35
|
+
const blob = await response.blob();
|
|
36
|
+
const url = URL.createObjectURL(blob);
|
|
37
|
+
return { url, type: response.headers.get("content-type") ?? blob.type, bytes: blob.size, revoke: () => URL.revokeObjectURL(url) };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { denied: offline() };
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// when a commit touches its tables; a write is sent with a client-minted id
|
|
4
4
|
// and applies at most once, and waits as `pending` while the link is down.
|
|
5
5
|
// Every word a screen reads is on the card (contract.ts).
|
|
6
|
+
import { assetSource, fetchAsset, uploadAsset } from "./assets.js";
|
|
6
7
|
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
7
8
|
/** A time-ordered 128-bit id: 48 bits of milliseconds, 80 bits of entropy, 26 base32 digits. */
|
|
8
9
|
export function mintId(now = Date.now()) {
|
|
@@ -254,6 +255,16 @@ export function createClient(options) {
|
|
|
254
255
|
// Durable admission on this device: the write waits for the link.
|
|
255
256
|
return { state: "pending", id };
|
|
256
257
|
},
|
|
258
|
+
async upload(bytes) {
|
|
259
|
+
if (!viewer)
|
|
260
|
+
return { ok: false, why: { code: "E_AUTH", family: "auth", message: "sign in to upload" } };
|
|
261
|
+
const uploaded = await uploadAsset(base, headers, doFetch, bytes);
|
|
262
|
+
if (!uploaded.ok && uploaded.why.code === "E_OFFLINE")
|
|
263
|
+
setLink("offline");
|
|
264
|
+
return uploaded;
|
|
265
|
+
},
|
|
266
|
+
asset: (id) => fetchAsset(base, headers, doFetch, id),
|
|
267
|
+
assetSource: (id) => assetSource(base, headers, id),
|
|
257
268
|
link: () => link,
|
|
258
269
|
onLink(listener) {
|
|
259
270
|
linkListeners.add(listener);
|
package/dist/contract.d.ts
CHANGED
|
@@ -74,11 +74,52 @@ export interface Observed<T> {
|
|
|
74
74
|
subscribe(notify: () => void): () => void;
|
|
75
75
|
close(): void;
|
|
76
76
|
}
|
|
77
|
+
/** An upload the server recorded: the facts it established, and the id a
|
|
78
|
+
* row's `image`/`video` column takes (`{ id }` is enough; the server fills
|
|
79
|
+
* the rest on the row). */
|
|
80
|
+
export interface Asset {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly type: string;
|
|
83
|
+
readonly bytes: number;
|
|
84
|
+
readonly width?: number | null;
|
|
85
|
+
readonly height?: number | null;
|
|
86
|
+
/** Milliseconds, for video. */
|
|
87
|
+
readonly duration?: number | null;
|
|
88
|
+
}
|
|
89
|
+
export type Uploaded = {
|
|
90
|
+
readonly ok: true;
|
|
91
|
+
readonly asset: Asset;
|
|
92
|
+
} | {
|
|
93
|
+
readonly ok: false;
|
|
94
|
+
readonly why: Refusal;
|
|
95
|
+
};
|
|
96
|
+
/** The bytes of an asset, fetched under the viewer's credentials: a blob
|
|
97
|
+
* URL to put in `src`, and `revoke()` when the screen is done with it. A
|
|
98
|
+
* one-view photo comes back `denied` with `E_ASSET_VIEWED` the second time. */
|
|
99
|
+
export type AssetRead = {
|
|
100
|
+
readonly url: string;
|
|
101
|
+
readonly type: string;
|
|
102
|
+
readonly bytes: number;
|
|
103
|
+
readonly revoke: () => void;
|
|
104
|
+
} | {
|
|
105
|
+
readonly denied: Refusal;
|
|
106
|
+
};
|
|
77
107
|
/** The whole client, as a screen sees it. Everything else is `why`. */
|
|
78
108
|
export interface Client {
|
|
79
109
|
query<A, R>(op: Query<A, R>, args: A): Promise<Read<R>>;
|
|
80
110
|
observe<A, R>(op: Query<A, R>, args: A): Observed<R>;
|
|
81
111
|
mutate<A, R>(op: Mutation<A, R>, args: A): Promise<Write>;
|
|
112
|
+
/** Send the bytes of a photo or video; place `asset.id` in a mutation's
|
|
113
|
+
* `image`/`video` argument. Online only: an upload waits for no link. */
|
|
114
|
+
upload(bytes: Blob | ArrayBuffer | Uint8Array): Promise<Uploaded>;
|
|
115
|
+
/** The bytes of an asset a row the viewer may read holds, as a blob URL. */
|
|
116
|
+
asset(id: string): Promise<AssetRead>;
|
|
117
|
+
/** Where the bytes are and the headers that fetch them, for native image
|
|
118
|
+
* and video views that take a URI with headers. */
|
|
119
|
+
assetSource(id: string): {
|
|
120
|
+
uri: string;
|
|
121
|
+
headers: Record<string, string>;
|
|
122
|
+
};
|
|
82
123
|
link(): Link;
|
|
83
124
|
onLink(notify: (link: Link) => void): () => void;
|
|
84
125
|
viewer(): Principal | null;
|
package/dist/local.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// server had at the last sync; a predictable mutation renders its rows as
|
|
6
6
|
// `pending` before the server confirms them.
|
|
7
7
|
import { mintId } from "./client.js";
|
|
8
|
+
import { assetSource, fetchAsset, uploadAsset } from "./assets.js";
|
|
8
9
|
import { Interpreter, Refused } from "./replica/interpreter.js";
|
|
9
10
|
import { Replica } from "./replica/replica.js";
|
|
10
11
|
import { MemoryStore, openIndexedDb } from "./replica/store.js";
|
|
@@ -362,6 +363,17 @@ export async function createLocalClient(options) {
|
|
|
362
363
|
const first = await Promise.race([outcome, new Promise((resolve) => setTimeout(() => resolve({ state: "pending", id }), 4_000))]);
|
|
363
364
|
return first;
|
|
364
365
|
},
|
|
366
|
+
async upload(bytes) {
|
|
367
|
+
// An upload is bytes on the server; there is nothing to queue.
|
|
368
|
+
if (link !== "online")
|
|
369
|
+
return { ok: false, why: { code: "E_OFFLINE", family: "link", message: "the server is unreachable; uploads wait for no link", retryable: true } };
|
|
370
|
+
const uploaded = await uploadAsset(base, headers, doFetch, bytes);
|
|
371
|
+
if (!uploaded.ok && uploaded.why.code === "E_OFFLINE")
|
|
372
|
+
setLink("offline");
|
|
373
|
+
return uploaded;
|
|
374
|
+
},
|
|
375
|
+
asset: (id) => fetchAsset(base, headers, doFetch, id),
|
|
376
|
+
assetSource: (id) => assetSource(base, headers, id),
|
|
365
377
|
link: () => link,
|
|
366
378
|
onLink(listener) {
|
|
367
379
|
linkListeners.add(listener);
|
package/dist/mock.js
CHANGED
|
@@ -103,6 +103,14 @@ export function createMockClient(options) {
|
|
|
103
103
|
setTimeout(() => { settleOne(entry); bump(); }, delay);
|
|
104
104
|
});
|
|
105
105
|
},
|
|
106
|
+
async upload(bytes) {
|
|
107
|
+
const size = bytes instanceof Blob ? bytes.size : bytes.byteLength;
|
|
108
|
+
return { ok: true, asset: { id: mockId(), type: "image/png", bytes: size, width: 1, height: 1 } };
|
|
109
|
+
},
|
|
110
|
+
async asset(id) {
|
|
111
|
+
return { denied: { code: "E_ASSET_UNKNOWN", family: "asset", message: `the mock holds no bytes for ${id}` } };
|
|
112
|
+
},
|
|
113
|
+
assetSource: (id) => ({ uri: `mock://assets/${id}`, headers: {} }),
|
|
106
114
|
link: () => link,
|
|
107
115
|
onLink(notify) { linkListeners.add(notify); return () => linkListeners.delete(notify); },
|
|
108
116
|
viewer: () => options.viewer,
|
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import type { Client, Cursor, Link, Mutation, Principal, Query, Read, Write } from "./contract.ts";
|
|
2
|
+
import type { Client, Cursor, Link, Mutation, Principal, Query, Read, Refusal, Write } from "./contract.ts";
|
|
3
3
|
export declare function SnapbackProvider({ client, children }: {
|
|
4
4
|
client: Client;
|
|
5
5
|
children: ReactNode;
|
|
@@ -23,3 +23,19 @@ export declare function useMutation<A, R>(op: Mutation<A, R>): {
|
|
|
23
23
|
};
|
|
24
24
|
export declare function useLink(): Link;
|
|
25
25
|
export declare function useViewer(): Principal | null;
|
|
26
|
+
export type AssetState = {
|
|
27
|
+
readonly status: "loading";
|
|
28
|
+
} | {
|
|
29
|
+
readonly status: "ready";
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly type: string;
|
|
32
|
+
} | {
|
|
33
|
+
readonly status: "denied";
|
|
34
|
+
readonly why: Refusal;
|
|
35
|
+
};
|
|
36
|
+
/** The bytes of an asset a row the viewer may read holds, fetched under
|
|
37
|
+
* the viewer's credentials as a blob URL for `src`. Revoked when the id
|
|
38
|
+
* changes or the screen unmounts. A one-view photo is fetched once per
|
|
39
|
+
* mount: a screen that shows it again is `denied` with `E_ASSET_VIEWED`,
|
|
40
|
+
* which is the feature. Pass `null` to show nothing. */
|
|
41
|
+
export declare function useAsset(id: string | null | undefined): AssetState;
|
package/dist/react.js
CHANGED
|
@@ -111,3 +111,34 @@ export function useLink() {
|
|
|
111
111
|
export function useViewer() {
|
|
112
112
|
return useClient().viewer();
|
|
113
113
|
}
|
|
114
|
+
/** The bytes of an asset a row the viewer may read holds, fetched under
|
|
115
|
+
* the viewer's credentials as a blob URL for `src`. Revoked when the id
|
|
116
|
+
* changes or the screen unmounts. A one-view photo is fetched once per
|
|
117
|
+
* mount: a screen that shows it again is `denied` with `E_ASSET_VIEWED`,
|
|
118
|
+
* which is the feature. Pass `null` to show nothing. */
|
|
119
|
+
export function useAsset(id) {
|
|
120
|
+
const client = useClient();
|
|
121
|
+
const [state, setState] = useState({ status: "loading" });
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!id)
|
|
124
|
+
return;
|
|
125
|
+
let live = true;
|
|
126
|
+
let read = null;
|
|
127
|
+
setState({ status: "loading" });
|
|
128
|
+
void client.asset(id).then((result) => {
|
|
129
|
+
read = result;
|
|
130
|
+
if (!live) {
|
|
131
|
+
if ("url" in result)
|
|
132
|
+
result.revoke();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setState("url" in result ? { status: "ready", url: result.url, type: result.type } : { status: "denied", why: result.denied });
|
|
136
|
+
});
|
|
137
|
+
return () => {
|
|
138
|
+
live = false;
|
|
139
|
+
if (read && "url" in read)
|
|
140
|
+
read.revoke();
|
|
141
|
+
};
|
|
142
|
+
}, [client, id]);
|
|
143
|
+
return id ? state : { status: "loading" };
|
|
144
|
+
}
|
|
@@ -82,6 +82,10 @@ export declare class Interpreter {
|
|
|
82
82
|
private recurse;
|
|
83
83
|
expr(e: unknown): Promise<unknown>;
|
|
84
84
|
private builtin;
|
|
85
|
+
/** Whether a held row is past its table's `expire` column on this
|
|
86
|
+
* device's clock: absent to every read, as on the server, until the
|
|
87
|
+
* server's sweep deletes it and the deletion arrives in the log. */
|
|
88
|
+
private expired;
|
|
85
89
|
private get;
|
|
86
90
|
private scan;
|
|
87
91
|
private merge;
|
|
@@ -489,6 +489,16 @@ export class Interpreter {
|
|
|
489
489
|
}
|
|
490
490
|
}
|
|
491
491
|
// ----- reads -----
|
|
492
|
+
/** Whether a held row is past its table's `expire` column on this
|
|
493
|
+
* device's clock: absent to every read, as on the server, until the
|
|
494
|
+
* server's sweep deletes it and the deletion arrives in the log. */
|
|
495
|
+
expired(table, row) {
|
|
496
|
+
const column = this.schema.tables[table]?.expire;
|
|
497
|
+
if (!column)
|
|
498
|
+
return false;
|
|
499
|
+
const at = row[column];
|
|
500
|
+
return typeof at === "number" && at <= this.ctx.now;
|
|
501
|
+
}
|
|
492
502
|
async get(read) {
|
|
493
503
|
const key = [];
|
|
494
504
|
for (const k of read.key)
|
|
@@ -506,7 +516,7 @@ export class Interpreter {
|
|
|
506
516
|
const row = await this.tx.lookup(read.table, read.index, key);
|
|
507
517
|
if (!this.charge("examined"))
|
|
508
518
|
return null;
|
|
509
|
-
if (!row)
|
|
519
|
+
if (!row || this.expired(read.table, row))
|
|
510
520
|
return null;
|
|
511
521
|
if (await this.readable(read.table, row))
|
|
512
522
|
return row;
|
|
@@ -569,6 +579,10 @@ export class Interpreter {
|
|
|
569
579
|
for (const { row, key } of fetched) {
|
|
570
580
|
if (!this.charge("examined"))
|
|
571
581
|
break outer;
|
|
582
|
+
cursor = { ...cursor, after: suffixOf(this.schema, table, index, row, prefix.length) };
|
|
583
|
+
// Gone, whatever the rule says; it cost one examined row.
|
|
584
|
+
if (this.expired(table, row))
|
|
585
|
+
continue;
|
|
572
586
|
let allowed;
|
|
573
587
|
if (verdict !== null)
|
|
574
588
|
allowed = verdict;
|
|
@@ -595,7 +609,6 @@ export class Interpreter {
|
|
|
595
609
|
this.capped = mark.capped;
|
|
596
610
|
break outer;
|
|
597
611
|
}
|
|
598
|
-
cursor = { ...cursor, after: suffixOf(this.schema, table, index, row, prefix.length) };
|
|
599
612
|
}
|
|
600
613
|
if (fetched.length < batch)
|
|
601
614
|
break;
|
|
@@ -765,7 +778,8 @@ export class Interpreter {
|
|
|
765
778
|
if (!this.charge("probes"))
|
|
766
779
|
return false;
|
|
767
780
|
this.tables.add(b.table);
|
|
768
|
-
|
|
781
|
+
const found = await this.tx.lookup(b.table, b.index, b.key.map(term));
|
|
782
|
+
return found !== undefined && !this.expired(b.table, found);
|
|
769
783
|
}
|
|
770
784
|
case "Created": {
|
|
771
785
|
const b = body;
|
|
@@ -838,7 +852,7 @@ export class Interpreter {
|
|
|
838
852
|
async update(table, id, patch) {
|
|
839
853
|
this.tables.add(table);
|
|
840
854
|
const old = await this.tx.get(table, id);
|
|
841
|
-
if (!old)
|
|
855
|
+
if (!old || this.expired(table, old))
|
|
842
856
|
throw new Refused({ code: "NOT_FOUND", family: "rule", message: "the row does not exist on this device", rule: "NOT_FOUND" });
|
|
843
857
|
const next = { ...old, ...patch, pending: true };
|
|
844
858
|
await this.permitted(table, "update", old, next);
|
|
@@ -854,7 +868,7 @@ export class Interpreter {
|
|
|
854
868
|
async delete(table, id) {
|
|
855
869
|
this.tables.add(table);
|
|
856
870
|
const old = await this.tx.get(table, id);
|
|
857
|
-
if (!old)
|
|
871
|
+
if (!old || this.expired(table, old))
|
|
858
872
|
throw new Refused({ code: "NOT_FOUND", family: "rule", message: "the row does not exist on this device", rule: "NOT_FOUND" });
|
|
859
873
|
await this.permitted(table, "delete", undefined, old);
|
|
860
874
|
this.written++;
|
package/dist/replica/store.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface TableJson {
|
|
|
25
25
|
} | null;
|
|
26
26
|
} | null;
|
|
27
27
|
leave?: "until-revoked" | "delivered-history";
|
|
28
|
+
/** The time column that ends a row's life; a row past it is absent here too. */
|
|
29
|
+
expire?: string | null;
|
|
28
30
|
}
|
|
29
31
|
export interface Bounds {
|
|
30
32
|
after?: unknown[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapback4",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Snapback 4: the card (contract), the online and local-first clients (IndexedDB, SQLite), React hooks, sign-in, a labelled mock, and the `snapback4` CLI. LLP 3000.",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"README.md"
|
|
43
43
|
],
|
|
44
44
|
"optionalDependencies": {
|
|
45
|
-
"snapback4-darwin-arm64": "0.0
|
|
45
|
+
"snapback4-darwin-arm64": "0.1.0"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "tsc -p tsconfig.build.json",
|