@gaganref/confect-alchemy 0.0.0-stage → 10.3.1-fork.1

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 (113) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +22 -2
  3. package/dist/Backend.d.ts +45 -0
  4. package/dist/Backend.d.ts.map +1 -0
  5. package/dist/Backend.js +38 -0
  6. package/dist/Backend.js.map +1 -0
  7. package/dist/Code.d.ts +80 -0
  8. package/dist/Code.d.ts.map +1 -0
  9. package/dist/Code.js +29 -0
  10. package/dist/Code.js.map +1 -0
  11. package/dist/Confect.d.ts +2 -0
  12. package/dist/Confect.d.ts.map +1 -0
  13. package/dist/Confect.js +8 -0
  14. package/dist/Confect.js.map +1 -0
  15. package/dist/Convex.d.ts +7 -0
  16. package/dist/Convex.d.ts.map +1 -0
  17. package/dist/Convex.js +21 -0
  18. package/dist/Convex.js.map +1 -0
  19. package/dist/ConvexClient.d.ts +215 -0
  20. package/dist/ConvexClient.d.ts.map +1 -0
  21. package/dist/ConvexClient.js +224 -0
  22. package/dist/ConvexClient.js.map +1 -0
  23. package/dist/DeployKey.d.ts +120 -0
  24. package/dist/DeployKey.d.ts.map +1 -0
  25. package/dist/DeployKey.js +151 -0
  26. package/dist/DeployKey.js.map +1 -0
  27. package/dist/Deployment.d.ts +113 -0
  28. package/dist/Deployment.d.ts.map +1 -0
  29. package/dist/Deployment.js +160 -0
  30. package/dist/Deployment.js.map +1 -0
  31. package/dist/EnvironmentVariables.d.ts +155 -0
  32. package/dist/EnvironmentVariables.d.ts.map +1 -0
  33. package/dist/EnvironmentVariables.js +135 -0
  34. package/dist/EnvironmentVariables.js.map +1 -0
  35. package/dist/Project.d.ts +100 -0
  36. package/dist/Project.d.ts.map +1 -0
  37. package/dist/Project.js +123 -0
  38. package/dist/Project.js.map +1 -0
  39. package/dist/Providers.d.ts +17 -0
  40. package/dist/Providers.d.ts.map +1 -0
  41. package/dist/Providers.js +46 -0
  42. package/dist/Providers.js.map +1 -0
  43. package/dist/_virtual/_rolldown/runtime.js +13 -0
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +3 -0
  47. package/dist/internal/CodeDeployment.d.ts +17 -0
  48. package/dist/internal/CodeDeployment.d.ts.map +1 -0
  49. package/dist/internal/CodeDeployment.js +53 -0
  50. package/dist/internal/CodeDeployment.js.map +1 -0
  51. package/dist/internal/CommandRunner.d.ts +55 -0
  52. package/dist/internal/CommandRunner.d.ts.map +1 -0
  53. package/dist/internal/CommandRunner.js +126 -0
  54. package/dist/internal/CommandRunner.js.map +1 -0
  55. package/dist/internal/FileLock.d.ts +30 -0
  56. package/dist/internal/FileLock.d.ts.map +1 -0
  57. package/dist/internal/FileLock.js +83 -0
  58. package/dist/internal/FileLock.js.map +1 -0
  59. package/dist/internal/Lifecycle.d.ts +11 -0
  60. package/dist/internal/Lifecycle.d.ts.map +1 -0
  61. package/dist/internal/Lifecycle.js +45 -0
  62. package/dist/internal/Lifecycle.js.map +1 -0
  63. package/dist/internal/LocalBackend.d.ts +63 -0
  64. package/dist/internal/LocalBackend.d.ts.map +1 -0
  65. package/dist/internal/LocalBackend.js +333 -0
  66. package/dist/internal/LocalBackend.js.map +1 -0
  67. package/dist/internal/LocalSidecar.d.ts +3 -0
  68. package/dist/internal/LocalSidecar.d.ts.map +1 -0
  69. package/dist/internal/LocalSidecar.js +7 -0
  70. package/dist/internal/LocalSidecar.js.map +1 -0
  71. package/dist/internal/Loopback.d.ts +18 -0
  72. package/dist/internal/Loopback.d.ts.map +1 -0
  73. package/dist/internal/Loopback.js +31 -0
  74. package/dist/internal/Loopback.js.map +1 -0
  75. package/dist/internal/PushLock.d.ts +36 -0
  76. package/dist/internal/PushLock.d.ts.map +1 -0
  77. package/dist/internal/PushLock.js +61 -0
  78. package/dist/internal/PushLock.js.map +1 -0
  79. package/dist/internal/ResourceIdentity.d.ts +3 -0
  80. package/dist/internal/ResourceIdentity.d.ts.map +1 -0
  81. package/dist/internal/ResourceIdentity.js +10 -0
  82. package/dist/internal/ResourceIdentity.js.map +1 -0
  83. package/dist/internal/UserCache.d.ts +12 -0
  84. package/dist/internal/UserCache.d.ts.map +1 -0
  85. package/dist/internal/UserCache.js +25 -0
  86. package/dist/internal/UserCache.js.map +1 -0
  87. package/dist/internal/Zip.d.ts +15 -0
  88. package/dist/internal/Zip.d.ts.map +1 -0
  89. package/dist/internal/Zip.js +65 -0
  90. package/dist/internal/Zip.js.map +1 -0
  91. package/package.json +65 -3
  92. package/src/Backend.ts +101 -0
  93. package/src/Code.ts +55 -0
  94. package/src/Confect.ts +1 -0
  95. package/src/Convex.ts +18 -0
  96. package/src/ConvexClient.ts +544 -0
  97. package/src/DeployKey.ts +256 -0
  98. package/src/Deployment.ts +277 -0
  99. package/src/EnvironmentVariables.ts +254 -0
  100. package/src/Project.ts +205 -0
  101. package/src/Providers.ts +67 -0
  102. package/src/index.ts +2 -0
  103. package/src/internal/CodeDeployment.ts +81 -0
  104. package/src/internal/CommandRunner.ts +251 -0
  105. package/src/internal/FileLock.ts +136 -0
  106. package/src/internal/Lifecycle.ts +49 -0
  107. package/src/internal/LocalBackend.ts +492 -0
  108. package/src/internal/LocalSidecar.ts +9 -0
  109. package/src/internal/Loopback.ts +34 -0
  110. package/src/internal/PushLock.ts +99 -0
  111. package/src/internal/ResourceIdentity.ts +20 -0
  112. package/src/internal/UserCache.ts +25 -0
  113. package/src/internal/Zip.ts +108 -0
