Skip to content

Commit d4450b0

Browse files
implement wrangler d1 time-travel (#3579)
* teach wrangler how to time travel * fixup migrations apply * add changesets, fix test * update help text * Update rare-squids-begin.md * extract and test timestamp handling * revert package-lock change * fix: update confirmation default * fixup! extract and test timestamp handling --------- Co-authored-by: Peter Bacon Darwin <[email protected]>
1 parent d719ee1 commit d4450b0

16 files changed

+595
-27
lines changed

.changeset/five-socks-try.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
fix: remove --experimental-backend from `wrangler d1 migrations apply`
6+
7+
This PR removes the need to pass a `--experimental-backend` flag when running migrations against an experimental D1 db.
8+
9+
Closes #3596

.changeset/rare-squids-begin.md

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
feat: implement time travel for experimental d1 dbs
6+
7+
This PR adds two commands under `wrangler d1 time-travel`:
8+
9+
```
10+
Use Time Travel to restore, fork or copy a database at a specific point-in-time.
11+
12+
Commands:
13+
14+
wrangler d1 time-travel info <database> Retrieve information about a database at a specific point-in-time using Time Travel.
15+
Options:
16+
--timestamp accepts a Unix (seconds from epoch) or RFC3339 timestamp (e.g. 2023-07-13T08:46:42.228Z) to retrieve a bookmark for [string]
17+
--json return output as clean JSON [boolean] [default: false]
18+
19+
wrangler d1 time-travel restore <database> Restore a database back to a specific point-in-time.
20+
Options:
21+
--bookmark Bookmark to use for time travel [string]
22+
--timestamp accepts a Unix (seconds from epoch) or RFC3339 timestamp (e.g. 2023-07-13T08:46:42.228Z) to retrieve a bookmark for [string]
23+
--json return output as clean JSON [boolean] [default: false]
24+
```
25+
26+
Closes #3577
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { convertTimestampToISO } from "../../d1/timeTravel/utils";
2+
3+
describe("convertTimestampToISO", () => {
4+
beforeAll(() => {
5+
jest.useFakeTimers();
6+
//lock time to 2023-08-01 UTC
7+
jest.setSystemTime(new Date(2023, 7, 1));
8+
});
9+
10+
afterAll(() => {
11+
jest.useRealTimers();
12+
});
13+
14+
it("should reject invalid date strings", () => {
15+
const timestamp = "asdf";
16+
let error = "";
17+
try {
18+
convertTimestampToISO(timestamp);
19+
} catch (e) {
20+
error = `${e}`.replace(
21+
/\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\d\+\d\d:\d\d/,
22+
"(DATE)"
23+
);
24+
}
25+
expect(error).toMatchInlineSnapshot(`
26+
"Error: Invalid timestamp 'asdf'. Please provide a valid Unix timestamp or ISO string, for example: (DATE)
27+
For accepted format, see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format"
28+
`);
29+
});
30+
31+
it("should convert a JS timestamp to an ISO string", () => {
32+
const now = +new Date();
33+
const converted = convertTimestampToISO(String(now));
34+
expect(converted).toEqual(new Date(now).toISOString());
35+
});
36+
37+
it("should automagically convert a unix timestamp to an ISO string", () => {
38+
const date = "1689355284"; // 2023-07-14T17:21:24.000Z
39+
const convertedDate = new Date(1689355284000);
40+
const output = convertTimestampToISO(String(date));
41+
expect(output).toEqual(convertedDate.toISOString());
42+
});
43+
44+
it("should reject unix timestamps older than 30 days", () => {
45+
const timestamp = "1626168000";
46+
expect(() =>
47+
convertTimestampToISO(timestamp)
48+
).toThrowErrorMatchingInlineSnapshot(
49+
`"Invalid timestamp '1626168000'. Please provide a timestamp within the last 30 days"`
50+
);
51+
});
52+
53+
it("should reject JS timestamps from the future", () => {
54+
const date = String(+new Date() + 10000);
55+
56+
let error = "";
57+
try {
58+
convertTimestampToISO(date);
59+
} catch (e) {
60+
error = `${e}`.replace(/\d+/, "(TIMESTAMP)");
61+
}
62+
expect(error).toMatchInlineSnapshot(
63+
`"Error: Invalid timestamp '(TIMESTAMP)'. Please provide a timestamp in the past"`
64+
);
65+
});
66+
67+
it("should return an ISO string when provided an ISO string", () => {
68+
const date = "2023-07-15T11:45:11.522Z";
69+
70+
const iso = convertTimestampToISO(date);
71+
expect(iso).toEqual(date);
72+
});
73+
74+
it("should reject ISO strings older than 30 days", () => {
75+
const date = "1975-07-17T11:45:11.522Z";
76+
77+
expect(() =>
78+
convertTimestampToISO(date)
79+
).toThrowErrorMatchingInlineSnapshot(
80+
`"Invalid timestamp '1975-07-17T11:45:11.522Z'. Please provide a timestamp within the last 30 days"`
81+
);
82+
});
83+
84+
it("should reject ISO strings from the future", () => {
85+
// TODO: fix Y3k bug
86+
const date = "3000-01-01T00:00:00.001Z";
87+
88+
expect(() =>
89+
convertTimestampToISO(date)
90+
).toThrowErrorMatchingInlineSnapshot(
91+
`"Invalid timestamp '3000-01-01T00:00:00.001Z'. Please provide a timestamp in the past"`
92+
);
93+
});
94+
});

packages/wrangler/src/__tests__/d1/d1.test.ts

+68
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe("d1", () => {
2323
wrangler d1 delete <name> Delete D1 database
2424
wrangler d1 backup Interact with D1 Backups
2525
wrangler d1 execute <database> Executed command or SQL file
26+
wrangler d1 time-travel Use Time Travel to restore, fork or copy a database at a specific point-in-time.
2627
wrangler d1 migrations Interact with D1 Migrations
2728
2829
Flags:
@@ -64,6 +65,7 @@ describe("d1", () => {
6465
wrangler d1 delete <name> Delete D1 database
6566
wrangler d1 backup Interact with D1 Backups
6667
wrangler d1 execute <database> Executed command or SQL file
68+
wrangler d1 time-travel Use Time Travel to restore, fork or copy a database at a specific point-in-time.
6769
wrangler d1 migrations Interact with D1 Migrations
6870
6971
Flags:
@@ -81,4 +83,70 @@ describe("d1", () => {
8183
--------------------"
8284
`);
8385
});
86+
87+
it("should show help when the migrations command is passed", async () => {
88+
await expect(() => runWrangler("d1 migrations")).rejects.toThrow(
89+
"Not enough non-option arguments: got 0, need at least 1"
90+
);
91+
92+
expect(std.err).toMatchInlineSnapshot(`
93+
"X [ERROR] Not enough non-option arguments: got 0, need at least 1
94+
95+
"
96+
`);
97+
expect(std.out).toMatchInlineSnapshot(`
98+
"
99+
wrangler d1 migrations
100+
101+
Interact with D1 Migrations
102+
103+
Commands:
104+
wrangler d1 migrations list <database> List your D1 migrations
105+
wrangler d1 migrations create <database> <message> Create a new Migration
106+
wrangler d1 migrations apply <database> Apply D1 Migrations
107+
108+
Flags:
109+
-j, --experimental-json-config Experimental: Support wrangler.json [boolean]
110+
-c, --config Path to .toml configuration file [string]
111+
-e, --env Environment to use for operations and .env files [string]
112+
-h, --help Show help [boolean]
113+
-v, --version Show version number [boolean]
114+
115+
--------------------
116+
🚧 D1 is currently in open alpha and is not recommended for production data and traffic
117+
🚧 Please report any bugs to https://github.com/cloudflare/workers-sdk/issues/new/choose
118+
🚧 To request features, visit https://community.cloudflare.com/c/developers/d1
119+
🚧 To give feedback, visit https://discord.gg/cloudflaredev
120+
--------------------"
121+
`);
122+
});
123+
124+
it("should show help when the time travel command is passed", async () => {
125+
await expect(() => runWrangler("d1 time-travel")).rejects.toThrow(
126+
"Not enough non-option arguments: got 0, need at least 1"
127+
);
128+
129+
expect(std.err).toMatchInlineSnapshot(`
130+
"X [ERROR] Not enough non-option arguments: got 0, need at least 1
131+
132+
"
133+
`);
134+
expect(std.out).toMatchInlineSnapshot(`
135+
"
136+
wrangler d1 time-travel
137+
138+
Use Time Travel to restore, fork or copy a database at a specific point-in-time.
139+
140+
Commands:
141+
wrangler d1 time-travel info <database> Retrieve information about a database at a specific point-in-time using Time Travel.
142+
wrangler d1 time-travel restore <database> Restore a database back to a specific point-in-time.
143+
144+
Flags:
145+
-j, --experimental-json-config Experimental: Support wrangler.json [boolean]
146+
-c, --config Path to .toml configuration file [string]
147+
-e, --env Environment to use for operations and .env files [string]
148+
-h, --help Show help [boolean]
149+
-v, --version Show version number [boolean]"
150+
`);
151+
});
84152
});

packages/wrangler/src/__tests__/d1/migrate.test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,28 @@ Your database may not be available to serve requests during the migration, conti
145145
}
146146
)
147147
);
148+
msw.use(
149+
rest.get(
150+
"*/accounts/:accountId/d1/database/:databaseId",
151+
async (req, res, ctx) => {
152+
return res(
153+
ctx.status(200),
154+
ctx.json({
155+
result: {
156+
file_size: 7421952,
157+
name: "benchmark3-v1",
158+
num_tables: 2,
159+
uuid: "7b0c1d24-ec57-4179-8663-9b82dafe9277",
160+
version: "alpha",
161+
},
162+
success: true,
163+
errors: [],
164+
messages: [],
165+
})
166+
);
167+
}
168+
)
169+
);
148170
writeWranglerToml({
149171
d1_databases: [
150172
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useMockIsTTY } from "../helpers/mock-istty";
2+
import { runWrangler } from "../helpers/run-wrangler";
3+
import writeWranglerToml from "../helpers/write-wrangler-toml";
4+
5+
describe("time-travel", () => {
6+
const { setIsTTY } = useMockIsTTY();
7+
8+
describe("restore", () => {
9+
it("should reject the use of --timestamp with --bookmark", async () => {
10+
setIsTTY(false);
11+
writeWranglerToml({
12+
d1_databases: [
13+
{ binding: "DATABASE", database_name: "db", database_id: "xxxx" },
14+
],
15+
});
16+
17+
await expect(
18+
runWrangler(
19+
"d1 time-travel restore db --timestamp=1234 --bookmark=5678"
20+
)
21+
).rejects.toThrowError(
22+
`Provide either a timestamp, or a bookmark - not both.`
23+
);
24+
});
25+
});
26+
});

packages/wrangler/src/d1/index.ts

+20
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Execute from "./execute";
55
import * as Info from "./info";
66
import * as List from "./list";
77
import * as Migrations from "./migrations";
8+
import * as TimeTravel from "./timeTravel";
89
import { d1BetaWarning } from "./utils";
910
import type { CommonYargsArgv } from "../yargs-types";
1011

@@ -79,6 +80,25 @@ export function d1(yargs: CommonYargsArgv) {
7980
Execute.Options,
8081
Execute.Handler
8182
)
83+
.command(
84+
"time-travel",
85+
"Use Time Travel to restore, fork or copy a database at a specific point-in-time.",
86+
(yargs2) =>
87+
yargs2
88+
.demandCommand()
89+
.command(
90+
"info <database>",
91+
"Retrieve information about a database at a specific point-in-time using Time Travel.",
92+
TimeTravel.InfoOptions,
93+
TimeTravel.InfoHandler
94+
)
95+
.command(
96+
"restore <database>",
97+
"Restore a database back to a specific point-in-time.",
98+
TimeTravel.RestoreOptions,
99+
TimeTravel.RestoreHandler
100+
)
101+
)
82102
.command("migrations", "Interact with D1 Migrations", (yargs2) =>
83103
yargs2
84104
.demandCommand()

packages/wrangler/src/d1/info.tsx

+7-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import Table from "ink-table";
22
import prettyBytes from "pretty-bytes";
33
import React from "react";
4-
import { fetchGraphqlResult, fetchResult } from "../cfetch";
4+
import { fetchGraphqlResult } from "../cfetch";
55
import { withConfig } from "../config";
66
import { logger } from "../logger";
77
import { requireAuth } from "../user";
88
import { renderToString } from "../utils/render";
9-
import { d1BetaWarning, getDatabaseByNameOrBinding } from "./utils";
9+
import {
10+
d1BetaWarning,
11+
getDatabaseByNameOrBinding,
12+
getDatabaseInfoFromId,
13+
} from "./utils";
1014
import type {
1115
CommonYargsArgv,
1216
StrictYargsOptionsToInterface,
@@ -38,14 +42,7 @@ export const Handler = withConfig<HandlerOptions>(
3842
name
3943
);
4044

41-
const result = await fetchResult<Record<string, string>>(
42-
`/accounts/${accountId}/d1/database/${db.uuid}`,
43-
{
44-
headers: {
45-
"Content-Type": "application/json",
46-
},
47-
}
48-
);
45+
const result = await getDatabaseInfoFromId(accountId, db.uuid);
4946

5047
const output: Record<string, string | number> = { ...result };
5148
if (output["file_size"]) {

0 commit comments

Comments
 (0)