ReviewOS

also looking at this

stacks/ts-cloud

fix(fleet): carry the on-box database when a site moves

#178
Merged chrisbbreuer wants to merge feat/site-move-database into main
4 files +295 -0
packages/ts-cloud/src/operations/site-move.tsmodified+97-0
Changes to packages/ts-cloud/src/operations/site-move.ts
@@ -243,6 +243,41 @@ export interface SiteMoveEffects {
243243 refreshTargetGateway: () => Promise<void>
244244 /** Does the target's gateway already route this site? */
245245 targetRoutesSite: () => Promise<boolean>
246 /**
247 * How to carry an ON-BOX database, when the project has one.
248 *
249 * A SQLite database rides along in the tree already — it lives under
250 * `shared/`, which is the whole point of `sharedPaths`. An engine database
251 * does not: Postgres and MySQL keep their data in the engine's own directory,
252 * which belongs to the box rather than to the site. Moving the tree and not
253 * the database would cut DNS over to a target serving an empty app, so the
254 * plan REFUSES to be built when a project has one of these and no way to
255 * carry it — see {@link SiteMoveOptions.database}.
256 *
257 * Absent when the database is external (RDS, a managed host): the target
258 * connects to the same endpoint the source did, and there is nothing to move.
259 */
260 database?: SiteMoveDatabaseEffects
261}
262
263/** Carrying an on-box engine database between boxes, in resumable pieces. */
264export interface SiteMoveDatabaseEffects {
265 /** Dump it on the source. */
266 dump: () => Promise<void>
267 /** Carry the dump to the target. */
268 transferDump: () => Promise<void>
269 /** Is the dump already staged on the target? Lets the transfer resume. */
270 dumpStaged: () => Promise<boolean>
271 /** Create the role + database on the target, then load the dump into it. */
272 restore: () => Promise<void>
273 /**
274 * Does the target ALREADY hold a database of this name with tables in it?
275 *
276 * Checked as a precondition, not as a step: loading a dump over someone
277 * else's data is the one genuinely destructive thing a move could do, and the
278 * answer decides whether the operation may run at all.
279 */
280 targetHasData: () => Promise<boolean>
246281}
247282
248283export interface SiteMoveOptions {
@@ -259,6 +294,13 @@ export interface SiteMoveOptions {
259294 port?: number
260295 /** Health-check path, defaulting to `/`. */
261296 healthCheckPath?: string
297 /**
298 * The on-box engine database this site's project owns, when it has one —
299 * `resolveAppDatabase(config)` narrowed by `isLocalDatabase`. Naming it here
300 * is what makes the move refuse to run without a way to carry it, rather than
301 * moving the app and quietly leaving its data behind.
302 */
303 database?: { name: string }
262304}
263305
264306/**
@@ -273,6 +315,27 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf
273315
274316 if (from === to) throw new Error(`'${siteName}' is already on ${to}.`)
275317
318 // A project with an on-box engine database and no way to carry it must not
319 // move at all. Moving the tree alone would pass every check in this plan —
320 // the app starts, answers its health gate, takes the DNS cutover — and serve
321 // an empty database to production. Refusing here is the only honest answer.
322 if (options.database && !effects.database)
323 throw new Error(
324 `'${siteName}' uses the on-box database '${options.database.name}', which lives in the engine's own `
325 + 'data directory rather than in the site tree, and this move has no way to carry it. '
326 + 'Moving the site without it would cut traffic over to an empty database.',
327 )
328
329 // Loading a dump over data already on the target is the one genuinely
330 // destructive thing this operation could do, so it is a precondition something
331 // the operator resolves, not a step with a confirmation flag on it.
332 if (options.database && effects.database && (await effects.database.targetHasData()))
333 throw new Error(
334 `${to} already has a database named '${options.database.name}' with tables in it. `
335 + 'Restoring this site\'s dump over it would destroy that data. '
336 + 'Rename or drop it on the target first, or point this project at a different database name.',
337 )
338
276339 const steps: OperationStep[] = [
277340 {
278341 id: 'pause-workers',
@@ -282,6 +345,20 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf
282345 await effects.runOnSource(buildPauseWorkersScript(slug, siteName))
283346 },
284347 },
348 ...(effects.database
349 ? [
350 {
351 id: 'database-dump',
352 title: `Dump the '${options.database?.name}' database on ${from}`,
353 // Never skipped, for the same reason the tree snapshot is not: a
354 // dump from an earlier attempt predates whatever the source has
355 // committed since, and shipping stale rows is worse than dumping
356 // twice.
357 satisfied: async () => false,
358 apply: () => effects.database!.dump(),
359 } satisfies OperationStep,
360 ]
361 : []),
285362 {
286363 id: 'snapshot',
287364 title: `Archive the site's tree and units on ${from}`,
@@ -301,6 +378,26 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf
301378 satisfied: () => effects.archiveStaged(),
302379 apply: () => effects.transferArchive(),
303380 },
381 ...(effects.database
382 ? [
383 {
384 id: 'database-transfer',
385 title: `Carry the database dump to ${to}`,
386 change: { from, to },
387 satisfied: () => effects.database!.dumpStaged(),
388 apply: () => effects.database!.transferDump(),
389 } satisfies OperationStep,
390 {
391 id: 'database-restore',
392 title: `Create and load '${options.database?.name}' on ${to}`,
393 // Before the app starts, so its first request finds its data —
394 // and never skipped, because the precondition above already
395 // established the target holds nothing this could overwrite.
396 satisfied: async () => false,
397 apply: () => effects.database!.restore(),
398 } satisfies OperationStep,
399 ]
400 : []),
304401 {
305402 id: 'restore',
306403 title: `Unpack the site on ${to} and start it`,