@@ -0,0 +1,492 @@
1
+ import { createServer, type Server } from "node:net";
2
+ import { dotAlchemyDirectory } from "alchemy/AlchemyContext";
3
+ import { Stack } from "alchemy/Stack";
4
+ import * as Config from "effect/Config";
5
+ import * as Crypto from "effect/Crypto";
6
+ import * as Deferred from "effect/Deferred";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Exit from "effect/Exit";
9
+ import * as FileSystem from "effect/FileSystem";
10
+ import * as Option from "effect/Option";
11
+ import * as Path from "effect/Path";
12
+ import * as Redacted from "effect/Redacted";
13
+ import * as Schedule from "effect/Schedule";
14
+ import * as Schema from "effect/Schema";
15
+ import * as Scope from "effect/Scope";
16
+ import * as Semaphore from "effect/Semaphore";
17
+ import * as Stream from "effect/Stream";
18
+ import * as HttpClient from "effect/http/HttpClient";
19
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
20
+ import { processEnvironment } from "./CommandRunner";
21
+ import * as FileLock from "./FileLock";
22
+ import * as Loopback from "./Loopback";
23
+ import { userCache } from "./UserCache";
24
+ import { extract } from "./Zip";
25
+
26
+ /**
27
+ * The backend release a new local deployment runs. An existing data directory
28
+ * keeps running the release that created it, so upgrading this package never
29
+ * opens a database with a different release.
30
+ */
31
+ export const backendVersion = "precompiled-2026-10-06-a3538c6";
32
+
33
+ export class LocalBackendError extends Schema.TaggedError<LocalBackendError>()(
34
+ "LocalBackendError",
35
+ {
36
+ deploymentName: Schema.String,
37
+ reason: Schema.Literals([
38
+ "configuration",
39
+ "filesystem",
40
+ "install",
41
+ "identity",
42
+ "ports",
43
+ "running",
44
+ "spawn",
45
+ "exit",
46
+ "timeout",
47
+ ]),
48
+ exitCode: Schema.optionalKey(Schema.Finite),
49
+ },
50
+ ) {
51
+ override get message() {
52
+ const subject = `Local backend for ${this.deploymentName}`;
53
+ switch (this.reason) {
54
+ case "configuration":
55
+ return `${subject} needs a home directory and, when set, CONFECT_LOCAL_BACKEND_PORTS as a range such as 3300-3399`;
56
+ case "filesystem":
57
+ return `${subject} could not read or write its data directory`;
58
+ case "install":
59
+ return `${subject} could not download or install the backend binary`;
60
+ case "identity":
61
+ return `${subject} could not read, create, or save its identity`;
62
+ case "ports":
63
+ return `${subject} could not get its ports; another process is using them`;
64
+ case "running":
65
+ return `${subject} is already running: stop the other alchemy dev session, or the backend process left over from one, that serves its data directory`;
66
+ case "spawn":
67
+ return `${subject} could not be started`;
68
+ case "exit":
69
+ return `${subject} exited${this.exitCode === undefined ? "" : ` with code ${this.exitCode}`} before it was ready`;
70
+ case "timeout":
71
+ return `${subject} did not become ready in time`;
72
+ }
73
+ }
74
+ }
75
+
76
+ export interface LocalBackend {
77
+ readonly url: string;
78
+ readonly siteUrl: string;
79
+ readonly adminKey: Redacted.Redacted<string>;
80
+ /**
81
+ * Completes when the backend process has exited and released its data
82
+ * directory, so the deployment can be booted again.
83
+ */
84
+ readonly exited: Effect.Effect<void>;
85
+ }
86
+
87
+ // Ports are saved once a backend has answered on them, so a first start that
88
+ // fails picks fresh ports next time instead of keeping ones that never worked.
89
+ const Identity = Schema.fromJsonString(
90
+ Schema.Struct({
91
+ name: Schema.String,
92
+ version: Schema.String,
93
+ instanceSecret: Schema.String,
94
+ adminKey: Schema.String,
95
+ ports: Schema.optionalKey(
96
+ Schema.Struct({ port: Schema.Int, sitePort: Schema.Int }),
97
+ ),
98
+ }),
99
+ );
100
+ type Identity = typeof Identity.Type;
101
+
102
+ // The release archive per platform, as the Convex CLI maps it
103
+ // (convex-js src/cli/lib/localDeployment/download.ts getDownloadPath).
104
+ const artifacts: Readonly<Record<string, string>> = {
105
+ "darwin-arm64": "convex-local-backend-aarch64-apple-darwin.zip",
106
+ "darwin-x64": "convex-local-backend-x86_64-apple-darwin.zip",
107
+ "linux-arm64": "convex-local-backend-aarch64-unknown-linux-gnu.zip",
108
+ "linux-x64": "convex-local-backend-x86_64-unknown-linux-gnu.zip",
109
+ "win32-arm64": "convex-local-backend-x86_64-pc-windows-msvc.zip",
110
+ "win32-x64": "convex-local-backend-x86_64-pc-windows-msvc.zip",
111
+ };
112
+ const executable =
113
+ process.platform === "win32"
114
+ ? "convex-local-backend.exe"
115
+ : "convex-local-backend";
116
+
117
+ const close = (server: Server) =>
118
+ Effect.callback<void>((resume) => {
119
+ server.close(() => resume(Effect.void));
120
+ });
121
+
122
+ // A server listening on `port`, or undefined when the port is taken, closed
123
+ // with the scope. Listening is uninterruptible, so an interrupted probe never
124
+ // leaves a server bound to the port.
125
+ const listen = (port: number) =>
126
+ Effect.acquireRelease(
127
+ Effect.callback<Server | undefined>((resume) => {
128
+ const server = createServer();
129
+ server.once("error", () => resume(Effect.undefined));
130
+ server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server)));
131
+ }),
132
+ (server) => (server ? close(server) : Effect.void),
133
+ );
134
+
135
+ const isFree = (port: number) =>
136
+ listen(port).pipe(
137
+ Effect.map((server) => server !== undefined),
138
+ Effect.scoped,
139
+ );
140
+
141
+ const portOf = (server: Server | undefined) => {
142
+ const address = server?.address();
143
+ return typeof address === "object" && address !== null
144
+ ? address.port
145
+ : undefined;
146
+ };
147
+
148
+ // Backends in one process start one at a time, so two deployments starting
149
+ // together never probe and claim the same ports.
150
+ const startup = Semaphore.makeUnsafe(1);
151
+
152
+ /**
153
+ * Resolves the directory that holds one local deployment's identity, database,
154
+ * and file storage: `.alchemy/convex/<stack>/<stage>/<deployment>`. Keyed by
155
+ * the deployment name because a deploy key resource only knows that name.
156
+ */
157
+ export const dataDirectories = Effect.gen(function* () {
158
+ const path = yield* Path.Path;
159
+ const stack = yield* Stack;
160
+ const root = path.resolve(
161
+ yield* dotAlchemyDirectory,
162
+ "convex",
163
+ stack.name,
164
+ stack.stage,
165
+ );
166
+ return (deploymentName: string) => path.join(root, deploymentName);
167
+ });
168
+
169
+ const readIdentity = Effect.fn("LocalBackend.readIdentity")(function* (
170
+ deploymentName: string,
171
+ directory: string,
172
+ ) {
173
+ const fs = yield* FileSystem.FileSystem;
174
+ const path = yield* Path.Path;
175
+ const file = path.join(directory, "identity.json");
176
+ const invalid = () =>
177
+ new LocalBackendError({ deploymentName, reason: "identity" });
178
+ if (!(yield* fs.exists(file).pipe(Effect.mapError(invalid))))
179
+ return Option.none<Identity>();
180
+ const identity = yield* fs
181
+ .readFileString(file)
182
+ .pipe(
183
+ Effect.flatMap(Schema.decodeEffect(Identity)),
184
+ Effect.mapError(invalid),
185
+ );
186
+ if (identity.name !== deploymentName) return yield* invalid();
187
+ return Option.some(identity);
188
+ });
189
+
190
+ /**
191
+ * The admin key of a local deployment, for its deploy key resource.
192
+ */
193
+ export const adminKey = Effect.fn("LocalBackend.adminKey")(function* (
194
+ deploymentName: string,
195
+ directory: string,
196
+ ) {
197
+ const identity = yield* readIdentity(deploymentName, directory);
198
+ if (Option.isNone(identity))
199
+ return yield* new LocalBackendError({ deploymentName, reason: "identity" });
200
+ return Redacted.make(identity.value.adminKey);
201
+ });
202
+
203
+ export const remove = Effect.fn("LocalBackend.remove")(function* (
204
+ deploymentName: string,
205
+ directory: string,
206
+ ) {
207
+ const fs = yield* FileSystem.FileSystem;
208
+ yield* fs
209
+ .remove(directory, { recursive: true, force: true })
210
+ .pipe(
211
+ Effect.mapError(
212
+ () => new LocalBackendError({ deploymentName, reason: "filesystem" }),
213
+ ),
214
+ );
215
+ });
216
+
217
+ /**
218
+ * Install a backend release into the Convex CLI's binary cache,
219
+ * `<cache>/convex/binaries/<version>/`, unless it is already there.
220
+ */
221
+ const install = Effect.fn("LocalBackend.install")(function* (
222
+ deploymentName: string,
223
+ version: string,
224
+ cache: string,
225
+ ) {
226
+ const fs = yield* FileSystem.FileSystem;
227
+ const path = yield* Path.Path;
228
+ const http = yield* HttpClient.HttpClient;
229
+ const failed = () =>
230
+ new LocalBackendError({ deploymentName, reason: "install" });
231
+ const binaries = path.join(cache, "convex", "binaries");
232
+ const binary = path.join(binaries, version, executable);
233
+ const artifact = artifacts[`${process.platform}-${process.arch}`];
234
+ return yield* Effect.gen(function* () {
235
+ if (yield* fs.exists(binary)) return binary;
236
+ if (artifact === undefined) return yield* failed();
237
+ yield* fs.makeDirectory(binaries, { recursive: true });
238
+ const temp = yield* fs.makeTempDirectoryScoped({
239
+ directory: binaries,
240
+ prefix: ".download-",
241
+ });
242
+ const archive = path.join(temp, artifact);
243
+ const response = yield* http.get(
244
+ `https://github.com/get-convex/convex-backend/releases/download/${version}/${artifact}`,
245
+ );
246
+ if (response.status !== 200) return yield* failed();
247
+ yield* response.stream.pipe(Stream.run(fs.sink(archive)));
248
+ const extracted = path.join(temp, executable);
249
+ yield* extract(archive, executable, extracted);
250
+ yield* fs.chmod(extracted, 0o755);
251
+ yield* fs.makeDirectory(path.dirname(binary), { recursive: true });
252
+ yield* fs.rename(extracted, binary);
253
+ return binary;
254
+ }).pipe(Effect.scoped, Effect.mapError(failed));
255
+ });
256
+
257
+ /**
258
+ * Boot the local backend for one deployment and keep it running for the ambient
259
+ * scope. The process and the data directory's `owner.lock` share a child scope
260
+ * that closes when the boot fails or is interrupted and when the process exits,
261
+ * so a deployment can be booted again in the same session. The first boot
262
+ * installs the pinned release, generates an instance secret and admin key, and
263
+ * picks two free loopback ports; later boots reuse all of them, so stored
264
+ * credentials and URLs stay valid. A saved port that something else now holds
265
+ * is an error rather than a move, because resources keep the URL.
266
+ */
267
+ export const boot = Effect.fn("LocalBackend.boot")(function* (
268
+ deploymentName: string,
269
+ directory: string,
270
+ ) {
271
+ const path = yield* Path.Path;
272
+ const fs = yield* FileSystem.FileSystem;
273
+ const http = yield* HttpClient.HttpClient;
274
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
275
+ const crypto = yield* Crypto.Crypto;
276
+ const fail = (reason: LocalBackendError["reason"], exitCode?: number) =>
277
+ new LocalBackendError({
278
+ deploymentName,
279
+ reason,
280
+ ...(exitCode === undefined ? {} : { exitCode }),
281
+ });
282
+ const settings = yield* Config.all({
283
+ environment: processEnvironment,
284
+ ports: Config.String("CONFECT_LOCAL_BACKEND_PORTS").pipe(Config.option),
285
+ }).pipe(Effect.mapError(() => fail("configuration")));
286
+ const cache = yield* userCache.pipe(
287
+ Effect.mapError(() => fail("configuration")),
288
+ );
289
+ if (Option.isNone(cache)) return yield* fail("configuration");
290
+ const range = Option.map(settings.ports, Loopback.parsePortRange);
291
+ if (Option.isSome(range) && Option.isNone(range.value))
292
+ return yield* fail("configuration");
293
+ const env = {
294
+ ...settings.environment,
295
+ DISABLE_BEACON: "1",
296
+ DO_NOT_TRACK: "1",
297
+ };
298
+
299
+ const answer = (url: string) =>
300
+ http.get(`${url}/instance_name`).pipe(
301
+ Effect.flatMap((response) =>
302
+ response.status === 200 ? response.text : Effect.undefined,
303
+ ),
304
+ Effect.orElseSucceed(() => undefined),
305
+ );
306
+
307
+ const allocate = Effect.gen(function* () {
308
+ if (Option.isNone(range) || Option.isNone(range.value)) {
309
+ // A closed server no longer has an address.
310
+ const [port, sitePort] = yield* Effect.all([listen(0), listen(0)]).pipe(
311
+ Effect.map((servers) => servers.map(portOf)),
312
+ Effect.scoped,
313
+ );
314
+ if (port === undefined || sitePort === undefined)
315
+ return yield* fail("ports");
316
+ return { port, sitePort };
317
+ }
318
+ const free: number[] = [];
319
+ const { from, to } = range.value.value;
320
+ for (let port = from; port <= to && free.length < 2; port++) {
321
+ if (yield* isFree(port)) free.push(port);
322
+ }
323
+ const [port, sitePort] = free;
324
+ if (port === undefined || sitePort === undefined)
325
+ return yield* fail("ports");
326
+ return { port, sitePort };
327
+ });
328
+
329
+ const generate = (binary: string) =>
330
+ Effect.gen(function* () {
331
+ const instanceSecret = Buffer.from(
332
+ yield* crypto.randomBytes(32),
333
+ ).toString("hex");
334
+ const output = yield* spawner.string(
335
+ ChildProcess.make(
336
+ binary,
337
+ [
338
+ "keygen",
339
+ "admin-key",
340
+ "--instance-name",
341
+ deploymentName,
342
+ "--instance-secret",
343
+ instanceSecret,
344
+ ],
345
+ { env, extendEnv: false, stdin: "ignore", stderr: "ignore" },
346
+ ),
347
+ );
348
+ const key = output.trim();
349
+ if (!key.startsWith(`${deploymentName}|`)) return yield* fail("identity");
350
+ return { instanceSecret, adminKey: key };
351
+ }).pipe(
352
+ Effect.catchTag("PlatformError", () => Effect.fail(fail("identity"))),
353
+ );
354
+
355
+ const save = (identity: Identity) =>
356
+ Effect.gen(function* () {
357
+ const file = path.join(directory, "identity.json");
358
+ const temp = `${file}.${process.pid}.tmp`;
359
+ yield* fs.writeFileString(
360
+ temp,
361
+ yield* Schema.encodeEffect(Identity)(identity),
362
+ { mode: 0o600 },
363
+ );
364
+ yield* fs.rename(temp, file);
365
+ }).pipe(
366
+ // Node cannot cancel a write or rename it has started; finishing it
367
+ // keeps a pending rename from landing after owner.lock is released.
368
+ Effect.uninterruptible,
369
+ Effect.mapError(() => fail("identity")),
370
+ );
371
+
372
+ const session = yield* Scope.Scope;
373
+ const owned = yield* Scope.fork(session);
374
+ const stopped = yield* Deferred.make<void>();
375
+ // Registered first, so it runs after the process and owner.lock finalizers
376
+ // however the scope closes.
377
+ yield* Scope.addFinalizer(owned, Deferred.succeed(stopped, undefined));
378
+ const start = Effect.gen(function* () {
379
+ yield* FileLock.acquire(path.join(directory, "owner.lock")).pipe(
380
+ Effect.mapError((error) =>
381
+ fail(error.reason === "held" ? "running" : "filesystem"),
382
+ ),
383
+ );
384
+ const saved = yield* readIdentity(deploymentName, directory);
385
+ const version = Option.isSome(saved) ? saved.value.version : backendVersion;
386
+ const binary = yield* install(deploymentName, version, cache.value);
387
+ let identity: Identity;
388
+ if (Option.isSome(saved)) {
389
+ identity = saved.value;
390
+ } else {
391
+ identity = {
392
+ name: deploymentName,
393
+ version,
394
+ ...(yield* generate(binary)),
395
+ };
396
+ yield* save(identity);
397
+ }
398
+ const ports = identity.ports ?? (yield* allocate);
399
+ if (identity.ports !== undefined) {
400
+ if (!(yield* isFree(ports.port)) || !(yield* isFree(ports.sitePort)))
401
+ return yield* fail(
402
+ (yield* answer(Loopback.origin(ports.port))) === deploymentName
403
+ ? "running"
404
+ : "ports",
405
+ );
406
+ }
407
+
408
+ const url = Loopback.origin(ports.port);
409
+ const siteUrl = Loopback.origin(ports.sitePort);
410
+ const handle = yield* spawner
411
+ .spawn(
412
+ ChildProcess.make(
413
+ binary,
414
+ [
415
+ "--interface",
416
+ "127.0.0.1",
417
+ "--port",
418
+ String(ports.port),
419
+ "--site-proxy-port",
420
+ String(ports.sitePort),
421
+ "--instance-name",
422
+ deploymentName,
423
+ "--instance-secret",
424
+ identity.instanceSecret,
425
+ "--convex-origin",
426
+ url,
427
+ "--convex-site",
428
+ siteUrl,
429
+ "--local-storage",
430
+ path.join(directory, "convex_local_storage"),
431
+ "--disable-beacon",
432
+ path.join(directory, "convex_local_backend.sqlite3"),
433
+ ],
434
+ {
435
+ cwd: directory,
436
+ env,
437
+ extendEnv: false,
438
+ stdin: "ignore",
439
+ stdout: "ignore",
440
+ stderr: "ignore",
441
+ forceKillAfter: "3 seconds",
442
+ },
443
+ ),
444
+ )
445
+ .pipe(Effect.mapError(() => fail("spawn")));
446
+
447
+ const ready = answer(url).pipe(
448
+ Effect.flatMap((name) =>
449
+ name === undefined || name === deploymentName
450
+ ? Effect.succeed(name === deploymentName)
451
+ : Effect.fail(fail("ports")),
452
+ ),
453
+ Effect.repeat({
454
+ until: (isReady) => isReady,
455
+ schedule: Schedule.spaced("100 millis"),
456
+ }),
457
+ );
458
+ const exit = handle.exitCode.pipe(
459
+ Effect.mapError(() => fail("exit")),
460
+ Effect.flatMap((code) => Effect.fail(fail("exit", Number(code)))),
461
+ );
462
+ yield* Effect.raceFirst(ready, exit).pipe(
463
+ Effect.timeoutOrElse({
464
+ duration: "60 seconds",
465
+ orElse: () => Effect.fail(fail("timeout")),
466
+ }),
467
+ );
468
+ if (identity.ports === undefined) yield* save({ ...identity, ports });
469
+ // An exited backend releases the data directory before `exited`
470
+ // completes, so the deployment can be booted again in this session.
471
+ yield* handle.exitCode.pipe(
472
+ Effect.ignore,
473
+ Effect.andThen(Scope.close(owned, Exit.void)),
474
+ Effect.forkIn(session),
475
+ );
476
+ return {
477
+ url,
478
+ siteUrl,
479
+ adminKey: Redacted.make(identity.adminKey),
480
+ exited: Deferred.await(stopped),
481
+ } satisfies LocalBackend;
482
+ });
483
+
484
+ return yield* startup
485
+ .withPermits(1)(start)
486
+ .pipe(
487
+ Scope.provide(owned),
488
+ Effect.onExit((exit) =>
489
+ Exit.isSuccess(exit) ? Effect.void : Scope.close(owned, exit),
490
+ ),
491
+ );
492
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The local Deployment provider served by Alchemy's dev sidecar, so a local
3
+ * backend outlives reloads of the stack. Alchemy imports this module by URL and
4
+ * builds its default export once per dev session.
5
+ */
6
+ import type * as RpcServer from "alchemy/Local/RpcServer";
7
+ import { localLayer } from "../Deployment";
8
+
9
+ export default localLayer satisfies RpcServer.ProviderLayer;
@@ -0,0 +1,34 @@
1
+ import * as Option from "effect/Option";
2
+
3
+ /**
4
+ * The origin of a local backend port. Local backends bind only loopback.
5
+ */
6
+ export const origin = (port: number) => `http://127.0.0.1:${port}`;
7
+
8
+ const isPort = (port: number) => port >= 1 && port <= 65535;
9
+
10
+ /**
11
+ * Whether `url` is exactly a loopback origin such as `http://127.0.0.1:3210`.
12
+ */
13
+ export const isOrigin = (url: string) => {
14
+ const match = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.exec(url);
15
+ return match !== null && isPort(Number(match[1]));
16
+ };
17
+
18
+ export interface PortRange {
19
+ readonly from: number;
20
+ readonly to: number;
21
+ }
22
+
23
+ /**
24
+ * Parse an inclusive `from-to` range of usable ports, such as `3300-3399`.
25
+ */
26
+ export const parsePortRange = (value: string): Option.Option<PortRange> => {
27
+ const match = /^([0-9]{1,5})-([0-9]{1,5})$/.exec(value);
28
+ if (match === null) return Option.none();
29
+ const from = Number(match[1]);
30
+ const to = Number(match[2]);
31
+ return isPort(from) && isPort(to) && from <= to
32
+ ? Option.some({ from, to })
33
+ : Option.none();
34
+ };
@@ -0,0 +1,99 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Crypto from "effect/Crypto";
3
+ import * as Duration from "effect/Duration";
4
+ import * as Effect from "effect/Effect";
5
+ import * as FileSystem from "effect/FileSystem";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Option from "effect/Option";
8
+ import * as Path from "effect/Path";
9
+ import * as Schema from "effect/Schema";
10
+ import * as Scope from "effect/Scope";
11
+ import * as FileLock from "./FileLock";
12
+ import { userCache } from "./UserCache";
13
+
14
+ export class PushLockError extends Schema.TaggedError<PushLockError>()(
15
+ "PushLockError",
16
+ {
17
+ deploymentName: Schema.String,
18
+ reason: Schema.Literals(["configuration", "timeout", "filesystem"]),
19
+ },
20
+ ) {
21
+ override get message() {
22
+ switch (this.reason) {
23
+ case "configuration":
24
+ return `Push lock for ${this.deploymentName} needs a home directory to keep its lock file in`;
25
+ case "timeout":
26
+ return `Timed out waiting for another push to ${this.deploymentName} to release its push lock`;
27
+ case "filesystem":
28
+ return `Push lock for ${this.deploymentName} could not read or write its lock file`;
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Serialises pushes per deployment. Overlapping `convex deploy` runs against
35
+ * one deployment race on `wait_for_schema`, so every push holds the lock for
36
+ * its whole duration, whichever stack, checkout, or process it comes from.
37
+ */
38
+ export class PushLock extends Context.Service<
39
+ PushLock,
40
+ {
41
+ readonly withLock: <A, E, R>(
42
+ deploymentName: string,
43
+ effect: Effect.Effect<A, E, R>,
44
+ ) => Effect.Effect<A, E | PushLockError, R>;
45
+ }
46
+ >()("@confect/alchemy/internal/PushLock") {}
47
+
48
+ /**
49
+ * One lock file per deployment under the user's cache,
50
+ * `confect/alchemy/locks/<deployment>.lock`, so every checkout on the machine
51
+ * shares it. A lock left by a process that exited is taken over; a holder that
52
+ * never releases ends in a typed timeout.
53
+ */
54
+ export const layer = (options?: { readonly timeout?: Duration.Input }) =>
55
+ Layer.effect(
56
+ PushLock,
57
+ Effect.gen(function* () {
58
+ const fs = yield* FileSystem.FileSystem;
59
+ const path = yield* Path.Path;
60
+ const crypto = yield* Crypto.Crypto;
61
+ const timeout = options?.timeout ?? Duration.minutes(10);
62
+ const lock = Effect.fn("PushLock.lock")(
63
+ function* (deploymentName: string, scope: Scope.Scope) {
64
+ const failed = (reason: PushLockError["reason"]) =>
65
+ new PushLockError({ deploymentName, reason });
66
+ const cache = yield* userCache.pipe(
67
+ Effect.mapError(() => failed("configuration")),
68
+ );
69
+ if (Option.isNone(cache)) return yield* failed("configuration");
70
+ yield* FileLock.acquire(
71
+ path.join(
72
+ cache.value,
73
+ "confect",
74
+ "alchemy",
75
+ "locks",
76
+ `${deploymentName}.lock`,
77
+ ),
78
+ timeout,
79
+ ).pipe(
80
+ Scope.provide(scope),
81
+ Effect.mapError((error) =>
82
+ failed(error.reason === "timeout" ? "timeout" : "filesystem"),
83
+ ),
84
+ );
85
+ },
86
+ Effect.provideService(FileSystem.FileSystem, fs),
87
+ Effect.provideService(Path.Path, path),
88
+ Effect.provideService(Crypto.Crypto, crypto),
89
+ );
90
+ return PushLock.of({
91
+ withLock: (deploymentName, effect) =>
92
+ Effect.acquireUseRelease(
93
+ Scope.make(),
94
+ (scope) => lock(deploymentName, scope).pipe(Effect.andThen(effect)),
95
+ (scope, exit) => Scope.close(scope, exit),
96
+ ),
97
+ });
98
+ }),
99
+ );
@@ -0,0 +1,20 @@
1
+ import * as Effect from "effect/Effect";
2
+
3
+ export const resourceName = Effect.fn("ResourceIdentity.name")(function* (
4
+ kind: string,
5
+ fqn: string,
6
+ instanceId: string,
7
+ ) {
8
+ const hash = yield* Effect.promise(() =>
9
+ crypto.subtle.digest(
10
+ "SHA-256",
11
+ new TextEncoder().encode(`${fqn}:${instanceId}`),
12
+ ),
13
+ );
14
+ const suffix = Array.from(new Uint8Array(hash), (byte) =>
15
+ byte.toString(16).padStart(2, "0"),
16
+ )
17
+ .join("")
18
+ .slice(0, 24);
19
+ return `confect-${kind}-${suffix}`;
20
+ });
@@ -0,0 +1,25 @@
1
+ import * as Config from "effect/Config";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Option from "effect/Option";
4
+ import * as Path from "effect/Path";
5
+
6
+ /**
7
+ * The user's cache directory, as the Convex CLI resolves it: `~/.cache` on
8
+ * POSIX and `%LOCALAPPDATA%` on Windows. Shared by every checkout on the
9
+ * machine, so binaries are downloaded once and push locks are seen by every
10
+ * stack. None when no absolute directory can be derived.
11
+ */
12
+ export const userCache = Effect.gen(function* () {
13
+ const path = yield* Path.Path;
14
+ const { HOME, LOCALAPPDATA, USERPROFILE } = yield* Config.all({
15
+ HOME: Config.String("HOME").pipe(Config.withDefault("")),
16
+ LOCALAPPDATA: Config.String("LOCALAPPDATA").pipe(Config.withDefault("")),
17
+ USERPROFILE: Config.String("USERPROFILE").pipe(Config.withDefault("")),
18
+ });
19
+ const root =
20
+ process.platform === "win32"
21
+ ? LOCALAPPDATA ||
22
+ (USERPROFILE && path.join(USERPROFILE, "AppData", "Local"))
23
+ : HOME && path.join(HOME, ".cache");
24
+ return root && path.isAbsolute(root) ? Option.some(root) : Option.none();
25
+ });