feat(migration): add Supabase import and recovery tooling
This commit is contained in:
+77
-2
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
|
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const config = ensureDataLayout();
|
const config = ensureDataLayout();
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const backupDir = path.join(config.backupsDir, `neta-${timestamp}`);
|
const backupDir = path.join(config.backupsDir, `neta-${timestamp}`);
|
||||||
@@ -24,6 +25,8 @@ try {
|
|||||||
copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir);
|
copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir);
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
|
format: "neta-backup",
|
||||||
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
source: {
|
source: {
|
||||||
dataDir: config.dataDir,
|
dataDir: config.dataDir,
|
||||||
@@ -39,7 +42,45 @@ const manifest = {
|
|||||||
|
|
||||||
fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
const removedBackups = applyRetention(config.backupsDir, backupDir, args.retentionCount);
|
||||||
console.log(`Backup created at ${backupDir}`);
|
console.log(`Backup created at ${backupDir}`);
|
||||||
|
if (args.retentionCount !== null) {
|
||||||
|
console.log(
|
||||||
|
`Retention kept the newest ${args.retentionCount} backup(s); removed ${removedBackups.length}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(values) {
|
||||||
|
let retentionCount = parseOptionalPositiveInteger(
|
||||||
|
process.env.BACKUP_RETENTION_COUNT,
|
||||||
|
"BACKUP_RETENTION_COUNT",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const value = values[index];
|
||||||
|
if (value === "--retention-count") {
|
||||||
|
retentionCount = parseOptionalPositiveInteger(values[index + 1], "--retention-count");
|
||||||
|
if (retentionCount === null) {
|
||||||
|
throw new Error("--retention-count requires a positive integer.");
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node scripts/backup.mjs [--retention-count <positive-integer>]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { retentionCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalPositiveInteger(value, name) {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
if (!/^[1-9]\d*$/.test(value)) {
|
||||||
|
throw new Error(`${name} must be a positive integer.`);
|
||||||
|
}
|
||||||
|
return Number.parseInt(value, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function copyDirectoryIfExists(sourceDir, targetDir) {
|
function copyDirectoryIfExists(sourceDir, targetDir) {
|
||||||
if (!fs.existsSync(sourceDir)) {
|
if (!fs.existsSync(sourceDir)) {
|
||||||
@@ -52,10 +93,14 @@ function copyDirectoryIfExists(sourceDir, targetDir) {
|
|||||||
const sourcePath = path.join(sourceDir, entry.name);
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
throw new Error(`Upload tree contains a symbolic link: ${sourcePath}`);
|
||||||
|
} else if (entry.isDirectory()) {
|
||||||
copyDirectoryIfExists(sourcePath, targetPath);
|
copyDirectoryIfExists(sourcePath, targetPath);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Upload tree contains an unsupported filesystem entry: ${sourcePath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +111,9 @@ function collectFiles(rootDir) {
|
|||||||
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||||
const entryPath = path.join(rootDir, entry.name);
|
const entryPath = path.join(rootDir, entry.name);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
throw new Error(`Backup contains a symbolic link: ${entryPath}`);
|
||||||
|
} else if (entry.isDirectory()) {
|
||||||
files.push(...collectFiles(entryPath));
|
files.push(...collectFiles(entryPath));
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
files.push(entryPath);
|
files.push(entryPath);
|
||||||
@@ -81,3 +128,31 @@ function hashFile(filePath) {
|
|||||||
hash.update(fs.readFileSync(filePath));
|
hash.update(fs.readFileSync(filePath));
|
||||||
return hash.digest("hex");
|
return hash.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRetention(backupsDir, currentBackupDir, retentionCount) {
|
||||||
|
if (retentionCount === null) return [];
|
||||||
|
|
||||||
|
const candidates = fs
|
||||||
|
.readdirSync(backupsDir, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.isDirectory() &&
|
||||||
|
!entry.isSymbolicLink() &&
|
||||||
|
/^neta-\d{4}-\d{2}-\d{2}T/.test(entry.name) &&
|
||||||
|
fs.existsSync(path.join(backupsDir, entry.name, "manifest.json")),
|
||||||
|
)
|
||||||
|
.map((entry) => path.join(backupsDir, entry.name))
|
||||||
|
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)));
|
||||||
|
|
||||||
|
const currentIndex = candidates.indexOf(currentBackupDir);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
candidates.splice(currentIndex, 1);
|
||||||
|
candidates.unshift(currentBackupDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = candidates.slice(retentionCount);
|
||||||
|
for (const candidate of removed) {
|
||||||
|
fs.rmSync(candidate, { recursive: true, force: false });
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { applySqlitePragmas, ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs";
|
||||||
|
import {
|
||||||
|
applySupabaseImport,
|
||||||
|
assertTargetImportSafety,
|
||||||
|
loadImportBundle,
|
||||||
|
prepareSupabaseImport,
|
||||||
|
rollbackStagedFiles,
|
||||||
|
stageImportFiles,
|
||||||
|
validateTargetOwner,
|
||||||
|
} from "./lib/supabase-import.mjs";
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (!args.from || !args.ownerUserId) {
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node scripts/import-supabase.mjs --from <export-dir> --owner-user-id <better-auth-user-id> [--dry-run] [--allow-existing] [--report <path>]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = ensureDataLayout(getDataConfig(process.env));
|
||||||
|
const { bundle, bundleDir, bundlePath } = loadImportBundle(args.from);
|
||||||
|
const plan = prepareSupabaseImport({
|
||||||
|
bundle,
|
||||||
|
bundleDir,
|
||||||
|
targetOwnerUserId: args.ownerUserId,
|
||||||
|
});
|
||||||
|
const db = new Database(config.databasePath);
|
||||||
|
let stagedFiles = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
applySqlitePragmas(db);
|
||||||
|
const owner = validateTargetOwner(db, args.ownerUserId);
|
||||||
|
const existingCounts = assertTargetImportSafety(db, plan, args.allowExisting || args.dryRun);
|
||||||
|
const report = {
|
||||||
|
format: "neta-supabase-import-report",
|
||||||
|
version: 1,
|
||||||
|
mode: args.dryRun ? "dry-run" : "apply",
|
||||||
|
exportPath: bundlePath,
|
||||||
|
sourceOwnerUserId: plan.sourceOwnerUserId,
|
||||||
|
targetOwnerUserId: plan.targetOwnerUserId,
|
||||||
|
targetOwnerEmail: owner.email,
|
||||||
|
sourceCounts: plan.sourceCounts,
|
||||||
|
targetCounts: plan.targetCounts,
|
||||||
|
existingCounts,
|
||||||
|
warnings: plan.warnings,
|
||||||
|
verification: null,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!args.dryRun) {
|
||||||
|
stagedFiles = stageImportFiles(plan, config.uploadsDir);
|
||||||
|
try {
|
||||||
|
report.verification = applySupabaseImport(db, plan);
|
||||||
|
report.completedAt = new Date().toISOString();
|
||||||
|
} catch (error) {
|
||||||
|
rollbackStagedFiles(stagedFiles);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportPath = path.resolve(
|
||||||
|
args.report ?? path.join(config.dataDir, args.dryRun ? "import-dry-run-report.json" : "import-report.json"),
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
console.log(`${args.dryRun ? "Import dry-run" : "Import"} completed. Report: ${reportPath}`);
|
||||||
|
for (const warning of report.warnings) console.warn(`Warning: ${warning}`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(values) {
|
||||||
|
const parsed = { dryRun: false, allowExisting: false };
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const value = values[index];
|
||||||
|
if (value === "--dry-run") parsed.dryRun = true;
|
||||||
|
else if (value === "--allow-existing") parsed.allowExisting = true;
|
||||||
|
else if (value === "--from") parsed.from = values[++index];
|
||||||
|
else if (value === "--owner-user-id") parsed.ownerUserId = values[++index];
|
||||||
|
else if (value === "--report") parsed.report = values[++index];
|
||||||
|
else throw new Error(`Unknown argument: ${value}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,20 @@ execFileSync(process.execPath, ["scripts/backup.mjs"], {
|
|||||||
env,
|
env,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
["scripts/backup.mjs", "--retention-count", "2"],
|
||||||
|
{ cwd: process.cwd(), env, stdio: "inherit" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const retainedBackups = fs
|
||||||
|
.readdirSync(path.join(smokeRoot, "backups"))
|
||||||
|
.filter((name) => name.startsWith("neta-"));
|
||||||
|
if (retainedBackups.length !== 2) {
|
||||||
|
throw new Error(`Backup retention smoke check failed: expected 2, received ${retainedBackups.length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
const backupDir = fs
|
const backupDir = fs
|
||||||
.readdirSync(path.join(smokeRoot, "backups"))
|
.readdirSync(path.join(smokeRoot, "backups"))
|
||||||
@@ -69,6 +83,14 @@ if (!backupDir) {
|
|||||||
throw new Error("Backup smoke check failed: no backup directory produced.");
|
throw new Error("Backup smoke check failed: no backup directory produced.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, DATA_DIR: restoreRoot, DATABASE_PATH: "" },
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
const staleUploadPath = path.join(restoreRoot, "uploads", "stale.txt");
|
||||||
|
fs.writeFileSync(staleUploadPath, "must-be-replaced");
|
||||||
|
|
||||||
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", restoreRoot, "--force"], {
|
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", restoreRoot, "--force"], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -96,6 +118,9 @@ const restoredUploadFixture = path.join(
|
|||||||
if (fs.readFileSync(restoredUploadFixture, "utf8") !== "neta-upload-backup-fixture") {
|
if (fs.readFileSync(restoredUploadFixture, "utf8") !== "neta-upload-backup-fixture") {
|
||||||
throw new Error("Restore smoke check failed: upload fixture missing or corrupted.");
|
throw new Error("Restore smoke check failed: upload fixture missing or corrupted.");
|
||||||
}
|
}
|
||||||
|
if (fs.existsSync(staleUploadPath)) {
|
||||||
|
throw new Error("Atomic restore smoke check failed: stale upload tree was not replaced.");
|
||||||
|
}
|
||||||
|
|
||||||
fs.appendFileSync(path.join(backupDir, "uploads", "project-assets", "backup-fixture.txt"), "-tampered");
|
fs.appendFileSync(path.join(backupDir, "uploads", "project-assets", "backup-fixture.txt"), "-tampered");
|
||||||
let corruptedBackupRejected = false;
|
let corruptedBackupRejected = false;
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const root = path.join(process.cwd(), ".data", `phase8-import-smoke-${Date.now()}`);
|
||||||
|
const dataDir = path.join(root, "data");
|
||||||
|
const exportDir = path.join(root, "export");
|
||||||
|
const databasePath = path.join(dataDir, "neta.db");
|
||||||
|
const ownerUserId = "phase8-target-owner";
|
||||||
|
const sourceOwnerUserId = "00000000-0000-4000-8000-000000000008";
|
||||||
|
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
|
||||||
|
const rollbackDir = path.join(root, "rollback");
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(exportDir, "storage", "avatars"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(exportDir, "storage", "project-assets"), { recursive: true });
|
||||||
|
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = new Database(databasePath);
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
db.prepare(`
|
||||||
|
insert into user (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
values (?, ?, ?, 1, ?, ?)
|
||||||
|
`).run(ownerUserId, "Target Owner", "phase8-owner@example.com", Date.now(), Date.now());
|
||||||
|
db.prepare(`
|
||||||
|
insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at)
|
||||||
|
values (?, ?, ?, 'freelancer', 0, ?, ?)
|
||||||
|
`).run(ownerUserId, "phase8-owner@example.com", "Target Owner", Date.now(), Date.now());
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/backup.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
const preCutoverBackupDir = fs
|
||||||
|
.readdirSync(path.join(dataDir, "backups"))
|
||||||
|
.map((name) => path.join(dataDir, "backups", name))
|
||||||
|
.sort()
|
||||||
|
.at(-1);
|
||||||
|
assert.ok(preCutoverBackupDir, "Pre-cutover backup must exist");
|
||||||
|
|
||||||
|
const avatarBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
|
||||||
|
const coverBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5, 6, 7, 8]);
|
||||||
|
const avatarLocalPath = "storage/avatars/avatar.png";
|
||||||
|
const coverLocalPath = "storage/project-assets/cover.png";
|
||||||
|
fs.writeFileSync(path.join(exportDir, avatarLocalPath), avatarBytes);
|
||||||
|
fs.writeFileSync(path.join(exportDir, coverLocalPath), coverBytes);
|
||||||
|
|
||||||
|
const baseBundle = createBundle();
|
||||||
|
writeBundle(exportDir, baseBundle);
|
||||||
|
|
||||||
|
runImport(["--dry-run"]);
|
||||||
|
let check = new Database(databasePath);
|
||||||
|
assert.equal(check.prepare("select count(*) as value from clients").get().value, 0, "Dry-run must not mutate");
|
||||||
|
check.close();
|
||||||
|
|
||||||
|
runImport([]);
|
||||||
|
runImport(["--allow-existing"]);
|
||||||
|
|
||||||
|
check = new Database(databasePath);
|
||||||
|
check.pragma("foreign_keys = ON");
|
||||||
|
try {
|
||||||
|
assert.equal(check.prepare("select count(*) as value from clients").get().value, 1);
|
||||||
|
assert.equal(check.prepare("select count(*) as value from projects").get().value, 1);
|
||||||
|
assert.equal(check.prepare("select count(*) as value from files").get().value, 2);
|
||||||
|
assert.equal(check.prepare("select count(*) as value from journal_entries").get().value, 1);
|
||||||
|
assert.equal(
|
||||||
|
check.prepare("select status from tasks where id = 'source-task'").get().status,
|
||||||
|
"done",
|
||||||
|
"Legacy completed task status must normalize to done",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
check.prepare("select source_journal_entry_id as value from tasks where id = 'source-task'").get().value,
|
||||||
|
"source-daily",
|
||||||
|
"Merged journal references must target the canonical daily log",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
check.prepare("select amount_minor as value from finance_transactions where id = 'source-finance'").get().value,
|
||||||
|
1234,
|
||||||
|
"Money must convert to integer minor units exactly",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
check.prepare("select tax_basis_points as value from invoices where id = 'source-invoice'").get().value,
|
||||||
|
1850,
|
||||||
|
"Tax percentage must convert to basis points exactly",
|
||||||
|
);
|
||||||
|
const journal = check.prepare(`
|
||||||
|
select note, legacy_ai_metadata as legacyAiMetadata
|
||||||
|
from journal_entries where id = 'source-daily'
|
||||||
|
`).get();
|
||||||
|
assert.match(journal.note, /Daily note/);
|
||||||
|
assert.match(journal.note, /Legacy journal content/);
|
||||||
|
assert.match(journal.legacyAiMetadata, /source-journal/);
|
||||||
|
const settings = check.prepare(`
|
||||||
|
select p.timezone, p.default_currency as defaultCurrency,
|
||||||
|
a.provider, a.model, a.encrypted_api_key as encryptedApiKey
|
||||||
|
from user_preferences p
|
||||||
|
inner join user_ai_settings a on a.owner_user_id = p.owner_user_id
|
||||||
|
where p.owner_user_id = ?
|
||||||
|
`).get(ownerUserId);
|
||||||
|
assert.deepEqual(settings, {
|
||||||
|
timezone: "Europe/Istanbul",
|
||||||
|
defaultCurrency: "TRY",
|
||||||
|
provider: "gemini",
|
||||||
|
model: "gemini-test-model",
|
||||||
|
encryptedApiKey: null,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
check.prepare("select auth_user_id as value from clients where id = 'source-client'").get().value,
|
||||||
|
null,
|
||||||
|
"Legacy client auth links must not be imported",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
check.prepare("select requested_by_user_id as value from project_revisions where id = 'source-revision'").get().value,
|
||||||
|
ownerUserId,
|
||||||
|
"Historical revision requester must use a valid target principal",
|
||||||
|
);
|
||||||
|
const project = check.prepare(`
|
||||||
|
select legacy_cover_image_path as cover from projects where id = 'source-project'
|
||||||
|
`).get();
|
||||||
|
assert.match(project.cover, /^\/api\/files\/import-/);
|
||||||
|
const section = check.prepare(`
|
||||||
|
select metadata from project_planning_sections where id = 'source-section'
|
||||||
|
`).get();
|
||||||
|
assert.match(section.metadata, /\/api\/files\/import-/);
|
||||||
|
assert.equal(check.prepare("select name from user where id = ?").get(ownerUserId).name, "Source Owner");
|
||||||
|
assert.deepEqual(check.pragma("foreign_key_check"), [], "Imported database must satisfy all foreign keys");
|
||||||
|
} finally {
|
||||||
|
check.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportText = fs.readFileSync(path.join(dataDir, "import-report.json"), "utf8");
|
||||||
|
assert.doesNotMatch(reportText, /legacy-plain-text-secret|source-password-secret/);
|
||||||
|
assert.match(reportText, /intentionally not imported/);
|
||||||
|
|
||||||
|
const invalidEnumDir = cloneExport("invalid-enum");
|
||||||
|
const invalidEnum = structuredClone(baseBundle);
|
||||||
|
invalidEnum.tables.tasks[0].status = "mystery";
|
||||||
|
writeBundle(invalidEnumDir, invalidEnum);
|
||||||
|
assertImportFails(invalidEnumDir, /unknown value/);
|
||||||
|
|
||||||
|
const invalidForeignKeyDir = cloneExport("invalid-foreign-key");
|
||||||
|
const invalidForeignKey = structuredClone(baseBundle);
|
||||||
|
invalidForeignKey.tables.projects[0].client_id = "missing-client";
|
||||||
|
writeBundle(invalidForeignKeyDir, invalidForeignKey);
|
||||||
|
assertImportFails(invalidForeignKeyDir, /references missing id/);
|
||||||
|
|
||||||
|
const unsafeStorageDir = cloneExport("unsafe-storage");
|
||||||
|
const unsafeStorage = structuredClone(baseBundle);
|
||||||
|
unsafeStorage.storage.objects[0].local_path = "../escape.png";
|
||||||
|
writeBundle(unsafeStorageDir, unsafeStorage);
|
||||||
|
assertImportFails(unsafeStorageDir, /unsafe|escapes root/);
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/restore.mjs",
|
||||||
|
"--from",
|
||||||
|
preCutoverBackupDir,
|
||||||
|
"--target",
|
||||||
|
rollbackDir,
|
||||||
|
"--force",
|
||||||
|
],
|
||||||
|
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
|
||||||
|
);
|
||||||
|
const rollbackDb = new Database(path.join(rollbackDir, "neta.db"), { readonly: true });
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
rollbackDb.prepare("select count(*) as value from clients").get().value,
|
||||||
|
0,
|
||||||
|
"Rollback backup must restore the pre-import client count",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
rollbackDb.prepare("select count(*) as value from user where id = ?").get(ownerUserId).value,
|
||||||
|
1,
|
||||||
|
"Rollback backup must preserve the Better Auth owner",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rollbackDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Phase 8 import smoke passed: dry-run, normalization, files, idempotency, negative validation and rollback rehearsal verified.");
|
||||||
|
|
||||||
|
function runImport(extraArgs, from = exportDir) {
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/import-supabase.mjs",
|
||||||
|
"--from",
|
||||||
|
from,
|
||||||
|
"--owner-user-id",
|
||||||
|
ownerUserId,
|
||||||
|
...extraArgs,
|
||||||
|
],
|
||||||
|
{ cwd: process.cwd(), env, stdio: "inherit" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertImportFails(from, pattern) {
|
||||||
|
assert.throws(
|
||||||
|
() => execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/import-supabase.mjs",
|
||||||
|
"--from",
|
||||||
|
from,
|
||||||
|
"--owner-user-id",
|
||||||
|
ownerUserId,
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
{ cwd: process.cwd(), env, stdio: "pipe" },
|
||||||
|
),
|
||||||
|
(error) => pattern.test(`${error.stdout ?? ""}\n${error.stderr ?? ""}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneExport(name) {
|
||||||
|
const destination = path.join(root, name);
|
||||||
|
fs.cpSync(exportDir, destination, { recursive: true });
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBundle(directory, bundle) {
|
||||||
|
fs.writeFileSync(path.join(directory, "export.json"), `${JSON.stringify(bundle, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(bytes) {
|
||||||
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBundle() {
|
||||||
|
const createdAt = "2026-07-17T10:00:00.000Z";
|
||||||
|
const tableNames = [
|
||||||
|
"profiles", "clients", "client_activities", "projects", "project_planning_sections",
|
||||||
|
"project_revisions", "tasks", "calendar_events", "finance_transactions", "daily_logs",
|
||||||
|
"journals", "chat_sessions", "chat_messages", "proposals", "contracts", "invoices",
|
||||||
|
"subscriptions", "app_settings", "document_embeddings",
|
||||||
|
];
|
||||||
|
const tables = Object.fromEntries(tableNames.map((name) => [name, []]));
|
||||||
|
|
||||||
|
tables.profiles.push({
|
||||||
|
id: sourceOwnerUserId,
|
||||||
|
first_name: "Source",
|
||||||
|
last_name: "Owner",
|
||||||
|
avatar_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png",
|
||||||
|
});
|
||||||
|
tables.clients.push({
|
||||||
|
id: "source-client", user_id: sourceOwnerUserId, client_auth_id: "legacy-client-auth-user",
|
||||||
|
name: "Imported Client", email: "client@example.com", status: "active",
|
||||||
|
pipeline_stage: "won", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.client_activities.push({
|
||||||
|
id: "source-activity", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
type: "meeting", title: "Imported Meeting", activity_date: createdAt, created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.projects.push({
|
||||||
|
id: "source-project", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
name: "Imported Project", type: "client_project", status: "active",
|
||||||
|
budget_amount: "1000.05", currency: "try", progress: 25, progress_type: "manual",
|
||||||
|
revision_quota: 2, cover_image_path: "source/project-cover.png", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.daily_logs.push({
|
||||||
|
id: "source-daily", user_id: sourceOwnerUserId, log_date: "2026-07-17",
|
||||||
|
mood_score: 4, energy_score: 3, work_satisfaction_score: 5,
|
||||||
|
note: "Daily note", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.journals.push({
|
||||||
|
id: "source-journal", user_id: sourceOwnerUserId, date: "2026-07-17T08:00:00.000Z",
|
||||||
|
mood: "focused", energy: 4, content: "Legacy journal content", ai_tags: ["focus"],
|
||||||
|
ai_summary: "Legacy summary", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.tasks.push({
|
||||||
|
id: "source-task", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
project_id: "source-project", source_journal_id: "source-journal", title: "Imported Task",
|
||||||
|
status: "completed", priority: "high", date: "2026-07-17",
|
||||||
|
is_public_to_client: true, created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.calendar_events.push({
|
||||||
|
id: "source-event", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
project_id: "source-project", task_id: "source-task", title: "Imported Event",
|
||||||
|
type: "meeting", starts_at: createdAt, ends_at: "2026-07-17T11:00:00.000Z",
|
||||||
|
created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.finance_transactions.push({
|
||||||
|
id: "source-finance", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
project_id: "source-project", type: "income", amount: "12.34", currency: "try",
|
||||||
|
transaction_date: "2026-07-17", payment_status: "paid", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.project_planning_sections.push({
|
||||||
|
id: "source-section", user_id: sourceOwnerUserId, project_id: "source-project",
|
||||||
|
category: "assets", title: "Imported Assets", metadata: { cover: "source/project-cover.png" },
|
||||||
|
sort_order: 0, created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.project_revisions.push({
|
||||||
|
id: "source-revision", project_id: "source-project", client_id: "source-client",
|
||||||
|
requested_by: "legacy-client-auth-user", description: "Imported revision",
|
||||||
|
status: "completed", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.chat_sessions.push({
|
||||||
|
id: "source-chat", user_id: sourceOwnerUserId, title: "Imported Chat", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.chat_messages.push({
|
||||||
|
id: "source-message", session_id: "source-chat", role: "user",
|
||||||
|
content: "Imported message", context_journal_ids: ["source-journal"], created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.proposals.push({
|
||||||
|
id: "source-proposal", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
project_id: "source-project", title: "Imported Proposal", amount: "100.00",
|
||||||
|
currency: "TRY", status: "accepted", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.contracts.push({
|
||||||
|
id: "source-contract", user_id: sourceOwnerUserId, proposal_id: "source-proposal",
|
||||||
|
client_id: "source-client", title: "Imported Contract", status: "active", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.invoices.push({
|
||||||
|
id: "source-invoice", user_id: sourceOwnerUserId, client_id: "source-client",
|
||||||
|
project_id: "source-project", invoice_number: "IMPORT-001", amount: "100.00",
|
||||||
|
tax_rate: "18.50", currency: "TRY", status: "paid", issue_date: "2026-07-17",
|
||||||
|
created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.subscriptions.push({
|
||||||
|
id: "source-subscription", user_id: sourceOwnerUserId, name: "Imported Hosting",
|
||||||
|
amount: "50.00", currency: "TRY", billing_cycle: "monthly", status: "active",
|
||||||
|
created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.app_settings.push({
|
||||||
|
id: "source-settings", user_id: sourceOwnerUserId, timezone: "Europe/Istanbul",
|
||||||
|
currency: "TRY", ai_provider: "google", ai_model: "gemini-test-model",
|
||||||
|
api_key: "legacy-plain-text-secret", created_at: createdAt,
|
||||||
|
});
|
||||||
|
tables.document_embeddings.push({
|
||||||
|
id: "source-embedding", user_id: sourceOwnerUserId, content: "Archived only",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: "neta-supabase-export",
|
||||||
|
version: 1,
|
||||||
|
exported_at: createdAt,
|
||||||
|
source: { owner_user_id: sourceOwnerUserId },
|
||||||
|
auth: { users: [{ password: "source-password-secret" }] },
|
||||||
|
tables,
|
||||||
|
storage: {
|
||||||
|
objects: [
|
||||||
|
{
|
||||||
|
bucket: "avatars", object_path: "source/avatar.png",
|
||||||
|
source_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png",
|
||||||
|
local_path: avatarLocalPath, original_name: "avatar.png", mime_type: "image/png",
|
||||||
|
bytes: avatarBytes.length, sha256: hash(avatarBytes), created_at: createdAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bucket: "project-assets", object_path: "source/project-cover.png",
|
||||||
|
local_path: coverLocalPath, original_name: "cover.png", mime_type: "image/png",
|
||||||
|
bytes: coverBytes.length, sha256: hash(coverBytes), project_id: "source-project",
|
||||||
|
portal_visible: true,
|
||||||
|
references: [{ type: "project_cover", project_id: "source-project" }],
|
||||||
|
created_at: createdAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+68
-5
@@ -12,7 +12,7 @@ if (!args.from) {
|
|||||||
const targetEnv = {
|
const targetEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
DATA_DIR: args.target || process.env.DATA_DIR,
|
DATA_DIR: args.target || process.env.DATA_DIR,
|
||||||
DATABASE_PATH: undefined,
|
DATABASE_PATH: args.target ? undefined : process.env.DATABASE_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = ensureDataLayout(getDataConfig(targetEnv));
|
const config = ensureDataLayout(getDataConfig(targetEnv));
|
||||||
@@ -31,13 +31,62 @@ if (fs.existsSync(config.databasePath) && !args.force) {
|
|||||||
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
|
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.copyFileSync(backupDbPath, config.databasePath);
|
const restoreId = `${process.pid}-${Date.now()}`;
|
||||||
|
const stagedDatabasePath = `${config.databasePath}.restore-stage-${restoreId}`;
|
||||||
|
const rollbackDatabasePath = `${config.databasePath}.restore-rollback-${restoreId}`;
|
||||||
|
const stagedUploadsDir = `${config.uploadsDir}.restore-stage-${restoreId}`;
|
||||||
|
const rollbackUploadsDir = `${config.uploadsDir}.restore-rollback-${restoreId}`;
|
||||||
|
|
||||||
if (fs.existsSync(backupUploadsDir)) {
|
let databaseMovedToRollback = false;
|
||||||
|
let uploadsMovedToRollback = false;
|
||||||
|
let stagedDatabaseInstalled = false;
|
||||||
|
let stagedUploadsInstalled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(backupDbPath, stagedDatabasePath, fs.constants.COPYFILE_EXCL);
|
||||||
|
if (hashFile(stagedDatabasePath) !== hashFile(backupDbPath)) {
|
||||||
|
throw new Error("Staged database checksum does not match the verified backup.");
|
||||||
|
}
|
||||||
|
fs.mkdirSync(stagedUploadsDir);
|
||||||
|
if (fs.existsSync(backupUploadsDir)) {
|
||||||
|
copyDirectory(backupUploadsDir, stagedUploadsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(config.databasePath)) {
|
||||||
|
fs.renameSync(config.databasePath, rollbackDatabasePath);
|
||||||
|
databaseMovedToRollback = true;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(config.uploadsDir)) {
|
||||||
|
fs.renameSync(config.uploadsDir, rollbackUploadsDir);
|
||||||
|
uploadsMovedToRollback = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(stagedDatabasePath, config.databasePath);
|
||||||
|
stagedDatabaseInstalled = true;
|
||||||
|
fs.renameSync(stagedUploadsDir, config.uploadsDir);
|
||||||
|
stagedUploadsInstalled = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (stagedDatabaseInstalled && fs.existsSync(config.databasePath)) {
|
||||||
|
fs.rmSync(config.databasePath, { force: true });
|
||||||
|
}
|
||||||
|
if (stagedUploadsInstalled && fs.existsSync(config.uploadsDir)) {
|
||||||
fs.rmSync(config.uploadsDir, { recursive: true, force: true });
|
fs.rmSync(config.uploadsDir, { recursive: true, force: true });
|
||||||
copyDirectory(backupUploadsDir, config.uploadsDir);
|
}
|
||||||
|
if (databaseMovedToRollback && fs.existsSync(rollbackDatabasePath)) {
|
||||||
|
fs.renameSync(rollbackDatabasePath, config.databasePath);
|
||||||
|
}
|
||||||
|
if (uploadsMovedToRollback && fs.existsSync(rollbackUploadsDir)) {
|
||||||
|
fs.renameSync(rollbackUploadsDir, config.uploadsDir);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(stagedDatabasePath, { force: true });
|
||||||
|
fs.rmSync(stagedUploadsDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs.rmSync(rollbackDatabasePath, { force: true });
|
||||||
|
fs.rmSync(rollbackUploadsDir, { recursive: true, force: true });
|
||||||
|
|
||||||
console.log(`Backup restored from ${backupDir} to ${config.dataDir}`);
|
console.log(`Backup restored from ${backupDir} to ${config.dataDir}`);
|
||||||
|
|
||||||
function parseArgs(values) {
|
function parseArgs(values) {
|
||||||
@@ -67,10 +116,14 @@ function copyDirectory(sourceDir, targetDir) {
|
|||||||
const sourcePath = path.join(sourceDir, entry.name);
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
throw new Error(`Backup contains a symbolic link: ${sourcePath}`);
|
||||||
|
} else if (entry.isDirectory()) {
|
||||||
copyDirectory(sourcePath, targetPath);
|
copyDirectory(sourcePath, targetPath);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Backup contains an unsupported filesystem entry: ${sourcePath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +134,12 @@ function verifyManifest(rootDir, manifestFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
|
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
|
||||||
|
if (
|
||||||
|
(manifest.format !== undefined && manifest.format !== "neta-backup") ||
|
||||||
|
(manifest.version !== undefined && manifest.version !== 1)
|
||||||
|
) {
|
||||||
|
throw new Error("Backup manifest format or version is not supported.");
|
||||||
|
}
|
||||||
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
|
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||||
throw new Error("Backup manifest has no file entries.");
|
throw new Error("Backup manifest has no file entries.");
|
||||||
}
|
}
|
||||||
@@ -135,3 +194,7 @@ function collectBackupFiles(rootDir, currentDir) {
|
|||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashFile(filePath) {
|
||||||
|
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `user_preferences` (
|
||||||
|
`owner_user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`timezone` text DEFAULT 'Europe/Istanbul' NOT NULL,
|
||||||
|
`default_currency` text DEFAULT 'TRY' NOT NULL,
|
||||||
|
`language` text DEFAULT 'tr' NOT NULL,
|
||||||
|
`date_format` text DEFAULT 'dd.MM.yyyy' NOT NULL,
|
||||||
|
`color_mode` text DEFAULT 'system' NOT NULL,
|
||||||
|
`sidebar_collapsed` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "user_preferences_currency_check" CHECK(length("user_preferences"."default_currency") = 3),
|
||||||
|
CONSTRAINT "user_preferences_language_check" CHECK("user_preferences"."language" in ('tr', 'en')),
|
||||||
|
CONSTRAINT "user_preferences_color_mode_check" CHECK("user_preferences"."color_mode" in ('light', 'dark', 'system'))
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1784234752708,
|
"when": 1784234752708,
|
||||||
"tag": "0005_brief_black_bolt",
|
"tag": "0005_brief_black_bolt",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784266217938,
|
||||||
|
"tag": "0006_moaning_kitty_pryde",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { check, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import { user } from "./auth";
|
import { user } from "./auth";
|
||||||
|
|
||||||
export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
|
export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
|
||||||
@@ -23,3 +23,25 @@ export const userAiSettings = sqliteTable(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const userPreferences = sqliteTable(
|
||||||
|
"user_preferences",
|
||||||
|
{
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
timezone: text("timezone").default("Europe/Istanbul").notNull(),
|
||||||
|
defaultCurrency: text("default_currency").default("TRY").notNull(),
|
||||||
|
language: text("language").default("tr").notNull(),
|
||||||
|
dateFormat: text("date_format").default("dd.MM.yyyy").notNull(),
|
||||||
|
colorMode: text("color_mode").default("system").notNull(),
|
||||||
|
sidebarCollapsed: integer("sidebar_collapsed", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
|
updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check("user_preferences_currency_check", sql`length(${table.defaultCurrency}) = 3`),
|
||||||
|
check("user_preferences_language_check", sql`${table.language} in ('tr', 'en')`),
|
||||||
|
check("user_preferences_color_mode_check", sql`${table.colorMode} in ('light', 'dark', 'system')`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user