@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
@@ -12,7 +12,6 @@ import {
12
12
  } from "node:fs/promises";
13
13
  import { tmpdir } from "node:os";
14
14
  import { basename, dirname, extname, join, relative } from "node:path";
15
- import { unzipSync, zipSync } from "fflate";
16
15
  import { parse, stringify } from "smol-toml";
17
16
  import {
18
17
  collectMediaReferences as collectParsedMediaReferences,
@@ -20,6 +19,7 @@ import {
20
19
  rewriteMediaReferences,
21
20
  } from "./site-media-parser.js";
22
21
  import { formatFrontMatter, parseFrontMatter } from "./hugo-markdown.js";
22
+ import { extractZipFile, writeDirectoryToZip } from "./zip-archive.js";
23
23
 
24
24
  export function getSitePathPrefix(baseUrl) {
25
25
  if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
@@ -477,29 +477,6 @@ async function resolveExistingPulledPath(
477
477
  return toPulledPublicPath(pathname, sitePathPrefix);
478
478
  }
479
479
 
480
- async function packDirectoryToZip(rootDir) {
481
- const files = {};
482
- const allFiles = await walkFiles(rootDir);
483
-
484
- for (const fullPath of allFiles) {
485
- const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
486
- files[relPath] = new Uint8Array(await readFile(fullPath));
487
- }
488
-
489
- return zipSync(files);
490
- }
491
-
492
- async function unpackZipToDirectory(zipBytes, rootDir) {
493
- const files = unzipSync(zipBytes);
494
- await Promise.all(
495
- Object.entries(files).map(async ([relPath, bytes]) => {
496
- const fullPath = join(rootDir, relPath);
497
- await mkdir(dirname(fullPath), { recursive: true });
498
- await writeFile(fullPath, bytes);
499
- }),
500
- );
501
- }
502
-
503
480
  export async function pullSiteExportDirectory(rootDir, options = {}) {
504
481
  const logger =
505
482
  typeof options.logger === "function" ? options.logger : () => {};
@@ -681,14 +658,30 @@ export async function pullSiteExportDirectory(rootDir, options = {}) {
681
658
  return stats;
682
659
  }
683
660
 
684
- export async function pullSiteExportZipBytes(zipBytes, options = {}) {
661
+ /**
662
+ * Pull media into an export ZIP: extract it to a temporary directory, pull
663
+ * there, and write the result as a new archive. Nothing is held in memory
664
+ * beyond one media file at a time, so the archive can be any size.
665
+ *
666
+ * @param {string} inputZipPath - Export archive to read
667
+ * @param {string} outputZipPath - Archive to write; may be the input
668
+ * @param {Parameters<typeof pullSiteExportDirectory>[1]} [options]
669
+ * @returns {Promise<Awaited<ReturnType<typeof pullSiteExportDirectory>>>} Pull stats
670
+ * @example
671
+ * await pullSiteExportZipFile("export.zip", "export.zip", { logger });
672
+ */
673
+ export async function pullSiteExportZipFile(
674
+ inputZipPath,
675
+ outputZipPath,
676
+ options = {},
677
+ ) {
685
678
  const tempDir = await mkdtemp(join(tmpdir(), "jant-site-pull-"));
686
679
 
687
680
  try {
688
- await unpackZipToDirectory(zipBytes, tempDir);
681
+ await extractZipFile(inputZipPath, tempDir);
689
682
  const stats = await pullSiteExportDirectory(tempDir, options);
690
- const pulledZip = await packDirectoryToZip(tempDir);
691
- return { zipBytes: pulledZip, stats };
683
+ await writeDirectoryToZip(tempDir, outputZipPath);
684
+ return stats;
692
685
  } finally {
693
686
  await rm(tempDir, { recursive: true, force: true });
694
687
  }
@@ -176,6 +176,15 @@ function formatSiteSummary(rows) {
176
176
  .join(", ");
177
177
  }
178
178
 
179
+ /**
180
+ * How the target instance maps requests to sites.
181
+ *
182
+ * @param {{ SITE_RESOLUTION_MODE?: unknown }} [env] The process environment,
183
+ * or runtime bindings built from it; defaults to `process.env`
184
+ * @returns {"host-based" | "single-site"} `host-based` only when set to it
185
+ * @example
186
+ * if (getCliSiteResolutionMode(process.env) === "host-based") { ... }
187
+ */
179
188
  export function getCliSiteResolutionMode(env = process.env) {
180
189
  return env.SITE_RESOLUTION_MODE === "host-based"
181
190
  ? "host-based"
@@ -192,7 +201,7 @@ export function getCliSiteResolutionMode(env = process.env) {
192
201
  *
193
202
  * @param {{ query(sql: string): Promise<Record<string, unknown>[]>, execute?(sql: string): Promise<void> }} queryRunner
194
203
  * Runs SQL against the target database
195
- * @param {{ env?: Record<string, string | undefined>, site?: string, host?: string, pathPrefix?: string, url?: string, createIfMissing?: boolean, bootstrapSite?: { id?: string, key?: string } }} [options]
204
+ * @param {{ env?: { SITE_RESOLUTION_MODE?: unknown }, site?: string, host?: string, pathPrefix?: string, url?: string, createIfMissing?: boolean, bootstrapSite?: { id?: string, key?: string } }} [options]
196
205
  * The environment, the command's site flags, and whether single-site mode
197
206
  * may create the site shell when none exists
198
207
  * @returns {Promise<{ created: boolean, site: { id: string, key: string, status: string, createdAt: number, updatedAt: number } }>}
@@ -5,31 +5,101 @@ export const SNAPSHOT_FORMAT = "jant-site-snapshot";
5
5
  export const SNAPSHOT_VERSION = 2;
6
6
  export const SUPPORTED_SNAPSHOT_VERSIONS = [1, SNAPSHOT_VERSION];
7
7
 
8
+ /**
9
+ * Site content, in the order rows are inserted on import.
10
+ *
11
+ * Membership is what this list decides: a table absent from it is never read,
12
+ * so its rows leave no trace in the snapshot and `--replace` cannot clear them.
13
+ *
14
+ * The order here is kept as a foreign-key topological sort for readability, but
15
+ * it is not what an export follows — `sortExportTables` re-sorts by
16
+ * `TABLE_EXPORT_ORDER` in `sql-export.js`, and that is the list to change when
17
+ * the dump order has to change. `src/__tests__/snapshot-tables.test.ts` checks
18
+ * the sorted result for that reason.
19
+ */
8
20
  export const SNAPSHOT_TABLES = [
9
21
  "site_setting",
10
22
  "collection",
11
- "nav_item",
12
- "collection_directory_item",
23
+ "smart_collection",
13
24
  "post",
14
25
  "thread_collection",
26
+ "nav_item",
27
+ "collection_directory_item",
15
28
  "path_registry",
16
29
  "media",
17
30
  ];
18
31
 
32
+ /**
33
+ * The same content, in the order `--replace` deletes it: children first, so no
34
+ * delete depends on a cascade to clean up after it. `site_setting` is absent on
35
+ * purpose — `buildReplaceSql` clears it by key, because a site's settings hold
36
+ * more than the snapshot carries.
37
+ */
19
38
  export const SNAPSHOT_CLEAR_TABLES = [
20
- "thread_collection",
21
39
  "media",
22
40
  "path_registry",
23
41
  "collection_directory_item",
24
42
  "nav_item",
43
+ "thread_collection",
25
44
  "post",
45
+ "smart_collection",
26
46
  "collection",
27
47
  ];
28
48
 
49
+ /**
50
+ * Site-scoped tables the snapshot deliberately leaves alone.
51
+ *
52
+ * Every table carrying a `site_id` has to appear here or in `SNAPSHOT_TABLES`
53
+ * — `src/__tests__/snapshot-tables.test.ts` fails the build otherwise, so a new
54
+ * table forces a decision rather than being silently dropped. The reasons fall
55
+ * into three groups: auth and routing shell that a content restore must
56
+ * preserve, credentials and external integration bindings that do not travel
57
+ * with content, and transient runtime bookkeeping.
58
+ */
59
+ export const SNAPSHOT_EXCLUDED_TABLES = [
60
+ // Auth and routing shell. `--replace` restores content into an existing
61
+ // site; its members and domains belong to the deployment, not the content.
62
+ "site_member",
63
+ "site_domain",
64
+ // Credentials. Reissued per deployment, never copied between sites. The demo
65
+ // rebuild clears these through the internal admin route instead.
66
+ "api_token",
67
+ // External integration bindings. They point at an installation or a chat on
68
+ // someone else's service, so restoring them into another site would aim that
69
+ // site at a binding it does not own.
70
+ "github_app_installation",
71
+ "telegram_binding",
72
+ "telegram_pending_binding",
73
+ "telegram_media_group_item",
74
+ // Transient runtime bookkeeping, rebuilt by the runtime as it goes:
75
+ // in-flight uploads, and the recycle-window ledger for deleted objects.
76
+ "upload_session",
77
+ "storage_purge",
78
+ ];
79
+
80
+ /**
81
+ * Settings the snapshot carries: the site's published identity and appearance.
82
+ *
83
+ * `site_setting` holds more than a site's content — deployment wiring, secrets,
84
+ * integration bindings, local UI state — so both the export and `--replace`
85
+ * work through this allowlist rather than the whole table. Membership is what
86
+ * it decides: a key absent from it is neither exported nor cleared on import.
87
+ *
88
+ * Every DB-backed key in `CONFIG_FIELDS` has to appear here or in
89
+ * `SNAPSHOT_EXCLUDED_SETTING_KEYS`, and
90
+ * `src/__tests__/snapshot-settings.test.ts` fails the build otherwise, so a new
91
+ * setting forces a decision instead of being silently dropped.
92
+ */
29
93
  export const SNAPSHOT_SETTING_KEYS = [
30
94
  "SITE_NAME",
31
95
  "SITE_DESCRIPTION",
96
+ // The language trio travels together. `post.language` and
97
+ // `post.translation_group_id` are already carried by the whole-row `post`
98
+ // dump, so leaving the switch behind restored a site whose posts were
99
+ // stamped per language while every per-language view was gone.
32
100
  "SITE_LANGUAGE",
101
+ "MULTILINGUAL_ENABLED",
102
+ "ADDITIONAL_LANGUAGES",
33
103
  "MAIN_RSS_FEED",
34
104
  "PUBLIC_API_ENABLED",
35
105
  "RSS_FEEDS_ENABLED",
@@ -48,6 +118,68 @@ export const SNAPSHOT_SETTING_KEYS = [
48
118
  "NOINDEX",
49
119
  ];
50
120
 
121
+ /**
122
+ * DB-backed settings the snapshot deliberately leaves alone.
123
+ *
124
+ * `envOnly` keys are absent from both lists on purpose: they never reach
125
+ * `site_setting`, so there is no row to carry or clear.
126
+ */
127
+ export const SNAPSHOT_EXCLUDED_SETTING_KEYS = [
128
+ // The operator's own dashboard locale, not the site's published language.
129
+ // A content restore should not relabel the dashboard of whoever runs the
130
+ // target site.
131
+ "DASHBOARD_LANGUAGE",
132
+ // Sizing and pacing knobs. They describe how much the target deployment
133
+ // serves at a time, not what it publishes, and they carry env defaults the
134
+ // deployment picked.
135
+ "ARCHIVE_DEFAULT_LAYOUT",
136
+ "PAGE_SIZE",
137
+ "SEARCH_PAGE_SIZE",
138
+ "ARCHIVE_PAGE_SIZE",
139
+ "SUMMARY_MAX_PARAGRAPHS",
140
+ "SUMMARY_MAX_CHARS",
141
+ "RSS_FEED_LIMIT",
142
+ "RSS_PUBLISH_DELAY_SECONDS",
143
+ // Code injection. `CUSTOM_CSS` is declarative and travels; these two are
144
+ // executable markup, and importing an archive must not be a way to run
145
+ // script on the importing site.
146
+ "CUSTOM_HEAD_HTML",
147
+ "CUSTOM_BODY_END_HTML",
148
+ // Being listed in the Jant Discover directory is the site owner's consent,
149
+ // and the announcement state below it belongs to the announcing instance.
150
+ "DISCOVER",
151
+ "DISCOVER_ANNOUNCE_STATE",
152
+ // Local UI state: which affordances this author has already been shown, and
153
+ // how far setup got.
154
+ "DISCOVERY_COMPOSE_OPEN_SHORTCUT_AT",
155
+ "DISCOVERY_SLASH_COMMAND_AT",
156
+ "ONBOARDING_STATUS",
157
+ // Credential. Reissued per deployment, never copied between sites.
158
+ "PASSWORD_RESET_TOKEN",
159
+ // External integration bindings, their secrets, and their sync bookkeeping.
160
+ // They point at a repo, an installation, or a chat on someone else's
161
+ // service, so restoring them would aim the target site at a binding it does
162
+ // not own — the same reason `github_app_installation` and `telegram_binding`
163
+ // are excluded tables.
164
+ "GITHUB_SYNC_ENABLED",
165
+ "GITHUB_SYNC_REPO",
166
+ "GITHUB_SYNC_TOKEN",
167
+ "GITHUB_SYNC_WEBHOOK_SECRET",
168
+ "GITHUB_SYNC_WEBHOOK_ID",
169
+ "GITHUB_SYNC_LAST_PUSH_SHA",
170
+ "GITHUB_SYNC_LAST_PUSH_AT",
171
+ "GITHUB_SYNC_PENDING",
172
+ "GITHUB_SYNC_PENDING_AT",
173
+ "GITHUB_SYNC_DIRTY",
174
+ "GITHUB_SYNC_LAST_ERROR",
175
+ "GITHUB_SYNC_AUTH_MODE",
176
+ "GITHUB_SYNC_APP_INSTALLATION_ID",
177
+ "TELEGRAM_BOT_TOKEN",
178
+ "TELEGRAM_BOT_ID",
179
+ "TELEGRAM_BOT_USERNAME",
180
+ "TELEGRAM_BOT_WEBHOOK_SECRET",
181
+ ];
182
+
51
183
  function escapeSqlString(value) {
52
184
  return String(value).replaceAll("'", "''");
53
185
  }
@@ -78,6 +210,12 @@ const SELECT_SQL_BY_TABLE = {
78
210
  WHERE "site_id" = ?1
79
211
  ORDER BY "position", "id"
80
212
  `,
213
+ smart_collection: `
214
+ SELECT *
215
+ FROM "smart_collection"
216
+ WHERE "site_id" = ?1
217
+ ORDER BY "created_at", "id"
218
+ `,
81
219
  post: `
82
220
  SELECT *
83
221
  FROM "post"
@@ -118,6 +256,14 @@ export function getSnapshotSelectSql(tableName, siteId) {
118
256
  return statement.trim().replaceAll("?1", `'${escapeSqlString(siteId)}'`);
119
257
  }
120
258
 
259
+ /**
260
+ * SQL listing a site's stored media objects: originals and video posters.
261
+ *
262
+ * @param {string} siteId Site whose media rows to read
263
+ * @returns {string} A query returning `key` and `contentType` columns
264
+ * @example
265
+ * collectSnapshotObjects(await query(buildSnapshotStorageQuery(site.id)))
266
+ */
121
267
  export function buildSnapshotStorageQuery(siteId) {
122
268
  return `
123
269
  SELECT "key", "contentType"
@@ -146,6 +292,27 @@ export function buildSnapshotStorageQuery(siteId) {
146
292
  `.trim();
147
293
  }
148
294
 
295
+ /**
296
+ * A storage object a snapshot carries.
297
+ *
298
+ * @typedef {object} SnapshotObject
299
+ * @property {string} key Storage key
300
+ * @property {string} contentType MIME type, or `""` when neither the row nor
301
+ * the key's extension gives one
302
+ */
303
+
304
+ /**
305
+ * Deduplicate the rows of `buildSnapshotStorageQuery` into storage objects.
306
+ *
307
+ * Rows with a blank key are skipped. A row without a content type takes one
308
+ * from the key's extension.
309
+ *
310
+ * @param {Record<string, unknown>[]} rows Rows with `key` and `contentType`
311
+ * @returns {SnapshotObject[]} One entry per distinct key
312
+ * @example
313
+ * collectSnapshotObjects([{ key: "media/a.png", contentType: null }])
314
+ * // => [{ key: "media/a.png", contentType: "image/png" }]
315
+ */
149
316
  export function collectSnapshotObjects(rows) {
150
317
  const objects = new Map();
151
318
 
@@ -198,6 +365,28 @@ export function buildSnapshotMeta(site, options = {}) {
198
365
  };
199
366
  }
200
367
 
368
+ /**
369
+ * A snapshot's `meta.json`, as `assertSnapshotMeta` accepts it.
370
+ *
371
+ * @typedef {object} SnapshotMeta
372
+ * @property {typeof SNAPSHOT_FORMAT} format
373
+ * @property {number} version One of `SUPPORTED_SNAPSHOT_VERSIONS`
374
+ * @property {"sqlite" | "pg"} [dialect] Absent from snapshots that predate it
375
+ * @property {{ id: string, key: string }} [site] The site the snapshot was
376
+ * exported from; absent from legacy snapshots
377
+ * @property {unknown} [tables] Listed by legacy snapshots only
378
+ */
379
+
380
+ /**
381
+ * Refuse a `meta.json` this version cannot import.
382
+ *
383
+ * @param {unknown} meta Parsed `meta.json`
384
+ * @returns {asserts meta is SnapshotMeta}
385
+ * @throws {Error} When the format, version, dialect, or site is not supported
386
+ * @example
387
+ * const meta = JSON.parse(await readFile(metaPath, "utf8"));
388
+ * assertSnapshotMeta(meta);
389
+ */
201
390
  export function assertSnapshotMeta(meta) {
202
391
  if (!meta || typeof meta !== "object") {
203
392
  throw new Error("Snapshot meta.json is missing or invalid.");
@@ -274,6 +463,15 @@ export function isLegacySnapshotMeta(meta) {
274
463
  return !meta?.site || tables.includes("setting");
275
464
  }
276
465
 
466
+ /**
467
+ * The site a snapshot was exported from, for creating or remapping the target.
468
+ *
469
+ * @param {SnapshotMeta} meta Snapshot meta accepted by `assertSnapshotMeta`
470
+ * @returns {{ id: string, key: string } | undefined} The source site, or
471
+ * undefined for a legacy snapshot, which records none
472
+ * @example
473
+ * const snapshotSite = getSnapshotBootstrapSite(meta);
474
+ */
277
475
  export function getSnapshotBootstrapSite(meta) {
278
476
  if (isLegacySnapshotMeta(meta)) {
279
477
  return undefined;
@@ -285,6 +483,18 @@ export function getSnapshotBootstrapSite(meta) {
285
483
  };
286
484
  }
287
485
 
486
+ /**
487
+ * Refuse to import a snapshot into a site other than the one it came from.
488
+ *
489
+ * Legacy snapshots record no site and pass.
490
+ *
491
+ * @param {SnapshotMeta} meta Snapshot meta accepted by `assertSnapshotMeta`
492
+ * @param {{ id: string }} site The import target
493
+ * @returns {void}
494
+ * @throws {Error} When the snapshot names a different site
495
+ * @example
496
+ * validateSnapshotTargetSite(meta, targetSite);
497
+ */
288
498
  export function validateSnapshotTargetSite(meta, site) {
289
499
  if (isLegacySnapshotMeta(meta)) {
290
500
  return;
@@ -297,6 +507,17 @@ export function validateSnapshotTargetSite(meta, site) {
297
507
  }
298
508
  }
299
509
 
510
+ /**
511
+ * Replace a snapshot's source site id with the target's throughout its SQL.
512
+ *
513
+ * @param {string} sql Snapshot `db.sql`
514
+ * @param {string} sourceSiteId Site the snapshot was exported from; empty
515
+ * leaves the SQL unchanged
516
+ * @param {string} targetSiteId Site the snapshot is imported into
517
+ * @returns {string} The rewritten SQL
518
+ * @example
519
+ * rewriteSnapshotSiteIdentifiers(dbSql, snapshotSite.id, targetSite.id)
520
+ */
300
521
  export function rewriteSnapshotSiteIdentifiers(
301
522
  sql,
302
523
  sourceSiteId,
@@ -311,6 +532,17 @@ export function rewriteSnapshotSiteIdentifiers(
311
532
  return sql.replaceAll(escapedSource, escapedTarget);
312
533
  }
313
534
 
535
+ /**
536
+ * Move a storage key from the source site's namespace to the target's.
537
+ *
538
+ * @param {string} key Storage key as exported
539
+ * @param {string} sourceSiteId Site the snapshot was exported from; empty
540
+ * leaves the key unchanged
541
+ * @param {string} targetSiteId Site the snapshot is imported into
542
+ * @returns {string} The key to store the object under
543
+ * @example
544
+ * remapSnapshotObjectKey(entry.key, snapshotSite.id, targetSite.id)
545
+ */
314
546
  export function remapSnapshotObjectKey(key, sourceSiteId, targetSiteId) {
315
547
  if (!sourceSiteId || sourceSiteId === targetSiteId) {
316
548
  return key;
@@ -325,6 +557,12 @@ export function remapSnapshotObjectKey(key, sourceSiteId, targetSiteId) {
325
557
  * export time (with forward slashes). If the snapshot was produced by a
326
558
  * different site than the import target, callers apply
327
559
  * `remapSnapshotObjectKey()` before uploading.
560
+ *
561
+ * @param {string} rootDir Snapshot directory
562
+ * @returns {Promise<{ key: string, filePath: string, contentType: string }[]>}
563
+ * Files sorted by key; empty when there is no `objects/` directory
564
+ * @example
565
+ * const objectFiles = await enumerateSnapshotObjectFiles(snapshotDir);
328
566
  */
329
567
  export async function enumerateSnapshotObjectFiles(rootDir) {
330
568
  const objectsRoot = join(rootDir, "objects");
@@ -485,6 +723,16 @@ function splitSqlStatements(sql) {
485
723
  return statements;
486
724
  }
487
725
 
726
+ /**
727
+ * Scope a legacy single-site dump to a site: global `setting` rows become
728
+ * `site_setting` rows, and site-owned tables gain a `site_id` column.
729
+ *
730
+ * @param {string} sql Legacy snapshot `db.sql`
731
+ * @param {string} siteId Site the snapshot is imported into
732
+ * @returns {string} Site-scoped SQL
733
+ * @example
734
+ * rewriteLegacySnapshotSql(dbSql, targetSite.id)
735
+ */
488
736
  export function rewriteLegacySnapshotSql(sql, siteId) {
489
737
  const rewrittenStatements = splitSqlStatements(sql).map((statement) => {
490
738
  const normalized = statement.trim();
@@ -840,6 +1088,58 @@ function parseSqlScalar(raw) {
840
1088
  return trimmed;
841
1089
  }
842
1090
 
1091
+ /**
1092
+ * SQL clearing a site's snapshot content before a replacing import.
1093
+ *
1094
+ * @param {string} siteId Site to clear
1095
+ * @returns {string} `DELETE` statements for every `SNAPSHOT_CLEAR_TABLES`
1096
+ * table and the snapshot's setting keys
1097
+ * @example
1098
+ * await execute(`${buildReplaceSql(site.id)}\n${dbSql}`);
1099
+ */
1100
+ /**
1101
+ * Order `post` rows so every row comes after its Thread root and the post it
1102
+ * replies to.
1103
+ *
1104
+ * The dump reads posts by creation time, and `(site_id, thread_id)` and
1105
+ * `(site_id, reply_to_id)` are foreign keys. A post moved into a Thread keeps
1106
+ * its own creation time, and an import restores creation times, so a reply
1107
+ * can be older than its root; inserted in creation order it names a row that
1108
+ * isn't there yet, and the whole import fails. Otherwise the order is kept.
1109
+ *
1110
+ * @param {Record<string, unknown>[]} rows - `post` rows in dump order
1111
+ * @returns {Record<string, unknown>[]} The same rows, parents first
1112
+ * @example
1113
+ * orderSnapshotPostRows([reply, root]); // [root, reply]
1114
+ */
1115
+ export function orderSnapshotPostRows(rows) {
1116
+ const byId = new Map(rows.map((row) => [row.id, row]));
1117
+ const placed = new Set();
1118
+ const ordered = [];
1119
+
1120
+ const place = (row, visiting) => {
1121
+ if (placed.has(row.id) || visiting.has(row.id)) return;
1122
+ visiting.add(row.id);
1123
+ for (const parentId of [row.thread_id, row.reply_to_id]) {
1124
+ const parent = parentId !== row.id ? byId.get(parentId) : undefined;
1125
+ if (parent) place(parent, visiting);
1126
+ }
1127
+ placed.add(row.id);
1128
+ ordered.push(row);
1129
+ };
1130
+
1131
+ for (const row of rows) place(row, new Set());
1132
+ return ordered;
1133
+ }
1134
+
1135
+ /**
1136
+ * SQL a snapshot import runs before its inserts on SQLite and D1: foreign
1137
+ * keys are checked at commit rather than per row, so a snapshot written
1138
+ * before posts were ordered parents-first still loads. Postgres has no
1139
+ * equivalent for its non-deferrable keys; its snapshots are ordered at export.
1140
+ */
1141
+ export const DEFER_FOREIGN_KEYS_SQL = "PRAGMA defer_foreign_keys = ON;";
1142
+
843
1143
  export function buildReplaceSql(siteId) {
844
1144
  const statements = [];
845
1145
 
@@ -856,6 +1156,41 @@ export function buildReplaceSql(siteId) {
856
1156
  return statements.join("\n");
857
1157
  }
858
1158
 
1159
+ /** The storage drivers a `media` row can name as its `provider`. */
1160
+ const MEDIA_STORAGE_PROVIDERS = ["r2", "s3", "local"];
1161
+
1162
+ /**
1163
+ * SQL recording a site's media under the storage an import just wrote it to.
1164
+ *
1165
+ * A snapshot carries each media row's `provider` from the site it was exported
1166
+ * from, but the import uploads every object into the target's own storage. A
1167
+ * row exported on R2 and imported into S3 would still say `r2`, and everything
1168
+ * that reads `provider` as where the bytes live goes wrong: the public URL looks
1169
+ * for `R2_PUBLIC_URL`, replacing the avatar or favicon misses the existing row
1170
+ * and inserts a second one for the same key, and a trashed object is never
1171
+ * purged because the sweep only handles the active driver. Run after the
1172
+ * snapshot's inserts, while every media row of the site is one the snapshot
1173
+ * brought.
1174
+ *
1175
+ * @param {string} siteId - Site whose media was imported
1176
+ * @param {string} provider - Storage driver the import uploaded through
1177
+ * @returns {string} An `UPDATE` statement for the site's media rows
1178
+ * @throws {Error} When `provider` is not one of r2, s3, or local
1179
+ * @example
1180
+ * await execute(
1181
+ * `${buildReplaceSql(site.id)}\n${dbSql}\n${buildMediaProviderSql(site.id, "s3")}`,
1182
+ * );
1183
+ */
1184
+ export function buildMediaProviderSql(siteId, provider) {
1185
+ if (!MEDIA_STORAGE_PROVIDERS.includes(provider)) {
1186
+ throw new Error(
1187
+ `Snapshot import cannot record media under storage driver "${provider}". Expected one of: ${MEDIA_STORAGE_PROVIDERS.join(", ")}.`,
1188
+ );
1189
+ }
1190
+
1191
+ return `UPDATE "media" SET "provider" = '${provider}' WHERE "site_id" = '${escapeSqlString(siteId)}';`;
1192
+ }
1193
+
859
1194
  export function normalizeD1Sql(sql) {
860
1195
  return sql
861
1196
  .replace(/^\s*BEGIN(?:\s+TRANSACTION)?\s*;\s*$/gim, "")
@@ -1,4 +1,17 @@
1
- const TABLE_EXPORT_ORDER = [
1
+ /**
2
+ * The order rows are dumped, and so the order they are replayed on import.
3
+ *
4
+ * It is a foreign-key topological sort: a table follows everything it points
5
+ * at. `sortExportTables` applies it to whatever table list it is handed, so
6
+ * this — not the caller's list — is what decides an export's order.
7
+ *
8
+ * A table missing from here is not an ordering no-op. Unknown names sort after
9
+ * every known one, so a new child table lands at the end, behind its own
10
+ * parents, and the dump only fails once there is a row to violate the
11
+ * constraint. `snapshot-tables.test.ts` checks the sorted result, not the
12
+ * caller's list, for that reason.
13
+ */
14
+ export const TABLE_EXPORT_ORDER = [
2
15
  "site",
3
16
  "user",
4
17
  "account",
@@ -8,11 +21,15 @@ const TABLE_EXPORT_ORDER = [
8
21
  "site_setting",
9
22
  "site_member",
10
23
  "collection",
11
- "nav_item",
12
- "collection_directory_item",
24
+ // Before the three tables that hold a key into it: `nav_item`,
25
+ // `collection_directory_item` and `path_registry`.
26
+ "smart_collection",
13
27
  "api_token",
14
28
  "post",
15
29
  "thread_collection",
30
+ // After `post`: a `page` nav item carries a `post_id`.
31
+ "nav_item",
32
+ "collection_directory_item",
16
33
  "path_registry",
17
34
  "media",
18
35
  ];
@@ -167,8 +184,48 @@ export async function getTableColumns(
167
184
  .map((row) => String(row.name));
168
185
  }
169
186
 
187
+ /**
188
+ * Read an ordered SELECT a page at a time.
189
+ *
190
+ * `LIMIT … OFFSET …` is appended to the statement, so it must end in an
191
+ * ORDER BY with a unique tiebreaker. Pages are separate reads: rows written
192
+ * between two of them can be skipped or repeated, which is why a snapshot
193
+ * should be taken of a site nobody is writing to.
194
+ *
195
+ * @param {{ query(sql: string): Promise<Record<string, unknown>[]> }} queryRunner
196
+ * @param {string} selectSql - An ordered SELECT with no LIMIT
197
+ * @param {number} pageSize - Rows per read
198
+ * @returns {Promise<Record<string, unknown>[]>} Every row, in order
199
+ * @example
200
+ * await queryAllPages(runner, 'SELECT * FROM "post" ORDER BY rowid', 200);
201
+ */
202
+ async function queryAllPages(queryRunner, selectSql, pageSize) {
203
+ const base = selectSql.trim();
204
+ const rows = [];
205
+ for (let offset = 0; ; offset += pageSize) {
206
+ const page = await queryRunner.query(
207
+ `${base} LIMIT ${pageSize} OFFSET ${offset}`,
208
+ );
209
+ rows.push(...page);
210
+ if (page.length < pageSize) return rows;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Dump tables as INSERT statements.
216
+ *
217
+ * `options.pageSize` reads each table in pages. D1 needs it: every query runs
218
+ * through `wrangler d1 execute --json`, and one SELECT of a real site's
219
+ * `post` table outgrew the output the CLI can buffer.
220
+ * `options.orderRowsByTable` reorders a table's rows before they are written,
221
+ * for inserts that must follow their own foreign keys.
222
+ */
170
223
  export async function dumpDatabaseToSql(queryRunner, options) {
171
224
  const dialect = options.dialect ?? "sqlite";
225
+ const pageSize =
226
+ Number.isInteger(options.pageSize) && options.pageSize > 0
227
+ ? options.pageSize
228
+ : null;
172
229
  const onProgress =
173
230
  typeof options.onProgress === "function" ? options.onProgress : null;
174
231
  const configuredTables = Array.isArray(options.tables)
@@ -194,8 +251,14 @@ export async function dumpDatabaseToSql(queryRunner, options) {
194
251
 
195
252
  const selectSql =
196
253
  options.selectSqlByTable?.[tableName] ||
197
- `SELECT * FROM ${quoteIdentifier(tableName)}`;
198
- const rows = await queryRunner.query(selectSql);
254
+ (pageSize && dialect === "sqlite"
255
+ ? `SELECT * FROM ${quoteIdentifier(tableName)} ORDER BY rowid`
256
+ : `SELECT * FROM ${quoteIdentifier(tableName)}`);
257
+ const readRows = pageSize
258
+ ? await queryAllPages(queryRunner, selectSql, pageSize)
259
+ : await queryRunner.query(selectSql);
260
+ const orderRows = options.orderRowsByTable?.[tableName];
261
+ const rows = orderRows ? orderRows(readRows) : readRows;
199
262
  if (rows.length === 0) {
200
263
  continue;
201
264
  }
@@ -48,12 +48,20 @@ function resolveWranglerBin(cwd = process.cwd()) {
48
48
  return resolve(dirname(manifestPath), binEntry);
49
49
  }
50
50
 
51
+ /**
52
+ * The most output a Wrangler call may print before the CLI gives up on it.
53
+ * `d1 execute --json` prints whole result sets, and Node's default of 1 MiB
54
+ * overflowed on one page of a real site's posts.
55
+ */
56
+ export const WRANGLER_MAX_BUFFER = 64 * 1024 * 1024;
57
+
51
58
  export function runLocalWrangler(args, options = {}) {
52
59
  const {
53
60
  cwd = process.cwd(),
54
61
  encoding = "utf-8",
55
62
  env = process.env,
56
63
  stdio = "pipe",
64
+ maxBuffer = WRANGLER_MAX_BUFFER,
57
65
  ...execOptions
58
66
  } = options;
59
67
 
@@ -63,5 +71,6 @@ export function runLocalWrangler(args, options = {}) {
63
71
  encoding,
64
72
  env,
65
73
  stdio,
74
+ maxBuffer,
66
75
  });
67
76
  }