@supacloud/elysia 0.6.0 → 0.7.0

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/README.md CHANGED
@@ -15,6 +15,9 @@ Runtime adapter that turns `@supacloud/compiler` output into a production-ready
15
15
  `destroyRequestScope` after the response, including when the handler fails.
16
16
  - **TypeBox schema binding**: attaches compiled parameter, query, body, and
17
17
  response TypeBox schemas directly to Elysia route definitions.
18
+ - **Compiler invoker execution**: uses the compiler-emitted positional invoker
19
+ after Elysia has decoded route input, while retaining the legacy input-object
20
+ handler path for hand-written compiled fixtures.
18
21
  - **Unified Command Pipeline**: runs `@Command`-decorated handlers through a
19
22
  structured `commandGovernance` adapter chain or a custom `composeCommandExecutors`
20
23
  pipeline (fail-closed if command routes lack an executor).
@@ -58,6 +61,31 @@ const app = createApplication({
58
61
  export default app;
59
62
  ```
60
63
 
64
+ For deterministic local verification, use the in-memory sandbox. It supplies
65
+ stable request identity, an isolated key-value database with optimistic
66
+ transaction rollback, and an in-memory object store without requiring
67
+ PostgreSQL, GoTrue, or S3:
68
+
69
+ ```ts
70
+ import { createMemorySandbox } from "@supacloud/elysia";
71
+
72
+ const sandbox = createMemorySandbox({
73
+ modules: [CaseModule],
74
+ identity: { authenticated: true, subject: "test-user" },
75
+ requestId: "test-request",
76
+ });
77
+
78
+ const response = await sandbox.request("/cases/42");
79
+ sandbox.db.set("cases", "42", { state: "draft" });
80
+ sandbox.storage.put("evidence", "42.txt", "fixture");
81
+ sandbox.reset();
82
+ ```
83
+
84
+ Route `body`, `params`, `query`, and `response` schemas are enforced by
85
+ Elysia before and after the handler. Invalid input returns the standard `422`
86
+ validation response; invalid handler output is rejected before it reaches the
87
+ client.
88
+
61
89
  Jobs are executed explicitly with `executeJob(compiledModule, services, job,
62
90
  input, requestContext)`. The asynchronous compiler-generated job scope is
63
91
  destroyed after execution, including when the job throws or scope construction
@@ -92,3 +120,17 @@ compiler-generated job scope.
92
120
 
93
121
  Lightweight error class carrying HTTP `status`, machine-readable `code`, and
94
122
  optional structured `details`.
123
+
124
+ ### `createMemorySandbox(options): MemorySandbox`
125
+
126
+ Creates an in-process application harness with `request()`, `db`, `storage`,
127
+ `identity`, `policy`, `audit`, and `reset()`. The memory database is a deterministic test adapter,
128
+ not a PostgreSQL emulator; its transaction callback operates on an isolated
129
+ snapshot and detects concurrent commits.
130
+
131
+ The adapter boundary is intentional: production authorization, RLS, PostgreSQL,
132
+ S3 visibility and failure semantics must be supplied by application governance
133
+ adapters. The memory harness is limited to deterministic HTTP, key-value
134
+ transaction and object-storage contract tests.
135
+ `policy` supplies explicit permission grants/revocations and idempotency claims;
136
+ `storage.failNext()` makes storage failure paths deterministic.
package/dist/index.d.ts CHANGED
@@ -9,6 +9,14 @@ export interface CompiledRoute {
9
9
  params?: unknown;
10
10
  query?: unknown;
11
11
  response?: unknown;
12
+ /** Compiler-emitted positional invoker; used when available. */
13
+ invoker?: (controller: unknown, request: {
14
+ params?: Record<string, unknown>;
15
+ query?: Record<string, unknown>;
16
+ body?: unknown;
17
+ headers?: Record<string, unknown>;
18
+ context?: unknown;
19
+ }) => Promise<unknown> | unknown;
12
20
  /** Class name of the @Command explicitly bound to this route. */
13
21
  command?: string;
14
22
  /** Statically generated route aspects. */
@@ -73,7 +81,7 @@ export interface CommandInvocation {
73
81
  command: CompiledCommand;
74
82
  input: {
75
83
  body: unknown;
76
- params: Record<string, string>;
84
+ params: Record<string, unknown>;
77
85
  query: Record<string, unknown>;
78
86
  };
79
87
  request: Request;
@@ -205,3 +213,7 @@ export declare function defaultErrorResponse(error: unknown, frameworkCode?: str
205
213
  export declare function createApplication(options: ApplicationOptions): Elysia;
206
214
  /** Semantic alias of createApplication for readable tests. */
207
215
  export declare function createTestApp(options: ApplicationOptions): Elysia;
216
+ export { createMemorySandbox, } from "./memory";
217
+ export type { HandleLike as MemoryHandleLike, MemoryDatabase, MemorySandbox, MemorySandboxOptions, MemoryStorage, MemoryStorageObject, } from "./memory";
218
+ export { createMemoryPolicy } from "./memory_policy";
219
+ export type { MemoryPolicy } from "./memory_policy";
package/dist/index.js CHANGED
@@ -1,5 +1,323 @@
1
1
  // src/index.ts
2
2
  import { Elysia } from "elysia";
3
+
4
+ // src/memory.ts
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+
7
+ // src/memory_policy.ts
8
+ function createMemoryPolicy() {
9
+ const permissions = new Map;
10
+ const receipts = new Map;
11
+ return {
12
+ grant(subject, permission) {
13
+ const values = permissions.get(subject) ?? new Set;
14
+ values.add(permission);
15
+ permissions.set(subject, values);
16
+ },
17
+ revoke(subject, permission) {
18
+ permissions.get(subject)?.delete(permission);
19
+ },
20
+ can(subject, permission) {
21
+ return permissions.get(subject)?.has(permission) ?? false;
22
+ },
23
+ claimIdempotency(subject, key) {
24
+ const id = JSON.stringify([subject, key]);
25
+ if (receipts.has(id))
26
+ return false;
27
+ receipts.set(id, { input: "", result: Promise.resolve(undefined) });
28
+ return true;
29
+ },
30
+ subject(context) {
31
+ if (typeof context === "object" && context !== null && "identity" in context) {
32
+ const identity = context.identity;
33
+ if (typeof identity === "object" && identity !== null && "authenticated" in identity && identity.authenticated === true && "subject" in identity && typeof identity.subject === "string" && identity.subject.length > 0)
34
+ return identity.subject;
35
+ }
36
+ throw new ApplicationError("Memory authenticated identity required", { status: 401, code: "MEMORY_IDENTITY_REQUIRED" });
37
+ },
38
+ async runOnce(subject, command, key, input, work) {
39
+ if (!subject || !command || !key)
40
+ throw new Error("Memory receipt requires subject, command and key");
41
+ const id = JSON.stringify([subject, command, key]);
42
+ const fingerprint = stableStringify(input);
43
+ const existing = receipts.get(id);
44
+ if (existing) {
45
+ if (existing.input !== fingerprint) {
46
+ throw new ApplicationError("Idempotency input conflict", { status: 409, code: "MEMORY_IDEMPOTENCY_CONFLICT" });
47
+ }
48
+ return structuredClone(await existing.result);
49
+ }
50
+ const result = Promise.resolve().then(work).then((value) => structuredClone(value));
51
+ const entry = { input: fingerprint, result };
52
+ receipts.set(id, entry);
53
+ try {
54
+ return structuredClone(await result);
55
+ } catch (error) {
56
+ if (receipts.get(id) === entry)
57
+ receipts.delete(id);
58
+ throw error;
59
+ }
60
+ },
61
+ clear() {
62
+ permissions.clear();
63
+ receipts.clear();
64
+ }
65
+ };
66
+ }
67
+ function stableStringify(value) {
68
+ if (value === null || typeof value !== "object")
69
+ return JSON.stringify(value);
70
+ if (Array.isArray(value))
71
+ return `[${value.map(stableStringify).join(",")}]`;
72
+ const record = value;
73
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
74
+ }
75
+
76
+ // src/memory.ts
77
+ class DefaultMemoryDatabase {
78
+ tables = new Map;
79
+ version = 0;
80
+ current = new AsyncLocalStorage;
81
+ get(table, key) {
82
+ if (this.current.getStore())
83
+ return this.current.getStore()?.get(table, key);
84
+ const value = this.tables.get(table)?.get(key);
85
+ return value === undefined ? undefined : cloneValue(value);
86
+ }
87
+ set(table, key, value) {
88
+ const current = this.current.getStore();
89
+ if (current)
90
+ return current.set(table, key, value);
91
+ const rows = this.tables.get(table) ?? new Map;
92
+ rows.set(key, cloneValue(value));
93
+ this.tables.set(table, rows);
94
+ this.version += 1;
95
+ }
96
+ delete(table, key) {
97
+ const current = this.current.getStore();
98
+ if (current)
99
+ return current.delete(table, key);
100
+ const deleted = this.tables.get(table)?.delete(key) ?? false;
101
+ if (deleted)
102
+ this.version += 1;
103
+ return deleted;
104
+ }
105
+ list(table) {
106
+ const current = this.current.getStore();
107
+ if (current)
108
+ return current.list(table);
109
+ return [...this.tables.get(table)?.entries() ?? []].map(([key, value]) => ({ key, value: cloneValue(value) })).sort((left, right) => left.key.localeCompare(right.key));
110
+ }
111
+ clear() {
112
+ const current = this.current.getStore();
113
+ if (current)
114
+ return current.clear();
115
+ this.tables.clear();
116
+ this.version += 1;
117
+ }
118
+ async transaction(work) {
119
+ if (this.current.getStore())
120
+ throw new Error("Nested memory transactions are not supported");
121
+ const baseVersion = this.version;
122
+ const snapshot = cloneTables(this.tables);
123
+ const transaction = new SnapshotMemoryDatabase(snapshot);
124
+ try {
125
+ const result = await this.current.run(transaction, () => work(transaction));
126
+ if (this.version !== baseVersion) {
127
+ throw new Error("Memory transaction conflict: state changed during transaction");
128
+ }
129
+ const committed = cloneTables(transaction.tables);
130
+ this.tables.clear();
131
+ for (const [table, rows] of committed)
132
+ this.tables.set(table, rows);
133
+ this.version += 1;
134
+ return result;
135
+ } finally {
136
+ transaction.close();
137
+ }
138
+ }
139
+ }
140
+
141
+ class SnapshotMemoryDatabase {
142
+ tables;
143
+ closed = false;
144
+ constructor(tables) {
145
+ this.tables = tables;
146
+ }
147
+ close() {
148
+ this.closed = true;
149
+ }
150
+ assertOpen() {
151
+ if (this.closed)
152
+ throw new Error("Memory transaction is closed");
153
+ }
154
+ get(table, key) {
155
+ this.assertOpen();
156
+ const value = this.tables.get(table)?.get(key);
157
+ return value === undefined ? undefined : cloneValue(value);
158
+ }
159
+ set(table, key, value) {
160
+ this.assertOpen();
161
+ const rows = this.tables.get(table) ?? new Map;
162
+ rows.set(key, cloneValue(value));
163
+ this.tables.set(table, rows);
164
+ }
165
+ delete(table, key) {
166
+ this.assertOpen();
167
+ return this.tables.get(table)?.delete(key) ?? false;
168
+ }
169
+ list(table) {
170
+ this.assertOpen();
171
+ return [...this.tables.get(table)?.entries() ?? []].map(([key, value]) => ({ key, value: cloneValue(value) })).sort((left, right) => left.key.localeCompare(right.key));
172
+ }
173
+ clear() {
174
+ this.assertOpen();
175
+ this.tables.clear();
176
+ }
177
+ transaction() {
178
+ return Promise.reject(new Error("Nested memory transactions are not supported"));
179
+ }
180
+ }
181
+
182
+ class DefaultMemoryStorage {
183
+ objects = new Map;
184
+ pendingFailure;
185
+ policy;
186
+ put(bucket, key, body, options = {}) {
187
+ this.check("put", bucket, key);
188
+ this.objects.set(storageKey(bucket, key), {
189
+ bucket,
190
+ key,
191
+ body: typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body),
192
+ contentType: options.contentType,
193
+ metadata: { ...options.metadata ?? {} }
194
+ });
195
+ }
196
+ get(bucket, key) {
197
+ this.check("get", bucket, key);
198
+ const object = this.objects.get(storageKey(bucket, key));
199
+ return object ? cloneObject(object) : undefined;
200
+ }
201
+ delete(bucket, key) {
202
+ this.check("delete", bucket, key);
203
+ return this.objects.delete(storageKey(bucket, key));
204
+ }
205
+ list(bucket, prefix = "") {
206
+ this.check("list", bucket, prefix);
207
+ return [...this.objects.values()].filter((object) => object.bucket === bucket && object.key.startsWith(prefix)).filter((object) => !this.policy || this.policy("get", object.bucket, object.key)).sort((left, right) => left.key.localeCompare(right.key)).map(cloneObject);
208
+ }
209
+ clear() {
210
+ this.objects.clear();
211
+ this.pendingFailure = undefined;
212
+ }
213
+ failNext(error = new Error("Memory storage failure")) {
214
+ this.pendingFailure = error;
215
+ }
216
+ setPolicy(policy) {
217
+ this.policy = policy;
218
+ }
219
+ check(operation, bucket, key) {
220
+ if (this.pendingFailure) {
221
+ const error = this.pendingFailure;
222
+ this.pendingFailure = undefined;
223
+ throw error;
224
+ }
225
+ if (this.policy && !this.policy(operation, bucket, key)) {
226
+ throw new ApplicationError("Memory storage access denied", { status: 403, code: "MEMORY_STORAGE_DENIED" });
227
+ }
228
+ }
229
+ }
230
+ function createMemorySandbox(options = {}) {
231
+ const db = new DefaultMemoryDatabase;
232
+ const storage = new DefaultMemoryStorage;
233
+ const policy = createMemoryPolicy();
234
+ const audit = [];
235
+ const governance = {
236
+ authorize(invocation) {
237
+ const subject = policy.subject(invocation.requestContext);
238
+ if (!invocation.command.permission || !policy.can(subject, invocation.command.permission)) {
239
+ throw new ApplicationError("Memory permission denied", { status: 403, code: "MEMORY_PERMISSION_DENIED" });
240
+ }
241
+ },
242
+ idempotency(invocation, next) {
243
+ return policy.runOnce(policy.subject(invocation.requestContext), invocation.command.name, requireIdempotencyKey(invocation), invocation.input, next);
244
+ },
245
+ transaction(_invocation, next) {
246
+ const count = audit.length;
247
+ return db.transaction(() => next()).catch((error) => {
248
+ audit.splice(count);
249
+ throw error;
250
+ });
251
+ },
252
+ audit: {
253
+ succeeded(invocation) {
254
+ audit.push({ command: invocation.command.name, outcome: "succeeded" });
255
+ },
256
+ failed(invocation) {
257
+ audit.push({ command: invocation.command.name, outcome: "failed" });
258
+ }
259
+ }
260
+ };
261
+ const identity = options.identity ?? { authenticated: false };
262
+ const requestId = options.requestId ?? "memory-request";
263
+ const configuredContext = options.requestContext;
264
+ const app = createApplication({
265
+ ...options,
266
+ commandGovernance: options.commandGovernance ?? (options.memoryGovernance ? governance : undefined),
267
+ deps: {
268
+ ...options.deps ?? {},
269
+ dbClient: db,
270
+ memoryDb: db,
271
+ storage,
272
+ memoryStorage: storage,
273
+ memoryPolicy: policy
274
+ },
275
+ requestContext: configuredContext ?? ((request) => ({
276
+ requestId: request.headers.get("x-request-id") ?? requestId,
277
+ request,
278
+ identity
279
+ }))
280
+ });
281
+ return {
282
+ app,
283
+ db,
284
+ storage,
285
+ policy,
286
+ audit,
287
+ identity,
288
+ request(path, init = {}) {
289
+ const url = path.startsWith("http://") || path.startsWith("https://") ? path : `http://memory.local${path.startsWith("/") ? path : `/${path}`}`;
290
+ return app.handle(new Request(url, init));
291
+ },
292
+ reset() {
293
+ db.clear();
294
+ storage.clear();
295
+ policy.clear();
296
+ audit.length = 0;
297
+ }
298
+ };
299
+ }
300
+ function storageKey(bucket, key) {
301
+ return JSON.stringify([bucket, key]);
302
+ }
303
+ function cloneObject(object) {
304
+ return {
305
+ ...object,
306
+ body: new Uint8Array(object.body),
307
+ metadata: { ...object.metadata }
308
+ };
309
+ }
310
+ function cloneTables(tables) {
311
+ return new Map([...tables.entries()].map(([table, rows]) => [
312
+ table,
313
+ new Map([...rows.entries()].map(([key, value]) => [key, cloneValue(value)]))
314
+ ]));
315
+ }
316
+ function cloneValue(value) {
317
+ return structuredClone(value);
318
+ }
319
+
320
+ // src/index.ts
3
321
  var VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
