Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

3.8 kB Ruw bestand
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();
  }
}