rastack 0.0.20 → 0.0.22

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/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
+ }
@@ -106,6 +106,7 @@ function analyzeResourceCall(call, checker) {
106
106
  search: options.search,
107
107
  ordering: options.ordering,
108
108
  permission: options.permission,
109
+ access: normaliseAccess(options.access),
109
110
  };
110
111
  }
111
112
  /**
@@ -229,6 +230,20 @@ function normaliseSync(sync) {
229
230
  model.clientIds = true;
230
231
  return model;
231
232
  }
233
+ function normaliseAccess(access) {
234
+ if (!access || typeof access !== "object")
235
+ return undefined;
236
+ const model = {};
237
+ if (typeof access.ownerField === "string")
238
+ model.ownerField = access.ownerField;
239
+ if (access.scope === "owner" || access.scope === "shared") {
240
+ model.scope = access.scope;
241
+ }
242
+ if (Array.isArray(access.adminGroups)) {
243
+ model.adminGroups = access.adminGroups.filter((g) => typeof g === "string");
244
+ }
245
+ return Object.keys(model).length ? model : undefined;
246
+ }
232
247
  function normaliseAdmin(admin) {
233
248
  if (admin === false)
234
249
  return false;
@@ -8,7 +8,15 @@ export interface CompileResult {
8
8
  openapi: any;
9
9
  files: string[];
10
10
  }
11
- /** List every `.ts` resource file under a directory (or accept explicit files). */
11
+ /**
12
+ * Every `.ts` resource file under a directory (recursively), or an explicit
13
+ * file. Recursion is what lets resource definitions live **co-located with the
14
+ * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
15
+ * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
16
+ * `.tsx` route components and `.d.ts` files are skipped; only files with a
17
+ * `resource(...)` call contribute to the manifest, so co-located helpers are
18
+ * harmless.
19
+ */
12
20
  export declare function resolveResourceFiles(input: string): string[];
13
21
  /** Analyse resource definitions into the canonical model (no file output). */
14
22
  export declare function analyzeResources(input: string): ResourceModel[];
@@ -55,17 +55,43 @@ Object.defineProperty(exports, "buildManifest", { enumerable: true, get: functio
55
55
  var openapi_2 = require("./openapi");
56
56
  Object.defineProperty(exports, "buildOpenApi", { enumerable: true, get: function () { return openapi_2.buildOpenApi; } });
57
57
  __exportStar(require("./model"), exports);
58
- /** List every `.ts` resource file under a directory (or accept explicit files). */
58
+ /** Directories never worth walking for resource definitions. */
59
+ const IGNORE_DIRS = new Set([
60
+ "node_modules",
61
+ ".git",
62
+ ".expo",
63
+ ".rad",
64
+ "dist",
65
+ "web-build",
66
+ ]);
67
+ /**
68
+ * Every `.ts` resource file under a directory (recursively), or an explicit
69
+ * file. Recursion is what lets resource definitions live **co-located with the
70
+ * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
71
+ * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
72
+ * `.tsx` route components and `.d.ts` files are skipped; only files with a
73
+ * `resource(...)` call contribute to the manifest, so co-located helpers are
74
+ * harmless.
75
+ */
59
76
  function resolveResourceFiles(input) {
60
77
  const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
61
78
  if (stat?.isFile())
62
79
  return [path.resolve(input)];
63
80
  if (stat?.isDirectory()) {
64
- return fs
65
- .readdirSync(input)
66
- .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
67
- .map((f) => path.resolve(input, f))
68
- .sort();
81
+ const files = [];
82
+ const walk = (dir) => {
83
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
84
+ if (entry.isDirectory()) {
85
+ if (!IGNORE_DIRS.has(entry.name))
86
+ walk(path.join(dir, entry.name));
87
+ }
88
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
89
+ files.push(path.resolve(dir, entry.name));
90
+ }
91
+ }
92
+ };
93
+ walk(input);
94
+ return files.sort();
69
95
  }
70
96
  throw new Error(`No such resources path: ${input}`);
71
97
  }
