snapback4 0.0.16 → 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 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
- `?` after a type makes the column optional (null when absent).
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, writes, reads,
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; `GET /schema` is the compiled backend;
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
- scheduled jobs, effects (server-side side effects to third parties),
389
- assets (`image`/`video` columns type-check and store metadata; there is
390
- no upload or serving path), platform binaries other than darwin-arm64
391
- (build from source with `cargo build --release -p snapback4` and set
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.
@@ -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);
@@ -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
- return (await this.tx.lookup(b.table, b.index, b.key.map(term))) !== undefined;
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++;
@@ -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.16",
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.16"
45
+ "snapback4-darwin-arm64": "0.1.0"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc -p tsconfig.build.json",