@jant/core 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -0,0 +1,136 @@
1
+ import { parseArgs } from "node:util";
2
+ import { loadNodeRuntime } from "../lib/load-node-runtime.js";
3
+ import { openNodeDatabase } from "../lib/node-database.js";
4
+ import { bootstrapCliRuntime } from "../lib/runtime-target.js";
5
+
6
+ function printHelp() {
7
+ console.log(
8
+ "Usage: jant setup --email <address> --password-stdin [--site-name <name>] [--language <tag>] [--time-zone <zone>] [--site-id <id>]",
9
+ );
10
+ console.log("");
11
+ console.log(
12
+ "Set up a self-hosted install without the browser: the owner account, the site, and onboarding.",
13
+ );
14
+ console.log("Does the same as the two /setup screens.");
15
+ console.log("");
16
+ console.log("Options:");
17
+ console.log(" --email The owner's sign-in address");
18
+ console.log(
19
+ " --password-stdin Read the owner's password from standard input",
20
+ );
21
+ console.log(
22
+ " --site-name The site's name (default: the built-in fallback name)",
23
+ );
24
+ console.log(
25
+ " --language Language the site publishes in, as a BCP 47 tag (default: en)",
26
+ );
27
+ console.log(" --time-zone The site's IANA time zone (default: UTC)");
28
+ console.log(
29
+ " --site-id Create the site with this id instead of a random one",
30
+ );
31
+ console.log(
32
+ " --node Force Node runtime even if DATABASE_URL is unset",
33
+ );
34
+ console.log("");
35
+ console.log("Safe to run on every start. An install that has finished setup");
36
+ console.log(
37
+ "is left unchanged, so a password changed since then is not reset.",
38
+ );
39
+ console.log("");
40
+ console.log("Example:");
41
+ console.log(
42
+ ` printf '%s' "$OWNER_PASSWORD" | jant setup --email owner@example.com --password-stdin`,
43
+ );
44
+ console.log("");
45
+ console.log(
46
+ "Node runtime and SITE_RESOLUTION_MODE=single-site only. `.env.node` next to",
47
+ );
48
+ console.log("your project (or in packages/core/) is auto-loaded.");
49
+ }
50
+
51
+ /**
52
+ * Read a secret piped to the command, the way `docker login --password-stdin`
53
+ * does: everything on the stream, minus the one line break `echo` adds.
54
+ *
55
+ * @param stream - Where the secret arrives
56
+ * @returns The secret
57
+ */
58
+ async function readPasswordFromStdin(stream) {
59
+ if (stream.isTTY) {
60
+ throw new Error(
61
+ "--password-stdin reads the password from a pipe. Example: printf '%s' \"$OWNER_PASSWORD\" | jant setup --email <address> --password-stdin",
62
+ );
63
+ }
64
+
65
+ const chunks = [];
66
+ for await (const chunk of stream) {
67
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
68
+ }
69
+ return Buffer.concat(chunks)
70
+ .toString("utf8")
71
+ .replace(/\r?\n$/, "");
72
+ }
73
+
74
+ const OUTCOME_MESSAGES = {
75
+ created: (siteId) => `Set up site ${siteId}.`,
76
+ resumed: (siteId) =>
77
+ `Finished setting up site ${siteId}, which an earlier run had started.`,
78
+ "already-set-up": (siteId) =>
79
+ `Site ${siteId} was already set up. Nothing changed.`,
80
+ };
81
+
82
+ export async function run(argv, io = { stdin: process.stdin }) {
83
+ const { values } = parseArgs({
84
+ args: argv,
85
+ options: {
86
+ email: { type: "string" },
87
+ help: { type: "boolean", short: "h" },
88
+ language: { type: "string" },
89
+ node: { type: "boolean", default: false },
90
+ "password-stdin": { type: "boolean", default: false },
91
+ "site-id": { type: "string" },
92
+ "site-name": { type: "string" },
93
+ "time-zone": { type: "string" },
94
+ },
95
+ });
96
+
97
+ if (values.help) {
98
+ printHelp();
99
+ process.exit(0);
100
+ }
101
+
102
+ if (!values.email) {
103
+ throw new Error("Pass the owner's sign-in address with --email.");
104
+ }
105
+ if (!values["password-stdin"]) {
106
+ throw new Error(
107
+ "Pass --password-stdin and pipe the owner's password in. The password is never read from an argument, where it would show up in the process list.",
108
+ );
109
+ }
110
+
111
+ const { runtime } = bootstrapCliRuntime(values);
112
+ if (runtime !== "node") {
113
+ throw new Error(
114
+ "jant setup runs against a Node database. Set DATABASE_URL or DATA_DIR, or pass --node.",
115
+ );
116
+ }
117
+
118
+ const password = await readPasswordFromStdin(io.stdin);
119
+ const { setUpNodeInstance } = await loadNodeRuntime();
120
+ const nodeDatabase = await openNodeDatabase(process.env);
121
+
122
+ try {
123
+ const result = await setUpNodeInstance(nodeDatabase.bindings, {
124
+ email: values.email,
125
+ password,
126
+ siteId: values["site-id"],
127
+ siteName: values["site-name"],
128
+ siteLanguage: values.language,
129
+ timeZone: values["time-zone"],
130
+ });
131
+ console.log(OUTCOME_MESSAGES[result.outcome](result.siteId));
132
+ return result;
133
+ } finally {
134
+ await nodeDatabase.close();
135
+ }
136
+ }
@@ -1,14 +1,26 @@
1
- import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
1
+ import { createWriteStream, mkdirSync, readdirSync } from "node:fs";
2
+ import { mkdir, mkdtemp, rename, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
3
7
  import { parseArgs } from "node:util";
4
- import { unzipSync } from "fflate";
5
8
  import {
6
9
  CLI_API_TOKEN_ENV_VAR,
7
10
  getCliApiToken,
8
11
  } from "../../lib/cli-api-token.js";
9
- import { pullSiteExportZipBytes } from "../../lib/site-pull-media.js";
12
+ import { pullSiteExportDirectory } from "../../lib/site-pull-media.js";
13
+ import { extractZipFile, writeDirectoryToZip } from "../../lib/zip-archive.js";
10
14
 
11
- async function exportRemoteSite(url, token) {
15
+ /**
16
+ * Stream the site's export archive to a file. The site streams it too, and a
17
+ * site that bundles its media answers with gigabytes.
18
+ *
19
+ * @param {string} url - Site URL
20
+ * @param {string} token - API token
21
+ * @param {string} destination - File to write
22
+ */
23
+ async function downloadSiteArchive(url, token, destination) {
12
24
  const response = await fetch(`${url.replace(/\/$/, "")}/api/export/hugo`, {
13
25
  method: "POST",
14
26
  headers: {
@@ -16,12 +28,16 @@ async function exportRemoteSite(url, token) {
16
28
  },
17
29
  });
18
30
 
19
- if (!response.ok) {
31
+ if (!response.ok || !response.body) {
20
32
  const text = await response.text();
21
33
  throw new Error(`HTTP ${response.status}: ${text}`);
22
34
  }
23
35
 
24
- return new Uint8Array(await response.arrayBuffer());
36
+ await mkdir(dirname(destination), { recursive: true });
37
+ await pipeline(
38
+ Readable.fromWeb(response.body),
39
+ createWriteStream(destination),
40
+ );
25
41
  }
26
42
 
27
43
  function describeProgressUrl(value) {
@@ -161,22 +177,8 @@ export async function run(argv) {
161
177
  process.exit(1);
162
178
  }
163
179
 
164
- console.log(`Exporting site from ${url}...`);
165
-
166
- const zipBytes = await exportRemoteSite(url, token);
167
- let zip = zipBytes;
168
- let pullStats = null;
169
-
170
- if (pullMedia) {
171
- console.log("Preparing pull-media export ZIP...");
172
- const pulled = await pullSiteExportZipBytes(zip, {
173
- assetLoader: null,
174
- logger: logPullProgress,
175
- });
176
- zip = pulled.zipBytes;
177
- pullStats = pulled.stats;
178
- }
179
-
180
+ // Fail before the download, not after it: pulling a real site's media
181
+ // takes minutes.
180
182
  if (outputDirectory) {
181
183
  let existingEntries = [];
182
184
  try {
@@ -194,20 +196,55 @@ export async function run(argv) {
194
196
  );
195
197
  process.exit(1);
196
198
  }
199
+ }
197
200
 
198
- console.log(`Writing export directory ${values.directory}...`);
199
- const files = unzipSync(zip);
200
- for (const [relativePath, bytes] of Object.entries(files)) {
201
- const fullPath = resolve(outputDirectory, relativePath);
202
- mkdirSync(dirname(fullPath), { recursive: true });
203
- writeFileSync(fullPath, Buffer.from(bytes));
204
- }
205
- console.log(`Exported site from ${url} to ${values.directory}`);
206
- console.log(`Preview with: cd ${values.directory} && hugo serve`);
207
- } else {
201
+ console.log(`Exporting site from ${url}...`);
202
+
203
+ // Nothing to add: the site's archive is the export.
204
+ if (!pullMedia && !outputDirectory) {
208
205
  console.log(`Writing ${values.output}...`);
209
- writeFileSync(output, Buffer.from(zip));
206
+ const partial = `${output}.partial`;
207
+ try {
208
+ await downloadSiteArchive(url, token, partial);
209
+ await rename(partial, output);
210
+ } catch (error) {
211
+ await rm(partial, { force: true });
212
+ throw error;
213
+ }
210
214
  console.log(`Exported site from ${url} to ${values.output}`);
215
+ return;
216
+ }
217
+
218
+ // Unpacked to disk and media downloaded next to it, so the export never
219
+ // holds more than one media file in memory.
220
+ const tempDir = await mkdtemp(join(tmpdir(), "jant-site-export-"));
221
+ const workDir = outputDirectory ?? join(tempDir, "site");
222
+ let pullStats = null;
223
+
224
+ try {
225
+ const archivePath = join(tempDir, "site.zip");
226
+ await downloadSiteArchive(url, token, archivePath);
227
+ await extractZipFile(archivePath, workDir);
228
+ await rm(archivePath, { force: true });
229
+
230
+ if (pullMedia) {
231
+ console.log("Pulling media...");
232
+ pullStats = await pullSiteExportDirectory(workDir, {
233
+ assetLoader: null,
234
+ logger: logPullProgress,
235
+ });
236
+ }
237
+
238
+ if (outputDirectory) {
239
+ console.log(`Exported site from ${url} to ${values.directory}`);
240
+ console.log(`Preview with: cd ${values.directory} && hugo serve`);
241
+ } else {
242
+ console.log(`Writing ${values.output}...`);
243
+ await writeDirectoryToZip(workDir, output);
244
+ console.log(`Exported site from ${url} to ${values.output}`);
245
+ }
246
+ } finally {
247
+ await rm(tempDir, { recursive: true, force: true });
211
248
  }
212
249
 
213
250
  if (pullStats) {
@@ -1,10 +1,9 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
1
  import { stat } from "node:fs/promises";
3
2
  import { resolve } from "node:path";
4
3
  import { parseArgs } from "node:util";
5
4
  import {
6
5
  pullSiteExportDirectory,
7
- pullSiteExportZipBytes,
6
+ pullSiteExportZipFile,
8
7
  } from "../../lib/site-pull-media.js";
9
8
 
10
9
  function describeProgressUrl(value) {
@@ -102,13 +101,10 @@ export async function run(argv) {
102
101
  }
103
102
 
104
103
  const outputPath = resolve(process.cwd(), values.output || values.path);
105
- const inputBytes = new Uint8Array(readFileSync(inputPath));
106
104
  console.log(`Pulling media in ${values.path}...`);
107
- const { zipBytes, stats } = await pullSiteExportZipBytes(inputBytes, {
105
+ const stats = await pullSiteExportZipFile(inputPath, outputPath, {
108
106
  logger: logPullProgress,
109
107
  });
110
- console.log(`Writing ${values.output || values.path}...`);
111
- writeFileSync(outputPath, Buffer.from(zipBytes));
112
108
  console.log(
113
109
  `Pulled media in ${values.path} -> ${values.output || values.path}`,
114
110
  );
@@ -1,17 +1,12 @@
1
- import { existsSync } from "node:fs";
2
- import {
3
- mkdir,
4
- mkdtemp,
5
- readFile,
6
- readdir,
7
- rm,
8
- writeFile,
9
- } from "node:fs/promises";
1
+ import { createWriteStream, existsSync } from "node:fs";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
10
5
  import { tmpdir } from "node:os";
11
- import { dirname, join, relative, resolve } from "node:path";
6
+ import { dirname, join, resolve } from "node:path";
12
7
  import { parseArgs } from "node:util";
13
- import { zipSync } from "fflate";
14
- import { queryD1 } from "../../../lib/d1-query.js";
8
+ import { writeDirectoryToZip } from "../../../lib/zip-archive.js";
9
+ import { D1_DUMP_PAGE_SIZE, queryD1 } from "../../../lib/d1-query.js";
15
10
  import { loadNodeRuntime } from "../../../lib/load-node-runtime.js";
16
11
  import { openNodeDatabase } from "../../../lib/node-database.js";
17
12
  import {
@@ -23,6 +18,7 @@ import {
23
18
  buildSnapshotStorageQuery,
24
19
  collectSnapshotObjects,
25
20
  getSnapshotSelectSql,
21
+ orderSnapshotPostRows,
26
22
  SNAPSHOT_TABLES,
27
23
  snapshotObjectPath,
28
24
  } from "../../../lib/site-snapshot.js";
@@ -38,49 +34,6 @@ function isZipPath(filePath) {
38
34
  return filePath.toLowerCase().endsWith(".zip");
39
35
  }
40
36
 
41
- async function readStorageBody(body) {
42
- const reader = body.getReader();
43
- const chunks = [];
44
- let totalLength = 0;
45
-
46
- for (;;) {
47
- const { done, value } = await reader.read();
48
- if (done) break;
49
- chunks.push(value);
50
- totalLength += value.length;
51
- }
52
-
53
- const bytes = new Uint8Array(totalLength);
54
- let offset = 0;
55
- for (const chunk of chunks) {
56
- bytes.set(chunk, offset);
57
- offset += chunk.length;
58
- }
59
-
60
- return bytes;
61
- }
62
-
63
- async function readDirectoryEntries(rootDir) {
64
- const entries = {};
65
-
66
- async function walk(dir) {
67
- const items = await readdir(dir, { withFileTypes: true });
68
- for (const item of items) {
69
- const fullPath = join(dir, item.name);
70
- if (item.isDirectory()) {
71
- await walk(fullPath);
72
- continue;
73
- }
74
-
75
- const relativePath = relative(rootDir, fullPath).replace(/\\/g, "/");
76
- entries[relativePath] = new Uint8Array(await readFile(fullPath));
77
- }
78
- }
79
-
80
- await walk(rootDir);
81
- return entries;
82
- }
83
-
84
37
  async function assertWritableOutput(outputPath, force) {
85
38
  if (!existsSync(outputPath)) {
86
39
  return;
@@ -123,7 +76,12 @@ async function createNodeExportContext() {
123
76
  }
124
77
 
125
78
  await mkdir(dirname(filePath), { recursive: true });
126
- await writeFile(filePath, await readStorageBody(object.body));
79
+ // Straight to disk: a snapshot holds every media file, and collecting
80
+ // each one in memory first ran a 3 GB export to a 2 GB peak.
81
+ await pipeline(
82
+ Readable.fromWeb(object.body),
83
+ createWriteStream(filePath),
84
+ );
127
85
  },
128
86
  };
129
87
  }
@@ -295,6 +253,9 @@ export async function run(argv) {
295
253
  {
296
254
  dialect: context.dialect,
297
255
  source: runtime,
256
+ // D1 answers through Wrangler's buffered output; read it in pages.
257
+ pageSize: runtime === "node" ? undefined : D1_DUMP_PAGE_SIZE,
258
+ orderRowsByTable: { post: orderSnapshotPostRows },
298
259
  tables: SNAPSHOT_TABLES,
299
260
  selectSqlByTable: Object.fromEntries(
300
261
  SNAPSHOT_TABLES.map((tableName) => [
@@ -344,10 +305,7 @@ export async function run(argv) {
344
305
 
345
306
  if (shouldZip) {
346
307
  await mkdir(dirname(outputPath), { recursive: true });
347
- const zipped = zipSync(await readDirectoryEntries(scratchDir), {
348
- level: 6,
349
- });
350
- await writeFile(outputPath, zipped);
308
+ await writeDirectoryToZip(scratchDir, outputPath);
351
309
  if (process.env.SNAPSHOT_SUPPRESS_SUCCESS_LOG !== "true") {
352
310
  console.log(
353
311
  `Exported ${getCliRuntimeLabel(runtime)} snapshot to ${values.output}`,
@@ -1,16 +1,10 @@
1
- import { existsSync } from "node:fs";
2
- import {
3
- mkdir,
4
- mkdtemp,
5
- readFile,
6
- rm,
7
- stat,
8
- writeFile,
9
- } from "node:fs/promises";
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { Readable } from "node:stream";
3
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
10
4
  import { tmpdir } from "node:os";
11
- import { dirname, join, resolve } from "node:path";
5
+ import { join, resolve } from "node:path";
12
6
  import { parseArgs } from "node:util";
13
- import { unzipSync } from "fflate";
7
+ import { extractZipFile } from "../../../lib/zip-archive.js";
14
8
  import { executeD1, queryD1 } from "../../../lib/d1-query.js";
15
9
  import { loadNodeRuntime } from "../../../lib/load-node-runtime.js";
16
10
  import { openNodeDatabase } from "../../../lib/node-database.js";
@@ -18,6 +12,8 @@ import { deleteR2Object, uploadR2Object } from "../../../lib/r2-query.js";
18
12
  import {
19
13
  assertSnapshotDialectMatches,
20
14
  assertSnapshotMeta,
15
+ buildMediaProviderSql,
16
+ DEFER_FOREIGN_KEYS_SQL,
21
17
  buildReplaceSql,
22
18
  buildSnapshotStorageQuery,
23
19
  collectSnapshotObjects,
@@ -58,11 +54,13 @@ async function createNodeImportContext() {
58
54
  // the current site, which (a) is redundant with the bin-level resolveCliSite
59
55
  // call below and (b) prints a generic "/setup first" error when the
60
56
  // snapshot's own error path is more informative.
61
- const { createStorageDriver } = await loadNodeRuntime();
57
+ const { createStorageDriver, getConfiguredStorageDriver } =
58
+ await loadNodeRuntime();
62
59
  const storage = createStorageDriver(nodeDatabase.bindings);
63
60
 
64
61
  return {
65
62
  dialect: nodeDatabase.database.dialect,
63
+ storageProvider: getConfiguredStorageDriver(nodeDatabase.bindings),
66
64
  async close() {
67
65
  await nodeDatabase.close();
68
66
  },
@@ -77,8 +75,7 @@ async function createNodeImportContext() {
77
75
  throw new Error("Snapshot import requires configured storage.");
78
76
  }
79
77
 
80
- const bytes = new Uint8Array(await readFile(filePath));
81
- await storage.put(key, bytes, {
78
+ await storage.put(key, Readable.toWeb(createReadStream(filePath)), {
82
79
  contentType: contentType || undefined,
83
80
  });
84
81
  },
@@ -96,6 +93,8 @@ function createD1ImportContext(runtime, values) {
96
93
 
97
94
  return {
98
95
  dialect: "sqlite",
96
+ // Objects go up through the wrangler R2 binding below.
97
+ storageProvider: "r2",
99
98
  async close() {},
100
99
  async query(sql) {
101
100
  return queryD1(sql, runtime, wranglerOptions);
@@ -141,14 +140,7 @@ async function materializeSnapshotInput(inputPath) {
141
140
  }
142
141
 
143
142
  const outputDir = await mkdtemp(join(tmpdir(), "jant-site-snapshot-import-"));
144
- const bytes = new Uint8Array(await readFile(inputPath));
145
- const files = unzipSync(bytes);
146
-
147
- for (const [relativePath, data] of Object.entries(files)) {
148
- const absolutePath = join(outputDir, relativePath);
149
- await mkdir(dirname(absolutePath), { recursive: true });
150
- await writeFile(absolutePath, data);
151
- }
143
+ await extractZipFile(inputPath, outputDir);
152
144
 
153
145
  return {
154
146
  cleanup: async () => {
@@ -402,7 +394,14 @@ export async function run(argv) {
402
394
  );
403
395
  }
404
396
 
405
- await context.execute(`${buildReplaceSql(targetSite.id)}\n${dbSql}`);
397
+ await context.execute(
398
+ [
399
+ ...(context.dialect === "postgres" ? [] : [DEFER_FOREIGN_KEYS_SQL]),
400
+ buildReplaceSql(targetSite.id),
401
+ dbSql,
402
+ buildMediaProviderSql(targetSite.id, context.storageProvider),
403
+ ].join("\n"),
404
+ );
406
405
 
407
406
  const keysToDelete = [...currentKeys].filter(
408
407
  (key) => !snapshotKeys.has(key),
@@ -1,9 +1,23 @@
1
- import { runLocalWrangler } from "./wrangler-cli.js";
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { WRANGLER_MAX_BUFFER, runLocalWrangler } from "./wrangler-cli.js";
2
5
  import { extractWranglerJson } from "./wrangler-json.js";
3
6
 
4
7
  const DEFAULT_RETRY_ATTEMPTS = 4;
5
8
  const DEFAULT_RETRY_DELAY_MS = 500;
6
9
 
10
+ /**
11
+ * Byte ceiling for a statement batch sent as one `--command=` argument.
12
+ *
13
+ * Linux caps a single argv entry at 128 KiB (`MAX_ARG_STRLEN`) and fails the
14
+ * spawn with `E2BIG`; macOS has no per-argument cap, so an oversized batch
15
+ * works on a developer's Mac and dies in CI and in the demo rebuild, which is
16
+ * exactly how it first surfaced — a snapshot import whose `db.sql` had grown
17
+ * past the limit. Anything at or above this goes through `--file` instead.
18
+ */
19
+ const MAX_INLINE_COMMAND_BYTES = 96 * 1024;
20
+
7
21
  function getD1Flag(runtime) {
8
22
  return runtime === "d1-remote" ? "--remote" : "--local";
9
23
  }
@@ -62,6 +76,46 @@ function sleepSync(ms) {
62
76
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
63
77
  }
64
78
 
79
+ const WRANGLER_OUTPUT_EXCERPT_LENGTH = 2000;
80
+
81
+ /**
82
+ * Rows per read when dumping a D1 table: small enough that a page of long
83
+ * posts stays well inside {@link WRANGLER_MAX_BUFFER}.
84
+ */
85
+ export const D1_DUMP_PAGE_SIZE = 200;
86
+
87
+ /**
88
+ * Say why a Wrangler call failed without echoing its whole output: on a
89
+ * failed `d1 execute` that output can be a site's rows, hundreds of KB of it.
90
+ *
91
+ * @param {string[]} args - The Wrangler arguments
92
+ * @param {Error & { code?: string, stdout?: string, stderr?: string }} error
93
+ * @returns {string} A message naming the command and the cause
94
+ * @example
95
+ * describeWranglerFailure(["d1", "execute", "DB"], enobufsError);
96
+ * // "`wrangler d1 execute DB` printed more than 67108864 bytes, …"
97
+ */
98
+ export function describeWranglerFailure(args, error) {
99
+ const command = `wrangler ${args.slice(0, 3).join(" ")}`;
100
+ if (error?.code === "ENOBUFS") {
101
+ return `\`${command}\` printed more than ${WRANGLER_MAX_BUFFER} bytes, the most the CLI buffers.`;
102
+ }
103
+
104
+ const stderr = String(error?.stderr ?? "").trim();
105
+ if (stderr) return `\`${command}\` failed: ${stderr}`;
106
+
107
+ const stdout = String(error?.stdout ?? "").trim();
108
+ if (stdout) {
109
+ const excerpt =
110
+ stdout.length > WRANGLER_OUTPUT_EXCERPT_LENGTH
111
+ ? `${stdout.slice(0, WRANGLER_OUTPUT_EXCERPT_LENGTH)}… (${stdout.length} characters)`
112
+ : stdout;
113
+ return `\`${command}\` failed: ${excerpt}`;
114
+ }
115
+
116
+ return `\`${command}\` failed: ${error?.message ?? "unknown error"}`;
117
+ }
118
+
65
119
  export function isRetryableWranglerD1Failure(output, error) {
66
120
  const combined = `${output ?? ""}\n${error?.message ?? ""}`.toLowerCase();
67
121
  return [
@@ -123,12 +177,43 @@ function runWrangler(args, options = {}) {
123
177
  throw new Error(`Wrangler error: ${wranglerError}`);
124
178
  }
125
179
 
126
- throw new Error(output || error.message, { cause: error });
180
+ throw new Error(describeWranglerFailure(args, error), { cause: error });
127
181
  }
128
182
  }
129
183
  }
130
184
 
185
+ /**
186
+ * Whether a statement batch is too large to travel as one CLI argument.
187
+ *
188
+ * @param {string} sql - SQL batch about to be sent to Wrangler
189
+ * @returns {boolean} true when it has to go through a file instead
190
+ * @example
191
+ * ```js
192
+ * exceedsInlineCommandLimit("SELECT 1;"); // false
193
+ * ```
194
+ */
195
+ export function exceedsInlineCommandLimit(sql) {
196
+ return Buffer.byteLength(sql, "utf8") >= MAX_INLINE_COMMAND_BYTES;
197
+ }
198
+
199
+ function withTemporarySqlFile(sql, run) {
200
+ const directory = mkdtempSync(join(tmpdir(), "jant-d1-"));
201
+ try {
202
+ const filePath = join(directory, "batch.sql");
203
+ writeFileSync(filePath, sql);
204
+ return run(filePath);
205
+ } finally {
206
+ rmSync(directory, { recursive: true, force: true });
207
+ }
208
+ }
209
+
131
210
  export function executeD1(sql, runtime, options = {}) {
211
+ if (exceedsInlineCommandLimit(sql)) {
212
+ return withTemporarySqlFile(sql, (filePath) =>
213
+ executeD1File(filePath, runtime, options),
214
+ );
215
+ }
216
+
132
217
  const args = appendWranglerContext(
133
218
  [
134
219
  "d1",
@@ -20,6 +20,7 @@ const FRONT_MATTER_KEY_ORDER = [
20
20
  "type",
21
21
  "draft",
22
22
  "aliases",
23
+ "feed_id",
23
24
  "build",
24
25
 
25
26
  // Post payload
@@ -27,12 +28,15 @@ const FRONT_MATTER_KEY_ORDER = [
27
28
  "status",
28
29
  "visibility",
29
30
  "summary_text",
31
+ "truncated",
30
32
  "link_url",
31
33
  "source_name",
32
34
  "source_url",
33
35
  "quote_text",
34
36
  "rating",
35
37
  "featured_at",
38
+ "featured_post_ids",
39
+ "featured_sort_at",
36
40
  "pinned_at",
37
41
 
38
42
  // Bookkeeping / attachments