rastack 0.0.63 → 0.0.69

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 (76) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -24
  3. package/admin/AdminApp.tsx +56 -3
  4. package/admin/httpEngine.ts +5 -6
  5. package/admin/index.tsx +11 -85
  6. package/admin/ui/icons.tsx +9 -0
  7. package/admin/ui/styles.ts +3 -0
  8. package/admin/useAdmin.ts +41 -4
  9. package/dist/admin.js +17 -22
  10. package/dist/compile/analyze.js +11 -1
  11. package/dist/compile/model.d.ts +1 -1
  12. package/dist/compile/run.d.ts +2 -2
  13. package/dist/compile/run.js +2 -2
  14. package/dist/define/index.d.ts +10 -4
  15. package/dist/define/index.js +2 -2
  16. package/dist/define/manifest.js +7 -1
  17. package/dist/deploy/io.d.ts +50 -0
  18. package/dist/deploy/io.js +124 -0
  19. package/dist/deploy/workflows.js +1 -1
  20. package/dist/dev/harness.d.ts +19 -27
  21. package/dist/dev/harness.js +31 -40
  22. package/dist/dev/native-server.d.ts +29 -0
  23. package/dist/dev/native-server.js +174 -0
  24. package/dist/external/envelope.d.ts +1 -1
  25. package/dist/external/envelope.js +1 -1
  26. package/dist/external/service.d.ts +2 -2
  27. package/dist/external/service.js +2 -2
  28. package/dist/external/types.d.ts +1 -1
  29. package/dist/external/types.js +1 -1
  30. package/dist/rastack-admin.d.ts +6 -14
  31. package/dist/rastack-admin.js +60 -88
  32. package/dist/rastack-bootstrap.js +18 -47
  33. package/dist/rastack-design.d.ts +1 -2
  34. package/dist/rastack-design.js +1 -2
  35. package/dist/rastack-dev.d.ts +7 -10
  36. package/dist/rastack-dev.js +68 -80
  37. package/dist/rastack-init.d.ts +6 -3
  38. package/dist/rastack-init.js +43 -30
  39. package/dist/rastack.d.ts +1 -1
  40. package/dist/rastack.js +5 -6
  41. package/dist/validate/transitions.d.ts +1 -1
  42. package/dist/validate/transitions.js +1 -1
  43. package/hooks/query/api.ts +4 -6
  44. package/package.json +2 -2
  45. package/provider/index.ts +0 -1
  46. package/provider/provider.tsx +31 -134
  47. package/provider/types.ts +30 -69
  48. package/src/compile/analyze.ts +11 -1
  49. package/src/compile/model.ts +1 -1
  50. package/src/compile/run.ts +2 -2
  51. package/src/define/index.ts +7 -4
  52. package/src/define/manifest.ts +7 -1
  53. package/src/deploy/io.ts +162 -1
  54. package/src/deploy/workflows.ts +1 -1
  55. package/src/dev/harness.ts +30 -43
  56. package/src/dev/native-server.ts +164 -0
  57. package/src/external/envelope.ts +1 -1
  58. package/src/external/service.ts +2 -2
  59. package/src/external/types.ts +1 -1
  60. package/src/rastack-admin.ts +66 -107
  61. package/src/rastack-bootstrap.ts +24 -69
  62. package/src/rastack-design.ts +1 -2
  63. package/src/rastack-dev.ts +79 -91
  64. package/src/rastack-init.ts +50 -41
  65. package/src/rastack.ts +5 -6
  66. package/src/validate/transitions.ts +1 -1
  67. package/dist/rastack-wasm-build.d.ts +0 -19
  68. package/dist/rastack-wasm-build.js +0 -94
  69. package/dist/wasm/package.json +0 -4
  70. package/dist/wasm/rastack_wasm.d.ts +0 -100
  71. package/dist/wasm/rastack_wasm.js +0 -745
  72. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  73. package/dist/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
  74. package/provider/wasm.ts +0 -126
  75. package/scripts/copy-wasm.js +0 -35
  76. package/src/rastack-wasm-build.ts +0 -113
