@jant/core 0.3.46 → 0.3.47

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 (109) hide show
  1. package/bin/commands/db/execute-file.js +12 -4
  2. package/bin/commands/db/rehearse.js +2 -2
  3. package/bin/commands/export.js +12 -4
  4. package/bin/commands/import-site.js +60 -267
  5. package/bin/commands/migrate.js +36 -69
  6. package/bin/commands/reset-password.js +10 -4
  7. package/bin/commands/site/export.js +59 -248
  8. package/bin/commands/site/snapshot/export.js +58 -45
  9. package/bin/commands/site/snapshot/import.js +104 -52
  10. package/bin/lib/node-env.js +100 -0
  11. package/bin/lib/runtime-target.js +64 -0
  12. package/bin/lib/site-snapshot.js +185 -54
  13. package/bin/lib/sql-export.js +19 -2
  14. package/dist/{app-DB-P66E5.js → app-3REcR-3U.js} +331 -189
  15. package/dist/app-B67XOEyo.js +6 -0
  16. package/dist/client/.vite/manifest.json +2 -2
  17. package/dist/client/_assets/{client-auth-BLCUje4M.js → client-auth-Ce5WEAVS.js} +102 -49
  18. package/dist/client/_assets/client-s71Js1Cu.css +2 -0
  19. package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
  20. package/dist/github-sync-C593r22F.js +4 -0
  21. package/dist/github-sync-bL1hnx3Q.js +428 -0
  22. package/dist/index.js +3 -2
  23. package/dist/node.js +5 -4
  24. package/package.json +3 -2
  25. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  26. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
  28. package/src/client/components/jant-settings-general.ts +164 -22
  29. package/src/client/components/settings-types.ts +4 -6
  30. package/src/client-auth.ts +1 -1
  31. package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
  32. package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
  33. package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
  34. package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
  35. package/src/db/migrations/meta/0021_snapshot.json +2121 -0
  36. package/src/db/migrations/meta/_journal.json +7 -0
  37. package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
  38. package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
  39. package/src/db/migrations/pg/meta/_journal.json +7 -0
  40. package/src/db/pg/schema.ts +21 -26
  41. package/src/db/rehearsal-fixtures/demo-current.json +1 -1
  42. package/src/db/schema.ts +16 -20
  43. package/src/i18n/__tests__/middleware.test.ts +43 -1
  44. package/src/i18n/coverage.generated.ts +17 -0
  45. package/src/i18n/i18n.ts +18 -2
  46. package/src/i18n/index.ts +3 -0
  47. package/src/i18n/locales/settings/en.po +16 -11
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +17 -12
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +16 -11
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/i18n/locales.ts +84 -2
  54. package/src/i18n/middleware.ts +25 -16
  55. package/src/i18n/supported-locales.ts +153 -0
  56. package/src/lib/__tests__/csp-builder.test.ts +19 -2
  57. package/src/lib/__tests__/feed.test.ts +242 -1
  58. package/src/lib/__tests__/post-meta.test.ts +0 -1
  59. package/src/lib/__tests__/view.test.ts +0 -1
  60. package/src/lib/csp-builder.ts +28 -10
  61. package/src/lib/feed.ts +153 -3
  62. package/src/middleware/__tests__/secure-headers.test.ts +89 -0
  63. package/src/middleware/auth.ts +1 -1
  64. package/src/middleware/secure-headers.ts +47 -1
  65. package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
  66. package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
  67. package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
  68. package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
  69. package/src/node/__tests__/cli-sql-export.test.ts +49 -0
  70. package/src/node/index.ts +1 -0
  71. package/src/preset.css +8 -2
  72. package/src/routes/api/__tests__/settings.test.ts +3 -2
  73. package/src/routes/api/github-sync.tsx +1 -1
  74. package/src/routes/api/settings.ts +4 -1
  75. package/src/routes/auth/signin.tsx +6 -0
  76. package/src/routes/pages/archive.tsx +4 -2
  77. package/src/services/__tests__/post.test.ts +19 -19
  78. package/src/services/__tests__/search.test.ts +0 -1
  79. package/src/services/__tests__/settings.test.ts +22 -3
  80. package/src/services/bootstrap.ts +7 -3
  81. package/src/services/collection.ts +3 -3
  82. package/src/services/export.ts +0 -3
  83. package/src/services/navigation.ts +0 -2
  84. package/src/services/path.ts +1 -38
  85. package/src/services/post.ts +32 -66
  86. package/src/services/search.ts +0 -6
  87. package/src/services/settings.ts +47 -6
  88. package/src/services/site-admin.ts +6 -1
  89. package/src/styles/ui.css +12 -23
  90. package/src/types/entities.ts +0 -1
  91. package/src/ui/color-themes.ts +1 -1
  92. package/src/ui/dash/settings/GeneralContent.tsx +17 -19
  93. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
  94. package/src/ui/feed/NoteCard.tsx +1 -11
  95. package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
  96. package/src/ui/pages/PostPage.tsx +2 -0
  97. package/bin/commands/collections.js +0 -268
  98. package/bin/commands/media.js +0 -302
  99. package/bin/commands/posts.js +0 -262
  100. package/bin/commands/search.js +0 -53
  101. package/bin/commands/settings.js +0 -93
  102. package/bin/lib/http-api.js +0 -223
  103. package/bin/lib/media-upload.js +0 -206
  104. package/dist/app-CM7sb3xO.js +0 -5
  105. package/dist/client/_assets/client-DDs6NzB3.css +0 -2
  106. package/src/__tests__/bin/content-cli.test.ts +0 -179
  107. package/src/__tests__/bin/media-cli.test.ts +0 -192
  108. /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
  109. /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ assertSnapshotDialectMatches,
