rastack 0.0.50 → 0.0.52

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/auth/context.ts +73 -0
  3. package/auth/core.ts +293 -0
  4. package/auth/index.ts +23 -0
  5. package/components/auto-form/AutoForm.tsx +32 -12
  6. package/components/auto-form/use-auto-form.ts +75 -6
  7. package/components/data-table/DataTable.tsx +72 -45
  8. package/components/data-table/use-data-table.ts +57 -3
  9. package/components/index.ts +2 -0
  10. package/db.ts +9 -0
  11. package/dist/admin.js +13 -13
  12. package/dist/compile/analyze.d.ts +1 -7
  13. package/dist/compile/analyze.js +19 -70
  14. package/dist/compile/entities.js +163 -16
  15. package/dist/compile/model.d.ts +7 -3
  16. package/dist/compile/openapi.js +13 -3
  17. package/dist/compile/program.d.ts +2 -1
  18. package/dist/compile/program.js +7 -2
  19. package/dist/define/auth.d.ts +27 -0
  20. package/dist/define/auth.js +42 -0
  21. package/dist/define/db.d.ts +24 -1
  22. package/dist/define/db.js +1 -1
  23. package/dist/define/index.d.ts +82 -18
  24. package/dist/define/index.js +27 -21
  25. package/dist/define/manifest.d.ts +64 -0
  26. package/dist/define/manifest.js +259 -0
  27. package/dist/define/markers.d.ts +80 -0
  28. package/dist/define/markers.js +37 -0
  29. package/dist/define/types.d.ts +10 -0
  30. package/dist/define/types.js +26 -0
  31. package/dist/plugin/core.d.ts +108 -0
  32. package/dist/plugin/core.js +198 -0
  33. package/dist/plugin/index.d.ts +112 -0
  34. package/dist/plugin/index.js +203 -0
  35. package/dist/wasm/rastack_wasm.js +1 -1
  36. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  37. package/hooks/data.ts +18 -6
  38. package/hooks/entity.ts +11 -5
  39. package/hooks/form/entity-form.ts +12 -8
  40. package/hooks/manifest.ts +50 -14
  41. package/hooks/registry.ts +25 -8
  42. package/package.json +1 -1
  43. package/plugin.ts +8 -0
  44. package/provider/index.ts +1 -5
  45. package/provider/provider.tsx +61 -16
  46. package/provider/types.ts +26 -7
  47. package/provider/warehouse.ts +4 -3
  48. package/provider/wasm.ts +3 -1
  49. package/runtime.ts +1 -0
  50. package/src/compile/analyze.ts +27 -65
  51. package/src/compile/entities.ts +193 -19
  52. package/src/compile/model.ts +8 -9
  53. package/src/compile/openapi.ts +11 -3
  54. package/src/compile/program.ts +10 -3
  55. package/src/define/auth.ts +28 -0
  56. package/src/define/db.ts +26 -4
  57. package/src/define/index.ts +118 -24
  58. package/src/define/manifest.ts +287 -0
  59. package/src/define/markers.ts +83 -0
  60. package/src/define/types.ts +11 -0
  61. package/src/plugin/core.ts +236 -0
  62. package/src/plugin/index.ts +243 -0
  63. package/test/auth.spec.ts +207 -0
  64. package/test/compile.spec.ts +18 -7
  65. package/test/components.spec.ts +116 -0
  66. package/test/entities.spec.ts +123 -6
  67. package/test/plugin.spec.ts +315 -0
  68. package/test/runtime-manifest.spec.ts +309 -0
  69. package/test/typed-hooks.spec.ts +7 -2
  70. package/types.ts +7 -2
  71. package/wasm/rastack_wasm.js +1 -1
  72. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -33,6 +33,14 @@ import {
33
33
  isAccessRevoked,
34
34
  purgeIdentityCache,
35
35
  } from "../cache";
36
+ import {
37
+ RastackAuthProvider,
38
+ userAuth,
39
+ type ManifestLike,
40
+ type RastackAuth,
41
+ } from "../auth";
42
+ import { toManifest } from "../src/define/manifest";
43
+ import { getGlobalManifest } from "../hooks/manifest";
36
44
 
37
45
  const RastackContext = createContext<RastackClient | null>(null);
38
46
 
