Skip to content

Programmatic API ​

You can run schema sync from your own code using the syncSchema convenience function or the SchemaSync class directly. This is useful for application startup, custom deployment scripts, and CI/CD integration.

Quick Start ​

The simplest way to sync your schema:

typescript
import { DbSpace } from "@atscript/db";
import { syncSchema } from "@atscript/db/sync";
import { User, Post, Comment } from "./schema/index.as.js";

const db = new DbSpace(adapterFactory);

const result = await syncSchema(db, [User, Post, Comment]);
console.log(result.status); // 'up-to-date' | 'synced' | 'synced-by-peer'

syncSchema is a one-liner that creates a SchemaSync instance and calls run(). It handles locking, hashing, diffing, and DDL execution in a single call.

SchemaSync Class ​

For more control, use the SchemaSync class directly. It provides separate plan() and run() methods:

typescript
import { SchemaSync } from "@atscript/db/sync";

const sync = new SchemaSync(db);

// Plan first — see what would change without applying
const plan = await sync.plan(types);
console.log(plan.status); // 'up-to-date' | 'changes-needed'

for (const entry of plan.entries) {
  console.log(entry.name, entry.status, entry.destructive);
}

// Then run — apply changes with distributed locking
const result = await sync.run(types, { force: true, safe: false });
console.log(result.status); // 'up-to-date' | 'synced' | 'synced-by-peer'

The constructor accepts an optional logger:

typescript
const sync = new SchemaSync(db, console);

TSyncOptions ​

Options for both syncSchema() and sync.run():

OptionTypeDefaultDescription
forcebooleanfalseIgnore hash check, always introspect the database
safebooleanfalseSkip destructive operations (column drops, table drops)
podIdstringRandom UUIDIdentifier for distributed locking
lockTtlMsnumber30000Lock time-to-live in milliseconds (auto-extended while sync runs)
waitTimeoutMsnumber60000Max wait time for another pod's lock
pollIntervalMsnumber500Poll interval when waiting for lock release

The plan() method accepts only force and safe.

TSyncPlan ​

Returned by sync.plan():

typescript
interface TSyncPlan {
  status: "up-to-date" | "changes-needed";
  schemaHash: string;
  entries: SyncEntry[];
}

When status is 'up-to-date', the entries still list all tables/views with their 'in-sync' status — useful for displaying a full schema overview.

TSyncResult ​

Returned by sync.run() and syncSchema():

typescript
interface TSyncResult {
  status: "up-to-date" | "synced" | "synced-by-peer";
  schemaHash: string;
  entries: SyncEntry[];
}
StatusMeaning
'up-to-date'Schema hash matched, no sync needed
'synced'This process applied changes
'synced-by-peer'Another process completed sync while this one was waiting for the lock

SyncEntry ​

Each entry in the plan/result represents a table or view:

typescript
interface SyncEntry {
  name: string;
  status: "create" | "alter" | "drop" | "in-sync" | "error";
  viewType?: "V" | "M" | "E"; // V=virtual, M=materialized, E=external

  // Plan fields — what will change
  columnsToAdd: TDbFieldMeta[];
  columnsToRename: Array<{ from: string; to: string }>;
  typeChanges: Array<{ column: string; fromType: string; toType: string }>;
  nullableChanges: Array<{ column: string; toNullable: boolean }>;
  defaultChanges: Array<{ column: string; oldDefault?: string; newDefault?: string }>;
  columnsToDrop: string[];
  optionChanges: Array<{ key: string; oldValue: string; newValue: string; destructive: boolean }>;
  fkAdded: Array<{ fields: string[]; targetTable: string }>;
  fkRemoved: Array<{ fields: string[]; targetTable: string }>;
  fkChanged: Array<{ fields: string[]; targetTable: string; details: string }>;

  // Result fields — what was applied
  columnsAdded: string[];
  columnsRenamed: string[];
  columnsDropped: string[];
  recreated: boolean;
  errors: string[];
  renamedFrom?: string;