@@ -14,23 +14,10 @@ import {
14
14
  RastackEngine,
15
15
  RAStackProviderConfig,
16
16
  RadStatus,
17
- WarehouseSource,
18
17
  } from "./types";
19
- import { createEngine, createWasmAdapter, defaultEngineLoader } from "./wasm";
20
18
  import {
21
- debounce,
22
- downloadBlob,
23
- manifestResources,
24
- seedFromIceberg,
25
- WarehousePersistence,
26
- } from "./warehouse";
27
- import {
28
- CacheStorage,
29
19
  defaultCacheStorage,
30
20
  deleteIndexedDb,
31
- httpFileSource,
32
- IcebergFileCache,
33
- isAccessRevoked,
34
21
  purgeIdentityCache,
35
22
  } from "../cache";
36
23
  import {
@@ -45,13 +32,18 @@ import { getGlobalManifest } from "../hooks/manifest";
45
32
  const RastackContext = createContext<RastackClient | null>(null);
46
33
 
47
34
  /**
48
- * The one place a React app configures *where the API runs* — over HTTP
49
- * (`remote`), in the browser via WebAssembly (`local`), or in the browser
50
- * against S3 (`s3`). It reconfigures the shared axios client in place, so every
51
- * generated hook keeps working with no changes.
35
+ * The one place a React app configures *where the API runs*. Every mode talks
36
+ * HTTP to a native Rust server (`rastack-server` locally, Lambda in
37
+ * production). The browser does not execute the engine.
38
+ *
39
+ * - `local` — same origin (or `baseURL`). `rastack dev` proxies `/api` to the
40
+ * native process whose warehouse is the on-disk directory.
41
+ * - `s3` — the same HTTP client. The native process owns an `s3://` warehouse.
42
+ * The page does not open the bucket.
43
+ * - `remote` — HTTP to `baseURL` (Lambda or a server you already started).
52
44
  *
53
45
  * ```tsx
54
- * <RAStackProvider mode="local" manifest={schema} warehouseBaseUrl="/data/warehouse/">
46
+ * <RAStackProvider mode="local" manifest={schema}>
55
47
  * <App />
56
48
  * </RAStackProvider>
57
49
  * ```
@@ -62,8 +54,6 @@ export function RAStackProvider({
62
54
  }: RAStackProviderConfig & { children: ReactNode }): JSX.Element {
63
55
  const [status, setStatus] = useState<RadStatus>("idle");
64
56
  const engineRef = useRef<RastackEngine | null>(null);
65
- const persistRef = useRef<(() => void) & { flush: () => void }>();
66
- const cacheStorageRef = useRef<CacheStorage | null>(null);
67
57
 
68
58
  // The `manifest` prop is optional: when the rastack dev-server plugin is
69
59
  // in the bundler it registers the compiled schema globally before the app
@@ -90,7 +80,6 @@ export function RAStackProvider({
90
80
  ? userAuth(config.identity, config.groups ?? [])
91
81
  : undefined);
92
82
  const identity = config.identity ?? auth?.sub;
93
- const groups = config.groups ?? auth?.groups ?? [];
94
83
 
95
84
  // Locally persisted state is namespaced per identity, so two accounts on
96
85
  // one device never share bytes.
@@ -99,10 +88,12 @@ export function RAStackProvider({
99
88
  identity,
100
89
  );
101
90
 
102
- /** Delete everything this identity persisted locally. */
91
+ /** Delete leftover device bytes (file cache, old warehouse snapshots). */
103
92
  const purgeLocal = async () => {
104
- if (cacheStorageRef.current) {
105
- await purgeIdentityCache(cacheStorageRef.current, identity);
93
+ try {
94
+ await purgeIdentityCache(defaultCacheStorage(), identity);
95
+ } catch {
96
+ /* no IndexedDB in this runtime */
106
97
  }
107
98
  await deleteIndexedDb(warehouseDb);
108
99
  // Replicated rows in the sync engine's store are derived from the same
@@ -132,87 +123,15 @@ export function RAStackProvider({
132
123
  });
133
124
  }
134
125
 
135
- if (config.mode === "remote") {
126
+ // Every mode is HTTP to a native server. `local` uses this origin
127
+ // (`rastack dev` proxies `/api`). `s3` is the same client — the native
128
+ // process holds the `s3://` warehouse. `remote` with no baseURL keeps
129
+ // the historical default (NEXT_PUBLIC_BASE_URL or 127.0.0.1:8000).
130
+ if (config.mode === "remote" && config.baseURL === undefined) {
131
+ resetApi();
132
+ } else {
136
133
  configureApi({ baseURL: config.baseURL ?? "", adapter: null });
137
- if (config.baseURL === undefined) resetApi();
138
- setStatusSafe("ready");
139
- return;
140
- }
141
-
142
- // -- WASM modes (local / s3) --
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
- );
147
- }
148
-
149
- setStatusSafe("loading");
150
- const loader = config.loadEngine ?? defaultEngineLoader;
151
- const mod = await loader();
152
- if (cancelled) return;
153
- const engine = createEngine(mod, manifest, config.openapi);
154
-
155
- // Run the in-browser engine as the signed-in identity so owner stamping
156
- // and row-level security match the server exactly.
157
- if (identity) {
158
- engine.setAuth?.(identity, groups);
159
- }
160
-
161
- // Warehouse persistence: IndexedDB for `local`, an S3 sink for `s3`.
162
- const persistence = pickPersistence(config, warehouseDb);
163
-
164
- setStatusSafe("seeding");
165
- try {
166
- // A persisted local copy (with the user's edits) wins; otherwise seed
167
- // from the warehouse's own Iceberg files — through the local file
168
- // cache by default, so a warm client revalidates one small pointer
169
- // per table and reads everything else from the device.
170
- const existing = await persistence.load();
171
- if (existing) {
172
- await engine.importWarehouse(existing);
173
- } else {
174
- const base = config.warehouseBaseUrl ?? "/data/warehouse/";
175
- const resources = manifestResources(manifest);
176
- if (config.cache === false) {
177
- await seedFromIceberg(engine, base, resources);
178
- } else {
179
- const storage =
180
- typeof config.cache === "object"
181
- ? config.cache
182
- : defaultCacheStorage();
183
- cacheStorageRef.current = storage;
184
- const cache = new IcebergFileCache({
185
- storage,
186
- source: config.fileSource ?? httpFileSource(base),
187
- identity,
188
- maxOfflineMs: config.maxOfflineMs,
189
- });
190
- await cache.seed(engine, resources);
191
- }
192
- }
193
- } catch (error) {
194
- if (isAccessRevoked(error)) {
195
- // Access is gone ⇒ so is every locally persisted byte it fetched.
196
- await handleAccessRevoked();
197
- return;
198
- }
199
- throw error;
200
134
  }
201
- if (cancelled) return;
202
-
203
- const persist = debounce(() => {
204
- void engine
205
- .exportWarehouse()
206
- .then((blob) => persistence.save(blob))
207
- .catch((error) => config.onError?.(error));
208
- }, 400);
209
- persistRef.current = persist;
210
-
211
- engineRef.current = engine;
212
- configureApi({
213
- baseURL: "",
214
- adapter: createWasmAdapter(engine, persist),
215
- });
216
135
  setStatusSafe("ready");
217
136
  }
