Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import type Database from 'better-sqlite3';
import { openSqlite } from '../db/driver.js';
/**
* The control plane.
*
* Exactly one of these exists per installation. It holds the things that span
* workspaces — accounts, which workspaces exist, who belongs to which, and the
* sessions and invites that connect the two. It holds no customer data: every
* table, row, view and audit line lives in the workspace's own database file.
*
* Its tables are unprefixed. A workspace database shares its namespace with
* user-created tables and therefore needs the `_wellformed_` prefix; this one never
* does, so the plain names read better and mean what they say.
*/
export interface ControlDb {
sqlite: Database.Database;
}
interface Migration {
version: number;
name: string;
up: string;
}
/** Forward-only, same rule as the workspace migrations: never edit a shipped one. */
const MIGRATIONS: Migration[] = [
{
version: 1,
name: 'accounts, workspaces, memberships',
up: `
CREATE TABLE account (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT
);
CREATE TABLE workspace (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
/* Relative to the tenant directory, or absolute for an adopted file. */
db_file TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by TEXT
);
CREATE TABLE membership (
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
account_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (workspace_id, account_id)
);
CREATE INDEX idx_membership_account ON membership(account_id);
CREATE TABLE session (
token_hash TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT
);
CREATE INDEX idx_session_account ON session(account_id);
CREATE TABLE invite (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL,
invited_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
accepted_at TEXT,
accepted_by TEXT
);
CREATE INDEX idx_invite_workspace ON invite(workspace_id, accepted_at);
`,
},
{
version: 2,
name: 'account tokens: password reset and email verification',
up: `
/*
* One table for both, because they are the same object: a random secret,
* stored hashed, that stands in for the account exactly once and then
* expires. Only the purpose and the lifetime differ.
*/
CREATE TABLE account_token (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
account_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX idx_account_token_account ON account_token(account_id, purpose, used_at);
/* Null means "never confirmed", including for every account made before this. */
ALTER TABLE account ADD COLUMN verified_at TEXT;
`,
},
{
version: 3,
name: 'billing: a plan per workspace',
up: `
/*
* Billing hangs off the workspace, because a workspace is what a customer
* buys: one database, one bill, one thing to cancel. Nothing here belongs
* to an account — the same person can be a guest in a paid workspace and
* never see an invoice for it.
*
* Existing workspaces default to 'free' with no trial, which is the
* honest reading of "they were here before there was a price".
*/
ALTER TABLE workspace ADD COLUMN plan TEXT NOT NULL DEFAULT 'free';
/* active | trialing | past_due | cancelled */
ALTER TABLE workspace ADD COLUMN billing_status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE workspace ADD COLUMN trial_ends_at TEXT;
ALTER TABLE workspace ADD COLUMN period_end TEXT;
ALTER TABLE workspace ADD COLUMN cancel_at_period_end INTEGER NOT NULL DEFAULT 0;
/* Whoever is taking the money, and their ids for this workspace. */
ALTER TABLE workspace ADD COLUMN provider TEXT;
ALTER TABLE workspace ADD COLUMN provider_customer_id TEXT;
ALTER TABLE workspace ADD COLUMN provider_subscription_id TEXT;
/*
* Every webhook that has been applied. Providers retry, and they are
* right to: the only safe way to receive money-shaped messages is to be
* able to receive the same one twice. The PRIMARY KEY is the idempotency.
*/
CREATE TABLE billing_event (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
kind TEXT NOT NULL,
workspace_id TEXT,
received_at TEXT NOT NULL DEFAULT (datetime('now')),
payload TEXT NOT NULL
);
CREATE INDEX idx_billing_event_workspace ON billing_event(workspace_id, received_at);
`,
},
{
version: 4,
name: 'agent keys: what an AI assistant signs in with',
up: `
/*
* Hashed like a session and a one-shot token, and for the same reason: a
* leaked control database must not be replayable. What makes this one
* different is that it is long-lived and bound to a workspace, so the two
* things a session carries implicitly — who, and for how long — are
* columns here instead.
*
* 'ceiling' is a cap, not a grant. The role that applies is the lower of
* it and the account's membership, resolved on every request, so a key
* can never outlive or exceed the access of the person who made it.
*/
CREATE TABLE agent_key (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
account_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
label TEXT NOT NULL,
ceiling TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
/* Null means it lives until revoked. */
expires_at TEXT,
revoked_at TEXT
);
CREATE INDEX idx_agent_key_workspace ON agent_key(workspace_id, revoked_at);
CREATE INDEX idx_agent_key_account ON agent_key(account_id);
`,
},
{
version: 5,
name: 'billing: who the invoices are addressed to',
up: `
/*
* The address the bill goes to, held by the workspace rather than borrowed
* from whichever admin happened to click Upgrade.
*
* That borrowing was the one place billing still reached into an account,
* against the rule stated in migration 3. It matters at handover: an admin
* leaves, their mailbox is closed, and the invoices for a workspace that is
* still being paid for keep going to it. Usually the payer is a company
* anyway, so the address wanted here — finance@, accounts@ — is not a
* person's to begin with.
*
* Null means "never set", and checkout asks for one rather than guessing.
*/
ALTER TABLE workspace ADD COLUMN billing_email TEXT;
`,
},
{
version: 6,
name: 'a workspace keeps its books in one currency',
up: `
/*
* Currency columns used to be dollars, hard-coded in the formatter, which
* is only right for the customers who happen to be American. It is a
* property of the business, not of the column: one workspace, one set of
* books, one sign in front of every amount in it.
*
* Existing workspaces move to the euro along with the default, rather
* than being frozen at the dollar they never chose. Anyone who did mean
* dollars says so once, in workspace settings.
*/
ALTER TABLE workspace ADD COLUMN currency TEXT NOT NULL DEFAULT 'EUR';
`,
},
{
version: 7,
name: 'the landing page as an instrument',
up: `
/*
* Which lines of the landing page a visitor was shown, and what became of
* them. The pool of lines lives in the shared package; only the ids are
* here, so rewording the site never touches this table — see the rule
* about permanent ids in shared/voice.ts.
*
* 'engaged_at' is the bot filter, and it is load-bearing rather than
* hygiene. The measure is durable use *per impression*, so the
* denominator is half the number; if crawlers count as impressions the
* denominator is mostly crawlers and every weight is noise around zero.
* A row is created when the page is served and only enters the fit once
* the browser says a person actually looked at it.
*
* SET NULL rather than CASCADE: an account deleted outright leaves an
* impression that really did happen and now has no use to show for it,
* which is a truthful zero. Losing the row would quietly shrink the
* denominator instead.
*/
CREATE TABLE voice_impression (
id TEXT PRIMARY KEY,
/* JSON array of item ids. */
items TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
engaged_at TEXT,
account_id TEXT REFERENCES account(id) ON DELETE SET NULL
);
CREATE INDEX idx_voice_impression_fit ON voice_impression(engaged_at, created_at);
CREATE INDEX idx_voice_impression_account ON voice_impression(account_id);
/*
* One row per account per day it wrote something, anywhere.
*
* The reward the page is tuned against is durable use, and this is the
* only durable-use signal that lives in the control plane. Counting it
* from the workspace audit logs would mean opening every workspace file
* to score a landing page, which is a bad trade and a worse dependency.
*
* Days rather than actions on purpose: it should not matter whether
* someone's work happens to arrive as one paste or four hundred edits.
*/
CREATE TABLE account_day (
account_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE,
day TEXT NOT NULL,
PRIMARY KEY (account_id, day)
) WITHOUT ROWID;
`,
},
{
version: 8,
name: 'an account reads in one language',
up: `
/*
* The reader's locale, on the account rather than the workspace, because
* it is a property of the person: the same Dutch bookkeeper reads Dutch
* in every workspace they open, and their English colleague reads the
* same books in English. The workspace keeps only the currency.
*
* Null means "never chosen" — the browser's language decides, which for
* every account made before this migration is also the honest answer.
* The set of valid values lives in shared/locale.ts; anything else in
* this column reads as null rather than crashing a sign-in.
*/
ALTER TABLE account ADD COLUMN locale TEXT;
`,
},
{
version: 9,
name: 'the language belongs to the workspace',
up: `
/*
* Migration 8 put the locale on the account, reasoning that the words in
* the chrome belong to the reader. That was wrong in practice: a
* workspace is full of proper names — tables, columns, views — written
* in one language, and chrome in another language around them reads like
* two products taped together. So the language moves to where the
* currency already lives: one workspace, one language, one way of
* writing its numbers and dates, the same for everyone who opens it.
*
* NOT NULL with a default, unlike the account column it replaces:
* outside a workspace the browser decides, but a workspace always
* answers. Existing workspaces get English — the language they have
* been speaking all along; anyone who means Dutch says so once, in
* workspace settings, next to the currency.
*/
ALTER TABLE workspace ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
ALTER TABLE account DROP COLUMN locale;
`,
},
];
export function openControlDatabase(file: string): ControlDb {
const control: ControlDb = { sqlite: openSqlite({ file }) };
migrateControl(control);
return control;
}
export function migrateControl(control: ControlDb): void {
control.sqlite.exec(`
CREATE TABLE IF NOT EXISTS control_migration (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const applied = new Set(
control.sqlite
.prepare<[], { version: number }>('SELECT version FROM control_migration')
.all()
.map((r) => r.version),
);
const record = control.sqlite.prepare('INSERT INTO control_migration (version, name) VALUES (?, ?)');
for (const m of MIGRATIONS) {
if (applied.has(m.version)) continue;
const run = control.sqlite.transaction(() => {
control.sqlite.exec(m.up);
record.run(m.version, m.name);
});
run();
}
}
export function closeControlDatabase(control: ControlDb): void {
control.sqlite.close();
}
/** Run `fn` inside a transaction on the control database. */
export function transactControl<T>(control: ControlDb, fn: () => T): T {
const run = control.sqlite.transaction(fn);
return run() as T;
}