4
322
  var EXECUTION_ID_HEADER = "x-sb-execution-id";
5
323
  var IDEMPOTENCY_KEY_HEADER = "idempotency-key";
@@ -250,11 +568,13 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
250
568
  body: ctx.body,
251
569
  params: ctx.params,
252
570
  query: ctx.query,
571
+ headers: Object.fromEntries(ctx.request.headers.entries()),
572
+ context: requestContext,
253
573
  request: ctx.request,
254
574
  scope: requestScope,
255
575
  requestContext
256
576
  };
257
- const invoke = () => Reflect.apply(method, instance, [input]);
577
+ const invoke = () => route.invoker ? route.invoker(instance, input) : Reflect.apply(method, instance, [input]);
258
578
  const routeContext = {
259
579
  kind: "route",
260
580
  name: `${route.method} ${path}`,
@@ -440,6 +760,8 @@ export {
440
760
  composeCommandExecutors,
441
761
  createApplication,
442
762
  createCommandExecutor,
763
+ createMemoryPolicy,
764
+ createMemorySandbox,
443
765
  createModulePlugin,
444
766
  createSupaCloudRequestContext,
445
767
  createTestApp,
@@ -0,0 +1,68 @@
1
+ import { type ApplicationOptions, type TrustedRequestIdentity } from "./index";
2
+ import { type MemoryPolicy } from "./memory_policy";
3
+ export interface HandleLike {
4
+ handle(request: Request): Promise<Response> | Response;
5
+ }
6
+ export interface MemoryDatabase {
7
+ get(table: string, key: string): unknown;
8
+ set(table: string, key: string, value: unknown): void;
9
+ delete(table: string, key: string): boolean;
10
+ list(table: string): Array<{
11
+ key: string;
12
+ value: unknown;
13
+ }>;
14
+ clear(): void;
15
+ transaction<T>(work: (tx: MemoryDatabase) => T | Promise<T>): Promise<T>;
16
+ }
17
+ export interface MemoryStorageObject {
18
+ bucket: string;
19
+ key: string;
20
+ body: Uint8Array;
21
+ contentType?: string;
22
+ metadata: Record<string, string>;
23
+ }
24
+ export interface MemoryStorage {
25
+ put(bucket: string, key: string, body: Uint8Array | string, options?: {
26
+ contentType?: string;
27
+ metadata?: Record<string, string>;
28
+ }): void;
29
+ get(bucket: string, key: string): MemoryStorageObject | undefined;
30
+ delete(bucket: string, key: string): boolean;
31
+ list(bucket: string, prefix?: string): MemoryStorageObject[];
32
+ clear(): void;
33
+ failNext(error?: Error): void;
34
+ setPolicy(policy?: (operation: "put" | "get" | "delete" | "list", bucket: string, key: string) => boolean): void;
35
+ }
36
+ export interface MemorySandboxOptions extends Omit<ApplicationOptions, "deps" | "requestContext"> {
37
+ /** Stable identity used by every request unless overridden by requestContext. */
38
+ identity?: TrustedRequestIdentity;
39
+ /** Stable request id used when the request does not provide x-request-id. */
40
+ requestId?: string;
41
+ /** Extend the platform dependency bag without replacing memory dependencies. */
42
+ deps?: Record<string, unknown>;
43
+ /** Replace the default deterministic request context factory. */
44
+ requestContext?: ApplicationOptions["requestContext"];
45
+ /** Enable the test-only permission, receipt, transaction and audit adapters. */
46
+ memoryGovernance?: boolean;
47
+ }
48
+ export interface MemorySandbox {
49
+ app: HandleLike;
50
+ db: MemoryDatabase;
51
+ storage: MemoryStorage;
52
+ policy: MemoryPolicy;
53
+ audit: Array<{
54
+ command: string;
55
+ outcome: "succeeded" | "failed";
56
+ }>;
57
+ identity: TrustedRequestIdentity;
58
+ request(path: string, init?: RequestInit): Promise<Response>;
59
+ reset(): void;
60
+ }
61
+ /**
62
+ * Creates a deterministic in-process application harness.
63
+ *
64
+ * The database and storage are intentionally small adapter contracts, not
65
+ * PostgreSQL or S3 emulators. They are sufficient for fast command and HTTP
66
+ * behavior tests while keeping production adapters replaceable.
67
+ */
68
+ export declare function createMemorySandbox(options?: MemorySandboxOptions): MemorySandbox;
@@ -0,0 +1,11 @@
1
+ export interface MemoryPolicy {
2
+ grant(subject: string, permission: string): void;
3
+ revoke(subject: string, permission: string): void;
4
+ can(subject: string, permission: string): boolean;
5
+ claimIdempotency(subject: string, key: string): boolean;
6
+ subject(context: unknown): string;
7
+ runOnce<T>(subject: string, command: string, key: string, input: unknown, work: () => T | Promise<T>): Promise<T>;
8
+ clear(): void;
9
+ }
10
+ /** Test-only grants and successful-result receipts, scoped by subject and command. */
11
+ export declare function createMemoryPolicy(): MemoryPolicy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/elysia",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Elysia runtime adapter for SupaCloud compiled modules: application/request scopes, route registration and validation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",