snapback2 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/bin/snapback2.mjs +36 -0
- package/dist/api.d.ts +20 -0
- package/dist/assets.d.ts +34 -0
- package/dist/client-assets.d.ts +26 -0
- package/dist/client-offline.d.ts +94 -0
- package/dist/client-outbox.d.ts +58 -0
- package/dist/client-upload.d.ts +41 -0
- package/dist/client-wire.d.ts +41 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.mjs +5 -0
- package/dist/client.mjs.map +7 -0
- package/dist/compiler-core.mjs +60 -0
- package/dist/compiler-core.mjs.map +7 -0
- package/dist/compiler-lib.d.ts +71 -0
- package/dist/compiler.mjs +5 -0
- package/dist/compiler.mjs.map +7 -0
- package/dist/drain.d.ts +33 -0
- package/dist/expo/files.d.ts +3 -0
- package/dist/expo/index.d.ts +16 -0
- package/dist/expo/token-store.d.ts +7 -0
- package/dist/expo/witness.d.ts +28 -0
- package/dist/expo/witness.mjs +2 -0
- package/dist/expo/witness.mjs.map +7 -0
- package/dist/expo.d.ts +4 -0
- package/dist/expo.mjs +9 -0
- package/dist/expo.mjs.map +7 -0
- package/dist/guide/auth.md +89 -0
- package/dist/guide/effects.md +52 -0
- package/dist/guide/families.json +446 -0
- package/dist/guide/grammar.md +52 -0
- package/dist/guide/live-query.md +27 -0
- package/dist/guide/offline.md +75 -0
- package/dist/guide/personas.md +15 -0
- package/dist/guide/quarry.md +297 -0
- package/dist/guide/testing.md +57 -0
- package/dist/index.d.ts +362 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +7 -0
- package/dist/offline-protocol.d.ts +180 -0
- package/dist/offline-schema.d.ts +1 -0
- package/dist/offline.d.ts +177 -0
- package/dist/react-core.d.ts +91 -0
- package/dist/react-core.mjs +2 -0
- package/dist/react-core.mjs.map +7 -0
- package/dist/react-native/components.d.ts +23 -0
- package/dist/react-native/files.d.ts +43 -0
- package/dist/react-native/source.d.ts +16 -0
- package/dist/react-native.d.ts +4 -0
- package/dist/react-native.mjs +2 -0
- package/dist/react-native.mjs.map +7 -0
- package/dist/react.d.ts +19 -0
- package/dist/react.mjs +2 -0
- package/dist/react.mjs.map +7 -0
- package/dist/sqlite-test.mjs +1720 -0
- package/dist/sqlite-test.mjs.map +7 -0
- package/dist/sse.d.ts +44 -0
- package/dist/store/byte-cache.d.ts +162 -0
- package/dist/store/canonical.d.ts +2 -0
- package/dist/store/indexeddb.d.ts +23 -0
- package/dist/store/locks.d.ts +11 -0
- package/dist/store/outbox.d.ts +113 -0
- package/dist/store/overlay-retirement.d.ts +17 -0
- package/dist/store/projection.d.ts +11 -0
- package/dist/store/range-store.d.ts +388 -0
- package/dist/store/sqlite-driver.conformance.d.ts +7 -0
- package/dist/store/sqlite-driver.d.ts +16 -0
- package/dist/store/sqlite-expo.d.ts +3 -0
- package/dist/store/sqlite.d.ts +31 -0
- package/dist/templates/chat/expo/App.tsx +29 -0
- package/dist/templates/chat/expo/app.json +11 -0
- package/dist/templates/chat/expo/index.js +3 -0
- package/dist/templates/chat/expo/shared/log.js +92 -0
- package/dist/templates/chat/expo/shared/log.ts +130 -0
- package/dist/templates/chat/expo/shared/offline.js +8 -0
- package/dist/templates/chat/expo/shared/offline.ts +9 -0
- package/dist/templates/chat/expo/src/Chat.tsx +23 -0
- package/dist/templates/chat/expo/src/Composer.tsx +23 -0
- package/dist/templates/chat/expo/src/MediaView.tsx +29 -0
- package/dist/templates/chat/expo/src/Witness.tsx +131 -0
- package/dist/templates/chat/expo/src/screens/Inbox.tsx +9 -0
- package/dist/templates/chat/expo/src/screens/Search.tsx +10 -0
- package/dist/templates/chat/expo/src/screens/Thread.tsx +22 -0
- package/dist/templates/chat/expo/src/witness-state.ts +68 -0
- package/dist/templates/chat/expo/tsconfig.json +5 -0
- package/dist/templates/chat/react/index.html +5 -0
- package/dist/templates/chat/react/shared/log.js +92 -0
- package/dist/templates/chat/react/shared/log.ts +130 -0
- package/dist/templates/chat/react/shared/offline.js +8 -0
- package/dist/templates/chat/react/shared/offline.ts +9 -0
- package/dist/templates/chat/react/src/App.tsx +36 -0
- package/dist/templates/chat/react/src/Composer.tsx +45 -0
- package/dist/templates/chat/react/src/Inbox.tsx +24 -0
- package/dist/templates/chat/react/src/Search.tsx +26 -0
- package/dist/templates/chat/react/src/Thread.tsx +126 -0
- package/dist/templates/chat/react/src/env.d.ts +1 -0
- package/dist/templates/chat/react/src/main.tsx +20 -0
- package/dist/templates/chat/shared/log.js +92 -0
- package/dist/templates/chat/shared/log.ts +130 -0
- package/dist/templates/chat/shared/offline.js +8 -0
- package/dist/templates/chat/shared/offline.ts +9 -0
- package/dist/templates/chat/snapback/deliveries.q +33 -0
- package/dist/templates/chat/snapback/delivery.ts +30 -0
- package/dist/templates/chat/snapback/feeds.q +14 -0
- package/dist/templates/chat/snapback/follows.q +11 -0
- package/dist/templates/chat/snapback/groups.q +40 -0
- package/dist/templates/chat/snapback/messages.q +32 -0
- package/dist/templates/chat/snapback/notifications.q +7 -0
- package/dist/templates/chat/snapback/posts.q +4 -0
- package/dist/templates/chat/snapback/profiles.q +17 -0
- package/dist/templates/chat/snapback/schema.q +136 -0
- package/dist/templates/chat/snapback/seed.ts +45 -0
- package/dist/templates/chat/snapback/tests/chat.test.ts +759 -0
- package/dist/templates/react/index.html +5 -0
- package/dist/templates/react/src/App.tsx +5 -0
- package/dist/templates/react/src/main.tsx +18 -0
- package/dist/templates/todos/snapback/schema.q +11 -0
- package/dist/templates/todos/snapback/seed.ts +10 -0
- package/dist/templates/todos/snapback/tests/todos.test.ts +13 -0
- package/dist/templates/todos/snapback/todos.q +6 -0
- package/dist/test-runner.mjs +8541 -0
- package/dist/test-runner.mjs.map +7 -0
- package/dist/test.d.ts +28 -0
- package/dist/test.mjs +8541 -0
- package/dist/test.mjs.map +7 -0
- package/dist/token-store.d.ts +3 -0
- package/dist/twin-hydrate.d.ts +43 -0
- package/dist/twin.d.ts +70 -0
- package/dist/types.d.ts +410 -0
- package/dist/witness-test.mjs +2 -0
- package/dist/witness-test.mjs.map +7 -0
- package/package.json +117 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type PersistedRangeState, type RangeStoreFactory } from "./range-store.js";
|
|
2
|
+
interface WebLockLike {
|
|
3
|
+
readonly name?: string;
|
|
4
|
+
}
|
|
5
|
+
interface WebLocksLike {
|
|
6
|
+
request<T>(name: string, options: {
|
|
7
|
+
ifAvailable: true;
|
|
8
|
+
}, callback: (lock: WebLockLike | null) => Promise<T> | T): Promise<T>;
|
|
9
|
+
}
|
|
10
|
+
export interface IndexedDbRangeStoreOptions {
|
|
11
|
+
indexedDB?: IDBFactory;
|
|
12
|
+
locks?: WebLocksLike;
|
|
13
|
+
databasePrefix?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Witness barrier: awaited with the next state before its readwrite
|
|
16
|
+
* transaction opens, so a harness can kill the profile between two swaps
|
|
17
|
+
* and prove the carrier lands whole swaps or nothing (LLP 1012 §10).
|
|
18
|
+
*/
|
|
19
|
+
beforeReplace?: (state: PersistedRangeState) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/** The sole web carrier exported by `snapback2/react`. */
|
|
22
|
+
export declare function indexedDbRangeStore(options?: IndexedDbRangeStoreOptions): RangeStoreFactory;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface StoreLocks {
|
|
2
|
+
request<T>(name: string, options: {
|
|
3
|
+
ifAvailable: true;
|
|
4
|
+
}, callback: (lock: {
|
|
5
|
+
readonly name?: string;
|
|
6
|
+
} | null) => T | Promise<T>): Promise<T>;
|
|
7
|
+
}
|
|
8
|
+
type LockScope = string | object;
|
|
9
|
+
export declare function inProcessLocks(scope?: LockScope): StoreLocks;
|
|
10
|
+
export declare function acquireStoreLock(locks: StoreLocks, partition: string): Promise<() => Promise<void>>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { OutboxEntry, OutboxGrant, OutboxState, OutboxTrace, OverlayRow, RangeStore, StoreDelivery } from "./range-store.js";
|
|
2
|
+
export declare const ENGINE_ID: RegExp;
|
|
3
|
+
export type OutboxRefusalCode = "E_OFFLINE_ID_BLOCK";
|
|
4
|
+
/** A local custody refusal at enqueue time; the client maps it onto the offline family. */
|
|
5
|
+
export declare class OutboxRefusal extends Error {
|
|
6
|
+
readonly code: OutboxRefusalCode;
|
|
7
|
+
readonly site: string;
|
|
8
|
+
readonly rewrite: string;
|
|
9
|
+
constructor(code: OutboxRefusalCode, message: string, site: string, rewrite: string);
|
|
10
|
+
}
|
|
11
|
+
export declare function emptyTrace(): OutboxTrace;
|
|
12
|
+
/** Reads `grant` off a login, guest, or resume response; anything malformed is ignored. */
|
|
13
|
+
export declare function decodeGrant(value: unknown): OutboxGrant | undefined;
|
|
14
|
+
/** The engine ids `from + offset …`, spelled exactly as `Store::replay_slice_ids` spells them. */
|
|
15
|
+
export declare function sliceIds(grant: Pick<OutboxGrant, "from" | "count">, offset: number, count: number): string[];
|
|
16
|
+
export declare function intentName(grant: string, offset: number): string;
|
|
17
|
+
export declare function settledEntry(entry: OutboxEntry, state: OutboxState, response: unknown, now: number): OutboxEntry;
|
|
18
|
+
export interface EnqueueInput {
|
|
19
|
+
op: string;
|
|
20
|
+
args: unknown;
|
|
21
|
+
issuedSeq: number;
|
|
22
|
+
trace?: OutboxTrace;
|
|
23
|
+
}
|
|
24
|
+
/** The twin's run over held rows for the slice it was handed: the overlay and the trace land with the entry. */
|
|
25
|
+
export interface EnqueuePrediction {
|
|
26
|
+
predicted: boolean;
|
|
27
|
+
overlay: OverlayRow[];
|
|
28
|
+
trace: OutboxTrace;
|
|
29
|
+
/** Maintained targets the overlay intentionally omits (LLP 1012 §7); recorded on the entry when non-empty. */
|
|
30
|
+
omitted?: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface OutboxLedgerOptions {
|
|
33
|
+
now(): number;
|
|
34
|
+
fenceMs: number;
|
|
35
|
+
}
|
|
36
|
+
export declare class OutboxLedger {
|
|
37
|
+
private readonly store;
|
|
38
|
+
private readonly options;
|
|
39
|
+
private readonly listeners;
|
|
40
|
+
/** A successful resume renews trust without changing the server-minted grant's age. */
|
|
41
|
+
private resumedAt?;
|
|
42
|
+
/** Runtime lifecycle, advanced synchronously on open, seal, wipe, reset, or close. */
|
|
43
|
+
private trustGeneration;
|
|
44
|
+
/**
|
|
45
|
+
* Grant custody is one critical section: a read of the grant's cursor and the
|
|
46
|
+
* swap that advances it never interleave with another enqueue or adoption, so
|
|
47
|
+
* two overlapping writes can never mint the same slice.
|
|
48
|
+
*/
|
|
49
|
+
private tail;
|
|
50
|
+
private fenceTimer?;
|
|
51
|
+
private fenceProbe;
|
|
52
|
+
constructor(store: RangeStore, options: OutboxLedgerOptions);
|
|
53
|
+
subscribe(listener: () => void): () => void;
|
|
54
|
+
notify(): void;
|
|
55
|
+
close(): void;
|
|
56
|
+
/** Readiness changes with time even when the outbox has no entries to push. */
|
|
57
|
+
private armFence;
|
|
58
|
+
entries(): Promise<OutboxEntry[]>;
|
|
59
|
+
queued(): Promise<OutboxEntry[]>;
|
|
60
|
+
grant(): Promise<OutboxGrant | undefined>;
|
|
61
|
+
grantById(id: string): Promise<OutboxGrant | undefined>;
|
|
62
|
+
/** A fresh grant replaces the consumption target; older grants stay for the intents that name them. */
|
|
63
|
+
adopt(grant: OutboxGrant): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Persists the program's write bounds (each op's `maxNewIds`) beside the grants,
|
|
66
|
+
* on the same tail as enqueue, so an enqueue issued after the program loaded
|
|
67
|
+
* always sees the bound it certified. The certified program itself rides the
|
|
68
|
+
* same swap when given, so a cold start with the network cut evaluates and
|
|
69
|
+
* prices from custody, never from a fallback (LLP 1012 §7, §8).
|
|
70
|
+
*/
|
|
71
|
+
recordBounds(bounds: Record<string, number>, programs?: Record<string, unknown>): Promise<void>;
|
|
72
|
+
/** Grant-only without an op; with one, probes the exact slice enqueue would need. */
|
|
73
|
+
ready(op?: string): Promise<boolean>;
|
|
74
|
+
/**
|
|
75
|
+
* Every outbox write shares this tail: enqueue, adoption, bounds, dismiss,
|
|
76
|
+
* and — through the runtime — settlements and the predictions they drop, so
|
|
77
|
+
* a settlement never interleaves with the enqueue that reads the grant.
|
|
78
|
+
*/
|
|
79
|
+
write<T>(action: () => Promise<T>): Promise<T>;
|
|
80
|
+
pastFence(grant: Pick<OutboxGrant, "mintedAt">): boolean;
|
|
81
|
+
/** The server-minted grant's own lifetime; a resume renews fence trust, not the grant. */
|
|
82
|
+
expired(grant: Pick<OutboxGrant, "expiresAt">): boolean;
|
|
83
|
+
/** When time alone next changes `fence()`: the crossing, or the lapse of a resume's renewed trust. */
|
|
84
|
+
private trustEndsAt;
|
|
85
|
+
private resumedSince;
|
|
86
|
+
/** Trust belongs to this open partition and session, never to the next one. */
|
|
87
|
+
resetTrust(): void;
|
|
88
|
+
/** Capture before a resume starts; this is independent of the program generation. */
|
|
89
|
+
get generation(): number;
|
|
90
|
+
/** A newer consumption grant cannot authorize work queued under an older one. */
|
|
91
|
+
canReplay(entry: OutboxEntry): Promise<boolean>;
|
|
92
|
+
fence(): Promise<"inside" | "past">;
|
|
93
|
+
/** The owner re-authorized this partition; old intents may now replay for the server to judge. */
|
|
94
|
+
resumeSucceeded(generation: number): boolean;
|
|
95
|
+
enqueue(input: EnqueueInput, predict?: (ids: string[]) => Promise<EnqueuePrediction>): Promise<OutboxEntry>;
|
|
96
|
+
private serial;
|
|
97
|
+
settle(entry: OutboxEntry, state: OutboxState, response?: unknown): OutboxEntry;
|
|
98
|
+
/** Removes a settled entry; a queued intent is never dismissed. */
|
|
99
|
+
dismiss(intent: string): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
/** The replay envelope for one queued intent, byte-stable across retries. */
|
|
102
|
+
export declare function replayEnvelope(entry: OutboxEntry): {
|
|
103
|
+
op: string;
|
|
104
|
+
args: unknown;
|
|
105
|
+
replay: {
|
|
106
|
+
grant: string;
|
|
107
|
+
offset: number;
|
|
108
|
+
count: number;
|
|
109
|
+
intent: string;
|
|
110
|
+
trace: OutboxTrace;
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
export type { OutboxEntry, OutboxGrant, OutboxState, OutboxTrace, StoreDelivery };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StoredAnswer, WatermarkRecord } from "./range-store.js";
|
|
2
|
+
export interface OverlayWatermark extends Pick<WatermarkRecord, "op" | "args" | "watermark"> {
|
|
3
|
+
cursorArg: string;
|
|
4
|
+
/** Last proven progress, and positions already consumed, survive a remount/reopen. */
|
|
5
|
+
progress?: string;
|
|
6
|
+
consumed?: string[];
|
|
7
|
+
}
|
|
8
|
+
/** Each page keeps the name used when it was filed, including a pre-manifest c. */
|
|
9
|
+
export declare function pagePosition(page: Pick<StoredAnswer, "op" | "args" | "cursorArg">, mark: OverlayWatermark): string | null | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* An ordered path starting at the commit snapshot, never inferred from a newer
|
|
12
|
+
* seq or a different opaque string. A cycle, ambiguity, or missing link ends
|
|
13
|
+
* the proven prefix. The caller supplies only non-empty, intact held pages.
|
|
14
|
+
*/
|
|
15
|
+
export declare function cursorChain(pages: StoredAnswer[], mark: OverlayWatermark, committedSeq: number): string[];
|
|
16
|
+
/** A re-mark can consume progress only once and only beyond the last proven mark. */
|
|
17
|
+
export declare function advanceOverlay(mark: OverlayWatermark, tail: WatermarkRecord | undefined, chain: string[]): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type PersistedRangeState } from "./range-store.js";
|
|
2
|
+
export type StateEntry = {
|
|
3
|
+
key: string;
|
|
4
|
+
value: unknown;
|
|
5
|
+
};
|
|
6
|
+
export type StateProjection = Record<"rows" | "answers" | "outbox" | "meta", StateEntry[]>;
|
|
7
|
+
export declare function projectState(state: PersistedRangeState): StateProjection;
|
|
8
|
+
export declare function unprojectState(projected: StateProjection): PersistedRangeState | undefined;
|
|
9
|
+
/** Split only the first separator: a row id may itself contain NUL. */
|
|
10
|
+
export declare function encodeStateKey(key: string, tuple: boolean): string;
|
|
11
|
+
export declare function decodeStateKey(key: string): string;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { type ByteCacheEntry, type ByteCacheStatus, type ByteDelta, type ByteSwapRequest, type ViewedRecord } from "./byte-cache.js";
|
|
2
|
+
import { type OverlayWatermark } from "./overlay-retirement.js";
|
|
3
|
+
export type PrincipalPartition = string;
|
|
4
|
+
export type RetentionPolicy = "until-revoked" | "delivered-history";
|
|
5
|
+
export interface RowRef {
|
|
6
|
+
table: string;
|
|
7
|
+
id: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StoredRow extends RowRef {
|
|
10
|
+
row: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface ReceiptRange {
|
|
13
|
+
site: string;
|
|
14
|
+
table: string;
|
|
15
|
+
index: string;
|
|
16
|
+
prefix: unknown[];
|
|
17
|
+
order: "Asc" | "Desc";
|
|
18
|
+
take: number;
|
|
19
|
+
after: unknown;
|
|
20
|
+
before: unknown;
|
|
21
|
+
gt: unknown;
|
|
22
|
+
gte: unknown;
|
|
23
|
+
lt: unknown;
|
|
24
|
+
lte: unknown;
|
|
25
|
+
rowIds: string[];
|
|
26
|
+
stop: "Limit" | "Exhausted";
|
|
27
|
+
absence: boolean;
|
|
28
|
+
haloId?: string;
|
|
29
|
+
ruleHash: string;
|
|
30
|
+
/**
|
|
31
|
+
* Members this page was recorded with that a later swap evicted (LLP 1012 §5):
|
|
32
|
+
* the page's answer is `partial/evicted` from then on, and the twin's
|
|
33
|
+
* drained-prefix rule (§6) no longer counts the page as a link in a complete chain.
|
|
34
|
+
*/
|
|
35
|
+
evictedIds?: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface Receipt {
|
|
38
|
+
ranges: ReceiptRange[];
|
|
39
|
+
absences: Array<[table: string, index: string, key: unknown[]]>;
|
|
40
|
+
verdicts: unknown[];
|
|
41
|
+
}
|
|
42
|
+
export interface HeldAnswer<Result = unknown> {
|
|
43
|
+
op: string;
|
|
44
|
+
args: unknown;
|
|
45
|
+
state: "complete" | "capped" | "partial" | "denied";
|
|
46
|
+
data?: Result;
|
|
47
|
+
next?: string | null;
|
|
48
|
+
seq: number;
|
|
49
|
+
validator?: string;
|
|
50
|
+
receipt?: Receipt;
|
|
51
|
+
deliveredAt: number;
|
|
52
|
+
retained?: boolean;
|
|
53
|
+
caughtUp?: boolean;
|
|
54
|
+
watermark?: string | null;
|
|
55
|
+
reason?: string;
|
|
56
|
+
rule?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface StoredAnswer extends Omit<HeldAnswer, "data"> {
|
|
59
|
+
data?: unknown;
|
|
60
|
+
refs: RowRef[];
|
|
61
|
+
order: number;
|
|
62
|
+
fingerprint?: string;
|
|
63
|
+
/** Cursor argument used when this page was filed, before or after manifest load. */
|
|
64
|
+
cursorArg?: string;
|
|
65
|
+
/** Seq of the delivery that recorded `next`; conditional refreshes cannot renew a link. */
|
|
66
|
+
nextSeq?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface WatermarkRecord {
|
|
69
|
+
op: string;
|
|
70
|
+
args: unknown;
|
|
71
|
+
/** The argument the manifest types as this query's cursor; `c` before the manifest loads. */
|
|
72
|
+
cursorArg?: string;
|
|
73
|
+
watermark: string | null;
|
|
74
|
+
validator?: string;
|
|
75
|
+
generation?: string;
|
|
76
|
+
schemaHash?: string;
|
|
77
|
+
authEpoch?: number;
|
|
78
|
+
updatedAt: number;
|
|
79
|
+
/**
|
|
80
|
+
* Durable position among this partition's drained queries: assigned by the store
|
|
81
|
+
* when the query first records a watermark, kept across every later mark. The
|
|
82
|
+
* lowest order is the log a settlement and a resume name (LLP 1012 §7).
|
|
83
|
+
*/
|
|
84
|
+
order?: number;
|
|
85
|
+
}
|
|
86
|
+
/** The generation and authority every held envelope of a partition was delivered under. */
|
|
87
|
+
export interface CoverageFacts {
|
|
88
|
+
generation: string;
|
|
89
|
+
schemaHash: string;
|
|
90
|
+
authEpoch: number;
|
|
91
|
+
}
|
|
92
|
+
/** One certified operation as `GET /api/program` serves it: the IR the twin runs and its certificate. */
|
|
93
|
+
export interface HeldOperation {
|
|
94
|
+
kind: "query" | "mutation";
|
|
95
|
+
planHash: string;
|
|
96
|
+
ir: unknown;
|
|
97
|
+
certificate: unknown;
|
|
98
|
+
}
|
|
99
|
+
export interface HeldProgram {
|
|
100
|
+
generation: string;
|
|
101
|
+
schemaHash: string;
|
|
102
|
+
schema: OfflineSchema & Record<string, unknown>;
|
|
103
|
+
programs: Record<string, HeldOperation>;
|
|
104
|
+
}
|
|
105
|
+
export interface Coverage {
|
|
106
|
+
rows: StoredRow[];
|
|
107
|
+
ranges: ReceiptRange[];
|
|
108
|
+
absences: Receipt["absences"];
|
|
109
|
+
verdicts: unknown[];
|
|
110
|
+
watermarks: WatermarkRecord[];
|
|
111
|
+
/**
|
|
112
|
+
* View-once rows already viewed on this partition: absent from `rows`, listed under
|
|
113
|
+
* `absences` as a `byId` point, and named here so a replay never renders them (LLP 1012 §9).
|
|
114
|
+
*/
|
|
115
|
+
viewed?: RowRef[];
|
|
116
|
+
/** Rows a swap evicted (LLP 1012 §5): the twin reports a read of one as `evicted`, not `never-held`. */
|
|
117
|
+
evicted?: RowRef[];
|
|
118
|
+
/** The generation and auth epoch the held coverage was delivered under; absent when nothing is held. */
|
|
119
|
+
facts?: CoverageFacts;
|
|
120
|
+
/**
|
|
121
|
+
* The facts a later delivery arrived under when its generation or schema differ
|
|
122
|
+
* from `facts`: the held answers predate the program the server now runs, so the
|
|
123
|
+
* twin serves nothing from them — every read is `hydrating` — until the reset
|
|
124
|
+
* path drops this coverage and rebuilds it under the live generation (LLP 1012 §8).
|
|
125
|
+
*/
|
|
126
|
+
superseded?: CoverageFacts;
|
|
127
|
+
/** The newest delivery's time: what an answer the twin computes reports as `stale.since`. */
|
|
128
|
+
deliveredAt?: number;
|
|
129
|
+
}
|
|
130
|
+
export type OutboxState = "queued" | "committed" | "rejected" | "expired";
|
|
131
|
+
/** What the twin read when the intent was queued: the replay envelope's `trace` (LLP 1012 §7). */
|
|
132
|
+
export interface OutboxTrace {
|
|
133
|
+
points: Array<{
|
|
134
|
+
table: string;
|
|
135
|
+
index: string;
|
|
136
|
+
key: unknown[];
|
|
137
|
+
digest: string | null;
|
|
138
|
+
}>;
|
|
139
|
+
verdicts: Array<{
|
|
140
|
+
ruleHash: string;
|
|
141
|
+
inputsDigest: string;
|
|
142
|
+
result: boolean;
|
|
143
|
+
}>;
|
|
144
|
+
}
|
|
145
|
+
export interface OutboxEntry {
|
|
146
|
+
intent: string;
|
|
147
|
+
grant: string;
|
|
148
|
+
offset: number;
|
|
149
|
+
count: number;
|
|
150
|
+
ids: string[];
|
|
151
|
+
op: string;
|
|
152
|
+
args: unknown;
|
|
153
|
+
issuedSeq: number;
|
|
154
|
+
state: OutboxState;
|
|
155
|
+
response?: unknown;
|
|
156
|
+
predicted?: boolean;
|
|
157
|
+
/** Prediction and its trace were skipped; the program will check this write on the server. */
|
|
158
|
+
reason?: "fence";
|
|
159
|
+
/** Engine-owned aggregate targets the prediction's overlay omits by design (the server writes them); absent when none. */
|
|
160
|
+
omitted?: string[];
|
|
161
|
+
trace?: OutboxTrace;
|
|
162
|
+
createdAt: number;
|
|
163
|
+
updatedAt: number;
|
|
164
|
+
/** Durable FIFO position; assigned by the store when absent. */
|
|
165
|
+
order?: number;
|
|
166
|
+
}
|
|
167
|
+
/** A principal-bound replay grant plus the client's own consumption cursor. */
|
|
168
|
+
export interface OutboxGrant {
|
|
169
|
+
id: string;
|
|
170
|
+
from: string;
|
|
171
|
+
count: number;
|
|
172
|
+
mintedAt: number;
|
|
173
|
+
expiresAt: number;
|
|
174
|
+
used: number;
|
|
175
|
+
}
|
|
176
|
+
export interface OutboxStore {
|
|
177
|
+
all(): Promise<OutboxEntry[]>;
|
|
178
|
+
get(intent: string): Promise<OutboxEntry | undefined>;
|
|
179
|
+
put(entry: OutboxEntry): Promise<void>;
|
|
180
|
+
remove(intent: string): Promise<void>;
|
|
181
|
+
/** The grant new intents consume from, if one is held. */
|
|
182
|
+
grant(): Promise<OutboxGrant | undefined>;
|
|
183
|
+
/** Every held grant, including those older queued intents still name. */
|
|
184
|
+
grants(): Promise<OutboxGrant[]>;
|
|
185
|
+
/** Each op's persisted write bound (`maxNewIds`); an enqueue refuses without one. */
|
|
186
|
+
bounds(): Promise<Record<string, number>>;
|
|
187
|
+
/** The prediction overlay by intent: the rows a queued intent's local run wrote, `removed` for a delete. */
|
|
188
|
+
overlay(): Promise<Record<string, OverlayRow[]>>;
|
|
189
|
+
/** The certified programs held for this partition, keyed by generation. */
|
|
190
|
+
programs(): Promise<Record<string, HeldProgram>>;
|
|
191
|
+
}
|
|
192
|
+
/** A predicted row: the write's final image, or a `removed` marker for a row the intent deletes. */
|
|
193
|
+
export interface OverlayRow extends StoredRow {
|
|
194
|
+
removed?: true;
|
|
195
|
+
}
|
|
196
|
+
export interface StoreDelivery {
|
|
197
|
+
cursorArg?: string;
|
|
198
|
+
op: string;
|
|
199
|
+
args: unknown;
|
|
200
|
+
state: HeldAnswer["state"];
|
|
201
|
+
data?: unknown;
|
|
202
|
+
next?: string | null;
|
|
203
|
+
seq: number;
|
|
204
|
+
validator?: string;
|
|
205
|
+
receipt?: Receipt;
|
|
206
|
+
rows?: StoredRow[];
|
|
207
|
+
deliveredAt?: number;
|
|
208
|
+
reason?: string;
|
|
209
|
+
rule?: string;
|
|
210
|
+
}
|
|
211
|
+
export interface StoreTombstone extends RowRef {
|
|
212
|
+
seq: number;
|
|
213
|
+
}
|
|
214
|
+
export interface StoreRefresh {
|
|
215
|
+
op: string;
|
|
216
|
+
args: unknown;
|
|
217
|
+
seq: number;
|
|
218
|
+
validator: string;
|
|
219
|
+
deliveredAt?: number;
|
|
220
|
+
}
|
|
221
|
+
export interface OfflineSchema {
|
|
222
|
+
tables?: Record<string, {
|
|
223
|
+
columns?: Record<string, unknown>;
|
|
224
|
+
indexes?: Record<string, {
|
|
225
|
+
columns?: string[];
|
|
226
|
+
}>;
|
|
227
|
+
retain?: RetentionPolicy;
|
|
228
|
+
}>;
|
|
229
|
+
}
|
|
230
|
+
export interface StoreSwap {
|
|
231
|
+
deliveries?: StoreDelivery[];
|
|
232
|
+
refreshes?: StoreRefresh[];
|
|
233
|
+
tombstones?: StoreTombstone[];
|
|
234
|
+
retention?: Record<string, RetentionPolicy>;
|
|
235
|
+
schema?: OfflineSchema;
|
|
236
|
+
watermark?: WatermarkRecord;
|
|
237
|
+
resetCoverage?: boolean;
|
|
238
|
+
/** One validated resume response, including unchanged; never a query re-delivery. */
|
|
239
|
+
resumeSeq?: number;
|
|
240
|
+
dropAnswers?: string[];
|
|
241
|
+
outbox?: {
|
|
242
|
+
put?: OutboxEntry[];
|
|
243
|
+
remove?: string[];
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Byte-cache puts and read touches; they land in the same swap as the rows: one
|
|
247
|
+
* readwrite transaction on IndexedDB, an ordered payload write + state rename with
|
|
248
|
+
* a sweep on open for the Node file carrier.
|
|
249
|
+
*/
|
|
250
|
+
bytes?: ByteSwapRequest;
|
|
251
|
+
/** View-once rows viewed now: excluded from every later answer, their bytes never cached. */
|
|
252
|
+
viewed?: RowRef[];
|
|
253
|
+
/** `current` names the grant new intents consume; `null` clears it. */
|
|
254
|
+
grants?: {
|
|
255
|
+
put?: OutboxGrant[];
|
|
256
|
+
current?: string | null;
|
|
257
|
+
remove?: string[];
|
|
258
|
+
};
|
|
259
|
+
/** Predicted rows keyed by intent; visible to the twin's local evaluation, never to `answer()` (LLP 1012 §7). */
|
|
260
|
+
overlay?: {
|
|
261
|
+
put?: Record<string, OverlayRow[]>;
|
|
262
|
+
remove?: string[];
|
|
263
|
+
};
|
|
264
|
+
/** The certified programs, replaced whole (one generation is held at a time); custody like the outbox. */
|
|
265
|
+
programs?: Record<string, HeldProgram>;
|
|
266
|
+
/** The facts the swap's envelopes were delivered under; cleared by `resetCoverage`. */
|
|
267
|
+
facts?: CoverageFacts;
|
|
268
|
+
/** A rotation: the facts a refused delivery arrived under; held coverage is superseded until rebuild completion clears it with `null`. */
|
|
269
|
+
superseded?: CoverageFacts | null;
|
|
270
|
+
/**
|
|
271
|
+
* The program's write bounds, replaced whole: what `/api/program` certified each op may
|
|
272
|
+
* mint. Custody like the outbox — a coverage reset never touches it (LLP 1012 §7).
|
|
273
|
+
*/
|
|
274
|
+
bounds?: Record<string, number>;
|
|
275
|
+
}
|
|
276
|
+
export interface RangeStore {
|
|
277
|
+
open(partition: PrincipalPartition): Promise<void>;
|
|
278
|
+
unseal(): Promise<void>;
|
|
279
|
+
seal(): Promise<void>;
|
|
280
|
+
wipe(partition: PrincipalPartition): Promise<void>;
|
|
281
|
+
answer<Result = unknown>(op: string, args: unknown): Promise<HeldAnswer<Result> | undefined>;
|
|
282
|
+
commit(swap: StoreSwap): Promise<void>;
|
|
283
|
+
coverage(): Promise<Coverage>;
|
|
284
|
+
readonly outbox: OutboxStore;
|
|
285
|
+
close?(): Promise<void> | void;
|
|
286
|
+
}
|
|
287
|
+
export interface RangeStoreFactory {
|
|
288
|
+
(): RangeStore;
|
|
289
|
+
readonly carrier?: string;
|
|
290
|
+
}
|
|
291
|
+
export declare class OfflineStoreFailure extends Error {
|
|
292
|
+
readonly code: "E_OFFLINE_CARRIER" | "E_OFFLINE_LOCK";
|
|
293
|
+
readonly rewrite?: string | undefined;
|
|
294
|
+
constructor(code: "E_OFFLINE_CARRIER" | "E_OFFLINE_LOCK", message: string, rewrite?: string | undefined);
|
|
295
|
+
}
|
|
296
|
+
export interface PersistedRangeState {
|
|
297
|
+
version: 1;
|
|
298
|
+
sealed: boolean;
|
|
299
|
+
clock: number;
|
|
300
|
+
rows: Record<string, StoredRow>;
|
|
301
|
+
answers: Record<string, StoredAnswer>;
|
|
302
|
+
watermarks: Record<string, WatermarkRecord>;
|
|
303
|
+
outbox: Record<string, OutboxEntry>;
|
|
304
|
+
grants: Record<string, OutboxGrant>;
|
|
305
|
+
currentGrant?: string;
|
|
306
|
+
overlay: Record<string, OverlayRow[]>;
|
|
307
|
+
/** Per committed intent, successful resumes or proven tail advances without its rows. */
|
|
308
|
+
overlayMisses: Record<string, number>;
|
|
309
|
+
/** The commit-time tail snapshot; null after reset until the next drain establishes a new head. */
|
|
310
|
+
overlayWatermarks: Record<string, OverlayWatermark | null>;
|
|
311
|
+
/** Only primary absences and tombstones, scoped to each prediction's ids. */
|
|
312
|
+
overlayNegatives: Record<string, Record<string, {
|
|
313
|
+
seq: number;
|
|
314
|
+
source: "absence" | "tombstone";
|
|
315
|
+
}>>;
|
|
316
|
+
bounds: Record<string, number>;
|
|
317
|
+
programs: Record<string, HeldProgram>;
|
|
318
|
+
facts?: CoverageFacts;
|
|
319
|
+
superseded?: CoverageFacts;
|
|
320
|
+
negative: Record<string, number>;
|
|
321
|
+
rowSeq: Record<string, number>;
|
|
322
|
+
/** The byte-cache index; payloads live beside the state in the carrier, keyed by `byteKey`. */
|
|
323
|
+
assets: Record<string, ByteCacheEntry>;
|
|
324
|
+
viewed: Record<string, ViewedRecord>;
|
|
325
|
+
}
|
|
326
|
+
export interface RangeStoreBackend {
|
|
327
|
+
readonly carrier: string;
|
|
328
|
+
open(partition: PrincipalPartition): Promise<PersistedRangeState | undefined>;
|
|
329
|
+
/** One carrier transaction: the state, plus the byte payloads to write and the keys to drop. */
|
|
330
|
+
replace(state: PersistedRangeState, bytes: ByteDelta): Promise<void>;
|
|
331
|
+
readBytes?(key: string): Promise<Uint8Array | undefined>;
|
|
332
|
+
wipe(partition: PrincipalPartition): Promise<void>;
|
|
333
|
+
close(): Promise<void> | void;
|
|
334
|
+
}
|
|
335
|
+
export declare function emptyState(): PersistedRangeState;
|
|
336
|
+
export declare function canonicalAnswerKey(op: string, args: unknown): string;
|
|
337
|
+
export declare function rowReference(table: string, id: string): {
|
|
338
|
+
$row: RowRef;
|
|
339
|
+
};
|
|
340
|
+
/** One JSON form for warm state and every carrier: omit undefined fields, preserve null. */
|
|
341
|
+
export declare function jsonState<T>(value: T): T;
|
|
342
|
+
/**
|
|
343
|
+
* The log a settlement's tail request and a resume's `log` both name: the drained
|
|
344
|
+
* query whose watermark was recorded first in this partition — the first-mounted
|
|
345
|
+
* drain, since a drain records its watermark when it first catches up — by the
|
|
346
|
+
* store's durable `order`, so the choice survives restarts and re-marks and never
|
|
347
|
+
* follows whichever page moved last (LLP 1012 §7, §8).
|
|
348
|
+
*/
|
|
349
|
+
export declare function tailWatermark(watermarks: WatermarkRecord[]): WatermarkRecord | undefined;
|
|
350
|
+
/** Shared swap engine; carrier subclasses only provide the atomic replace. */
|
|
351
|
+
export declare class PersistentRangeStore implements RangeStore {
|
|
352
|
+
private readonly backend;
|
|
353
|
+
private state;
|
|
354
|
+
private partition?;
|
|
355
|
+
private tail;
|
|
356
|
+
readonly outbox: OutboxStore;
|
|
357
|
+
constructor(backend: RangeStoreBackend);
|
|
358
|
+
open(partition: PrincipalPartition): Promise<void>;
|
|
359
|
+
unseal(): Promise<void>;
|
|
360
|
+
seal(): Promise<void>;
|
|
361
|
+
wipe(partition: PrincipalPartition): Promise<void>;
|
|
362
|
+
answer<Result = unknown>(op: string, args: unknown): Promise<HeldAnswer<Result> | undefined>;
|
|
363
|
+
coverage(): Promise<Coverage>;
|
|
364
|
+
commit(swap: StoreSwap): Promise<void>;
|
|
365
|
+
/** The cached rendition for an asset at a width; nothing when unheld, sealed, or the payload is gone. */
|
|
366
|
+
readBytes(assetId: string, width: number | null): Promise<{
|
|
367
|
+
bytes: Uint8Array;
|
|
368
|
+
type: string;
|
|
369
|
+
} | undefined>;
|
|
370
|
+
bytes(): Promise<ByteCacheStatus>;
|
|
371
|
+
/** Held rows whose values carry this asset record: the lease under which its bytes may be cached. */
|
|
372
|
+
rowsReferencing(assetId: string): Promise<StoredRow[]>;
|
|
373
|
+
/** True while every held row carrying this asset is a viewed view-once row (per row, LLP 1012 §9). */
|
|
374
|
+
viewedAsset(assetId: string): Promise<boolean>;
|
|
375
|
+
/** The viewed view-once rows; a live frame nulls them exactly as a held answer does. */
|
|
376
|
+
viewedRows(): Promise<RowRef[]>;
|
|
377
|
+
/**
|
|
378
|
+
* LRU read stamps, coalesced per swap: a read stamps the entry in memory and the next
|
|
379
|
+
* swap's `replace` persists it, so a read never rewrites the carrier (LLP 1012 §9).
|
|
380
|
+
*/
|
|
381
|
+
touchBytes(keys: string[]): Promise<void>;
|
|
382
|
+
close(): Promise<void>;
|
|
383
|
+
private applySwap;
|
|
384
|
+
private ensureOpen;
|
|
385
|
+
private ensureUnsealed;
|
|
386
|
+
private enqueue;
|
|
387
|
+
private read;
|
|
388
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SqliteDriver } from "./sqlite-driver.js";
|
|
2
|
+
export interface SqliteConformanceResult {
|
|
3
|
+
journalMode: string;
|
|
4
|
+
checks: string[];
|
|
5
|
+
}
|
|
6
|
+
/** Portable: the witness passes two independently opened connections to the same scratch database. */
|
|
7
|
+
export declare function runSqliteDriverConformance(open: () => SqliteDriver | Promise<SqliteDriver>): Promise<SqliteConformanceResult>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SqliteParams = Array<string | number | null | Uint8Array>;
|
|
2
|
+
export interface SqliteExecutor {
|
|
3
|
+
run(sql: string, params?: SqliteParams): Promise<void>;
|
|
4
|
+
get<T>(sql: string, params?: SqliteParams): Promise<T | undefined>;
|
|
5
|
+
all<T>(sql: string, params?: SqliteParams): Promise<T[]>;
|
|
6
|
+
}
|
|
7
|
+
export interface SqliteDriver extends SqliteExecutor {
|
|
8
|
+
/** Stable identity of the database, shared by every connection/factory opening it. */
|
|
9
|
+
readonly identity: string;
|
|
10
|
+
exec(sql: string): Promise<void>;
|
|
11
|
+
/** All statements in body must use tx. Commit only after its promise resolves. */
|
|
12
|
+
transaction<T>(body: (tx: SqliteExecutor) => Promise<T>): Promise<T>;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/** Outer operations wait for an async transaction; its executor bypasses the queue. */
|
|
16
|
+
export declare function driverQueue(): <T>(body: () => Promise<T>) => Promise<T>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type PersistedRangeState, type RangeStoreBackend, type RangeStoreFactory } from "./range-store.js";
|
|
2
|
+
import type { ByteDelta } from "./byte-cache.js";
|
|
3
|
+
import { type StoreLocks } from "./locks.js";
|
|
4
|
+
import type { SqliteDriver } from "./sqlite-driver.js";
|
|
5
|
+
export type { SqliteDriver, SqliteExecutor, SqliteParams } from "./sqlite-driver.js";
|
|
6
|
+
export { inProcessLocks } from "./locks.js";
|
|
7
|
+
export interface SqliteRangeStoreOptions {
|
|
8
|
+
locks?: StoreLocks;
|
|
9
|
+
/** Witness barrier, after all DML and inside the transaction, before COMMIT. */
|
|
10
|
+
beforeCommit?: (state: PersistedRangeState, bytes: ByteDelta) => void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
type DriverSource = SqliteDriver | (() => SqliteDriver | Promise<SqliteDriver>);
|
|
13
|
+
/** Public backend seam: byte deltas share replace's transaction; wipe deletes all five tables. */
|
|
14
|
+
export declare class SqliteBackend implements RangeStoreBackend {
|
|
15
|
+
private readonly source;
|
|
16
|
+
private readonly options;
|
|
17
|
+
readonly carrier = "SQLite";
|
|
18
|
+
private driver?;
|
|
19
|
+
private partition?;
|
|
20
|
+
private release?;
|
|
21
|
+
private locks?;
|
|
22
|
+
constructor(source: DriverSource, options?: SqliteRangeStoreOptions);
|
|
23
|
+
private guard;
|
|
24
|
+
open(partition: string): Promise<PersistedRangeState | undefined>;
|
|
25
|
+
replace(state: PersistedRangeState, bytes: ByteDelta): Promise<void>;
|
|
26
|
+
readBytes(key: string): Promise<Uint8Array | undefined>;
|
|
27
|
+
wipe(partition: string): Promise<void>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** Pass a driver factory when this factory will construct multiple clients. */
|
|
31
|
+
export declare function sqliteRangeStore(driver: DriverSource, options?: SqliteRangeStoreOptions): RangeStoreFactory;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React, { lazy, Suspense, useEffect, useState } from "react";
|
|
2
|
+
import { createExpoClient, SnapbackProvider } from "snapback2/expo";
|
|
3
|
+
import type { SnapbackClient } from "snapback2/client";
|
|
4
|
+
import { Text } from "react-native";
|
|
5
|
+
import { Chat } from "./src/Chat";
|
|
6
|
+
import { chatOffline } from "./shared/offline.js";
|
|
7
|
+
|
|
8
|
+
const url = process.env.EXPO_PUBLIC_SNAPBACK_URL ?? "http://127.0.0.1:3210";
|
|
9
|
+
// Metro inlines this in app source, never at simctl launch or inside node_modules.
|
|
10
|
+
const witnessUrl = process.env.EXPO_PUBLIC_SNAPBACK_WITNESS;
|
|
11
|
+
|
|
12
|
+
const Witness = witnessUrl ? lazy(() => import("./src/Witness")) : undefined;
|
|
13
|
+
|
|
14
|
+
export function NormalApp() {
|
|
15
|
+
const [client, setClient] = useState<SnapbackClient>();
|
|
16
|
+
const [error, setError] = useState<string>();
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const client = createExpoClient({ url, as: "alice", offline: chatOffline });
|
|
19
|
+
setClient(client);
|
|
20
|
+
void client.ready.catch((error) => setError(String(error)));
|
|
21
|
+
return () => { void client.close(); };
|
|
22
|
+
}, []);
|
|
23
|
+
if (!client || error) return <Text accessibilityLiveRegion="polite">{error ?? "Connecting…"}</Text>;
|
|
24
|
+
return <SnapbackProvider client={client}><Chat /></SnapbackProvider>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function App() {
|
|
28
|
+
return witnessUrl && Witness ? <Suspense fallback={<Text>Loading witness…</Text>}><Witness url={url} controlUrl={witnessUrl} /></Suspense> : <NormalApp />;
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expo": {
|
|
3
|
+
"name": "Snapback Chat",
|
|
4
|
+
"slug": "snapback-chat",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"orientation": "portrait",
|
|
7
|
+
"jsEngine": "hermes",
|
|
8
|
+
"ios": { "bundleIdentifier": "dev.snapback.chat", "supportsTablet": true },
|
|
9
|
+
"plugins": ["expo-sqlite", "expo-secure-store", "expo-video"]
|
|
10
|
+
}
|
|
11
|
+
}
|