alchemy 2.0.0-beta.21 → 2.0.0-beta.23
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/bin/alchemy.js +3 -3
- package/bin/alchemy.js.map +1 -1
- package/bin/commands/logs.ts +1 -1
- package/bin/commands/tail.ts +1 -1
- package/lib/AWS/Lambda/Function.d.ts.map +1 -1
- package/lib/AWS/Lambda/Function.js +25 -21
- package/lib/AWS/Lambda/Function.js.map +1 -1
- package/lib/AWS/Lambda/HttpServer.d.ts.map +1 -1
- package/lib/AWS/Lambda/HttpServer.js +3 -1
- package/lib/AWS/Lambda/HttpServer.js.map +1 -1
- package/lib/Cloudflare/Container/Container.d.ts +55 -2
- package/lib/Cloudflare/Container/Container.d.ts.map +1 -1
- package/lib/Cloudflare/Container/Container.js +55 -2
- package/lib/Cloudflare/Container/Container.js.map +1 -1
- package/lib/Cloudflare/D1/D1Clone.d.ts +13 -0
- package/lib/Cloudflare/D1/D1Clone.d.ts.map +1 -0
- package/lib/Cloudflare/D1/D1Clone.js +31 -0
- package/lib/Cloudflare/D1/D1Clone.js.map +1 -0
- package/lib/Cloudflare/D1/D1Database.d.ts +150 -5
- package/lib/Cloudflare/D1/D1Database.d.ts.map +1 -1
- package/lib/Cloudflare/D1/D1Database.js +253 -0
- package/lib/Cloudflare/D1/D1Database.js.map +1 -1
- package/lib/Cloudflare/D1/D1Export.d.ts +24 -0
- package/lib/Cloudflare/D1/D1Export.d.ts.map +1 -0
- package/lib/Cloudflare/D1/D1Export.js +34 -0
- package/lib/Cloudflare/D1/D1Export.js.map +1 -0
- package/lib/Cloudflare/D1/D1Import.d.ts +21 -0
- package/lib/Cloudflare/D1/D1Import.d.ts.map +1 -0
- package/lib/Cloudflare/D1/D1Import.js +87 -0
- package/lib/Cloudflare/D1/D1Import.js.map +1 -0
- package/lib/Cloudflare/D1/D1Migrations.d.ts +16 -0
- package/lib/Cloudflare/D1/D1Migrations.d.ts.map +1 -0
- package/lib/Cloudflare/D1/D1Migrations.js +110 -0
- package/lib/Cloudflare/D1/D1Migrations.js.map +1 -0
- package/lib/Cloudflare/D1/D1SqlFile.d.ts +20 -0
- package/lib/Cloudflare/D1/D1SqlFile.d.ts.map +1 -0
- package/lib/Cloudflare/D1/D1SqlFile.js +46 -0
- package/lib/Cloudflare/D1/D1SqlFile.js.map +1 -0
- package/lib/Cloudflare/Providers.d.ts +1 -1
- package/lib/Cloudflare/Providers.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/Queue.d.ts +34 -4
- package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/Queue.js +34 -4
- package/lib/Cloudflare/Queue/Queue.js.map +1 -1
- package/lib/Cloudflare/Queue/QueueBinding.d.ts +33 -0
- package/lib/Cloudflare/Queue/QueueBinding.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/QueueBinding.js +33 -0
- package/lib/Cloudflare/Queue/QueueBinding.js.map +1 -1
- package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts +103 -6
- package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/DurableObjectNamespace.js +103 -6
- package/lib/Cloudflare/Workers/DurableObjectNamespace.js.map +1 -1
- package/lib/Cloudflare/Workers/Workflow.d.ts +94 -0
- package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Workflow.js +94 -0
- package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
- package/lib/Platform.js +1 -1
- package/lib/Platform.js.map +1 -1
- package/package.json +1 -1
- package/src/AWS/Lambda/Function.ts +30 -24
- package/src/AWS/Lambda/HttpServer.ts +3 -1
- package/src/Cloudflare/Container/Container.ts +55 -2
- package/src/Cloudflare/D1/D1Clone.ts +42 -0
- package/src/Cloudflare/D1/D1Database.ts +386 -3
- package/src/Cloudflare/D1/D1Export.ts +66 -0
- package/src/Cloudflare/D1/D1Import.ts +156 -0
- package/src/Cloudflare/D1/D1Migrations.ts +229 -0
- package/src/Cloudflare/D1/D1SqlFile.ts +55 -0
- package/src/Cloudflare/Queue/Queue.ts +34 -4
- package/src/Cloudflare/Queue/QueueBinding.ts +33 -0
- package/src/Cloudflare/Workers/DurableObjectNamespace.ts +103 -6
- package/src/Cloudflare/Workers/Workflow.ts +94 -0
- package/src/Platform.ts +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as d1 from "@distilled.cloud/cloudflare/d1";
|
|
2
|
+
import {
|
|
3
|
+
Credentials,
|
|
4
|
+
formatHeaders,
|
|
5
|
+
} from "@distilled.cloud/cloudflare/Credentials";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
9
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
10
|
+
|
|
11
|
+
export interface ImportD1DatabaseOptions {
|
|
12
|
+
accountId: string;
|
|
13
|
+
databaseId: string;
|
|
14
|
+
sqlData: string | Uint8Array;
|
|
15
|
+
filename?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ImportD1DatabaseResult {
|
|
19
|
+
filename: string;
|
|
20
|
+
numQueries: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ImportPollingResponse {
|
|
24
|
+
at_bookmark?: string | null;
|
|
25
|
+
error?: string | null;
|
|
26
|
+
filename?: string | null;
|
|
27
|
+
messages?: string[] | null;
|
|
28
|
+
result?: {
|
|
29
|
+
final_bookmark?: string | null;
|
|
30
|
+
num_queries?: number | null;
|
|
31
|
+
} | null;
|
|
32
|
+
status?: "complete" | "error" | null;
|
|
33
|
+
success?: boolean | null;
|
|
34
|
+
upload_url?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const md5 = (data: string | Uint8Array): string =>
|
|
38
|
+
crypto.createHash("md5").update(data).digest("hex");
|
|
39
|
+
|
|
40
|
+
const importEndpoint = (
|
|
41
|
+
apiBaseUrl: string,
|
|
42
|
+
accountId: string,
|
|
43
|
+
databaseId: string,
|
|
44
|
+
) => `${apiBaseUrl}/accounts/${accountId}/d1/database/${databaseId}/import`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Import SQL into a D1 database via the multi-step Cloudflare flow:
|
|
48
|
+
* 1. POST `action: "init"` -> returns presigned `upload_url`
|
|
49
|
+
* 2. PUT raw SQL to `upload_url`
|
|
50
|
+
* 3. POST `action: "ingest"` -> returns `at_bookmark`
|
|
51
|
+
* 4. POST `action: "poll"` until `status === "complete"`
|
|
52
|
+
*/
|
|
53
|
+
export const importD1Database = (options: ImportD1DatabaseOptions) =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const credentialsEff = yield* Credentials;
|
|
56
|
+
const credentials = yield* credentialsEff;
|
|
57
|
+
const authHeaders = formatHeaders(credentials);
|
|
58
|
+
const url = importEndpoint(
|
|
59
|
+
credentials.apiBaseUrl,
|
|
60
|
+
options.accountId,
|
|
61
|
+
options.databaseId,
|
|
62
|
+
);
|
|
63
|
+
const client = yield* HttpClient.HttpClient;
|
|
64
|
+
|
|
65
|
+
const postJson = (
|
|
66
|
+
body: unknown,
|
|
67
|
+
): Effect.Effect<ImportPollingResponse, never, never> =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const req = HttpClientRequest.post(url).pipe(
|
|
70
|
+
HttpClientRequest.setHeaders(authHeaders),
|
|
71
|
+
HttpClientRequest.bodyJsonUnsafe(body),
|
|
72
|
+
);
|
|
73
|
+
const res = yield* client.execute(req).pipe(Effect.orDie);
|
|
74
|
+
if (res.status < 200 || res.status >= 300) {
|
|
75
|
+
const text = yield* res.text.pipe(Effect.orElseSucceed(() => ""));
|
|
76
|
+
return yield* Effect.die(
|
|
77
|
+
`D1 import request failed (${res.status}): ${text}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const text = yield* res.text.pipe(Effect.orDie);
|
|
81
|
+
const json = JSON.parse(text) as { result: ImportPollingResponse };
|
|
82
|
+
return json.result;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const etag = md5(options.sqlData);
|
|
86
|
+
|
|
87
|
+
// Step 1: init via the typed d1 client
|
|
88
|
+
const initDb = yield* d1.importDatabase;
|
|
89
|
+
const init = yield* initDb({
|
|
90
|
+
accountId: options.accountId,
|
|
91
|
+
databaseId: options.databaseId,
|
|
92
|
+
action: "init",
|
|
93
|
+
etag,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!init.uploadUrl) {
|
|
97
|
+
return yield* Effect.die(
|
|
98
|
+
init.error ?? "Failed to get upload URL for D1 import",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const uploadFilename = options.filename ?? init.filename ?? "import.sql";
|
|
102
|
+
|
|
103
|
+
// Step 2: PUT to the presigned upload URL
|
|
104
|
+
const bytes =
|
|
105
|
+
typeof options.sqlData === "string"
|
|
106
|
+
? new TextEncoder().encode(options.sqlData)
|
|
107
|
+
: options.sqlData;
|
|
108
|
+
const putReq = HttpClientRequest.put(init.uploadUrl).pipe(
|
|
109
|
+
HttpClientRequest.bodyUint8Array(bytes, "application/sql"),
|
|
110
|
+
);
|
|
111
|
+
const putRes = yield* client.execute(putReq).pipe(Effect.orDie);
|
|
112
|
+
if (putRes.status < 200 || putRes.status >= 300) {
|
|
113
|
+
const text = yield* putRes.text.pipe(Effect.orElseSucceed(() => ""));
|
|
114
|
+
return yield* Effect.die(
|
|
115
|
+
`Failed to upload SQL file to D1 (${putRes.status}): ${text}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Step 3: ingest
|
|
120
|
+
const ingest = yield* postJson({
|
|
121
|
+
action: "ingest",
|
|
122
|
+
etag,
|
|
123
|
+
filename: init.filename,
|
|
124
|
+
});
|
|
125
|
+
if (!ingest.at_bookmark) {
|
|
126
|
+
return yield* Effect.die(
|
|
127
|
+
ingest.error ?? "Ingest response missing bookmark",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Step 4: poll until complete
|
|
132
|
+
const poll = (
|
|
133
|
+
bookmark: string,
|
|
134
|
+
): Effect.Effect<ImportD1DatabaseResult, never, never> =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const data = yield* postJson({
|
|
137
|
+
action: "poll",
|
|
138
|
+
current_bookmark: bookmark,
|
|
139
|
+
});
|
|
140
|
+
if (data.status === "complete" && data.result) {
|
|
141
|
+
return {
|
|
142
|
+
filename: data.filename ?? uploadFilename,
|
|
143
|
+
numQueries: data.result.num_queries ?? 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (data.status === "error") {
|
|
147
|
+
return yield* Effect.die(data.error ?? "Error during D1 import");
|
|
148
|
+
}
|
|
149
|
+
if (!data.at_bookmark) {
|
|
150
|
+
return yield* Effect.die("D1 import poll missing bookmark");
|
|
151
|
+
}
|
|
152
|
+
return yield* poll(data.at_bookmark);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return yield* poll(ingest.at_bookmark);
|
|
156
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as d1 from "@distilled.cloud/cloudflare/d1";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import type * as HttpClient from "effect/unstable/http/HttpClient";
|
|
4
|
+
import type { Credentials } from "@distilled.cloud/cloudflare/Credentials";
|
|
5
|
+
import type { D1SqlFile } from "./D1SqlFile.ts";
|
|
6
|
+
|
|
7
|
+
export interface ApplyMigrationsOptions {
|
|
8
|
+
accountId: string;
|
|
9
|
+
databaseId: string;
|
|
10
|
+
migrationsTable: string;
|
|
11
|
+
migrationsFiles: ReadonlyArray<D1SqlFile>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TableColumn {
|
|
15
|
+
name: string;
|
|
16
|
+
type: string;
|
|
17
|
+
pk: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SchemaInfo {
|
|
21
|
+
exists: boolean;
|
|
22
|
+
hasIdColumn: boolean;
|
|
23
|
+
hasNameColumn: boolean;
|
|
24
|
+
isLegacySchema: boolean;
|
|
25
|
+
columns: TableColumn[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const executeSQL = (
|
|
29
|
+
accountId: string,
|
|
30
|
+
databaseId: string,
|
|
31
|
+
sql: string,
|
|
32
|
+
): Effect.Effect<
|
|
33
|
+
d1.QueryDatabaseResponse,
|
|
34
|
+
d1.QueryDatabaseError,
|
|
35
|
+
Credentials | HttpClient.HttpClient
|
|
36
|
+
> =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const queryDb = yield* d1.queryDatabase;
|
|
39
|
+
return yield* queryDb({ accountId, databaseId, sql });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const detectSchema = (
|
|
43
|
+
accountId: string,
|
|
44
|
+
databaseId: string,
|
|
45
|
+
migrationsTable: string,
|
|
46
|
+
) =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const result = yield* executeSQL(
|
|
49
|
+
accountId,
|
|
50
|
+
databaseId,
|
|
51
|
+
`PRAGMA table_info(${migrationsTable});`,
|
|
52
|
+
).pipe(Effect.option);
|
|
53
|
+
|
|
54
|
+
const columns: TableColumn[] = [];
|
|
55
|
+
if (result._tag === "Some") {
|
|
56
|
+
const rows = (result.value.result[0]?.results ?? []) as Array<{
|
|
57
|
+
name: string;
|
|
58
|
+
type: string;
|
|
59
|
+
pk: number;
|
|
60
|
+
}>;
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
columns.push({ name: row.name, type: row.type, pk: row.pk });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (columns.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
exists: false,
|
|
69
|
+
hasIdColumn: false,
|
|
70
|
+
hasNameColumn: false,
|
|
71
|
+
isLegacySchema: false,
|
|
72
|
+
columns,
|
|
73
|
+
} satisfies SchemaInfo;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const names = columns.map((c) => c.name);
|
|
77
|
+
const hasIdColumn = names.includes("id");
|
|
78
|
+
const hasNameColumn = names.includes("name");
|
|
79
|
+
const isLegacySchema =
|
|
80
|
+
columns.length === 2 && !(hasIdColumn && hasNameColumn);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
exists: true,
|
|
84
|
+
hasIdColumn,
|
|
85
|
+
hasNameColumn,
|
|
86
|
+
isLegacySchema,
|
|
87
|
+
columns,
|
|
88
|
+
} satisfies SchemaInfo;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const migrateLegacySchema = (
|
|
92
|
+
accountId: string,
|
|
93
|
+
databaseId: string,
|
|
94
|
+
migrationsTable: string,
|
|
95
|
+
schema: SchemaInfo,
|
|
96
|
+
) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const primaryColumn =
|
|
99
|
+
schema.columns.find((c) => c.pk === 1)?.name ?? schema.columns[0]?.name;
|
|
100
|
+
if (!primaryColumn) {
|
|
101
|
+
return yield* Effect.die(
|
|
102
|
+
"Cannot migrate legacy migration table: no columns found",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const tempTable = `${migrationsTable}_temp_migration`;
|
|
106
|
+
yield* executeSQL(
|
|
107
|
+
accountId,
|
|
108
|
+
databaseId,
|
|
109
|
+
`CREATE TABLE ${tempTable} (
|
|
110
|
+
id TEXT PRIMARY KEY,
|
|
111
|
+
name TEXT NOT NULL,
|
|
112
|
+
applied_at TEXT NOT NULL
|
|
113
|
+
);`,
|
|
114
|
+
);
|
|
115
|
+
yield* executeSQL(
|
|
116
|
+
accountId,
|
|
117
|
+
databaseId,
|
|
118
|
+
`INSERT INTO ${tempTable} (id, name, applied_at)
|
|
119
|
+
SELECT
|
|
120
|
+
printf('%05d', row_number() OVER (ORDER BY applied_at)) as id,
|
|
121
|
+
${primaryColumn} as name,
|
|
122
|
+
applied_at
|
|
123
|
+
FROM ${migrationsTable}
|
|
124
|
+
ORDER BY applied_at;`,
|
|
125
|
+
);
|
|
126
|
+
yield* executeSQL(accountId, databaseId, `DROP TABLE ${migrationsTable};`);
|
|
127
|
+
yield* executeSQL(
|
|
128
|
+
accountId,
|
|
129
|
+
databaseId,
|
|
130
|
+
`ALTER TABLE ${tempTable} RENAME TO ${migrationsTable};`,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const ensureMigrationsTable = (
|
|
135
|
+
accountId: string,
|
|
136
|
+
databaseId: string,
|
|
137
|
+
migrationsTable: string,
|
|
138
|
+
) =>
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
const schema = yield* detectSchema(accountId, databaseId, migrationsTable);
|
|
141
|
+
if (!schema.exists) {
|
|
142
|
+
yield* executeSQL(
|
|
143
|
+
accountId,
|
|
144
|
+
databaseId,
|
|
145
|
+
`CREATE TABLE ${migrationsTable} (
|
|
146
|
+
id TEXT PRIMARY KEY,
|
|
147
|
+
name TEXT NOT NULL,
|
|
148
|
+
applied_at TEXT NOT NULL
|
|
149
|
+
);`,
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (schema.isLegacySchema || !schema.hasIdColumn || !schema.hasNameColumn) {
|
|
154
|
+
yield* migrateLegacySchema(
|
|
155
|
+
accountId,
|
|
156
|
+
databaseId,
|
|
157
|
+
migrationsTable,
|
|
158
|
+
schema,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const getAppliedMigrations = (
|
|
164
|
+
accountId: string,
|
|
165
|
+
databaseId: string,
|
|
166
|
+
migrationsTable: string,
|
|
167
|
+
) =>
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const result = yield* executeSQL(
|
|
170
|
+
accountId,
|
|
171
|
+
databaseId,
|
|
172
|
+
`SELECT name FROM ${migrationsTable};`,
|
|
173
|
+
);
|
|
174
|
+
const rows = (result.result[0]?.results ?? []) as Array<{ name: string }>;
|
|
175
|
+
return new Set(rows.map((r) => r.name));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const getNextSeq = (
|
|
179
|
+
accountId: string,
|
|
180
|
+
databaseId: string,
|
|
181
|
+
migrationsTable: string,
|
|
182
|
+
) =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const result = yield* executeSQL(
|
|
185
|
+
accountId,
|
|
186
|
+
databaseId,
|
|
187
|
+
`SELECT id FROM ${migrationsTable} ORDER BY id;`,
|
|
188
|
+
);
|
|
189
|
+
const rows = (result.result[0]?.results ?? []) as Array<{ id: string }>;
|
|
190
|
+
let max = 0;
|
|
191
|
+
for (const { id } of rows) {
|
|
192
|
+
if (/^\d+$/.test(id)) {
|
|
193
|
+
max = Math.max(max, Number.parseInt(id, 10));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return max + 1;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Apply pending D1 migrations in order. Uses the wrangler-compatible
|
|
201
|
+
* 3-column schema `(id TEXT PK, name TEXT, applied_at TEXT)`.
|
|
202
|
+
*/
|
|
203
|
+
export const applyMigrations = (options: ApplyMigrationsOptions) =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const { accountId, databaseId, migrationsTable, migrationsFiles } = options;
|
|
206
|
+
yield* ensureMigrationsTable(accountId, databaseId, migrationsTable);
|
|
207
|
+
const applied = yield* getAppliedMigrations(
|
|
208
|
+
accountId,
|
|
209
|
+
databaseId,
|
|
210
|
+
migrationsTable,
|
|
211
|
+
);
|
|
212
|
+
let nextSeq = yield* getNextSeq(accountId, databaseId, migrationsTable);
|
|
213
|
+
|
|
214
|
+
for (const migration of migrationsFiles) {
|
|
215
|
+
if (applied.has(migration.id)) continue;
|
|
216
|
+
const migrationId = nextSeq.toString().padStart(5, "0");
|
|
217
|
+
nextSeq += 1;
|
|
218
|
+
// D1 over HTTP doesn't support transactions; run migration + record
|
|
219
|
+
// in a single batched query.
|
|
220
|
+
yield* executeSQL(
|
|
221
|
+
accountId,
|
|
222
|
+
databaseId,
|
|
223
|
+
[
|
|
224
|
+
migration.sql,
|
|
225
|
+
`INSERT INTO ${migrationsTable} (id, name, applied_at) VALUES ('${migrationId}', '${migration.id}', datetime('now'));`,
|
|
226
|
+
].join("\n"),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as FileSystem from "effect/FileSystem";
|
|
4
|
+
import * as Path from "effect/Path";
|
|
5
|
+
|
|
6
|
+
export interface D1SqlFile {
|
|
7
|
+
id: string;
|
|
8
|
+
sql: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Recursively list `.sql` files under `directory`, sorted by their numeric
|
|
14
|
+
* prefix (e.g. `0001_init.sql`) and then by name.
|
|
15
|
+
*/
|
|
16
|
+
export const listSqlFiles = (directory: string) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const fs = yield* FileSystem.FileSystem;
|
|
19
|
+
const entries = yield* fs.readDirectory(directory, { recursive: true });
|
|
20
|
+
|
|
21
|
+
const sqlFiles = entries
|
|
22
|
+
.filter((name) => name.endsWith(".sql"))
|
|
23
|
+
.sort((a, b) => {
|
|
24
|
+
const aNum = getPrefix(a);
|
|
25
|
+
const bNum = getPrefix(b);
|
|
26
|
+
if (aNum !== null && bNum !== null) return aNum - bNum;
|
|
27
|
+
if (aNum !== null) return -1;
|
|
28
|
+
if (bNum !== null) return 1;
|
|
29
|
+
return a.localeCompare(b);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return yield* Effect.all(sqlFiles.map((id) => readSqlFile(directory, id)));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read a single `.sql` file relative to `directory` and compute its content
|
|
37
|
+
* hash. The `sql` field is marked non-enumerable so it isn't serialized into
|
|
38
|
+
* resource state.
|
|
39
|
+
*/
|
|
40
|
+
export const readSqlFile = (directory: string, name: string) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const fs = yield* FileSystem.FileSystem;
|
|
43
|
+
const path = yield* Path.Path;
|
|
44
|
+
const sql = yield* fs.readFileString(path.resolve(directory, name));
|
|
45
|
+
const hash = crypto.createHash("sha256").update(sql).digest("hex");
|
|
46
|
+
const file: D1SqlFile = { id: name, sql, hash };
|
|
47
|
+
Object.defineProperty(file, "sql", { enumerable: false });
|
|
48
|
+
return file;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const getPrefix = (name: string): number | null => {
|
|
52
|
+
const prefix = name.split("_")[0];
|
|
53
|
+
const num = Number.parseInt(prefix, 10);
|
|
54
|
+
return Number.isNaN(num) ? null : num;
|
|
55
|
+
};
|
|
@@ -49,12 +49,42 @@ export type Queue = Resource<
|
|
|
49
49
|
* ```
|
|
50
50
|
*
|
|
51
51
|
* @section Binding to a Worker
|
|
52
|
+
* In an Effect-style Worker, use `Cloudflare.QueueBinding.bind` in
|
|
53
|
+
* the init phase and provide `Cloudflare.QueueBindingLive` in the
|
|
54
|
+
* runtime layer. The returned `QueueSender` exposes `send` and
|
|
55
|
+
* `sendBatch`.
|
|
56
|
+
*
|
|
52
57
|
* @example Sending messages from a Worker
|
|
53
58
|
* ```typescript
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
59
|
+
* import * as Cloudflare from "alchemy/Cloudflare";
|
|
60
|
+
* import * as Effect from "effect/Effect";
|
|
61
|
+
* import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
|
|
62
|
+
* import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
|
|
63
|
+
*
|
|
64
|
+
* export const Queue = Cloudflare.Queue("Queue");
|
|
65
|
+
*
|
|
66
|
+
* export default Cloudflare.Worker(
|
|
67
|
+
* "Worker",
|
|
68
|
+
* { main: import.meta.path },
|
|
69
|
+
* Effect.gen(function* () {
|
|
70
|
+
* const queue = yield* Cloudflare.QueueBinding.bind(Queue);
|
|
71
|
+
*
|
|
72
|
+
* return {
|
|
73
|
+
* fetch: Effect.gen(function* () {
|
|
74
|
+
* const request = yield* HttpServerRequest;
|
|
75
|
+
* if (request.url === "/queue/send" && request.method === "POST") {
|
|
76
|
+
* const text = yield* request.text;
|
|
77
|
+
* yield* queue.send({ text, sentAt: Date.now() }).pipe(Effect.orDie);
|
|
78
|
+
* return yield* HttpServerResponse.json(
|
|
79
|
+
* { sent: { text } },
|
|
80
|
+
* { status: 202 },
|
|
81
|
+
* );
|
|
82
|
+
* }
|
|
83
|
+
* return HttpServerResponse.text("Not Found", { status: 404 });
|
|
84
|
+
* }),
|
|
85
|
+
* };
|
|
86
|
+
* }).pipe(Effect.provide(Cloudflare.QueueBindingLive)),
|
|
87
|
+
* );
|
|
58
88
|
* ```
|
|
59
89
|
*/
|
|
60
90
|
export const Queue = Resource<Queue>("Cloudflare.Queue")({
|
|
@@ -26,6 +26,39 @@ export class QueueSendError extends Data.TaggedError("QueueSendError")<{
|
|
|
26
26
|
cause?: unknown;
|
|
27
27
|
}> {}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Binding service that turns a {@link Queue} resource into a typed
|
|
31
|
+
* {@link QueueSender} you can call from a Worker's runtime Effect.
|
|
32
|
+
*
|
|
33
|
+
* @section Sending Messages
|
|
34
|
+
* Bind the queue in the Worker's init phase, then use `send` for
|
|
35
|
+
* single messages or `sendBatch` for many messages in one call.
|
|
36
|
+
* Messages can be any JSON-serializable value.
|
|
37
|
+
*
|
|
38
|
+
* @example Producer route
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const queue = yield* Cloudflare.QueueBinding.bind(Queue);
|
|
41
|
+
*
|
|
42
|
+
* return {
|
|
43
|
+
* fetch: Effect.gen(function* () {
|
|
44
|
+
* yield* queue.send({ text: "hi", sentAt: Date.now() });
|
|
45
|
+
* return HttpServerResponse.empty({ status: 202 });
|
|
46
|
+
* }),
|
|
47
|
+
* };
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example Sending a batch
|
|
51
|
+
* ```typescript
|
|
52
|
+
* yield* queue.sendBatch([
|
|
53
|
+
* { body: { event: "click", id: 1 } },
|
|
54
|
+
* { body: { event: "click", id: 2 } },
|
|
55
|
+
* { body: "raw text", contentType: "text" },
|
|
56
|
+
* ]);
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* Provide {@link QueueBindingLive} in the worker's runtime layer to
|
|
60
|
+
* resolve the underlying Cloudflare queue at request time.
|
|
61
|
+
*/
|
|
29
62
|
export class QueueBinding extends Binding.Service<
|
|
30
63
|
QueueBinding,
|
|
31
64
|
(queue: Queue) => Effect.Effect<QueueSender>
|
|
@@ -301,6 +301,51 @@ export class DurableObjectNamespaceScope extends Context.Service<
|
|
|
301
301
|
* };
|
|
302
302
|
* ```
|
|
303
303
|
*
|
|
304
|
+
* @section Returning Streams from RPC
|
|
305
|
+
* RPC methods can return an Effect `Stream` and the caller will see
|
|
306
|
+
* the chunks as they're produced. Combine with `Stream.schedule` to
|
|
307
|
+
* pace emission, or with `Stream.fromQueue` to bridge an inbound
|
|
308
|
+
* subscription.
|
|
309
|
+
*
|
|
310
|
+
* @example Streaming sequential numbers
|
|
311
|
+
* ```typescript
|
|
312
|
+
* import * as Schedule from "effect/Schedule";
|
|
313
|
+
* import * as Stream from "effect/Stream";
|
|
314
|
+
*
|
|
315
|
+
* return {
|
|
316
|
+
* tick: (n: number) =>
|
|
317
|
+
* Stream.iterate(0, (i) => i + 1).pipe(
|
|
318
|
+
* Stream.take(n),
|
|
319
|
+
* Stream.schedule(Schedule.spaced("100 millis")),
|
|
320
|
+
* ),
|
|
321
|
+
* };
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* @example Forwarding the stream as a chunked HTTP response
|
|
325
|
+
* ```typescript
|
|
326
|
+
* // in a Worker fetch handler
|
|
327
|
+
* const counter = counters.getByName("tick");
|
|
328
|
+
* const stream = counter.tick(5).pipe(
|
|
329
|
+
* Stream.map((i) => `${i}\n`),
|
|
330
|
+
* Stream.encodeText,
|
|
331
|
+
* );
|
|
332
|
+
* return HttpServerResponse.stream(stream, {
|
|
333
|
+
* headers: { "content-type": "text/plain" },
|
|
334
|
+
* });
|
|
335
|
+
* ```
|
|
336
|
+
*
|
|
337
|
+
* @section Worker → DO HTTP forwarding
|
|
338
|
+
* In addition to RPC methods, the typed stub exposes a `fetch`
|
|
339
|
+
* method that forwards an `HttpServerRequest` straight to the DO.
|
|
340
|
+
* The DO's own `fetch` Effect produces the response — useful for
|
|
341
|
+
* WebSocket upgrades and other request-shaped interactions.
|
|
342
|
+
*
|
|
343
|
+
* @example Forwarding an HTTP request to a DO
|
|
344
|
+
* ```typescript
|
|
345
|
+
* const room = rooms.getByName(roomId);
|
|
346
|
+
* return yield* room.fetch(request);
|
|
347
|
+
* ```
|
|
348
|
+
*
|
|
304
349
|
* @section Accessing Instance State
|
|
305
350
|
* Each Durable Object instance has its own transactional key-value
|
|
306
351
|
* storage via `Cloudflare.DurableObjectState`. Use `storage.get` and
|
|
@@ -356,14 +401,66 @@ export class DurableObjectNamespaceScope extends Context.Service<
|
|
|
356
401
|
* ```
|
|
357
402
|
*
|
|
358
403
|
* @example Recovering sessions after hibernation
|
|
404
|
+
* Place the rehydration loop **inside the inner `Effect.gen`** so
|
|
405
|
+
* it runs every time the DO instance is reconstructed (including
|
|
406
|
+
* after Cloudflare wakes the DO from hibernation).
|
|
407
|
+
*
|
|
359
408
|
* ```typescript
|
|
360
|
-
*
|
|
361
|
-
*
|
|
409
|
+
* return Effect.gen(function* () {
|
|
410
|
+
* const state = yield* Cloudflare.DurableObjectState;
|
|
411
|
+
* const sessions = new Map<string, Cloudflare.DurableWebSocket>();
|
|
412
|
+
*
|
|
413
|
+
* // Rehydrate the in-memory session map after hibernation.
|
|
414
|
+
* for (const socket of yield* state.getWebSockets()) {
|
|
415
|
+
* const data = socket.deserializeAttachment<{ id: string }>();
|
|
416
|
+
* if (data) sessions.set(data.id, socket);
|
|
417
|
+
* }
|
|
362
418
|
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
419
|
+
* return {
|
|
420
|
+
* fetch: Effect.gen(function* () {
|
|
421
|
+
* const [response, socket] = yield* Cloudflare.upgrade();
|
|
422
|
+
* const id = crypto.randomUUID();
|
|
423
|
+
* socket.serializeAttachment({ id });
|
|
424
|
+
* sessions.set(id, socket);
|
|
425
|
+
* return response;
|
|
426
|
+
* }),
|
|
427
|
+
* webSocketMessage: Effect.fnUntraced(function* (socket, message) {
|
|
428
|
+
* const text =
|
|
429
|
+
* typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
430
|
+
* for (const peer of sessions.values()) {
|
|
431
|
+
* yield* peer.send(text);
|
|
432
|
+
* }
|
|
433
|
+
* }),
|
|
434
|
+
* };
|
|
435
|
+
* });
|
|
436
|
+
* ```
|
|
437
|
+
*
|
|
438
|
+
* @section Scheduled Alarms
|
|
439
|
+
* Each Durable Object can have a single alarm timestamp. Alchemy
|
|
440
|
+
* layers a small SQLite-backed scheduler on top via
|
|
441
|
+
* `Cloudflare.scheduleEvent` and `Cloudflare.processScheduledEvents`,
|
|
442
|
+
* so you can register many named events with arbitrary payloads and
|
|
443
|
+
* fire them from a single `alarm` handler.
|
|
444
|
+
*
|
|
445
|
+
* @example Scheduling and processing events
|
|
446
|
+
* ```typescript
|
|
447
|
+
* // schedule from a request or message handler
|
|
448
|
+
* yield* Cloudflare.scheduleEvent(
|
|
449
|
+
* "reminder-1",
|
|
450
|
+
* new Date(Date.now() + 60_000),
|
|
451
|
+
* { message: "your meeting starts in a minute" },
|
|
452
|
+
* );
|
|
453
|
+
*
|
|
454
|
+
* return {
|
|
455
|
+
* alarm: () =>
|
|
456
|
+
* Effect.gen(function* () {
|
|
457
|
+
* const fired = yield* Cloudflare.processScheduledEvents;
|
|
458
|
+
* for (const event of fired) {
|
|
459
|
+
* const payload = event.payload as { message: string };
|
|
460
|
+
* // dispatch / broadcast / persist...
|
|
461
|
+
* }
|
|
462
|
+
* }),
|
|
463
|
+
* };
|
|
367
464
|
* ```
|
|
368
465
|
*
|
|
369
466
|
* @section Using from a Worker
|