rastack 0.0.20 → 0.0.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.21](https://github.com/theserverkid/reactapistack/compare/v0.0.20...v0.0.21) (2026-07-08)
6
+
5
7
  ### [0.0.20](https://github.com/theserverkid/reactapistack/compare/v0.0.19...v0.0.20) (2026-07-07)
6
8
 
7
9
  ### 0.0.19 (2026-07-07)
package/cache/cache.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The Iceberg file cache: query the database's own files locally instead of
3
+ * loading them into a second database.
4
+ *
5
+ * Iceberg gives the cache its correctness model for free:
6
+ *
7
+ * - `metadata/version-hint.text` is the only mutable object per table — a tiny
8
+ * pointer to the current snapshot. It is revalidated against the source on
9
+ * every seed (network-first, cache fallback when offline).
10
+ * - `metadata/v{N}.metadata.json` and `data/*.parquet` are immutable — once
11
+ * written they never change — so a cached copy is correct forever
12
+ * (cache-first; the network is only touched for files never seen before).
13
+ *
14
+ * Seeding hands the cached bytes to the WASM engine (`RadApi.seedFile`), which
15
+ * reads the *actual* Parquet/metadata files — the cache is a transparent layer
16
+ * between object storage and the engine, not a new store format.
17
+ *
18
+ * Security model:
19
+ * - Every entry is namespaced by identity (the Cognito `sub`), so two accounts
20
+ * on one device can never read each other's cached rows.
21
+ * - When the source reports access revoked (401/403/AccessDenied), the cache
22
+ * deletes everything it holds for that identity before surfacing the error.
23
+ */
24
+
25
+ import {
26
+ AccessRevokedError,
27
+ CacheExpiredError,
28
+ CacheStorage,
29
+ isAccessRevoked,
30
+ SeedableEngine,
31
+ SeedStats,
32
+ WarehouseFileSource,
33
+ } from "./types";
34
+
35
+ const ANONYMOUS = "anonymous";
36
+ const LAST_VERIFIED = "__meta/last-verified";
37
+
38
+ export interface IcebergFileCacheOptions {
39
+ storage: CacheStorage;
40
+ source: WarehouseFileSource;
41
+ /**
42
+ * The identity the cached data belongs to — the verified Cognito `sub`.
43
+ * Entries are namespaced per identity and purged per identity.
44
+ */
45
+ identity?: string;
46
+ /**
47
+ * Offline-retention bound: how long cached data may be served without the
48
+ * source re-confirming access (each successful source contact refreshes the
49
+ * stamp). Past the bound the cache is *deleted*, not served — a device that
50
+ * revocation can't reach stops holding the data. Unset = no bound.
51
+ */
52
+ maxOfflineMs?: number;
53
+ /** Clock override for tests. */
54
+ now?: () => number;
55
+ }
56
+
57
+ export class IcebergFileCache {
58
+ private storage: CacheStorage;
59
+ private source: WarehouseFileSource;
60
+ private maxOfflineMs?: number;
61
+ private now: () => number;
62
+ readonly identity: string;
63
+
64
+ constructor(options: IcebergFileCacheOptions) {
65
+ this.storage = options.storage;
66
+ this.source = options.source;
67
+ this.identity = options.identity || ANONYMOUS;
68
+ this.maxOfflineMs = options.maxOfflineMs;
69
+ this.now = options.now ?? (() => Date.now());
70
+ }
71
+
72
+ private ns(key: string): string {
73
+ return `${this.identity}::${key}`;
74
+ }
75
+
76
+ /** Refresh the access-verified stamp after any successful source contact. */
77
+ private async stampVerified(): Promise<void> {
78
+ if (this.maxOfflineMs === undefined) return;
79
+ await this.storage.put(
80
+ this.ns(LAST_VERIFIED),
81
+ new TextEncoder().encode(String(this.now())),
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Enforce the offline-retention bound before serving cached bytes without a
87
+ * live access check. Too stale ⇒ purge and refuse.
88
+ */
89
+ private async ensureWithinOfflineBound(): Promise<void> {
90
+ if (this.maxOfflineMs === undefined) return;
91
+ const stamp = await this.storage.get(this.ns(LAST_VERIFIED));
92
+ const verifiedAt = stamp ? Number(new TextDecoder().decode(stamp)) : NaN;
93
+ if (!Number.isFinite(verifiedAt) || this.now() - verifiedAt > this.maxOfflineMs) {
94
+ await this.purge();
95
+ throw new CacheExpiredError();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * An immutable warehouse file (snapshot metadata, Parquet data file):
101
+ * cache-first, fetched and stored on first sight. Revocation purges before
102
+ * rethrowing.
103
+ */
104
+ async getImmutable(key: string): Promise<Uint8Array | null> {
105
+ const cached = await this.storage.get(this.ns(key));
106
+ if (cached) return cached;
107
+ const bytes = await this.fetch(key);
108
+ if (bytes) await this.storage.put(this.ns(key), bytes);
109
+ return bytes;
110
+ }
111
+
112
+ /**
113
+ * The mutable snapshot pointer (`version-hint.text`): network-first so a new
114
+ * commit is picked up immediately, falling back to the cached copy when the
115
+ * network is unreachable (offline-tolerant). An access-revoked answer never
116
+ * falls back — it purges.
117
+ */
118
+ async getHint(key: string): Promise<{ bytes: Uint8Array | null; fromCache: boolean }> {
119
+ try {
120
+ const bytes = await this.fetch(key);
121
+ if (bytes) {
122
+ await this.storage.put(this.ns(key), bytes);
123
+ return { bytes, fromCache: false };
124
+ }
125
+ // The table genuinely has no committed data: drop any stale pointer.
126
+ await this.storage.delete(this.ns(key));
127
+ return { bytes: null, fromCache: false };
128
+ } catch (error) {
129
+ if (isAccessRevoked(error)) throw error; // already purged by fetch()
130
+ const cached = await this.storage.get(this.ns(key));
131
+ if (cached) {
132
+ // Offline fallback is bounded: past maxOfflineMs the data is deleted
133
+ // rather than served un-re-verified.
134
+ await this.ensureWithinOfflineBound();
135
+ return { bytes: cached, fromCache: true };
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ private async fetch(key: string): Promise<Uint8Array | null> {
142
+ try {
143
+ const bytes = await this.source.fetchFile(key);
144
+ await this.stampVerified();
145
+ return bytes;
146
+ } catch (error) {
147
+ if (isAccessRevoked(error)) {
148
+ // Revoked access ⇒ this identity's local data must not outlive it.
149
+ await this.purge();
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Seed a WASM engine by walking each table's Iceberg pointers through the
157
+ * cache: `version-hint.text` → `v{N}.metadata.json` → that snapshot's data
158
+ * files. On a warm cache with no new commits this costs one small read per
159
+ * table; everything else is served locally. Files from superseded snapshots
160
+ * are pruned from the cache as tables move forward.
161
+ */
162
+ async seed(
163
+ engine: SeedableEngine,
164
+ resources: { app: string; model: string }[],
165
+ ): Promise<SeedStats> {
166
+ const decoder = new TextDecoder();
167
+ const stats: SeedStats = { tables: 0, fromCache: 0, fetched: 0 };
168
+
169
+ for (const { app, model } of resources) {
170
+ const prefix = `${app}.db/${model}`;
171
+ const hintKey = `${prefix}/metadata/version-hint.text`;
172
+
173
+ const hint = await this.getHint(hintKey);
174
+ if (!hint.bytes) continue; // no committed data for this table
175
+ hint.fromCache ? stats.fromCache++ : stats.fetched++;
176
+ await engine.seedFile(hintKey, hint.bytes);
177
+ const version = decoder.decode(hint.bytes).trim();
178
+
179
+ const metaKey = `${prefix}/metadata/v${version}.metadata.json`;
180
+ const metaBytes = await this.trackImmutable(metaKey, stats);
181
+ if (!metaBytes) continue;
182
+ await engine.seedFile(metaKey, metaBytes);
183
+
184
+ const meta = JSON.parse(decoder.decode(metaBytes)) as {
185
+ data_files?: string[];
186
+ };
187
+ const live = new Set([hintKey, metaKey]);
188
+ for (const rel of meta.data_files || []) {
189
+ const key = `${prefix}/${rel}`;
190
+ live.add(key);
191
+ const bytes = await this.trackImmutable(key, stats);
192
+ if (bytes) await engine.seedFile(key, bytes);
193
+ }
194
+ await this.pruneTable(prefix, live);
195
+ stats.tables++;
196
+ }
197
+ return stats;
198
+ }
199
+
200
+ private async trackImmutable(key: string, stats: SeedStats): Promise<Uint8Array | null> {
201
+ const hadCached = (await this.storage.get(this.ns(key))) !== null;
202
+ const bytes = await this.getImmutable(key);
203
+ if (bytes) hadCached ? stats.fromCache++ : stats.fetched++;
204
+ return bytes;
205
+ }
206
+
207
+ /** Drop cached files of superseded snapshots for one table. */
208
+ private async pruneTable(tablePrefix: string, live: Set<string>): Promise<void> {
209
+ const keys = await this.storage.keys(this.ns(`${tablePrefix}/`));
210
+ for (const nsKey of keys) {
211
+ const key = nsKey.slice(this.ns("").length);
212
+ if (!live.has(key)) await this.storage.delete(nsKey);
213
+ }
214
+ }
215
+
216
+ /** Delete every cached file belonging to this identity. */
217
+ async purge(): Promise<void> {
218
+ await purgeIdentityCache(this.storage, this.identity);
219
+ }
220
+ }
221
+
222
+ /** Delete every cached warehouse file a given identity holds on this device. */
223
+ export async function purgeIdentityCache(
224
+ storage: CacheStorage,
225
+ identity: string | undefined,
226
+ ): Promise<void> {
227
+ const keys = await storage.keys(`${identity || ANONYMOUS}::`);
228
+ await Promise.all(keys.map((key) => storage.delete(key)));
229
+ }
230
+
231
+ /**
232
+ * A WarehouseFileSource over plain HTTP(S) — the committed `data/warehouse`
233
+ * directory in `local` mode, or a CloudFront/S3-website endpoint. 401/403
234
+ * surface as {@link AccessRevokedError}, which is what triggers local purge.
235
+ */
236
+ export function httpFileSource(
237
+ baseUrl: string,
238
+ init?: { headers?: () => Record<string, string> | Promise<Record<string, string>> },
239
+ ): WarehouseFileSource {
240
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
241
+ return {
242
+ async fetchFile(key: string): Promise<Uint8Array | null> {
243
+ const headers = init?.headers ? await init.headers() : undefined;
244
+ const response = await fetch(base + key, headers ? { headers } : undefined);
245
+ if (response.status === 404) return null;
246
+ if (response.status === 401 || response.status === 403) {
247
+ throw new AccessRevokedError(
248
+ `access to ${key} refused (${response.status})`,
249
+ response.status,
250
+ );
251
+ }
252
+ if (!response.ok) {
253
+ throw new Error(`failed to fetch ${key} (${response.status})`);
254
+ }
255
+ return new Uint8Array(await response.arrayBuffer());
256
+ },
257
+ };
258
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * At-rest encryption for the local cache: wrap any {@link CacheStorage} in
3
+ * AES-256-GCM (WebCrypto). Every value is sealed with a fresh random 12-byte
4
+ * IV (prepended to the ciphertext), so identical plaintexts never produce
5
+ * identical stored bytes and integrity is authenticated on read.
6
+ *
7
+ * Key custody stays with the app — pass a `CryptoKey` or an async provider:
8
+ * on web, generate a **non-extractable** key once and persist the CryptoKey
9
+ * object itself in IndexedDB (the raw bits never exist in JS); on React
10
+ * Native, keep the raw key in the platform keystore (Keychain / Android
11
+ * Keystore via expo-secure-store) and `importEncryptionKey` it at startup.
12
+ *
13
+ * A value that fails to decrypt (tampered, or written under a different key)
14
+ * reads as a cache miss — the cache re-fetches from the source rather than
15
+ * ever surfacing unauthenticated bytes.
16
+ */
17
+
18
+ import { CacheStorage } from "./types";
19
+
20
+ const IV_BYTES = 12;
21
+
22
+ function subtle(): any {
23
+ const subtleCrypto = (globalThis as any).crypto?.subtle;
24
+ if (!subtleCrypto) {
25
+ throw new Error(
26
+ "WebCrypto is unavailable; provide a platform crypto polyfill to use the encrypted cache.",
27
+ );
28
+ }
29
+ return subtleCrypto;
30
+ }
31
+
32
+ /** Generate a non-extractable AES-256-GCM cache key. */
33
+ export function generateEncryptionKey(extractable = false): Promise<any> {
34
+ return subtle().generateKey({ name: "AES-GCM", length: 256 }, extractable, [
35
+ "encrypt",
36
+ "decrypt",
37
+ ]);
38
+ }
39
+
40
+ /** Import a raw 32-byte key (e.g. from the platform keystore). */
41
+ export function importEncryptionKey(raw: Uint8Array): Promise<any> {
42
+ return subtle().importKey("raw", raw.slice().buffer, { name: "AES-GCM" }, false, [
43
+ "encrypt",
44
+ "decrypt",
45
+ ]);
46
+ }
47
+
48
+ export type EncryptionKeySource = any | (() => any | Promise<any>);
49
+
50
+ export function createEncryptedCacheStorage(
51
+ inner: CacheStorage,
52
+ key: EncryptionKeySource,
53
+ ): CacheStorage {
54
+ const resolveKey = async (): Promise<any> =>
55
+ typeof key === "function" ? key() : key;
56
+
57
+ return {
58
+ async get(name: string): Promise<Uint8Array | null> {
59
+ const sealed = await inner.get(name);
60
+ if (!sealed || sealed.length <= IV_BYTES) return null;
61
+ try {
62
+ const plain = await subtle().decrypt(
63
+ { name: "AES-GCM", iv: sealed.slice(0, IV_BYTES) },
64
+ await resolveKey(),
65
+ sealed.slice(IV_BYTES),
66
+ );
67
+ return new Uint8Array(plain);
68
+ } catch {
69
+ // Wrong key or tampered bytes: never surface unauthenticated data.
70
+ return null;
71
+ }
72
+ },
73
+
74
+ async put(name: string, bytes: Uint8Array): Promise<void> {
75
+ const iv = new Uint8Array(IV_BYTES);
76
+ (globalThis as any).crypto.getRandomValues(iv);
77
+ const sealed = new Uint8Array(
78
+ await subtle().encrypt(
79
+ { name: "AES-GCM", iv },
80
+ await resolveKey(),
81
+ bytes.slice().buffer,
82
+ ),
83
+ );
84
+ const out = new Uint8Array(IV_BYTES + sealed.length);
85
+ out.set(iv, 0);
86
+ out.set(sealed, IV_BYTES);
87
+ await inner.put(name, out);
88
+ },
89
+
90
+ delete(name: string): Promise<void> {
91
+ return inner.delete(name);
92
+ },
93
+
94
+ keys(prefix?: string): Promise<string[]> {
95
+ // Keys stay in the clear (they are warehouse paths, not row data) so
96
+ // namespace purge and pruning keep working.
97
+ return inner.keys(prefix);
98
+ },
99
+ };
100
+ }
package/cache/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export {
2
+ AccessRevokedError,
3
+ CacheExpiredError,
4
+ isAccessRevoked,
5
+ } from "./types";
6
+ export {
7
+ createEncryptedCacheStorage,
8
+ generateEncryptionKey,
9
+ importEncryptionKey,
10
+ } from "./encrypted";
11
+ export type { EncryptionKeySource } from "./encrypted";
12
+ export type {
13
+ CacheStorage,
14
+ SeedableEngine,
15
+ SeedStats,
16
+ WarehouseFileSource,
17
+ } from "./types";
18
+ export {
19
+ httpFileSource,
20
+ IcebergFileCache,
21
+ purgeIdentityCache,
22
+ } from "./cache";
23
+ export type { IcebergFileCacheOptions } from "./cache";
24
+ export {
25
+ createKeyValueCacheStorage,
26
+ defaultCacheStorage,
27
+ IndexedDbCacheStorage,
28
+ MemoryCacheStorage,
29
+ } from "./storage";
30
+ export type { KeyValueStore } from "./storage";
31
+ export { deleteIndexedDb, purgeLocalData } from "./revocation";
32
+ export type { PurgeLocalDataOptions } from "./revocation";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Local data deletion for access revocation and sign-out.
3
+ *
4
+ * The rule: local data never outlives the access that fetched it. When the
5
+ * backend says an identity may no longer read the data (revoked token, expired
6
+ * Cognito session that can't refresh, S3 AccessDenied), everything that
7
+ * identity holds on the device is deleted — the Iceberg file cache, the
8
+ * persisted warehouse snapshot, and the sync-engine store.
9
+ */
10
+
11
+ import { purgeIdentityCache } from "./cache";
12
+ import { CacheStorage } from "./types";
13
+
14
+ /** Delete an IndexedDB database outright. Resolves quietly where IDB is absent. */
15
+ export function deleteIndexedDb(name: string): Promise<void> {
16
+ const idb = (globalThis as any).indexedDB;
17
+ if (!idb) return Promise.resolve();
18
+ return new Promise((resolve, reject) => {
19
+ const request = idb.deleteDatabase(name);
20
+ request.onsuccess = () => resolve();
21
+ // `blocked` still deletes once open connections close; don't hang on it.
22
+ request.onblocked = () => resolve();
23
+ request.onerror = () => reject(request.error);
24
+ });
25
+ }
26
+
27
+ export interface PurgeLocalDataOptions {
28
+ /** The identity whose data is being removed (the Cognito `sub`). */
29
+ identity?: string;
30
+ /** The Iceberg file cache's storage — its identity namespace is emptied. */
31
+ cacheStorage?: CacheStorage;
32
+ /**
33
+ * IndexedDB databases to delete outright — the persisted warehouse snapshot
34
+ * (`rad-warehouse…`) and the sync-engine store, which hold row data derived
35
+ * from the revoked access.
36
+ */
37
+ databases?: string[];
38
+ }
39
+
40
+ /** Remove every locally persisted trace of an identity's data. */
41
+ export async function purgeLocalData(options: PurgeLocalDataOptions): Promise<void> {
42
+ const tasks: Promise<unknown>[] = [];
43
+ if (options.cacheStorage) {
44
+ tasks.push(purgeIdentityCache(options.cacheStorage, options.identity));
45
+ }
46
+ for (const name of options.databases || []) {
47
+ tasks.push(deleteIndexedDb(name));
48
+ }
49
+ await Promise.all(tasks);
50
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * CacheStorage implementations: IndexedDB for the web, an in-memory fallback
3
+ * for SSR/tests, and a key-value adapter that turns any React Native storage
4
+ * engine (expo-file-system, MMKV, expo-sqlite/kv-store, AsyncStorage) into a
5
+ * CacheStorage — the mobile answer to "IndexedDB on web".
6
+ *
7
+ * IndexedDB is reached through `globalThis` so this module type-checks (and
8
+ * degrades cleanly) on platforms compiled without DOM types.
9
+ */
10
+
11
+ import { CacheStorage } from "./types";
12
+
13
+ const STORE = "files";
14
+
15
+ function idbFactory(): any | undefined {
16
+ return (globalThis as any).indexedDB;
17
+ }
18
+
19
+ function openDb(name: string): Promise<any> {
20
+ return new Promise((resolve, reject) => {
21
+ const request = idbFactory()!.open(name, 1);
22
+ request.onupgradeneeded = () => {
23
+ const db = request.result;
24
+ if (!db.objectStoreNames.contains(STORE)) {
25
+ db.createObjectStore(STORE);
26
+ }
27
+ };
28
+ request.onsuccess = () => resolve(request.result);
29
+ request.onerror = () => reject(request.error);
30
+ });
31
+ }
32
+
33
+ export class IndexedDbCacheStorage implements CacheStorage {
34
+ private db: Promise<any>;
35
+
36
+ constructor(databaseName = "rad-iceberg-cache") {
37
+ this.db = openDb(databaseName);
38
+ }
39
+
40
+ private async run<T>(mode: "readonly" | "readwrite", op: (store: any) => any): Promise<T> {
41
+ const db = await this.db;
42
+ return new Promise((resolve, reject) => {
43
+ const tx = db.transaction(STORE, mode);
44
+ const request = op(tx.objectStore(STORE));
45
+ request.onsuccess = () => resolve(request.result);
46
+ request.onerror = () => reject(request.error);
47
+ });
48
+ }
49
+
50
+ async get(key: string): Promise<Uint8Array | null> {
51
+ const value = await this.run<ArrayBuffer | undefined>("readonly", (s) => s.get(key));
52
+ return value ? new Uint8Array(value) : null;
53
+ }
54
+
55
+ async put(key: string, bytes: Uint8Array): Promise<void> {
56
+ // Store a copy: the caller may reuse its buffer.
57
+ await this.run("readwrite", (s) => s.put(bytes.slice().buffer, key));
58
+ }
59
+
60
+ async delete(key: string): Promise<void> {
61
+ await this.run("readwrite", (s) => s.delete(key));
62
+ }
63
+
64
+ async keys(prefix = ""): Promise<string[]> {
65
+ const all = await this.run<unknown[]>("readonly", (s) => s.getAllKeys());
66
+ return all
67
+ .filter((k): k is string => typeof k === "string")
68
+ .filter((k) => k.startsWith(prefix));
69
+ }
70
+ }
71
+
72
+ /** In-memory CacheStorage — SSR, tests, and unsupported environments. */
73
+ export class MemoryCacheStorage implements CacheStorage {
74
+ private map = new Map<string, Uint8Array>();
75
+
76
+ async get(key: string): Promise<Uint8Array | null> {
77
+ const bytes = this.map.get(key);
78
+ return bytes ? bytes.slice() : null;
79
+ }
80
+
81
+ async put(key: string, bytes: Uint8Array): Promise<void> {
82
+ this.map.set(key, bytes.slice());
83
+ }
84
+
85
+ async delete(key: string): Promise<void> {
86
+ this.map.delete(key);
87
+ }
88
+
89
+ async keys(prefix = ""): Promise<string[]> {
90
+ return Array.from(this.map.keys()).filter((k) => k.startsWith(prefix));
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The minimal string key-value surface most React Native storage engines
96
+ * already expose (AsyncStorage verbatim; MMKV / expo-file-system / SQLite in a
97
+ * few lines).
98
+ */
99
+ export interface KeyValueStore {
100
+ getItem(key: string): Promise<string | null>;
101
+ setItem(key: string, value: string): Promise<void>;
102
+ removeItem(key: string): Promise<void>;
103
+ getAllKeys(): Promise<string[]>;
104
+ }
105
+
106
+ /**
107
+ * Adapt a string KeyValueStore into a byte CacheStorage (values are
108
+ * base64-encoded). This is how mobile apps plug their platform storage into
109
+ * the same Iceberg file cache the web uses:
110
+ *
111
+ * ```ts
112
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
113
+ * const storage = createKeyValueCacheStorage(AsyncStorage);
114
+ * ```
115
+ */
116
+ export function createKeyValueCacheStorage(kv: KeyValueStore): CacheStorage {
117
+ return {
118
+ async get(key) {
119
+ const value = await kv.getItem(key);
120
+ return value === null ? null : fromBase64(value);
121
+ },
122
+ async put(key, bytes) {
123
+ await kv.setItem(key, toBase64(bytes));
124
+ },
125
+ async delete(key) {
126
+ await kv.removeItem(key);
127
+ },
128
+ async keys(prefix = "") {
129
+ const all = await kv.getAllKeys();
130
+ return all.filter((k) => k.startsWith(prefix));
131
+ },
132
+ };
133
+ }
134
+
135
+ /** The default storage for the current platform. */
136
+ export function defaultCacheStorage(databaseName?: string): CacheStorage {
137
+ if (idbFactory()) {
138
+ return new IndexedDbCacheStorage(databaseName);
139
+ }
140
+ return new MemoryCacheStorage();
141
+ }
142
+
143
+ // -- base64 (btoa/atob on web, Buffer on node/RN polyfills) -------------------
144
+
145
+ function toBase64(bytes: Uint8Array): string {
146
+ const btoaFn = (globalThis as any).btoa;
147
+ if (typeof btoaFn === "function") {
148
+ let binary = "";
149
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
150
+ return btoaFn(binary);
151
+ }
152
+ return (globalThis as any).Buffer.from(bytes).toString("base64");
153
+ }
154
+
155
+ function fromBase64(value: string): Uint8Array {
156
+ const atobFn = (globalThis as any).atob;
157
+ if (typeof atobFn === "function") {
158
+ const binary = atobFn(value);
159
+ const bytes = new Uint8Array(binary.length);
160
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
161
+ return bytes;
162
+ }
163
+ return new Uint8Array((globalThis as any).Buffer.from(value, "base64"));
164
+ }
package/cache/types.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The local Iceberg file cache: types.
3
+ *
4
+ * The database is a set of Iceberg files on object storage, and the browser
5
+ * queries them through the WASM engine — so the local cache is a cache of
6
+ * *those files*, not a second database. Iceberg's layout makes this cheap and
7
+ * exact: snapshot metadata and data files are immutable (a new commit writes
8
+ * new files and swaps one tiny pointer, `version-hint.text`), so everything
9
+ * except the pointer can be cached forever and revalidation costs one small
10
+ * read.
11
+ */
12
+
13
+ /**
14
+ * Async byte storage behind the cache. Web uses {@link IndexedDbCacheStorage};
15
+ * React Native / Expo supply any key-value engine (expo-file-system, MMKV,
16
+ * expo-sqlite, AsyncStorage) through {@link createKeyValueCacheStorage} — the
17
+ * cache itself is platform-free.
18
+ */
19
+ export interface CacheStorage {
20
+ get(key: string): Promise<Uint8Array | null>;
21
+ put(key: string, bytes: Uint8Array): Promise<void>;
22
+ delete(key: string): Promise<void>;
23
+ /** Every stored key, optionally only those starting with `prefix`. */
24
+ keys(prefix?: string): Promise<string[]>;
25
+ }
26
+
27
+ /**
28
+ * Where warehouse files come from: a public HTTP directory in `local` mode, or
29
+ * SigV4-signed S3 GETs (Cognito Identity Pool temp credentials) in `s3` mode.
30
+ * Implementations MUST throw {@link AccessRevokedError} when the backend
31
+ * refuses access (HTTP 401/403, S3 AccessDenied, an expired identity that can
32
+ * no longer be refreshed) — that signal is what triggers local data deletion.
33
+ */
34
+ export interface WarehouseFileSource {
35
+ /** Fetch one warehouse object by key, or `null` if it does not exist. */
36
+ fetchFile(key: string): Promise<Uint8Array | null>;
37
+ }
38
+
39
+ /** The engine surface seeding needs (the WASM `RadApi`). */
40
+ export interface SeedableEngine {
41
+ seedFile(key: string, bytes: Uint8Array): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Access to the data has been revoked (or the identity can no longer prove
46
+ * it). The cache reacts by deleting everything it holds for that identity.
47
+ */
48
+ export class AccessRevokedError extends Error {
49
+ readonly status?: number;
50
+
51
+ constructor(message = "Access revoked", status?: number) {
52
+ super(message);
53
+ this.name = "AccessRevokedError";
54
+ this.status = status;
55
+ }
56
+ }
57
+
58
+ export function isAccessRevoked(error: unknown): error is AccessRevokedError {
59
+ return (
60
+ error instanceof AccessRevokedError ||
61
+ (error instanceof Error && error.name === "AccessRevokedError")
62
+ );
63
+ }
64
+
65
+ /**
66
+ * The cache exceeded its offline-retention bound (`maxOfflineMs`): access
67
+ * could not be re-verified for too long, so the cached data was deleted
68
+ * rather than served. Distinct from {@link AccessRevokedError} — access may
69
+ * still be fine, but it could not be proven.
70
+ */
71
+ export class CacheExpiredError extends Error {
72
+ constructor(message = "Cached data expired: access not re-verified in time") {
73
+ super(message);
74
+ this.name = "CacheExpiredError";
75
+ }
76
+ }
77
+
78
+ /** What a cached seed did — how much came from cache vs. the network. */
79
+ export interface SeedStats {
80
+ tables: number;
81
+ /** Files served straight from the local cache (no network). */
82
+ fromCache: number;
83
+ /** Files fetched from the source (then cached). */
84
+ fetched: number;
85
+ }