@hot-updater/cloudflare 1.0.0-rc.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +90 -924
- package/dist/index.d.cts +16 -6
- package/dist/index.d.mts +16 -6
- package/dist/index.mjs +91 -926
- package/dist/init/index.cjs +11777 -0
- package/dist/init/index.d.cts +5 -1
- package/dist/init/index.d.mts +5 -1
- package/dist/init/index.mjs +11771 -1
- package/dist/worker/index.cjs +117 -916
- package/dist/worker/index.d.cts +45 -11
- package/dist/worker/index.d.mts +45 -11
- package/dist/worker/index.mjs +118 -917
- package/package.json +9 -14
- package/sql/bundles.sql +121 -212
- package/src/d1Database.ts +58 -43
- package/src/d1Executor.ts +84 -0
- package/src/d1Schema.ts +26 -0
- package/src/presignR2Url.ts +103 -0
- package/src/r2S3Storage.ts +20 -19
- package/src/r2Storage.ts +5 -3
- package/src/r2WorkerStorage.ts +55 -17
- package/src/worker/d1Database.ts +21 -39
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +9859 -5696
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0001_hot-updater_1.0.0.sql +121 -222
- package/worker/src/index.ts +15 -9
- package/worker/wrangler.json +1 -0
- package/dist/iac/index.cjs +0 -12687
- package/dist/iac/index.d.cts +0 -5
- package/dist/iac/index.d.mts +0 -5
- package/dist/iac/index.mjs +0 -12679
- package/src/d1Database.spec.ts +0 -277
- package/src/d1Implementation.spec.ts +0 -342
- package/src/d1Implementation.ts +0 -781
- package/src/d1Query.spec.ts +0 -46
- package/src/d1Query.ts +0 -92
- package/src/d1Rows.spec.ts +0 -143
- package/src/d1Rows.ts +0 -357
- package/src/d1Sql.spec.ts +0 -30
- package/src/d1Sql.ts +0 -175
- package/src/r2Storage.spec.ts +0 -346
- package/src/r2WorkerStorage.spec.ts +0 -184
- package/src/utils/createWrangler.spec.ts +0 -75
- package/src/worker/d1Database.spec.ts +0 -69
- package/worker/src/databaseMigration.integration.spec.ts +0 -191
- package/worker/src/databasePlugins.integration.spec.ts +0 -326
- package/worker/src/index.integration.spec.ts +0 -190
- package/worker/src/insightsMovement.integration.spec.ts +0 -73
- package/worker/src/storagePlugin.integration.spec.ts +0 -59
- package/worker/src/testWorker.ts +0 -5
package/src/d1Implementation.ts
DELETED
|
@@ -1,781 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
BundlePatchRow,
|
|
3
|
-
BundleRow,
|
|
4
|
-
ChannelInsertInput,
|
|
5
|
-
ChannelInsertResult,
|
|
6
|
-
ChannelRow,
|
|
7
|
-
ChannelDeleteInput,
|
|
8
|
-
ChannelDeleteResult,
|
|
9
|
-
DatabaseChange,
|
|
10
|
-
DatabaseCommit,
|
|
11
|
-
DatabaseCommitExpectation,
|
|
12
|
-
DatabaseCommitResult,
|
|
13
|
-
} from "@hot-updater/plugin-core";
|
|
14
|
-
import type {
|
|
15
|
-
CreateDatabaseImplementationInput,
|
|
16
|
-
DatabasePluginImplementation,
|
|
17
|
-
DeleteDatabaseImplementationInput,
|
|
18
|
-
FindOneDatabaseImplementationInput,
|
|
19
|
-
UpdateDatabaseImplementationInput,
|
|
20
|
-
} from "@hot-updater/plugin-core/internal";
|
|
21
|
-
|
|
22
|
-
import { countD1Rows, d1TableNames, findManyD1Rows } from "./d1Query";
|
|
23
|
-
import { parseD1Row } from "./d1Rows";
|
|
24
|
-
import { buildD1Where, d1Placeholders, encodeD1Values } from "./d1Sql";
|
|
25
|
-
|
|
26
|
-
export interface D1Executor {
|
|
27
|
-
query(sql: string, params: readonly string[]): Promise<readonly unknown[]>;
|
|
28
|
-
batch(
|
|
29
|
-
statements: readonly D1Statement[],
|
|
30
|
-
): Promise<readonly (readonly unknown[])[]>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface D1Statement {
|
|
34
|
-
readonly sql: string;
|
|
35
|
-
readonly params: readonly string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export class D1ExecutionError extends Error {
|
|
39
|
-
readonly name = "D1ExecutionError";
|
|
40
|
-
constructor() {
|
|
41
|
-
super("D1 did not successfully execute every requested statement");
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
type D1Guard = {
|
|
46
|
-
readonly sql: string;
|
|
47
|
-
readonly params: readonly string[];
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const EXPECTATION_CONFLICT_MARKER = "HOT_UPDATER_COMMIT_EXPECTATION_CONFLICT";
|
|
51
|
-
|
|
52
|
-
const expectationGuard = (
|
|
53
|
-
expectations: readonly DatabaseCommitExpectation[],
|
|
54
|
-
): D1Guard => {
|
|
55
|
-
const clauses: string[] = [];
|
|
56
|
-
const params: string[] = [];
|
|
57
|
-
for (const expectation of expectations) {
|
|
58
|
-
const isRelease = expectation.model === "releases";
|
|
59
|
-
const table = isRelease ? "releases" : "release_catalogs";
|
|
60
|
-
const keyField = isRelease ? "id" : "scope_key";
|
|
61
|
-
const versionField = isRelease ? "revision" : "generation";
|
|
62
|
-
const key = isRelease ? expectation.id : expectation.scopeKey;
|
|
63
|
-
const version = isRelease ? expectation.revision : expectation.generation;
|
|
64
|
-
if (version === null) {
|
|
65
|
-
clauses.push(
|
|
66
|
-
`NOT EXISTS (SELECT 1 FROM ${table} WHERE ${keyField} = json_extract(?, '$'))`,
|
|
67
|
-
);
|
|
68
|
-
params.push(...encodeD1Values([key]));
|
|
69
|
-
} else {
|
|
70
|
-
clauses.push(
|
|
71
|
-
`EXISTS (SELECT 1 FROM ${table} WHERE ${keyField} = json_extract(?, '$') AND ${versionField} = json_extract(?, '$'))`,
|
|
72
|
-
);
|
|
73
|
-
params.push(...encodeD1Values([key, version]));
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return { sql: clauses.join(" AND ") || "1", params };
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const readVersion = (
|
|
80
|
-
row: unknown,
|
|
81
|
-
field: "generation" | "revision",
|
|
82
|
-
): number | null => {
|
|
83
|
-
if (typeof row !== "object" || row === null) return null;
|
|
84
|
-
const value = Reflect.get(row, field);
|
|
85
|
-
return typeof value === "number" ? value : null;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const expectationConflict = async (
|
|
89
|
-
executor: D1Executor,
|
|
90
|
-
expectations: readonly DatabaseCommitExpectation[],
|
|
91
|
-
): Promise<DatabaseCommitResult | null> => {
|
|
92
|
-
for (const expectation of expectations) {
|
|
93
|
-
const isRelease = expectation.model === "releases";
|
|
94
|
-
const table = isRelease ? "releases" : "release_catalogs";
|
|
95
|
-
const keyField = isRelease ? "id" : "scope_key";
|
|
96
|
-
const versionField = isRelease ? "revision" : "generation";
|
|
97
|
-
const key = isRelease ? expectation.id : expectation.scopeKey;
|
|
98
|
-
const expectedVersion = isRelease
|
|
99
|
-
? expectation.revision
|
|
100
|
-
: expectation.generation;
|
|
101
|
-
const rows = await executor.query(
|
|
102
|
-
`SELECT ${versionField} FROM ${table} WHERE ${keyField} = json_extract(?, '$') LIMIT 1`,
|
|
103
|
-
encodeD1Values([key]),
|
|
104
|
-
);
|
|
105
|
-
const actualVersion = readVersion(rows[0], versionField);
|
|
106
|
-
if (actualVersion !== expectedVersion) {
|
|
107
|
-
return {
|
|
108
|
-
committed: false,
|
|
109
|
-
conflict: {
|
|
110
|
-
actualVersion,
|
|
111
|
-
changeIndex: -1,
|
|
112
|
-
expectedVersion,
|
|
113
|
-
key,
|
|
114
|
-
model: expectation.model,
|
|
115
|
-
reason: "version_conflict",
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const isExpectationConflictError = (error: unknown): boolean => {
|
|
124
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
-
return (
|
|
126
|
-
message.includes(EXPECTATION_CONFLICT_MARKER) ||
|
|
127
|
-
message.toLowerCase().includes("malformed json")
|
|
128
|
-
);
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const bundleValues = (row: BundleRow): readonly unknown[] => [
|
|
132
|
-
row.id,
|
|
133
|
-
row.platform,
|
|
134
|
-
row.file_hash,
|
|
135
|
-
row.git_commit_hash,
|
|
136
|
-
row.storage_uri,
|
|
137
|
-
row.archive_byte_size,
|
|
138
|
-
row.metadata,
|
|
139
|
-
row.manifest_storage_uri,
|
|
140
|
-
row.manifest_file_hash,
|
|
141
|
-
row.asset_base_storage_uri,
|
|
142
|
-
];
|
|
143
|
-
|
|
144
|
-
const patchValues = (row: BundlePatchRow): readonly unknown[] => [
|
|
145
|
-
row.id,
|
|
146
|
-
row.bundle_id,
|
|
147
|
-
row.base_bundle_id,
|
|
148
|
-
row.base_file_hash,
|
|
149
|
-
row.patch_file_hash,
|
|
150
|
-
row.patch_storage_uri,
|
|
151
|
-
row.byte_size,
|
|
152
|
-
row.order_index,
|
|
153
|
-
];
|
|
154
|
-
|
|
155
|
-
const channelValues = (row: ChannelRow): readonly unknown[] => [
|
|
156
|
-
row.id,
|
|
157
|
-
row.name,
|
|
158
|
-
];
|
|
159
|
-
|
|
160
|
-
const insertQuery = (
|
|
161
|
-
input: CreateDatabaseImplementationInput,
|
|
162
|
-
guard?: D1Guard,
|
|
163
|
-
conflictMode: "returnExisting" | "ignore" = "returnExisting",
|
|
164
|
-
): D1Statement => {
|
|
165
|
-
let columns: readonly string[];
|
|
166
|
-
let values: readonly unknown[];
|
|
167
|
-
switch (input.model) {
|
|
168
|
-
case "bundles":
|
|
169
|
-
columns = [
|
|
170
|
-
"id",
|
|
171
|
-
"platform",
|
|
172
|
-
"file_hash",
|
|
173
|
-
"git_commit_hash",
|
|
174
|
-
"storage_uri",
|
|
175
|
-
"archive_byte_size",
|
|
176
|
-
"metadata",
|
|
177
|
-
"manifest_storage_uri",
|
|
178
|
-
"manifest_file_hash",
|
|
179
|
-
"asset_base_storage_uri",
|
|
180
|
-
];
|
|
181
|
-
values = bundleValues(input.data);
|
|
182
|
-
break;
|
|
183
|
-
case "bundle_patches":
|
|
184
|
-
columns = [
|
|
185
|
-
"id",
|
|
186
|
-
"bundle_id",
|
|
187
|
-
"base_bundle_id",
|
|
188
|
-
"base_file_hash",
|
|
189
|
-
"patch_file_hash",
|
|
190
|
-
"patch_storage_uri",
|
|
191
|
-
"byte_size",
|
|
192
|
-
"order_index",
|
|
193
|
-
];
|
|
194
|
-
values = patchValues(input.data);
|
|
195
|
-
break;
|
|
196
|
-
case "channels":
|
|
197
|
-
columns = ["id", "name"];
|
|
198
|
-
values = channelValues(input.data);
|
|
199
|
-
break;
|
|
200
|
-
case "bundle_events":
|
|
201
|
-
case "bundle_installations":
|
|
202
|
-
case "api_keys":
|
|
203
|
-
case "release_catalogs":
|
|
204
|
-
case "releases":
|
|
205
|
-
columns = Object.keys(input.data);
|
|
206
|
-
values = Object.values(input.data);
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const conflict =
|
|
211
|
-
input.onConflict !== "ignore"
|
|
212
|
-
? ""
|
|
213
|
-
: input.model === "channels"
|
|
214
|
-
? conflictMode === "ignore"
|
|
215
|
-
? " ON CONFLICT(name) DO NOTHING"
|
|
216
|
-
: " ON CONFLICT(name) DO UPDATE SET name = excluded.name"
|
|
217
|
-
: input.model === "api_keys"
|
|
218
|
-
? conflictMode === "ignore"
|
|
219
|
-
? " ON CONFLICT(hash) DO NOTHING"
|
|
220
|
-
: " ON CONFLICT(hash) DO UPDATE SET hash = excluded.hash"
|
|
221
|
-
: " ON CONFLICT(install_id) DO UPDATE SET install_id = excluded.install_id";
|
|
222
|
-
|
|
223
|
-
return {
|
|
224
|
-
sql: `INSERT INTO ${d1TableNames[input.model]} (${columns.join(", ")}) ${
|
|
225
|
-
guard === undefined
|
|
226
|
-
? `VALUES (${d1Placeholders(values.length)})`
|
|
227
|
-
: `SELECT ${d1Placeholders(values.length)} WHERE ${guard.sql}`
|
|
228
|
-
}${conflict} RETURNING *`,
|
|
229
|
-
params: [...encodeD1Values(values), ...(guard?.params ?? [])],
|
|
230
|
-
};
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const updateEntries = (
|
|
234
|
-
update: UpdateDatabaseImplementationInput["update"],
|
|
235
|
-
): readonly [string, unknown][] => Object.entries(update);
|
|
236
|
-
|
|
237
|
-
const updateQuery = (
|
|
238
|
-
input: UpdateDatabaseImplementationInput,
|
|
239
|
-
guard?: D1Guard,
|
|
240
|
-
): D1Statement => {
|
|
241
|
-
const entries = updateEntries(input.update);
|
|
242
|
-
const where = buildD1Where(input.where);
|
|
243
|
-
if (entries.length === 0) {
|
|
244
|
-
return {
|
|
245
|
-
sql: `SELECT * FROM ${d1TableNames[input.model]}${where.sql}${guard ? ` AND ${guard.sql}` : ""} LIMIT 1`,
|
|
246
|
-
params: [...where.params, ...(guard?.params ?? [])],
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
const assignments = entries
|
|
250
|
-
.map(([field]) => `${field} = json_extract(?, '$')`)
|
|
251
|
-
.join(", ");
|
|
252
|
-
return {
|
|
253
|
-
sql: `UPDATE ${d1TableNames[input.model]} SET ${assignments}${where.sql}${guard ? ` AND ${guard.sql}` : ""} RETURNING *`,
|
|
254
|
-
params: [
|
|
255
|
-
...encodeD1Values(entries.map(([, value]) => value)),
|
|
256
|
-
...where.params,
|
|
257
|
-
...(guard?.params ?? []),
|
|
258
|
-
],
|
|
259
|
-
};
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
const deleteQuery = (
|
|
263
|
-
input: DeleteDatabaseImplementationInput,
|
|
264
|
-
guard?: D1Guard,
|
|
265
|
-
): D1Statement => {
|
|
266
|
-
const where = buildD1Where(input.where);
|
|
267
|
-
return {
|
|
268
|
-
sql: `DELETE FROM ${d1TableNames[input.model]}${where.sql}${guard ? ` AND ${guard.sql}` : ""}`,
|
|
269
|
-
params: [...where.params, ...(guard?.params ?? [])],
|
|
270
|
-
};
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
type D1RequiredRow = {
|
|
274
|
-
readonly model: "bundles" | "api_keys" | "releases";
|
|
275
|
-
readonly id: string;
|
|
276
|
-
readonly changeIndex: number;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
type D1Check = {
|
|
280
|
-
readonly changeIndex: number;
|
|
281
|
-
readonly resultIndex: number;
|
|
282
|
-
readonly conflictWhen: "empty" | "nonempty";
|
|
283
|
-
readonly reason: "not_found" | "referenced";
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
type D1CommitPlan = {
|
|
287
|
-
readonly checks: readonly D1Check[];
|
|
288
|
-
readonly statements: readonly D1Statement[];
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
const insertedKey = (change: DatabaseChange): string | undefined => {
|
|
292
|
-
if (change.operation !== "insert") return undefined;
|
|
293
|
-
if (change.model === "bundles") return `bundles:${change.row.id}`;
|
|
294
|
-
if (change.model === "apiKeys") {
|
|
295
|
-
return `api_keys:${change.row.id}`;
|
|
296
|
-
}
|
|
297
|
-
if (change.model === "releases") {
|
|
298
|
-
return `releases:${change.row.id}`;
|
|
299
|
-
}
|
|
300
|
-
return undefined;
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const requiredRow = (
|
|
304
|
-
change: DatabaseChange,
|
|
305
|
-
changeIndex: number,
|
|
306
|
-
): D1RequiredRow | undefined => {
|
|
307
|
-
if (change.operation !== "update") return undefined;
|
|
308
|
-
if (change.model === "bundles") {
|
|
309
|
-
return {
|
|
310
|
-
model: "bundles",
|
|
311
|
-
id: change.where.id,
|
|
312
|
-
changeIndex,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
if (change.model === "apiKeys") {
|
|
316
|
-
return {
|
|
317
|
-
model: "api_keys",
|
|
318
|
-
id: change.where.id,
|
|
319
|
-
changeIndex,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
if (change.model === "releases") {
|
|
323
|
-
return {
|
|
324
|
-
model: "releases",
|
|
325
|
-
id: change.where.id,
|
|
326
|
-
changeIndex,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
return undefined;
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
type D1ChannelDeletePrecondition = {
|
|
333
|
-
readonly id: string;
|
|
334
|
-
readonly excludedReleaseIds: readonly string[];
|
|
335
|
-
readonly addedReferenceReleaseIds: readonly string[];
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
const channelDeletePrecondition = (
|
|
339
|
-
changes: readonly DatabaseChange[],
|
|
340
|
-
changeIndex: number,
|
|
341
|
-
channelId: string,
|
|
342
|
-
): D1ChannelDeletePrecondition => {
|
|
343
|
-
const releaseEffects = new Map<string, string | null>();
|
|
344
|
-
for (const change of changes.slice(0, changeIndex)) {
|
|
345
|
-
if (change.model !== "releases") continue;
|
|
346
|
-
switch (change.operation) {
|
|
347
|
-
case "insert":
|
|
348
|
-
releaseEffects.set(change.row.id, change.row.channel_id);
|
|
349
|
-
break;
|
|
350
|
-
case "update":
|
|
351
|
-
break;
|
|
352
|
-
case "delete":
|
|
353
|
-
releaseEffects.set(change.where.id, null);
|
|
354
|
-
break;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return {
|
|
358
|
-
id: channelId,
|
|
359
|
-
excludedReleaseIds: [...releaseEffects.keys()],
|
|
360
|
-
addedReferenceReleaseIds: [...releaseEffects]
|
|
361
|
-
.filter(([, finalChannelId]) => finalChannelId === channelId)
|
|
362
|
-
.map(([releaseId]) => releaseId),
|
|
363
|
-
};
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const changeQuery = (change: DatabaseChange, guard: D1Guard): D1Statement => {
|
|
367
|
-
switch (change.model) {
|
|
368
|
-
case "bundles":
|
|
369
|
-
switch (change.operation) {
|
|
370
|
-
case "insert":
|
|
371
|
-
return insertQuery({ model: "bundles", data: change.row }, guard);
|
|
372
|
-
case "update":
|
|
373
|
-
return updateQuery(
|
|
374
|
-
{
|
|
375
|
-
model: "bundles",
|
|
376
|
-
where: [{ field: "id", value: change.where.id }],
|
|
377
|
-
update: change.update,
|
|
378
|
-
},
|
|
379
|
-
guard,
|
|
380
|
-
);
|
|
381
|
-
case "delete":
|
|
382
|
-
return deleteQuery(
|
|
383
|
-
{
|
|
384
|
-
model: "bundles",
|
|
385
|
-
where: [{ field: "id", value: change.where.id }],
|
|
386
|
-
},
|
|
387
|
-
guard,
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
case "bundlePatches":
|
|
391
|
-
return change.operation === "insert"
|
|
392
|
-
? insertQuery({ model: "bundle_patches", data: change.row }, guard)
|
|
393
|
-
: deleteQuery(
|
|
394
|
-
{
|
|
395
|
-
model: "bundle_patches",
|
|
396
|
-
where: [{ field: "bundle_id", value: change.where.bundleId }],
|
|
397
|
-
},
|
|
398
|
-
guard,
|
|
399
|
-
);
|
|
400
|
-
case "releases":
|
|
401
|
-
switch (change.operation) {
|
|
402
|
-
case "insert":
|
|
403
|
-
return insertQuery({ model: "releases", data: change.row }, guard);
|
|
404
|
-
case "update":
|
|
405
|
-
return updateQuery(
|
|
406
|
-
{
|
|
407
|
-
model: "releases",
|
|
408
|
-
where: [{ field: "id", value: change.where.id }],
|
|
409
|
-
update: change.update,
|
|
410
|
-
},
|
|
411
|
-
guard,
|
|
412
|
-
);
|
|
413
|
-
case "delete":
|
|
414
|
-
return deleteQuery(
|
|
415
|
-
{
|
|
416
|
-
model: "releases",
|
|
417
|
-
where: [{ field: "id", value: change.where.id }],
|
|
418
|
-
},
|
|
419
|
-
guard,
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
case "releaseCatalogs":
|
|
423
|
-
return {
|
|
424
|
-
sql: `INSERT INTO release_catalogs (${Object.keys(change.row).join(", ")}) SELECT ${d1Placeholders(Object.keys(change.row).length)} WHERE ${guard.sql} ON CONFLICT(scope_key) DO UPDATE SET ${Object.keys(
|
|
425
|
-
change.row,
|
|
426
|
-
)
|
|
427
|
-
.filter((field) => field !== "scope_key")
|
|
428
|
-
.map((field) => `${field} = excluded.${field}`)
|
|
429
|
-
.join(", ")} RETURNING *`,
|
|
430
|
-
params: [...encodeD1Values(Object.values(change.row)), ...guard.params],
|
|
431
|
-
};
|
|
432
|
-
case "channels":
|
|
433
|
-
return change.operation === "insert"
|
|
434
|
-
? insertQuery(
|
|
435
|
-
{
|
|
436
|
-
model: "channels",
|
|
437
|
-
data: change.row,
|
|
438
|
-
onConflict: change.onConflict,
|
|
439
|
-
},
|
|
440
|
-
guard,
|
|
441
|
-
"ignore",
|
|
442
|
-
)
|
|
443
|
-
: {
|
|
444
|
-
sql: `DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS (SELECT 1 FROM releases WHERE channel_id = json_extract(?, '$')) AND ${guard.sql}`,
|
|
445
|
-
params: [
|
|
446
|
-
...encodeD1Values([change.where.id, change.where.id]),
|
|
447
|
-
...guard.params,
|
|
448
|
-
],
|
|
449
|
-
};
|
|
450
|
-
case "apiKeys":
|
|
451
|
-
return change.operation === "insert"
|
|
452
|
-
? insertQuery(
|
|
453
|
-
{
|
|
454
|
-
model: "api_keys",
|
|
455
|
-
data: change.row,
|
|
456
|
-
onConflict: change.onConflict,
|
|
457
|
-
},
|
|
458
|
-
guard,
|
|
459
|
-
"ignore",
|
|
460
|
-
)
|
|
461
|
-
: updateQuery(
|
|
462
|
-
{
|
|
463
|
-
model: "api_keys",
|
|
464
|
-
where: [{ field: "id", value: change.where.id }],
|
|
465
|
-
update: { revoked_at_ms: change.update.revokedAtMs },
|
|
466
|
-
},
|
|
467
|
-
guard,
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
const createCommitPlan = (input: DatabaseCommit): D1CommitPlan => {
|
|
473
|
-
const checks: D1Check[] = [];
|
|
474
|
-
const statements: D1Statement[] = [];
|
|
475
|
-
const expectations = input.expectations ?? [];
|
|
476
|
-
if (expectations.length > 0) {
|
|
477
|
-
const guard = expectationGuard(expectations);
|
|
478
|
-
statements.push({
|
|
479
|
-
sql: `SELECT CASE WHEN ${guard.sql} THEN 1 ELSE json_extract('${EXPECTATION_CONFLICT_MARKER}', '$') END AS expectation_guard`,
|
|
480
|
-
params: guard.params,
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
const requiredRows: D1RequiredRow[] = [];
|
|
484
|
-
const channelDeletes: D1ChannelDeletePrecondition[] = [];
|
|
485
|
-
const inserted = new Set<string>();
|
|
486
|
-
for (const [changeIndex, change] of input.changes.entries()) {
|
|
487
|
-
const required = requiredRow(change, changeIndex);
|
|
488
|
-
if (required !== undefined) {
|
|
489
|
-
if (!inserted.has(`${required.model}:${required.id}`)) {
|
|
490
|
-
requiredRows.push(required);
|
|
491
|
-
checks.push({
|
|
492
|
-
changeIndex,
|
|
493
|
-
resultIndex: statements.length,
|
|
494
|
-
conflictWhen: "empty",
|
|
495
|
-
reason: "not_found",
|
|
496
|
-
});
|
|
497
|
-
statements.push({
|
|
498
|
-
sql: `SELECT id FROM ${d1TableNames[required.model]} WHERE id = json_extract(?, '$') LIMIT 1`,
|
|
499
|
-
params: encodeD1Values([required.id]),
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
if (change.model === "channels" && change.operation === "delete") {
|
|
504
|
-
const precondition = channelDeletePrecondition(
|
|
505
|
-
input.changes,
|
|
506
|
-
changeIndex,
|
|
507
|
-
change.where.id,
|
|
508
|
-
);
|
|
509
|
-
channelDeletes.push(precondition);
|
|
510
|
-
checks.push({
|
|
511
|
-
changeIndex,
|
|
512
|
-
resultIndex: statements.length,
|
|
513
|
-
conflictWhen: "nonempty",
|
|
514
|
-
reason: "referenced",
|
|
515
|
-
});
|
|
516
|
-
statements.push({
|
|
517
|
-
sql: "SELECT id FROM releases WHERE channel_id = json_extract(?, '$') AND id NOT IN (SELECT value FROM json_each(?)) UNION ALL SELECT value AS id FROM json_each(?) LIMIT 1",
|
|
518
|
-
params: encodeD1Values([
|
|
519
|
-
precondition.id,
|
|
520
|
-
precondition.excludedReleaseIds,
|
|
521
|
-
precondition.addedReferenceReleaseIds,
|
|
522
|
-
]),
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
const key = insertedKey(change);
|
|
526
|
-
if (key !== undefined) inserted.add(key);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const guard: D1Guard = {
|
|
530
|
-
sql: `NOT EXISTS (
|
|
531
|
-
SELECT 1 FROM json_each(?) AS required
|
|
532
|
-
WHERE NOT EXISTS (
|
|
533
|
-
SELECT 1 FROM bundles
|
|
534
|
-
WHERE json_extract(required.value, '$.model') = 'bundles'
|
|
535
|
-
AND bundles.id = json_extract(required.value, '$.id')
|
|
536
|
-
UNION ALL
|
|
537
|
-
SELECT 1 FROM api_keys
|
|
538
|
-
WHERE json_extract(required.value, '$.model') = 'api_keys'
|
|
539
|
-
AND api_keys.id = json_extract(required.value, '$.id')
|
|
540
|
-
UNION ALL
|
|
541
|
-
SELECT 1 FROM channels
|
|
542
|
-
WHERE json_extract(required.value, '$.model') = 'channels'
|
|
543
|
-
AND channels.id = json_extract(required.value, '$.id')
|
|
544
|
-
UNION ALL
|
|
545
|
-
SELECT 1 FROM releases
|
|
546
|
-
WHERE json_extract(required.value, '$.model') = 'releases'
|
|
547
|
-
AND releases.id = json_extract(required.value, '$.id')
|
|
548
|
-
)
|
|
549
|
-
) AND NOT EXISTS (
|
|
550
|
-
SELECT 1 FROM json_each(?) AS channel_delete
|
|
551
|
-
WHERE EXISTS (
|
|
552
|
-
SELECT 1 FROM releases
|
|
553
|
-
WHERE releases.channel_id = json_extract(channel_delete.value, '$.id')
|
|
554
|
-
AND releases.id NOT IN (
|
|
555
|
-
SELECT value FROM json_each(
|
|
556
|
-
json_extract(channel_delete.value, '$.excludedReleaseIds')
|
|
557
|
-
)
|
|
558
|
-
)
|
|
559
|
-
UNION ALL
|
|
560
|
-
SELECT value FROM json_each(
|
|
561
|
-
json_extract(channel_delete.value, '$.addedReferenceReleaseIds')
|
|
562
|
-
)
|
|
563
|
-
)
|
|
564
|
-
)`,
|
|
565
|
-
params: encodeD1Values([
|
|
566
|
-
requiredRows.map(({ model, id }) => ({ model, id })),
|
|
567
|
-
channelDeletes,
|
|
568
|
-
]),
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
statements.push(...input.changes.map((change) => changeQuery(change, guard)));
|
|
572
|
-
return { checks, statements };
|
|
573
|
-
};
|
|
574
|
-
|
|
575
|
-
const resultForPlan = (
|
|
576
|
-
plan: D1CommitPlan,
|
|
577
|
-
results: readonly (readonly unknown[])[],
|
|
578
|
-
): DatabaseCommitResult => {
|
|
579
|
-
const missing = plan.checks.find(({ conflictWhen, resultIndex }) =>
|
|
580
|
-
conflictWhen === "empty"
|
|
581
|
-
? (results[resultIndex]?.length ?? 0) === 0
|
|
582
|
-
: (results[resultIndex]?.length ?? 0) > 0,
|
|
583
|
-
);
|
|
584
|
-
return missing === undefined
|
|
585
|
-
? { committed: true }
|
|
586
|
-
: {
|
|
587
|
-
committed: false,
|
|
588
|
-
conflict: {
|
|
589
|
-
changeIndex: missing.changeIndex,
|
|
590
|
-
reason: missing.reason,
|
|
591
|
-
},
|
|
592
|
-
};
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
const insertChannel = async (
|
|
596
|
-
executor: D1Executor,
|
|
597
|
-
input: ChannelInsertInput,
|
|
598
|
-
): Promise<ChannelInsertResult> => {
|
|
599
|
-
const insert = insertQuery(
|
|
600
|
-
{ model: "channels", data: input.row, onConflict: "ignore" },
|
|
601
|
-
undefined,
|
|
602
|
-
"ignore",
|
|
603
|
-
);
|
|
604
|
-
const select = {
|
|
605
|
-
sql: "SELECT id, name FROM channels WHERE name = json_extract(?, '$') LIMIT 1",
|
|
606
|
-
params: encodeD1Values([input.row.name]),
|
|
607
|
-
};
|
|
608
|
-
const [insertedRows = [], canonicalRows = []] = await executor.batch([
|
|
609
|
-
insert,
|
|
610
|
-
select,
|
|
611
|
-
]);
|
|
612
|
-
return {
|
|
613
|
-
row: parseD1Row("channels", canonicalRows[0]),
|
|
614
|
-
inserted: insertedRows.length > 0,
|
|
615
|
-
};
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
const deleteChannel = async (
|
|
619
|
-
executor: D1Executor,
|
|
620
|
-
input: ChannelDeleteInput,
|
|
621
|
-
): Promise<ChannelDeleteResult> => {
|
|
622
|
-
const [channelRows = [], referenceRows = [], deletedRows = []] =
|
|
623
|
-
await executor.batch([
|
|
624
|
-
{
|
|
625
|
-
sql: "SELECT id FROM channels WHERE id = json_extract(?, '$') LIMIT 1",
|
|
626
|
-
params: encodeD1Values([input.id]),
|
|
627
|
-
},
|
|
628
|
-
{
|
|
629
|
-
sql: "SELECT id FROM releases WHERE channel_id = json_extract(?, '$') LIMIT 1",
|
|
630
|
-
params: encodeD1Values([input.id]),
|
|
631
|
-
},
|
|
632
|
-
{
|
|
633
|
-
sql: "DELETE FROM channels WHERE id = json_extract(?, '$') AND NOT EXISTS (SELECT 1 FROM releases WHERE channel_id = json_extract(?, '$')) RETURNING id",
|
|
634
|
-
params: encodeD1Values([input.id, input.id]),
|
|
635
|
-
},
|
|
636
|
-
]);
|
|
637
|
-
if (channelRows.length === 0) return { deleted: false, reason: "not_found" };
|
|
638
|
-
if (referenceRows.length > 0) return { deleted: false, reason: "not_empty" };
|
|
639
|
-
return deletedRows.length > 0
|
|
640
|
-
? { deleted: true }
|
|
641
|
-
: { deleted: false, reason: "not_empty" };
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
export const createD1Implementation = (
|
|
645
|
-
executor: D1Executor,
|
|
646
|
-
): DatabasePluginImplementation => ({
|
|
647
|
-
async recordInsights({ event, installation }) {
|
|
648
|
-
const columns = Object.keys(installation);
|
|
649
|
-
const eventInsert = insertQuery({ model: "bundle_events", data: event });
|
|
650
|
-
// D1 executes the whole batch as one serial transaction. The snapshot
|
|
651
|
-
// guard runs before the event insert so a duplicate cannot mutate it.
|
|
652
|
-
await executor.batch([
|
|
653
|
-
{
|
|
654
|
-
sql: `INSERT INTO bundle_installations (${columns.join(", ")})
|
|
655
|
-
SELECT ${d1Placeholders(columns.length)}
|
|
656
|
-
WHERE NOT EXISTS (SELECT 1 FROM bundle_events WHERE id = json_extract(?, '$'))
|
|
657
|
-
ON CONFLICT(install_id) DO UPDATE SET ${columns
|
|
658
|
-
.filter((column) => column !== "install_id")
|
|
659
|
-
.map((column) => `${column} = excluded.${column}`)
|
|
660
|
-
.join(", ")}
|
|
661
|
-
WHERE (excluded.received_at_ms, excluded.id) >
|
|
662
|
-
(bundle_installations.received_at_ms, bundle_installations.id)`,
|
|
663
|
-
params: encodeD1Values([...Object.values(installation), event.id]),
|
|
664
|
-
},
|
|
665
|
-
{
|
|
666
|
-
...eventInsert,
|
|
667
|
-
sql: eventInsert.sql.replace(
|
|
668
|
-
" RETURNING *",
|
|
669
|
-
" ON CONFLICT(id) DO NOTHING",
|
|
670
|
-
),
|
|
671
|
-
},
|
|
672
|
-
]);
|
|
673
|
-
},
|
|
674
|
-
async create(input) {
|
|
675
|
-
const query = insertQuery(input);
|
|
676
|
-
const rows = await executor.query(query.sql, query.params);
|
|
677
|
-
switch (input.model) {
|
|
678
|
-
case "bundles":
|
|
679
|
-
return parseD1Row("bundles", rows[0]);
|
|
680
|
-
case "bundle_patches":
|
|
681
|
-
return parseD1Row("bundle_patches", rows[0]);
|
|
682
|
-
case "channels":
|
|
683
|
-
return parseD1Row("channels", rows[0]);
|
|
684
|
-
case "bundle_events":
|
|
685
|
-
return parseD1Row("bundle_events", rows[0]);
|
|
686
|
-
case "bundle_installations":
|
|
687
|
-
return parseD1Row("bundle_installations", rows[0]);
|
|
688
|
-
case "api_keys":
|
|
689
|
-
return parseD1Row("api_keys", rows[0]);
|
|
690
|
-
case "releases":
|
|
691
|
-
return parseD1Row("releases", rows[0]);
|
|
692
|
-
case "release_catalogs":
|
|
693
|
-
return parseD1Row("release_catalogs", rows[0]);
|
|
694
|
-
}
|
|
695
|
-
},
|
|
696
|
-
async update(input) {
|
|
697
|
-
const query = updateQuery(input);
|
|
698
|
-
const rows = await executor.query(query.sql, query.params);
|
|
699
|
-
if (rows[0] === undefined) return null;
|
|
700
|
-
switch (input.model) {
|
|
701
|
-
case "bundles":
|
|
702
|
-
return parseD1Row("bundles", rows[0]);
|
|
703
|
-
case "api_keys":
|
|
704
|
-
return parseD1Row("api_keys", rows[0]);
|
|
705
|
-
case "releases":
|
|
706
|
-
return parseD1Row("releases", rows[0]);
|
|
707
|
-
case "release_catalogs":
|
|
708
|
-
return parseD1Row("release_catalogs", rows[0]);
|
|
709
|
-
case "bundle_installations":
|
|
710
|
-
return parseD1Row("bundle_installations", rows[0]);
|
|
711
|
-
}
|
|
712
|
-
},
|
|
713
|
-
async delete(input) {
|
|
714
|
-
const query = deleteQuery(input);
|
|
715
|
-
await executor.query(query.sql, query.params);
|
|
716
|
-
},
|
|
717
|
-
count: (input) => countD1Rows(executor, input),
|
|
718
|
-
async findOne(input: FindOneDatabaseImplementationInput) {
|
|
719
|
-
const where = buildD1Where(input.where);
|
|
720
|
-
const rows = await executor.query(
|
|
721
|
-
`SELECT * FROM ${d1TableNames[input.model]}${where.sql} LIMIT 1`,
|
|
722
|
-
where.params,
|
|
723
|
-
);
|
|
724
|
-
if (rows[0] === undefined) return null;
|
|
725
|
-
switch (input.model) {
|
|
726
|
-
case "bundles":
|
|
727
|
-
return parseD1Row("bundles", rows[0]);
|
|
728
|
-
case "bundle_patches":
|
|
729
|
-
return parseD1Row("bundle_patches", rows[0]);
|
|
730
|
-
case "channels":
|
|
731
|
-
return parseD1Row("channels", rows[0]);
|
|
732
|
-
case "api_keys":
|
|
733
|
-
return parseD1Row("api_keys", rows[0]);
|
|
734
|
-
case "releases":
|
|
735
|
-
return parseD1Row("releases", rows[0]);
|
|
736
|
-
case "release_catalogs":
|
|
737
|
-
return parseD1Row("release_catalogs", rows[0]);
|
|
738
|
-
case "bundle_installations":
|
|
739
|
-
return parseD1Row("bundle_installations", rows[0]);
|
|
740
|
-
}
|
|
741
|
-
},
|
|
742
|
-
findMany: (input) => findManyD1Rows(executor, input),
|
|
743
|
-
insertChannel: (input) => insertChannel(executor, input),
|
|
744
|
-
deleteChannel: (input) => deleteChannel(executor, input),
|
|
745
|
-
async commit(input) {
|
|
746
|
-
if (input.changes.length === 0) return { committed: true };
|
|
747
|
-
const expectations = input.expectations ?? [];
|
|
748
|
-
const conflict = await expectationConflict(executor, expectations);
|
|
749
|
-
if (conflict !== null) return conflict;
|
|
750
|
-
const plan = createCommitPlan(input);
|
|
751
|
-
try {
|
|
752
|
-
return resultForPlan(plan, await executor.batch(plan.statements));
|
|
753
|
-
} catch (error) {
|
|
754
|
-
if (expectations.length === 0 || !isExpectationConflictError(error)) {
|
|
755
|
-
throw error;
|
|
756
|
-
}
|
|
757
|
-
return (
|
|
758
|
-
(await expectationConflict(executor, expectations)) ?? {
|
|
759
|
-
committed: false,
|
|
760
|
-
conflict: {
|
|
761
|
-
actualVersion:
|
|
762
|
-
expectations[0].model === "releases"
|
|
763
|
-
? expectations[0].revision
|
|
764
|
-
: expectations[0].generation,
|
|
765
|
-
changeIndex: -1,
|
|
766
|
-
expectedVersion:
|
|
767
|
-
expectations[0].model === "releases"
|
|
768
|
-
? expectations[0].revision
|
|
769
|
-
: expectations[0].generation,
|
|
770
|
-
key:
|
|
771
|
-
expectations[0].model === "releases"
|
|
772
|
-
? expectations[0].id
|
|
773
|
-
: expectations[0].scopeKey,
|
|
774
|
-
model: expectations[0].model,
|
|
775
|
-
reason: "version_conflict",
|
|
776
|
-
},
|
|
777
|
-
}
|
|
778
|
-
);
|
|
779
|
-
}
|
|
780
|
-
},
|
|
781
|
-
});
|