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.
- package/CHANGELOG.md +4 -0
- package/auth/context.ts +73 -0
- package/auth/core.ts +293 -0
- package/auth/index.ts +23 -0
- package/components/auto-form/AutoForm.tsx +32 -12
- package/components/auto-form/use-auto-form.ts +75 -6
- package/components/data-table/DataTable.tsx +72 -45
- package/components/data-table/use-data-table.ts +57 -3
- package/components/index.ts +2 -0
- package/db.ts +9 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +1 -7
- package/dist/compile/analyze.js +19 -70
- package/dist/compile/entities.js +163 -16
- package/dist/compile/model.d.ts +7 -3
- package/dist/compile/openapi.js +13 -3
- package/dist/compile/program.d.ts +2 -1
- package/dist/compile/program.js +7 -2
- package/dist/define/auth.d.ts +27 -0
- package/dist/define/auth.js +42 -0
- package/dist/define/db.d.ts +24 -1
- package/dist/define/db.js +1 -1
- package/dist/define/index.d.ts +82 -18
- package/dist/define/index.js +27 -21
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +259 -0
- package/dist/define/markers.d.ts +80 -0
- package/dist/define/markers.js +37 -0
- package/dist/define/types.d.ts +10 -0
- package/dist/define/types.js +26 -0
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +18 -6
- package/hooks/entity.ts +11 -5
- package/hooks/form/entity-form.ts +12 -8
- package/hooks/manifest.ts +50 -14
- package/hooks/registry.ts +25 -8
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/index.ts +1 -5
- package/provider/provider.tsx +61 -16
- package/provider/types.ts +26 -7
- package/provider/warehouse.ts +4 -3
- package/provider/wasm.ts +3 -1
- package/runtime.ts +1 -0
- package/src/compile/analyze.ts +27 -65
- package/src/compile/entities.ts +193 -19
- package/src/compile/model.ts +8 -9
- package/src/compile/openapi.ts +11 -3
- package/src/compile/program.ts +10 -3
- package/src/define/auth.ts +28 -0
- package/src/define/db.ts +26 -4
- package/src/define/index.ts +118 -24
- package/src/define/manifest.ts +287 -0
- package/src/define/markers.ts +83 -0
- package/src/define/types.ts +11 -0
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- package/test/auth.spec.ts +207 -0
- package/test/compile.spec.ts +18 -7
- package/test/components.spec.ts +116 -0
- package/test/entities.spec.ts +123 -6
- package/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/typed-hooks.spec.ts +7 -2
- package/types.ts +7 -2
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/src/define/index.ts
CHANGED
|
@@ -23,14 +23,32 @@
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Every constraint a scalar builder may be handed, merged — the shape the
|
|
28
|
+
* runtime value carries so a manifest can be assembled without the compiler.
|
|
29
|
+
*/
|
|
30
|
+
export interface ScalarFieldOptions {
|
|
31
|
+
maxLength?: number;
|
|
32
|
+
unique?: boolean;
|
|
33
|
+
primaryKey?: boolean;
|
|
34
|
+
default?: unknown;
|
|
35
|
+
null?: boolean;
|
|
36
|
+
autoNow?: boolean;
|
|
37
|
+
autoNowAdd?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
/**
|
|
27
41
|
* A scalar field. The literal `__rastackScalar` type is what the compiler
|
|
28
42
|
* reads; `Nullable` phantom-carries the `null: true` option so {@link RowOf}
|
|
29
|
-
* can add `| null` to the inferred row type.
|
|
43
|
+
* can add `| null` to the inferred row type. The authored options ride along
|
|
44
|
+
* on the runtime value too, so `manifestFromResources()` can build the same
|
|
45
|
+
* manifest `rastack compile` emits — with no compile step at all.
|
|
30
46
|
*/
|
|
31
47
|
export interface Scalar<T extends string, Nullable extends boolean = boolean> {
|
|
32
48
|
readonly __rastackScalar: T;
|
|
33
49
|
readonly __rastackNullable?: Nullable;
|
|
50
|
+
/** The constraint options as authored — carried at runtime. */
|
|
51
|
+
readonly options?: ScalarFieldOptions;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
/** Whether an options literal declares `null: true` (as a type). */
|
|
@@ -38,12 +56,16 @@ type NullOf<O> = O extends { null: true } ? true : false;
|
|
|
38
56
|
|
|
39
57
|
/**
|
|
40
58
|
* A foreign-key field. `App`/`Model` are carried as string-literal type
|
|
41
|
-
* arguments so the compiler can recover the relation target purely from
|
|
59
|
+
* arguments so the compiler can recover the relation target purely from
|
|
60
|
+
* types; the target thunk is *also* kept on the runtime value so
|
|
61
|
+
* `manifestFromResources()` can recover the relation with no compiler.
|
|
42
62
|
*/
|
|
43
63
|
export interface Ref<App extends string, Model extends string> {
|
|
44
64
|
readonly __rastackRef: true;
|
|
45
65
|
readonly __app: App;
|
|
46
66
|
readonly __model: Model;
|
|
67
|
+
/** The authored `() => Resource` thunk — carried at runtime. */
|
|
68
|
+
readonly target?: () => ResourceType<App, Model, any>;
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
export interface StringOptions {
|
|
@@ -73,51 +95,66 @@ export interface DateOptions {
|
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
/**
|
|
76
|
-
* Field builders. Each returns a
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
98
|
+
* Field builders. Each returns a value whose *type* encodes the field kind
|
|
99
|
+
* (what the compiler reads — it analyses the source, it does not execute it)
|
|
100
|
+
* and whose runtime shape keeps the authored options/target, so the same
|
|
101
|
+
* definition doubles as its own manifest via `manifestFromResources()`.
|
|
80
102
|
*/
|
|
81
103
|
export const s = {
|
|
82
104
|
string<const O extends StringOptions = StringOptions>(
|
|
83
|
-
|
|
105
|
+
opts?: O,
|
|
84
106
|
): Scalar<"string", NullOf<O>> {
|
|
85
|
-
return { __rastackScalar: "string" } as Scalar<
|
|
107
|
+
return { __rastackScalar: "string", options: opts } as Scalar<
|
|
108
|
+
"string",
|
|
109
|
+
NullOf<O>
|
|
110
|
+
>;
|
|
86
111
|
},
|
|
87
112
|
int<const O extends NumberOptions = NumberOptions>(
|
|
88
|
-
|
|
113
|
+
opts?: O,
|
|
89
114
|
): Scalar<"int", NullOf<O>> {
|
|
90
|
-
return { __rastackScalar: "int" } as Scalar<"int", NullOf<O>>;
|
|
115
|
+
return { __rastackScalar: "int", options: opts } as Scalar<"int", NullOf<O>>;
|
|
91
116
|
},
|
|
92
117
|
float<const O extends NumberOptions = NumberOptions>(
|
|
93
|
-
|
|
118
|
+
opts?: O,
|
|
94
119
|
): Scalar<"float", NullOf<O>> {
|
|
95
|
-
return { __rastackScalar: "float" } as Scalar<
|
|
120
|
+
return { __rastackScalar: "float", options: opts } as Scalar<
|
|
121
|
+
"float",
|
|
122
|
+
NullOf<O>
|
|
123
|
+
>;
|
|
96
124
|
},
|
|
97
125
|
bool<const O extends BoolOptions = BoolOptions>(
|
|
98
|
-
|
|
126
|
+
opts?: O,
|
|
99
127
|
): Scalar<"bool", NullOf<O>> {
|
|
100
|
-
return { __rastackScalar: "bool" } as Scalar<
|
|
128
|
+
return { __rastackScalar: "bool", options: opts } as Scalar<
|
|
129
|
+
"bool",
|
|
130
|
+
NullOf<O>
|
|
131
|
+
>;
|
|
101
132
|
},
|
|
102
133
|
datetime<const O extends DateOptions = DateOptions>(
|
|
103
|
-
|
|
134
|
+
opts?: O,
|
|
104
135
|
): Scalar<"datetime", NullOf<O>> {
|
|
105
|
-
return { __rastackScalar: "datetime" } as Scalar<
|
|
136
|
+
return { __rastackScalar: "datetime", options: opts } as Scalar<
|
|
137
|
+
"datetime",
|
|
138
|
+
NullOf<O>
|
|
139
|
+
>;
|
|
106
140
|
},
|
|
107
141
|
uuid<const O extends StringOptions = StringOptions>(
|
|
108
|
-
|
|
142
|
+
opts?: O,
|
|
109
143
|
): Scalar<"uuid", NullOf<O>> {
|
|
110
|
-
return { __rastackScalar: "uuid" } as Scalar<
|
|
144
|
+
return { __rastackScalar: "uuid", options: opts } as Scalar<
|
|
145
|
+
"uuid",
|
|
146
|
+
NullOf<O>
|
|
147
|
+
>;
|
|
111
148
|
},
|
|
112
149
|
/**
|
|
113
150
|
* A foreign key. The target is `() => SomeResource`; the compiler resolves
|
|
114
|
-
* the relation from the *type* `Ref<App, Model>` this returns
|
|
115
|
-
*
|
|
151
|
+
* the relation from the *type* `Ref<App, Model>` this returns, and the
|
|
152
|
+
* runtime keeps the thunk so `manifestFromResources()` can resolve it too.
|
|
116
153
|
*/
|
|
117
154
|
ref<A extends string, M extends string>(
|
|
118
|
-
|
|
155
|
+
thunk: () => ResourceType<A, M, any>,
|
|
119
156
|
): Ref<A, M> {
|
|
120
|
-
return { __rastackRef: true } as Ref<A, M>;
|
|
157
|
+
return { __rastackRef: true, target: thunk } as Ref<A, M>;
|
|
121
158
|
},
|
|
122
159
|
};
|
|
123
160
|
|
|
@@ -140,18 +177,36 @@ export interface SyncOptions {
|
|
|
140
177
|
* - `ownerField` — the field that records the owning identity (the verified
|
|
141
178
|
* token `sub`). Rows are only listed/read/written by their owner; the field
|
|
142
179
|
* is stamped from the token on create and immutable on update.
|
|
180
|
+
* - `groupField` — the field that records the owning *group* (a verified
|
|
181
|
+
* `cognito:groups` value). Rows are only visible to members of their group,
|
|
182
|
+
* and the dataset defaults to per-group physical partitioning.
|
|
143
183
|
* - `scope: "owner"` — additionally *physically* partitions the resource's
|
|
144
184
|
* Iceberg table per identity (`tenants/{sub}/{app}.db/{model}/…`), so
|
|
145
185
|
* object-storage credentials (e.g. Cognito Identity Pool temp creds) can be
|
|
146
186
|
* IAM-restricted to `tenants/${"${cognito-identity.amazonaws.com:sub}"}/*`.
|
|
187
|
+
* `scope: "group"` partitions per group instead (`groups/{name}/…`);
|
|
188
|
+
* `scope: "shared"` keeps one table.
|
|
147
189
|
* - `adminGroups` — token groups (`cognito:groups`) that bypass row filters.
|
|
190
|
+
*
|
|
191
|
+
* Class/interface entities express the same policy in the type system — the
|
|
192
|
+
* `Owner`/`Group` field brands and `IsAuthenticated`/`AdminGroups<…>`/
|
|
193
|
+
* `OwnerScoped` markers from `rastack/auth`.
|
|
148
194
|
*/
|
|
149
195
|
export interface AccessOptions {
|
|
150
196
|
ownerField?: string;
|
|
151
|
-
|
|
197
|
+
groupField?: string;
|
|
198
|
+
scope?: "owner" | "group" | "shared";
|
|
152
199
|
adminGroups?: string[];
|
|
153
200
|
}
|
|
154
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The resource-level permission gate — which paths an identity may hit at all.
|
|
204
|
+
* Enforced by `rastack-api-core` before any row is read; mirrored client-side
|
|
205
|
+
* by `rastack/auth`'s `can()` so components hide what the server would reject.
|
|
206
|
+
* Unset means `authenticated` — secure by default.
|
|
207
|
+
*/
|
|
208
|
+
export type Permission = "authenticatedOrReadOnly" | "authenticated" | "public";
|
|
209
|
+
|
|
155
210
|
/**
|
|
156
211
|
* One edge of a resource's state machine. `from` is the state (or states) the
|
|
157
212
|
* transition may fire from, `to` the state it enters, and `set` the effect —
|
|
@@ -210,7 +265,7 @@ export interface ResourceOptions {
|
|
|
210
265
|
/** Local-first replication config. */
|
|
211
266
|
sync?: SyncOptions;
|
|
212
267
|
/** Access policy (secure by default). */
|
|
213
|
-
permission?:
|
|
268
|
+
permission?: Permission;
|
|
214
269
|
/** Row-level security + physical tenant partitioning. */
|
|
215
270
|
access?: AccessOptions;
|
|
216
271
|
/** Declarative state machine over one field, enforced on every write surface. */
|
|
@@ -310,3 +365,42 @@ export type RowOf<R> =
|
|
|
310
365
|
R extends ResourceType<string, string, infer F, ResourceOptions>
|
|
311
366
|
? { id: number | string } & { [K in keyof F]: FieldValueOf<F[K]> }
|
|
312
367
|
: never;
|
|
368
|
+
|
|
369
|
+
/** The `"app.model"` registry key a `resource()` definition implies. */
|
|
370
|
+
export type EntityKeyOf<R> =
|
|
371
|
+
R extends ResourceType<infer A, infer M, any, ResourceOptions>
|
|
372
|
+
? `${A}.${M}`
|
|
373
|
+
: never;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The `EntityTypes` registry entries a module of `resource()` definitions
|
|
377
|
+
* implies — the *hand-written* alternative to the `rastack-env.d.ts` that
|
|
378
|
+
* `rastack compile` emits. Point it at your resources module once and the
|
|
379
|
+
* registry tracks it forever, with no compile step in the loop:
|
|
380
|
+
*
|
|
381
|
+
* ```ts
|
|
382
|
+
* // rastack-env.d.ts — written once by you, never regenerated
|
|
383
|
+
* import type { EntityTypesOf } from "rastack/define";
|
|
384
|
+
* declare module "rastack/hooks/registry" {
|
|
385
|
+
* interface EntityTypes
|
|
386
|
+
* extends EntityTypesOf<typeof import("./resources/airports")> {}
|
|
387
|
+
* }
|
|
388
|
+
* ```
|
|
389
|
+
*
|
|
390
|
+
* Non-resource exports are dropped, so `typeof import("…")` over a whole
|
|
391
|
+
* module is safe. (Class entities carry their own row type — they never need
|
|
392
|
+
* the registry; interfaces still register via the compile-emitted file.)
|
|
393
|
+
*/
|
|
394
|
+
export type EntityTypesOf<M> = {
|
|
395
|
+
[K in keyof M as EntityKeyOf<M[K]>]: RowOf<M[K]>;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// The runtime manifest builder — a resource() definition is its own manifest.
|
|
399
|
+
export {
|
|
400
|
+
isResourceValue,
|
|
401
|
+
manifestFromResources,
|
|
402
|
+
resourceModel,
|
|
403
|
+
toManifest,
|
|
404
|
+
} from "./manifest";
|
|
405
|
+
export type { AnyResourceValue, ManifestInput } from "./manifest";
|
|
406
|
+
export type { Manifest, ResourceModel } from "../compile/model";
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime manifest builder — `resource()` definitions are their own
|
|
3
|
+
* manifest, **no `rastack compile` step required**.
|
|
4
|
+
*
|
|
5
|
+
* The DSL values carry everything the canonical manifest needs at runtime
|
|
6
|
+
* (field kinds, constraint options, FK target thunks, resource options), so
|
|
7
|
+
* this module assembles the exact `schema.rastack.json` shape the compiler
|
|
8
|
+
* emits — same fields, same relations, same normalisation — as a pure
|
|
9
|
+
* function:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { Airport, Terminal, Flight } from "./resources/airports";
|
|
13
|
+
*
|
|
14
|
+
* <RAStackProvider mode="local" manifest={[Airport, Terminal, Flight]}>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The compiler (`tools/src/compile/analyze.ts`) imports the option
|
|
18
|
+
* normalisers below, so the two paths cannot drift: `rastack compile` remains
|
|
19
|
+
* the way to get class/interface entities, `openapi.json`, and the artifacts
|
|
20
|
+
* the Rust backend and admin read — but the typed hooks and the in-browser
|
|
21
|
+
* engine need only this.
|
|
22
|
+
*
|
|
23
|
+
* Everything here is browser-safe: no `typescript`, no filesystem.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
AccessModel,
|
|
28
|
+
AdminModel,
|
|
29
|
+
FieldModel,
|
|
30
|
+
Manifest,
|
|
31
|
+
RelationModel,
|
|
32
|
+
ResourceModel,
|
|
33
|
+
ScalarType,
|
|
34
|
+
SyncModel,
|
|
35
|
+
} from "../compile/model";
|
|
36
|
+
import { normalizeTransitions } from "../validate/transitions";
|
|
37
|
+
import type { ResourceOptions, ResourceType } from "./index";
|
|
38
|
+
|
|
39
|
+
/** Any `resource()` value, generics erased. */
|
|
40
|
+
export type AnyResourceValue = ResourceType<string, string, any, any>;
|
|
41
|
+
|
|
42
|
+
/** Everything `toManifest()` understands — see {@link toManifest}. */
|
|
43
|
+
export type ManifestInput =
|
|
44
|
+
| Manifest
|
|
45
|
+
| object
|
|
46
|
+
| string
|
|
47
|
+
| AnyResourceValue
|
|
48
|
+
| readonly AnyResourceValue[];
|
|
49
|
+
|
|
50
|
+
/** Whether a value is a `resource()` definition. */
|
|
51
|
+
export function isResourceValue(value: unknown): value is AnyResourceValue {
|
|
52
|
+
return (
|
|
53
|
+
!!value &&
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
(value as { __rastack?: unknown }).__rastack === "resource"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One `resource()` value → its canonical {@link ResourceModel} — the same
|
|
61
|
+
* shape `rastack compile` derives from the source, produced from the runtime
|
|
62
|
+
* value instead.
|
|
63
|
+
*/
|
|
64
|
+
export function resourceModel(res: AnyResourceValue): ResourceModel {
|
|
65
|
+
const fields: FieldModel[] = [];
|
|
66
|
+
const relations: RelationModel[] = [];
|
|
67
|
+
|
|
68
|
+
for (const [name, value] of Object.entries(
|
|
69
|
+
(res.fields ?? {}) as Record<string, unknown>,
|
|
70
|
+
)) {
|
|
71
|
+
const field = runtimeField(res, name, value);
|
|
72
|
+
if (!field) continue;
|
|
73
|
+
fields.push(field);
|
|
74
|
+
if (field.type === "fk" && field.relation) {
|
|
75
|
+
relations.push({
|
|
76
|
+
field: name,
|
|
77
|
+
target: `${field.relation.app}.${field.relation.model}`,
|
|
78
|
+
kind: "fk",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const options = (res.options ?? {}) as ResourceOptions;
|
|
84
|
+
const model: ResourceModel = {
|
|
85
|
+
app: res.app,
|
|
86
|
+
model: res.model,
|
|
87
|
+
fields,
|
|
88
|
+
relations,
|
|
89
|
+
sync: normaliseSync(options.sync),
|
|
90
|
+
admin: normaliseAdmin(options.admin),
|
|
91
|
+
search: options.search,
|
|
92
|
+
ordering: options.ordering,
|
|
93
|
+
permission: options.permission,
|
|
94
|
+
access: normaliseAccess(options.access),
|
|
95
|
+
};
|
|
96
|
+
applyTransitions(model, options.transitions);
|
|
97
|
+
return model;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve one runtime field value to its {@link FieldModel}. */
|
|
101
|
+
function runtimeField(
|
|
102
|
+
res: AnyResourceValue,
|
|
103
|
+
name: string,
|
|
104
|
+
value: unknown,
|
|
105
|
+
): FieldModel | undefined {
|
|
106
|
+
if (!value || typeof value !== "object") return undefined;
|
|
107
|
+
const field = value as {
|
|
108
|
+
__rastackRef?: unknown;
|
|
109
|
+
__rastackScalar?: unknown;
|
|
110
|
+
target?: () => AnyResourceValue;
|
|
111
|
+
options?: Record<string, unknown>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Foreign key: the runtime value keeps the `() => Resource` thunk, so the
|
|
115
|
+
// relation target is recovered by calling it — the runtime twin of the
|
|
116
|
+
// compiler's "read the Ref<App, Model> type arguments" step.
|
|
117
|
+
if (field.__rastackRef === true) {
|
|
118
|
+
const target = field.target?.();
|
|
119
|
+
if (!isResourceValue(target)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`manifestFromResources: ${res.app}.${res.model}.${name} is an s.ref(...) whose target thunk did not return a resource() value`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
type: "fk",
|
|
127
|
+
relation: { app: target.app, model: target.model },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof field.__rastackScalar !== "string") return undefined;
|
|
132
|
+
const model: FieldModel = {
|
|
133
|
+
name,
|
|
134
|
+
type: field.__rastackScalar as ScalarType,
|
|
135
|
+
};
|
|
136
|
+
// Constraint mapping mirrors the compiler's `applyConstraints` exactly.
|
|
137
|
+
const opts = field.options;
|
|
138
|
+
if (opts && typeof opts === "object") {
|
|
139
|
+
if (typeof opts.maxLength === "number") model.maxLength = opts.maxLength;
|
|
140
|
+
if (opts.unique === true) model.unique = true;
|
|
141
|
+
if (opts.primaryKey === true) model.primaryKey = true;
|
|
142
|
+
if (opts.null === true) model.null = true;
|
|
143
|
+
if (opts.default !== undefined) model.default = opts.default;
|
|
144
|
+
}
|
|
145
|
+
return model;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Assemble the canonical manifest from `resource()` values at runtime —
|
|
150
|
+
* accepts the values themselves, arrays of them, or whole module namespaces
|
|
151
|
+
* (`import * as resources from "./resources"`), in any mix. Output order
|
|
152
|
+
* matches the compiler's (app, then model), so the result is byte-for-byte
|
|
153
|
+
* the manifest `rastack compile` would emit for the same definitions.
|
|
154
|
+
*/
|
|
155
|
+
export function manifestFromResources(
|
|
156
|
+
...inputs: Array<
|
|
157
|
+
AnyResourceValue | readonly AnyResourceValue[] | Record<string, unknown>
|
|
158
|
+
>
|
|
159
|
+
): Manifest {
|
|
160
|
+
const resources: ResourceModel[] = collectResources(inputs).map(resourceModel);
|
|
161
|
+
resources.sort((a, b) =>
|
|
162
|
+
a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
|
|
163
|
+
);
|
|
164
|
+
return { version: 1, resources };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Flatten values / arrays / module namespaces into the resource list. */
|
|
168
|
+
function collectResources(inputs: unknown[]): AnyResourceValue[] {
|
|
169
|
+
const out: AnyResourceValue[] = [];
|
|
170
|
+
const seen = new Set<AnyResourceValue>();
|
|
171
|
+
const push = (res: AnyResourceValue) => {
|
|
172
|
+
if (!seen.has(res)) {
|
|
173
|
+
seen.add(res);
|
|
174
|
+
out.push(res);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
for (const input of inputs) {
|
|
178
|
+
if (isResourceValue(input)) {
|
|
179
|
+
push(input);
|
|
180
|
+
} else if (Array.isArray(input)) {
|
|
181
|
+
for (const item of input) if (isResourceValue(item)) push(item);
|
|
182
|
+
} else if (input && typeof input === "object") {
|
|
183
|
+
for (const item of Object.values(input)) {
|
|
184
|
+
if (isResourceValue(item)) push(item);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Normalise anything a provider may be handed as "the manifest": a compiled
|
|
193
|
+
* manifest (object or JSON string), a `resource()` value, an array of them,
|
|
194
|
+
* or a module namespace containing them. This is what lets
|
|
195
|
+
* `<RAStackProvider manifest={[Airport, Terminal]}>` work with no compiled
|
|
196
|
+
* `schema.rastack.json` anywhere.
|
|
197
|
+
*/
|
|
198
|
+
export function toManifest(
|
|
199
|
+
input: ManifestInput | null | undefined,
|
|
200
|
+
): Manifest | undefined {
|
|
201
|
+
if (input === null || input === undefined) return undefined;
|
|
202
|
+
if (typeof input === "string") return JSON.parse(input) as Manifest;
|
|
203
|
+
if (Array.isArray(input) || isResourceValue(input)) {
|
|
204
|
+
return manifestFromResources(
|
|
205
|
+
input as AnyResourceValue | readonly AnyResourceValue[],
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const record = input as Record<string, unknown>;
|
|
209
|
+
// A compiled manifest passes through untouched.
|
|
210
|
+
if (Array.isArray(record.resources)) return input as Manifest;
|
|
211
|
+
// A module namespace of resource() exports builds one.
|
|
212
|
+
const values = Object.values(record).filter(isResourceValue);
|
|
213
|
+
if (values.length) return manifestFromResources(values);
|
|
214
|
+
return input as Manifest;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Option normalisers — shared with the compiler (`compile/analyze.ts`), so
|
|
219
|
+
// the runtime and compiled manifests can never disagree about a resource.
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Attach a normalised `transitions` block to a resource and derive what the
|
|
224
|
+
* machine implies about its state field: the `states` become the field's
|
|
225
|
+
* allowed-value set (`enum` in OpenAPI, the `member` gate in validation), and
|
|
226
|
+
* `initial` becomes its default when the field doesn't declare one.
|
|
227
|
+
*/
|
|
228
|
+
export function applyTransitions(resource: ResourceModel, raw: unknown): void {
|
|
229
|
+
const transitions = normalizeTransitions(raw);
|
|
230
|
+
if (!transitions) return;
|
|
231
|
+
resource.transitions = transitions;
|
|
232
|
+
|
|
233
|
+
const stateField = resource.fields.find((f) => f.name === transitions.field);
|
|
234
|
+
if (stateField && stateField.type !== "fk") {
|
|
235
|
+
stateField.options = [...transitions.states];
|
|
236
|
+
if (stateField.default === undefined && transitions.initial !== undefined) {
|
|
237
|
+
stateField.default = transitions.initial;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function normaliseSync(sync: any): SyncModel | undefined {
|
|
243
|
+
if (!sync || typeof sync !== "object") return undefined;
|
|
244
|
+
const mode = sync.mode === "local" ? "local" : "server";
|
|
245
|
+
if (mode === "server" && !sync.confirmed) {
|
|
246
|
+
// A bare server resource carries no sync semantics.
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const model: SyncModel = { mode };
|
|
250
|
+
if (typeof sync.scope === "string") model.scope = sync.scope;
|
|
251
|
+
if (Array.isArray(sync.confirmed)) model.confirmed = sync.confirmed;
|
|
252
|
+
if (sync.load === "lazy") model.load = "lazy";
|
|
253
|
+
if (sync.clientIds === true) model.clientIds = true;
|
|
254
|
+
return model;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function normaliseAccess(access: any): AccessModel | undefined {
|
|
258
|
+
if (!access || typeof access !== "object") return undefined;
|
|
259
|
+
const model: AccessModel = {};
|
|
260
|
+
if (typeof access.ownerField === "string") model.ownerField = access.ownerField;
|
|
261
|
+
if (typeof access.groupField === "string") model.groupField = access.groupField;
|
|
262
|
+
if (
|
|
263
|
+
access.scope === "owner" ||
|
|
264
|
+
access.scope === "group" ||
|
|
265
|
+
access.scope === "shared"
|
|
266
|
+
) {
|
|
267
|
+
model.scope = access.scope;
|
|
268
|
+
} else if (model.groupField) {
|
|
269
|
+
// Group-owned datasets are physically partitioned per group by default —
|
|
270
|
+
// that is their point. `scope: "shared"` opts out explicitly.
|
|
271
|
+
model.scope = "group";
|
|
272
|
+
}
|
|
273
|
+
if (Array.isArray(access.adminGroups)) {
|
|
274
|
+
model.adminGroups = access.adminGroups.filter(
|
|
275
|
+
(g: unknown) => typeof g === "string",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return Object.keys(model).length ? model : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function normaliseAdmin(admin: any): AdminModel | false | undefined {
|
|
282
|
+
if (admin === false) return false;
|
|
283
|
+
if (!admin || typeof admin !== "object") return undefined;
|
|
284
|
+
const model: AdminModel = {};
|
|
285
|
+
if (Array.isArray(admin.listDisplay)) model.listDisplay = admin.listDisplay;
|
|
286
|
+
return model;
|
|
287
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity-level authorization markers — auth as *TypeScript types*.
|
|
3
|
+
*
|
|
4
|
+
* Class and interface entities have no options bag, so their access policy is
|
|
5
|
+
* expressed the same way their columns are: in the type system. Attach a
|
|
6
|
+
* marker with `implements` (classes) or `extends` (interfaces) and the
|
|
7
|
+
* compiler reads the policy straight off the heritage clause:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { AdminGroups, IsAuthenticated, Owner, Group } from "rastack/auth";
|
|
11
|
+
*
|
|
12
|
+
* // Per-user rows: RLS on `owner`, staff sees everything.
|
|
13
|
+
* export class Itinerary implements IsAuthenticated, AdminGroups<"staff"> {
|
|
14
|
+
* owner!: string & Owner;
|
|
15
|
+
* notes!: string;
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* // Group-owned dataset: rows belong to a team and are *physically stored*
|
|
19
|
+
* // under that team's own storage prefix (groups/{team}/…).
|
|
20
|
+
* export class Report implements IsAuthenticated, AdminGroups<"staff"> {
|
|
21
|
+
* team!: string & Group;
|
|
22
|
+
* title!: string;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The markers are phantom types: every property is optional and never exists
|
|
27
|
+
* at runtime, so `implements` needs no members and instances stay plain data.
|
|
28
|
+
* Each marker family uses its own phantom key, so markers combine freely —
|
|
29
|
+
* while two *permission* markers on one entity conflict in the type checker,
|
|
30
|
+
* which is exactly right.
|
|
31
|
+
*
|
|
32
|
+
* JSDoc equivalents exist for every marker (`@rastackPermission`,
|
|
33
|
+
* `@rastackAdminGroups staff, ops`, `@rastackScope owner|group|shared`, and
|
|
34
|
+
* field-level `@owner` / `@group`), mirroring the column-constraint tags.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The permission gate carried in the type system. */
|
|
38
|
+
export interface PermissionOf<P extends string> {
|
|
39
|
+
readonly __rastackPermission?: P;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every path requires a verified identity (this is also the default). */
|
|
43
|
+
export interface IsAuthenticated extends PermissionOf<"authenticated"> {}
|
|
44
|
+
|
|
45
|
+
/** Anonymous callers may read (list/retrieve); writes require identity. */
|
|
46
|
+
export interface IsAuthenticatedOrReadOnly extends PermissionOf<"authenticatedOrReadOnly"> {}
|
|
47
|
+
|
|
48
|
+
/** No gate — every caller may read and write. Opt-in, never the default. */
|
|
49
|
+
export interface IsPublic extends PermissionOf<"public"> {}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Token groups (`cognito:groups`) that bypass row-level security on this
|
|
53
|
+
* entity — the "is admin" role, driven from scopes: `implements
|
|
54
|
+
* AdminGroups<"staff" | "ops">`. Members see and act on every row.
|
|
55
|
+
*/
|
|
56
|
+
export interface AdminGroups<G extends string> {
|
|
57
|
+
readonly __rastackAdminGroups?: G;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Storage scope carried in the type system. */
|
|
61
|
+
export interface ScopeOf<S extends string> {
|
|
62
|
+
readonly __rastackScope?: S;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Physically partition the entity's table per identity
|
|
67
|
+
* (`tenants/{sub}/…`) — the prefix object-storage IAM can fence per user.
|
|
68
|
+
* Combine with an `Owner` field brand.
|
|
69
|
+
*/
|
|
70
|
+
export interface OwnerScoped extends ScopeOf<"owner"> {}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Physically partition the entity's table per group (`groups/{name}/…`).
|
|
74
|
+
* This is the default whenever a `Group` field brand is present — declare it
|
|
75
|
+
* only for emphasis or with a JSDoc-configured group field.
|
|
76
|
+
*/
|
|
77
|
+
export interface GroupScoped extends ScopeOf<"group"> {}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keep rows in one shared table (row-level filters still apply). Use to opt a
|
|
81
|
+
* `Group`-branded entity out of the default per-group physical partitioning.
|
|
82
|
+
*/
|
|
83
|
+
export interface SharedStorage extends ScopeOf<"shared"> {}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `rastack/types` compatibility surface — one module aliasing the two
|
|
3
|
+
* split authoring subpaths: `rastack/db` (the `DbConfig` column brands) and
|
|
4
|
+
* `rastack/auth` (the entity-level auth markers + `Owner`/`Group` brands).
|
|
5
|
+
* Prefer those; everything stays re-exported here so existing imports keep
|
|
6
|
+
* working. The compiler maps all three specifiers (see `compile/program.ts`);
|
|
7
|
+
* the published package re-exports the same modules from `tools/types.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from "./db";
|
|
11
|
+
export * from "./markers";
|