4
+ assertSnapshotMeta,
5
+ buildSnapshotMeta,
6
+ getSnapshotDialect,
7
+ } from "../../../bin/lib/site-snapshot.js";
8
+
9
+ const SITE = { id: "sit_test", key: "default" };
10
+
11
+ describe("buildSnapshotMeta", () => {
12
+ it("includes the dialect when provided", () => {
13
+ const meta = buildSnapshotMeta(SITE, { dialect: "pg" });
14
+ expect(meta.dialect).toBe("pg");
15
+ expect(meta.site).toEqual({ id: "sit_test", key: "default" });
16
+ });
17
+
18
+ it("omits the dialect field when not provided (back-compat)", () => {
19
+ const meta = buildSnapshotMeta(SITE);
20
+ expect("dialect" in meta).toBe(false);
21
+ });
22
+
23
+ it("rejects unknown dialects at build time", () => {
24
+ expect(() => buildSnapshotMeta(SITE, { dialect: "mysql" })).toThrow(
25
+ /Unsupported snapshot dialect/,
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("assertSnapshotMeta", () => {
31
+ it("accepts a known dialect", () => {
32
+ expect(() =>
33
+ assertSnapshotMeta(buildSnapshotMeta(SITE, { dialect: "sqlite" })),
34
+ ).not.toThrow();
35
+ });
36
+
37
+ it("accepts a snapshot without a dialect (legacy)", () => {
38
+ expect(() => assertSnapshotMeta(buildSnapshotMeta(SITE))).not.toThrow();
39
+ });
40
+
41
+ it("rejects an unknown dialect at read time", () => {
42
+ expect(() =>
43
+ assertSnapshotMeta({
44
+ format: "jant-site-snapshot",
45
+ version: 1,
46
+ dialect: "mysql",
47
+ site: SITE,
48
+ }),
49
+ ).toThrow(/Snapshot meta has unsupported dialect/);
50
+ });
51
+ });
52
+
53
+ describe("getSnapshotDialect", () => {
54
+ it("returns the dialect when valid", () => {
55
+ expect(getSnapshotDialect({ dialect: "pg" })).toBe("pg");
56
+ expect(getSnapshotDialect({ dialect: "sqlite" })).toBe("sqlite");
57
+ });
58
+
59
+ it("returns undefined for missing or invalid dialect", () => {
60
+ expect(getSnapshotDialect({})).toBeUndefined();
61
+ expect(getSnapshotDialect({ dialect: "mysql" })).toBeUndefined();
62
+ expect(getSnapshotDialect(null)).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("assertSnapshotDialectMatches", () => {
67
+ it("passes when source and target dialects match", () => {
68
+ expect(() =>
69
+ assertSnapshotDialectMatches({ dialect: "pg" }, "pg"),
70
+ ).not.toThrow();
71
+ });
72
+
73
+ it("throws a descriptive error when dialects mismatch", () => {
74
+ expect(() =>
75
+ assertSnapshotDialectMatches({ dialect: "sqlite" }, "pg"),
76
+ ).toThrow(/Snapshot dialect mismatch.*source is sqlite.*target is pg/s);
77
+ });
78
+
79
+ it("skips validation when the snapshot predates the dialect field", () => {
80
+ // Older snapshots have no dialect field — we don't know the source, so we
81
+ // can't refuse them. The user opts into the looser check by importing
82
+ // legacy snapshots; cross-dialect SQL errors will surface mid-import.
83
+ expect(() => assertSnapshotDialectMatches({}, "pg")).not.toThrow();
84
+ });
85
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getTableColumns } from "../../../bin/lib/sql-export.js";
3
+
4
+ interface CapturedQueryRunner {
5
+ query: (sql: string) => Promise<Array<Record<string, unknown>>>;
6
+ lastSql?: string;
7
+ }
8
+
9
+ function createQueryRunner(
10
+ rows: Array<Record<string, unknown>>,
11
+ ): CapturedQueryRunner {
12
+ const runner: CapturedQueryRunner = {
13
+ async query(sql: string) {
14
+ runner.lastSql = sql;
15
+ return rows;
16
+ },
17
+ };
18
+ return runner;
19
+ }
20
+
21
+ describe("getTableColumns", () => {
22
+ it("filters Postgres GENERATED ALWAYS columns out of the dump column list", async () => {
23
+ const runner = createQueryRunner([
24
+ { name: "id" },
25
+ { name: "title" },
26
+ // pg already filters via is_generated = 'NEVER', so the runner only
27
+ // returns the storable columns. We capture the SQL to assert the WHERE.
28
+ ]);
29
+
30
+ const columns = await getTableColumns(runner, "post", "pg");
31
+
32
+ expect(columns).toEqual(["id", "title"]);
33
+ expect(runner.lastSql).toMatch(/is_generated\s*=\s*'NEVER'/);
34
+ });
35
+
36
+ it("filters SQLite STORED/VIRTUAL generated columns via table_xinfo.hidden", async () => {
37
+ const runner = createQueryRunner([
38
+ { cid: 0, name: "id", hidden: 0 },
39
+ { cid: 1, name: "title", hidden: 0 },
40
+ { cid: 2, name: "search_virtual", hidden: 2 },
41
+ { cid: 3, name: "search_stored", hidden: 3 },
42
+ ]);
43
+
44
+ const columns = await getTableColumns(runner, "post", "sqlite");
45
+
46
+ expect(columns).toEqual(["id", "title"]);
47
+ expect(runner.lastSql).toMatch(/PRAGMA\s+table_xinfo/);
48
+ });
49
+ });
package/src/node/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  createNodeRequestRuntime,
9
9
  } from "../runtime/node.js";
10
10
  export { createExportService } from "../services/export.js";
11
+ export { createStorageDriver } from "../lib/storage.js";
11
12
  export { resolveConfig } from "../lib/resolve-config.js";
12
13
  export { buildThemeStyle } from "../lib/theme.js";
13
14
  export { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
package/src/preset.css CHANGED
@@ -490,7 +490,8 @@ html {
490
490
  .feed-link-domain,
491
491
  .feed-continue-link,
492
492
  [data-post-body].prose,
493
- [data-post-meta] {
493
+ [data-post-meta],
494
+ [data-post-end] {
494
495
  width: var(--layout-content-width);
495
496
  }
496
497
 
@@ -498,6 +499,10 @@ html {
498
499
  width: min(80%, 45rem);
499
500
  }
500
501
 
502
+ [data-post-end]:not(:empty) {
503
+ margin-top: 3rem;
504
+ }
505
+
501
506
  @media (max-width: 1024px) {
502
507
  [data-page="search"],
503
508
  .site-home-header,
@@ -512,7 +517,8 @@ html {
512
517
  .feed-link-domain,
513
518
  .feed-continue-link,
514
519
  [data-post-body].prose,
515
- [data-post-meta] {
520
+ [data-post-meta],
521
+ [data-post-end] {
516
522
  width: min(100%, 35rem);
517
523
  }
518
524
  }
@@ -542,8 +542,9 @@ describe("Settings API Routes", () => {
542
542
  expect(storage.put).toHaveBeenCalledTimes(2);
543
543
 
544
544
  const mediaList = await services.media.list();
545
- expect(mediaList).toHaveLength(1);
546
- expect(mediaList[0]?.originalName).toBe("avatar.png");
545
+ expect(mediaList).toHaveLength(2);
546
+ const originalNames = mediaList.map((entry) => entry.originalName).sort();
547
+ expect(originalNames).toEqual(["apple-touch-icon.png", "avatar.png"]);
547
548
  });
548
549
  });
549
550
 
@@ -256,7 +256,7 @@ const ConnectSchema = z.object({
256
256
  // Connect: validate token, save config, create webhook
257
257
  githubSyncAdminRoutes.post("/setup", requireAuthApi(), async (c) => {
258
258
  // PAT connect is disabled when a GitHub App is configured — see the
259
- // dashboard route for rationale.
259
+ // settings route for rationale.
260
260
  if (getGitHubAppConfig(c.env)) {
261
261
  return c.json(
262
262
  {
@@ -176,7 +176,10 @@ settingsApiRoutes.post("/avatar", requireAuthApi(), async (c) => {
176
176
 
177
177
  // Remove site avatar (requires auth)
178
178
  settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
179
- await c.var.services.settings.removeAvatar(c.var.storage);
179
+ await c.var.services.settings.removeAvatar(c.var.storage, {
180
+ media: c.var.services.media,
181
+ storageProvider: c.var.appConfig.storageDriver,
182
+ });
180
183
  try {
181
184
  await syncHostedControlPlaneSiteAvatar({
182
185
  appConfig: c.var.appConfig,
@@ -128,6 +128,12 @@ signinRoutes.get("/signin", async (c) => {
128
128
  ? rawRedirect
129
129
  : undefined;
130
130
 
131
+ if (c.var.isAuthenticated) {
132
+ return c.redirect(
133
+ toPublicPath(redirect ?? "/", c.var.appConfig.sitePathPrefix),
134
+ );
135
+ }
136
+
131
137
  const hostedSigninUrl = getHostedControlPlaneSigninUrl(
132
138
  c.env,
133
139
  c.var.publicRequestUrl,
@@ -521,13 +521,15 @@ async function buildArchiveFeedData(
521
521
  ? await services.collections.getBySlug(params.collectionSlug)
522
522
  : undefined;
523
523
 
524
- // Feed always serves public-only content
524
+ // Feed mirrors the unauthenticated archive page: published + non-private,
525
+ // including Hidden-from-Latest. /archive is the canonical "all posts" view,
526
+ // so its feed must match.
525
527
  const filters: PostFilters = {
526
528
  format: params.format,
527
529
  status: "published",
528
530
  excludeReplies: true,
529
531
  excludePrivate: true,
530
- excludeLatestHidden: true,
532
+ excludeLatestHidden: false,
531
533
  collectionId: collection?.id,
532
534
  mediaKinds: params.mediaKinds,
533
535
  hasMedia: params.hasMedia,
@@ -82,7 +82,6 @@ describe("PostService", () => {
82
82
  expect(post.visibility).toBe("public");
83
83
  expect(post.pinnedAt).toBeNull();
84
84
  expect(post.bodyHtml).toContain("<p>Hello world</p>");
85
- expect(post.deletedAt).toBeNull();
86
85
  expect(post.threadId).toBe(post.id);
87
86
  });
88
87
 
@@ -787,7 +786,7 @@ describe("PostService", () => {
787
786
  ]);
788
787
  });
789
788
 
790
- it("excludes deleted posts by default", async () => {
789
+ it("excludes deleted posts", async () => {
791
790
  const post = await postService.create({
792
791
  format: "note",
793
792
  bodyMarkdown: "test",
@@ -800,17 +799,6 @@ describe("PostService", () => {
800
799
  expect(posts[0]?.bodyText).toBe("kept");
801
800
  });
802
801
 
803
- it("includes deleted posts when requested", async () => {
804
- const post = await postService.create({
805
- format: "note",
806
- bodyMarkdown: "test",
807
- });
808
- await postService.delete(post.id);
809
-
810
- const posts = await postService.list({ includeDeleted: true });
811
- expect(posts).toHaveLength(1);
812
- });
813
-
814
802
  it("supports limit", async () => {
815
803
  for (let i = 0; i < 5; i++) {
816
804
  await postService.create({ format: "note", bodyMarkdown: `post ${i}` });
@@ -1485,8 +1473,8 @@ describe("PostService", () => {
1485
1473
  });
1486
1474
  });
1487
1475
 
1488
- describe("delete (soft delete)", () => {
1489
- it("soft-deletes a post", async () => {
1476
+ describe("delete", () => {
1477
+ it("removes a post", async () => {
1490
1478
  const post = await postService.create({
1491
1479
  format: "note",
1492
1480
  bodyMarkdown: "test",
@@ -1495,7 +1483,6 @@ describe("PostService", () => {
1495
1483
  const result = await postService.delete(post.id);
1496
1484
  expect(result).toBe(true);
1497
1485
 
1498
- // Should not appear in regular queries
1499
1486
  const found = await postService.getById(post.id);
1500
1487
  expect(found).toBeNull();
1501
1488
  });
@@ -1518,7 +1505,6 @@ describe("PostService", () => {
1518
1505
 
1519
1506
  await postService.delete(root.id);
1520
1507
 
1521
- // Both root and reply should be soft-deleted
1522
1508
  expect(await postService.getById(root.id)).toBeNull();
1523
1509
  expect(await postService.getById(reply.id)).toBeNull();
1524
1510
  });
@@ -1541,11 +1527,25 @@ describe("PostService", () => {
1541
1527
 
1542
1528
  await postService.delete(reply1.id);
1543
1529
 
1544
- // Root should still exist
1545
1530
  expect(await postService.getById(root.id)).not.toBeNull();
1546
- // reply1 should be deleted
1547
1531
  expect(await postService.getById(reply1.id)).toBeNull();
1548
1532
  });
1533
+
1534
+ it("frees the slug for reuse after deletion", async () => {
1535
+ const post = await postService.create({
1536
+ format: "note",
1537
+ bodyMarkdown: "first",
1538
+ title: "Same Title",
1539
+ });
1540
+ await postService.delete(post.id);
1541
+
1542
+ const reused = await postService.create({
1543
+ format: "note",
1544
+ bodyMarkdown: "second",
1545
+ title: "Same Title",
1546
+ });
1547
+ expect(reused.slug).toBe(post.slug);
1548
+ });
1549
1549
  });
1550
1550
 
1551
1551
  describe("threads", () => {
@@ -44,7 +44,6 @@ function createSearchRow(overrides?: Partial<Record<string, unknown>>) {
44
44
  collection_id: null,
45
45
  reply_to_id: null,
46
46
  thread_id: "pst_01jpyz2pvf4m7s2k8r5c9t0qce",
47
- deleted_at: null,
48
47
  published_at: 1774009100,
49
48
  last_activity_at: 1774009100,
50
49
  created_at: 1774009100,
@@ -264,13 +264,32 @@ describe("SettingsService", () => {
264
264
  expect(result.languageChanged).toBe(true);
265
265
  });
266
266
 
267
- it("rejects unsupported locales", async () => {
267
+ it("accepts a locale without a shipped catalog (e.g. Swedish)", async () => {
268
+ const result = await settingsService.updateGeneral(
269
+ { ...defaults, siteLanguage: "sv" },
270
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
271
+ );
272
+
273
+ expect(result.languageChanged).toBe(true);
274
+ expect(await settingsService.get("SITE_LANGUAGE")).toBe("sv");
275
+ });
276
+
277
+ it("normalizes BCP 47 casing to canonical form", async () => {
278
+ await settingsService.updateGeneral(
279
+ { ...defaults, siteLanguage: "ZH-hans" },
280
+ { oldLanguage: "en", fallbackSiteName: "Jant" },
281
+ );
282
+
283
+ expect(await settingsService.get("SITE_LANGUAGE")).toBe("zh-Hans");
284
+ });
285
+
286
+ it("rejects values that are not valid BCP 47 tags", async () => {
268
287
  await expect(
269
288
  settingsService.updateGeneral(
270
- { ...defaults, siteLanguage: "sv" },
289
+ { ...defaults, siteLanguage: "not a locale!!!" },
271
290
  { oldLanguage: "en", fallbackSiteName: "Jant" },
272
291
  ),
273
- ).rejects.toThrow(/supported language/i);
292
+ ).rejects.toThrow(/BCP 47/i);
274
293
  });
275
294
 
276
295
  it("returns no language change when same", async () => {
@@ -9,7 +9,11 @@ import {
9
9
  sqliteSchemaBundle,
10
10
  type DatabaseSchema,
11
11
  } from "../db/schema-bundle.js";
12
- import { baseLocale, isLocale } from "../i18n/locales.js";
12
+ import {
13
+ baseLocale,
14
+ isValidContentLanguage,
15
+ normalizeContentLanguage,
16
+ } from "../i18n/locales.js";
13
17
  import { createNavItemService } from "./navigation.js";
14
18
  import { createSettingsService } from "./settings.js";
15
19
  import { createSiteMemberService } from "./site-member.js";
@@ -57,8 +61,8 @@ export function createBootstrapService(
57
61
  await settings.set("SITE_NAME", data.siteName.trim());
58
62
  await settings.set("TIME_ZONE", data.timeZone ?? "UTC");
59
63
  const siteLanguage =
60
- data.siteLanguage && isLocale(data.siteLanguage)
61
- ? data.siteLanguage
64
+ data.siteLanguage && isValidContentLanguage(data.siteLanguage)
65
+ ? normalizeContentLanguage(data.siteLanguage)
62
66
  : baseLocale;
63
67
  await settings.set("SITE_LANGUAGE", siteLanguage);
64
68
  if (data.cjkSerifFont && data.cjkSerifFont !== "off") {
@@ -432,14 +432,14 @@ export function createCollectionService(
432
432
  const postCount = sql<number>`
433
433
  CAST(COUNT(
434
434
  CASE
435
- WHEN ${posts.id} IS NOT NULL AND ${posts.deletedAt} IS NULL THEN 1
435
+ WHEN ${posts.id} IS NOT NULL THEN 1
436
436
  END
437
437
  ) AS INTEGER)
438
438
  `.as("post_count");
439
439
  const recentActivityAt = sql<number | null>`
440
440
  MAX(
441
441
  CASE
442
- WHEN ${posts.id} IS NOT NULL AND ${posts.deletedAt} IS NULL
442
+ WHEN ${posts.id} IS NOT NULL
443
443
  THEN COALESCE(
444
444
  ${posts.lastActivityAt},
445
445
  ${posts.publishedAt},
@@ -1050,7 +1050,7 @@ export function createCollectionService(
1050
1050
  .from(postCollections)
1051
1051
  .innerJoin(
1052
1052
  sql`post`,
1053
- sql`post.id = ${postCollections.postId} AND post.deleted_at IS NULL AND post.site_id = ${siteId}`,
1053
+ sql`post.id = ${postCollections.postId} AND post.site_id = ${siteId}`,
1054
1054
  )
1055
1055
  .where(eq(postCollections.siteId, siteId))
1056
1056
  .groupBy(postCollections.collectionId);
@@ -1274,9 +1274,6 @@ function buildExportedCollectionMetrics(
1274
1274
  }
1275
1275
 
1276
1276
  for (const post of posts) {
1277
- if (post.deletedAt !== null) {
1278
- continue;
1279
- }
1280
1277
  // Drafts and private posts are excluded — they won't reach Hugo.
1281
1278
  if (post.status === "draft" || post.visibility === "private") {
1282
1279
  continue;
@@ -446,14 +446,12 @@ export function createNavItemService(
446
446
  sql`(
447
447
  ${postCollections.createdAt} > ${threshold}
448
448
  OR (${posts.updatedAt} > ${threshold}
449
- AND ${posts.deletedAt} IS NULL
450
449
  AND ${posts.status} = 'published')
451
450
  OR EXISTS (
452
451
  SELECT 1 FROM ${posts} reply
453
452
  WHERE reply.site_id = ${postCollections.siteId}
454
453
  AND reply.thread_id = ${postCollections.postId}
455
454
  AND reply.reply_to_id IS NOT NULL
456
- AND reply.deleted_at IS NULL
457
455
  AND reply.status = 'published'
458
456
  AND reply.created_at > ${threshold}
459
457
  )
@@ -6,7 +6,7 @@
6
6
  * slash (for example: "hello-world" or "collections/reading+tools").
7
7
  */
8
8
 
9
- import { and, eq, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm";
9
+ import { and, eq, inArray, isNotNull, ne } from "drizzle-orm";
10
10
  import { type Database, batchQuery } from "../db/index.js";
11
11
  import {
12
12
  sqliteSchemaBundle,
@@ -95,36 +95,10 @@ export function createPathService(
95
95
  return normalizePath(path);
96
96
  }
97
97
 
98
- /**
99
- * Removes a path_registry entry for the given normalized path if it belongs
100
- * to a soft-deleted post, allowing the path to be reused.
101
- */
102
- async function reclaimDeletedPostPath(
103
- normalizedPath: string,
104
- excludePostId?: string,
105
- ): Promise<void> {
106
- const conditions = [
107
- eq(pathRegistry.siteId, siteId),
108
- eq(pathRegistry.path, normalizedPath),
109
- isNotNull(pathRegistry.postId),
110
- sql`EXISTS (
111
- SELECT 1 FROM ${posts}
112
- WHERE ${posts.id} = ${pathRegistry.postId}
113
- AND ${posts.deletedAt} IS NOT NULL
114
- )`,
115
- ];
116
- if (excludePostId) {
117
- conditions.push(ne(pathRegistry.postId, excludePostId));
118
- }
119
- await db.delete(pathRegistry).where(and(...conditions));
120
- }
121
-
122
98
  async function insertPath(input: CreatePathInput): Promise<PathRecord> {
123
99
  const timestamp = now();
124
100
  const normalizedPath = normalizeStoredPath(input.path);
125
101
 
126
- await reclaimDeletedPostPath(normalizedPath);
127
-
128
102
  try {
129
103
  const result = await db
130
104
  .insert(pathRegistry)
@@ -192,14 +166,6 @@ export function createPathService(
192
166
  const conditions = [
193
167
  eq(pathRegistry.siteId, siteId),
194
168
  eq(pathRegistry.path, normalized),
195
- // Ignore paths owned by soft-deleted posts — those slugs are available
196
- // for reuse. Paths not linked to a post (collections, redirects,
197
- // archives) always block.
198
- sql`(${pathRegistry.postId} IS NULL OR NOT EXISTS (
199
- SELECT 1 FROM ${posts}
200
- WHERE ${posts.id} = ${pathRegistry.postId}
201
- AND ${posts.deletedAt} IS NOT NULL
202
- ))`,
203
169
  ];
204
170
  if (excludeId) conditions.push(ne(pathRegistry.id, excludeId));
205
171
 
@@ -310,8 +276,6 @@ export function createPathService(
310
276
  const timestamp = now();
311
277
  const normalized = normalizeStoredPath(slug);
312
278
 
313
- await reclaimDeletedPostPath(normalized, postId);
314
-
315
279
  try {
316
280
  await db
317
281
  .update(pathRegistry)
@@ -424,7 +388,6 @@ export function createPathService(
424
388
  eq(pathRegistry.kind, "slug"),
425
389
  isNotNull(pathRegistry.postId),
426
390
  isNotNull(posts.title),
427
- isNull(posts.deletedAt),
428
391
  eq(posts.format, "note"),
429
392
  ),
430
393
  );