eraspace 0.1.0 → 0.3.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 +29 -7
- package/bin.js +437 -104
- package/package.json +2 -4
- package/sdk/src/client/client.ts +30 -13
- package/sdk/src/client/config.ts +22 -4
- package/sdk/src/client/connection.ts +230 -47
- package/sdk/src/client/errors.ts +10 -1
- package/sdk/src/client/files.ts +389 -0
- package/sdk/src/client/index.ts +4 -3
- package/sdk/src/client/pagination.ts +6 -3
- package/sdk/src/client/request-key.ts +13 -0
- package/sdk/src/client/retry.ts +5 -0
- package/sdk/src/client/room.ts +70 -18
- package/sdk/src/client/router.ts +12 -3
- package/sdk/src/client/storage.ts +127 -10
- package/sdk/src/client/sub-key.ts +16 -0
- package/sdk/src/client/subscription.ts +16 -1
- package/sdk/src/client/transport.ts +423 -0
- package/sdk/src/client/types.ts +7 -0
- package/sdk/src/server/index.ts +44 -6
- package/templates/chat/client/index.tsx +10 -6
- package/templates/chat/client/styles.css +15 -0
- package/templates/chat/server/index.ts +16 -6
- package/templates/poll/client/index.tsx +1 -1
- package/templates/todo/client/index.tsx +1 -1
package/sdk/src/client/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReadonlySignal } from "@preact/signals";
|
|
2
2
|
import type { EraspaceError } from "./errors.ts";
|
|
3
3
|
import type { StorageClient } from "./storage.ts";
|
|
4
|
+
import type { Upload, UseUploadOptions } from "./files.ts";
|
|
4
5
|
|
|
5
6
|
import type {
|
|
6
7
|
ActionArgs,
|
|
@@ -72,11 +73,15 @@ export interface Room<M = Record<string, unknown>, S extends Record<string, unkn
|
|
|
72
73
|
on(event: string, cb: (payload: any, from: string | undefined) => void): () => void;
|
|
73
74
|
/** Ephemeral shared state (kept by the room process, never stored). */
|
|
74
75
|
state: Partial<S>;
|
|
76
|
+
/** Last room join or write error. Cleared by a successful join or state update. */
|
|
77
|
+
error: EraspaceError | undefined;
|
|
75
78
|
setState<K extends keyof S & string>(key: K, value: S[K]): void;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
export interface EraspaceClient<C> {
|
|
79
82
|
readonly storage: StorageClient;
|
|
83
|
+
/** Upload files to app storage with progress and errors as component state. `<FileUpload>` wraps this. */
|
|
84
|
+
useUpload(options?: UseUploadOptions): Upload;
|
|
80
85
|
/** Subscribe to a query. Returns `undefined` until the first result arrives, then live values. */
|
|
81
86
|
useQuery<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): QueryResult<C, N> | undefined;
|
|
82
87
|
/** Pass computed signals directly to JSX text nodes for updates without a component render. */
|
|
@@ -102,6 +107,8 @@ export interface EraspaceClient<C> {
|
|
|
102
107
|
|
|
103
108
|
query<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): Promise<QueryResult<C, N>>;
|
|
104
109
|
mutation<N extends MutationName<C>>(name: N, ...args: MutationArgs<C, N>): Promise<MutationResult<C, N>>;
|
|
110
|
+
/** Recover an unknown mutation outcome with its original name, arguments and request key, within 24 hours. */
|
|
111
|
+
retryMutation<N extends MutationName<C>>(name: N, args: MutationArgs<C, N>, requestKey: string): Promise<MutationResult<C, N>>;
|
|
105
112
|
action<N extends ActionName<C>>(name: N, ...args: ActionArgs<C, N>): Promise<ActionResult<C, N>>;
|
|
106
113
|
/** Imperative live subscription. Returns an unsubscribe function. */
|
|
107
114
|
subscribe<N extends QueryName<C>>(
|
package/sdk/src/server/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/generate-server-api.ts. Edit api/server-api.ts or api/server.ts.in.
|
|
2
2
|
// eraspace/server: capsule definition API.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// module also works in Bun (tests, tooling) and, mainly, so the types drive inference.
|
|
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.
|
|
7
6
|
//
|
|
8
7
|
// Type design. `ctx.db` must be typed from `schema` even though both live in the same object literal:
|
|
9
8
|
//
|
|
@@ -152,8 +151,24 @@ export interface TableDefinition<F extends FieldMap = FieldMap, I extends IndexM
|
|
|
152
151
|
name: N,
|
|
153
152
|
fields: Fs,
|
|
154
153
|
): TableDefinition<F, Simplify<I & { [P in N]: Fs }>>;
|
|
154
|
+
/**
|
|
155
|
+
* Upgrade rows written before this migration to the table's current fields. Versions start at any positive
|
|
156
|
+
* number and each call takes the next one; rows are written at the last version. A deploy then accepts rows of
|
|
157
|
+
* an older shape: they are upgraded when read and rewritten in the background. `upgrade` receives the stored
|
|
158
|
+
* row with `id` and `createdAt` and must return the new row synchronously, depending only on the row.
|
|
159
|
+
* Undeclared fields of the result are dropped. Keep a migration until every row has passed it.
|
|
160
|
+
*/
|
|
161
|
+
migrate(version: number, upgrade: (row: StoredRow<F>) => MigratedRow<F>): TableDefinition<F, I>;
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A row as an earlier deployment stored it: `id`, `createdAt` and whatever fields it had then. Their types are
|
|
166
|
+
* unknown, and fields of the current schema may be absent, so they read as `any`.
|
|
167
|
+
*/
|
|
168
|
+
export type StoredRow<F extends FieldMap = FieldMap> = { id: string; createdAt: number } & { [K in keyof F]: any } & { [field: string]: any };
|
|
169
|
+
/** What a migration returns: the row with the table's current fields. `id` and `createdAt` keep their values. */
|
|
170
|
+
export type MigratedRow<F extends FieldMap> = InferInsert<F> & { id?: string; createdAt?: number };
|
|
171
|
+
|
|
157
172
|
export type AnyTable = TableDefinition<any, any>;
|
|
158
173
|
export type Schema = Record<string, AnyTable>;
|
|
159
174
|
/** The schema a capsule gets when it declares none. Tables and fields are untyped. */
|
|
@@ -161,6 +176,7 @@ export type DefaultSchema = Record<string, TableDefinition<Record<string, Field<
|
|
|
161
176
|
|
|
162
177
|
export function table<const F extends FieldMap>(fields: F): TableDefinition<F, {}> {
|
|
163
178
|
const indexes: Record<string, readonly string[]> = {};
|
|
179
|
+
const migrations: number[] = [];
|
|
164
180
|
const def: any = {
|
|
165
181
|
kind: "table",
|
|
166
182
|
fields,
|
|
@@ -170,10 +186,20 @@ export function table<const F extends FieldMap>(fields: F): TableDefinition<F, {
|
|
|
170
186
|
indexes[name] = idx;
|
|
171
187
|
return def;
|
|
172
188
|
},
|
|
189
|
+
migrate(version: number, upgrade: unknown) {
|
|
190
|
+
if (!Number.isInteger(version) || version < 1) throw new TypeError("migrate() expects a positive integer version");
|
|
191
|
+
if (typeof upgrade !== "function") throw new TypeError("migrate() expects a function from a stored row to the upgraded row");
|
|
192
|
+
const last = migrations.length ? migrations[migrations.length - 1]! : version - 1;
|
|
193
|
+
if (version !== last + 1) throw new Error(`migrate(${version}) must follow migrate(${last}) with the next version, ${last + 1}`);
|
|
194
|
+
migrations.push(version);
|
|
195
|
+
return def;
|
|
196
|
+
},
|
|
173
197
|
toJSON() {
|
|
198
|
+
const versions = migrations.length ? { version: migrations[migrations.length - 1], migratesFrom: migrations[0]! - 1 } : {};
|
|
174
199
|
return {
|
|
175
200
|
fields: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, describeField(v)])),
|
|
176
201
|
indexes,
|
|
202
|
+
...versions,
|
|
177
203
|
};
|
|
178
204
|
},
|
|
179
205
|
};
|
|
@@ -350,6 +376,16 @@ interface BaseContext {
|
|
|
350
376
|
interface DbHolder<Db> extends BaseContext {
|
|
351
377
|
db: Db;
|
|
352
378
|
}
|
|
379
|
+
export interface Scheduler {
|
|
380
|
+
/**
|
|
381
|
+
* Runs mutation `name` with `args` once, `delayMs` milliseconds after this mutation commits. The job commits with
|
|
382
|
+
* this mutation, so it exists only if this mutation does, and its run commits at most once. Returns the job's id.
|
|
383
|
+
*/
|
|
384
|
+
runAfter(delayMs: number, name: string, ...args: any[]): string;
|
|
385
|
+
}
|
|
386
|
+
interface MutationHolder<Db> extends DbHolder<Db> {
|
|
387
|
+
scheduler: Scheduler;
|
|
388
|
+
}
|
|
353
389
|
interface ActionHolder<Db> extends DbHolder<Db> {
|
|
354
390
|
runQuery(name: string, ...args: any[]): Promise<any>;
|
|
355
391
|
runMutation(name: string, ...args: any[]): Promise<any>;
|
|
@@ -358,8 +394,8 @@ interface ActionHolder<Db> extends DbHolder<Db> {
|
|
|
358
394
|
|
|
359
395
|
/** The `ctx` a `query()` (or a `readOnly` endpoint) receives. */
|
|
360
396
|
export type QueryServerContext<S extends Schema = DefaultSchema> = DbHolder<DbContext<S>>;
|
|
361
|
-
/** The `ctx` a `mutation()` or writable `endpoint()` receives. Its `db` can write. */
|
|
362
|
-
export type ServerContext<S extends Schema = DefaultSchema> =
|
|
397
|
+
/** The `ctx` a `mutation()` or writable `endpoint()` receives. Its `db` can write and its `scheduler` schedules mutations. */
|
|
398
|
+
export type ServerContext<S extends Schema = DefaultSchema> = MutationHolder<MutationDbContext<S>>;
|
|
363
399
|
/** The `ctx` an `action()` receives: read-only db plus `runQuery`/`runMutation`. Use global `fetch` for HTTP. */
|
|
364
400
|
export type ActionServerContext<S extends Schema = DefaultSchema> = ActionHolder<DbContext<S>>;
|
|
365
401
|
|
|
@@ -476,8 +512,10 @@ export interface EndpointRequest {
|
|
|
476
512
|
query: EndpointQuery;
|
|
477
513
|
text(): Promise<string>;
|
|
478
514
|
json<T = unknown>(): Promise<T>;
|
|
479
|
-
/** Request body bytes
|
|
515
|
+
/** Request body bytes as a JSON-compatible number array. */
|
|
480
516
|
bytes(): Promise<number[]>;
|
|
517
|
+
/** A multipart/form-data or application/x-www-form-urlencoded body. Other content types reject with a TypeError. */
|
|
518
|
+
formData(): Promise<FormData>;
|
|
481
519
|
}
|
|
482
520
|
|
|
483
521
|
export interface EndpointResponseOptions {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createClient } from "eraspace/client";
|
|
1
|
+
import { createClient, FilePreview, FileUpload, type FileRef } from "eraspace/client";
|
|
2
2
|
import { useState, useEffect } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
|
-
const client = createClient<typeof app>(
|
|
4
|
+
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
6
6
|
const [room, setRoom] = useState("general");
|
|
7
7
|
const [author, setAuthor] = useState("Guest");
|
|
@@ -9,6 +9,7 @@ export default function App() {
|
|
|
9
9
|
const [error, setError] = useState("");
|
|
10
10
|
const [sending, setSending] = useState(false);
|
|
11
11
|
const [typing, setTyping] = useState("");
|
|
12
|
+
const [file, setFile] = useState<FileRef | null>(null);
|
|
12
13
|
const messages = client.useQuery("messages", room);
|
|
13
14
|
const send = client.useMutation("send");
|
|
14
15
|
const channel = client.useRoom<{ name: string }> (room, { name: author });
|
|
@@ -22,14 +23,17 @@ export default function App() {
|
|
|
22
23
|
<input name="room" aria-label="Room" defaultValue={room} maxLength={40}/><button class="subtle">Join room</button>
|
|
23
24
|
</form>
|
|
24
25
|
<div class="presence">{channel.presence.length} online · {channel.presence.map(member => member.meta.name || "Guest").join(", ")}</div>
|
|
25
|
-
{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}</span><time>{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time></li>)}</ul>}
|
|
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>}
|
|
26
27
|
<form onSubmit={async e => {
|
|
27
28
|
e.preventDefault(); setSending(true); setError("");
|
|
28
|
-
try { await send(room, author, body); setBody(""); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
|
|
29
|
+
try { await send(room, author, body, file); setBody(""); setFile(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
|
|
29
30
|
}}>
|
|
30
31
|
<input aria-label="Your name" value={author} maxLength={40} onInput={e => setAuthor(e.currentTarget.value)}/>
|
|
31
|
-
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required onInput={e => { setBody(e.currentTarget.value); channel.broadcast("typing", author); }}/>
|
|
32
|
-
<button disabled={sending || !body.trim()}>Send</button>
|
|
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>}
|
|
33
37
|
</form>
|
|
34
38
|
{typing && <small>{typing} is typing</small>}
|
|
35
39
|
{error && <p role="alert">{error}</p>}
|
|
@@ -24,5 +24,20 @@
|
|
|
24
24
|
meter::-moz-meter-bar { background: #fff; }
|
|
25
25
|
.done { text-decoration: line-through; }
|
|
26
26
|
.presence { padding: 12px 0; font-size: 13px; }
|
|
27
|
+
.attach { flex-basis: 100%; padding: 10px; border: 1px dashed #666; color: #aaa; font-size: 13px; text-align: center; cursor: pointer; position: relative; }
|
|
28
|
+
.attach[data-dragging], .attach:hover, .attach:focus-within { border-color: #fff; color: #fff; }
|
|
29
|
+
.attach progress { display: block; width: 100%; margin-top: 6px; accent-color: #fff; }
|
|
30
|
+
.pending { flex-basis: 100%; display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
|
31
|
+
.pending > span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
32
|
+
li:has(.attachment) { align-items: flex-start; }
|
|
33
|
+
.attachment { margin: 8px 0 0; }
|
|
34
|
+
li > span > .attachment:first-child { margin-top: 0; }
|
|
35
|
+
.attachment img, .attachment video { display: block; max-width: 100%; max-height: 280px; border: 1px solid #333; }
|
|
36
|
+
.pending .attachment { margin: 0; }
|
|
37
|
+
.pending .attachment img { max-height: 48px; }
|
|
38
|
+
.attachment audio { display: block; width: 100%; }
|
|
39
|
+
.attachment iframe { display: block; width: 100%; height: 160px; border: 1px solid #333; background: #fff; }
|
|
40
|
+
.attachment a { color: #ccc; display: inline-flex; gap: 8px; }
|
|
41
|
+
.attachment figcaption { display: flex; gap: 8px; margin-top: 4px; font-size: 12px; color: #888; }
|
|
27
42
|
@media (max-width: 480px) { main { padding: 20px 12px; } li { flex-wrap: wrap; } }
|
|
28
43
|
}
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import { capsule, table, query, mutation, endpoint, string, text } from "eraspace/server";
|
|
1
|
+
import { capsule, table, query, mutation, endpoint, string, number, object, text } from "eraspace/server";
|
|
2
|
+
type Attachment = { key: string; name: string; contentType: string; size: number };
|
|
3
|
+
const KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2
4
|
export default capsule({
|
|
3
|
-
schema: {
|
|
5
|
+
schema: {
|
|
6
|
+
messages: table({
|
|
7
|
+
room: string(), author: string(), body: string(),
|
|
8
|
+
// A file in app storage, saved from the client's FileUpload.
|
|
9
|
+
file: object({ key: string(), name: string(), contentType: string(), size: number() }).optional(),
|
|
10
|
+
}).index("by_room", ["room"]),
|
|
11
|
+
},
|
|
4
12
|
queries: {
|
|
5
13
|
messages: query(async (ctx, room: string) => ctx.db.messages.withIndex("by_room", q => q.eq("room", room)).order("desc").take(100)),
|
|
6
14
|
},
|
|
7
15
|
mutations: {
|
|
8
|
-
send: mutation(async (ctx, room: string, author: string, body: string) => {
|
|
16
|
+
send: mutation(async (ctx, room: string, author: string, body: string, file?: Attachment | null) => {
|
|
9
17
|
const trimmed = body.trim();
|
|
10
|
-
if (
|
|
18
|
+
if (trimmed.length > 2000 || (!trimmed && !file)) throw new Error("Write a message of 1 to 2000 characters.");
|
|
19
|
+
if (file && (!KEY.test(String(file.key)) || typeof file.name !== "string" || typeof file.contentType !== "string" || !Number.isInteger(file.size) || file.size < 0)) throw new Error("That attachment is not a stored file.");
|
|
11
20
|
const name = author.trim().slice(0, 40) || "Guest";
|
|
12
21
|
ctx.log.info("message sent", { room });
|
|
13
|
-
|
|
22
|
+
const attachment = file ? { key: file.key, name: file.name.slice(0, 255) || "file", contentType: file.contentType.slice(0, 256), size: file.size } : undefined;
|
|
23
|
+
return ctx.db.messages.insert({ room, author: name, body: trimmed, file: attachment });
|
|
14
24
|
}),
|
|
15
25
|
},
|
|
16
|
-
endpoints: { hi: endpoint({ method: "GET", path: "/hi", readOnly: true }, () => text("hello from
|
|
26
|
+
endpoints: { hi: endpoint({ method: "GET", path: "/hi", readOnly: true }, () => text("hello from eraspace")) },
|
|
17
27
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient } from "eraspace/client";
|
|
2
2
|
import { useState } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
|
-
const client = createClient<typeof app>(
|
|
4
|
+
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
6
6
|
const options = client.useQuery("results");
|
|
7
7
|
const add = client.useMutation("addOption");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient } from "eraspace/client";
|
|
2
2
|
import { useState } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
|
-
const client = createClient<typeof app>(
|
|
4
|
+
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
6
6
|
const todos = client.useQuery("list");
|
|
7
7
|
const [title, setTitle] = useState("");
|