eraspace 0.3.0 → 0.4.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
@@ -25,6 +25,8 @@ sign in and claim it (`eraspace auth login`, then `eraspace claim`).
25
25
  | `typecheck` | Typecheck `server/`, `client/` and `shared/` |
26
26
  | `deploy` | Build the client, compile the server and deploy (`--delete-table <table>=<rows>` confirms deleting a removed table; `--shadow` first reruns the app's live queries on the new code and reports which results change or start throwing, and `--fail-on-query-errors` refuses the deploy if any would throw) |
27
27
  | `dev` | Deploy, then redeploy whenever `server/`, `client/`, `shared/` or `.env.eraspace.server` change |
28
+ | `deploys [app]` | List the latest 50 kept versions, newest first, marking the one serving; older kept versions still roll back by number |
29
+ | `rollback [version]` | Deploy an earlier version's server code, client files and server env again as a new version; without a version, the one before the serving version. Takes `--delete-table`, `--shadow` and `--fail-on-query-errors` like `deploy` |
28
30
  | `call <fn> '<json>'...` | Call a query, mutation or action, each argument a JSON value |
29
31
  | `profile [app] --seconds <n>` | Sample the app's server CPU time and write a `.cpuprofile` (TypeScript positions) for Chrome DevTools, printing the top functions by self time (`--out <file>`) |
30
32
  | `explain <fn> '<json>'...` | Run a query once and print the rows it scanned and kept, with an index for each scan it filtered in JavaScript (`--json` for the full report) |
package/bin.js CHANGED
@@ -32879,15 +32879,15 @@ var TomlParser = class {
32879
32879
  }
32880
32880
  parseDate(token) {
32881
32881
  const date = "\\d{4}-\\d{2}-\\d{2}";
32882
- const time3 = "\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?";
32883
- if (new RegExp(`^${date}[Tt]${time3}(?:[Zz]|[+-]\\d{2}:\\d{2})$`).test(token)) {
32882
+ const time4 = "\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?";
32883
+ if (new RegExp(`^${date}[Tt]${time4}(?:[Zz]|[+-]\\d{2}:\\d{2})$`).test(token)) {
32884
32884
  const value4 = new Date(token.replace("t", "T").replace("z", "Z"));
32885
32885
  if (Number.isNaN(value4.getTime())) {
32886
32886
  this.fail(`Invalid date-time '${token}'`);
32887
32887
  }
32888
32888
  return value4;
32889
32889
  }
32890
- if (new RegExp(`^${date}[Tt]${time3}$`).test(token) || new RegExp(`^${date}$`).test(token) || new RegExp(`^${time3}$`).test(token)) {
32890
+ if (new RegExp(`^${date}[Tt]${time4}$`).test(token) || new RegExp(`^${date}$`).test(token) || new RegExp(`^${time4}$`).test(token)) {
32891
32891
  return token.replace("t", "T");
32892
32892
  }
32893
32893
  return void 0;
@@ -38994,7 +38994,7 @@ var runWith2 = (command2, config2) => {
38994
38994
  // cli/package.json
38995
38995
  var package_default = {
38996
38996
  name: "eraspace-cli",
38997
- version: "0.3.0",
38997
+ version: "0.4.0",
38998
38998
  private: true,
38999
38999
  license: "MIT",
39000
39000
  type: "module",
@@ -39314,12 +39314,12 @@ function findSpecifier(source, spec) {
39314
39314
  }
39315
39315
  function formatLog(l) {
39316
39316
  const d = l.ts === void 0 ? /* @__PURE__ */ new Date() : new Date(typeof l.ts === "number" ? l.ts : Date.parse(l.ts));
39317
- const time3 = isNaN(d.getTime()) ? String(l.ts) : d.toISOString().slice(11, 23);
39317
+ const time4 = isNaN(d.getTime()) ? String(l.ts) : d.toISOString().slice(11, 23);
39318
39318
  const level = (l.level ?? "info").toLowerCase();
39319
39319
  const color = level === "error" ? c.red : level === "warn" ? c.yellow : level === "debug" ? c.gray : c.blue;
39320
39320
  const fn3 = l.fn ? c.magenta(l.fn) + " " : "";
39321
39321
  const data = l.data === void 0 || l.data === null ? "" : " " + c.gray(JSON.stringify(l.data));
39322
- return `${c.gray(time3)} ${color(level.padEnd(5))} ${fn3}${l.msg ?? ""}${data}`;
39322
+ return `${c.gray(time4)} ${color(level.padEnd(5))} ${fn3}${l.msg ?? ""}${data}`;
39323
39323
  }
39324
39324
  var SKIPPED = {
39325
39325
  fetch: "uses fetch",
@@ -39404,7 +39404,13 @@ var Deployed = Schema_exports.Struct({
39404
39404
  ...App.fields,
39405
39405
  version: Schema_exports.optional(Schema_exports.Number),
39406
39406
  claimToken: Schema_exports.optional(Schema_exports.String),
39407
- shadow: Schema_exports.optional(ShadowReport)
39407
+ shadow: Schema_exports.optional(ShadowReport),
39408
+ /** For a rollback: the kept version whose files were deployed again. */
39409
+ rolledBackTo: Schema_exports.optional(Schema_exports.Number)
39410
+ });
39411
+ var DeployHistory = Schema_exports.Struct({
39412
+ version: Schema_exports.optional(Schema_exports.Number),
39413
+ deploys: Schema_exports.optional(Schema_exports.Array(Schema_exports.Struct({ version: Schema_exports.Number, createdAt: Schema_exports.optional(Schema_exports.Number) })))
39408
39414
  });
39409
39415
  var AppList = Schema_exports.Struct({ apps: Schema_exports.Array(App) });
39410
39416
  var StateChange = Schema_exports.Struct({ state: Schema_exports.String });
@@ -39421,7 +39427,7 @@ var Token2 = Schema_exports.Struct({
39421
39427
  lastUsedAt: Schema_exports.optional(Timestamp)
39422
39428
  });
39423
39429
  var TokenList = Schema_exports.Struct({ tokens: Schema_exports.Array(Token2) });
39424
- var CreatedToken = Schema_exports.Struct({ token: Schema_exports.String });
39430
+ var CreatedToken = Schema_exports.Struct({ token: Schema_exports.String, id: Schema_exports.String });
39425
39431
  var DeviceStart = Schema_exports.Struct({
39426
39432
  deviceCode: Schema_exports.String,
39427
39433
  userCode: Schema_exports.String,
@@ -39961,6 +39967,11 @@ var createApp = Effect_exports.fnUntraced(function* (config2, payload, extra, op
39961
39967
  return { result: result4, name: naming.name };
39962
39968
  });
39963
39969
  var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
39970
+ const t = yield* target(config2);
39971
+ if (t.ref && opts.name !== void 0)
39972
+ return yield* fail12(
39973
+ t.claim ? `--name only names a new app, and this checkout already deploys to ${t.ref}. Name it when you claim it: eraspace claim --name ${opts.name}` : `--name only names a new app, and this checkout already deploys to ${t.ref}. Add the name with eraspace names add ${opts.name}`
39974
+ );
39964
39975
  const build3 = yield* buildCapsule(config2.root);
39965
39976
  if (!build3.ok || !build3.payload)
39966
39977
  return yield* fail12(yield* formatErrors(build3.errors, config2.root));
@@ -39972,7 +39983,6 @@ var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
39972
39983
  ...opts.failOnQueryErrors ? { failOnQueryErrors: true } : {}
39973
39984
  };
39974
39985
  const signedIn = (yield* bearerFor(config2.origin)) !== void 0;
39975
- const t = yield* target(config2);
39976
39986
  let result4;
39977
39987
  if (t.ref) {
39978
39988
  const payload = t.claim ? yield* withoutEnv(build3.payload) : build3.payload;
@@ -40035,6 +40045,40 @@ var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
40035
40045
  }
40036
40046
  return result4;
40037
40047
  });
40048
+ var time2 = (ms) => ms === void 0 ? "" : new Date(ms).toISOString();
40049
+ var deploys = Effect_exports.fnUntraced(function* (config2, explicit) {
40050
+ const t = yield* requireTarget(config2, explicit);
40051
+ const history = yield* ok(DeployHistory)(config2, "GET", `/_api/apps/${enc(t.ref)}`, { claim: t.claim });
40052
+ const kept = history.deploys ?? [];
40053
+ if (!kept.length) return yield* Console_exports.log(`${t.ref} has no deploys.`);
40054
+ for (const d of kept) {
40055
+ const serving = d.version === history.version ? ` ${c.green("serving")}` : "";
40056
+ yield* Console_exports.log(`v${d.version} ${c.dim(time2(d.createdAt))}${serving}`);
40057
+ }
40058
+ if (kept.length >= 50) yield* Console_exports.log(c.dim(`Showing the latest ${kept.length}; any older kept versions still roll back by number: eraspace rollback <version>`));
40059
+ });
40060
+ var rollback = Effect_exports.fnUntraced(function* (config2, version, opts = {}) {
40061
+ const t = yield* requireTarget(config2);
40062
+ const deleteTables = yield* confirmedDeletes(opts.deleteTables);
40063
+ const body = {
40064
+ ...version === void 0 ? {} : { version },
40065
+ ...Object.keys(deleteTables).length ? { deleteTables } : {},
40066
+ ...opts.shadow ? { shadow: true } : {},
40067
+ ...opts.failOnQueryErrors ? { failOnQueryErrors: true } : {}
40068
+ };
40069
+ const path = `/_api/apps/${enc(t.ref)}/rollback`;
40070
+ const response = yield* api(config2, "POST", path, { body, claim: t.claim, timeout: DEPLOY_TIMEOUT });
40071
+ if (response.status >= 400 || response.json.ok === false) {
40072
+ const refused = Schema_exports.decodeUnknownOption(ShadowReport)(response.json.shadow);
40073
+ if (Option_exports.isSome(refused)) yield* Console_exports.log(formatShadow(refused.value));
40074
+ return yield* Effect_exports.fail(yield* failure(response, config2.root));
40075
+ }
40076
+ const result4 = yield* decode5(Deployed, response.json, `POST ${path}`);
40077
+ if (result4.shadow) yield* Console_exports.log(formatShadow(result4.shadow));
40078
+ const label = result4.names?.[0] ?? result4.id;
40079
+ yield* Console_exports.log(`${label} v${String(result4.version)}: rolled back to v${String(result4.rolledBackTo)}: ${result4.url ?? config2.url}`);
40080
+ return result4;
40081
+ });
40038
40082
  var claimRequest = (config2, path, body, claim2) => api(config2, "POST", path, { body, claim: claim2 });
40039
40083
  var claim = Effect_exports.fnUntraced(function* (config2, name) {
40040
40084
  const local = yield* localDeploy(config2.root, config2.origin);
@@ -40082,10 +40126,14 @@ var IGNORED = /(^|[/\\])(node_modules|\.git|\.eraspace)([/\\]|$)/;
40082
40126
  var isWatched = (file2) => !IGNORED.test(file2) && WATCHED.test(file2);
40083
40127
  var dev = Effect_exports.fnUntraced(function* (config2, opts) {
40084
40128
  const fs = yield* FileSystem_exports.FileSystem;
40129
+ let current = opts;
40085
40130
  const redeploy = Effect_exports.suspend(
40086
40131
  () => loadConfig({ dir: config2.root, app: config2.app, url: config2.url })
40087
40132
  ).pipe(
40088
- Effect_exports.flatMap((current) => deploy(current, opts)),
40133
+ Effect_exports.flatMap((loaded) => deploy(loaded, current)),
40134
+ Effect_exports.tap(() => Effect_exports.sync(() => {
40135
+ current = { ...current, name: void 0 };
40136
+ })),
40089
40137
  Effect_exports.catch((error2) => Console_exports.error(messageOf(error2)))
40090
40138
  );
40091
40139
  yield* redeploy;
@@ -40193,7 +40241,7 @@ var logs = Effect_exports.fnUntraced(function* (config2, explicit, follow, limit
40193
40241
  var okApp = ok(App);
40194
40242
  var okJson = ok(JsonObject2);
40195
40243
  var okState = ok(StateChange);
40196
- var time2 = (value4) => {
40244
+ var time3 = (value4) => {
40197
40245
  if (value4 === null || value4 === void 0) return void 0;
40198
40246
  const date = new Date(typeof value4 === "number" ? value4 : Date.parse(value4));
40199
40247
  return Number.isNaN(date.getTime()) ? String(value4) : date.toISOString();
@@ -40300,7 +40348,7 @@ var listTokens = Effect_exports.fnUntraced(function* (config2) {
40300
40348
  if (!tokens.length) yield* Console_exports.log("No tokens.");
40301
40349
  for (const t of tokens) {
40302
40350
  const scope3 = t.scope === "personal" ? "personal (all your apps)" : `${t.app ?? "?"} (${t.access ?? "?"})`;
40303
- const used = time2(t.lastUsedAt);
40351
+ const used = time3(t.lastUsedAt);
40304
40352
  yield* Console_exports.log(`${t.id} ${t.name} ${scope3} ${c.dim(used ? `last used ${used}` : "never used")}`);
40305
40353
  }
40306
40354
  });
@@ -40311,9 +40359,10 @@ var revokeToken = Effect_exports.fnUntraced(function* (config2, id2) {
40311
40359
  var createToken = Effect_exports.fnUntraced(function* (config2, name, opts) {
40312
40360
  if (opts.personal && opts.read) return yield* fail12("--read only applies to app tokens; personal tokens have full access to your apps.");
40313
40361
  const spec = opts.personal ? { name, personal: true } : { name, app: (yield* requireTarget(config2)).ref, access: opts.read ? "read" : "deploy" };
40314
- const { token } = yield* ok(CreatedToken)(config2, "POST", "/_api/tokens", { body: spec });
40362
+ const { token, id: id2 } = yield* ok(CreatedToken)(config2, "POST", "/_api/tokens", { body: spec });
40315
40363
  yield* Console_exports.log(token);
40316
40364
  yield* Console_exports.error(c.dim(`Shown once. Use it as ERASPACE_TOKEN with ERASPACE_TOKEN_API=${config2.origin}.`));
40365
+ yield* Console_exports.error(c.dim(`Token id ${id2}; revoke it with eraspace token revoke ${id2}.`));
40317
40366
  });
40318
40367
  function parseCallArgs(inputs) {
40319
40368
  const args2 = [];
@@ -40350,138 +40399,74 @@ import { basename as basename2, join as join7, relative as relative3, resolve as
40350
40399
  var TEMPLATES = ["chat", "todo", "poll"];
40351
40400
  var agents = (cli) => `# eraspace capsule: agent instructions
40352
40401
 
40353
- This directory is one eraspace app ("capsule"): a TypeScript server running in V8, a Preact client, a
40354
- Postgres-backed database with live queries, and hosting, all through the \`eraspace\` CLI. Treat this directory as the
40355
- whole app. Build the requested app by editing the starter files, deploy it, verify it, and report the URL.
40356
-
40357
- ## Commands
40358
-
40359
- Run these from this directory. There is nothing to install: the CLI bundles the SDK, Preact and TypeScript.
40360
-
40361
- - \`${cli} typecheck\`: tsc over \`server/\`, \`client/\` and \`shared/\`. Deploy does not typecheck.
40362
- - \`${cli} deploy\`: build the client, compile the server and print the app URL.
40363
- - \`${cli} call <fn> '<json arg>'...\`: call a query, mutation or action, e.g. \`call add '"milk"'\`.
40364
- - \`${cli} logs\`: handler logs, \`ctx.log\` and \`console.log\` output.
40365
- - \`${cli} inspect\`: deployed functions, schema and metadata.
40366
- - \`${cli} dev\`: redeploy on every save (long-running; keep it open).
40367
-
40368
- The platform URL comes from \`ERASPACE_URL\`, then \`eraspace.json\`, then \`${DEFAULT_URL}\`.
40402
+ This directory is an eraspace app ("capsule"): a TypeScript server in V8 and a Preact client over a database with
40403
+ live queries, hosted and driven by \`${cli}\`, which supplies TypeScript, Preact and \`eraspace/*\`, so there is
40404
+ nothing to install.
40369
40405
 
40370
40406
  ## Workflow
40371
40407
 
40372
- 1. Read \`server/index.ts\` and \`client/index.tsx\`, then replace them with the requested app.
40373
- 2. Run \`${cli} typecheck\` and fix every error.
40374
- 3. Run \`${cli} deploy\`. Fix any compile errors it prints (they include file:line:column).
40375
- 4. Exercise every query and mutation with \`${cli} call ...\`, including one invalid input. Check
40376
- \`${cli} logs\` for errors.
40377
- 5. Report the URL printed by deploy. Open it in two tabs to see live updates.
40378
-
40379
- ## Files
40380
-
40381
- - \`server/index.ts\` default-exports \`capsule({...})\` from \`eraspace/server\`. Other server files go in \`server/\`.
40382
- - \`client/index.tsx\` default-exports \`function App()\`. The CLI mounts it. Other client files go in \`client/\`.
40383
- - \`shared/\` holds pure TypeScript used by both sides (no DOM, no env, no eraspace runtime imports).
40384
- - \`client/styles.css\` plus Tailwind utility classes in JSX. Write complete class names.
40385
- - \`.env.eraspace.server\` holds server secrets, read as \`ctx.env.KEY\`. Never import it from client code.
40386
- - There is no package.json, tsconfig.json or node_modules, and none is needed. Do not add them.
40387
-
40388
- ## Server API (\`eraspace/server\`)
40389
-
40390
- \`\`\`ts
40391
- import { capsule, table, query, mutation, action, endpoint, string, number, boolean, id, array, object, any, json, text } from "eraspace/server";
40392
-
40393
- export default capsule({
40394
- schema: {
40395
- items: table({ title: string(), done: boolean().default(false), listId: id("lists"), tags: array(string()).optional() })
40396
- .index("by_list", ["listId"]),
40397
- lists: table({ name: string() }),
40398
- },
40399
- queries: {
40400
- items: query(async (ctx, listId: string) =>
40401
- ctx.db.items.withIndex("by_list", (q) => q.eq("listId", listId)).order("desc").take(100)),
40402
- },
40403
- mutations: {
40404
- add: mutation(async (ctx, listId: string, title: string) => {
40405
- const clean = title.trim();
40406
- if (!clean) throw new Error("Title is required."); // the message reaches the client
40407
- return ctx.db.items.insert({ listId, title: clean });
40408
- }),
40409
- },
40410
- actions: {
40411
- summary: action(async (ctx) => {
40412
- const rows = await ctx.runQuery("items", "some-list");
40413
- return rows.length;
40414
- }),
40415
- },
40416
- endpoints: {
40417
- health: endpoint({ method: "GET", path: "/health", readOnly: true }, async (ctx, req) => json({ ok: true })),
40418
- },
40419
- });
40420
- \`\`\`
40421
-
40422
- - Field validators: \`string() number() boolean() id("table") userId() any() array(v) object({...})\`, each with
40423
- \`.optional()\` or \`.default(value)\`. Rows always have \`id\` (string) and \`createdAt\` (epoch ms).
40424
- - \`ctx.db.<table>\`: \`insert(doc)\` returns the id, \`get(id)\` returns the row or null, \`update(id, partial)\`
40425
- (\`null\` removes a field), \`replace(id, doc)\`, \`delete(id)\`. Reads chain \`withIndex(name, q => q.eq(f, v).gt(...))\`,
40426
- \`order("asc" | "desc")\`, then \`collect()\`, \`take(n)\`, \`first()\`, \`unique()\`, \`count()\` or
40427
- \`paginate({ cursor, numItems })\`. Every table has an implicit \`by_creation\` index. Await every db call.
40428
- - Queries and actions have a read-only db. Write in mutations or writable endpoints. Actions can call
40429
- \`ctx.runQuery\`, \`ctx.runMutation\` and global \`fetch\`.
40430
- - Mutations schedule later work with \`ctx.scheduler.runAfter(ms, "mutationName", ...args)\`, which returns a job id.
40431
- The job commits with the mutation and its run commits once; only mutations can be scheduled.
40432
- - Handler arguments are positional and not validated. Check them in the handler.
40433
- - \`ctx.auth.userId\` is a per-connection guest id that changes on reload. It is not a login: never use it for
40434
- ownership or access control. Every app is public.
40435
- - \`ctx.log.info(msg, data?)\` (also debug/warn/error) writes to \`eraspace logs\`.
40436
- - Endpoints receive \`req\` with \`method, path, url, headers.get(), query.get(), json(), text()\` and return
40437
- \`json(value)\`, \`text(string)\`, \`empty()\`, \`redirect(url)\` or a plain value (sent as JSON).
40438
- - Removing a populated table needs its row count confirmed: \`${cli} deploy --delete-table <table>=<rows>\` (the
40439
- refused deploy prints the exact flag). Deletion runs in background batches.
40440
- - Changing a field's type or making it required fails the deploy while stored rows do not match. Declare a
40441
- migration instead: \`table({ ... }).migrate(1, row => ({ ...row, done: false }))\` upgrades old rows when read
40442
- and rewrites them in the background. Number migrations 1, 2, 3, ... and keep each one until its rows are done.
40443
-
40444
- ## Client API (\`eraspace/client\`)
40445
-
40446
- \`\`\`tsx
40447
- import { createClient } from "eraspace/client";
40448
- import { useState } from "preact/hooks";
40449
- import type app from "../server/index";
40450
-
40451
- const client = createClient<typeof app>();
40452
-
40453
- export default function App() {
40454
- const items = client.useQuery("items", "some-list"); // undefined while loading, then live
40455
- const add = client.useMutation("add");
40456
- const [title, setTitle] = useState("");
40457
- if (items === undefined) return <p>Loading\u2026</p>;
40458
- return <main class="p-6">
40459
- <form onSubmit={async (e) => { e.preventDefault(); await add("some-list", title); setTitle(""); }}>
40460
- <input value={title} onInput={(e) => setTitle(e.currentTarget.value)} />
40461
- </form>
40462
- <ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
40463
- </main>;
40464
- }
40465
- \`\`\`
40466
-
40467
- - Query, mutation and action names, arguments and results are typed from the server. Import the server with
40468
- \`import type\` only.
40469
- - Optimistic updates: \`client.useMutation("vote", { optimistic(store, id) { store.update("results", [],
40470
- (rows) => rows.map((r) => (r.id === id ? { ...r, votes: r.votes + 1 } : r))) } })\`. The second argument of
40471
- \`store.update\` is the query's argument list.
40472
- - \`useAction(name)\`, \`usePaginatedQuery(name, args, { initialNumItems })\`, \`client.query/mutation/action(...)\`.
40473
- - Realtime rooms, not stored: \`const room = client.useRoom(name, meta)\` gives \`room.presence\`,
40474
- \`room.broadcast(event, payload)\` and \`room.on(event, cb)\` (call \`on\` inside \`useEffect\` and return its
40475
- cleanup). \`client.usePresence(name, meta)\` returns just the members.
40476
- - Files: see "File uploads" below.
40477
- - Pages: \`Router\`, \`Route\`, \`Link\`, \`useLocation\`, \`useNavigate\`, \`useParams\` from \`eraspace/client\`.
40478
- - Open tabs reload by themselves after each deploy. Pass \`createClient({ reloadOnDeploy: false })\` to keep the
40479
- page and react to \`client.onDeployed(version => ...)\` instead.
40480
- - Client imports are limited to \`eraspace/client\`, \`preact\`, \`preact/hooks\`, \`@preact/signals\` and relative files.
40408
+ 1. Read \`CODING_STANDARDS.md\`, \`server/index.ts\` and \`client/index.tsx\`.
40409
+ 2. Write the requested app or change.
40410
+ 3. Run \`${cli} typecheck\` until it prints "No type errors."
40411
+ 4. Run \`${cli} deploy\` until it prints the app URL. Pass \`--delete-table\` only for rows the user agreed to lose.
40412
+ 5. Run \`${cli} call <fn> '<json arg>'...\` on every query, mutation and action, and on an invalid input for each
40413
+ mutation that takes arguments, until each returns what the code intends and each invalid input is refused with
40414
+ its message; then \`${cli} logs\` shows no unexpected error.
40415
+ 6. Once steps 3 to 5 pass after your last edit, report the URL. For an unclaimed app, also report the expiry deploy
40416
+ printed and that \`${cli} auth login\` then \`${cli} claim\` keeps the app.
40417
+ `;
40418
+ var CODING_STANDARDS = `# eraspace capsule: coding standards
40419
+
40420
+ Before using one of these features, read its section in \`FEATURES.md\`: file uploads, rooms and presence, routing,
40421
+ optimistic updates, pagination, scheduled mutations, HTTP endpoints, reload on deploy.
40422
+
40423
+ ## Security
40424
+
40425
+ - Every caller is an anonymous guest and every app is public. \`ctx.auth.userId\` is a guest id that changes with
40426
+ each connection, so it never grants ownership or access.
40427
+ - Keep secrets in \`.env.eraspace.server\` as \`KEY=value\` lines and read them in server code as \`ctx.env.KEY\`:
40428
+ \`client/\` and \`shared/\` ship to the browser.
40429
+
40430
+ ## Server (\`eraspace/server\`)
40431
+
40432
+ - Fields: \`string() number() boolean() id("table") userId() any() array(field) object({...})\`, each with
40433
+ \`.optional()\` or \`.default(value)\`. Rows also have \`id\` and \`createdAt\` (epoch ms).
40434
+ - \`ctx.db.<table>\` has \`insert\`, \`get\`, \`update(id, partial)\` (\`null\` removes a field), \`replace\` and
40435
+ \`delete\`. Reads chain \`withIndex\`, \`order("asc" | "desc")\`, then \`collect()\`, \`take(n)\`, \`first()\`,
40436
+ \`unique()\`, \`count()\` or \`paginate(...)\`.
40437
+ - Declare an index with \`table({...}).index("by_list", ["listId"])\` and read it with
40438
+ \`ctx.db.items.withIndex("by_list", (q) => q.eq("listId", id))\`, then \`.gt(...)\` or \`.lt(...)\` on the next
40439
+ field. \`by_creation\` indexes every table by \`createdAt\`.
40440
+ - Validate every argument in the handler: arguments are positional, come from anyone and are not checked against
40441
+ their TypeScript types.
40442
+ - A query is a live query: its cached result is reused until rows it read change, then rerun and pushed to
40443
+ subscribers. Make it depend only on its arguments, its caller and the rows it reads: pass a time it needs as an
40444
+ argument, rounded so it stays the same across renders, and draw random values in mutations.
40445
+ - Mutations run one at a time per app, so a read-then-write in one mutation never races another.
40446
+ - Actions call outbound \`fetch\`, \`ctx.runQuery\`, \`ctx.runMutation\` and \`ctx.runAction\`: fetch in an action
40447
+ and save the result with \`ctx.runMutation\`.
40448
+ - Rows and results are JSON: keep times as epoch-ms numbers, as \`createdAt\` is.
40449
+ - Keep state in the database: module variables reset whenever the app unloads.
40450
+ - Handlers have CPU, wall-clock and heap limits: keep loops and reads bounded.
40451
+ - A \`.migrate\` upgrade is a pure function of the stored row.
40452
+
40453
+ ## Client (\`eraspace/client\`)
40454
+
40455
+ - \`client.useQuery(name, ...args)\` subscribes to a live query: \`undefined\` while loading, then every new result,
40456
+ so mutations need no refetch.
40457
+ - \`client.useAction(name)\` returns a function, as \`useMutation\` does. Outside components, call \`client.query\`,
40458
+ \`client.mutation\` or \`client.action(name, ...args)\`.
40459
+ - Style with Tailwind utility classes, each written as a complete literal, and \`client/styles.css\`.
40460
+
40461
+ ## Shared code
40462
+
40463
+ - Keep \`shared/\` to types, constants and pure functions, which both the browser and the server can run.
40464
+ `;
40465
+ var FEATURES = `# eraspace capsule: features
40481
40466
 
40482
40467
  ## File uploads
40483
40468
 
40484
- Files go to the app's bucket, not the database. Save the returned \`FileRef\` in a row, then preview it.
40469
+ Files live in app storage, not the database: a row saves the \`FileRef\` an upload returns.
40485
40470
 
40486
40471
  \`\`\`tsx
40487
40472
  // server/index.ts: declare the file in the schema and copy its fields in the mutation (extra fields are refused).
@@ -40501,36 +40486,93 @@ const addPhoto = client.useMutation("addPhoto");
40501
40486
  - \`<FileUpload>\` is a drop zone and file picker with progress and error text. Props: \`accept\`, \`multiple\`,
40502
40487
  \`maxBytes\`, \`disabled\`, \`class\` (replaces its default style; \`data-dragging\` is set during a drag) and
40503
40488
  children for the label. \`onUpload(file)\` runs once per stored file; if it throws, the file is deleted again.
40504
- - \`<FilePreview file={fileRef or key}>\` shows images, video and audio inline, plain text in a frame, and other
40505
- types (PDF, zip...) as a download card. \`caption={false}\` hides the name/size line; \`class\` replaces its style.
40506
- Pass the saved \`FileRef\`: a bare key costs a metadata request per preview. Images, video and audio uploaded
40507
- from the same page show at once from the browser's copy; \`<FileUpload>\` shows an image while it uploads.
40489
+ - \`<FilePreview file={fileRef}>\` shows images, video and audio inline, plain text in a frame, and other types as a
40490
+ download card. Pass it the saved \`FileRef\`: a bare key costs a metadata request per preview. \`caption={false}\`
40491
+ hides the name/size line; \`class\` replaces its style.
40508
40492
  - Custom UI: \`const { upload, uploading, progress, error, file } = client.useUpload({ accept, maxBytes, onUpload })\`,
40509
40493
  then \`upload(input.files)\`; \`file\` is the Blob uploading now. Lower level: \`client.storage.upload(blob, { onProgress })\`,
40510
40494
  \`.url(key)\`, \`.localUrl(key)\`, \`.info(key)\` and \`.delete(key)\`.
40495
+ - Up to 5 MiB per file and 100 MiB per app.
40511
40496
  - \`accept\` and \`maxBytes\` are checked in the browser only; the server stores any type up to its limit.
40512
- - Up to 5 MiB per file and 100 MiB per app. Anyone with a key can read or delete that file; never upload secrets.
40497
+ - Anyone with a file's key can read that file, so upload only what may be public. The \`deleteToken\` that
40498
+ \`client.storage.upload\` returns lets its holder delete the file: keep it out of rows.
40499
+
40500
+ ## Rooms and presence
40501
+
40502
+ Rooms are realtime and never stored. \`const room = client.useRoom(name, meta)\` gives:
40503
+
40504
+ - \`room.presence\`, the members with their \`meta\`, and \`room.self\`, this connection's id.
40505
+ - \`room.broadcast(event, payload)\` and \`room.on(event, (payload, from) => ...)\`. Call \`on\` inside \`useEffect\`
40506
+ and return its cleanup.
40507
+ - \`room.state\`, shared by the members and set with \`room.setState(key, value)\`. It vanishes when the room
40508
+ empties: save what must last with a mutation.
40509
+
40510
+ \`client.usePresence(name, meta)\` returns just the members.
40513
40511
 
40514
- ## Server execution
40512
+ ## Routing
40515
40513
 
40516
- Server TypeScript is transformed with oxc and runs as ES modules in V8. JavaScript language features such as
40517
- classes, closures, async/await, generators, getters, private fields, Proxy, Reflect and BigInt use V8 semantics.
40518
- Values crossing the database or RPC boundary must be JSON-compatible.
40514
+ \`\`\`tsx
40515
+ import { Router, Route, Link, useParams, useNavigate } from "eraspace/client";
40516
+
40517
+ <Router>
40518
+ <Route path="/" component={Home} />
40519
+ <Route path="/items/:id" component={Item} /> {/* Item reads useParams().id */}
40520
+ <Route default component={NotFound} />
40521
+ </Router>
40522
+ \`\`\`
40523
+
40524
+ Paths are app-relative: the app is served under \`/<app>/\`, and every path below it loads the app. Link with
40525
+ \`<Link to="/items/1">\` and navigate with \`useNavigate()("/items/1")\`, which add that base. \`useLocation()\`
40526
+ gives the current \`path\` and \`url\`.
40527
+
40528
+ ## Optimistic updates
40519
40529
 
40520
- - Imports support relative deployed files and \`eraspace/server\`. npm packages, Node built-ins and dynamic
40521
- \`import()\` are unavailable. Apps need no installed server dependencies.
40522
- - The host supplies \`fetch\`, \`URL\`, \`URLPattern\`, \`Headers\`, \`Request\`, \`Response\`, Web Streams,
40523
- \`Blob\`, \`File\`, \`FormData\`, \`crypto\` (including \`crypto.subtle\`), \`TextEncoder\`, \`TextDecoder\`,
40524
- \`CompressionStream\`, timers and \`console\`. Bodies arrive and leave whole rather than incrementally.
40525
- - Persist state in the database. Module state is not durable and resets when an app unloads or a worker restarts.
40526
- - Await database calls. Mutations run through one writer per app and reply after their Postgres commit.
40527
- - Handlers have CPU, wall-clock and heap limits. Keep loops and reads bounded.
40528
- - Outbound fetch is available to claimed apps in actions and read-only endpoints. Private addresses are blocked.
40530
+ \`client.useMutation("vote", { optimistic(store, id) { store.update("results", [], (rows) => rows.map((r) =>
40531
+ (r.id === id ? { ...r, votes: r.votes + 1 } : r))) } })\`. The second argument of \`store.update\` is the query's
40532
+ argument list.
40533
+
40534
+ ## Pagination
40535
+
40536
+ \`\`\`ts
40537
+ // server: the query takes PaginationOptions last and returns paginate(); cap numItems, which the client chooses.
40538
+ feed: query(async (ctx, listId: string, page: PaginationOptions) =>
40539
+ ctx.db.items.withIndex("by_list", (q) => q.eq("listId", listId)).order("desc")
40540
+ .paginate({ cursor: page.cursor, numItems: Math.min(page.numItems, 50) })),
40541
+
40542
+ // client
40543
+ const { results, status, loadMore } = client.usePaginatedQuery("feed", ["some-list"], { initialNumItems: 20 });
40544
+ \`\`\`
40529
40545
 
40530
- ## Deploys without an account
40546
+ \`PaginationOptions\` comes from \`eraspace/server\`. \`status\` is \`"LoadingFirstPage"\`, \`"CanLoadMore"\`,
40547
+ \`"LoadingMore"\` or \`"Exhausted"\`; \`loadMore(n?)\` fetches the next page.
40531
40548
 
40532
- A deploy without sign-in creates a temporary app and saves its claim token in \`.eraspace/\` (git-ignored). It has no
40533
- server env or outbound fetch and expires after about a week. \`eraspace auth login\` then \`eraspace claim\` keeps it.
40549
+ ## Scheduled mutations
40550
+
40551
+ Mutations and writable endpoints run a mutation later with \`ctx.scheduler.runAfter(ms, "mutationName", ...args)\`,
40552
+ which returns a job id. The job commits with the mutation that schedules it, and its run commits at most once. For
40553
+ recurring work, the scheduled mutation schedules its next run.
40554
+
40555
+ ## HTTP endpoints
40556
+
40557
+ \`\`\`ts
40558
+ import { endpoint, json } from "eraspace/server";
40559
+
40560
+ endpoints: {
40561
+ health: endpoint({ method: "GET", path: "/health", readOnly: true }, async (ctx, req) => json({ ok: true })),
40562
+ },
40563
+ \`\`\`
40564
+
40565
+ - An endpoint is served at \`/<app>/<path>\`. \`readOnly: true\` gives it a read-only db; otherwise it writes like a
40566
+ mutation.
40567
+ - \`req\` has \`method, path, url, headers.get(), query.get(), json(), text(), bytes(), formData()\`. Return
40568
+ \`json(value)\`, \`text(string)\`, \`empty()\`, \`redirect(url)\` (each takes \`{ status, headers }\` last) or a
40569
+ plain value (sent as JSON).
40570
+ - Endpoints are public: authenticate a webhook by checking its signature with \`crypto.subtle\`.
40571
+
40572
+ ## Reload on deploy
40573
+
40574
+ Open tabs reload by themselves after each deploy. Pass \`createClient({ reloadOnDeploy: false })\` to keep the page
40575
+ and react to \`client.onDeployed(version => ...)\` instead.
40534
40576
  `;
40535
40577
  var GITIGNORE = [".eraspace/", ".env.eraspace.server"];
40536
40578
  var keepsOnInit = (name) => name.startsWith(".") || /^(README|LICENSE)(\.|$)/i.test(name);
@@ -40560,6 +40602,8 @@ var createCapsule = Effect_exports.fnUntraced(function* (directory, template, mo
40560
40602
  const files = {
40561
40603
  "eraspace.json": JSON.stringify({ name: app }, null, 2) + "\n",
40562
40604
  "AGENTS.md": agents(command2),
40605
+ "CODING_STANDARDS.md": CODING_STANDARDS,
40606
+ "FEATURES.md": FEATURES,
40563
40607
  "CLAUDE.md": "@AGENTS.md\n"
40564
40608
  };
40565
40609
  const gitignore = join7(destination, ".gitignore");
@@ -40659,7 +40703,7 @@ var config = Effect_exports.gen(function* () {
40659
40703
  return yield* loadConfig({ dir: value3(flags.dir), app: value3(flags.app), url: value3(flags.url) });
40660
40704
  });
40661
40705
  var deployFlags = {
40662
- name: optionalString("name", "Exact name for a new app, failing if it is taken (default: the eraspace.json or directory name, or a free variant of it)"),
40706
+ name: optionalString("name", "Exact name for a new app, failing if it is taken (default: the eraspace.json or directory name, or a free variant of it). A redeploy refuses it; use eraspace names add"),
40663
40707
  publicInspect: Flag_exports.Boolean("public-inspect").pipe(Flag_exports.withDefault(false), Flag_exports.withDescription("Let anyone read this app's inspection data and logs"))
40664
40708
  };
40665
40709
  var deployOptions = (input) => ({ name: value3(input.name), publicInspect: input.publicInspect });
@@ -40714,6 +40758,32 @@ var deployCommand = Command_exports.make(
40714
40758
  { command: "eraspace deploy --fail-on-query-errors", description: "Deploy only if no live query starts throwing on the new code" }
40715
40759
  ])
40716
40760
  );
40761
+ var deploysCommand = Command_exports.make(
40762
+ "deploys",
40763
+ { app: optionalArg("app", "App name or id (default: this checkout's app)") },
40764
+ ({ app }) => Effect_exports.flatMap(config, (c2) => deploys(c2, value3(app)))
40765
+ ).pipe(Command_exports.withDescription("List the latest 50 kept versions you can roll back to, newest first"));
40766
+ var rollbackCommand = Command_exports.make(
40767
+ "rollback",
40768
+ {
40769
+ version: Argument_exports.Int("version").pipe(
40770
+ Argument_exports.withDescription("Version to roll back to (default: the newest kept version below the one serving)"),
40771
+ Argument_exports.optional
40772
+ ),
40773
+ ...deleteTableFlag,
40774
+ ...shadowFlags
40775
+ },
40776
+ (input) => Effect_exports.flatMap(
40777
+ config,
40778
+ (c2) => rollback(c2, value3(input.version), { deleteTables: value3(input.deleteTable), shadow: input.shadow, failOnQueryErrors: input.failOnQueryErrors })
40779
+ )
40780
+ ).pipe(
40781
+ Command_exports.withDescription("Deploy an earlier version's server code, client files and server env again, as a new version"),
40782
+ Command_exports.withExamples([
40783
+ { command: "eraspace rollback", description: "Go back to the version before the one serving" },
40784
+ { command: "eraspace rollback 3", description: "Serve version 3's code again" }
40785
+ ])
40786
+ );
40717
40787
  var devCommand = Command_exports.make("dev", deployFlags, (input) => Effect_exports.flatMap(config, (c2) => dev(c2, deployOptions(input)))).pipe(
40718
40788
  Command_exports.withDescription("Deploy, then redeploy whenever server/, client/, shared/ or .env.eraspace.server change")
40719
40789
  );
@@ -40861,6 +40931,8 @@ var command = eraspace.pipe(
40861
40931
  initCommand,
40862
40932
  typecheckCommand,
40863
40933
  deployCommand,
40934
+ deploysCommand,
40935
+ rollbackCommand,
40864
40936
  devCommand,
40865
40937
  callCommand,
40866
40938
  explainCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eraspace",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "eraspace: build, deploy and manage eraspace capsules",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -13,7 +13,7 @@ import { Pager } from "./pagination.ts";
13
13
 
14
14
  export { readConfig, type EraspaceConfig, type ClientOptions } from "./config.ts";
15
15
  export { subKey } from "./sub-key.ts";
16
- export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult } from "./types.ts";
16
+ export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult, Status } from "./types.ts";
17
17
 
18
18
  type Fn = (...args: any[]) => any;
19
19
 
@@ -114,11 +114,8 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
114
114
 
115
115
  function useRoom(room: string, meta?: Record<string, unknown>): Room<any, any> {
116
116
  const r = useRoomConn(room, meta);
117
- return {
118
- presence: r.members.value,
119
- self: r.self.value,
120
- state: r.state.value,
121
- error: r.error.value,
117
+ // Stable per room, so effects and memoized children that depend on them do not rerun every render.
118
+ const methods = useMemo(() => ({
122
119
  broadcast: (event: string, payload?: unknown) => {
123
120
  void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
124
121
  },
@@ -131,6 +128,13 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
131
128
  setState: (key: string, value: unknown) => {
132
129
  r.setState(key, value);
133
130
  },
131
+ }), [r]);
132
+ return {
133
+ presence: r.members.value,
134
+ self: r.self.value,
135
+ state: r.state.value,
136
+ error: r.error.value,
137
+ ...methods,
134
138
  };
135
139
  }
136
140
 
@@ -343,6 +343,8 @@ export function FilePreview(props: FilePreviewProps) {
343
343
  if (meta === undefined || meta === null || meta === "failed" || broken === key) {
344
344
  // A media error cannot tell a missing file from a failed request, so every failure offers a retry.
345
345
  const retry = () => {
346
+ // Found metadata stays cached, so a file deleted since would show as found again and fail the same way.
347
+ infoCache.delete(client.storage.url(key));
346
348
  setBroken(null);
347
349
  setFetched(undefined);
348
350
  setAttempt((n) => n + 1);
@@ -10,6 +10,7 @@ export {
10
10
  type PaginatedQueryResult,
11
11
  type PresenceEntry,
12
12
  type Room,
13
+ type Status,
13
14
  type EraspaceClient,
14
15
  type EraspaceConfig,
15
16
  } from "./client.ts";
@@ -18,9 +18,12 @@ function canonical(v: unknown): unknown {
18
18
  // Own data properties, so a `__proto__` key stays a key rather than setting the prototype.
19
19
  return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canonical(o[k])]));
20
20
  }
21
+ /** Sorted-key JSON. Like `JSON.stringify` and `canonicalArgs`, it serializes what `toJSON` returns, so two Dates differ. */
21
22
  export function stableStringify(v: unknown): string {
22
23
  if (v === undefined) return "null";
23
24
  if (v === null || typeof v !== "object") return JSON.stringify(v);
25
+ const json = (v as { toJSON?: unknown }).toJSON;
26
+ if (typeof json === "function") return stableStringify(json.call(v));
24
27
  if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
25
28
  const o = v as Record<string, unknown>;
26
29
  const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
@@ -1,5 +1,5 @@
1
1
  import { createClient, FilePreview, FileUpload, type FileRef } from "eraspace/client";
2
- import { useState, useEffect } from "preact/hooks";
2
+ import { useState, useEffect, useRef } from "preact/hooks";
3
3
  import type app from "../server/index";
4
4
  const client = createClient<typeof app>();
5
5
  export default function App() {
@@ -12,7 +12,12 @@ export default function App() {
12
12
  const [file, setFile] = useState<FileRef | null>(null);
13
13
  const messages = client.useQuery("messages", room);
14
14
  const send = client.useMutation("send");
15
- const channel = client.useRoom<{ name: string }> (room, { name: author });
15
+ // Room messages share the app's request budget, so presence takes the name once typing pauses and the typing
16
+ // notice goes out at most once a second; the 1.5 s display below bridges the gap.
17
+ const [shownName, setShownName] = useState(author);
18
+ useEffect(() => { const timer = setTimeout(() => setShownName(author), 500); return () => clearTimeout(timer); }, [author]);
19
+ const lastTyping = useRef(0);
20
+ const channel = client.useRoom<{ name: string }> (room, { name: shownName });
16
21
  useEffect(() => channel.on("typing", (payload: unknown) => {
17
22
  if (typeof payload === "string") setTyping(payload);
18
23
  }), [room]);
@@ -29,7 +34,10 @@ export default function App() {
29
34
  try { await send(room, author, body, file); setBody(""); setFile(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
30
35
  }}>
31
36
  <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); }}/>
37
+ <input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => {
38
+ setBody(e.currentTarget.value);
39
+ if (Date.now() - lastTyping.current >= 1000) { lastTyping.current = Date.now(); channel.broadcast("typing", author); }
40
+ }}/>
33
41
  <button disabled={sending || (!body.trim() && !file)}>Send</button>
34
42
  {file
35
43
  ? <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>