mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: Added a Sync version control system for tracking changes to posts, pages, home content, and footer.
This commit is contained in:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -29,6 +29,7 @@ import type * as search from "../search.js";
|
||||
import type * as semanticSearch from "../semanticSearch.js";
|
||||
import type * as semanticSearchQueries from "../semanticSearchQueries.js";
|
||||
import type * as stats from "../stats.js";
|
||||
import type * as versions from "../versions.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -58,6 +59,7 @@ declare const fullApi: ApiFromModules<{
|
||||
semanticSearch: typeof semanticSearch;
|
||||
semanticSearchQueries: typeof semanticSearchQueries;
|
||||
stats: typeof stats;
|
||||
versions: typeof versions;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Shared validator for post data
|
||||
const postDataValidator = v.object({
|
||||
@@ -150,6 +151,17 @@ export const updatePost = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
// Capture version before update (async, non-blocking)
|
||||
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
|
||||
contentType: "post",
|
||||
contentId: args.id,
|
||||
slug: existing.slug,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
description: existing.description,
|
||||
source: "dashboard",
|
||||
});
|
||||
|
||||
await ctx.db.patch(args.id, {
|
||||
...args.post,
|
||||
lastSyncedAt: Date.now(),
|
||||
@@ -253,6 +265,16 @@ export const updatePage = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
// Capture version before update (async, non-blocking)
|
||||
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
|
||||
contentType: "page",
|
||||
contentId: args.id,
|
||||
slug: existing.slug,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
source: "dashboard",
|
||||
});
|
||||
|
||||
await ctx.db.patch(args.id, {
|
||||
...args.page,
|
||||
lastSyncedAt: Date.now(),
|
||||
|
||||
@@ -35,5 +35,14 @@ crons.cron(
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up old content versions daily at 3:00 AM UTC
|
||||
// Deletes versions older than 3 days to maintain storage efficiency
|
||||
crons.cron(
|
||||
"cleanup old content versions",
|
||||
"0 3 * * *", // 3:00 AM UTC daily
|
||||
internal.versions.cleanupOldVersions,
|
||||
{}
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Get all pages (published and unpublished) for dashboard admin view
|
||||
export const listAll = query({
|
||||
@@ -392,6 +393,15 @@ export const syncPagesPublic = mutation({
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Capture version before update (async, non-blocking)
|
||||
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
|
||||
contentType: "page",
|
||||
contentId: existing._id,
|
||||
slug: existing.slug,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
source: "sync",
|
||||
});
|
||||
// Update existing sync page
|
||||
await ctx.db.patch(existing._id, {
|
||||
title: page.title,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Get all posts (published and unpublished) for dashboard admin view
|
||||
export const listAll = query({
|
||||
@@ -545,6 +546,16 @@ export const syncPostsPublic = mutation({
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Capture version before update (async, non-blocking)
|
||||
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
|
||||
contentType: "post",
|
||||
contentId: existing._id,
|
||||
slug: existing.slug,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
description: existing.description,
|
||||
source: "sync",
|
||||
});
|
||||
// Update existing sync post
|
||||
await ctx.db.patch(existing._id, {
|
||||
title: post.title,
|
||||
|
||||
@@ -244,4 +244,32 @@ export default defineSchema({
|
||||
)
|
||||
), // Optional sources cited in the response
|
||||
}).index("by_stream", ["streamId"]),
|
||||
|
||||
// Content version history for posts and pages
|
||||
// Stores snapshots before each update for 3-day retention
|
||||
contentVersions: defineTable({
|
||||
contentType: v.union(v.literal("post"), v.literal("page")), // Type of content
|
||||
contentId: v.string(), // ID of the post or page (stored as string for flexibility)
|
||||
slug: v.string(), // Slug for display and querying
|
||||
title: v.string(), // Title at time of snapshot
|
||||
content: v.string(), // Full markdown content at time of snapshot
|
||||
description: v.optional(v.string()), // Description (posts only)
|
||||
createdAt: v.number(), // Timestamp when version was created
|
||||
source: v.union(
|
||||
v.literal("sync"),
|
||||
v.literal("dashboard"),
|
||||
v.literal("restore")
|
||||
), // What triggered the version capture
|
||||
})
|
||||
.index("by_content", ["contentType", "contentId"])
|
||||
.index("by_slug", ["contentType", "slug"])
|
||||
.index("by_createdAt", ["createdAt"])
|
||||
.index("by_content_createdAt", ["contentType", "contentId", "createdAt"]),
|
||||
|
||||
// Version control settings
|
||||
// Stores toggle state for version control feature
|
||||
versionControlSettings: defineTable({
|
||||
key: v.string(), // Setting key: "enabled"
|
||||
value: v.boolean(), // Setting value
|
||||
}).index("by_key", ["key"]),
|
||||
});
|
||||
|
||||
276
convex/versions.ts
Normal file
276
convex/versions.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
// Retention period: 3 days in milliseconds
|
||||
const RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Check if version control is enabled
|
||||
export const isEnabled = query({
|
||||
args: {},
|
||||
returns: v.boolean(),
|
||||
handler: async (ctx) => {
|
||||
const setting = await ctx.db
|
||||
.query("versionControlSettings")
|
||||
.withIndex("by_key", (q) => q.eq("key", "enabled"))
|
||||
.first();
|
||||
return setting?.value === true;
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle version control on/off
|
||||
export const setEnabled = mutation({
|
||||
args: { enabled: v.boolean() },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("versionControlSettings")
|
||||
.withIndex("by_key", (q) => q.eq("key", "enabled"))
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { value: args.enabled });
|
||||
} else {
|
||||
await ctx.db.insert("versionControlSettings", {
|
||||
key: "enabled",
|
||||
value: args.enabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// Create a version snapshot (called before updates)
|
||||
// This is an internal mutation to be called from other mutations
|
||||
export const createVersion = internalMutation({
|
||||
args: {
|
||||
contentType: v.union(v.literal("post"), v.literal("page")),
|
||||
contentId: v.string(),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
content: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
source: v.union(
|
||||
v.literal("sync"),
|
||||
v.literal("dashboard"),
|
||||
v.literal("restore")
|
||||
),
|
||||
},
|
||||
returns: v.union(v.id("contentVersions"), v.null()),
|
||||
handler: async (ctx, args) => {
|
||||
// Check if version control is enabled
|
||||
const setting = await ctx.db
|
||||
.query("versionControlSettings")
|
||||
.withIndex("by_key", (q) => q.eq("key", "enabled"))
|
||||
.first();
|
||||
|
||||
if (setting?.value !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create version snapshot
|
||||
const versionId = await ctx.db.insert("contentVersions", {
|
||||
contentType: args.contentType,
|
||||
contentId: args.contentId,
|
||||
slug: args.slug,
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
description: args.description,
|
||||
createdAt: Date.now(),
|
||||
source: args.source,
|
||||
});
|
||||
|
||||
return versionId;
|
||||
},
|
||||
});
|
||||
|
||||
// Get version history for a piece of content
|
||||
export const getVersionHistory = query({
|
||||
args: {
|
||||
contentType: v.union(v.literal("post"), v.literal("page")),
|
||||
contentId: v.string(),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("contentVersions"),
|
||||
title: v.string(),
|
||||
createdAt: v.number(),
|
||||
source: v.union(
|
||||
v.literal("sync"),
|
||||
v.literal("dashboard"),
|
||||
v.literal("restore")
|
||||
),
|
||||
contentPreview: v.string(),
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const versions = await ctx.db
|
||||
.query("contentVersions")
|
||||
.withIndex("by_content", (q) =>
|
||||
q.eq("contentType", args.contentType).eq("contentId", args.contentId)
|
||||
)
|
||||
.order("desc")
|
||||
.collect();
|
||||
|
||||
return versions.map((v) => ({
|
||||
_id: v._id,
|
||||
title: v.title,
|
||||
createdAt: v.createdAt,
|
||||
source: v.source,
|
||||
contentPreview:
|
||||
v.content.slice(0, 150) + (v.content.length > 150 ? "..." : ""),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Get a specific version's full content
|
||||
export const getVersion = query({
|
||||
args: { versionId: v.id("contentVersions") },
|
||||
returns: v.union(
|
||||
v.object({
|
||||
_id: v.id("contentVersions"),
|
||||
contentType: v.union(v.literal("post"), v.literal("page")),
|
||||
contentId: v.string(),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
content: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
source: v.union(
|
||||
v.literal("sync"),
|
||||
v.literal("dashboard"),
|
||||
v.literal("restore")
|
||||
),
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version) return null;
|
||||
|
||||
return {
|
||||
_id: version._id,
|
||||
contentType: version.contentType,
|
||||
contentId: version.contentId,
|
||||
slug: version.slug,
|
||||
title: version.title,
|
||||
content: version.content,
|
||||
description: version.description,
|
||||
createdAt: version.createdAt,
|
||||
source: version.source,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Restore a previous version
|
||||
export const restoreVersion = mutation({
|
||||
args: { versionId: v.id("contentVersions") },
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version) {
|
||||
return { success: false, message: "Version not found" };
|
||||
}
|
||||
|
||||
// Get current content to create a backup before restoring
|
||||
let currentContent;
|
||||
if (version.contentType === "post") {
|
||||
currentContent = await ctx.db.get(
|
||||
version.contentId as Id<"posts">
|
||||
);
|
||||
} else {
|
||||
currentContent = await ctx.db.get(
|
||||
version.contentId as Id<"pages">
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentContent) {
|
||||
return { success: false, message: "Original content not found" };
|
||||
}
|
||||
|
||||
// Create backup version of current state before restoring
|
||||
await ctx.db.insert("contentVersions", {
|
||||
contentType: version.contentType,
|
||||
contentId: version.contentId,
|
||||
slug: version.slug,
|
||||
title: currentContent.title,
|
||||
content: currentContent.content,
|
||||
description:
|
||||
"description" in currentContent ? currentContent.description : undefined,
|
||||
createdAt: Date.now(),
|
||||
source: "restore",
|
||||
});
|
||||
|
||||
// Restore the content
|
||||
if (version.contentType === "post") {
|
||||
await ctx.db.patch(version.contentId as Id<"posts">, {
|
||||
title: version.title,
|
||||
content: version.content,
|
||||
description: version.description || "",
|
||||
lastSyncedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.patch(version.contentId as Id<"pages">, {
|
||||
title: version.title,
|
||||
content: version.content,
|
||||
lastSyncedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, message: "Version restored successfully" };
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up versions older than 3 days
|
||||
// Called by cron job
|
||||
export const cleanupOldVersions = internalMutation({
|
||||
args: {},
|
||||
returns: v.number(),
|
||||
handler: async (ctx) => {
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
|
||||
// Get old versions using the createdAt index
|
||||
const oldVersions = await ctx.db
|
||||
.query("contentVersions")
|
||||
.withIndex("by_createdAt", (q) => q.lt("createdAt", cutoff))
|
||||
.take(1000);
|
||||
|
||||
// Delete the batch
|
||||
await Promise.all(oldVersions.map((version) => ctx.db.delete(version._id)));
|
||||
|
||||
return oldVersions.length;
|
||||
},
|
||||
});
|
||||
|
||||
// Get version control stats (for dashboard display)
|
||||
export const getStats = query({
|
||||
args: {},
|
||||
returns: v.object({
|
||||
enabled: v.boolean(),
|
||||
totalVersions: v.number(),
|
||||
oldestVersion: v.union(v.number(), v.null()),
|
||||
newestVersion: v.union(v.number(), v.null()),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const setting = await ctx.db
|
||||
.query("versionControlSettings")
|
||||
.withIndex("by_key", (q) => q.eq("key", "enabled"))
|
||||
.first();
|
||||
|
||||
const versions = await ctx.db.query("contentVersions").collect();
|
||||
|
||||
const timestamps = versions.map((v) => v.createdAt);
|
||||
const oldest = timestamps.length > 0 ? Math.min(...timestamps) : null;
|
||||
const newest = timestamps.length > 0 ? Math.max(...timestamps) : null;
|
||||
|
||||
return {
|
||||
enabled: setting?.value === true,
|
||||
totalVersions: versions.length,
|
||||
oldestVersion: oldest,
|
||||
newestVersion: newest,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user