@boxd-sh/convex 0.1.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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +327 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +126 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +52 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +357 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +135 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +80 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/boxd.d.ts +35 -0
  28. package/dist/component/boxd.d.ts.map +1 -0
  29. package/dist/component/boxd.js +144 -0
  30. package/dist/component/boxd.js.map +1 -0
  31. package/dist/component/convex.config.d.ts +6 -0
  32. package/dist/component/convex.config.d.ts.map +1 -0
  33. package/dist/component/convex.config.js +9 -0
  34. package/dist/component/convex.config.js.map +1 -0
  35. package/dist/component/errors.d.ts +25 -0
  36. package/dist/component/errors.d.ts.map +1 -0
  37. package/dist/component/errors.js +75 -0
  38. package/dist/component/errors.js.map +1 -0
  39. package/dist/component/exec.d.ts +25 -0
  40. package/dist/component/exec.d.ts.map +1 -0
  41. package/dist/component/exec.js +100 -0
  42. package/dist/component/exec.js.map +1 -0
  43. package/dist/component/executions.d.ts +70 -0
  44. package/dist/component/executions.d.ts.map +1 -0
  45. package/dist/component/executions.js +106 -0
  46. package/dist/component/executions.js.map +1 -0
  47. package/dist/component/files.d.ts +44 -0
  48. package/dist/component/files.d.ts.map +1 -0
  49. package/dist/component/files.js +105 -0
  50. package/dist/component/files.js.map +1 -0
  51. package/dist/component/limits.d.ts +33 -0
  52. package/dist/component/limits.d.ts.map +1 -0
  53. package/dist/component/limits.js +44 -0
  54. package/dist/component/limits.js.map +1 -0
  55. package/dist/component/machines.d.ts +337 -0
  56. package/dist/component/machines.d.ts.map +1 -0
  57. package/dist/component/machines.js +411 -0
  58. package/dist/component/machines.js.map +1 -0
  59. package/dist/component/records.d.ts +87 -0
  60. package/dist/component/records.d.ts.map +1 -0
  61. package/dist/component/records.js +51 -0
  62. package/dist/component/records.js.map +1 -0
  63. package/dist/component/schema.d.ts +138 -0
  64. package/dist/component/schema.d.ts.map +1 -0
  65. package/dist/component/schema.js +70 -0
  66. package/dist/component/schema.js.map +1 -0
  67. package/dist/component/sessions.d.ts +21 -0
  68. package/dist/component/sessions.d.ts.map +1 -0
  69. package/dist/component/sessions.js +59 -0
  70. package/dist/component/sessions.js.map +1 -0
  71. package/dist/component/validate.d.ts +15 -0
  72. package/dist/component/validate.d.ts.map +1 -0
  73. package/dist/component/validate.js +44 -0
  74. package/dist/component/validate.js.map +1 -0
  75. package/package.json +99 -0
  76. package/src/client/_generated/_ignore.ts +1 -0
  77. package/src/client/index.ts +199 -0
  78. package/src/component/_generated/api.ts +68 -0
  79. package/src/component/_generated/component.ts +430 -0
  80. package/src/component/_generated/dataModel.ts +60 -0
  81. package/src/component/_generated/server.ts +171 -0
  82. package/src/component/boxd.ts +188 -0
  83. package/src/component/convex.config.ts +9 -0
  84. package/src/component/errors.ts +105 -0
  85. package/src/component/exec.ts +134 -0
  86. package/src/component/executions.ts +115 -0
  87. package/src/component/files.ts +128 -0
  88. package/src/component/limits.ts +48 -0
  89. package/src/component/machines.ts +500 -0
  90. package/src/component/records.ts +71 -0
  91. package/src/component/schema.ts +77 -0
  92. package/src/component/sessions.ts +61 -0
  93. package/src/component/validate.ts +58 -0
  94. package/src/test.ts +28 -0
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Machines: reactive queries over the `machines` table, the bookkeeping
3
+ * mutations behind them, and the lifecycle actions that call boxd and write
4
+ * back what they observed.
5
+ */
6
+
7
+ import type { Boxd, Machine } from "@boxd-sh/sdk/web";
8
+ import { ConvexError, v } from "convex/values";
9
+ import { internal } from "./_generated/api.js";
10
+ import {
11
+ action,
12
+ internalMutation,
13
+ internalQuery,
14
+ query,
15
+ type ActionCtx,
16
+ } from "./_generated/server.js";
17
+ import { withBoxd } from "./boxd.js";
18
+ import {
19
+ boxdError,
20
+ errorMessage,
21
+ isNotFound,
22
+ toConvexError,
23
+ } from "./errors.js";
24
+ import {
25
+ DEFAULT_READY_TIMEOUT_MS,
26
+ MAX_READY_TIMEOUT_MS,
27
+ clampLimit,
28
+ } from "./limits.js";
29
+ import {
30
+ machineDoc,
31
+ observed,
32
+ recordFailure,
33
+ requireMachine,
34
+ type MachineDoc,
35
+ } from "./records.js";
36
+ import {
37
+ boundedTimeout,
38
+ requireNonEmpty,
39
+ requirePort,
40
+ requireSubdomainLabel,
41
+ } from "./validate.js";
42
+
43
+ // ---- Reactive reads -------------------------------------------------------
44
+
45
+ export const get = query({
46
+ args: { machineId: v.string(), ownerId: v.optional(v.string()) },
47
+ returns: v.union(v.null(), machineDoc),
48
+ handler: async (ctx, { machineId, ownerId }) => {
49
+ const row = await ctx.db
50
+ .query("machines")
51
+ .withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
52
+ .unique();
53
+ return row && row.ownerId === ownerId ? row : null;
54
+ },
55
+ });
56
+
57
+ /** The owner's machines, newest first. Destroyed machines stay listed. */
58
+ export const list = query({
59
+ args: { ownerId: v.optional(v.string()), limit: v.optional(v.number()) },
60
+ returns: v.array(machineDoc),
61
+ handler: async (ctx, { ownerId, limit }) => {
62
+ return await ctx.db
63
+ .query("machines")
64
+ .withIndex("by_owner", (q) => q.eq("ownerId", ownerId))
65
+ .order("desc")
66
+ .take(clampLimit(limit));
67
+ },
68
+ });
69
+
70
+ // ---- Bookkeeping ----------------------------------------------------------
71
+
72
+ export const record = internalQuery({
73
+ args: { machineId: v.string() },
74
+ returns: v.union(v.null(), machineDoc),
75
+ handler: async (ctx, { machineId }) => {
76
+ return await ctx.db
77
+ .query("machines")
78
+ .withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
79
+ .unique();
80
+ },
81
+ });
82
+
83
+ /**
84
+ * Insert a machine's row or refresh it with what boxd reported. `ownerId`
85
+ * and `forkedFrom` are set once, on insert. A success clears `lastError`.
86
+ * "destroyed" is final: an action that read the machine before a concurrent
87
+ * destroy finished must not bring the row back to life.
88
+ */
89
+ export const upsert = internalMutation({
90
+ args: {
91
+ machineId: v.string(),
92
+ name: v.string(),
93
+ status: v.string(),
94
+ image: v.optional(v.string()),
95
+ url: v.optional(v.string()),
96
+ vcpu: v.optional(v.number()),
97
+ memoryBytes: v.optional(v.number()),
98
+ ownerId: v.optional(v.string()),
99
+ forkedFrom: v.optional(v.string()),
100
+ },
101
+ returns: machineDoc,
102
+ handler: async (ctx, { ownerId, forkedFrom, ...fields }) => {
103
+ const row = await ctx.db
104
+ .query("machines")
105
+ .withIndex("by_machine_id", (q) => q.eq("machineId", fields.machineId))
106
+ .unique();
107
+ const updatedAt = Date.now();
108
+ if (row?.status === "destroyed") return row;
109
+ if (row) {
110
+ await ctx.db.patch("machines", row._id, {
111
+ ...fields,
112
+ lastError: undefined,
113
+ updatedAt,
114
+ });
115
+ return { ...row, ...fields, lastError: undefined, updatedAt };
116
+ }
117
+ const doc = { ...fields, ownerId, forkedFrom, updatedAt };
118
+ const id = await ctx.db.insert("machines", doc);
119
+ return (await ctx.db.get("machines", id))!;
120
+ },
121
+ });
122
+
123
+ export const setError = internalMutation({
124
+ args: { machineId: v.string(), error: v.string() },
125
+ returns: v.null(),
126
+ handler: async (ctx, { machineId, error }) => {
127
+ const row = await ctx.db
128
+ .query("machines")
129
+ .withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
130
+ .unique();
131
+ if (row) {
132
+ await ctx.db.patch("machines", row._id, {
133
+ lastError: error,
134
+ updatedAt: Date.now(),
135
+ });
136
+ }
137
+ return null;
138
+ },
139
+ });
140
+
141
+ /** Keep the row, and with it the execution history, as a record. */
142
+ export const markDestroyed = internalMutation({
143
+ args: { machineId: v.string() },
144
+ returns: v.union(v.null(), machineDoc),
145
+ handler: async (ctx, { machineId }) => {
146
+ const row = await ctx.db
147
+ .query("machines")
148
+ .withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
149
+ .unique();
150
+ if (!row) return null;
151
+ const patch = {
152
+ status: "destroyed",
153
+ lastError: undefined,
154
+ updatedAt: Date.now(),
155
+ };
156
+ await ctx.db.patch("machines", row._id, patch);
157
+ return { ...row, ...patch };
158
+ },
159
+ });
160
+
161
+ // ---- Actions --------------------------------------------------------------
162
+
163
+ const target = { machineId: v.string(), ownerId: v.optional(v.string()) };
164
+
165
+ const readiness = {
166
+ /** Wait until the machine accepts an exec. Default true. */
167
+ waitUntilReady: v.optional(v.boolean()),
168
+ /** How long to wait, in ms. Default 120s, capped at 480s. */
169
+ readyTimeoutMs: v.optional(v.number()),
170
+ };
171
+
172
+ function readyTimeout(args: {
173
+ waitUntilReady?: boolean;
174
+ readyTimeoutMs?: number;
175
+ }): number | undefined {
176
+ if (args.waitUntilReady === false) return undefined;
177
+ return boundedTimeout(
178
+ args.readyTimeoutMs,
179
+ DEFAULT_READY_TIMEOUT_MS,
180
+ MAX_READY_TIMEOUT_MS,
181
+ "readyTimeoutMs",
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Run a boxd operation that ends in a fresh `Machine`, and store it. A
187
+ * failure lands on the row; a machine boxd no longer knows is marked
188
+ * destroyed.
189
+ */
190
+ async function settle(
191
+ ctx: ActionCtx,
192
+ machineId: string,
193
+ operation: (boxd: Boxd) => Promise<Machine>,
194
+ ): Promise<MachineDoc> {
195
+ let machine: Machine;
196
+ try {
197
+ machine = await withBoxd(ctx, operation);
198
+ } catch (error) {
199
+ if (isNotFound(error)) {
200
+ await ctx.runMutation(internal.machines.markDestroyed, { machineId });
201
+ throw error;
202
+ }
203
+ return await recordFailure(ctx, machineId, error);
204
+ }
205
+ return await ctx.runMutation(internal.machines.upsert, observed(machine));
206
+ }
207
+
208
+ /**
209
+ * Store a machine boxd just created. If the row can't be written, destroy
210
+ * the machine: a machine no row tracks would run, and bill, unseen.
211
+ */
212
+ async function adopt(
213
+ ctx: ActionCtx,
214
+ machine: Machine,
215
+ extra: { ownerId?: string; forkedFrom?: string },
216
+ ): Promise<MachineDoc> {
217
+ try {
218
+ return await ctx.runMutation(internal.machines.upsert, {
219
+ ...observed(machine),
220
+ ...extra,
221
+ });
222
+ } catch (error) {
223
+ await withBoxd(ctx, (boxd) => boxd.machines.delete(machine.id)).catch(
224
+ (cleanup: unknown) =>
225
+ console.error(
226
+ `boxd: could not destroy untracked machine ${machine.id}: ${errorMessage(cleanup)}`,
227
+ ),
228
+ );
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Wait for a machine `create` or `fork` just made. On failure the machine
235
+ * still exists and bills, so the error carries its `machineId`: the caller
236
+ * can destroy it, or wait longer, instead of creating another one.
237
+ */
238
+ async function ready(
239
+ ctx: ActionCtx,
240
+ row: MachineDoc,
241
+ timeout: number | undefined,
242
+ ): Promise<MachineDoc> {
243
+ if (timeout === undefined) return row;
244
+ try {
245
+ return await settle(ctx, row.machineId, (boxd) =>
246
+ boxd.machines.waitUntilReady(row.machineId, { timeout }),
247
+ );
248
+ } catch (error) {
249
+ const { data } = toConvexError(error);
250
+ throw new ConvexError({ ...data, machineId: row.machineId });
251
+ }
252
+ }
253
+
254
+ export const create = action({
255
+ args: {
256
+ ownerId: v.optional(v.string()),
257
+ /** Unique in the key's org. Omit for a generated name. */
258
+ name: v.optional(v.string()),
259
+ /** OCI image. Omit for the boxd default image. */
260
+ image: v.optional(v.string()),
261
+ /** Environment variables for the machine. Not stored by the component. */
262
+ env: v.optional(v.record(v.string(), v.string())),
263
+ /** Size class: 1, 2 or 4 vCPU. The memory follows (4, 8 or 16 GiB). */
264
+ vcpu: v.optional(v.number()),
265
+ /** Size class by memory: "4G", "8G" or "16G". */
266
+ memory: v.optional(v.string()),
267
+ /** Idle seconds before boxd suspends the machine. 0 disables. */
268
+ autoSuspendSeconds: v.optional(v.number()),
269
+ /** Idle seconds before boxd destroys the machine. 0 disables. */
270
+ autoDestroySeconds: v.optional(v.number()),
271
+ /** Reachable only through explicit networks; no in-VM boxd CLI. */
272
+ isolated: v.optional(v.boolean()),
273
+ ...readiness,
274
+ },
275
+ returns: machineDoc,
276
+ handler: async (ctx, args) => {
277
+ const timeout = readyTimeout(args);
278
+ if (args.name !== undefined) requireNonEmpty(args.name, "name");
279
+ const machine = await withBoxd(ctx, (boxd) =>
280
+ boxd.machines.create({
281
+ name: args.name,
282
+ image: args.image,
283
+ env: args.env,
284
+ isolated: args.isolated,
285
+ config: {
286
+ vcpu: args.vcpu,
287
+ memory: args.memory,
288
+ autoSuspendTimeout: args.autoSuspendSeconds,
289
+ autoDestroyTimeout: args.autoDestroySeconds,
290
+ },
291
+ }),
292
+ );
293
+ const row = await adopt(ctx, machine, { ownerId: args.ownerId });
294
+ return await ready(ctx, row, timeout);
295
+ },
296
+ });
297
+
298
+ /**
299
+ * Fork a running machine: a copy of its disk and memory, booted as a new
300
+ * machine. The fork has the source's owner and records `forkedFrom`.
301
+ */
302
+ export const fork = action({
303
+ args: { ...target, name: v.optional(v.string()), ...readiness },
304
+ returns: machineDoc,
305
+ handler: async (ctx, args) => {
306
+ const timeout = readyTimeout(args);
307
+ if (args.name !== undefined) requireNonEmpty(args.name, "name");
308
+ const source = await requireMachine(ctx, args.machineId, args.ownerId);
309
+ let machine: Machine;
310
+ try {
311
+ machine = await withBoxd(ctx, (boxd) =>
312
+ boxd.machines.fork(source.machineId, { name: args.name }),
313
+ );
314
+ } catch (error) {
315
+ return await recordFailure(ctx, source.machineId, error);
316
+ }
317
+ const row = await adopt(ctx, machine, {
318
+ ownerId: source.ownerId,
319
+ forkedFrom: source.machineId,
320
+ });
321
+ return await ready(ctx, row, timeout);
322
+ },
323
+ });
324
+
325
+ /** Re-read the machine from boxd into its row. */
326
+ export const refresh = action({
327
+ args: target,
328
+ returns: machineDoc,
329
+ handler: async (ctx, { machineId, ownerId }) => {
330
+ await requireMachine(ctx, machineId, ownerId);
331
+ return await settle(ctx, machineId, (boxd) => boxd.machines.get(machineId));
332
+ },
333
+ });
334
+
335
+ type Transition = "start" | "stop" | "pause" | "resume" | "hibernate" | "wake";
336
+
337
+ /** The status each transition ends in. */
338
+ const TARGET: Record<Transition, string> = {
339
+ start: "running",
340
+ resume: "running",
341
+ wake: "running",
342
+ stop: "stopped",
343
+ pause: "suspended",
344
+ hibernate: "hibernated",
345
+ };
346
+
347
+ /** How long a sleeping transition polls for its target status. */
348
+ const SETTLE_TIMEOUT_MS = 60_000;
349
+ const SETTLE_POLL_MS = 500;
350
+
351
+ /**
352
+ * Ask boxd for `verb`. A machine already in the target status is not an
353
+ * error: boxd refuses `resume` on a running machine with a conflict, but
354
+ * the caller's goal is reached.
355
+ */
356
+ async function request(
357
+ boxd: Boxd,
358
+ machineId: string,
359
+ verb: Transition,
360
+ ): Promise<void> {
361
+ try {
362
+ await boxd.machines[verb](machineId);
363
+ } catch (error) {
364
+ if (toConvexError(error).data.code !== "CONFLICT") throw error;
365
+ const machine = await boxd.machines.get(machineId);
366
+ if (machine.status !== TARGET[verb]) throw error;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Poll until the machine reports `status`, or return the last observed
372
+ * machine after `timeoutMs`: a transition still in progress is not a
373
+ * failure, and the row shows exactly what boxd reported.
374
+ */
375
+ async function reach(
376
+ boxd: Boxd,
377
+ machineId: string,
378
+ status: string,
379
+ timeoutMs: number,
380
+ ): Promise<Machine> {
381
+ const deadline = Date.now() + timeoutMs;
382
+ let machine = await boxd.machines.get(machineId);
383
+ while (machine.status !== status && Date.now() < deadline) {
384
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
385
+ machine = await boxd.machines.get(machineId);
386
+ }
387
+ return machine;
388
+ }
389
+
390
+ /**
391
+ * A transition that ends with the machine asleep or off. Returns once boxd
392
+ * reports the target status, or after 60 seconds with the status it reports
393
+ * then.
394
+ */
395
+ function sleeping(verb: Transition) {
396
+ return action({
397
+ args: target,
398
+ returns: machineDoc,
399
+ handler: async (ctx, { machineId, ownerId }) => {
400
+ await requireMachine(ctx, machineId, ownerId);
401
+ return await settle(ctx, machineId, async (boxd) => {
402
+ await request(boxd, machineId, verb);
403
+ return await reach(boxd, machineId, TARGET[verb], SETTLE_TIMEOUT_MS);
404
+ });
405
+ },
406
+ });
407
+ }
408
+
409
+ /**
410
+ * A transition that ends with the machine running. By default it returns
411
+ * once the machine accepts an exec.
412
+ */
413
+ function waking(verb: Transition) {
414
+ return action({
415
+ args: { ...target, ...readiness },
416
+ returns: machineDoc,
417
+ handler: async (ctx, args) => {
418
+ const timeout = readyTimeout(args);
419
+ await requireMachine(ctx, args.machineId, args.ownerId);
420
+ return await settle(ctx, args.machineId, async (boxd) => {
421
+ await request(boxd, args.machineId, verb);
422
+ return timeout === undefined
423
+ ? await boxd.machines.get(args.machineId)
424
+ : await boxd.machines.waitUntilReady(args.machineId, { timeout });
425
+ });
426
+ },
427
+ });
428
+ }
429
+
430
+ /** Boot a stopped machine (cold boot: disk kept, memory not). */
431
+ export const start = waking("start");
432
+ /** Shut the machine down. The disk is kept. */
433
+ export const stop = sleeping("stop");
434
+ /** Suspend to RAM: frozen in place, resumes in milliseconds. */
435
+ export const pause = sleeping("pause");
436
+ /** Resume a paused machine. */
437
+ export const resume = waking("resume");
438
+ /** Suspend to disk: frees the host's memory, `wake` restores it. */
439
+ export const hibernate = sleeping("hibernate");
440
+ /** Restore a hibernated machine, memory included. */
441
+ export const wake = waking("wake");
442
+
443
+ /** Destroy the machine. Its row stays, with status "destroyed". */
444
+ export const destroy = action({
445
+ args: target,
446
+ returns: machineDoc,
447
+ handler: async (ctx, { machineId, ownerId }): Promise<MachineDoc> => {
448
+ await requireMachine(ctx, machineId, ownerId);
449
+ try {
450
+ await withBoxd(ctx, (boxd) => boxd.machines.delete(machineId));
451
+ } catch (error) {
452
+ // Already gone on boxd: the goal is reached.
453
+ if (!isNotFound(error)) return await recordFailure(ctx, machineId, error);
454
+ }
455
+ const row: MachineDoc | null = await ctx.runMutation(
456
+ internal.machines.markDestroyed,
457
+ { machineId },
458
+ );
459
+ return row!;
460
+ },
461
+ });
462
+
463
+ /**
464
+ * Route public HTTPS to a port inside the machine. Without `name`, this
465
+ * points the machine's default URL (`https://<machine>.<zone>`) at `port`.
466
+ * With `name`, it adds `https://<name>.<machine>.<zone>` beside it.
467
+ */
468
+ export const expose = action({
469
+ args: { ...target, port: v.number(), name: v.optional(v.string()) },
470
+ returns: v.object({ url: v.string(), port: v.number() }),
471
+ handler: async (ctx, { machineId, ownerId, port, name }) => {
472
+ requirePort(port);
473
+ if (name !== undefined) requireSubdomainLabel(name);
474
+ const row = await requireMachine(ctx, machineId, ownerId);
475
+ try {
476
+ return await withBoxd(ctx, async (boxd) => {
477
+ const base = row.url ?? (await boxd.machines.get(machineId)).access.url;
478
+ if (!base) {
479
+ throw boxdError(
480
+ "BOXD_ERROR",
481
+ `machine ${machineId} has no HTTPS URL to route`,
482
+ );
483
+ }
484
+ if (name === undefined) {
485
+ await boxd.machines.proxies.setPort(machineId, port);
486
+ return { url: base, port };
487
+ }
488
+ const created = await boxd.machines.proxies.create(
489
+ machineId,
490
+ name,
491
+ port,
492
+ );
493
+ const host = new URL(base).host;
494
+ return { url: `https://${created.name}.${host}`, port: created.port };
495
+ });
496
+ } catch (error) {
497
+ return await recordFailure(ctx, machineId, error);
498
+ }
499
+ },
500
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Helpers shared by the action modules: turning an SDK `Machine` into row
3
+ * fields, the tenant check, and failure bookkeeping.
4
+ */
5
+
6
+ import type { Machine } from "@boxd-sh/sdk/web";
7
+ import type { Infer } from "convex/values";
8
+ import { v } from "convex/values";
9
+ import { internal } from "./_generated/api.js";
10
+ import type { ActionCtx } from "./_generated/server.js";
11
+ import { boxdError, errorMessage } from "./errors.js";
12
+ import schema from "./schema.js";
13
+
14
+ export const machineDoc = schema.tables.machines.validator.extend({
15
+ _id: v.id("machines"),
16
+ _creationTime: v.number(),
17
+ });
18
+ export type MachineDoc = Infer<typeof machineDoc>;
19
+
20
+ export const executionDoc = schema.tables.executions.validator.extend({
21
+ _id: v.id("executions"),
22
+ _creationTime: v.number(),
23
+ });
24
+
25
+ /** The row fields boxd owns, read off an SDK `Machine`. */
26
+ export function observed(machine: Machine) {
27
+ return {
28
+ machineId: machine.id,
29
+ name: machine.name,
30
+ status: machine.status,
31
+ image: machine.imageRef || undefined,
32
+ url: machine.access.url || undefined,
33
+ vcpu: machine.resources.vcpu || undefined,
34
+ memoryBytes: machine.resources.memoryBytes || undefined,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * The machine's row, if it exists and belongs to `ownerId`.
40
+ *
41
+ * `ownerId` must match exactly: a machine created without one is only
42
+ * reachable without one. A mismatch reads as NOT_FOUND, so a caller can't
43
+ * probe for other tenants' machines.
44
+ */
45
+ export async function requireMachine(
46
+ ctx: ActionCtx,
47
+ machineId: string,
48
+ ownerId: string | undefined,
49
+ ): Promise<MachineDoc> {
50
+ const row = await ctx.runQuery(internal.machines.record, { machineId });
51
+ if (!row || row.ownerId !== ownerId) {
52
+ throw boxdError(
53
+ "NOT_FOUND",
54
+ `no machine ${machineId} is managed by this component for this owner`,
55
+ );
56
+ }
57
+ return row;
58
+ }
59
+
60
+ /** Store the failure on the machine's row, then rethrow it. */
61
+ export async function recordFailure(
62
+ ctx: ActionCtx,
63
+ machineId: string,
64
+ error: unknown,
65
+ ): Promise<never> {
66
+ await ctx.runMutation(internal.machines.setError, {
67
+ machineId,
68
+ error: errorMessage(error),
69
+ });
70
+ throw error;
71
+ }
@@ -0,0 +1,77 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ /**
5
+ * A reactive mirror of one boxd machine this component created or forked.
6
+ *
7
+ * boxd stays the source of truth: every lifecycle action writes back the
8
+ * status it observed, and `machines.refresh` re-reads it on demand. The row
9
+ * outlives the machine (status `"destroyed"`) so its execution history stays
10
+ * readable.
11
+ */
12
+ export const machineFields = {
13
+ /** boxd machine id. External, not a Convex document id. */
14
+ machineId: v.string(),
15
+ name: v.string(),
16
+ /**
17
+ * Opaque tenant key from the host app. Components can't read `ctx.auth`, so
18
+ * the app derives this from its own auth and every call must present the
19
+ * same value to touch the machine.
20
+ */
21
+ ownerId: v.optional(v.string()),
22
+ /** Last observed boxd status: `running`, `suspended`, `hibernated`, ... */
23
+ status: v.string(),
24
+ image: v.optional(v.string()),
25
+ /** `https://<name>.<zone>`, the machine's default HTTPS route. */
26
+ url: v.optional(v.string()),
27
+ vcpu: v.optional(v.number()),
28
+ memoryBytes: v.optional(v.number()),
29
+ /** Set when this machine is a fork of another machine. */
30
+ forkedFrom: v.optional(v.string()),
31
+ /** The last failed operation's message. Cleared by the next success. */
32
+ lastError: v.optional(v.string()),
33
+ updatedAt: v.number(),
34
+ };
35
+
36
+ export const executionStatus = v.union(
37
+ v.literal("running"),
38
+ v.literal("completed"),
39
+ v.literal("failed"),
40
+ );
41
+
42
+ /** One row per `exec`, so the app can render live status and history. */
43
+ export const executionFields = {
44
+ machineId: v.string(),
45
+ ownerId: v.optional(v.string()),
46
+ /** The command line that ran, truncated for storage. */
47
+ command: v.string(),
48
+ cwd: v.optional(v.string()),
49
+ status: executionStatus,
50
+ exitCode: v.optional(v.number()),
51
+ /** Truncated for storage; the action's return value carries more. */
52
+ stdout: v.optional(v.string()),
53
+ stderr: v.optional(v.string()),
54
+ error: v.optional(v.string()),
55
+ startedAt: v.number(),
56
+ finishedAt: v.optional(v.number()),
57
+ };
58
+
59
+ export default defineSchema({
60
+ machines: defineTable(machineFields)
61
+ .index("by_machine_id", ["machineId"])
62
+ .index("by_owner", ["ownerId"]),
63
+ executions: defineTable(executionFields).index("by_machine", ["machineId"]),
64
+ /**
65
+ * Cached session tokens, keyed by a SHA-256 of the API key and endpoint.
66
+ * boxd rate-limits the key-for-token exchange per source IP, and every
67
+ * Convex deployment in a region shares a few egress IPs, so each action
68
+ * must reuse the token rather than exchange the key again. Internal only:
69
+ * nothing in the component's public API returns these rows.
70
+ */
71
+ sessions: defineTable({
72
+ keyHash: v.string(),
73
+ token: v.string(),
74
+ /** Epoch milliseconds. */
75
+ expiresAt: v.number(),
76
+ }).index("by_key_hash", ["keyHash"]),
77
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The session-token cache behind every boxd call. Internal functions only:
3
+ * the app can't read a token through the component's API.
4
+ */
5
+
6
+ import { v } from "convex/values";
7
+ import { internalMutation, internalQuery } from "./_generated/server.js";
8
+
9
+ const session = v.object({ token: v.string(), expiresAt: v.number() });
10
+
11
+ export const get = internalQuery({
12
+ args: { keyHash: v.string() },
13
+ returns: v.union(v.null(), session),
14
+ handler: async (ctx, { keyHash }) => {
15
+ const row = await ctx.db
16
+ .query("sessions")
17
+ .withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash))
18
+ .unique();
19
+ return row ? { token: row.token, expiresAt: row.expiresAt } : null;
20
+ },
21
+ });
22
+
23
+ export const put = internalMutation({
24
+ args: { keyHash: v.string(), token: v.string(), expiresAt: v.number() },
25
+ returns: v.null(),
26
+ handler: async (ctx, args) => {
27
+ const row = await ctx.db
28
+ .query("sessions")
29
+ .withIndex("by_key_hash", (q) => q.eq("keyHash", args.keyHash))
30
+ .unique();
31
+ if (row) {
32
+ // Two actions can exchange at the same time. Keep the token that lives
33
+ // longer, so a slow writer never replaces a fresher one.
34
+ if (row.expiresAt < args.expiresAt) {
35
+ await ctx.db.patch("sessions", row._id, {
36
+ token: args.token,
37
+ expiresAt: args.expiresAt,
38
+ });
39
+ }
40
+ } else {
41
+ await ctx.db.insert("sessions", args);
42
+ }
43
+ return null;
44
+ },
45
+ });
46
+
47
+ /** Forget a token boxd rejected, so the next call exchanges the key again. */
48
+ export const drop = internalMutation({
49
+ args: { keyHash: v.string(), token: v.string() },
50
+ returns: v.null(),
51
+ handler: async (ctx, { keyHash, token }) => {
52
+ const row = await ctx.db
53
+ .query("sessions")
54
+ .withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash))
55
+ .unique();
56
+ // Only drop the token that failed: another action may already have
57
+ // stored a fresh one.
58
+ if (row && row.token === token) await ctx.db.delete("sessions", row._id);
59
+ return null;
60
+ },
61
+ });