rastack 0.0.19 → 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 +4 -0
- package/cache/cache.ts +258 -0
- package/cache/encrypted.ts +100 -0
- package/cache/index.ts +32 -0
- package/cache/revocation.ts +50 -0
- package/cache/storage.ts +164 -0
- package/cache/types.ts +85 -0
- package/dist/compile/analyze.js +15 -0
- package/dist/compile/index.d.ts +9 -1
- package/dist/compile/index.js +32 -6
- package/dist/compile/model.d.ts +12 -0
- package/dist/compile/openapi.d.ts +2 -1
- package/dist/compile/openapi.js +20 -1
- package/dist/define/index.d.ts +21 -0
- package/package.json +1 -1
- package/provider/provider.tsx +96 -14
- package/provider/types.ts +63 -0
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +17 -0
- package/src/compile/index.ts +31 -6
- package/src/compile/model.ts +13 -0
- package/src/compile/openapi.ts +17 -1
- package/src/define/index.ts +22 -0
- package/test/cache.spec.ts +263 -0
- package/test/compile.spec.ts +38 -1
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
|
+
}
|
package/dist/compile/analyze.js
CHANGED
|
@@ -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;
|
package/dist/compile/index.d.ts
CHANGED
|
@@ -8,7 +8,15 @@ export interface CompileResult {
|
|
|
8
8
|
openapi: any;
|
|
9
9
|
files: string[];
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
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[];
|
package/dist/compile/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|
package/dist/compile/model.d.ts
CHANGED
|
@@ -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;
|
package/dist/compile/openapi.js
CHANGED
|
@@ -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;
|
package/dist/define/index.d.ts
CHANGED
|
@@ -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
package/provider/provider.tsx
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
package/src/compile/analyze.ts
CHANGED
|
@@ -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;
|
package/src/compile/index.ts
CHANGED
|
@@ -17,16 +17,41 @@ export interface CompileResult {
|
|
|
17
17
|
files: string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|