@@ -37,6 +37,17 @@ export interface SyncModel {
37
37
  export interface AdminModel {
38
38
  listDisplay?: string[];
39
39
  }
40
+ /**
41
+ * Row-level access control, enforced by `rad-api-core` on every surface.
42
+ * `ownerField` names the field stamped with the authenticated subject;
43
+ * `scope: "owner"` places the table under a per-identity warehouse prefix
44
+ * (`tenants/{sub}/…`) so object-storage IAM can fence data per user.
45
+ */
46
+ export interface AccessModel {
47
+ ownerField?: string;
48
+ scope?: "owner" | "shared";
49
+ adminGroups?: string[];
50
+ }
40
51
  export interface ResourceModel {
41
52
  app: string;
42
53
  model: string;
@@ -47,6 +58,7 @@ export interface ResourceModel {
47
58
  search?: string[];
48
59
  ordering?: string[];
49
60
  permission?: string;
61
+ access?: AccessModel;
50
62
  }
51
63
  export interface Manifest {
52
64
  version: 1;
@@ -5,7 +5,8 @@ import { ResourceModel } from "./model";
5
5
  * on — `/api/{app}/v1/{model}/` URL segments and the `x-rastack-sync*` vendor
6
6
  * extensions — so those packages are unchanged. Foreign keys additionally carry
7
7
  * a new `x-rastack-relation` extension so downstream tooling (and the admin) can
8
- * follow relations.
8
+ * follow relations, and access-controlled resources carry `x-rastack-access`
9
+ * (owner field + tenant scope) so the client cache can partition per identity.
9
10
  */
10
11
  export declare function buildOpenApi(resources: ResourceModel[]): any;
11
12
  export declare function openApiToJson(doc: any): string;
@@ -8,7 +8,8 @@ exports.openApiToJson = openApiToJson;
8
8
  * on — `/api/{app}/v1/{model}/` URL segments and the `x-rastack-sync*` vendor
9
9
  * extensions — so those packages are unchanged. Foreign keys additionally carry
10
10
  * a new `x-rastack-relation` extension so downstream tooling (and the admin) can
11
- * follow relations.
11
+ * follow relations, and access-controlled resources carry `x-rastack-access`
12
+ * (owner field + tenant scope) so the client cache can partition per identity.
12
13
  */
13
14
  function buildOpenApi(resources) {
14
15
  const paths = {};
@@ -196,6 +197,7 @@ function queryParam(name, type) {
196
197
  * local resource replicates.
197
198
  */
198
199
  function syncStamp(operation, resource, verb) {
200
+ accessStamp(operation, resource);
199
201
  const sync = resource.sync;
200
202
  if (!sync || sync.mode !== "local")
201
203
  return operation;
@@ -208,6 +210,23 @@ function syncStamp(operation, resource, verb) {
208
210
  operation["x-rastack-sync-client-ids"] = true;
209
211
  return operation;
210
212
  }
213
+ /**
214
+ * Stamp `x-rastack-access` on an operation of an access-controlled resource so
215
+ * downstream tooling (hooks, the local cache) knows the owner field and whether
216
+ * the table is tenant-partitioned on object storage.
217
+ */
218
+ function accessStamp(operation, resource) {
219
+ const access = resource.access;
220
+ if (!access)
221
+ return;
222
+ const stamp = {};
223
+ if (access.ownerField)
224
+ stamp.ownerField = access.ownerField;
225
+ if (access.scope)
226
+ stamp.scope = access.scope;
227
+ if (Object.keys(stamp).length)
228
+ operation["x-rastack-access"] = stamp;
229
+ }
211
230
  function isConfirmed(confirmed, verb, operation) {
212
231
  if (!confirmed || !confirmed.length)
213
232
  return false;
@@ -86,6 +86,25 @@ export interface SyncOptions {
86
86
  load?: "instant" | "lazy";
87
87
  clientIds?: boolean;
88
88
  }
89
+ /**
90
+ * Row-level access control. Enforced by `rad-api-core`, so the rules are
91
+ * identical on the server (Lambda / `rad serve`) and in the in-browser WASM
92
+ * engine.
93
+ *
94
+ * - `ownerField` — the field that records the owning identity (the verified
95
+ * token `sub`). Rows are only listed/read/written by their owner; the field
96
+ * is stamped from the token on create and immutable on update.
97
+ * - `scope: "owner"` — additionally *physically* partitions the resource's
98
+ * Iceberg table per identity (`tenants/{sub}/{app}.db/{model}/…`), so
99
+ * object-storage credentials (e.g. Cognito Identity Pool temp creds) can be
100
+ * IAM-restricted to `tenants/${"${cognito-identity.amazonaws.com:sub}"}/*`.
101
+ * - `adminGroups` — token groups (`cognito:groups`) that bypass row filters.
102
+ */
103
+ export interface AccessOptions {
104
+ ownerField?: string;
105
+ scope?: "owner" | "shared";
106
+ adminGroups?: string[];
107
+ }
89
108
  export interface ResourceOptions {
90
109
  /** DRF-style search fields. */
91
110
  search?: string[];
@@ -99,6 +118,8 @@ export interface ResourceOptions {
99
118
  sync?: SyncOptions;
100
119
  /** Access policy (secure by default). */
101
120
  permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
121
+ /** Row-level security + physical tenant partitioning. */
122
+ access?: AccessOptions;
102
123
  }
103
124
  /**
104
125
  * A resource definition. `App`/`Model` are preserved as string-literal type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -23,6 +23,15 @@ import {
23
23
  seedFromIceberg,
24
24
  WarehousePersistence,
25
25
  } from "./warehouse";
26
+ import {
27
+ CacheStorage,
28
+ defaultCacheStorage,
29
+ deleteIndexedDb,
30
+ httpFileSource,
31
+ IcebergFileCache,
32
+ isAccessRevoked,
33
+ purgeIdentityCache,
34
+ } from "../cache";
26
35
 
27
36
  const RadContext = createContext<RadClient | null>(null);
28
37
 
@@ -45,12 +54,38 @@ export function RAStackProvider({
45
54
  const [status, setStatus] = useState<RadStatus>("idle");
46
55
  const engineRef = useRef<RadEngine | null>(null);
47
56
  const persistRef = useRef<(() => void) & { flush: () => void }>();
57
+ const cacheStorageRef = useRef<CacheStorage | null>(null);
48
58
 
49
59
  const setStatusSafe = (next: RadStatus) => {
50
60
  setStatus(next);
51
61
  config.onStatusChange?.(next);
52
62
  };
53
63
 
64
+ // Locally persisted state is namespaced per identity, so two accounts on
65
+ // one device never share bytes.
66
+ const warehouseDb = withIdentity(
67
+ config.databaseName || "rad-warehouse",
68
+ config.identity,
69
+ );
70
+
71
+ /** Delete everything this identity persisted locally. */
72
+ const purgeLocal = async () => {
73
+ if (cacheStorageRef.current) {
74
+ await purgeIdentityCache(cacheStorageRef.current, config.identity);
75
+ }
76
+ await deleteIndexedDb(warehouseDb);
77
+ // Replicated rows in the sync engine's store are derived from the same
78
+ // access — they go too.
79
+ await deleteIndexedDb(config.syncDatabaseName || "rad-sync");
80
+ };
81
+
82
+ /** Revocation entry point shared by automatic detection and app signals. */
83
+ const handleAccessRevoked = async () => {
84
+ await purgeLocal();
85
+ config.onAccessRevoked?.();
86
+ setStatusSafe("revoked");
87
+ };
88
+
54
89
  useEffect(() => {
55
90
  let cancelled = false;
56
91
  let ejectToken: number | undefined;
@@ -84,19 +119,51 @@ export function RAStackProvider({
84
119
  if (cancelled) return;
85
120
  const engine = createEngine(mod, config.manifest, config.openapi);
86
121
 
122
+ // Run the in-browser engine as the signed-in identity so owner stamping
123
+ // and row-level security match the server exactly.
124
+ if (config.identity) {
125
+ engine.setAuth?.(config.identity, config.groups ?? []);
126
+ }
127
+
87
128
  // Warehouse persistence: IndexedDB for `local`, an S3 sink for `s3`.
88
- const persistence = pickPersistence(config);
129
+ const persistence = pickPersistence(config, warehouseDb);
89
130
 
90
131
  setStatusSafe("seeding");
91
- // A persisted local copy (with the user's edits) wins; otherwise seed
92
- // from the committed Iceberg warehouse — a directory of files walked over
93
- // HTTP, not a blob.
94
- const existing = await persistence.load();
95
- if (existing) {
96
- await engine.importWarehouse(existing);
97
- } else {
98
- const base = config.warehouseBaseUrl ?? "/data/warehouse/";
99
- await seedFromIceberg(engine, base, manifestResources(config.manifest));
132
+ try {
133
+ // A persisted local copy (with the user's edits) wins; otherwise seed
134
+ // from the warehouse's own Iceberg files — through the local file
135
+ // cache by default, so a warm client revalidates one small pointer
136
+ // per table and reads everything else from the device.
137
+ const existing = await persistence.load();
138
+ if (existing) {
139
+ await engine.importWarehouse(existing);
140
+ } else {
141
+ const base = config.warehouseBaseUrl ?? "/data/warehouse/";
142
+ const resources = manifestResources(config.manifest);
143
+ if (config.cache === false) {
144
+ await seedFromIceberg(engine, base, resources);
145
+ } else {
146
+ const storage =
147
+ typeof config.cache === "object"
148
+ ? config.cache
149
+ : defaultCacheStorage();
150
+ cacheStorageRef.current = storage;
151
+ const cache = new IcebergFileCache({
152
+ storage,
153
+ source: config.fileSource ?? httpFileSource(base),
154
+ identity: config.identity,
155
+ maxOfflineMs: config.maxOfflineMs,
156
+ });
157
+ await cache.seed(engine, resources);
158
+ }
159
+ }
160
+ } catch (error) {
161
+ if (isAccessRevoked(error)) {
162
+ // Access is gone ⇒ so is every locally persisted byte it fetched.
163
+ await handleAccessRevoked();
164
+ return;
165
+ }
166
+ throw error;
100
167
  }
101
168
  if (cancelled) return;
102
169
 
@@ -127,7 +194,7 @@ export function RAStackProvider({
127
194
  engineRef.current = null;
128
195
  };
129
196
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, [config.mode, config.baseURL, config.warehouseBaseUrl]);
197
+ }, [config.mode, config.baseURL, config.warehouseBaseUrl, config.identity]);
131
198
 
132
199
  const client = useMemo<RadClient>(
133
200
  () => ({
@@ -149,14 +216,24 @@ export function RAStackProvider({
149
216
  async persistNow() {
150
217
  persistRef.current?.flush();
151
218
  },
219
+ async purgeLocal() {
220
+ await purgeLocal();
221
+ },
222
+ async reportAccessRevoked() {
223
+ await handleAccessRevoked();
224
+ },
152
225
  }),
153
- [config.mode, status],
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
227
+ [config.mode, status, config.identity],
154
228
  );
155
229
 
156
230
  return <RadContext.Provider value={client}>{children}</RadContext.Provider>;
157
231
  }
158
232
 
159
- function pickPersistence(config: RAStackProviderConfig): WarehouseSource {
233
+ function pickPersistence(
234
+ config: RAStackProviderConfig,
235
+ databaseName: string,
236
+ ): WarehouseSource {
160
237
  if (config.mode === "s3") {
161
238
  if (!config.warehouse) {
162
239
  throw new Error(
@@ -168,10 +245,15 @@ function pickPersistence(config: RAStackProviderConfig): WarehouseSource {
168
245
  if (config.persist === false) {
169
246
  return { load: async () => null, save: async () => {} };
170
247
  }
171
- const store = new WarehousePersistence(config.databaseName || "rad-warehouse");
248
+ const store = new WarehousePersistence(databaseName);
172
249
  return { load: () => store.load(), save: (blob) => store.save(blob) };
173
250
  }
174
251
 
252
+ /** Suffix a local database/cache name with the identity that owns it. */
253
+ function withIdentity(name: string, identity?: string): string {
254
+ return identity ? `${name}::${identity}` : name;
255
+ }
256
+
175
257
  /** Access the imperative Rad client (engine, status, warehouse helpers). */
176
258
  export function useRadClient(): RadClient {
177
259
  const client = useContext(RadContext);
package/provider/types.ts CHANGED
@@ -26,6 +26,12 @@ export interface RadEngine {
26
26
  seedFile(key: string, bytes: Uint8Array): Promise<void>;
27
27
  importWarehouse(blob: Uint8Array): Promise<void>;
28
28
  exportWarehouse(): Promise<Uint8Array>;
29
+ /**
30
+ * Run requests as a signed-in identity (Cognito `sub` + `cognito:groups`),
31
+ * so owner stamping / row-level security in the in-browser engine match the
32
+ * server. Optional for older engine bundles.
33
+ */
34
+ setAuth?(subject: string | undefined, groups?: string[]): void;
29
35
  }
30
36
 
31
37
  /** Constructor of the generated `RadApi` class. */
@@ -61,6 +67,35 @@ export interface RAStackProviderConfig {
61
67
  /** Attach a Bearer token to every request. */
62
68
  getToken?: () => string | Promise<string>;
63
69
 
70
+ // -- identity & local-data security (all modes) --
71
+ /**
72
+ * The signed-in identity — the verified Cognito `sub`. It namespaces every
73
+ * locally persisted byte (Iceberg file cache, warehouse snapshot DB), so
74
+ * accounts sharing a device can't read each other's data, and it is the
75
+ * subject the WASM engine stamps/filters owner rows with.
76
+ */
77
+ identity?: string;
78
+ /** Verified group claims (`cognito:groups`) for the WASM engine's auth. */
79
+ groups?: string[];
80
+ /**
81
+ * Fired when the data source reports this identity's access has been
82
+ * revoked (401/403/AccessDenied). By then the provider has already deleted
83
+ * the identity's local data; use this to route to sign-in.
84
+ */
85
+ onAccessRevoked?: () => void;
86
+ /**
87
+ * The sync engine's IndexedDB database (`rad-sync` by default) so
88
+ * revocation and `purgeLocal()` erase replicated rows too. Set to the same
89
+ * `databaseName` you pass the sync engine if you changed it.
90
+ */
91
+ syncDatabaseName?: string;
92
+ /**
93
+ * Offline-retention bound for the Iceberg file cache: cached data older
94
+ * than this without a successful access re-verification is deleted rather
95
+ * than served (`CacheExpiredError`). Unset = no bound.
96
+ */
97
+ maxOfflineMs?: number;
98
+
64
99
  // -- local / s3 mode --
65
100
  /** `schema.rad.json` — object or JSON string. Required for WASM modes. */
66
101
  manifest?: object | string;
@@ -78,6 +113,21 @@ export interface RAStackProviderConfig {
78
113
  persist?: boolean;
79
114
  /** IndexedDB database name for the local warehouse. */
80
115
  databaseName?: string;
116
+ /**
117
+ * Cache the warehouse's Iceberg files locally (IndexedDB on web; supply a
118
+ * `CacheStorage` for React Native) so repeat loads revalidate one tiny
119
+ * pointer per table and read everything else from the device. `false`
120
+ * disables caching; default on. Immutable Parquet/metadata files are cached
121
+ * forever by key — invalidation is driven by Iceberg's own snapshot pointer.
122
+ */
123
+ cache?: boolean | import("../cache").CacheStorage;
124
+ /**
125
+ * Where warehouse files are fetched from — defaults to HTTP GETs against
126
+ * `warehouseBaseUrl`. Supply a SigV4-signing source (AWS SDK S3 client over
127
+ * Cognito Identity Pool credentials) for `s3` mode. Must throw
128
+ * `AccessRevokedError` on 401/403 so revocation deletes local data.
129
+ */
130
+ fileSource?: import("../cache").WarehouseFileSource;
81
131
  /** `s3` mode: how to load/save the warehouse blob from object storage. */
82
132
  warehouse?: WarehouseSource;
83
133
  /**
@@ -97,6 +147,7 @@ export type RadStatus =
97
147
  | "loading" // fetching/instantiating the WASM engine
98
148
  | "seeding" // importing the warehouse
99
149
  | "ready"
150
+ | "revoked" // access revoked — local data has been deleted
100
151
  | "error";
101
152
 
102
153
  /** Imperative handle exposed via `useRadClient()`. */
@@ -111,4 +162,16 @@ export interface RadClient {
111
162
  downloadWarehouse(filename?: string): Promise<void>;
112
163
  /** Force-persist now (IndexedDB and/or the `s3` sink). */
113
164
  persistNow(): Promise<void>;
165
+ /**
166
+ * Delete everything this identity has persisted on the device — the Iceberg
167
+ * file cache, the warehouse snapshot DB, and the sync engine's DB. Call on
168
+ * sign-out; called automatically when the source reports access revoked.
169
+ */
170
+ purgeLocal(): Promise<void>;
171
+ /**
172
+ * Tell the provider access has been revoked (e.g. a Cognito refresh failed
173
+ * definitively in `remote` mode, where no seeding fetch would notice):
174
+ * purges local data, fires `onAccessRevoked`, sets status `revoked`.
175
+ */
176
+ reportAccessRevoked(): Promise<void>;
114
177
  }
package/runtime.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./hooks";
2
2
  export * from "./types";
3
3
  export * from "./provider";
4
+ export * from "./cache";
@@ -1,5 +1,6 @@
1
1
  import * as ts from "typescript";
2
2
  import {
3
+ AccessModel,
3
4
  AdminModel,
4
5
  FieldModel,
5
6
  RelationModel,
@@ -93,6 +94,7 @@ function analyzeResourceCall(
93
94
  search: options.search,
94
95
  ordering: options.ordering,
95
96
  permission: options.permission,
97
+ access: normaliseAccess(options.access),
96
98
  };
97
99
  }
98
100
 
@@ -215,6 +217,21 @@ function normaliseSync(sync: any): SyncModel | undefined {
215
217
  return model;
216
218
  }
217
219
 
220
+ function normaliseAccess(access: any): AccessModel | undefined {
221
+ if (!access || typeof access !== "object") return undefined;
222
+ const model: AccessModel = {};
223
+ if (typeof access.ownerField === "string") model.ownerField = access.ownerField;
224
+ if (access.scope === "owner" || access.scope === "shared") {
225
+ model.scope = access.scope;
226
+ }
227
+ if (Array.isArray(access.adminGroups)) {
228
+ model.adminGroups = access.adminGroups.filter(
229
+ (g: unknown) => typeof g === "string",
230
+ );
231
+ }
232
+ return Object.keys(model).length ? model : undefined;
233
+ }
234
+
218
235
  function normaliseAdmin(admin: any): AdminModel | false | undefined {
219
236
  if (admin === false) return false;
220
237
  if (!admin || typeof admin !== "object") return undefined;
@@ -17,16 +17,41 @@ export interface CompileResult {
17
17
  files: string[];
18
18
  }
19
19
 
20
- /** List every `.ts` resource file under a directory (or accept explicit files). */
20
+ /** Directories never worth walking for resource definitions. */
21
+ const IGNORE_DIRS = new Set([
22
+ "node_modules",
23
+ ".git",
24
+ ".expo",
25
+ ".rad",
26
+ "dist",
27
+ "web-build",
28
+ ]);
29
+
30
+ /**
31
+ * Every `.ts` resource file under a directory (recursively), or an explicit
32
+ * file. Recursion is what lets resource definitions live **co-located with the
33
+ * pages that consume them** — e.g. `app/airports/_schema.rad.ts` beside
34
+ * `app/airports/index.tsx` — instead of in one top-level `resources/` folder.
35
+ * `.tsx` route components and `.d.ts` files are skipped; only files with a
36
+ * `resource(...)` call contribute to the manifest, so co-located helpers are
37
+ * harmless.
38
+ */
21
39
  export function resolveResourceFiles(input: string): string[] {
22
40
  const stat = fs.existsSync(input) ? fs.statSync(input) : undefined;
23
41
  if (stat?.isFile()) return [path.resolve(input)];
24
42
  if (stat?.isDirectory()) {
25
- return fs
26
- .readdirSync(input)
27
- .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
28
- .map((f) => path.resolve(input, f))
29
- .sort();
43
+ const files: string[] = [];
44
+ const walk = (dir: string): void => {
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ if (entry.isDirectory()) {
47
+ if (!IGNORE_DIRS.has(entry.name)) walk(path.join(dir, entry.name));
48
+ } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
49
+ files.push(path.resolve(dir, entry.name));
50
+ }
51
+ }
52
+ };
53
+ walk(input);
54
+ return files.sort();
30
55
  }
31
56
  throw new Error(`No such resources path: ${input}`);
32
57
  }