From 0ff5585b96619c7ae01b5ceefc3c8c37b1b31f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 17:05:29 -0400 Subject: [PATCH] Add D1 migration baseline --- cloudflare/migrations/0001_devices.sql | 36 +++++++ cloudflare/migrations/0002_sync.sql | 62 ++++++++++++ cloudflare/migrations/0003_plugins.sql | 44 +++++++++ cloudflare/migrations/0004_releases.sql | 15 +++ cloudflare/migrations/0005_audit.sql | 17 ++++ cloudflare/src/bindings.ts | 14 +++ cloudflare/tests/api_controls.test.ts | 32 ++++++ cloudflare/tests/index.test.ts | 32 ++++++ cloudflare/tests/migrations.test.ts | 125 ++++++++++++++++++++++++ cloudflare/wrangler.toml | 6 ++ 10 files changed, 383 insertions(+) create mode 100644 cloudflare/migrations/0001_devices.sql create mode 100644 cloudflare/migrations/0002_sync.sql create mode 100644 cloudflare/migrations/0003_plugins.sql create mode 100644 cloudflare/migrations/0004_releases.sql create mode 100644 cloudflare/migrations/0005_audit.sql create mode 100644 cloudflare/tests/migrations.test.ts diff --git a/cloudflare/migrations/0001_devices.sql b/cloudflare/migrations/0001_devices.sql new file mode 100644 index 0000000..94afe91 --- /dev/null +++ b/cloudflare/migrations/0001_devices.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS user_devices ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + public_key TEXT NOT NULL, + device_name TEXT NOT NULL, + platform TEXT NOT NULL, + approval_status TEXT NOT NULL CHECK (approval_status IN ('pending', 'approved', 'revoked')), + created_at INTEGER NOT NULL, + approved_at INTEGER, + last_active_at INTEGER, + revoked_at INTEGER, + idempotency_key TEXT NOT NULL, + PRIMARY KEY (user_id, device_id), + UNIQUE (user_id, idempotency_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_devices_user_active + ON user_devices (user_id, revoked_at, last_active_at); + +CREATE TABLE IF NOT EXISTS device_approvals ( + user_id TEXT NOT NULL, + approval_id TEXT NOT NULL, + device_id TEXT NOT NULL, + requester_device_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'expired')), + requested_at INTEGER NOT NULL, + decided_at INTEGER, + expires_at INTEGER NOT NULL, + idempotency_key TEXT NOT NULL, + PRIMARY KEY (user_id, approval_id), + UNIQUE (user_id, idempotency_key), + FOREIGN KEY (user_id, device_id) REFERENCES user_devices (user_id, device_id) +); + +CREATE INDEX IF NOT EXISTS idx_device_approvals_user_status + ON device_approvals (user_id, status, expires_at); diff --git a/cloudflare/migrations/0002_sync.sql b/cloudflare/migrations/0002_sync.sql new file mode 100644 index 0000000..18acbc5 --- /dev/null +++ b/cloudflare/migrations/0002_sync.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS sync_objects ( + user_id TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + payload_inline BLOB, + payload_r2_key TEXT, + payload_hash TEXT NOT NULL, + schema_rev INTEGER NOT NULL, + logical_clock INTEGER NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + PRIMARY KEY (user_id, object_id), + CHECK (payload_inline IS NULL OR payload_r2_key IS NULL), + CHECK (deleted_at IS NOT NULL OR payload_inline IS NOT NULL OR payload_r2_key IS NOT NULL) +); + +CREATE INDEX IF NOT EXISTS idx_sync_objects_delta + ON sync_objects (user_id, object_type, logical_clock, updated_at); + +CREATE TABLE IF NOT EXISTS sync_change_log ( + change_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ('upsert', 'delete')), + payload_hash TEXT NOT NULL, + logical_clock INTEGER NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (user_id, object_id, logical_clock, device_id, operation) +); + +CREATE INDEX IF NOT EXISTS idx_sync_change_log_pull + ON sync_change_log (user_id, change_id); + +CREATE TABLE IF NOT EXISTS sync_snapshots ( + user_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + r2_key TEXT NOT NULL, + payload_hash TEXT NOT NULL, + schema_rev INTEGER NOT NULL, + logical_clock INTEGER NOT NULL, + device_id TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes > 0), + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, snapshot_id) +); + +CREATE INDEX IF NOT EXISTS idx_sync_snapshots_latest + ON sync_snapshots (user_id, created_at); + +CREATE TABLE IF NOT EXISTS sync_tombstones ( + user_id TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + logical_clock INTEGER NOT NULL, + device_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + PRIMARY KEY (user_id, object_id) +); diff --git a/cloudflare/migrations/0003_plugins.sql b/cloudflare/migrations/0003_plugins.sql new file mode 100644 index 0000000..9ac462d --- /dev/null +++ b/cloudflare/migrations/0003_plugins.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS plugin_registry ( + plugin_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + author TEXT NOT NULL, + permissions_json TEXT NOT NULL, + signature_state TEXT NOT NULL CHECK (signature_state IN ('valid', 'revoked')), + min_ely_build TEXT NOT NULL, + package_r2_key TEXT NOT NULL, + package_sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + revoked_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_plugin_registry_signature + ON plugin_registry (signature_state, updated_at); + +CREATE TABLE IF NOT EXISTS plugin_packages ( + plugin_id TEXT NOT NULL, + package_version TEXT NOT NULL, + r2_key TEXT NOT NULL, + sha256 TEXT NOT NULL, + signature_key_id TEXT NOT NULL, + signature_value TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes > 0), + published_at INTEGER NOT NULL, + revoked_at INTEGER, + PRIMARY KEY (plugin_id, package_version), + FOREIGN KEY (plugin_id) REFERENCES plugin_registry (plugin_id) +); + +CREATE INDEX IF NOT EXISTS idx_plugin_packages_active + ON plugin_packages (plugin_id, revoked_at, published_at); + +CREATE TABLE IF NOT EXISTS plugin_reviews ( + review_id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + reviewer_user_id TEXT NOT NULL, + review_status TEXT NOT NULL CHECK (review_status IN ('pending', 'approved', 'rejected')), + notes_hash TEXT, + created_at INTEGER NOT NULL, + decided_at INTEGER, + FOREIGN KEY (plugin_id) REFERENCES plugin_registry (plugin_id) +); diff --git a/cloudflare/migrations/0004_releases.sql b/cloudflare/migrations/0004_releases.sql new file mode 100644 index 0000000..bdf7923 --- /dev/null +++ b/cloudflare/migrations/0004_releases.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS release_manifests ( + channel TEXT NOT NULL CHECK (channel IN ('stable', 'beta', 'nightly')), + platform TEXT NOT NULL, + architecture TEXT NOT NULL, + release_version TEXT NOT NULL, + artifact_r2_key TEXT NOT NULL, + artifact_sha256 TEXT NOT NULL, + artifact_signature TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes > 0), + generated_at INTEGER NOT NULL, + PRIMARY KEY (channel, platform, architecture, release_version) +); + +CREATE INDEX IF NOT EXISTS idx_release_manifests_latest + ON release_manifests (channel, platform, architecture, generated_at); diff --git a/cloudflare/migrations/0005_audit.sql b/cloudflare/migrations/0005_audit.sql new file mode 100644 index 0000000..25adc6f --- /dev/null +++ b/cloudflare/migrations/0005_audit.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS audit_events ( + event_id TEXT PRIMARY KEY, + user_id TEXT, + actor_device_id TEXT, + event_type TEXT NOT NULL, + subject_type TEXT NOT NULL, + subject_id TEXT, + outcome TEXT NOT NULL CHECK (outcome IN ('success', 'failure', 'denied')), + metadata_hash TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_events_user_time + ON audit_events (user_id, created_at); + +CREATE INDEX IF NOT EXISTS idx_audit_events_type_time + ON audit_events (event_type, created_at); diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 1143b1f..31ab4a5 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -2,6 +2,19 @@ export interface ElyKvNamespace { get(key: string): Promise; } +export interface ElyD1PreparedStatement { + bind(...values: unknown[]): ElyD1PreparedStatement; + first(): Promise; + all(): Promise<{ results: T[] }>; + run(): Promise; +} + +export interface ElyD1Database { + prepare(query: string): ElyD1PreparedStatement; + batch(statements: ElyD1PreparedStatement[]): Promise; + exec(query: string): Promise; +} + export interface ElyRateLimit { limit(options: { key: string }): Promise<{ success: boolean }>; } @@ -17,6 +30,7 @@ export interface ElyAnalyticsDataset { } export interface Env { + ELY_DB: ElyD1Database; ELY_KV: ElyKvNamespace; ELY_RATE_LIMITER: ElyRateLimit; ELY_API_AUDIT: ElyAnalyticsDataset; diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 9a02366..c86c282 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -96,6 +96,7 @@ function testEnv(options: TestEnvOptions = {}): Env { return { ELY_ENVIRONMENT: "local", + ELY_DB: testD1Database(), ELY_KV: { get(key: string): Promise { options.kvReads?.push(key); @@ -117,3 +118,34 @@ function testEnv(options: TestEnvOptions = {}): Env { }, }; } + +function testD1Database(): Env["ELY_DB"] { + return { + prepare() { + return testD1PreparedStatement(); + }, + batch() { + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({}); + }, + }; +} + +function testD1PreparedStatement(): ReturnType { + return { + bind() { + return this; + }, + first() { + return Promise.resolve(null); + }, + all() { + return Promise.resolve({ results: [] }); + }, + run() { + return Promise.resolve({}); + }, + }; +} diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index c69e770..e814927 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -341,6 +341,7 @@ function testEnv( return { ELY_ENVIRONMENT: "local", + ELY_DB: testD1Database(), ELY_KV: { get(key: string): Promise { return Promise.resolve(values.get(key) ?? null); @@ -376,6 +377,37 @@ function releaseManifestDocument(): string { }); } +function testD1Database(): Env["ELY_DB"] { + return { + prepare() { + return testD1PreparedStatement(); + }, + batch() { + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({}); + }, + }; +} + +function testD1PreparedStatement(): ReturnType { + return { + bind() { + return this; + }, + first() { + return Promise.resolve(null); + }, + all() { + return Promise.resolve({ results: [] }); + }, + run() { + return Promise.resolve({}); + }, + }; +} + function pluginRegistryDocument(): string { return JSON.stringify({ version: 1, diff --git a/cloudflare/tests/migrations.test.ts b/cloudflare/tests/migrations.test.ts new file mode 100644 index 0000000..3b068d4 --- /dev/null +++ b/cloudflare/tests/migrations.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); +const EXPECTED_MIGRATIONS = [ + "0001_devices.sql", + "0002_sync.sql", + "0003_plugins.sql", + "0004_releases.sql", + "0005_audit.sql", +]; +const USER_SCOPED_TABLES = [ + "user_devices", + "device_approvals", + "sync_objects", + "sync_change_log", + "sync_snapshots", + "sync_tombstones", +]; + +describe("D1 migrations", () => { + it("keep the PRD migration order explicit", () => { + assert.deepEqual(migrationFiles(), EXPECTED_MIGRATIONS); + }); + + it("replays cleanly and creates the PRD custom tables", () => { + withReplayedDatabase((databasePath) => { + const tables = sqliteJson<{ name: string }>( + databasePath, + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ).map((row) => row.name); + + for (const table of [ + "audit_events", + "device_approvals", + "plugin_packages", + "plugin_registry", + "plugin_reviews", + "release_manifests", + "sync_change_log", + "sync_objects", + "sync_snapshots", + "sync_tombstones", + "user_devices", + ]) { + assert.ok(tables.includes(table), table); + } + }); + }); + + it("keeps user-scoped D1 tables partitioned by user_id", () => { + withReplayedDatabase((databasePath) => { + for (const table of USER_SCOPED_TABLES) { + assert.ok(tableColumns(databasePath, table).includes("user_id"), table); + } + }); + }); + + it("keeps sync facts compatible with cursor pull and tombstones", () => { + withReplayedDatabase((databasePath) => { + assert.deepEqual( + requiredColumns(databasePath, "sync_objects", [ + "object_id", + "object_type", + "logical_clock", + "device_id", + "deleted_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_change_log", [ + "change_id", + "object_id", + "object_type", + "operation", + "logical_clock", + "device_id", + ]), + [], + ); + }); + }); +}); + +function migrationFiles(): string[] { + return readdirSync(MIGRATIONS_DIR).filter((fileName) => fileName.endsWith(".sql")).sort(); +} + +function withReplayedDatabase(assertions: (databasePath: string) => void): void { + const tempDir = mkdtempSync(join(tmpdir(), "ely-d1-migrations-")); + try { + const databasePath = join(tempDir, "ely.db"); + const sql = migrationFiles() + .map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")) + .join("\n"); + execFileSync("sqlite3", [databasePath], { input: sql }); + execFileSync("sqlite3", [databasePath], { input: sql }); + assertions(databasePath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function tableColumns(databasePath: string, table: string): string[] { + return sqliteJson<{ name: string }>(databasePath, `PRAGMA table_info(${table})`).map( + (row) => row.name, + ); +} + +function requiredColumns(databasePath: string, table: string, columns: string[]): string[] { + const existingColumns = new Set(tableColumns(databasePath, table)); + return columns.filter((column) => !existingColumns.has(column)); +} + +function sqliteJson(databasePath: string, sql: string): T[] { + const output = execFileSync("sqlite3", ["-json", databasePath, sql], { + encoding: "utf8", + }); + return JSON.parse(output) as T[]; +} diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index f2ac7e7..ba03f8c 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -2,6 +2,12 @@ name = "ely-browser-cloud" main = "src/index.ts" compatibility_date = "2026-05-08" +[[d1_databases]] +binding = "ELY_DB" +database_name = "elydora-db" +database_id = "6cf37748-b5ff-4765-ba91-69f3780dcbd7" +migrations_dir = "migrations" + [[kv_namespaces]] binding = "ELY_KV" id = "5eff92ba31c94fcf83e1b6d5e79ce070"