Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions apps/cms/src/admin/pages/SystemStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ type PhaseResult = {
type PhaseWatermark = {
phase: string
lastSyncedAt: string
created: number
updated: number
softDeleted: number
errors: number
}

type SyncStatus = {
Expand Down Expand Up @@ -175,15 +179,23 @@ function PhaseWatermarksTable({
}) {
if (watermarks.length === 0) return null

const hasStats = watermarks.some(
(w) => w.created > 0 || w.updated > 0 || w.softDeleted > 0 || w.errors > 0,
)

const headers = hasStats
? ["Phase", "Last Synced", "Created", "Updated", "Deleted", "Errors"]
: ["Phase", "Last Synced"]

return (
<Box paddingTop={2}>
<Typography variant="sigma" textColor="neutral600">
Phase watermarks (from database)
Last sync results (from database)
</Typography>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
{["Phase", "Last Synced"].map((h) => (
{headers.map((h) => (
<th
key={h}
style={{
Expand All @@ -210,6 +222,27 @@ function PhaseWatermarksTable({
{formatTime(w.lastSyncedAt)}
</Typography>
</td>
{hasStats && (
<>
<td style={{ padding: "4px 8px" }}>
<Typography variant="omega">{w.created}</Typography>
</td>
<td style={{ padding: "4px 8px" }}>
<Typography variant="omega">{w.updated}</Typography>
</td>
<td style={{ padding: "4px 8px" }}>
<Typography variant="omega">{w.softDeleted}</Typography>
</td>
<td style={{ padding: "4px 8px" }}>
<Typography
variant="omega"
textColor={w.errors > 0 ? "danger600" : undefined}
>
{w.errors}
</Typography>
</td>
</>
)}
</tr>
))}
</tbody>
Expand Down Expand Up @@ -244,8 +277,8 @@ function SyncCard({
</Flex>

{!isProduction && (
<Box paddingTop={2}>
<Alert variant="default" title="Development mode" closeLabel="Close">
<Box paddingTop={3} paddingBottom={2}>
<Alert variant="default" title="Development mode">
Core sync is disabled outside production. Use{" "}
<code>pnpm data-import</code> to restore a snapshot locally.
</Alert>
Expand Down Expand Up @@ -354,8 +387,8 @@ function SnapshotCard({
</Flex>

{!isProduction && (
<Box paddingTop={2}>
<Alert variant="default" title="Development mode" closeLabel="Close">
<Box paddingTop={3} paddingBottom={2}>
<Alert variant="default" title="Development mode">
Snapshot creation is disabled outside production. Use{" "}
<code>pnpm data-import</code> to download a snapshot locally.
</Alert>
Expand Down
4 changes: 2 additions & 2 deletions apps/cms/src/api/core-sync/controllers/core-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type StrapiContext = {

export default ({ strapi }: { strapi: Core.Strapi }) => ({
async trigger(ctx: StrapiContext) {
if (process.env.NODE_ENV !== "production") {
if (process.env.CORE_SYNC_ENABLED !== "true") {
ctx.status = 403
ctx.body = {
error:
Expand Down Expand Up @@ -49,7 +49,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({

async status(ctx: StrapiContext) {
const status = getSyncStatus()
const isProduction = process.env.NODE_ENV === "production"
const isProduction = process.env.CORE_SYNC_ENABLED === "true"

// When idle with no in-memory lastRun (e.g. after restart), enrich with
// persistent watermarks from the core_sync_states table so the UI always
Expand Down
8 changes: 6 additions & 2 deletions apps/cms/src/api/core-sync/services/core-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getLastSyncTime,
setLastSyncTime,
getAllSyncTimes,
updateSyncStats,
} from "./strapi-helpers"
import { syncLanguages } from "./sync-languages"
import { syncCountries } from "./sync-countries"
Expand Down Expand Up @@ -192,13 +193,16 @@ export async function runSync(
phases.push({ phase, ...stats })

// Only advance the watermark if the phase had no errors — failed records
// need to be retried on the next incremental sync
// need to be retried on the next incremental sync.
// Always persist stats so the admin UI can show row counts after restart.
if (stats.errors === 0) {
await setLastSyncTime(strapi, phase, syncStartTime)
await setLastSyncTime(strapi, phase, syncStartTime, stats)
} else {
strapi.log.warn(
`[core-sync] ${phase}: ${stats.errors} errors — watermark NOT advanced`,
)
// Persist stats without advancing the watermark
await updateSyncStats(strapi, phase, stats).catch(() => {})
}
}

Expand Down
97 changes: 81 additions & 16 deletions apps/cms/src/api/core-sync/services/strapi-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export async function buildCoreIdMap(
const SYNC_STATE_TABLE = "core_sync_states"
let tableEnsured = false

/** Ensure the sync-state table exists (idempotent, cached after first check). */
/** Ensure the sync-state table exists with stats columns (idempotent, cached). */
export async function ensureSyncStateTable(strapi: Core.Strapi): Promise<void> {
if (tableEnsured) return
const knex = strapi.db.connection
Expand All @@ -167,8 +167,24 @@ export async function ensureSyncStateTable(strapi: Core.Strapi): Promise<void> {
await knex.schema.createTable(SYNC_STATE_TABLE, (t) => {
t.string("phase").primary()
t.timestamp("last_synced_at").notNullable()
t.integer("created").defaultTo(0)
t.integer("updated").defaultTo(0)
t.integer("soft_deleted").defaultTo(0)
t.integer("errors").defaultTo(0)
})
strapi.log.info(`[core-sync] Created ${SYNC_STATE_TABLE} table`)
} else {
// Migrate existing tables: add stats columns if missing
const hasCreated = await knex.schema.hasColumn(SYNC_STATE_TABLE, "created")
if (!hasCreated) {
await knex.schema.alterTable(SYNC_STATE_TABLE, (t) => {
t.integer("created").defaultTo(0)
t.integer("updated").defaultTo(0)
t.integer("soft_deleted").defaultTo(0)
t.integer("errors").defaultTo(0)
})
strapi.log.info(`[core-sync] Added stats columns to ${SYNC_STATE_TABLE}`)
}
}
tableEnsured = true
}
Expand All @@ -186,36 +202,85 @@ export async function getLastSyncTime(
return val instanceof Date ? val.toISOString() : String(val)
}

/** Read the most recent sync timestamp across all phases (null = never synced). */
type PersistedPhaseState = {
phase: string
lastSyncedAt: string
created: number
updated: number
softDeleted: number
errors: number
}

/** Read all phase sync states from the database, ordered by most recent first. */
export async function getAllSyncTimes(
strapi: Core.Strapi,
): Promise<{ phase: string; lastSyncedAt: string }[]> {
): Promise<PersistedPhaseState[]> {
const knex = strapi.db.connection
const exists = await knex.schema.hasTable(SYNC_STATE_TABLE)
if (!exists) return []
const rows = await knex(SYNC_STATE_TABLE)
.select("phase", "last_synced_at")
.select(
"phase",
"last_synced_at",
"created",
"updated",
"soft_deleted",
"errors",
)
.orderBy("last_synced_at", "desc")
return rows.map((row: { phase: string; last_synced_at: Date | string }) => ({
phase: row.phase,
lastSyncedAt:
row.last_synced_at instanceof Date
? row.last_synced_at.toISOString()
: String(row.last_synced_at),
}))
return rows.map(
(row: {
phase: string
last_synced_at: Date | string
created?: number
updated?: number
soft_deleted?: number
errors?: number
}) => ({
phase: row.phase,
lastSyncedAt:
row.last_synced_at instanceof Date
? row.last_synced_at.toISOString()
: String(row.last_synced_at),
created: row.created ?? 0,
updated: row.updated ?? 0,
softDeleted: row.soft_deleted ?? 0,
errors: row.errors ?? 0,
}),
)
}

/** Persist the sync timestamp for a phase after a successful run. */
/** Persist the sync timestamp and stats for a phase after a successful run. */
export async function setLastSyncTime(
strapi: Core.Strapi,
phase: string,
timestamp: string,
stats?: SyncStats,
): Promise<void> {
const knex = strapi.db.connection
await knex(SYNC_STATE_TABLE)
.insert({ phase, last_synced_at: timestamp })
.onConflict("phase")
.merge()
const row: Record<string, unknown> = { phase, last_synced_at: timestamp }
if (stats) {
row.created = stats.created
row.updated = stats.updated
row.soft_deleted = stats.softDeleted
row.errors = stats.errors
}
await knex(SYNC_STATE_TABLE).insert(row).onConflict("phase").merge()
}

/** Update only the stats columns for a phase (without advancing the watermark). */
export async function updateSyncStats(
strapi: Core.Strapi,
phase: string,
stats: SyncStats,
): Promise<void> {
const knex = strapi.db.connection
await knex(SYNC_STATE_TABLE).where({ phase }).update({
created: stats.created,
updated: stats.updated,
soft_deleted: stats.softDeleted,
errors: stats.errors,
})
}

export async function softDeleteUnseen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({

async status(ctx: StrapiContext) {
const status = getSnapshotStatus()
const isProduction = process.env.NODE_ENV === "production"
const isProduction = process.env.CORE_SYNC_ENABLED === "true"

// When idle with no in-memory lastRun (e.g. after restart), enrich with
// persistent metadata from S3 so the UI always shows the latest snapshot info.
Expand Down
Loading