  // Computed properties
  destructive: boolean; // involves drops, type changes, or recreation
  hasChanges: boolean; // status is not 'in-sync' or 'error'
  hasErrors: boolean; // status is 'error' or errors array is non-empty
}

Plan-Then-Decide Pattern ​

Use plan() to inspect changes before deciding whether to apply:

typescript
const sync = new SchemaSync(db);
const plan = await sync.plan(types);

if (plan.status === "up-to-date") {
  console.log("Nothing to do");
  return;
}

// Check for destructive changes
const hasDestructive = plan.entries.some((e) => e.destructive);
if (hasDestructive) {
  console.log("Destructive changes detected:");
  for (const entry of plan.entries.filter((e) => e.destructive)) {
    console.log(`  ${entry.name}: ${entry.status}`);
    for (const col of entry.columnsToDrop) {
      console.log(`    - drop column: ${col}`);
    }
  }
  // Require explicit approval in production
  if (!approved) return;
}

// Apply with force to skip hash check (we already know changes are needed)
const result = await sync.run(types, { force: true });

Integration with Application Startup ​

A common pattern is to sync on startup in development and skip in production:

typescript
const db = new DbSpace(adapterFactory);

if (process.env.NODE_ENV !== "production") {
  const { syncSchema } = await import("@atscript/db/sync");
  await syncSchema(db, allTypes);
}

The hash check makes this very cheap when nothing has changed — only a lightweight control table read occurs, skipping all schema introspection and DDL.

Error Handling ​

Sync can fail for several reasons:

typescript
try {
  await syncSchema(db, types);
} catch (error) {
  if (error.message.includes("lock wait timed out")) {
    // Another process holds the lock and didn't release in time
    // Increase waitTimeoutMs or investigate the other process
  }
  if (error.message.includes("Failed to acquire")) {
    // Lock acquisition failed after waiting
  }
  if (error.message.includes("lock was stolen")) {
    // Another pod took over the lock mid-sync — the current sync aborted safely.
    // Usually transient (network partition recovery). Retry or rely on `synced-by-peer`.
  }
  // Other errors: DB connection issues, DDL failures
}

Schema-level errors don't throw — they appear as entries with status: 'error' and populated errors arrays. This covers rename conflicts, type changes without a sync method, and index/FK DDL failures (e.g. adding a unique index over duplicate data).

Error entries have retry semantics: the schema hash and the errored table's snapshot are not persisted, so the next run() (or application boot) attempts the same changes again instead of reporting 'up-to-date' over a diverged schema. Once the underlying conflict is resolved (data cleaned up, annotation fixed), the retry converges and the hash settles:

typescript
const result = await syncSchema(db, types);
const failed = result.entries.filter((e) => e.hasErrors);
for (const entry of failed) {
  console.error(`${entry.name}: ${entry.errors.join("; ")}`);
}
// failed entries re-attempt on every subsequent run until they succeed

Printing the Plan ​

SyncEntry instances render themselves to coloured terminal output via entry.print(mode, colors?) — mode is 'plan' (preview) or 'result' (after run()). It returns an array of lines you can console.log directly:

typescript
const plan = await sync.plan(types);
for (const entry of plan.entries) {
  for (const line of entry.print("plan")) {
    console.log(line);
  }
}

Pass a colour adapter (matching the TSyncColors shape — green/red/cyan/yellow/bold/dim/underline) as the second argument to control styling. Omit it for plain text output suitable for logs.

Exported Utilities ​

The @atscript/db/sync sub-entry exports the building blocks used by the CLI:

ExportPurpose
SchemaSyncFull sync class with plan() and run()
SyncEntryPlan/result entry with print() and computed flags
syncSchema()One-liner convenience: new SchemaSync(db).run(types)

Lower-level diff/snapshot helpers are also exported for building custom dashboards or sync UIs — consult the package source if you need them.

Next Steps ​

Released under the MIT License.