Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { existsSync } from 'node:fs';
import Database from 'better-sqlite3';
import { slugifyWorkspaceName, type Role } from '@wellformed/shared';
import { availableSlug } from '../tenancy/provision.js';
import { createAccount, getAccountByEmail } from '../control/accounts.js';
import { transactControl, type ControlDb } from '../control/db.js';
import {
createWorkspace,
setMembership,
workspaceExistsForFile,
type WorkspaceRecord,
} from '../control/workspaces.js';
export interface AdoptionResult {
adopted: boolean;
workspace?: WorkspaceRecord;
accountsMoved: number;
}
interface LegacyUser {
id: string;
email: string;
name: string;
role: string;
password_hash: string;
disabled: number;
created_at: string;
last_seen_at: string | null;
}
/**
* Turn a pre-tenancy `wellformed.db` into the installation's first workspace.
*
* Single-tenant Wellformed kept its users inside the same file as the data. Those
* accounts have to be lifted into the control plane *before* the workspace
* migrations run, because migration 2 drops the tables they live in. So this
* opens the file directly, with no migration pass, reads what it needs and
* closes again; the registry opens it properly afterwards and the drop happens
* then.
*
* Idempotent, and a no-op when there is no such file: on a fresh install, or
* once the file is already registered, it does nothing.
*/
export function adoptLegacyDatabase(
control: ControlDb,
options: { file: string; slug: string; name: string },
): AdoptionResult {
if (!existsSync(options.file)) return { adopted: false, accountsMoved: 0 };
if (workspaceExistsForFile(control, options.file)) return { adopted: false, accountsMoved: 0 };
const users = readLegacyUsers(options.file);
// The configured slug is a preference, not a promise: it may be reserved, or
// already taken by a workspace created before this ran. Boot must not fail
// over the *name* of the thing it is rescuing.
const slug = availableSlug(control, slugifyWorkspaceName(options.slug));
return transactControl(control, () => {
const workspace = createWorkspace(control, {
slug,
name: options.name,
// Adopted in place: the file keeps its path, so an existing backup script
// or bind mount pointing at it goes on working.
dbFile: options.file,
});
let accountsMoved = 0;
for (const user of users) {
const email = user.email.trim().toLowerCase();
// An account may already exist if adoption ran, was interrupted after the
// account insert, and is running again.
const existing = getAccountByEmail(control, email);
const account =
existing ??
createAccount(control, {
id: user.id,
email,
name: user.name,
passwordHash: user.password_hash,
disabled: user.disabled === 1,
createdAt: user.created_at,
lastSeenAt: user.last_seen_at,
});
setMembership(control, workspace.id, account.id, normaliseRole(user.role));
accountsMoved++;
}
return { adopted: true, workspace, accountsMoved };
});
}
function normaliseRole(role: string): Role {
return role === 'admin' || role === 'editor' ? role : 'viewer';
}
function readLegacyUsers(file: string): LegacyUser[] {
const sqlite = new Database(file, { readonly: true });
try {
const hasUsers = sqlite
.prepare<[], { n: number }>(
`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '_wellformed_user'`,
)
.get();
if ((hasUsers?.n ?? 0) === 0) return [];
return sqlite
.prepare<[], LegacyUser>(
`SELECT id, email, name, role, password_hash, disabled, created_at, last_seen_at
FROM _wellformed_user`,
)
.all();
} finally {
sqlite.close();
}
}