Skip to content

Commit e6a206e

Browse files
tataihonoclaude
andauthored
fix(cms): use CORE_SYNC_ENABLED for env detection and persist sync stats (#632)
* fix(cms): use CORE_SYNC_ENABLED for production detection and fix alert UI NODE_ENV is not reliably set to "production" on Railway. Switch to CORE_SYNC_ENABLED which is the explicit production flag already used by the cron system. Remove closeLabel from dev-mode alerts to hide the non-functional X button and add padding underneath. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(cms): use CORE_SYNC_ENABLED for env detection, persist sync stats, fix alert UI - Switch production detection from NODE_ENV to CORE_SYNC_ENABLED which is the explicit production flag already used by the cron system - Add created/updated/deleted/errors columns to core_sync_states table so row counts persist across server restarts - Auto-migrate existing tables to add the new stats columns - Remove closeLabel from dev-mode alerts to hide non-functional X button and add padding underneath Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 8026fc2 commit e6a206e

5 files changed

Lines changed: 129 additions & 27 deletions

File tree

apps/cms/src/admin/pages/SystemStatus.tsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ type PhaseResult = {
2929
type PhaseWatermark = {
3030
phase: string
3131
lastSyncedAt: string
32+
created: number
33+
updated: number
34+
softDeleted: number
35+
errors: number
3236
}
3337

3438
type SyncStatus = {
@@ -175,15 +179,23 @@ function PhaseWatermarksTable({
175179
}) {
176180
if (watermarks.length === 0) return null
177181

182+
const hasStats = watermarks.some(
183+
(w) => w.created > 0 || w.updated > 0 || w.softDeleted > 0 || w.errors > 0,
184+
)
185+
186+
const headers = hasStats
187+
? ["Phase", "Last Synced", "Created", "Updated", "Deleted", "Errors"]
188+
: ["Phase", "Last Synced"]
189+
178190
return (
179191
<Box paddingTop={2}>
180192
<Typography variant="sigma" textColor="neutral600">
181-
Phase watermarks (from database)
193+
Last sync results (from database)
182194
</Typography>
183195
<table style={{ width: "100%", borderCollapse: "collapse" }}>
184196
<thead>
185197
<tr>
186-
{["Phase", "Last Synced"].map((h) => (
198+
{headers.map((h) => (
187199
<th
188200
key={h}
189201
style={{
@@ -210,6 +222,27 @@ function PhaseWatermarksTable({
210222
{formatTime(w.lastSyncedAt)}
211223
</Typography>
212224
</td>
225+
{hasStats && (
226+
<>
227+
<td style={{ padding: "4px 8px" }}>
228+
<Typography variant="omega">{w.created}</Typography>
229+
</td>
230+
<td style={{ padding: "4px 8px" }}>
231+
<Typography variant="omega">{w.updated}</Typography>
232+
</td>
233+
<td style={{ padding: "4px 8px" }}>
234+
<Typography variant="omega">{w.softDeleted}</Typography>
235+
</td>
236+
<td style={{ padding: "4px 8px" }}>
237+
<Typography
238+
variant="omega"
239+
textColor={w.errors > 0 ? "danger600" : undefined}
240+
>
241+
{w.errors}
242+
</Typography>
243+
</td>
244+
</>
245+
)}
213246
</tr>
214247
))}
215248
</tbody>
@@ -244,8 +277,8 @@ function SyncCard({
244277
</Flex>
245278

246279
{!isProduction && (
247-
<Box paddingTop={2}>
248-
<Alert variant="default" title="Development mode" closeLabel="Close">
280+
<Box paddingTop={3} paddingBottom={2}>
281+
<Alert variant="default" title="Development mode">
249282
Core sync is disabled outside production. Use{" "}
250283
<code>pnpm data-import</code> to restore a snapshot locally.
251284
</Alert>
@@ -354,8 +387,8 @@ function SnapshotCard({
354387
</Flex>
355388

356389
{!isProduction && (
357-
<Box paddingTop={2}>
358-
<Alert variant="default" title="Development mode" closeLabel="Close">
390+
<Box paddingTop={3} paddingBottom={2}>
391+
<Alert variant="default" title="Development mode">
359392
Snapshot creation is disabled outside production. Use{" "}
360393
<code>pnpm data-import</code> to download a snapshot locally.
361394
</Alert>

apps/cms/src/api/core-sync/controllers/core-sync.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type StrapiContext = {
1515

1616
export default ({ strapi }: { strapi: Core.Strapi }) => ({
1717
async trigger(ctx: StrapiContext) {
18-
if (process.env.NODE_ENV !== "production") {
18+
if (process.env.CORE_SYNC_ENABLED !== "true") {
1919
ctx.status = 403
2020
ctx.body = {
2121
error:
@@ -49,7 +49,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
4949

5050
async status(ctx: StrapiContext) {
5151
const status = getSyncStatus()
52-
const isProduction = process.env.NODE_ENV === "production"
52+
const isProduction = process.env.CORE_SYNC_ENABLED === "true"
5353

5454
// When idle with no in-memory lastRun (e.g. after restart), enrich with
5555
// persistent watermarks from the core_sync_states table so the UI always

apps/cms/src/api/core-sync/services/core-sync.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getLastSyncTime,
99
setLastSyncTime,
1010
getAllSyncTimes,
11+
updateSyncStats,
1112
} from "./strapi-helpers"
1213
import { syncLanguages } from "./sync-languages"
1314
import { syncCountries } from "./sync-countries"
@@ -192,13 +193,16 @@ export async function runSync(
192193
phases.push({ phase, ...stats })
193194

194195
// Only advance the watermark if the phase had no errors — failed records
195-
// need to be retried on the next incremental sync
196+
// need to be retried on the next incremental sync.
197+
// Always persist stats so the admin UI can show row counts after restart.
196198
if (stats.errors === 0) {
197-
await setLastSyncTime(strapi, phase, syncStartTime)
199+
await setLastSyncTime(strapi, phase, syncStartTime, stats)
198200
} else {
199201
strapi.log.warn(
200202
`[core-sync] ${phase}: ${stats.errors} errors — watermark NOT advanced`,
201203
)
204+
// Persist stats without advancing the watermark
205+
await updateSyncStats(strapi, phase, stats).catch(() => {})
202206
}
203207
}
204208

apps/cms/src/api/core-sync/services/strapi-helpers.ts

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export async function buildCoreIdMap(
158158
const SYNC_STATE_TABLE = "core_sync_states"
159159
let tableEnsured = false
160160

161-
/** Ensure the sync-state table exists (idempotent, cached after first check). */
161+
/** Ensure the sync-state table exists with stats columns (idempotent, cached). */
162162
export async function ensureSyncStateTable(strapi: Core.Strapi): Promise<void> {
163163
if (tableEnsured) return
164164
const knex = strapi.db.connection
@@ -167,8 +167,24 @@ export async function ensureSyncStateTable(strapi: Core.Strapi): Promise<void> {
167167
await knex.schema.createTable(SYNC_STATE_TABLE, (t) => {
168168
t.string("phase").primary()
169169
t.timestamp("last_synced_at").notNullable()
170+
t.integer("created").defaultTo(0)
171+
t.integer("updated").defaultTo(0)
172+
t.integer("soft_deleted").defaultTo(0)
173+
t.integer("errors").defaultTo(0)
170174
})
171175
strapi.log.info(`[core-sync] Created ${SYNC_STATE_TABLE} table`)
176+
} else {
177+
// Migrate existing tables: add stats columns if missing
178+
const hasCreated = await knex.schema.hasColumn(SYNC_STATE_TABLE, "created")
179+
if (!hasCreated) {
180+
await knex.schema.alterTable(SYNC_STATE_TABLE, (t) => {
181+
t.integer("created").defaultTo(0)
182+
t.integer("updated").defaultTo(0)
183+
t.integer("soft_deleted").defaultTo(0)
184+
t.integer("errors").defaultTo(0)
185+
})
186+
strapi.log.info(`[core-sync] Added stats columns to ${SYNC_STATE_TABLE}`)
187+
}
172188
}
173189
tableEnsured = true
174190
}
@@ -186,36 +202,85 @@ export async function getLastSyncTime(
186202
return val instanceof Date ? val.toISOString() : String(val)
187203
}
188204

189-
/** Read the most recent sync timestamp across all phases (null = never synced). */
205+
type PersistedPhaseState = {
206+
phase: string
207+
lastSyncedAt: string
208+
created: number
209+
updated: number
210+
softDeleted: number
211+
errors: number
212+
}
213+
214+
/** Read all phase sync states from the database, ordered by most recent first. */
190215
export async function getAllSyncTimes(
191216
strapi: Core.Strapi,
192-
): Promise<{ phase: string; lastSyncedAt: string }[]> {
217+
): Promise<PersistedPhaseState[]> {
193218
const knex = strapi.db.connection
194219
const exists = await knex.schema.hasTable(SYNC_STATE_TABLE)
195220
if (!exists) return []
196221
const rows = await knex(SYNC_STATE_TABLE)
197-
.select("phase", "last_synced_at")
222+
.select(
223+
"phase",
224+
"last_synced_at",
225+
"created",
226+
"updated",
227+
"soft_deleted",
228+
"errors",
229+
)
198230
.orderBy("last_synced_at", "desc")
199-
return rows.map((row: { phase: string; last_synced_at: Date | string }) => ({
200-
phase: row.phase,
201-
lastSyncedAt:
202-
row.last_synced_at instanceof Date
203-
? row.last_synced_at.toISOString()
204-
: String(row.last_synced_at),
205-
}))
231+
return rows.map(
232+
(row: {
233+
phase: string
234+
last_synced_at: Date | string
235+
created?: number
236+
updated?: number
237+
soft_deleted?: number
238+
errors?: number
239+
}) => ({
240+
phase: row.phase,
241+
lastSyncedAt:
242+
row.last_synced_at instanceof Date
243+
? row.last_synced_at.toISOString()
244+
: String(row.last_synced_at),
245+
created: row.created ?? 0,
246+
updated: row.updated ?? 0,
247+
softDeleted: row.soft_deleted ?? 0,
248+
errors: row.errors ?? 0,
249+
}),
250+
)
206251
}
207252

208-
/** Persist the sync timestamp for a phase after a successful run. */
253+
/** Persist the sync timestamp and stats for a phase after a successful run. */
209254
export async function setLastSyncTime(
210255
strapi: Core.Strapi,
211256
phase: string,
212257
timestamp: string,
258+
stats?: SyncStats,
213259
): Promise<void> {
214260
const knex = strapi.db.connection
215-
await knex(SYNC_STATE_TABLE)
216-
.insert({ phase, last_synced_at: timestamp })
217-
.onConflict("phase")
218-
.merge()
261+
const row: Record<string, unknown> = { phase, last_synced_at: timestamp }
262+
if (stats) {
263+
row.created = stats.created
264+
row.updated = stats.updated
265+
row.soft_deleted = stats.softDeleted
266+
row.errors = stats.errors
267+
}
268+
await knex(SYNC_STATE_TABLE).insert(row).onConflict("phase").merge()
269+
}
270+
271+
/** Update only the stats columns for a phase (without advancing the watermark). */
272+
export async function updateSyncStats(
273+
strapi: Core.Strapi,
274+
phase: string,
275+
stats: SyncStats,
276+
): Promise<void> {
277+
const knex = strapi.db.connection
278+
await knex(SYNC_STATE_TABLE).where({ phase }).update({
279+
created: stats.created,
280+
updated: stats.updated,
281+
soft_deleted: stats.softDeleted,
282+
errors: stats.errors,
283+
})
219284
}
220285

221286
export async function softDeleteUnseen(

apps/cms/src/api/data-snapshot/controllers/data-snapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
5454

5555
async status(ctx: StrapiContext) {
5656
const status = getSnapshotStatus()
57-
const isProduction = process.env.NODE_ENV === "production"
57+
const isProduction = process.env.CORE_SYNC_ENABLED === "true"
5858

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

0 commit comments

Comments
 (0)