@hot-updater/cloudflare 1.0.0-rc.18 → 1.0.0-rc.21
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 +7 -9
- package/dist/index.d.cts +323 -35
- package/dist/index.d.mts +323 -35
- package/dist/index.mjs +2 -4
- package/dist/init/index.cjs +11768 -0
- package/dist/init/index.d.cts +5 -1
- package/dist/init/index.d.mts +5 -1
- package/dist/init/index.mjs +11761 -1
- package/dist/worker/index.cjs +4 -5
- package/dist/worker/index.d.cts +323 -35
- package/dist/worker/index.d.mts +323 -35
- package/dist/worker/index.mjs +2 -3
- package/package.json +8 -11
- package/src/d1Database.ts +5 -2
- package/src/d1Executor.ts +2 -2
- package/src/d1Schema.ts +3 -2
- package/src/r2S3Storage.ts +4 -4
- package/src/r2Storage.ts +2 -2
- package/src/r2WorkerStorage.ts +4 -4
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +4804 -4720
- package/worker/dist/index.js.map +4 -4
- package/dist/iac/index.cjs +0 -11838
- package/dist/iac/index.d.cts +0 -5
- package/dist/iac/index.d.mts +0 -5
- package/dist/iac/index.mjs +0 -11830
- package/src/d1Database.spec.ts +0 -190
- package/src/d1Schema.spec.ts +0 -89
- package/src/d1TestDatabase.ts +0 -38
- 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 -59
- package/worker/src/d1.conformance.integration.spec.ts +0 -53
- package/worker/src/d1.readBudgets.integration.spec.ts +0 -85
- package/worker/src/databaseMigration.integration.spec.ts +0 -101
- package/worker/src/databasePlugins.integration.spec.ts +0 -318
- package/worker/src/index.integration.spec.ts +0 -182
- package/worker/src/readPlans.integration.spec.ts +0 -89
- package/worker/src/storagePlugin.integration.spec.ts +0 -59
- package/worker/src/testWorker.ts +0 -5
package/src/d1Database.spec.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import type { DeployReleasePolicy } from "@hot-updater/plugin-core";
|
|
2
|
-
import { createHotUpdater } from "@hot-updater/server";
|
|
3
|
-
import { DatabaseConstraintError } from "@hot-updater/server/database";
|
|
4
|
-
import {
|
|
5
|
-
createDatabaseCoreApi,
|
|
6
|
-
createMigrator,
|
|
7
|
-
generateSchema,
|
|
8
|
-
} from "@hot-updater/server/db";
|
|
9
|
-
import { definePlugin, defineTable } from "@hot-updater/server/plugins";
|
|
10
|
-
import { beforeEach, expect, it, vi } from "vitest";
|
|
11
|
-
|
|
12
|
-
import { createBundleFixture } from "../../../packages/test-utils/src/databaseTestFixtures";
|
|
13
|
-
import { d1Database } from "./d1Database";
|
|
14
|
-
import { createD1TestDatabase } from "./d1TestDatabase";
|
|
15
|
-
|
|
16
|
-
type Body = {
|
|
17
|
-
readonly sql?: string;
|
|
18
|
-
readonly params?: readonly string[];
|
|
19
|
-
readonly batch?: readonly { sql: string; params: readonly string[] }[];
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const state = vi.hoisted(() => ({
|
|
23
|
-
database: undefined as ReturnType<typeof createD1TestDatabase> | undefined,
|
|
24
|
-
bodies: [] as Body[],
|
|
25
|
-
}));
|
|
26
|
-
|
|
27
|
-
/** The REST API over `node:sqlite`, recording each request body. */
|
|
28
|
-
vi.mock("cloudflare", () => ({
|
|
29
|
-
default: class MockCloudflare {
|
|
30
|
-
readonly d1 = {
|
|
31
|
-
database: {
|
|
32
|
-
query: async (_databaseId: string, body: Body) => {
|
|
33
|
-
state.bodies.push(body);
|
|
34
|
-
const result = state.database!.batch(
|
|
35
|
-
body.batch ?? [{ sql: body.sql!, params: body.params ?? [] }],
|
|
36
|
-
);
|
|
37
|
-
return {
|
|
38
|
-
async *iterPages() {
|
|
39
|
-
yield { result };
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
},
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
const config = {
|
|
49
|
-
accountId: "account-id",
|
|
50
|
-
cloudflareApiToken: "api-token",
|
|
51
|
-
databaseId: "database-id",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/** Core's API over the REST database, as the CLI and console run it. */
|
|
55
|
-
const core = () => createDatabaseCoreApi(d1Database(config));
|
|
56
|
-
|
|
57
|
-
/** A plugin with one table. */
|
|
58
|
-
const notes = definePlugin({
|
|
59
|
-
id: "notes",
|
|
60
|
-
schemaVersion: "1",
|
|
61
|
-
schema: {
|
|
62
|
-
notes: defineTable(
|
|
63
|
-
{ id: { type: "string" }, text: { type: "string" } },
|
|
64
|
-
{ key: ["id"] },
|
|
65
|
-
),
|
|
66
|
-
},
|
|
67
|
-
init: ({ db }) => ({
|
|
68
|
-
api: {
|
|
69
|
-
add: (id: string, text: string) =>
|
|
70
|
-
db.transaction(async (tx) => {
|
|
71
|
-
tx.create("notes", { id, text });
|
|
72
|
-
}),
|
|
73
|
-
read: (id: string) => db.findOne("notes", { id }),
|
|
74
|
-
},
|
|
75
|
-
}),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const server = () =>
|
|
79
|
-
createHotUpdater({
|
|
80
|
-
database: d1Database(config),
|
|
81
|
-
plugins: [notes],
|
|
82
|
-
clientAccess: "public",
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const release: DeployReleasePolicy = {
|
|
86
|
-
channel: "production",
|
|
87
|
-
enabled: true,
|
|
88
|
-
fingerprintHash: null,
|
|
89
|
-
message: null,
|
|
90
|
-
shouldForceUpdate: false,
|
|
91
|
-
targetAppVersion: "1.0.0",
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
beforeEach(() => {
|
|
95
|
-
state.database = createD1TestDatabase();
|
|
96
|
-
state.bodies = [];
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("binds every value as JSON text and reads it back with json_extract", async () => {
|
|
100
|
-
const channel = await core().ensureChannel("production");
|
|
101
|
-
await expect(core().listChannels()).resolves.toEqual([channel]);
|
|
102
|
-
const statements = state.bodies.flatMap(
|
|
103
|
-
(body) => body.batch ?? [{ sql: body.sql!, params: body.params ?? [] }],
|
|
104
|
-
);
|
|
105
|
-
for (const { sql, params } of statements) {
|
|
106
|
-
expect(sql.match(/\?/gu)?.length ?? 0).toBe(
|
|
107
|
-
sql.match(/json_extract\(\?, '\$'\)/gu)?.length ?? 0,
|
|
108
|
-
);
|
|
109
|
-
for (const param of params) expect(() => JSON.parse(param)).not.toThrow();
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("sends a write as one batch: the guard row, each guard, the changes, then the failure", async () => {
|
|
114
|
-
await core().ensureChannel("production");
|
|
115
|
-
state.bodies = [];
|
|
116
|
-
const [deployed] = await core().deploy([
|
|
117
|
-
{ bundle: createBundleFixture("1"), release },
|
|
118
|
-
]);
|
|
119
|
-
const writes = state.bodies.filter((body) => body.batch !== undefined);
|
|
120
|
-
expect(writes).toHaveLength(1);
|
|
121
|
-
const sql = writes[0]!.batch!.map((statement) => statement.sql);
|
|
122
|
-
expect(sql[0]).toMatch(/^INSERT INTO "_hu_write"/u);
|
|
123
|
-
expect(sql.at(-2)).toMatch(/^SELECT "failed_op" FROM "_hu_write"/u);
|
|
124
|
-
expect(sql.at(-1)).toMatch(/^DELETE FROM "_hu_write"/u);
|
|
125
|
-
await expect(core().getRelease(deployed!.release!.id)).resolves.toEqual(
|
|
126
|
-
deployed!.release,
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("applies nothing when a guard fails", async () => {
|
|
131
|
-
const bundle = createBundleFixture("2");
|
|
132
|
-
// The patch's base bundle does not exist, so the guard on its row fails.
|
|
133
|
-
await expect(
|
|
134
|
-
core().deploy([
|
|
135
|
-
{
|
|
136
|
-
bundle: {
|
|
137
|
-
...bundle,
|
|
138
|
-
patches: [
|
|
139
|
-
{
|
|
140
|
-
baseBundleId: createBundleFixture("1").id,
|
|
141
|
-
baseFileHash: "base-hash",
|
|
142
|
-
byteSize: 1,
|
|
143
|
-
patchFileHash: "patch-hash",
|
|
144
|
-
patchStorageUri: "storage://patches/2.patch",
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
},
|
|
148
|
-
release,
|
|
149
|
-
},
|
|
150
|
-
]),
|
|
151
|
-
).rejects.toBeInstanceOf(DatabaseConstraintError);
|
|
152
|
-
await expect(core().getBundle(bundle.id)).resolves.toBeNull();
|
|
153
|
-
await expect(
|
|
154
|
-
core().listReleases({ limit: 10, filter: { kind: "all" } }),
|
|
155
|
-
).resolves.toEqual([]);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("generates a wrangler migration with a server's plugin tables", async () => {
|
|
159
|
-
const hotUpdater = server();
|
|
160
|
-
const migration = generateSchema(hotUpdater, "latest");
|
|
161
|
-
expect(migration.path).toMatch(/^migrations\/\d{14}_hot-updater\.sql$/u);
|
|
162
|
-
await expect(hotUpdater.api.notes.read("n1")).rejects.toThrow(
|
|
163
|
-
'"schema.notes" for d1Database is missing',
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
state.database!.db.exec(migration.code);
|
|
167
|
-
await hotUpdater.api.notes.add("n1", "hello");
|
|
168
|
-
await expect(hotUpdater.api.notes.read("n1")).resolves.toEqual({
|
|
169
|
-
id: "n1",
|
|
170
|
-
text: "hello",
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("migrates an empty database's tables, write guard included, through the API", async () => {
|
|
175
|
-
state.database = createD1TestDatabase([]);
|
|
176
|
-
const hotUpdater = server();
|
|
177
|
-
const result = await createMigrator(hotUpdater).migrateToLatest({
|
|
178
|
-
mode: "from-schema",
|
|
179
|
-
updateSettings: true,
|
|
180
|
-
});
|
|
181
|
-
await result.execute();
|
|
182
|
-
|
|
183
|
-
await hotUpdater.api.notes.add("n1", "hello");
|
|
184
|
-
const [deployed] = await hotUpdater.core.deploy([
|
|
185
|
-
{ bundle: createBundleFixture("1"), release },
|
|
186
|
-
]);
|
|
187
|
-
await expect(
|
|
188
|
-
hotUpdater.core.getRelease(deployed!.release!.id),
|
|
189
|
-
).resolves.toEqual(deployed!.release);
|
|
190
|
-
});
|
package/src/d1Schema.spec.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { DatabaseSync, type SqliteValue } from "node:sqlite";
|
|
4
|
-
|
|
5
|
-
import { toolingTargetOf } from "@hot-updater/server/database";
|
|
6
|
-
import {
|
|
7
|
-
createDatabaseCoreApi,
|
|
8
|
-
HotUpdaterSchemaMigrationRequiredError,
|
|
9
|
-
} from "@hot-updater/server/db";
|
|
10
|
-
import { describe, expect, it } from "vitest";
|
|
11
|
-
|
|
12
|
-
import { createBundleFixture } from "../../../packages/test-utils/src/databaseTestFixtures";
|
|
13
|
-
import { createD1Database } from "./d1Executor";
|
|
14
|
-
import { d1SchemaSql, d1SchemaStatements } from "./d1Schema";
|
|
15
|
-
import { plugins } from "./plugins";
|
|
16
|
-
|
|
17
|
-
/** The managed Worker's migration: core's tables and its plugins'. */
|
|
18
|
-
const managedSql = () => d1SchemaSql(toolingTargetOf(plugins));
|
|
19
|
-
|
|
20
|
-
const FILES = [
|
|
21
|
-
"plugins/cloudflare/sql/bundles.sql",
|
|
22
|
-
"plugins/cloudflare/worker/migrations/0001_hot-updater_1.0.0.sql",
|
|
23
|
-
].map((file) => path.resolve(file));
|
|
24
|
-
|
|
25
|
-
/** D1's API over `node:sqlite`: statements one at a time, batches in one transaction. */
|
|
26
|
-
const sqliteD1 = (db: DatabaseSync) => {
|
|
27
|
-
const run = (sql: string, params: readonly unknown[]) => {
|
|
28
|
-
const statement = db.prepare(sql);
|
|
29
|
-
const values = params as SqliteValue[];
|
|
30
|
-
return statement.columns().length > 0
|
|
31
|
-
? { rows: statement.all(...values), changes: 0 }
|
|
32
|
-
: { rows: [], changes: Number(statement.run(...values).changes) };
|
|
33
|
-
};
|
|
34
|
-
return createD1Database({
|
|
35
|
-
query: async ({ sql, params }) => run(sql, params),
|
|
36
|
-
batch: async (batch) => {
|
|
37
|
-
db.exec("BEGIN IMMEDIATE");
|
|
38
|
-
try {
|
|
39
|
-
const results = batch.map(({ sql, params }) => run(sql, params));
|
|
40
|
-
db.exec("COMMIT");
|
|
41
|
-
return results;
|
|
42
|
-
} catch (error) {
|
|
43
|
-
db.exec("ROLLBACK");
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
describe("d1 schema", () => {
|
|
51
|
-
it("checks in exactly the generated schema as the SQL file and the first migration", async () => {
|
|
52
|
-
if (process.env.HOT_UPDATER_UPDATE_SQL === "1") {
|
|
53
|
-
for (const file of FILES) await fs.writeFile(file, managedSql());
|
|
54
|
-
}
|
|
55
|
-
// Regenerate with HOT_UPDATER_UPDATE_SQL=1 after the schema changes.
|
|
56
|
-
for (const file of FILES) {
|
|
57
|
-
expect(await fs.readFile(file, "utf8")).toBe(managedSql());
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("serves once the schema is applied, writing each change as one batch", async () => {
|
|
62
|
-
const db = new DatabaseSync(":memory:");
|
|
63
|
-
const core = createDatabaseCoreApi(sqliteD1(db));
|
|
64
|
-
await expect(core.listChannels()).rejects.toBeInstanceOf(
|
|
65
|
-
HotUpdaterSchemaMigrationRequiredError,
|
|
66
|
-
);
|
|
67
|
-
for (const statement of d1SchemaStatements()) db.exec(statement);
|
|
68
|
-
const [deployed] = await core.deploy([
|
|
69
|
-
{
|
|
70
|
-
bundle: createBundleFixture("1"),
|
|
71
|
-
release: {
|
|
72
|
-
channel: "production",
|
|
73
|
-
enabled: true,
|
|
74
|
-
fingerprintHash: null,
|
|
75
|
-
message: null,
|
|
76
|
-
shouldForceUpdate: false,
|
|
77
|
-
targetAppVersion: "1.0.0",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
]);
|
|
81
|
-
await expect(core.getRelease(deployed!.release!.id)).resolves.toEqual(
|
|
82
|
-
deployed!.release,
|
|
83
|
-
);
|
|
84
|
-
expect(db.prepare("SELECT COUNT(*) AS n FROM _hu_write").all()).toEqual([
|
|
85
|
-
{ n: 0 },
|
|
86
|
-
]);
|
|
87
|
-
db.close();
|
|
88
|
-
});
|
|
89
|
-
});
|
package/src/d1TestDatabase.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { DatabaseSync, type SqliteValue } from "node:sqlite";
|
|
2
|
-
|
|
3
|
-
import type { D1ResultLike } from "./d1Executor";
|
|
4
|
-
import { d1SchemaStatements } from "./d1Schema";
|
|
5
|
-
|
|
6
|
-
/** A D1 database over `node:sqlite`, answering as D1 does; migrated unless given other statements. */
|
|
7
|
-
export const createD1TestDatabase = (
|
|
8
|
-
statements: readonly string[] = d1SchemaStatements(),
|
|
9
|
-
) => {
|
|
10
|
-
const db = new DatabaseSync(":memory:");
|
|
11
|
-
for (const sql of statements) db.exec(sql);
|
|
12
|
-
const run = (sql: string, params: readonly unknown[]): D1ResultLike => {
|
|
13
|
-
const statement = db.prepare(sql);
|
|
14
|
-
const values = params as SqliteValue[];
|
|
15
|
-
return statement.columns().length > 0
|
|
16
|
-
? { success: true, results: statement.all(...values), meta: {} }
|
|
17
|
-
: {
|
|
18
|
-
success: true,
|
|
19
|
-
results: [],
|
|
20
|
-
meta: { changes: Number(statement.run(...values).changes) },
|
|
21
|
-
};
|
|
22
|
-
};
|
|
23
|
-
/** Runs statements atomically, as `batch` does. */
|
|
24
|
-
const batch = (
|
|
25
|
-
statements: readonly { sql: string; params: readonly unknown[] }[],
|
|
26
|
-
) => {
|
|
27
|
-
db.exec("BEGIN IMMEDIATE");
|
|
28
|
-
try {
|
|
29
|
-
const results = statements.map(({ sql, params }) => run(sql, params));
|
|
30
|
-
db.exec("COMMIT");
|
|
31
|
-
return results;
|
|
32
|
-
} catch (error) {
|
|
33
|
-
db.exec("ROLLBACK");
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
return { db, run, batch };
|
|
38
|
-
};
|
package/src/r2Storage.spec.ts
DELETED
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
import { Buffer } from "buffer";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
DeleteObjectCommand,
|
|
5
|
-
GetObjectCommand,
|
|
6
|
-
HeadObjectCommand,
|
|
7
|
-
S3Client,
|
|
8
|
-
} from "@aws-sdk/client-s3";
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
-
|
|
11
|
-
import { r2Storage } from "./r2Storage";
|
|
12
|
-
|
|
13
|
-
let fakeStore: Record<string, Buffer> = {};
|
|
14
|
-
let deletedKeys: string[] = [];
|
|
15
|
-
let uploadedParams:
|
|
16
|
-
| {
|
|
17
|
-
Body: ReadableStream<Uint8Array>;
|
|
18
|
-
Bucket: string;
|
|
19
|
-
CacheControl?: string;
|
|
20
|
-
ContentLength?: number;
|
|
21
|
-
ContentType?: string;
|
|
22
|
-
Key: string;
|
|
23
|
-
}
|
|
24
|
-
| undefined;
|
|
25
|
-
|
|
26
|
-
const createBody = (value: string) => new Response(value).body!;
|
|
27
|
-
|
|
28
|
-
vi.mock("@aws-sdk/lib-storage", () => ({
|
|
29
|
-
Upload: class {
|
|
30
|
-
params: {
|
|
31
|
-
Body: ReadableStream<Uint8Array>;
|
|
32
|
-
Bucket: string;
|
|
33
|
-
CacheControl?: string;
|
|
34
|
-
ContentLength?: number;
|
|
35
|
-
ContentType?: string;
|
|
36
|
-
Key: string;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
constructor({
|
|
40
|
-
params,
|
|
41
|
-
}: {
|
|
42
|
-
params: {
|
|
43
|
-
Body: ReadableStream<Uint8Array>;
|
|
44
|
-
Bucket: string;
|
|
45
|
-
CacheControl?: string;
|
|
46
|
-
ContentLength?: number;
|
|
47
|
-
ContentType?: string;
|
|
48
|
-
Key: string;
|
|
49
|
-
};
|
|
50
|
-
}) {
|
|
51
|
-
this.params = params;
|
|
52
|
-
uploadedParams = params;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async done() {
|
|
56
|
-
fakeStore[this.params.Key] = Buffer.from(
|
|
57
|
-
await new Response(this.params.Body).arrayBuffer(),
|
|
58
|
-
);
|
|
59
|
-
return {
|
|
60
|
-
Bucket: this.params.Bucket,
|
|
61
|
-
Key: this.params.Key,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
}));
|
|
66
|
-
|
|
67
|
-
describe("r2Storage", () => {
|
|
68
|
-
beforeEach(() => {
|
|
69
|
-
fakeStore = {};
|
|
70
|
-
deletedKeys = [];
|
|
71
|
-
uploadedParams = undefined;
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
vi.restoreAllMocks();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const mockS3Client = () => {
|
|
79
|
-
return vi
|
|
80
|
-
.spyOn(S3Client.prototype, "send")
|
|
81
|
-
.mockImplementation(async (command: any) => {
|
|
82
|
-
if (command instanceof HeadObjectCommand) {
|
|
83
|
-
const key = command.input.Key!;
|
|
84
|
-
if (fakeStore[key]) {
|
|
85
|
-
return {};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const error = new Error("Not found");
|
|
89
|
-
error.name = "NotFound";
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (command instanceof GetObjectCommand) {
|
|
94
|
-
const key = command.input.Key!;
|
|
95
|
-
const object = fakeStore[key];
|
|
96
|
-
if (!object) {
|
|
97
|
-
const error = new Error("No such key");
|
|
98
|
-
error.name = "NoSuchKey";
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
Body: {
|
|
104
|
-
transformToByteArray: async () => new Uint8Array(object),
|
|
105
|
-
transformToString: async () => object.toString("utf8"),
|
|
106
|
-
transformToWebStream: () => new Response(object).body!,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (command instanceof DeleteObjectCommand) {
|
|
112
|
-
deletedKeys.push(command.input.Key!);
|
|
113
|
-
delete fakeStore[command.input.Key!];
|
|
114
|
-
return {};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
throw new Error("Unsupported command");
|
|
118
|
-
});
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
it("uploads R2 objects through the S3 API when credentials are provided", async () => {
|
|
122
|
-
mockS3Client();
|
|
123
|
-
|
|
124
|
-
const storage = r2Storage({
|
|
125
|
-
accountId: "account-id",
|
|
126
|
-
bucketName: "test-bucket",
|
|
127
|
-
credentials: {
|
|
128
|
-
accessKeyId: "access-key-id",
|
|
129
|
-
secretAccessKey: "secret-access-key",
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
await expect(
|
|
134
|
-
storage.put?.({
|
|
135
|
-
key: "releases/bundle-1/hot-updater-r2-upload.txt",
|
|
136
|
-
body: createBody("hello r2"),
|
|
137
|
-
contentLength: 8,
|
|
138
|
-
contentType: "text/plain",
|
|
139
|
-
}),
|
|
140
|
-
).resolves.toEqual({
|
|
141
|
-
storageUri:
|
|
142
|
-
"r2://test-bucket/releases/bundle-1/hot-updater-r2-upload.txt",
|
|
143
|
-
});
|
|
144
|
-
expect(
|
|
145
|
-
fakeStore["releases/bundle-1/hot-updater-r2-upload.txt"].toString(),
|
|
146
|
-
).toBe("hello r2");
|
|
147
|
-
expect(uploadedParams).toMatchObject({
|
|
148
|
-
ContentLength: 8,
|
|
149
|
-
ContentType: "text/plain",
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("round-trips spaces, Unicode, fragments, and percent signs in S3 keys", async () => {
|
|
154
|
-
mockS3Client();
|
|
155
|
-
const storage = r2Storage({
|
|
156
|
-
accountId: "account-id",
|
|
157
|
-
bucketName: "test-bucket",
|
|
158
|
-
credentials: {
|
|
159
|
-
accessKeyId: "access-key-id",
|
|
160
|
-
secretAccessKey: "secret-access-key",
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
const key = "릴리스 folder/logo@2x #100%/bundle.zip";
|
|
164
|
-
|
|
165
|
-
const uploaded = await storage.put?.({
|
|
166
|
-
key,
|
|
167
|
-
body: createBody("bundle"),
|
|
168
|
-
contentLength: 6,
|
|
169
|
-
contentType: "application/zip",
|
|
170
|
-
});
|
|
171
|
-
expect(uploaded?.storageUri).toContain("logo%402x");
|
|
172
|
-
expect(uploaded?.storageUri).not.toContain("#100%");
|
|
173
|
-
await expect(
|
|
174
|
-
storage.get?.({ storageUri: uploaded!.storageUri }),
|
|
175
|
-
).resolves.toEqual({ response: expect.any(Response) });
|
|
176
|
-
await storage.delete?.({ storageUri: uploaded!.storageUri });
|
|
177
|
-
await storage.delete?.({ storageUri: uploaded!.storageUri });
|
|
178
|
-
|
|
179
|
-
expect(deletedKeys).toEqual([key, key]);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("downloads R2 objects through the S3 API when credentials are provided", async () => {
|
|
183
|
-
mockS3Client();
|
|
184
|
-
fakeStore["releases/bundle-1/manifest.json"] = Buffer.from(
|
|
185
|
-
JSON.stringify({
|
|
186
|
-
assets: {},
|
|
187
|
-
bundleId: "bundle-1",
|
|
188
|
-
}),
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
const storage = r2Storage({
|
|
192
|
-
accountId: "account-id",
|
|
193
|
-
bucketName: "test-bucket",
|
|
194
|
-
credentials: {
|
|
195
|
-
accessKeyId: "access-key-id",
|
|
196
|
-
secretAccessKey: "secret-access-key",
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const { response } = (await storage.get?.({
|
|
201
|
-
storageUri: "r2://test-bucket/releases/bundle-1/manifest.json",
|
|
202
|
-
})) ?? { response: null };
|
|
203
|
-
|
|
204
|
-
expect(JSON.parse((await response?.text()) ?? "")).toEqual({
|
|
205
|
-
assets: {},
|
|
206
|
-
bundleId: "bundle-1",
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("checks R2 object existence through the S3 API", async () => {
|
|
211
|
-
mockS3Client();
|
|
212
|
-
fakeStore["releases/logo.png"] = Buffer.from("logo");
|
|
213
|
-
|
|
214
|
-
const storage = r2Storage({
|
|
215
|
-
accountId: "account-id",
|
|
216
|
-
bucketName: "test-bucket",
|
|
217
|
-
credentials: {
|
|
218
|
-
accessKeyId: "access-key-id",
|
|
219
|
-
secretAccessKey: "secret-access-key",
|
|
220
|
-
},
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
await expect(
|
|
224
|
-
storage.exists?.({ storageUri: "r2://test-bucket/releases/logo.png" }),
|
|
225
|
-
).resolves.toEqual({ exists: true });
|
|
226
|
-
await expect(
|
|
227
|
-
storage.exists?.({ storageUri: "r2://test-bucket/releases/missing.png" }),
|
|
228
|
-
).resolves.toEqual({ exists: false });
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("deletes R2 objects through the S3 API", async () => {
|
|
232
|
-
mockS3Client();
|
|
233
|
-
fakeStore["releases/logo.png"] = Buffer.from("logo");
|
|
234
|
-
|
|
235
|
-
const storage = r2Storage({
|
|
236
|
-
accountId: "account-id",
|
|
237
|
-
bucketName: "test-bucket",
|
|
238
|
-
credentials: {
|
|
239
|
-
accessKeyId: "access-key-id",
|
|
240
|
-
secretAccessKey: "secret-access-key",
|
|
241
|
-
},
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
await expect(
|
|
245
|
-
storage.delete?.({
|
|
246
|
-
storageUri: "r2://test-bucket/releases/logo.png",
|
|
247
|
-
}),
|
|
248
|
-
).resolves.toEqual({
|
|
249
|
-
deleted: true,
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
expect(deletedKeys).toEqual(["releases/logo.png"]);
|
|
253
|
-
expect(fakeStore["releases/logo.png"]).toBeUndefined();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("reads R2 text through the runtime S3 API when credentials are provided", async () => {
|
|
257
|
-
mockS3Client();
|
|
258
|
-
fakeStore["releases/bundle-1/manifest.json"] = Buffer.from(
|
|
259
|
-
JSON.stringify({
|
|
260
|
-
assets: {},
|
|
261
|
-
bundleId: "bundle-1",
|
|
262
|
-
}),
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const storage = r2Storage({
|
|
266
|
-
accountId: "account-id",
|
|
267
|
-
bucketName: "test-bucket",
|
|
268
|
-
credentials: {
|
|
269
|
-
accessKeyId: "access-key-id",
|
|
270
|
-
secretAccessKey: "secret-access-key",
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const { response } = (await storage.get?.({
|
|
275
|
-
storageUri: "r2://test-bucket/releases/bundle-1/manifest.json",
|
|
276
|
-
})) ?? { response: null };
|
|
277
|
-
expect(await response?.text()).toBe('{"assets":{},"bundleId":"bundle-1"}');
|
|
278
|
-
await expect(
|
|
279
|
-
storage.get?.({
|
|
280
|
-
storageUri: "r2://test-bucket/releases/missing.json",
|
|
281
|
-
}),
|
|
282
|
-
).resolves.toEqual({ response: null });
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("rejects configs without S3 credentials", () => {
|
|
286
|
-
expect(() =>
|
|
287
|
-
r2Storage({
|
|
288
|
-
accountId: "account-id",
|
|
289
|
-
bucketName: "test-bucket",
|
|
290
|
-
} as never),
|
|
291
|
-
).toThrow("r2Storage requires S3-compatible credentials");
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("does not add getDownloadUrl to a deploy-only configuration", () => {
|
|
295
|
-
const storage = r2Storage({
|
|
296
|
-
accountId: "account-id",
|
|
297
|
-
bucketName: "test-bucket",
|
|
298
|
-
credentials: {
|
|
299
|
-
accessKeyId: "access-key-id",
|
|
300
|
-
secretAccessKey: "secret-access-key",
|
|
301
|
-
},
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
expect(Reflect.has(storage, "getDownloadUrl")).toBe(false);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("adds a signed download URL operation when configured for a server", async () => {
|
|
308
|
-
mockS3Client();
|
|
309
|
-
const storage = r2Storage({
|
|
310
|
-
accountId: "account-id",
|
|
311
|
-
bucketName: "test-bucket",
|
|
312
|
-
credentials: {
|
|
313
|
-
accessKeyId: "access-key-id",
|
|
314
|
-
secretAccessKey: "secret-access-key",
|
|
315
|
-
},
|
|
316
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
await expect(
|
|
320
|
-
storage.getDownloadUrl?.({
|
|
321
|
-
storageUri: "r2://test-bucket/releases/bundle.zip",
|
|
322
|
-
}),
|
|
323
|
-
).resolves.toEqual({
|
|
324
|
-
url: expect.stringMatching(/^\/storage\//),
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("rejects downloads from a different bucket", async () => {
|
|
329
|
-
const storage = r2Storage({
|
|
330
|
-
accountId: "account-id",
|
|
331
|
-
bucketName: "test-bucket",
|
|
332
|
-
credentials: {
|
|
333
|
-
accessKeyId: "access-key-id",
|
|
334
|
-
secretAccessKey: "secret-access-key",
|
|
335
|
-
},
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
await expect(
|
|
339
|
-
storage.get?.({
|
|
340
|
-
storageUri: "r2://other-bucket/releases/bundle-1/manifest.json",
|
|
341
|
-
}),
|
|
342
|
-
).rejects.toThrow(
|
|
343
|
-
'Bucket name mismatch: expected "test-bucket", but found "other-bucket".',
|
|
344
|
-
);
|
|
345
|
-
});
|
|
346
|
-
});
|