feat: Added a Sync version control system for tracking changes to posts, pages, home content, and footer.

This commit is contained in:
Wayne Sutton
2026-01-09 23:02:28 -08:00
parent 1323928341
commit 03bf3e49e5
38 changed files with 1530 additions and 87 deletions

View File

@@ -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;
}>;
/**

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
View 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,
};
},
});