@@ -57,22 +65,44 @@ export function RAStackProvider({
57
65
  const persistRef = useRef<(() => void) & { flush: () => void }>();
58
66
  const cacheStorageRef = useRef<CacheStorage | null>(null);
59
67
 
68
+ // The `manifest` prop is optional: when the rastack dev-server plugin is
69
+ // in the bundler it registers the compiled schema globally before the app
70
+ // boots, so `<RAStackProvider mode="local">` needs no schema wiring at
71
+ // all. An explicit prop (schema.rastack.json object/string, or resource()
72
+ // definitions — array or module namespace) always wins.
73
+ const manifest = useMemo(
74
+ () => toManifest(config.manifest) ?? getGlobalManifest(),
75
+ [config.manifest],
76
+ );
77
+
60
78
  const setStatusSafe = (next: RadStatus) => {
61
79
  setStatus(next);
62
80
  config.onStatusChange?.(next);
63
81
  };
64
82
 
83
+ // One resolved identity, whichever way it was supplied: the typed `auth`
84
+ // object, or the raw `identity` / `groups` strings (which win when both are
85
+ // given). This is what the engine runs as, what namespaces local bytes, and
86
+ // what the component tree gates its affordances with.
87
+ const auth: RastackAuth | undefined =
88
+ config.auth ??
89
+ (config.identity != null
90
+ ? userAuth(config.identity, config.groups ?? [])
91
+ : undefined);
92
+ const identity = config.identity ?? auth?.sub;
93
+ const groups = config.groups ?? auth?.groups ?? [];
94
+
65
95
  // Locally persisted state is namespaced per identity, so two accounts on
66
96
  // one device never share bytes.
67
97
  const warehouseDb = withIdentity(
68
98
  config.databaseName || "rastack-warehouse",
69
- config.identity,
99
+ identity,
70
100
  );
71
101
 
72
102
  /** Delete everything this identity persisted locally. */
73
103
  const purgeLocal = async () => {
74
104
  if (cacheStorageRef.current) {
75
- await purgeIdentityCache(cacheStorageRef.current, config.identity);
105
+ await purgeIdentityCache(cacheStorageRef.current, identity);
76
106
  }
77
107
  await deleteIndexedDb(warehouseDb);
78
108
  // Replicated rows in the sync engine's store are derived from the same
@@ -110,20 +140,22 @@ export function RAStackProvider({
110
140
  }
111
141
 
112
142
  // -- WASM modes (local / s3) --
113
- if (!config.manifest) {
114
- throw new Error("RAStackProvider: `manifest` is required in local/s3 mode.");
143
+ if (!manifest) {
144
+ throw new Error(
145
+ "RAStackProvider: no manifest found for local/s3 mode — add rastackPlugin() to your bundler (rastack/plugin), or pass the `manifest` prop.",
146
+ );
115
147
  }
116
148
 
117
149
  setStatusSafe("loading");
118
150
  const loader = config.loadEngine ?? defaultEngineLoader;
119
151
  const mod = await loader();
120
152
  if (cancelled) return;
121
- const engine = createEngine(mod, config.manifest, config.openapi);
153
+ const engine = createEngine(mod, manifest, config.openapi);
122
154
 
123
155
  // Run the in-browser engine as the signed-in identity so owner stamping
124
156
  // and row-level security match the server exactly.
125
- if (config.identity) {
126
- engine.setAuth?.(config.identity, config.groups ?? []);
157
+ if (identity) {
158
+ engine.setAuth?.(identity, groups);
127
159
  }
128
160
 
129
161
  // Warehouse persistence: IndexedDB for `local`, an S3 sink for `s3`.
@@ -140,7 +172,7 @@ export function RAStackProvider({
140
172
  await engine.importWarehouse(existing);
141
173
  } else {
142
174
  const base = config.warehouseBaseUrl ?? "/data/warehouse/";
143
- const resources = manifestResources(config.manifest);
175
+ const resources = manifestResources(manifest);
144
176
  if (config.cache === false) {
145
177
  await seedFromIceberg(engine, base, resources);
146
178
  } else {
@@ -152,7 +184,7 @@ export function RAStackProvider({
152
184
  const cache = new IcebergFileCache({
153
185
  storage,
154
186
  source: config.fileSource ?? httpFileSource(base),
155
- identity: config.identity,
187
+ identity,
156
188
  maxOfflineMs: config.maxOfflineMs,
157
189
  });
158
190
  await cache.seed(engine, resources);
@@ -177,7 +209,10 @@ export function RAStackProvider({
177
209
  persistRef.current = persist;
178
210
 
179
211
  engineRef.current = engine;
180
- configureApi({ baseURL: "", adapter: createWasmAdapter(engine, persist) });
212
+ configureApi({
213
+ baseURL: "",
214
+ adapter: createWasmAdapter(engine, persist),
215
+ });
181
216
  setStatusSafe("ready");
182
217
  }
183
218
 
@@ -195,18 +230,21 @@ export function RAStackProvider({
195
230
  engineRef.current = null;
196
231
  };
197
232
  // eslint-disable-next-line react-hooks/exhaustive-deps
198
- }, [config.mode, config.baseURL, config.warehouseBaseUrl, config.identity]);
233
+ }, [config.mode, config.baseURL, config.warehouseBaseUrl, identity]);
199
234
 
200
235
  const client = useMemo<RastackClient>(
201
236
  () => ({
202
237
  mode: config.mode,
203
238
  status,
239
+ auth,
204
240
  get engine() {
205
241
  return engineRef.current;
206
242
  },
207
243
  async exportWarehouse() {
208
244
  if (!engineRef.current) {
209
- throw new Error("exportWarehouse is only available in local/s3 mode.");
245
+ throw new Error(
246
+ "exportWarehouse is only available in local/s3 mode.",
247
+ );
210
248
  }
211
249
  return engineRef.current.exportWarehouse();
212
250
  },
@@ -225,13 +263,18 @@ export function RAStackProvider({
225
263
  },
226
264
  }),
227
265
  // eslint-disable-next-line react-hooks/exhaustive-deps
228
- [config.mode, status, config.identity],
266
+ [config.mode, status, identity],
229
267
  );
230
268
 
231
269
  return (
232
270
  <RastackContext.Provider value={client}>
233
- <RastackManifestProvider manifest={config.manifest}>
234
- {children}
271
+ <RastackManifestProvider manifest={manifest}>
272
+ <RastackAuthProvider
273
+ auth={auth}
274
+ manifest={manifest as ManifestLike | undefined}
275
+ >
276
+ {children}
277
+ </RastackAuthProvider>
235
278
  </RastackManifestProvider>
236
279
  </RastackContext.Provider>
237
280
  );
@@ -265,7 +308,9 @@ function withIdentity(name: string, identity?: string): string {
265
308
  export function useRastackClient(): RastackClient {
266
309
  const client = useContext(RastackContext);
267
310
  if (!client) {
268
- throw new Error("useRastackClient must be used within a <RAStackProvider>.");
311
+ throw new Error(
312
+ "useRastackClient must be used within a <RAStackProvider>.",
313
+ );
269
314
  }
270
315
  return client;
271
316
  }
package/provider/types.ts CHANGED
@@ -68,14 +68,24 @@ export interface RAStackProviderConfig {
68
68
  getToken?: () => string | Promise<string>;
69
69
 
70
70
  // -- identity & local-data security (all modes) --
71
+ /**
72
+ * The signed-in identity as a typed {@link import("../auth").RastackAuth} —
73
+ * the one auth object the whole stack shares. Supplies `identity`/`groups`
74
+ * when those aren't set, runs the WASM engine as this identity, and is
75
+ * provided to the component tree (via `rastack/auth`'s context) so
76
+ * `<AutoForm>` / `<DataTable>` gate their affordances against each
77
+ * resource's `permission` + `access` policy automatically.
78
+ */
79
+ auth?: import("../auth").RastackAuth;
71
80
  /**
72
81
  * The signed-in identity — the verified Cognito `sub`. It namespaces every
73
82
  * locally persisted byte (Iceberg file cache, warehouse snapshot DB), so
74
83
  * 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.
84
+ * subject the WASM engine stamps/filters owner rows with. Defaults to
85
+ * `auth.sub`.
76
86
  */
77
87
  identity?: string;
78
- /** Verified group claims (`cognito:groups`) for the WASM engine's auth. */
88
+ /** Verified group claims (`cognito:groups`). Defaults to `auth.groups`. */
79
89
  groups?: string[];
80
90
  /**
81
91
  * Fired when the data source reports this identity's access has been
@@ -98,12 +108,19 @@ export interface RAStackProviderConfig {
98
108
 
99
109
  // -- all modes --
100
110
  /**
101
- * `schema.rastack.json` — object or JSON string. Required for WASM modes
102
- * (it drives the in-browser engine), and recommended in `remote` mode too:
103
- * it is what lets the generic `useData(Entity)` / `useForm(Entity)` hooks
104
- * resolve a plain TypeScript type to its resource at runtime.
111
+ * The schema — **usually omitted**: with the `rastack/plugin` dev-server
112
+ * plugin in the bundler, the manifest compiled from your entities is
113
+ * registered globally before the app boots and the provider picks it up
114
+ * automatically. Pass it explicitly to override: the compiled
115
+ * `schema.rastack.json` (object or JSON string), the
116
+ * `virtual:rastack-manifest` module, or `resource()` definitions (an array
117
+ * or a module namespace), from which the provider builds the identical
118
+ * manifest at runtime. One of the two (plugin or prop) is required for
119
+ * WASM modes (the manifest drives the in-browser engine); it is also what
120
+ * lets `useData(Entity)` / `useForm(Entity)` resolve a plain TypeScript
121
+ * type to its resource at runtime.
105
122
  */
106
- manifest?: object | string;
123
+ manifest?: object | readonly object[] | string;
107
124
  /** `openapi.json` — object or JSON string. Optional (`/api/schema/`). */
108
125
  openapi?: object | string;
109
126
  /**
@@ -159,6 +176,8 @@ export type RadStatus =
159
176
  export interface RastackClient {
160
177
  mode: RadMode;
161
178
  status: RadStatus;
179
+ /** The resolved identity this client runs as (from `auth` or `identity`). */
180
+ auth?: import("../auth").RastackAuth;
162
181
  /** The live WASM engine (null in `remote` mode). */
163
182
  engine: RastackEngine | null;
164
183
  /** Snapshot the local warehouse to a portable blob (WASM modes only). */
@@ -26,8 +26,7 @@ export class WarehousePersistence {
26
26
  private db: Promise<IDBDatabase> | null;
27
27
 
28
28
  constructor(private databaseName: string) {
29
- this.db =
30
- typeof indexedDB !== "undefined" ? openDb(databaseName) : null;
29
+ this.db = typeof indexedDB !== "undefined" ? openDb(databaseName) : null;
31
30
  }
32
31
 
33
32
  get enabled(): boolean {
@@ -109,7 +108,9 @@ export async function seedFromIceberg(
109
108
  if (!metaBytes) continue;
110
109
  await engine.seedFile(metaKey, metaBytes);
111
110
 
112
- const meta = JSON.parse(decoder.decode(metaBytes)) as { data_files?: string[] };
111
+ const meta = JSON.parse(decoder.decode(metaBytes)) as {
112
+ data_files?: string[];
113
+ };
113
114
  for (const rel of meta.data_files || []) {
114
115
  const key = `${prefix}/${rel}`;
115
116
  const bytes = await fetchBytes(base + key);
package/provider/wasm.ts CHANGED
@@ -13,7 +13,9 @@ const runtimeImport: (specifier: string) => Promise<any> =
13
13
  new Function("s", "return import(s)") as any;
14
14
 
15
15
  export const defaultEngineLoader: RastackEngineLoader = async () => {
16
- const mod: RastackWasmModule = await runtimeImport("rastack/wasm/rastack_wasm.js");
16
+ const mod: RastackWasmModule = await runtimeImport(
17
+ "rastack/wasm/rastack_wasm.js",
18
+ );
17
19
  const init = mod.default || mod.init;
18
20
  if (typeof init === "function") {
19
21
  await init();
package/runtime.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./hooks";
2
2
  export * from "./types";
3
3
  export * from "./provider";
4
4
  export * from "./cache";
5
+ export * from "./auth";
@@ -1,18 +1,26 @@
1
1
  import * as path from "path";
2
2
  import * as ts from "typescript";
3
3
  import {
4
- AccessModel,
5
- AdminModel,
6
4
  FieldModel,
7
5
  RelationModel,
8
6
  ResourceModel,
9
7
  ScalarType,
10
- SyncModel,
11
8
  } from "./model";
12
9
  import { resourceSourceFiles } from "./program";
13
- import { normalizeTransitions } from "../validate/transitions";
10
+ import {
11
+ applyTransitions,
12
+ normaliseAccess,
13
+ normaliseAdmin,
14
+ normaliseSync,
15
+ } from "../define/manifest";
14
16
  import type { EntityTypeExport } from "./entities";
15
17
 
18
+ // The option normalisers live beside the runtime manifest builder
19
+ // (`define/manifest.ts`) and are shared with it, so a manifest built from
20
+ // runtime `resource()` values and one compiled from the same source can
21
+ // never disagree. Re-exported here for existing importers.
22
+ export { applyTransitions } from "../define/manifest";
23
+
16
24
  /**
17
25
  * Statically analyse resource definitions and produce the canonical model.
18
26
  *
@@ -61,7 +69,9 @@ export function analyzeDsl(
61
69
 
62
70
  // Deterministic order: app, then model.
63
71
  resources.sort((a, b) =>
64
- a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
72
+ a.app === b.app
73
+ ? a.model.localeCompare(b.model)
74
+ : a.app.localeCompare(b.app),
65
75
  );
66
76
  types.sort((a, b) => a.key.localeCompare(b.key));
67
77
  return { resources, types };
@@ -106,7 +116,12 @@ function analyzeResourceCall(
106
116
  const app = stringLiteralArg(call.arguments[0]);
107
117
  const model = stringLiteralArg(call.arguments[1]);
108
118
  const fieldsArg = call.arguments[2];
109
- if (!app || !model || !fieldsArg || !ts.isObjectLiteralExpression(fieldsArg)) {
119
+ if (
120
+ !app ||
121
+ !model ||
122
+ !fieldsArg ||
123
+ !ts.isObjectLiteralExpression(fieldsArg)
124
+ ) {
110
125
  return undefined;
111
126
  }
112
127
 
@@ -147,26 +162,6 @@ function analyzeResourceCall(
147
162
  return resource;
148
163
  }
149
164
 
150
- /**
151
- * Attach a normalised `transitions` block to a resource and derive what the
152
- * machine implies about its state field: the `states` become the field's
153
- * allowed-value set (`enum` in OpenAPI, the `member` gate in validation), and
154
- * `initial` becomes its default when the field doesn't declare one.
155
- */
156
- export function applyTransitions(resource: ResourceModel, raw: unknown): void {
157
- const transitions = normalizeTransitions(raw);
158
- if (!transitions) return;
159
- resource.transitions = transitions;
160
-
161
- const stateField = resource.fields.find((f) => f.name === transitions.field);
162
- if (stateField && stateField.type !== "fk") {
163
- stateField.options = [...transitions.states];
164
- if (stateField.default === undefined && transitions.initial !== undefined) {
165
- stateField.default = transitions.initial;
166
- }
167
- }
168
- }
169
-
170
165
  /**
171
166
  * Resolve a single field from its initializer. FK detection is type-driven:
172
167
  * the initializer's *type* is inspected, and a `Ref` type yields a foreign key.
@@ -190,7 +185,12 @@ function analyzeField(
190
185
  }
191
186
 
192
187
  // Scalar: the field type carries a `__rastackScalar` string-literal discriminant.
193
- const scalar = literalTypeProp(fieldType, "__rastackScalar", checker, initializer);
188
+ const scalar = literalTypeProp(
189
+ fieldType,
190
+ "__rastackScalar",
191
+ checker,
192
+ initializer,
193
+ );
194
194
  if (!scalar) return undefined;
195
195
 
196
196
  const field: FieldModel = { name, type: scalar as ScalarType };
@@ -280,41 +280,3 @@ export function literalToValue(node: ts.Expression | undefined): any {
280
280
  }
281
281
  return undefined;
282
282
  }
283
-
284
- function normaliseSync(sync: any): SyncModel | undefined {
285
- if (!sync || typeof sync !== "object") return undefined;
286
- const mode = sync.mode === "local" ? "local" : "server";
287
- if (mode === "server" && !sync.confirmed) {
288
- // A bare server resource carries no sync semantics.
289
- return undefined;
290
- }
291
- const model: SyncModel = { mode };
292
- if (typeof sync.scope === "string") model.scope = sync.scope;
293
- if (Array.isArray(sync.confirmed)) model.confirmed = sync.confirmed;
294
- if (sync.load === "lazy") model.load = "lazy";
295
- if (sync.clientIds === true) model.clientIds = true;
296
- return model;
297
- }
298
-
299
- function normaliseAccess(access: any): AccessModel | undefined {
300
- if (!access || typeof access !== "object") return undefined;
301
- const model: AccessModel = {};
302
- if (typeof access.ownerField === "string") model.ownerField = access.ownerField;
303
- if (access.scope === "owner" || access.scope === "shared") {
304
- model.scope = access.scope;
305
- }
306
- if (Array.isArray(access.adminGroups)) {
307
- model.adminGroups = access.adminGroups.filter(
308
- (g: unknown) => typeof g === "string",
309
- );
310
- }
311
- return Object.keys(model).length ? model : undefined;
312
- }
313
-
314
- function normaliseAdmin(admin: any): AdminModel | false | undefined {
315
- if (admin === false) return false;
316
- if (!admin || typeof admin !== "object") return undefined;
317
- const model: AdminModel = {};
318
- if (Array.isArray(admin.listDisplay)) model.listDisplay = admin.listDisplay;
319
- return model;
320
- }