@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.
- package/LICENSE +7 -0
- package/README.md +22 -2
- package/dist/Backend.d.ts +45 -0
- package/dist/Backend.d.ts.map +1 -0
- package/dist/Backend.js +38 -0
- package/dist/Backend.js.map +1 -0
- package/dist/Code.d.ts +80 -0
- package/dist/Code.d.ts.map +1 -0
- package/dist/Code.js +29 -0
- package/dist/Code.js.map +1 -0
- package/dist/Confect.d.ts +2 -0
- package/dist/Confect.d.ts.map +1 -0
- package/dist/Confect.js +8 -0
- package/dist/Confect.js.map +1 -0
- package/dist/Convex.d.ts +7 -0
- package/dist/Convex.d.ts.map +1 -0
- package/dist/Convex.js +21 -0
- package/dist/Convex.js.map +1 -0
- package/dist/ConvexClient.d.ts +215 -0
- package/dist/ConvexClient.d.ts.map +1 -0
- package/dist/ConvexClient.js +224 -0
- package/dist/ConvexClient.js.map +1 -0
- package/dist/DeployKey.d.ts +120 -0
- package/dist/DeployKey.d.ts.map +1 -0
- package/dist/DeployKey.js +151 -0
- package/dist/DeployKey.js.map +1 -0
- package/dist/Deployment.d.ts +113 -0
- package/dist/Deployment.d.ts.map +1 -0
- package/dist/Deployment.js +160 -0
- package/dist/Deployment.js.map +1 -0
- package/dist/EnvironmentVariables.d.ts +155 -0
- package/dist/EnvironmentVariables.d.ts.map +1 -0
- package/dist/EnvironmentVariables.js +135 -0
- package/dist/EnvironmentVariables.js.map +1 -0
- package/dist/Project.d.ts +100 -0
- package/dist/Project.d.ts.map +1 -0
- package/dist/Project.js +123 -0
- package/dist/Project.js.map +1 -0
- package/dist/Providers.d.ts +17 -0
- package/dist/Providers.d.ts.map +1 -0
- package/dist/Providers.js +46 -0
- package/dist/Providers.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/internal/CodeDeployment.d.ts +17 -0
- package/dist/internal/CodeDeployment.d.ts.map +1 -0
- package/dist/internal/CodeDeployment.js +53 -0
- package/dist/internal/CodeDeployment.js.map +1 -0
- package/dist/internal/CommandRunner.d.ts +55 -0
- package/dist/internal/CommandRunner.d.ts.map +1 -0
- package/dist/internal/CommandRunner.js +126 -0
- package/dist/internal/CommandRunner.js.map +1 -0
- package/dist/internal/FileLock.d.ts +30 -0
- package/dist/internal/FileLock.d.ts.map +1 -0
- package/dist/internal/FileLock.js +83 -0
- package/dist/internal/FileLock.js.map +1 -0
- package/dist/internal/Lifecycle.d.ts +11 -0
- package/dist/internal/Lifecycle.d.ts.map +1 -0
- package/dist/internal/Lifecycle.js +45 -0
- package/dist/internal/Lifecycle.js.map +1 -0
- package/dist/internal/LocalBackend.d.ts +63 -0
- package/dist/internal/LocalBackend.d.ts.map +1 -0
- package/dist/internal/LocalBackend.js +333 -0
- package/dist/internal/LocalBackend.js.map +1 -0
- package/dist/internal/LocalSidecar.d.ts +3 -0
- package/dist/internal/LocalSidecar.d.ts.map +1 -0
- package/dist/internal/LocalSidecar.js +7 -0
- package/dist/internal/LocalSidecar.js.map +1 -0
- package/dist/internal/Loopback.d.ts +18 -0
- package/dist/internal/Loopback.d.ts.map +1 -0
- package/dist/internal/Loopback.js +31 -0
- package/dist/internal/Loopback.js.map +1 -0
- package/dist/internal/PushLock.d.ts +36 -0
- package/dist/internal/PushLock.d.ts.map +1 -0
- package/dist/internal/PushLock.js +61 -0
- package/dist/internal/PushLock.js.map +1 -0
- package/dist/internal/ResourceIdentity.d.ts +3 -0
- package/dist/internal/ResourceIdentity.d.ts.map +1 -0
- package/dist/internal/ResourceIdentity.js +10 -0
- package/dist/internal/ResourceIdentity.js.map +1 -0
- package/dist/internal/UserCache.d.ts +12 -0
- package/dist/internal/UserCache.d.ts.map +1 -0
- package/dist/internal/UserCache.js +25 -0
- package/dist/internal/UserCache.js.map +1 -0
- package/dist/internal/Zip.d.ts +15 -0
- package/dist/internal/Zip.d.ts.map +1 -0
- package/dist/internal/Zip.js +65 -0
- package/dist/internal/Zip.js.map +1 -0
- package/package.json +65 -3
- package/src/Backend.ts +101 -0
- package/src/Code.ts +55 -0
- package/src/Confect.ts +1 -0
- package/src/Convex.ts +18 -0
- package/src/ConvexClient.ts +544 -0
- package/src/DeployKey.ts +256 -0
- package/src/Deployment.ts +277 -0
- package/src/EnvironmentVariables.ts +254 -0
- package/src/Project.ts +205 -0
- package/src/Providers.ts +67 -0
- package/src/index.ts +2 -0
- package/src/internal/CodeDeployment.ts +81 -0
- package/src/internal/CommandRunner.ts +251 -0
- package/src/internal/FileLock.ts +136 -0
- package/src/internal/Lifecycle.ts +49 -0
- package/src/internal/LocalBackend.ts +492 -0
- package/src/internal/LocalSidecar.ts +9 -0
- package/src/internal/Loopback.ts +34 -0
- package/src/internal/PushLock.ts +99 -0
- package/src/internal/ResourceIdentity.ts +20 -0
- package/src/internal/UserCache.ts +25 -0
- 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
|
+
});
|