@supacloud/elysia 0.6.0 → 0.8.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 +58 -0
- package/dist/feature.d.ts +15 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +355 -1
- package/dist/memory.d.ts +68 -0
- package/dist/memory_policy.d.ts +11 -0
- package/package.json +1 -1
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
|
|
@@ -88,7 +116,37 @@ once is rejected.
|
|
|
88
116
|
Executes a compiler-emitted Job descriptor with its static aspect list and
|
|
89
117
|
compiler-generated job scope.
|
|
90
118
|
|
|
119
|
+
### `assertFeatureTransition(spec, state, event)`
|
|
120
|
+
|
|
121
|
+
Checks a declared feature transition against an authoritative state and returns
|
|
122
|
+
the destination state. Unknown events, stale/illegal states and inherited object
|
|
123
|
+
members fail with HTTP 409 / `FEATURE_TRANSITION_CONFLICT`; malformed destinations
|
|
124
|
+
fail with `FEATURE_SPEC_INVALID`. The helper is a matrix assertion, not a workflow
|
|
125
|
+
engine, persistence or authorization layer. Call it inside the application's
|
|
126
|
+
transaction and persist with a row lock or expected-version check.
|
|
127
|
+
|
|
91
128
|
### `ApplicationError`
|
|
92
129
|
|
|
93
130
|
Lightweight error class carrying HTTP `status`, machine-readable `code`, and
|
|
94
131
|
optional structured `details`.
|
|
132
|
+
|
|
133
|
+
### `createMemorySandbox(options): MemorySandbox`
|
|
134
|
+
|
|
135
|
+
Creates an in-process application harness with `request()`, `db`, `storage`,
|
|
136
|
+
`identity`, `policy`, `audit`, and `reset()`. The memory database is a deterministic test adapter,
|
|
137
|
+
not a PostgreSQL emulator; its transaction callback operates on an isolated
|
|
138
|
+
snapshot and detects concurrent commits.
|
|
139
|
+
|
|
140
|
+
The adapter boundary is intentional: production authorization, RLS, PostgreSQL,
|
|
141
|
+
S3 visibility and failure semantics must be supplied by application governance
|
|
142
|
+
adapters. The memory harness is limited to deterministic HTTP, key-value
|
|
143
|
+
transaction and object-storage contract tests.
|
|
144
|
+
`policy` supplies explicit permission grants/revocations and idempotency claims;
|
|
145
|
+
`storage.failNext()` makes storage failure paths deterministic.
|
|
146
|
+
|
|
147
|
+
Set `memoryGovernance: true` to enable test-only authorization, transaction,
|
|
148
|
+
idempotency and audit adapters; permissions still require explicit grants.
|
|
149
|
+
HTTP receipt fingerprints include route, body, params, query and business
|
|
150
|
+
headers, not mutable request scopes/services or identity/tracing transport.
|
|
151
|
+
Authorization runs again on a replay. Use durable application-owned adapters
|
|
152
|
+
for production receipts, transactions and audits.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Structural contract: the runtime does not import the compiler or discover metadata. */
|
|
2
|
+
export interface FeatureTransitionSpec {
|
|
3
|
+
name: string;
|
|
4
|
+
states: readonly string[];
|
|
5
|
+
transitions: Readonly<Record<string, {
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
}>>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Check a declared transition against an authoritative state read by the caller.
|
|
12
|
+
* This is a matrix assertion, not persistence, authorization, or an FSM engine.
|
|
13
|
+
* Call inside the application's transaction and persist with a version check.
|
|
14
|
+
*/
|
|
15
|
+
export declare function assertFeatureTransition<Spec extends FeatureTransitionSpec>(spec: Spec, state: string, event: string): Spec["states"][number];
|
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,
|
|
84
|
+
params: Record<string, unknown>;
|
|
77
85
|
query: Record<string, unknown>;
|
|
78
86
|
};
|
|
79
87
|
request: Request;
|
|
@@ -82,6 +90,8 @@ export interface CommandInvocation {
|
|
|
82
90
|
services: Record<string, unknown>;
|
|
83
91
|
}
|
|
84
92
|
export type CommandExecutor = (invocation: CommandInvocation, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
|
|
93
|
+
export { assertFeatureTransition } from "./feature";
|
|
94
|
+
export type { FeatureTransitionSpec } from "./feature";
|
|
85
95
|
export interface ApplicationAspectContext {
|
|
86
96
|
kind: "route" | "command" | "job";
|
|
87
97
|
name: string;
|
|
@@ -205,3 +215,7 @@ export declare function defaultErrorResponse(error: unknown, frameworkCode?: str
|
|
|
205
215
|
export declare function createApplication(options: ApplicationOptions): Elysia;
|
|
206
216
|
/** Semantic alias of createApplication for readable tests. */
|
|
207
217
|
export declare function createTestApp(options: ApplicationOptions): Elysia;
|
|
218
|
+
export { createMemorySandbox, } from "./memory";
|
|
219
|
+
export type { HandleLike as MemoryHandleLike, MemoryDatabase, MemorySandbox, MemorySandboxOptions, MemoryStorage, MemoryStorageObject, } from "./memory";
|
|
220
|
+
export { createMemoryPolicy } from "./memory_policy";
|
|
221
|
+
export type { MemoryPolicy } from "./memory_policy";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,354 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { Elysia } from "elysia";
|
|
3
|
+
|
|
4
|
+
// src/feature.ts
|
|
5
|
+
function assertFeatureTransition(spec, state, event) {
|
|
6
|
+
const transition = Object.hasOwn(spec.transitions, event) ? spec.transitions[event] : undefined;
|
|
7
|
+
if (!transition || !spec.states.includes(state) || transition.from !== state) {
|
|
8
|
+
throw new ApplicationError("Feature transition is not allowed", {
|
|
9
|
+
status: 409,
|
|
10
|
+
code: "FEATURE_TRANSITION_CONFLICT"
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
if (!spec.states.includes(transition.to)) {
|
|
14
|
+
throw new ApplicationError("Feature transition references an undeclared state", {
|
|
15
|
+
code: "FEATURE_SPEC_INVALID"
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return transition.to;
|
|
19
|
+
}
|
|
20
|
+
// src/memory.ts
|
|
21
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
|
+
|
|
23
|
+
// src/memory_policy.ts
|
|
24
|
+
function createMemoryPolicy() {
|
|
25
|
+
const permissions = new Map;
|
|
26
|
+
const receipts = new Map;
|
|
27
|
+
return {
|
|
28
|
+
grant(subject, permission) {
|
|
29
|
+
const values = permissions.get(subject) ?? new Set;
|
|
30
|
+
values.add(permission);
|
|
31
|
+
permissions.set(subject, values);
|
|
32
|
+
},
|
|
33
|
+
revoke(subject, permission) {
|
|
34
|
+
permissions.get(subject)?.delete(permission);
|
|
35
|
+
},
|
|
36
|
+
can(subject, permission) {
|
|
37
|
+
return permissions.get(subject)?.has(permission) ?? false;
|
|
38
|
+
},
|
|
39
|
+
claimIdempotency(subject, key) {
|
|
40
|
+
const id = JSON.stringify([subject, key]);
|
|
41
|
+
if (receipts.has(id))
|
|
42
|
+
return false;
|
|
43
|
+
receipts.set(id, { input: "", result: Promise.resolve(undefined) });
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
subject(context) {
|
|
47
|
+
if (typeof context === "object" && context !== null && "identity" in context) {
|
|
48
|
+
const identity = context.identity;
|
|
49
|
+
if (typeof identity === "object" && identity !== null && "authenticated" in identity && identity.authenticated === true && "subject" in identity && typeof identity.subject === "string" && identity.subject.length > 0)
|
|
50
|
+
return identity.subject;
|
|
51
|
+
}
|
|
52
|
+
throw new ApplicationError("Memory authenticated identity required", { status: 401, code: "MEMORY_IDENTITY_REQUIRED" });
|
|
53
|
+
},
|
|
54
|
+
async runOnce(subject, command, key, input, work) {
|
|
55
|
+
if (!subject || !command || !key)
|
|
56
|
+
throw new Error("Memory receipt requires subject, command and key");
|
|
57
|
+
const id = JSON.stringify([subject, command, key]);
|
|
58
|
+
const fingerprint = stableStringify(input);
|
|
59
|
+
const existing = receipts.get(id);
|
|
60
|
+
if (existing) {
|
|
61
|
+
if (existing.input !== fingerprint) {
|
|
62
|
+
throw new ApplicationError("Idempotency input conflict", { status: 409, code: "MEMORY_IDEMPOTENCY_CONFLICT" });
|
|
63
|
+
}
|
|
64
|
+
return structuredClone(await existing.result);
|
|
65
|
+
}
|
|
66
|
+
const result = Promise.resolve().then(work).then((value) => structuredClone(value));
|
|
67
|
+
const entry = { input: fingerprint, result };
|
|
68
|
+
receipts.set(id, entry);
|
|
69
|
+
try {
|
|
70
|
+
return structuredClone(await result);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (receipts.get(id) === entry)
|
|
73
|
+
receipts.delete(id);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
clear() {
|
|
78
|
+
permissions.clear();
|
|
79
|
+
receipts.clear();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function stableStringify(value) {
|
|
84
|
+
if (value === null || typeof value !== "object")
|
|
85
|
+
return JSON.stringify(value);
|
|
86
|
+
if (Array.isArray(value))
|
|
87
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
88
|
+
const record = value;
|
|
89
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/memory.ts
|
|
93
|
+
class DefaultMemoryDatabase {
|
|
94
|
+
tables = new Map;
|
|
95
|
+
version = 0;
|
|
96
|
+
current = new AsyncLocalStorage;
|
|
97
|
+
get(table, key) {
|
|
98
|
+
if (this.current.getStore())
|
|
99
|
+
return this.current.getStore()?.get(table, key);
|
|
100
|
+
const value = this.tables.get(table)?.get(key);
|
|
101
|
+
return value === undefined ? undefined : cloneValue(value);
|
|
102
|
+
}
|
|
103
|
+
set(table, key, value) {
|
|
104
|
+
const current = this.current.getStore();
|
|
105
|
+
if (current)
|
|
106
|
+
return current.set(table, key, value);
|
|
107
|
+
const rows = this.tables.get(table) ?? new Map;
|
|
108
|
+
rows.set(key, cloneValue(value));
|
|
109
|
+
this.tables.set(table, rows);
|
|
110
|
+
this.version += 1;
|
|
111
|
+
}
|
|
112
|
+
delete(table, key) {
|
|
113
|
+
const current = this.current.getStore();
|
|
114
|
+
if (current)
|
|
115
|
+
return current.delete(table, key);
|
|
116
|
+
const deleted = this.tables.get(table)?.delete(key) ?? false;
|
|
117
|
+
if (deleted)
|
|
118
|
+
this.version += 1;
|
|
119
|
+
return deleted;
|
|
120
|
+
}
|
|
121
|
+
list(table) {
|
|
122
|
+
const current = this.current.getStore();
|
|
123
|
+
if (current)
|
|
124
|
+
return current.list(table);
|
|
125
|
+
return [...this.tables.get(table)?.entries() ?? []].map(([key, value]) => ({ key, value: cloneValue(value) })).sort((left, right) => left.key.localeCompare(right.key));
|
|
126
|
+
}
|
|
127
|
+
clear() {
|
|
128
|
+
const current = this.current.getStore();
|
|
129
|
+
if (current)
|
|
130
|
+
return current.clear();
|
|
131
|
+
this.tables.clear();
|
|
132
|
+
this.version += 1;
|
|
133
|
+
}
|
|
134
|
+
async transaction(work) {
|
|
135
|
+
if (this.current.getStore())
|
|
136
|
+
throw new Error("Nested memory transactions are not supported");
|
|
137
|
+
const baseVersion = this.version;
|
|
138
|
+
const snapshot = cloneTables(this.tables);
|
|
139
|
+
const transaction = new SnapshotMemoryDatabase(snapshot);
|
|
140
|
+
try {
|
|
141
|
+
const result = await this.current.run(transaction, () => work(transaction));
|
|
142
|
+
if (this.version !== baseVersion) {
|
|
143
|
+
throw new Error("Memory transaction conflict: state changed during transaction");
|
|
144
|
+
}
|
|
145
|
+
const committed = cloneTables(transaction.tables);
|
|
146
|
+
this.tables.clear();
|
|
147
|
+
for (const [table, rows] of committed)
|
|
148
|
+
this.tables.set(table, rows);
|
|
149
|
+
this.version += 1;
|
|
150
|
+
return result;
|
|
151
|
+
} finally {
|
|
152
|
+
transaction.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class SnapshotMemoryDatabase {
|
|
158
|
+
tables;
|
|
159
|
+
closed = false;
|
|
160
|
+
constructor(tables) {
|
|
161
|
+
this.tables = tables;
|
|
162
|
+
}
|
|
163
|
+
close() {
|
|
164
|
+
this.closed = true;
|
|
165
|
+
}
|
|
166
|
+
assertOpen() {
|
|
167
|
+
if (this.closed)
|
|
168
|
+
throw new Error("Memory transaction is closed");
|
|
169
|
+
}
|
|
170
|
+
get(table, key) {
|
|
171
|
+
this.assertOpen();
|
|
172
|
+
const value = this.tables.get(table)?.get(key);
|
|
173
|
+
return value === undefined ? undefined : cloneValue(value);
|
|
174
|
+
}
|
|
175
|
+
set(table, key, value) {
|
|
176
|
+
this.assertOpen();
|
|
177
|
+
const rows = this.tables.get(table) ?? new Map;
|
|
178
|
+
rows.set(key, cloneValue(value));
|
|
179
|
+
this.tables.set(table, rows);
|
|
180
|
+
}
|
|
181
|
+
delete(table, key) {
|
|
182
|
+
this.assertOpen();
|
|
183
|
+
return this.tables.get(table)?.delete(key) ?? false;
|
|
184
|
+
}
|
|
185
|
+
list(table) {
|
|
186
|
+
this.assertOpen();
|
|
187
|
+
return [...this.tables.get(table)?.entries() ?? []].map(([key, value]) => ({ key, value: cloneValue(value) })).sort((left, right) => left.key.localeCompare(right.key));
|
|
188
|
+
}
|
|
189
|
+
clear() {
|
|
190
|
+
this.assertOpen();
|
|
191
|
+
this.tables.clear();
|
|
192
|
+
}
|
|
193
|
+
transaction() {
|
|
194
|
+
return Promise.reject(new Error("Nested memory transactions are not supported"));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
class DefaultMemoryStorage {
|
|
199
|
+
objects = new Map;
|
|
200
|
+
pendingFailure;
|
|
201
|
+
policy;
|
|
202
|
+
put(bucket, key, body, options = {}) {
|
|
203
|
+
this.check("put", bucket, key);
|
|
204
|
+
this.objects.set(storageKey(bucket, key), {
|
|
205
|
+
bucket,
|
|
206
|
+
key,
|
|
207
|
+
body: typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body),
|
|
208
|
+
contentType: options.contentType,
|
|
209
|
+
metadata: { ...options.metadata ?? {} }
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
get(bucket, key) {
|
|
213
|
+
this.check("get", bucket, key);
|
|
214
|
+
const object = this.objects.get(storageKey(bucket, key));
|
|
215
|
+
return object ? cloneObject(object) : undefined;
|
|
216
|
+
}
|
|
217
|
+
delete(bucket, key) {
|
|
218
|
+
this.check("delete", bucket, key);
|
|
219
|
+
return this.objects.delete(storageKey(bucket, key));
|
|
220
|
+
}
|
|
221
|
+
list(bucket, prefix = "") {
|
|
222
|
+
this.check("list", bucket, prefix);
|
|
223
|
+
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);
|
|
224
|
+
}
|
|
225
|
+
clear() {
|
|
226
|
+
this.objects.clear();
|
|
227
|
+
this.pendingFailure = undefined;
|
|
228
|
+
}
|
|
229
|
+
failNext(error = new Error("Memory storage failure")) {
|
|
230
|
+
this.pendingFailure = error;
|
|
231
|
+
}
|
|
232
|
+
setPolicy(policy) {
|
|
233
|
+
this.policy = policy;
|
|
234
|
+
}
|
|
235
|
+
check(operation, bucket, key) {
|
|
236
|
+
if (this.pendingFailure) {
|
|
237
|
+
const error = this.pendingFailure;
|
|
238
|
+
this.pendingFailure = undefined;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
if (this.policy && !this.policy(operation, bucket, key)) {
|
|
242
|
+
throw new ApplicationError("Memory storage access denied", { status: 403, code: "MEMORY_STORAGE_DENIED" });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function createMemorySandbox(options = {}) {
|
|
247
|
+
const db = new DefaultMemoryDatabase;
|
|
248
|
+
const storage = new DefaultMemoryStorage;
|
|
249
|
+
const policy = createMemoryPolicy();
|
|
250
|
+
const audit = [];
|
|
251
|
+
const governance = {
|
|
252
|
+
authorize(invocation) {
|
|
253
|
+
const subject = policy.subject(invocation.requestContext);
|
|
254
|
+
if (!invocation.command.permission || !policy.can(subject, invocation.command.permission)) {
|
|
255
|
+
throw new ApplicationError("Memory permission denied", { status: 403, code: "MEMORY_PERMISSION_DENIED" });
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
idempotency(invocation, next) {
|
|
259
|
+
return policy.runOnce(policy.subject(invocation.requestContext), invocation.command.name, requireIdempotencyKey(invocation), memoryCommandInput(invocation), next);
|
|
260
|
+
},
|
|
261
|
+
transaction(_invocation, next) {
|
|
262
|
+
const count = audit.length;
|
|
263
|
+
return db.transaction(() => next()).catch((error) => {
|
|
264
|
+
audit.splice(count);
|
|
265
|
+
throw error;
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
audit: {
|
|
269
|
+
succeeded(invocation) {
|
|
270
|
+
audit.push({ command: invocation.command.name, outcome: "succeeded" });
|
|
271
|
+
},
|
|
272
|
+
failed(invocation) {
|
|
273
|
+
audit.push({ command: invocation.command.name, outcome: "failed" });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const identity = options.identity ?? { authenticated: false };
|
|
278
|
+
const requestId = options.requestId ?? "memory-request";
|
|
279
|
+
const configuredContext = options.requestContext;
|
|
280
|
+
const app = createApplication({
|
|
281
|
+
...options,
|
|
282
|
+
commandGovernance: options.commandGovernance ?? (options.memoryGovernance ? governance : undefined),
|
|
283
|
+
deps: {
|
|
284
|
+
...options.deps ?? {},
|
|
285
|
+
dbClient: db,
|
|
286
|
+
memoryDb: db,
|
|
287
|
+
storage,
|
|
288
|
+
memoryStorage: storage,
|
|
289
|
+
memoryPolicy: policy
|
|
290
|
+
},
|
|
291
|
+
requestContext: configuredContext ?? ((request) => ({
|
|
292
|
+
requestId: request.headers.get("x-request-id") ?? requestId,
|
|
293
|
+
request,
|
|
294
|
+
identity
|
|
295
|
+
}))
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
app,
|
|
299
|
+
db,
|
|
300
|
+
storage,
|
|
301
|
+
policy,
|
|
302
|
+
audit,
|
|
303
|
+
identity,
|
|
304
|
+
request(path, init = {}) {
|
|
305
|
+
const url = path.startsWith("http://") || path.startsWith("https://") ? path : `http://memory.local${path.startsWith("/") ? path : `/${path}`}`;
|
|
306
|
+
return app.handle(new Request(url, init));
|
|
307
|
+
},
|
|
308
|
+
reset() {
|
|
309
|
+
db.clear();
|
|
310
|
+
storage.clear();
|
|
311
|
+
policy.clear();
|
|
312
|
+
audit.length = 0;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function memoryCommandInput(invocation) {
|
|
317
|
+
const input = invocation.input;
|
|
318
|
+
if (typeof input !== "object" || input === null)
|
|
319
|
+
return input;
|
|
320
|
+
const ignoredHeaders = new Set(["authorization", "idempotency-key", "x-request-id", "traceparent", "tracestate"]);
|
|
321
|
+
const headers = Object.fromEntries([...invocation.request.headers.entries()].filter(([name]) => !ignoredHeaders.has(name)));
|
|
322
|
+
return {
|
|
323
|
+
method: invocation.request.method,
|
|
324
|
+
path: new URL(invocation.request.url).pathname,
|
|
325
|
+
body: "body" in input ? input.body : undefined,
|
|
326
|
+
params: "params" in input ? input.params : undefined,
|
|
327
|
+
query: "query" in input ? input.query : undefined,
|
|
328
|
+
headers
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function storageKey(bucket, key) {
|
|
332
|
+
return JSON.stringify([bucket, key]);
|
|
333
|
+
}
|
|
334
|
+
function cloneObject(object) {
|
|
335
|
+
return {
|
|
336
|
+
...object,
|
|
337
|
+
body: new Uint8Array(object.body),
|
|
338
|
+
metadata: { ...object.metadata }
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function cloneTables(tables) {
|
|
342
|
+
return new Map([...tables.entries()].map(([table, rows]) => [
|
|
343
|
+
table,
|
|
344
|
+
new Map([...rows.entries()].map(([key, value]) => [key, cloneValue(value)]))
|
|
345
|
+
]));
|
|
346
|
+
}
|
|
347
|
+
function cloneValue(value) {
|
|
348
|
+
return structuredClone(value);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/index.ts
|
|
3
352
|
var VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
|
|
4
353
|
var EXECUTION_ID_HEADER = "x-sb-execution-id";
|
|
5
354
|
var IDEMPOTENCY_KEY_HEADER = "idempotency-key";
|
|
@@ -250,11 +599,13 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
250
599
|
body: ctx.body,
|
|
251
600
|
params: ctx.params,
|
|
252
601
|
query: ctx.query,
|
|
602
|
+
headers: Object.fromEntries(ctx.request.headers.entries()),
|
|
603
|
+
context: requestContext,
|
|
253
604
|
request: ctx.request,
|
|
254
605
|
scope: requestScope,
|
|
255
606
|
requestContext
|
|
256
607
|
};
|
|
257
|
-
const invoke = () => Reflect.apply(method, instance, [input]);
|
|
608
|
+
const invoke = () => route.invoker ? route.invoker(instance, input) : Reflect.apply(method, instance, [input]);
|
|
258
609
|
const routeContext = {
|
|
259
610
|
kind: "route",
|
|
260
611
|
name: `${route.method} ${path}`,
|
|
@@ -436,10 +787,13 @@ export {
|
|
|
436
787
|
EXECUTION_ID_HEADER,
|
|
437
788
|
IDEMPOTENCY_KEY_HEADER,
|
|
438
789
|
VERIFIED_JWT_SUBJECT_HEADER,
|
|
790
|
+
assertFeatureTransition,
|
|
439
791
|
composeAspects,
|
|
440
792
|
composeCommandExecutors,
|
|
441
793
|
createApplication,
|
|
442
794
|
createCommandExecutor,
|
|
795
|
+
createMemoryPolicy,
|
|
796
|
+
createMemorySandbox,
|
|
443
797
|
createModulePlugin,
|
|
444
798
|
createSupaCloudRequestContext,
|
|
445
799
|
createTestApp,
|
package/dist/memory.d.ts
ADDED
|
@@ -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