rastack 0.0.58 → 0.0.60
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 +16 -0
- package/README.md +190 -0
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +1 -2
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +16 -1
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +24 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +1 -2
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +17 -1
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.60](https://github.com/theserverkid/reactapistack/compare/v0.0.59...v0.0.60) (2026-07-18)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* repair perf bench + SAST breakage from the root refactor ([396994b](https://github.com/theserverkid/reactapistack/commit/396994b29f66034b108493a90f561179387c6ce3))
|
|
11
|
+
|
|
12
|
+
### [0.0.59](https://github.com/theserverkid/reactapistack/compare/v0.0.58...v0.0.59) (2026-07-18)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **ci:** valid pnpm-workspace.yaml (pnpm 11 moved overrides/onlyBuiltDependencies out of package.json); fixes ERR_PNPM_INVALID_WORKSPACE_CONFIGURATION ([b739531](https://github.com/theserverkid/reactapistack/commit/b739531a34d323afd2a0df3394d95563cfa175a0))
|
|
18
|
+
* **hooks:** type the query compat layer against @tanstack/react-query v5, drop the dead react-query v3 dependency ([6642bc6](https://github.com/theserverkid/reactapistack/commit/6642bc68c917465bd763deaf4a5696983e1dde45))
|
|
19
|
+
* **pkg:** add files allowlist so npm publishes only the framework surface (root-package layout no longer leaks engine/web/docs) ([a6bc9ee](https://github.com/theserverkid/reactapistack/commit/a6bc9ee93d794628be42bac14eef12575a5cdfba))
|
|
20
|
+
|
|
5
21
|
### [0.0.58](https://github.com/theserverkid/reactapistack/compare/v0.0.57...v0.0.58) (2026-07-18)
|
|
6
22
|
|
|
7
23
|
### [0.0.57](https://github.com/theserverkid/reactapistack/compare/v0.0.56...v0.0.57) (2026-07-18)
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# React API Stack
|
|
2
|
+
|
|
3
|
+
**A full-stack framework on top of object storage. Scale to millions.**
|
|
4
|
+
|
|
5
|
+
React API Stack is not a UI library and not "just" an API package — it is the
|
|
6
|
+
**whole vertical**: the API exposure surface, the database storage layer, and the
|
|
7
|
+
devops that ships them. You author **one TypeScript resource** and the framework
|
|
8
|
+
derives everything from the React data hooks at the top to the Parquet files on
|
|
9
|
+
S3 at the bottom, with a stateless Rust API in between.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────────────────────────────────────┐
|
|
13
|
+
│ React / Expo │ API EXPOSURE
|
|
14
|
+
│ useData(Airport) / useForm(Flight) │ SURFACE
|
|
15
|
+
└───────────────────────▲─────────────────────┘
|
|
16
|
+
│ your types, resolved via the manifest
|
|
17
|
+
┌───────────────────────┴─────────────────────┐
|
|
18
|
+
│ TypeScript resources → `rastack compile` │ API EXPOSURE
|
|
19
|
+
│ FK inferred from types → manifest+OpenAPI │ SURFACE
|
|
20
|
+
└───────────────────────▲─────────────────────┘
|
|
21
|
+
│ schema.rastack.json (manifest)
|
|
22
|
+
┌───────────────────────┴─────────────────────┐
|
|
23
|
+
│ Rust API (rastack-server, axum) │ API + ADMIN
|
|
24
|
+
│ manifest-driven CRUD · FK validation · │
|
|
25
|
+
│ Django-admin-style /admin DB browser │
|
|
26
|
+
└───────────────────────▲─────────────────────┘
|
|
27
|
+
│ object_store
|
|
28
|
+
┌───────────────────────┴─────────────────────┐
|
|
29
|
+
│ Apache Iceberg tables on S3 (Parquet) │ DATABASE
|
|
30
|
+
│ snapshots · ACID on object storage │ STORAGE
|
|
31
|
+
└───────────────────────▲─────────────────────┘
|
|
32
|
+
│
|
|
33
|
+
┌───────────────────────┴─────────────────────┐
|
|
34
|
+
│ AWS Lambda (stateless) + S3 (state) │ DEVOPS
|
|
35
|
+
│ compute/storage separation, IaC, scale-to- │
|
|
36
|
+
│ zero — modelled on SurrealDB's Gen-3 │
|
|
37
|
+
└─────────────────────────────────────────────┘
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The three surfaces, one framework
|
|
41
|
+
|
|
42
|
+
| Surface | What it is | Package(s) |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| **API exposure** | TypeScript resources → `rastack compile` (FK inferred from types) → manifest + OpenAPI → pass your type to `useData` / `useForm` (typed TanStack Query hooks, no codegen) | the repo root, published as `rastack` (npm) — `components/`, `hooks/`, `theme/`, … |
|
|
45
|
+
| **Database storage** | A stateless Rust API that stores every model as an Apache Iceberg table on object storage — snapshots, ACID on S3, columnar Parquet | `engine/` (`serve/rastack-server`) |
|
|
46
|
+
| **DevOps** | Serverless Rust on AWS Lambda + S3: stateless compute, state in object storage, infrastructure-as-code, scale-to-zero — driven by `rastack init` / `deploy` / `ci` | `deploy/cloudformation/`, `examples/serverless/` |
|
|
47
|
+
|
|
48
|
+
## Install the CLI
|
|
49
|
+
|
|
50
|
+
One command, no clone — installs the `rastack` CLI into a per-user dir
|
|
51
|
+
(`~/.rastack`, no sudo), the way you'd install Poetry or rustup. Node.js is the
|
|
52
|
+
only prerequisite.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# macOS / Linux
|
|
56
|
+
curl -fsSL https://reactapistack.com/setup.sh | sh
|
|
57
|
+
|
|
58
|
+
# Windows (PowerShell) — from cmd, prefix with: powershell -c "…"
|
|
59
|
+
irm https://reactapistack.com/setup.ps1 | iex
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Prefer a package manager, or adding it to a single project? `npm install -g rastack`
|
|
63
|
+
(global CLI) or `npm install -D rastack` (project dependency) work too. Pin a
|
|
64
|
+
version with `RASTACK_VERSION=x.y.z` before the installer.
|
|
65
|
+
|
|
66
|
+
## Author once, compile the rest
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// resources/airports.ts
|
|
70
|
+
import { resource, s } from "rastack/define";
|
|
71
|
+
|
|
72
|
+
export const Airport = resource("airports", "airport", {
|
|
73
|
+
code: s.string({ maxLength: 3, unique: true }),
|
|
74
|
+
name: s.string({ maxLength: 120 }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const Terminal = resource("airports", "terminal", {
|
|
78
|
+
airport: s.ref(() => Airport), // ← foreign key, detected from the type
|
|
79
|
+
label: s.string({ maxLength: 20 }),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
rastack compile resources .rastack # TS → schema.rastack.json + openapi.json (FK detected)
|
|
85
|
+
rastack dev # the app + admin, in-browser over the WASM engine
|
|
86
|
+
rastack admin # Django-admin-style DB browser
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
// Then just pass your type to the hooks — nothing is generated into your repo.
|
|
91
|
+
const airports = useData(Airport);
|
|
92
|
+
<DataTable query={airports} />
|
|
93
|
+
|
|
94
|
+
const form = useForm(Flight, { id });
|
|
95
|
+
form.transition("board"); // a typed, state-machine-safe save
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The compile step runs a real TypeScript type checker and infers relationships
|
|
99
|
+
straight from the field types — see
|
|
100
|
+
[docs/compile-and-relations.md](docs/compile-and-relations.md); the hooks
|
|
101
|
+
resolve your types against the compiled manifest at runtime — see
|
|
102
|
+
[docs/typed-hooks.md](docs/typed-hooks.md). Resources can declare **state
|
|
103
|
+
transitions** (`scheduled → boarding → departed`), compiled into the manifest
|
|
104
|
+
and enforced on every write surface — an illegal jump is a 400 from the form,
|
|
105
|
+
the importer, the server, and the in-browser WASM engine alike.
|
|
106
|
+
|
|
107
|
+
## Ship it — one command per stage
|
|
108
|
+
|
|
109
|
+
The DevOps surface is a CLI too. A new repo is scaffolded, configured, and
|
|
110
|
+
deployed with three commands, and GitHub Actions stays a one-liner because the
|
|
111
|
+
pipeline lives in the CLI, not the YAML:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
rastack init # template .github/workflows + .rastack/deploy.json
|
|
115
|
+
rastack deploy # configure the deployment; print the stack launch command
|
|
116
|
+
rastack ci deploy # what CI runs: build the arm64 Lambda, bake the JWKS, ship the code
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`init` scaffolds the base GitHub Actions and a deploy config (defaulting the
|
|
120
|
+
GitHub coordinates from your `origin` remote); `deploy` fills that config in and
|
|
121
|
+
prints the one-time `aws cloudformation deploy` plus the repo Variables to wire;
|
|
122
|
+
`ci` runs the build/deploy pipeline so a workflow is just
|
|
123
|
+
`run: rastack ci deploy` (`rastack ci --plan deploy` prints every step). Full
|
|
124
|
+
guide: **[docs/serverless-deploy.md](docs/serverless-deploy.md)**.
|
|
125
|
+
|
|
126
|
+
## Run the whole API in the browser
|
|
127
|
+
|
|
128
|
+
The Rust API is stateless compute over object storage, so it also compiles to
|
|
129
|
+
**WebAssembly and runs entirely in the browser** — no server, no network. One
|
|
130
|
+
setting on the React provider picks where the API runs; the hooks never
|
|
131
|
+
change:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
// Local-first dev: the entire API in the browser, over the data/ Iceberg files.
|
|
135
|
+
<RAStackProvider mode="local" manifest={schema} warehouseBaseUrl="/data/warehouse/">
|
|
136
|
+
<App />
|
|
137
|
+
</RAStackProvider>
|
|
138
|
+
|
|
139
|
+
// Production: the same code on AWS Lambda + S3.
|
|
140
|
+
<RAStackProvider mode="remote" baseURL="https://api.example.com">…</RAStackProvider>
|
|
141
|
+
|
|
142
|
+
// Client-only, reading and writing S3 directly.
|
|
143
|
+
<RAStackProvider mode="s3" manifest={schema} warehouseBaseUrl="https://bucket.s3.amazonaws.com/warehouse/">…</RAStackProvider>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`local` and `s3` run the same `rastack-api-core` request logic (CRUD, foreign-key
|
|
147
|
+
validation, DRF-shaped lists) that Lambda runs — and, because Arrow/Parquet
|
|
148
|
+
compile to WebAssembly, the browser reads and writes the **same Parquet Iceberg
|
|
149
|
+
files** the server does. The local database is just a committed directory of
|
|
150
|
+
those files under `data/`. Full guide:
|
|
151
|
+
**[docs/local-wasm-dev.md](docs/local-wasm-dev.md)**.
|
|
152
|
+
|
|
153
|
+
## Why "on top of object storage"?
|
|
154
|
+
|
|
155
|
+
Traditional stacks pin you to a server-bound RDBMS. React API Stack instead puts
|
|
156
|
+
**S3 at the bottom of the stack as the system of record** and runs the API as
|
|
157
|
+
**stateless, elastic compute** on top — the same compute/storage-separation
|
|
158
|
+
principle behind [SurrealDB's Gen-3 architecture](https://surrealdb.com/platform/surrealds).
|
|
159
|
+
That gives you:
|
|
160
|
+
|
|
161
|
+
- **Scale to millions** — compute scales horizontally and independently of data;
|
|
162
|
+
storage is S3's 11-nines durability and effectively unbounded capacity.
|
|
163
|
+
- **Scale to zero** — when no requests are in flight, no compute runs and you pay
|
|
164
|
+
only for object storage.
|
|
165
|
+
- **No vendor lock-in** — data lives in open Apache Iceberg / Parquet, readable by
|
|
166
|
+
Spark, DuckDB, Trino, and Polars.
|
|
167
|
+
|
|
168
|
+
## Start here
|
|
169
|
+
|
|
170
|
+
- **[docs/compile-and-relations.md](docs/compile-and-relations.md)** — the
|
|
171
|
+
TypeScript-first compile step and foreign-key inference.
|
|
172
|
+
- **[docs/typed-hooks.md](docs/typed-hooks.md)** — pass your type to
|
|
173
|
+
`useData` / `useForm`, and state transitions as typesafe backend functions.
|
|
174
|
+
- **[docs/architecture.md](docs/architecture.md)** — the full serverless,
|
|
175
|
+
object-storage architecture and how the three surfaces compose.
|
|
176
|
+
- **[docs/local-wasm-dev.md](docs/local-wasm-dev.md)** — running the whole API in
|
|
177
|
+
the browser via WebAssembly, and the `RAStackProvider` mode switch.
|
|
178
|
+
- **[engine/](engine/)** — the Rust engine: `core/` (manifest, Iceberg storage, request brain) and `serve/` (axum API, server binary, wasm).
|
|
179
|
+
- **[examples/serverless/](examples/serverless/)** — Lambda + S3 + IaC.
|
|
180
|
+
- **[CLAUDE.md](CLAUDE.md)** — codebase guide for contributors.
|
|
181
|
+
- **Building with an LLM?** The documentation site is mirrored as plain Markdown
|
|
182
|
+
for agents — [reactapistack.com/llms.txt](https://reactapistack.com/llms.txt)
|
|
183
|
+
(curated index), [llms-full.txt](https://reactapistack.com/llms-full.txt)
|
|
184
|
+
(every page in one file), or any page at `/docs/<slug>.md`.
|
|
185
|
+
|
|
186
|
+
> **Performance note.** Apache Iceberg over S3 is currently the portable,
|
|
187
|
+
> correct-by-default storage path, not the fastest one. S3 first-byte latency
|
|
188
|
+
> dominates small OLTP reads. The roadmap closes this with a custom S3 backend
|
|
189
|
+
> fronted by a DynamoDB metadata/cache layer (and optionally EFS for hot IO) —
|
|
190
|
+
> see [docs/architecture.md](docs/architecture.md#performance-roadmap).
|
package/dist/deploy/ci.js
CHANGED
|
@@ -50,7 +50,7 @@ function stackOutput(cfg, outputKey, literal) {
|
|
|
50
50
|
}
|
|
51
51
|
/** The Lambda staging directory `cargo lambda build` writes the binary into. */
|
|
52
52
|
function stageDir(cfg) {
|
|
53
|
-
return `
|
|
53
|
+
return `engine/target/lambda/${cfg.lambda.bin}`;
|
|
54
54
|
}
|
|
55
55
|
/** `check` — validate the resource graph and compile the manifest. A CI gate. */
|
|
56
56
|
function checkSteps(cfg, bin) {
|
|
@@ -76,7 +76,7 @@ function buildSteps(cfg, opts = {}) {
|
|
|
76
76
|
{
|
|
77
77
|
name: "Cross-build the Lambda binary",
|
|
78
78
|
run: `cargo lambda build --release ${archFlag} --bin ${cfg.lambda.bin} --features ${cfg.lambda.features}`,
|
|
79
|
-
cwd: "
|
|
79
|
+
cwd: "engine",
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
name: "Bake the Cognito JWKS into the bundle",
|
package/dist/deploy/io.js
CHANGED
|
@@ -85,8 +85,8 @@ function resolveDeployAsset(relPath, cwd = process.cwd()) {
|
|
|
85
85
|
const candidates = [
|
|
86
86
|
// Installed package: dist/deploy/io.js → dist/templates/cloudformation/…
|
|
87
87
|
path.join(__dirname, "..", "templates", "cloudformation", relPath),
|
|
88
|
-
// Running from TS source:
|
|
89
|
-
path.join(__dirname, "..", "..", "
|
|
88
|
+
// Running from TS source: src/deploy/io.ts → repo-root deploy/…
|
|
89
|
+
path.join(__dirname, "..", "..", "deploy", "cloudformation", relPath),
|
|
90
90
|
// Invoked inside the framework repo itself (cwd is the repo root).
|
|
91
91
|
path.join(cwd, "deploy", "cloudformation", relPath),
|
|
92
92
|
];
|
package/dist/dev/harness.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export declare function contentType(filePath: string): string;
|
|
|
74
74
|
* Ordered directories to look for the prebuilt wasm bundle (`rastack_wasm.js` +
|
|
75
75
|
* `rastack_wasm_bg.wasm`). The bundle is shipped inside the package
|
|
76
76
|
* (`dist/wasm/`, copied at build), so `rastack dev` works from any repo with no
|
|
77
|
-
* cargo; the repo-local `
|
|
77
|
+
* cargo; the repo-local `wasm/` (from a source build) and a sibling of the
|
|
78
78
|
* running module are fallbacks for development. `dirnameDir` is the directory of
|
|
79
79
|
* the compiled `rastack-dev.js` (`dist/`), so `../wasm` and `./wasm` cover both
|
|
80
80
|
* `dist/rastack-dev.js` → `dist/wasm` and `dist/dev/…` layouts.
|
package/dist/dev/harness.js
CHANGED
|
@@ -171,7 +171,7 @@ function contentType(filePath) {
|
|
|
171
171
|
* Ordered directories to look for the prebuilt wasm bundle (`rastack_wasm.js` +
|
|
172
172
|
* `rastack_wasm_bg.wasm`). The bundle is shipped inside the package
|
|
173
173
|
* (`dist/wasm/`, copied at build), so `rastack dev` works from any repo with no
|
|
174
|
-
* cargo; the repo-local `
|
|
174
|
+
* cargo; the repo-local `wasm/` (from a source build) and a sibling of the
|
|
175
175
|
* running module are fallbacks for development. `dirnameDir` is the directory of
|
|
176
176
|
* the compiled `rastack-dev.js` (`dist/`), so `../wasm` and `./wasm` cover both
|
|
177
177
|
* `dist/rastack-dev.js` → `dist/wasm` and `dist/dev/…` layouts.
|
|
@@ -180,7 +180,6 @@ function wasmBundleCandidates(dirnameDir, cwd = process.cwd()) {
|
|
|
180
180
|
return [
|
|
181
181
|
path.join(dirnameDir, "wasm"),
|
|
182
182
|
path.join(dirnameDir, "..", "wasm"),
|
|
183
|
-
path.join(cwd, "tools", "wasm"),
|
|
184
183
|
path.join(cwd, "wasm"),
|
|
185
184
|
];
|
|
186
185
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy's response cache — the reason `all API calls are proxied in Lambda`.
|
|
3
|
+
*
|
|
4
|
+
* A tiny pure abstraction (`get`/`set` over string keys) so the *same* handler
|
|
5
|
+
* runs against an in-memory `Map` (the mini `rastack dev` service, a warm Lambda
|
|
6
|
+
* container) or a shared store (DynamoDB/ElastiCache in production) with no code
|
|
7
|
+
* change. Freshness and ETag bookkeeping are pure functions here; the transport
|
|
8
|
+
* (conditional `If-None-Match` requests) lives in the handler.
|
|
9
|
+
*/
|
|
10
|
+
export interface CacheEntry {
|
|
11
|
+
/** The normalised rastack response body that was cached. */
|
|
12
|
+
value: unknown;
|
|
13
|
+
/** Epoch ms the entry was (re)validated. */
|
|
14
|
+
storedAt: number;
|
|
15
|
+
/** The upstream ETag, if any, for conditional revalidation. */
|
|
16
|
+
etag?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProxyCache {
|
|
19
|
+
get(key: string): CacheEntry | undefined;
|
|
20
|
+
set(key: string, entry: CacheEntry): void;
|
|
21
|
+
delete(key: string): void;
|
|
22
|
+
}
|
|
23
|
+
/** An in-memory cache — the default for `rastack dev` and warm Lambda containers. */
|
|
24
|
+
export declare function memoryCache(): ProxyCache;
|
|
25
|
+
/** A stable cache key for a request (order-independent over query params). */
|
|
26
|
+
export declare function cacheKey(app: string, model: string, id: string | undefined, query: Record<string, string[]>): string;
|
|
27
|
+
/** Whether an entry is still within its TTL (and so served without an upstream call). */
|
|
28
|
+
export declare function isFresh(entry: CacheEntry, ttlMs: number, now: number): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The proxy's response cache — the reason `all API calls are proxied in Lambda`.
|
|
4
|
+
*
|
|
5
|
+
* A tiny pure abstraction (`get`/`set` over string keys) so the *same* handler
|
|
6
|
+
* runs against an in-memory `Map` (the mini `rastack dev` service, a warm Lambda
|
|
7
|
+
* container) or a shared store (DynamoDB/ElastiCache in production) with no code
|
|
8
|
+
* change. Freshness and ETag bookkeeping are pure functions here; the transport
|
|
9
|
+
* (conditional `If-None-Match` requests) lives in the handler.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.memoryCache = memoryCache;
|
|
13
|
+
exports.cacheKey = cacheKey;
|
|
14
|
+
exports.isFresh = isFresh;
|
|
15
|
+
/** An in-memory cache — the default for `rastack dev` and warm Lambda containers. */
|
|
16
|
+
function memoryCache() {
|
|
17
|
+
const store = new Map();
|
|
18
|
+
return {
|
|
19
|
+
get: (key) => store.get(key),
|
|
20
|
+
set: (key, entry) => void store.set(key, entry),
|
|
21
|
+
delete: (key) => void store.delete(key),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** A stable cache key for a request (order-independent over query params). */
|
|
25
|
+
function cacheKey(app, model, id, query) {
|
|
26
|
+
const params = Object.keys(query)
|
|
27
|
+
.sort()
|
|
28
|
+
.map((k) => `${k}=${[...query[k]].sort().join(",")}`)
|
|
29
|
+
.join("&");
|
|
30
|
+
return `${app}.${model}/${id ?? "list"}?${params}`;
|
|
31
|
+
}
|
|
32
|
+
/** Whether an entry is still within its TTL (and so served without an upstream call). */
|
|
33
|
+
function isFresh(entry, ttlMs, now) {
|
|
34
|
+
return now - entry.storedAt < ttlMs;
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise an upstream response into rastack's REST contract — the DRF-shaped
|
|
3
|
+
* list envelope and bare detail object the typed hooks and `<DataTable>` expect,
|
|
4
|
+
* identical to what the Rust API and the WASM/local engine emit.
|
|
5
|
+
*
|
|
6
|
+
* External APIs rarely page the way rastack does (GitHub uses `per_page` + Link
|
|
7
|
+
* headers; a GraphQL list just returns an array), so the envelope is synthesised
|
|
8
|
+
* from what we have: the page the client asked for and the number of items this
|
|
9
|
+
* page actually returned. `count`/`last` are best-effort when the upstream total
|
|
10
|
+
* is unknown — enough for the pager to advance while there is a full page.
|
|
11
|
+
*/
|
|
12
|
+
export interface ListEnvelope {
|
|
13
|
+
results: Record<string, unknown>[];
|
|
14
|
+
pagination: {
|
|
15
|
+
current: number;
|
|
16
|
+
last: number;
|
|
17
|
+
next: number | null;
|
|
18
|
+
previous: number | null;
|
|
19
|
+
page_size: number;
|
|
20
|
+
/** Best-effort total; equals what we can prove when the upstream omits it. */
|
|
21
|
+
count: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface NormalizeOptions {
|
|
25
|
+
page: number;
|
|
26
|
+
pageSize: number;
|
|
27
|
+
primaryKey: string;
|
|
28
|
+
fields?: string[];
|
|
29
|
+
/** A known upstream total, if the transport could determine one. */
|
|
30
|
+
totalCount?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Shape a page of upstream items into the list envelope. */
|
|
33
|
+
export declare function toListEnvelope(items: unknown, opts: NormalizeOptions): ListEnvelope;
|
|
34
|
+
/** Shape a single upstream object into a rastack detail row. */
|
|
35
|
+
export declare function toDetail(item: unknown, primaryKey: string, fields?: string[]): Record<string, unknown> | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Normalise an upstream response into rastack's REST contract — the DRF-shaped
|
|
4
|
+
* list envelope and bare detail object the typed hooks and `<DataTable>` expect,
|
|
5
|
+
* identical to what the Rust API and the WASM/local engine emit.
|
|
6
|
+
*
|
|
7
|
+
* External APIs rarely page the way rastack does (GitHub uses `per_page` + Link
|
|
8
|
+
* headers; a GraphQL list just returns an array), so the envelope is synthesised
|
|
9
|
+
* from what we have: the page the client asked for and the number of items this
|
|
10
|
+
* page actually returned. `count`/`last` are best-effort when the upstream total
|
|
11
|
+
* is unknown — enough for the pager to advance while there is a full page.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.toListEnvelope = toListEnvelope;
|
|
15
|
+
exports.toDetail = toDetail;
|
|
16
|
+
const util_1 = require("./util");
|
|
17
|
+
/** Shape a page of upstream items into the list envelope. */
|
|
18
|
+
function toListEnvelope(items, opts) {
|
|
19
|
+
const rows = (Array.isArray(items) ? items : [])
|
|
20
|
+
.filter((r) => !!r && typeof r === "object")
|
|
21
|
+
.map((r) => (0, util_1.projectFields)((0, util_1.ensureId)(r, opts.primaryKey), opts.fields));
|
|
22
|
+
const { page, pageSize } = opts;
|
|
23
|
+
// A full page implies there may be more; a short page is the last one.
|
|
24
|
+
const hasMore = rows.length >= pageSize;
|
|
25
|
+
const count = opts.totalCount ?? (page - 1) * pageSize + rows.length;
|
|
26
|
+
const last = opts.totalCount
|
|
27
|
+
? Math.max(1, Math.ceil(opts.totalCount / pageSize))
|
|
28
|
+
: hasMore
|
|
29
|
+
? page + 1
|
|
30
|
+
: page;
|
|
31
|
+
return {
|
|
32
|
+
results: rows,
|
|
33
|
+
pagination: {
|
|
34
|
+
current: page,
|
|
35
|
+
last,
|
|
36
|
+
next: page < last ? page + 1 : null,
|
|
37
|
+
previous: page > 1 ? page - 1 : null,
|
|
38
|
+
page_size: pageSize,
|
|
39
|
+
count,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Shape a single upstream object into a rastack detail row. */
|
|
44
|
+
function toDetail(item, primaryKey, fields) {
|
|
45
|
+
if (!item || typeof item !== "object")
|
|
46
|
+
return null;
|
|
47
|
+
return (0, util_1.projectFields)((0, util_1.ensureId)(item, primaryKey), fields);
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the upstream request for a GraphQL-backed resource (Expo's API is
|
|
3
|
+
* GraphQL, for instance), and pull the item array / detail object out of the
|
|
4
|
+
* `data` payload. A GraphQL call is always a POST of `{ query, variables }`; the
|
|
5
|
+
* rastack list params map onto the query's variables (`page_size` → `first`,
|
|
6
|
+
* etc.), so the same `/api/{app}/v1/{model}/` contract fronts a GraphQL API with
|
|
7
|
+
* no client awareness that it isn't REST.
|
|
8
|
+
*/
|
|
9
|
+
import type { GraphQLBinding } from "./types";
|
|
10
|
+
export interface GraphQLRequest {
|
|
11
|
+
url: string;
|
|
12
|
+
method: "POST";
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
/** Build the GraphQL list request. */
|
|
17
|
+
export declare function graphqlListRequest(binding: GraphQLBinding, query: Record<string, string[]>, headers: Record<string, string>): GraphQLRequest;
|
|
18
|
+
/** Build the GraphQL detail request. Throws if the binding has no detail query. */
|
|
19
|
+
export declare function graphqlDetailRequest(binding: GraphQLBinding, id: string, headers: Record<string, string>): GraphQLRequest;
|
|
20
|
+
/** A GraphQL response is `{ data, errors }`; surface errors, else return `data`. */
|
|
21
|
+
export declare function graphqlData(body: unknown): unknown;
|
|
22
|
+
/** Extract the item array from a GraphQL list response. */
|
|
23
|
+
export declare function graphqlListItems(binding: GraphQLBinding, body: unknown): unknown;
|
|
24
|
+
/** Extract the detail object from a GraphQL detail response. */
|
|
25
|
+
export declare function graphqlDetailItem(binding: GraphQLBinding, body: unknown): unknown;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Build the upstream request for a GraphQL-backed resource (Expo's API is
|
|
4
|
+
* GraphQL, for instance), and pull the item array / detail object out of the
|
|
5
|
+
* `data` payload. A GraphQL call is always a POST of `{ query, variables }`; the
|
|
6
|
+
* rastack list params map onto the query's variables (`page_size` → `first`,
|
|
7
|
+
* etc.), so the same `/api/{app}/v1/{model}/` contract fronts a GraphQL API with
|
|
8
|
+
* no client awareness that it isn't REST.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.graphqlListRequest = graphqlListRequest;
|
|
12
|
+
exports.graphqlDetailRequest = graphqlDetailRequest;
|
|
13
|
+
exports.graphqlData = graphqlData;
|
|
14
|
+
exports.graphqlListItems = graphqlListItems;
|
|
15
|
+
exports.graphqlDetailItem = graphqlDetailItem;
|
|
16
|
+
const util_1 = require("./util");
|
|
17
|
+
function buildVariables(binding, query, id) {
|
|
18
|
+
const vars = { ...(binding.variables ?? {}) };
|
|
19
|
+
const map = binding.variableMap ?? {};
|
|
20
|
+
const put = (rastackKey, raw) => {
|
|
21
|
+
const name = map[rastackKey];
|
|
22
|
+
if (!name || raw == null || raw === "")
|
|
23
|
+
return;
|
|
24
|
+
// page_size / page are numbers in GraphQL land; coerce when they parse.
|
|
25
|
+
const num = Number(raw);
|
|
26
|
+
vars[name] = Number.isFinite(num) && /^\d+$/.test(raw) ? num : raw;
|
|
27
|
+
};
|
|
28
|
+
put("page", (0, util_1.firstParam)(query, "page"));
|
|
29
|
+
put("page_size", (0, util_1.firstParam)(query, "page_size"));
|
|
30
|
+
put("search", (0, util_1.firstParam)(query, "search"));
|
|
31
|
+
put("order_by", (0, util_1.firstParam)(query, "order_by"));
|
|
32
|
+
if (id != null && map.id)
|
|
33
|
+
vars[map.id] = id;
|
|
34
|
+
return vars;
|
|
35
|
+
}
|
|
36
|
+
/** Build the GraphQL list request. */
|
|
37
|
+
function graphqlListRequest(binding, query, headers) {
|
|
38
|
+
return {
|
|
39
|
+
url: binding.baseUrl,
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
query: binding.listQuery,
|
|
44
|
+
variables: buildVariables(binding, query),
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Build the GraphQL detail request. Throws if the binding has no detail query. */
|
|
49
|
+
function graphqlDetailRequest(binding, id, headers) {
|
|
50
|
+
if (!binding.detailQuery) {
|
|
51
|
+
throw new Error(`GraphQL binding ${binding.app}.${binding.model} has no detailQuery.`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
url: binding.baseUrl,
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
query: binding.detailQuery,
|
|
59
|
+
variables: buildVariables(binding, {}, id),
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** A GraphQL response is `{ data, errors }`; surface errors, else return `data`. */
|
|
64
|
+
function graphqlData(body) {
|
|
65
|
+
const payload = body;
|
|
66
|
+
if (payload?.errors && payload.errors.length) {
|
|
67
|
+
throw new Error(`GraphQL error: ${payload.errors.map((e) => e.message ?? "unknown").join("; ")}`);
|
|
68
|
+
}
|
|
69
|
+
return payload?.data;
|
|
70
|
+
}
|
|
71
|
+
/** Extract the item array from a GraphQL list response. */
|
|
72
|
+
function graphqlListItems(binding, body) {
|
|
73
|
+
return (0, util_1.getPath)(graphqlData(body), binding.itemsPath);
|
|
74
|
+
}
|
|
75
|
+
/** Extract the detail object from a GraphQL detail response. */
|
|
76
|
+
function graphqlDetailItem(binding, body) {
|
|
77
|
+
return (0, util_1.getPath)(graphqlData(body), binding.detailPath ?? binding.itemsPath);
|
|
78
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy core — one pure(ish) function from a rastack request to a rastack
|
|
3
|
+
* response, given the bindings and injected effects (`fetch`, cache, clock,
|
|
4
|
+
* auth). This is the "work" the user's Lambda does; the mini service inside
|
|
5
|
+
* `rastack dev` and the deployed Lambda are both thin adapters that parse an
|
|
6
|
+
* HTTP request into {@link RastackRequest}, call {@link handleExternalRequest},
|
|
7
|
+
* and serialise the result.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities, in order:
|
|
10
|
+
* 1. Resolve the binding for `app.model` (404 if none — not an external model).
|
|
11
|
+
* 2. Serve a fresh cache entry without any upstream call (the cache is the
|
|
12
|
+
* whole point of the proxy).
|
|
13
|
+
* 3. Otherwise call the upstream (REST or GraphQL), using a conditional
|
|
14
|
+
* `If-None-Match` when we hold an ETag so a `304` costs no rate-limit.
|
|
15
|
+
* 4. Normalise into the DRF envelope / detail row, cache it, and return it.
|
|
16
|
+
*
|
|
17
|
+
* Auth (the GitHub/Expo token from Secrets Manager in prod, an env var in dev)
|
|
18
|
+
* is injected by `deps.authHeaders`, so **no credential is ever hard-coded here**.
|
|
19
|
+
*/
|
|
20
|
+
import { type ProxyCache } from "./cache";
|
|
21
|
+
import { type FetchLike, type ResourceBinding, type ResourceResolver } from "./types";
|
|
22
|
+
export interface RastackRequest {
|
|
23
|
+
app: string;
|
|
24
|
+
model: string;
|
|
25
|
+
/** Present for a detail request (`/…/{model}/{id}/`). */
|
|
26
|
+
id?: string;
|
|
27
|
+
query: Record<string, string[]>;
|
|
28
|
+
}
|
|
29
|
+
export interface RastackResponse {
|
|
30
|
+
status: number;
|
|
31
|
+
body: unknown;
|
|
32
|
+
/** Whether this response was served from cache without an upstream call. */
|
|
33
|
+
cached: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface HandlerDeps {
|
|
36
|
+
fetch: FetchLike;
|
|
37
|
+
cache: ProxyCache;
|
|
38
|
+
now: () => number;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve upstream auth/headers for an app — e.g. `{ Authorization: "Bearer …" }`.
|
|
41
|
+
* Reads Secrets Manager in the Lambda; reads env vars in `rastack dev`.
|
|
42
|
+
*/
|
|
43
|
+
authHeaders?: (app: string) => Record<string, string> | Promise<Record<string, string>>;
|
|
44
|
+
/**
|
|
45
|
+
* Named resolvers for {@link ResolverBinding}s — the app plugs in transports
|
|
46
|
+
* the core doesn't ship (e.g. an AWS SDK resolver), keeping the core
|
|
47
|
+
* dependency-free.
|
|
48
|
+
*/
|
|
49
|
+
resolvers?: Record<string, ResourceResolver>;
|
|
50
|
+
}
|
|
51
|
+
/** Handle one rastack request against the external bindings. */
|
|
52
|
+
export declare function handleExternalRequest(req: RastackRequest, bindings: ResourceBinding[], deps: HandlerDeps): Promise<RastackResponse>;
|