eraspace 0.0.0-stage → 0.2.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.
@@ -0,0 +1,635 @@
1
+ // Generated by scripts/generate-server-api.ts. Edit api/server-api.ts or api/server.ts.in.
2
+ // eraspace/server: capsule definition API.
3
+ //
4
+ // V8 supplies this module from the engine bootstrap. These descriptors also run in Node for tooling and tests;
5
+ // their types drive client and server inference.
6
+ //
7
+ // Type design. `ctx.db` must be typed from `schema` even though both live in the same object literal:
8
+ //
9
+ // capsule({ schema: {...}, queries: { list: query(async (ctx) => ctx.db.todos.collect()) } })
10
+ //
11
+ // `query()` is generic over the schema `S` and returns a *callable* definition type. TypeScript skips nested calls
12
+ // to generic functions that return functions in its first inference pass over `capsule()`'s argument, so `S` is
13
+ // inferred from `schema` first. In the second pass `query(...)` is checked with the contextual type
14
+ // `QueryDefinition<S>`, and return-type inference hands `S` to the handler's `ctx`. Without the call signature this
15
+ // does not work (see sdk/test/types).
16
+
17
+ // ---------------------------------------------------------------------------------------------------------------
18
+ // Ids and validators
19
+
20
+ declare const tableBrand: unique symbol;
21
+
22
+ /** A row id tagged with the table it points at. Any `Id<T>` is a `string`. */
23
+ export type Id<TableName extends string = string> = string & { readonly [tableBrand]?: TableName };
24
+
25
+ export type FieldKind = "string" | "number" | "boolean" | "id" | "userId" | "any" | "array" | "object";
26
+
27
+ /**
28
+ * One column (or nested value) validator: what `string()`, `number()`, `id()`, ... return.
29
+ * `Out` is the stored/read type, `In` the accepted write type.
30
+ */
31
+ export interface Field<Out = unknown, In = Out, Opt extends boolean = boolean, Def extends boolean = boolean> {
32
+ readonly kind: FieldKind;
33
+ readonly isOptional: Opt;
34
+ readonly hasDefault: Def;
35
+ readonly defaultValue?: Out;
36
+ readonly table?: string;
37
+ readonly element?: AnyField;
38
+ readonly fields?: Record<string, AnyField>;
39
+ /** Allow absence; writes accept null to clear the field. */
40
+ optional(): Field<Out, In, true, Def>;
41
+ /** Fill omitted insert values with this default. */
42
+ default(value: In): Field<Out, In, Opt, true>;
43
+ /** Phantom inference marker, never set at runtime. */
44
+ readonly __types?: { out: Out; in: In };
45
+ }
46
+
47
+ export type AnyField = Field<any, any, boolean, boolean>;
48
+ export type FieldMap = Record<string, AnyField>;
49
+
50
+ type FieldOut<F> = F extends Field<infer O, any, any, any> ? O : never;
51
+ type FieldIn<F> = F extends Field<any, infer I, any, any> ? I : never;
52
+ type IsOpt<F> = F extends Field<any, any, infer O, any> ? (O extends true ? true : false) : false;
53
+ type HasDef<F> = F extends Field<any, any, any, infer D> ? (D extends true ? true : false) : false;
54
+
55
+ type Simplify<T> = { [K in keyof T]: T[K] } & {};
56
+
57
+ /** Keys that may be absent on a read: optional without a default. */
58
+ type ReadOptionalKeys<F extends FieldMap> = {
59
+ [K in keyof F]: IsOpt<F[K]> extends true ? (HasDef<F[K]> extends true ? never : K) : never;
60
+ }[keyof F];
61
+ type ReadRequiredKeys<F extends FieldMap> = Exclude<keyof F, ReadOptionalKeys<F>>;
62
+ type WriteOptionalKeys<F extends FieldMap> = {
63
+ [K in keyof F]: IsOpt<F[K]> extends true ? K : HasDef<F[K]> extends true ? K : never;
64
+ }[keyof F];
65
+ type WriteRequiredKeys<F extends FieldMap> = Exclude<keyof F, WriteOptionalKeys<F>>;
66
+ type Nullable<F, T> = IsOpt<F> extends true ? T | null : T;
67
+
68
+ /** The fields of a stored row (without `id`/`createdAt`). */
69
+ export type InferFields<F extends FieldMap> = Simplify<
70
+ { [K in ReadRequiredKeys<F>]: FieldOut<F[K]> } & { [K in ReadOptionalKeys<F>]?: FieldOut<F[K]> }
71
+ >;
72
+ /** What `insert`/`replace` accept. */
73
+ export type InferInsert<F extends FieldMap> = Simplify<
74
+ { [K in WriteRequiredKeys<F>]: FieldIn<F[K]> } & { [K in WriteOptionalKeys<F>]?: Nullable<F[K], FieldIn<F[K]>> }
75
+ >;
76
+ /** What `update` accepts. `null` removes an optional field. */
77
+ export type InferPatch<F extends FieldMap> = Simplify<{ [K in keyof F]?: Nullable<F[K], FieldIn<F[K]>> }>;
78
+
79
+ function makeField(kind: FieldKind, extra: Partial<Record<string, unknown>> = {}): any {
80
+ const base = {
81
+ kind,
82
+ isOptional: false,
83
+ hasDefault: false,
84
+ ...extra,
85
+ optional() {
86
+ return makeField(kind, { ...strip(this), isOptional: true });
87
+ },
88
+ default(value: unknown) {
89
+ return makeField(kind, { ...strip(this), hasDefault: true, defaultValue: value });
90
+ },
91
+ toJSON() {
92
+ return describeField(this as AnyField);
93
+ },
94
+ };
95
+ return base;
96
+ }
97
+
98
+ function strip(f: any): Record<string, unknown> {
99
+ const { kind: _k, optional: _o, default: _d, toJSON: _t, ...rest } = f;
100
+ return rest;
101
+ }
102
+
103
+ function describeField(f: AnyField): Record<string, unknown> {
104
+ const out: Record<string, unknown> = { type: f.kind };
105
+ if (f.isOptional) out.optional = true;
106
+ if (f.hasDefault) out.default = f.defaultValue;
107
+ if (f.table) out.table = f.table;
108
+ if (f.element) out.element = describeField(f.element);
109
+ if (f.fields) out.fields = Object.fromEntries(Object.entries(f.fields).map(([k, v]) => [k, describeField(v)]));
110
+ return out;
111
+ }
112
+
113
+ export function string(): Field<string, string, false, false> {
114
+ return makeField("string");
115
+ }
116
+ export function number(): Field<number, number, false, false> {
117
+ return makeField("number");
118
+ }
119
+ export function boolean(): Field<boolean, boolean, false, false> {
120
+ return makeField("boolean");
121
+ }
122
+ /** A row id from the named table. Reads as `Id<table>`, accepts any string on write. */
123
+ export function id<T extends string>(table: T): Field<Id<T>, string, false, false> {
124
+ return makeField("id", { table });
125
+ }
126
+ /** A user reference (in v1 a per-browser guest id from `ctx.auth`). */
127
+ export function userId(): Field<string, string, false, false> {
128
+ return makeField("userId");
129
+ }
130
+ export function any(): Field<any, any, false, false> {
131
+ return makeField("any");
132
+ }
133
+ export function array<F extends AnyField>(element: F): Field<FieldOut<F>[], FieldIn<F>[], false, false> {
134
+ return makeField("array", { element });
135
+ }
136
+ export function object<F extends FieldMap>(fields: F): Field<InferFields<F>, InferInsert<F>, false, false> {
137
+ return makeField("object", { fields });
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------------------------------------------
141
+ // Tables and schema
142
+
143
+ export type IndexMap = Record<string, readonly string[]>;
144
+
145
+ export interface TableDefinition<F extends FieldMap = FieldMap, I extends IndexMap = {}> {
146
+ readonly kind: "table";
147
+ readonly fields: F;
148
+ readonly indexes: I;
149
+ /** Declare an index with an implicit createdAt, id suffix. */
150
+ index<const N extends string, const Fs extends readonly (keyof F & string)[]>(
151
+ name: N,
152
+ fields: Fs,
153
+ ): TableDefinition<F, Simplify<I & { [P in N]: Fs }>>;
154
+ }
155
+
156
+ export type AnyTable = TableDefinition<any, any>;
157
+ export type Schema = Record<string, AnyTable>;
158
+ /** The schema a capsule gets when it declares none. Tables and fields are untyped. */
159
+ export type DefaultSchema = Record<string, TableDefinition<Record<string, Field<any, any, boolean, boolean>>, IndexMap>>;
160
+
161
+ export function table<const F extends FieldMap>(fields: F): TableDefinition<F, {}> {
162
+ const indexes: Record<string, readonly string[]> = {};
163
+ const def: any = {
164
+ kind: "table",
165
+ fields,
166
+ indexes,
167
+ index(name: string, idx: readonly string[]) {
168
+ if (name === "by_creation" || name in indexes) throw new Error(`Duplicate index ${name}`);
169
+ indexes[name] = idx;
170
+ return def;
171
+ },
172
+ toJSON() {
173
+ return {
174
+ fields: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, describeField(v)])),
175
+ indexes,
176
+ };
177
+ },
178
+ };
179
+ return def;
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------------------------------------------
183
+ // Documents and the database
184
+
185
+ type IsLoose<S> = string extends keyof S ? true : false;
186
+ type TableFields<S, T extends keyof S> = S[T] extends TableDefinition<infer F, any> ? F : never;
187
+ type TableIndexes<S, T extends keyof S> = S[T] extends TableDefinition<any, infer I> ? I : never;
188
+
189
+ /** A stored row of table `T`: its fields plus `id` and `createdAt` (epoch ms). */
190
+ export type Doc<S extends Schema = DefaultSchema, T extends keyof S & string = keyof S & string> =
191
+ IsLoose<S> extends true
192
+ ? { id: Id<T>; createdAt: number; [field: string]: any }
193
+ : Simplify<{ id: Id<T>; createdAt: number } & InferFields<TableFields<S, T>>>;
194
+
195
+ export interface PaginationOptions {
196
+ cursor: string | null;
197
+ numItems: number;
198
+ }
199
+ export interface PaginationResult<T> {
200
+ page: T[];
201
+ isDone: boolean;
202
+ continueCursor: string;
203
+ }
204
+
205
+ /** Marker returned by an index range callback. */
206
+ export interface IndexRange {
207
+ readonly __indexRange: true;
208
+ }
209
+
210
+ type Head<Fs extends readonly string[]> = Fs extends readonly [infer H extends string, ...any[]] ? H : never;
211
+ type Tail<Fs extends readonly string[]> = Fs extends readonly [any, ...infer R extends string[]] ? R : [];
212
+ type ValueOf<D, K> = K extends keyof D
213
+ ? undefined extends D[K]
214
+ ? Exclude<D[K], undefined> | null
215
+ : D[K]
216
+ : never;
217
+ type RangeValue<D, K> = K extends keyof D ? Exclude<D[K], undefined> : never;
218
+
219
+ /** `q` in `withIndex(name, q => ...)`: an equality prefix, then one range on the next index field. */
220
+ export interface IndexRangeBuilder<D, Fs extends readonly string[]> extends IndexRange {
221
+ eq<K extends Head<Fs>>(field: K, value: ValueOf<D, K>): IndexRangeBuilder<D, Tail<Fs>>;
222
+ gt<K extends Head<Fs>>(field: K, value: RangeValue<D, K>): UpperBoundBuilder<D, K>;
223
+ gte<K extends Head<Fs>>(field: K, value: RangeValue<D, K>): UpperBoundBuilder<D, K>;
224
+ lt<K extends Head<Fs>>(field: K, value: RangeValue<D, K>): LowerBoundBuilder<D, K>;
225
+ lte<K extends Head<Fs>>(field: K, value: RangeValue<D, K>): LowerBoundBuilder<D, K>;
226
+ }
227
+ export interface UpperBoundBuilder<D, K> extends IndexRange {
228
+ lt(field: K, value: RangeValue<D, K>): IndexRange;
229
+ lte(field: K, value: RangeValue<D, K>): IndexRange;
230
+ }
231
+ export interface LowerBoundBuilder<D, K> extends IndexRange {
232
+ gt(field: K, value: RangeValue<D, K>): IndexRange;
233
+ gte(field: K, value: RangeValue<D, K>): IndexRange;
234
+ }
235
+
236
+ /** An ordered index scan. Terminals are async. */
237
+ export interface OrderedQuery<D> {
238
+ collect(): Promise<D[]>;
239
+ take(n: number): Promise<D[]>;
240
+ /** Return null when no row matches. */
241
+ first(): Promise<D | null>;
242
+ /** Return null when no row matches; throw if more than one row matches. */
243
+ unique(): Promise<D | null>;
244
+ count(): Promise<number>;
245
+ paginate(options: PaginationOptions): Promise<PaginationResult<D>>;
246
+ }
247
+ export interface IndexQuery<D> extends OrderedQuery<D> {
248
+ order(order: "asc" | "desc"): OrderedQuery<D>;
249
+ }
250
+
251
+ type IndexFieldsOf<I, N> = N extends "by_creation"
252
+ ? ["createdAt"]
253
+ : N extends keyof I
254
+ ? I[N] extends readonly string[]
255
+ ? [...I[N], "createdAt"]
256
+ : never
257
+ : never;
258
+
259
+ /** Read access to one table. Table-level `order/collect/take/...` scan the implicit `by_creation` index. */
260
+ export interface ReadTable<D, I extends IndexMap> extends IndexQuery<D> {
261
+ get(id: string): Promise<D | null>;
262
+ withIndex<N extends (keyof I & string) | "by_creation">(
263
+ name: N,
264
+ range?: (q: IndexRangeBuilder<D, IndexFieldsOf<I, N>>) => IndexRange,
265
+ ): IndexQuery<D>;
266
+ }
267
+ export interface WriteTable<D, I extends IndexMap, Ins, Patch, T extends string = string> extends ReadTable<D, I> {
268
+ insert(doc: Ins): Promise<Id<T>>;
269
+ /** Shallow merge; null removes an optional field. */
270
+ update(id: string, patch: Patch): Promise<void>;
271
+ replace(id: string, doc: Ins): Promise<void>;
272
+ delete(id: string): Promise<void>;
273
+ /** Shallow merge; null removes an optional field. */
274
+ patch(id: string, patch: Patch): Promise<void>;
275
+ }
276
+
277
+ type LooseDoc = Doc<DefaultSchema, string>;
278
+ interface LooseQuery extends OrderedQuery<LooseDoc> {
279
+ order(order: "asc" | "desc"): OrderedQuery<LooseDoc>;
280
+ }
281
+ /** Untyped table access (no schema type available). Any typed table is assignable to it. */
282
+ interface LooseRead extends LooseQuery {
283
+ get(id: string): Promise<LooseDoc | null>;
284
+ withIndex(name: string, range?: (q: any) => any): LooseQuery;
285
+ }
286
+ interface LooseWrite extends LooseRead {
287
+ insert(doc: Record<string, any>): Promise<Id<any>>;
288
+ update(id: string, patch: Record<string, any>): Promise<void>;
289
+ patch(id: string, patch: Record<string, any>): Promise<void>;
290
+ replace(id: string, doc: Record<string, any>): Promise<void>;
291
+ delete(id: string): Promise<void>;
292
+ }
293
+
294
+ /** Read-only `ctx.db`, used by queries and actions. */
295
+ export type DbContext<S extends Schema = DefaultSchema> =
296
+ IsLoose<S> extends true
297
+ ? Record<string, LooseRead>
298
+ : { readonly [T in keyof S & string]: ReadTable<Doc<S, T>, TableIndexes<S, T>> };
299
+
300
+ /** Writable `ctx.db`, used by mutations and writable endpoints. */
301
+ export type MutationDbContext<S extends Schema = DefaultSchema> =
302
+ IsLoose<S> extends true
303
+ ? Record<string, LooseWrite>
304
+ : {
305
+ readonly [T in keyof S & string]: WriteTable<
306
+ Doc<S, T>,
307
+ TableIndexes<S, T>,
308
+ InferInsert<TableFields<S, T>>,
309
+ InferPatch<TableFields<S, T>>,
310
+ T
311
+ >;
312
+ };
313
+
314
+ // ---------------------------------------------------------------------------------------------------------------
315
+ // Handler contexts
316
+
317
+ export interface LogContext {
318
+ debug(message: string, data?: unknown): void;
319
+ info(message: string, data?: unknown): void;
320
+ warn(message: string, data?: unknown): void;
321
+ error(message: string, data?: unknown): void;
322
+ }
323
+
324
+ export interface Identity {
325
+ userId: string;
326
+ isGuest: true;
327
+ displayName: string;
328
+ }
329
+ export interface AuthContext {
330
+ /** The caller's guest id, or null for an anonymous request. */
331
+ readonly userId: string | null;
332
+ /** Throw if the request has no identity. */
333
+ requireIdentity(): Identity;
334
+ getIdentity(): Identity | null;
335
+ requireSignedIn(): never;
336
+ readonly isGuest: true;
337
+ readonly isSignedIn: false;
338
+ }
339
+
340
+ interface BaseContext {
341
+ log: LogContext;
342
+ /** Deployment environment, never sent to the client. */
343
+ env: Record<string, string | undefined>;
344
+ auth: AuthContext;
345
+ }
346
+
347
+ // The contexts are parameterised by their db type (not the schema) so that TypeScript compares them structurally:
348
+ // a typed `ServerContext<typeof schema>` is assignable to the untyped `ServerContext`.
349
+ interface DbHolder<Db> extends BaseContext {
350
+ db: Db;
351
+ }
352
+ interface ActionHolder<Db> extends DbHolder<Db> {
353
+ runQuery(name: string, ...args: any[]): Promise<any>;
354
+ runMutation(name: string, ...args: any[]): Promise<any>;
355
+ runAction(name: string, ...args: unknown[]): Promise<unknown>;
356
+ }
357
+
358
+ /** The `ctx` a `query()` (or a `readOnly` endpoint) receives. */
359
+ export type QueryServerContext<S extends Schema = DefaultSchema> = DbHolder<DbContext<S>>;
360
+ /** The `ctx` a `mutation()` or writable `endpoint()` receives. Its `db` can write. */
361
+ export type ServerContext<S extends Schema = DefaultSchema> = DbHolder<MutationDbContext<S>>;
362
+ /** The `ctx` an `action()` receives: read-only db plus `runQuery`/`runMutation`. Use global `fetch` for HTTP. */
363
+ export type ActionServerContext<S extends Schema = DefaultSchema> = ActionHolder<DbContext<S>>;
364
+
365
+ // ---------------------------------------------------------------------------------------------------------------
366
+ // Handlers
367
+
368
+ export interface ToolOptions<A extends FieldMap | undefined = FieldMap | undefined> {
369
+ /** What the tool does, what it returns, and where its inputs come from. Written for an agent. */
370
+ description: string;
371
+ args?: A;
372
+ }
373
+ export interface HandlerOptions<A extends FieldMap | undefined = FieldMap | undefined> {
374
+ tool?: ToolOptions<A>;
375
+ }
376
+
377
+ export type HandlerKind = "query" | "mutation" | "action" | "endpoint";
378
+
379
+ interface DefinitionBase<K extends HandlerKind, A extends any[], R> {
380
+ readonly kind: K;
381
+ readonly tool?: ToolOptions;
382
+ /** Phantom marker for the client types. Never set at runtime. */
383
+ readonly __types?: { args: A; result: R };
384
+ }
385
+
386
+ export interface QueryDefinition<S extends Schema, A extends any[], R> extends DefinitionBase<"query", A, R> {
387
+ (ctx: QueryServerContext<S>, ...args: A): Promise<R>;
388
+ }
389
+ export interface MutationDefinition<S extends Schema, A extends any[], R> extends DefinitionBase<"mutation", A, R> {
390
+ (ctx: ServerContext<S>, ...args: A): Promise<R>;
391
+ }
392
+ export interface ActionDefinition<S extends Schema, A extends any[], R> extends DefinitionBase<"action", A, R> {
393
+ (ctx: ActionServerContext<S>, ...args: A): Promise<R>;
394
+ }
395
+
396
+ type ToolArgs<TA extends FieldMap> = InferFields<TA>;
397
+
398
+ function define(kind: HandlerKind, fn: Function, options?: HandlerOptions<any>): any {
399
+ const def: any = (ctx: unknown, ...args: unknown[]) => Promise.resolve(fn(ctx, ...args));
400
+ def.kind = kind;
401
+ def.handler = fn;
402
+ if (options?.tool) def.tool = options.tool;
403
+ return def;
404
+ }
405
+
406
+ /** A read-only handler. Clients subscribe to it with `useQuery` and get live updates. */
407
+ export function query<S extends Schema, const TA extends FieldMap, R>(
408
+ handler: (ctx: QueryServerContext<S>, args: ToolArgs<TA>) => R,
409
+ options: { tool: ToolOptions<TA> & { args: TA } },
410
+ ): QueryDefinition<S, [args: ToolArgs<TA>], Awaited<R>>;
411
+ export function query<S extends Schema, A extends any[], R>(
412
+ handler: (ctx: QueryServerContext<S>, ...args: A) => R,
413
+ options?: HandlerOptions<undefined>,
414
+ ): QueryDefinition<S, A, Awaited<R>>;
415
+ export function query(handler: Function, options?: HandlerOptions<any>): any {
416
+ return define("query", handler, options);
417
+ }
418
+
419
+ /** A serializable write. Conflicts retry the handler; fetch is forbidden. */
420
+ export function mutation<S extends Schema, const TA extends FieldMap, R>(
421
+ handler: (ctx: ServerContext<S>, args: ToolArgs<TA>) => R,
422
+ options: { tool: ToolOptions<TA> & { args: TA } },
423
+ ): MutationDefinition<S, [args: ToolArgs<TA>], Awaited<R>>;
424
+ export function mutation<S extends Schema, A extends any[], R>(
425
+ handler: (ctx: ServerContext<S>, ...args: A) => R,
426
+ options?: HandlerOptions<undefined>,
427
+ ): MutationDefinition<S, A, Awaited<R>>;
428
+ export function mutation(handler: Function, options?: HandlerOptions<any>): any {
429
+ return define("mutation", handler, options);
430
+ }
431
+
432
+ /** Server work with read-only db access, `fetch`, and `ctx.runQuery`/`ctx.runMutation`. */
433
+ export function action<S extends Schema, const TA extends FieldMap, R>(
434
+ handler: (ctx: ActionServerContext<S>, args: ToolArgs<TA>) => R,
435
+ options: { tool: ToolOptions<TA> & { args: TA } },
436
+ ): ActionDefinition<S, [args: ToolArgs<TA>], Awaited<R>>;
437
+ export function action<S extends Schema, A extends any[], R>(
438
+ handler: (ctx: ActionServerContext<S>, ...args: A) => R,
439
+ options?: HandlerOptions<undefined>,
440
+ ): ActionDefinition<S, A, Awaited<R>>;
441
+ export function action(handler: Function, options?: HandlerOptions<any>): any {
442
+ return define("action", handler, options);
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------------------------------------------
446
+ // Endpoints
447
+
448
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
449
+
450
+ export interface EndpointRoute {
451
+ method: HttpMethod;
452
+ /** Path under the app, e.g. `/api/hook`. Served at `/<app>/api/hook`. */
453
+ path: string;
454
+ /** Read-only endpoints get a read-only `ctx.db`. */
455
+ readOnly?: boolean;
456
+ }
457
+
458
+ export interface EndpointHeaders {
459
+ get(name: string): string | null;
460
+ has(name: string): boolean;
461
+ entries(): [string, string][];
462
+ keys(): string[];
463
+ }
464
+ export interface EndpointQuery {
465
+ get(name: string): string | null;
466
+ has(name: string): boolean;
467
+ entries(): [string, string][];
468
+ getAll(name: string): string[];
469
+ }
470
+ export interface EndpointRequest {
471
+ method: string;
472
+ path: string;
473
+ url: string;
474
+ headers: EndpointHeaders;
475
+ query: EndpointQuery;
476
+ text(): Promise<string>;
477
+ json<T = unknown>(): Promise<T>;
478
+ /** Request body bytes as a JSON-compatible number array. */
479
+ bytes(): Promise<number[]>;
480
+ }
481
+
482
+ export interface EndpointResponseOptions {
483
+ status?: number;
484
+ headers?: Record<string, string>;
485
+ }
486
+ export interface EndpointResponse {
487
+ readonly kind: "response";
488
+ status: number;
489
+ headers: Record<string, string>;
490
+ body: string | null;
491
+ }
492
+
493
+ type EndpointContext<S extends Schema, R extends EndpointRoute> = R extends { readOnly: true }
494
+ ? QueryServerContext<S>
495
+ : ServerContext<S>;
496
+
497
+ export interface EndpointDefinition<S extends Schema = DefaultSchema, R extends EndpointRoute = EndpointRoute>
498
+ extends DefinitionBase<"endpoint", [request: EndpointRequest], EndpointResponse | unknown> {
499
+ (ctx: EndpointContext<S, R>, request: EndpointRequest): Promise<EndpointResponse | unknown>;
500
+ readonly method: HttpMethod;
501
+ readonly path: string;
502
+ readonly readOnly: boolean;
503
+ }
504
+
505
+ /** An HTTP route under `/<app>/`. Return `json() / text() / empty() / redirect()`, or a plain value (sent as JSON). */
506
+ export function endpoint<S extends Schema, const R extends EndpointRoute>(
507
+ route: R,
508
+ handler: (ctx: EndpointContext<S, R>, request: EndpointRequest) => unknown,
509
+ ): EndpointDefinition<S, R> {
510
+ const def = define("endpoint", handler);
511
+ def.method = route.method;
512
+ def.path = route.path;
513
+ def.readOnly = route.readOnly === true;
514
+ return def;
515
+ }
516
+
517
+ function response(status: number, contentType: string | null, body: string | null, options?: EndpointResponseOptions): EndpointResponse {
518
+ const headers: Record<string, string> = {};
519
+ if (contentType) headers["content-type"] = contentType;
520
+ if (options?.headers) for (const k of Object.keys(options.headers)) headers[k.toLowerCase()] = options.headers[k]!;
521
+ return { kind: "response", status: options?.status ?? status, headers, body };
522
+ }
523
+
524
+ /** JSON body, status 200. */
525
+ export function json(value: unknown, options?: EndpointResponseOptions): EndpointResponse {
526
+ return response(200, "application/json; charset=utf-8", JSON.stringify(value) ?? "null", options);
527
+ }
528
+ /** Plain text body, status 200. */
529
+ export function text(value: string, options?: EndpointResponseOptions): EndpointResponse {
530
+ return response(200, "text/plain; charset=utf-8", value, options);
531
+ }
532
+ /** No body, status 204. */
533
+ export function empty(options?: EndpointResponseOptions): EndpointResponse {
534
+ return response(204, null, null, options);
535
+ }
536
+ /** No body, status 302, `Location: url`. */
537
+ export function redirect(url: string, options?: EndpointResponseOptions): EndpointResponse {
538
+ return response(302, null, null, { ...options, headers: { location: url, ...options?.headers } });
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------------------------------------------
542
+ // capsule()
543
+
544
+ /** Any handler definition, matched by its `kind` marker (contexts are not compared). */
545
+ export type AnyHandler = DefinitionBase<HandlerKind, any[], any> & ((...args: any[]) => any);
546
+
547
+ type HandlerFor<S extends Schema> =
548
+ | QueryDefinition<S, any, any>
549
+ | MutationDefinition<S, any, any>
550
+ | ActionDefinition<S, any, any>
551
+ | EndpointDefinition<S, any>;
552
+
553
+ const GROUP_KEYS = ["queries", "mutations", "actions", "endpoints"] as const;
554
+ type GroupKey = (typeof GROUP_KEYS)[number];
555
+ type ReservedKey = GroupKey | "schema";
556
+
557
+ /** The argument of `capsule()`. Handlers go in groups (Lakebed form) or at the top level (flat form). */
558
+ export interface CapsuleConfig<S extends Schema> {
559
+ schema?: S;
560
+ queries?: Record<string, QueryDefinition<S, any, any>>;
561
+ mutations?: Record<string, MutationDefinition<S, any, any>>;
562
+ actions?: Record<string, ActionDefinition<S, any, any>>;
563
+ endpoints?: Record<string, EndpointDefinition<S, any>>;
564
+ [name: string]:
565
+ | S
566
+ | HandlerFor<S>
567
+ | Record<string, QueryDefinition<S, any, any>>
568
+ | Record<string, MutationDefinition<S, any, any>>
569
+ | Record<string, ActionDefinition<S, any, any>>
570
+ | Record<string, EndpointDefinition<S, any>>
571
+ | undefined;
572
+ }
573
+
574
+ type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
575
+ type GroupHandlers<D> = UnionToIntersection<
576
+ { [G in GroupKey]: G extends keyof D ? (NonNullable<D[G]> extends Record<string, any> ? NonNullable<D[G]> : {}) : {} }[GroupKey]
577
+ >;
578
+ type FlatHandlers<D> = { [K in keyof D as K extends ReservedKey ? never : D[K] extends AnyHandler ? K : never]: D[K] };
579
+
580
+ /** All handlers of a capsule config, by name. */
581
+ export type HandlersOf<D> = Simplify<GroupHandlers<D> & FlatHandlers<D>>;
582
+
583
+ /** What `capsule()` returns. Import it on the client with `import type app from "../server/index"`. */
584
+ export interface Capsule<S extends Schema = Schema, H = Record<string, AnyHandler>> {
585
+ readonly kind: "capsule";
586
+ readonly schema: S;
587
+ readonly functions: H;
588
+ }
589
+
590
+ /** Define the capsule. `server/index.ts` must `export default capsule({...})`. */
591
+ export function capsule<S extends Schema = {}, D extends CapsuleConfig<S> = CapsuleConfig<S>>(
592
+ config: D & { schema?: S },
593
+ ): Capsule<S, HandlersOf<D>> {
594
+ const functions: Record<string, AnyHandler> = {};
595
+ const add = (name: string, def: any) => {
596
+ if (typeof def !== "function" || !def.kind) throw new Error(`capsule: ${name} is not a query/mutation/action/endpoint`);
597
+ if (name in functions) throw new Error(`capsule: duplicate handler name ${name}`);
598
+ functions[name] = def;
599
+ };
600
+ for (const key of Object.keys(config)) {
601
+ const value: any = (config as any)[key];
602
+ if (key === "schema" || value === undefined) continue;
603
+ if ((GROUP_KEYS as readonly string[]).includes(key)) {
604
+ for (const name of Object.keys(value)) add(name, value[name]);
605
+ } else {
606
+ add(key, value);
607
+ }
608
+ }
609
+ return { kind: "capsule", schema: (config.schema ?? {}) as S, functions: functions as unknown as HandlersOf<D> };
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------------------------------------------
613
+ // Type helpers over a capsule (used by eraspace/client)
614
+
615
+ export type SchemaOf<C> = C extends Capsule<infer S, any> ? S : never;
616
+ export type HandlersOfCapsule<C> = C extends Capsule<any, infer H> ? H : never;
617
+ export type ReadDatabaseOf<C> = DbContext<SchemaOf<C>>;
618
+ export type WriteDatabaseOf<C> = MutationDbContext<SchemaOf<C>>;
619
+
620
+ type NamesOfKind<C, K extends HandlerKind> = {
621
+ [N in keyof HandlersOfCapsule<C>]: HandlersOfCapsule<C>[N] extends { kind: K } ? N : never;
622
+ }[keyof HandlersOfCapsule<C>] &
623
+ string;
624
+ type ArgsOf<D> = D extends { __types?: { args: infer A } } ? (A extends any[] ? A : never) : never;
625
+ type ResultOf<D> = D extends { __types?: { result: infer R } } ? R : never;
626
+
627
+ export type QueryName<C> = NamesOfKind<C, "query">;
628
+ export type MutationName<C> = NamesOfKind<C, "mutation">;
629
+ export type ActionName<C> = NamesOfKind<C, "action">;
630
+ export type QueryArgs<C, N extends QueryName<C>> = ArgsOf<HandlersOfCapsule<C>[N]>;
631
+ export type QueryResult<C, N extends QueryName<C>> = ResultOf<HandlersOfCapsule<C>[N]>;
632
+ export type MutationArgs<C, N extends MutationName<C>> = ArgsOf<HandlersOfCapsule<C>[N]>;
633
+ export type MutationResult<C, N extends MutationName<C>> = ResultOf<HandlersOfCapsule<C>[N]>;
634
+ export type ActionArgs<C, N extends ActionName<C>> = ArgsOf<HandlersOfCapsule<C>[N]>;
635
+ export type ActionResult<C, N extends ActionName<C>> = ResultOf<HandlersOfCapsule<C>[N]>;
@@ -0,0 +1,41 @@
1
+ import { createClient, FilePreview, FileUpload, type FileRef } from "eraspace/client";
2
+ import { useState, useEffect } from "preact/hooks";
3
+ import type app from "../server/index";
4
+ const client = createClient<typeof app>({ reloadOnDeploy: true });
5
+ export default function App() {
6
+ const [room, setRoom] = useState("general");
7
+ const [author, setAuthor] = useState("Guest");
8
+ const [body, setBody] = useState("");
9
+ const [error, setError] = useState("");
10
+ const [sending, setSending] = useState(false);
11
+ const [typing, setTyping] = useState("");
12
+ const [file, setFile] = useState<FileRef | null>(null);
13
+ const messages = client.useQuery("messages", room);
14
+ const send = client.useMutation("send");
15
+ const channel = client.useRoom<{ name: string }> (room, { name: author });
16
+ useEffect(() => channel.on("typing", (payload: unknown) => {
17
+ if (typeof payload === "string") setTyping(payload);
18
+ }), [room]);
19
+ useEffect(() => { if (!typing) return; const timer = setTimeout(() => setTyping(""), 1500); return () => clearTimeout(timer); }, [typing]);
20
+ return <main>
21
+ <header><h1>chat / {room}</h1><small>{client.status.value}</small></header>
22
+ <form onSubmit={e => { e.preventDefault(); const data = new FormData(e.currentTarget); setRoom(String(data.get("room") || "general")); }}>
23
+ <input name="room" aria-label="Room" defaultValue={room} maxLength={40}/><button class="subtle">Join room</button>
24
+ </form>
25
+ <div class="presence">{channel.presence.length} online · {channel.presence.map(member => member.meta.name || "Guest").join(", ")}</div>
26
+ {messages === undefined ? <p>Loading messages…</p> : messages.length === 0 ? <p>Send the first message.</p> : <ul>{[...messages].reverse().map(message => <li key={message.id}><strong>{message.author}</strong><span>{message.body}{message.file && <FilePreview client={client} file={message.file} class="attachment"/>}</span><time>{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time></li>)}</ul>}
27
+ <form onSubmit={async e => {
28
+ e.preventDefault(); setSending(true); setError("");
29
+ try { await send(room, author, body, file); setBody(""); setFile(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
30
+ }}>
31
+ <input aria-label="Your name" value={author} maxLength={40} onInput={e => setAuthor(e.currentTarget.value)}/>
32
+ <input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => { setBody(e.currentTarget.value); channel.broadcast("typing", author); }}/>
33
+ <button disabled={sending || (!body.trim() && !file)}>Send</button>
34
+ {file
35
+ ? <div class="pending"><FilePreview client={client} file={file} caption={false} class="attachment"/><span>{file.name}</span><button type="button" class="subtle" disabled={sending} onClick={() => { void client.storage.delete(file.key).catch(() => {}); setFile(null); }}>Remove</button></div>
36
+ : <FileUpload client={client} class="attach" onUpload={setFile}>Attach a file (up to 5 MB)</FileUpload>}
37
+ </form>
38
+ {typing && <small>{typing} is typing</small>}
39
+ {error && <p role="alert">{error}</p>}
40
+ </main>;
41
+ }