218
137
 
@@ -224,7 +143,6 @@ export function RAStackProvider({
224
143
 
225
144
  return () => {
226
145
  cancelled = true;
227
- persistRef.current?.flush();
228
146
  if (ejectToken !== undefined) API.interceptors.request.eject(ejectToken);
229
147
  resetApi();
230
148
  engineRef.current = null;
@@ -241,19 +159,17 @@ export function RAStackProvider({
241
159
  return engineRef.current;
242
160
  },
243
161
  async exportWarehouse() {
244
- if (!engineRef.current) {
245
- throw new Error(
246
- "exportWarehouse is only available in local/s3 mode.",
247
- );
248
- }
249
- return engineRef.current.exportWarehouse();
162
+ throw new Error(
163
+ "The warehouse lives in the native rastack-server process. The browser does not snapshot it.",
164
+ );
250
165
  },
251
- async downloadWarehouse(filename = "warehouse-snapshot.json") {
252
- const blob = await this.exportWarehouse();
253
- downloadBlob(blob, filename);
166
+ async downloadWarehouse() {
167
+ throw new Error(
168
+ "The warehouse lives in the native rastack-server process. The browser does not snapshot it.",
169
+ );
254
170
  },
255
171
  async persistNow() {
256
- persistRef.current?.flush();
172
+ /* The native server writes the warehouse as it handles each request. */
257
173
  },
258
174
  async purgeLocal() {
259
175
  await purgeLocal();
@@ -280,25 +196,6 @@ export function RAStackProvider({
280
196
  );
281
197
  }
282
198
 
283
- function pickPersistence(
284
- config: RAStackProviderConfig,
285
- databaseName: string,
286
- ): WarehouseSource {
287
- if (config.mode === "s3") {
288
- if (!config.warehouse) {
289
- throw new Error(
290
- "RAStackProvider: `warehouse` (a WarehouseSource over S3) is required in s3 mode.",
291
- );
292
- }
293
- return config.warehouse;
294
- }
295
- if (config.persist === false) {
296
- return { load: async () => null, save: async () => {} };
297
- }
298
- const store = new WarehousePersistence(databaseName);
299
- return { load: () => store.load(), save: (blob) => store.save(blob) };
300
- }
301
-
302
199
  /** Suffix a local database/cache name with the identity that owns it. */
303
200
  function withIdentity(name: string, identity?: string): string {
304
201
  return identity ? `${name}::${identity}` : name;
@@ -315,7 +212,7 @@ export function useRastackClient(): RastackClient {
315
212
  return client;
316
213
  }
317
214
 
318
- /** Just the current status — handy for a loading gate while WASM boots. */
215
+ /** Just the current status — handy for a loading gate. */
319
216
  export function useRadStatus(): RadStatus {
320
217
  return useRastackClient().status;
321
218
  }
package/provider/types.ts CHANGED
@@ -2,19 +2,17 @@
2
2
  * Configuration for `<RAStackProvider>` — the single place a React app chooses
3
3
  * *where the API runs*:
4
4
  *
5
- * - `remote` — talk to the Rust API over HTTP (Lambda in prod, `rastack serve`
6
- * locally). The classic client/server split.
7
- * - `local` — compile-free local development: the entire API runs in the
8
- * browser via WebAssembly over an in-memory Iceberg warehouse, seeded from a
9
- * repo `data/` folder and persisted to IndexedDB. No server, no network,
10
- * instant reads/writes.
11
- * - `s3` — full client mode: the same WASM engine, but the warehouse is
12
- * loaded from and saved back to S3, so a static site reads and writes the
13
- * object-storage database directly.
5
+ * - `remote` — talk to the Rust API over HTTP (Lambda in prod, a
6
+ * `rastack-server` you already started).
7
+ * - `local` — HTTP to the native `rastack-server` that `rastack dev` starts
8
+ * and proxies at `/api` on this origin. The warehouse is that process's
9
+ * on-disk directory. The browser does not run the engine.
10
+ * - `s3` — the same HTTP client. The native server (Lambda, or
11
+ * `rastack-server` pointed at `s3://`) owns the bucket. The page does not.
14
12
  */
15
13
  export type RadMode = "remote" | "local" | "s3";
16
14
 
17
- /** The subset of the WASM `RastackApi` class the provider uses. */
15
+ /** The `handle` seam the admin console uses. Backed by HTTP to the native server. */
18
16
  export interface RastackEngine {
19
17
  handle(
20
18
  method: string,
@@ -22,34 +20,14 @@ export interface RastackEngine {
22
20
  query: Record<string, string> | undefined,
23
21
  body: unknown,
24
22
  ): Promise<{ status: number; body: any }>;
25
- /** Seed one Iceberg file (metadata or `.parquet` data file) at its key. */
23
+ /** Not available. The native server owns warehouse files. */
26
24
  seedFile(key: string, bytes: Uint8Array): Promise<void>;
27
25
  importWarehouse(blob: Uint8Array): Promise<void>;
28
26
  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
- */
27
+ /** Not used. Identity is the bearer token the native server verifies. */
34
28
  setAuth?(subject: string | undefined, groups?: string[]): void;
35
29
  }
36
30
 
37
- /** Constructor of the generated `RastackApi` class. */
38
- export interface RastackEngineCtor {
39
- new (manifestJson: string, openapiJson?: string): RastackEngine;
40
- }
41
-
42
- /** What a WASM bundle loader must resolve to. */
43
- export interface RastackWasmModule {
44
- /** wasm-pack `web` target exposes an init fn that fetches the `.wasm`. */
45
- default?: (input?: any) => Promise<any>;
46
- init?: (input?: any) => Promise<any>;
47
- RastackApi: RastackEngineCtor;
48
- }
49
-
50
- /** Load and initialise the compiled WASM engine module. */
51
- export type RastackEngineLoader = () => Promise<RastackWasmModule>;
52
-
53
31
  /** Where to read/write the warehouse blob in `s3` mode (or any custom sink). */
54
32
  export interface WarehouseSource {
55
33
  /** Fetch the current warehouse blob, or `null` if none exists yet. */
@@ -71,7 +49,7 @@ export interface RAStackProviderConfig {
71
49
  /**
72
50
  * The signed-in identity as a typed {@link import("../auth").RastackAuth} —
73
51
  * 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
52
+ * when those aren't set, and is
75
53
  * provided to the component tree (via `rastack/auth`'s context) so
76
54
  * `<AutoForm>` / `<DataTable>` gate their affordances against each
77
55
  * resource's `permission` + `access` policy automatically.
@@ -81,7 +59,7 @@ export interface RAStackProviderConfig {
81
59
  * The signed-in identity — the verified Cognito `sub`. It namespaces every
82
60
  * locally persisted byte (Iceberg file cache, warehouse snapshot DB), so
83
61
  * accounts sharing a device can't read each other's data, and it is the
84
- * subject the WASM engine stamps/filters owner rows with. Defaults to
62
+ * subject the native server stamps/filters owner rows with. Defaults to
85
63
  * `auth.sub`.
86
64
  */
87
65
  identity?: string;
@@ -115,50 +93,33 @@ export interface RAStackProviderConfig {
115
93
  * `schema.rastack.json` (object or JSON string), the
116
94
  * `virtual:rastack-manifest` module, or `resource()` definitions (an array
117
95
  * 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.
96
+ * manifest at runtime. The native server reads the compiled manifest from
97
+ * disk; this copy is what lets `useData(Entity)` / `useForm(Entity)`
98
+ * resolve a plain TypeScript type to its resource in the browser.
122
99
  */
123
100
  manifest?: object | readonly object[] | string;
124
101
  /** `openapi.json` — object or JSON string. Optional (`/api/schema/`). */
125
102
  openapi?: object | string;
126
103
  /**
127
- * Base URL of the committed Iceberg warehouse (a *directory of files*, not a
128
- * blob) to seed `local` mode from. The provider walks the tree —
129
- * `version-hint.text` → snapshot metadata → `.parquet` data files — exactly
130
- * as the server walks it over object storage. Defaults to `/data/warehouse/`.
131
- * Ignored once a persisted local copy exists.
104
+ * Unused by the request path. The native server opens the warehouse
105
+ * directory itself. Kept so existing call sites still type-check.
132
106
  */
133
107
  warehouseBaseUrl?: string;
134
- /** Persist the local warehouse to IndexedDB between reloads (default true). */
108
+ /** Unused. The native server writes the warehouse itself. */
135
109
  persist?: boolean;
136
110
  /** IndexedDB database name for the local warehouse. */
137
111
  databaseName?: string;
138
112
  /**
139
- * Cache the warehouse's Iceberg files locally (IndexedDB on web; supply a
140
- * `CacheStorage` for React Native) so repeat loads revalidate one tiny
141
- * pointer per table and read everything else from the device. `false`
142
- * disables caching; default on. Immutable Parquet/metadata files are cached
143
- * forever by key — invalidation is driven by Iceberg's own snapshot pointer.
113
+ * Unused by the request path. The native server reads the warehouse from
114
+ * disk or S3. Kept so existing call sites still type-check.
144
115
  */
145
116
  cache?: boolean | import("../cache").CacheStorage;
146
- /**
147
- * Where warehouse files are fetched from — defaults to HTTP GETs against
148
- * `warehouseBaseUrl`. Supply a SigV4-signing source (AWS SDK S3 client over
149
- * Cognito Identity Pool credentials) for `s3` mode. Must throw
150
- * `AccessRevokedError` on 401/403 so revocation deletes local data.
151
- */
117
+ /** Unused. The browser does not fetch warehouse files into an engine. */
152
118
  fileSource?: import("../cache").WarehouseFileSource;
153
- /** `s3` mode: how to load/save the warehouse blob from object storage. */
119
+ /** Unused. The native server owns the `s3://` warehouse. */
154
120
  warehouse?: WarehouseSource;
155
- /**
156
- * Override how the WASM module is loaded. Supply this from app code (where
157
- * your bundler can resolve the generated bundle) if the default loader can't
158
- * find it. Defaults to importing `rastack/wasm/rastack_wasm.js`.
159
- */
160
- loadEngine?: RastackEngineLoader;
161
-
121
+ /** Unused. The native server opens the warehouse directory on disk. */
122
+ seedWarehouse?: () => Uint8Array | null | Promise<Uint8Array | null>;
162
123
  // -- lifecycle --
163
124
  onStatusChange?: (status: RadStatus) => void;
164
125
  onError?: (error: unknown) => void;
@@ -166,8 +127,8 @@ export interface RAStackProviderConfig {
166
127
 
167
128
  export type RadStatus =
168
129
  | "idle"
169
- | "loading" // fetching/instantiating the WASM engine
170
- | "seeding" // importing the warehouse
130
+ | "loading" // contacting the native server
131
+ | "seeding" // unused; the native server opens the warehouse itself
171
132
  | "ready"
172
133
  | "revoked" // access revoked — local data has been deleted
173
134
  | "error";
@@ -178,13 +139,13 @@ export interface RastackClient {
178
139
  status: RadStatus;
179
140
  /** The resolved identity this client runs as (from `auth` or `identity`). */
180
141
  auth?: import("../auth").RastackAuth;
181
- /** The live WASM engine (null in `remote` mode). */
142
+ /** Always null. The engine is the native server, not an object in the page. */
182
143
  engine: RastackEngine | null;
183
- /** Snapshot the local warehouse to a portable blob (WASM modes only). */
144
+ /** Throws. The native server owns the warehouse. */
184
145
  exportWarehouse(): Promise<Uint8Array>;
185
- /** Download that snapshot as a file — a portable backup of local edits. */
146
+ /** Throws. The native server owns the warehouse. */
186
147
  downloadWarehouse(filename?: string): Promise<void>;
187
- /** Force-persist now (IndexedDB and/or the `s3` sink). */
148
+ /** No-op. The native server writes each request as it handles it. */
188
149
  persistNow(): Promise<void>;
189
150
  /**
190
151
  * Delete everything this identity has persisted on the device — the Iceberg
@@ -179,7 +179,17 @@ function analyzeField(
179
179
  const app = literalTypeProp(fieldType, "__app", checker, initializer);
180
180
  const model = literalTypeProp(fieldType, "__model", checker, initializer);
181
181
  if (app && model) {
182
- return { name, type: "fk", relation: { app, model } };
182
+ const fk: FieldModel = { name, type: "fk", relation: { app, model } };
183
+ // `s.ref(() => X, { null: true })` — a nullable relation (a shared secret
184
+ // with no project, an unassigned task). The options object is the ref
185
+ // call's second argument, read the same way scalar constraints are.
186
+ if (ts.isCallExpression(initializer)) {
187
+ const opts = literalToValue(initializer.arguments[1]);
188
+ if (opts && typeof opts === "object" && opts.null === true) {
189
+ fk.null = true;
190
+ }
191
+ }
192
+ return fk;
183
193
  }
184
194
  return undefined;
185
195
  }
@@ -87,7 +87,7 @@ export interface TransitionEdgeModel {
87
87
  /**
88
88
  * A resource's declarative state machine, keyed off one state field. Compiled
89
89
  * into the manifest and enforced on every write surface: the TS record machine
90
- * (forms, `rastack import`) and `rastack-api-core` (server, Lambda, WASM) all
90
+ * (forms, `rastack import`) and `rastack-api-core` (the native server) all
91
91
  * reject a write that jumps between states without a declared edge.
92
92
  */
93
93
  export interface TransitionsModel {
@@ -9,8 +9,8 @@ import { compile } from "./index";
9
9
  *
10
10
  * This is the single entry point the two real modalities of use share instead
11
11
  * of shelling out to a standalone `compile` command:
12
- * • `rastack dev` / `rastack admin` — local development (compile, then serve
13
- * the app + admin in the browser over WASM), and
12
+ * • `rastack dev` / `rastack admin` — local development (compile, then start
13
+ * the native rastack-server; the browser is only the UI), and
14
14
  * • `rastack ci` — the deploy pipeline (compile the manifest the Rust backend
15
15
  * reads + the OpenAPI contract, then build/ship).
16
16
  */
@@ -66,6 +66,9 @@ export interface Ref<App extends string, Model extends string> {
66
66
  readonly __model: Model;
67
67
  /** The authored `() => Resource` thunk — carried at runtime. */
68
68
  readonly target?: () => ResourceType<App, Model, any>;
69
+ /** Optional FK constraints — `null: true` makes the relation nullable
70
+ * (e.g. a shared secret with no project, or an unassigned task). */
71
+ readonly options?: { null?: boolean };
69
72
  }
70
73
 
71
74
  export interface StringOptions {
@@ -153,8 +156,9 @@ export const s = {
153
156
  */
154
157
  ref<A extends string, M extends string>(
155
158
  thunk: () => ResourceType<A, M, any>,
159
+ options?: { null?: boolean },
156
160
  ): Ref<A, M> {
157
- return { __rastackRef: true, target: thunk } as Ref<A, M>;
161
+ return { __rastackRef: true, target: thunk, options } as Ref<A, M>;
158
162
  },
159
163
  };
160
164
 
@@ -171,8 +175,7 @@ export interface SyncOptions {
171
175
 
172
176
  /**
173
177
  * Row-level access control. Enforced by `rastack-api-core`, so the rules are
174
- * identical on the server (Lambda / `rastack serve`) and in the in-browser WASM
175
- * engine.
178
+ * identical on the native server (laptop and Lambda).
176
179
  *
177
180
  * - `ownerField` — the field that records the owning identity (the verified
178
181
  * token `sub`). Rows are only listed/read/written by their owner; the field
@@ -212,7 +215,7 @@ export type Permission = "authenticatedOrReadOnly" | "authenticated" | "public";
212
215
  * transition may fire from, `to` the state it enters, and `set` the effect —
213
216
  * field patches the engine applies alongside the state change. The block is
214
217
  * static data: it compiles into the manifest and is enforced by
215
- * `rastack-api-core` on every surface (server, Lambda, in-browser WASM), so a
218
+ * `rastack-api-core` on the native server (laptop and Lambda), so a
216
219
  * transition behaves as a declarative, typesafe backend function with no
217
220
  * per-resource server code.
218
221
  */
@@ -121,11 +121,17 @@ function runtimeField(
121
121
  `manifestFromResources: ${res.app}.${res.model}.${name} is an s.ref(...) whose target thunk did not return a resource() value`,
122
122
  );
123
123
  }
124
- return {
124
+ const fk: FieldModel = {
125
125
  name,
126
126
  type: "fk",
127
127
  relation: { app: target.app, model: target.model },
128
128
  };
129
+ // A nullable FK (`s.ref(() => X, { null: true })`) — e.g. a shared secret
130
+ // with no project — so the engine allows and stores a null relation.
131
+ if ((field.options as { null?: boolean } | undefined)?.null === true) {
132
+ fk.null = true;
133
+ }
134
+ return fk;
129
135
  }
130
136
 
131
137
  if (typeof field.__rastackScalar !== "string") return undefined;