Add D1 migration baseline
This commit is contained in:
@@ -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);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -2,6 +2,19 @@ export interface ElyKvNamespace {
|
|||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElyD1PreparedStatement {
|
||||||
|
bind(...values: unknown[]): ElyD1PreparedStatement;
|
||||||
|
first<T = unknown>(): Promise<T | null>;
|
||||||
|
all<T = unknown>(): Promise<{ results: T[] }>;
|
||||||
|
run(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElyD1Database {
|
||||||
|
prepare(query: string): ElyD1PreparedStatement;
|
||||||
|
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
|
||||||
|
exec(query: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElyRateLimit {
|
export interface ElyRateLimit {
|
||||||
limit(options: { key: string }): Promise<{ success: boolean }>;
|
limit(options: { key: string }): Promise<{ success: boolean }>;
|
||||||
}
|
}
|
||||||
@@ -17,6 +30,7 @@ export interface ElyAnalyticsDataset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
|
ELY_DB: ElyD1Database;
|
||||||
ELY_KV: ElyKvNamespace;
|
ELY_KV: ElyKvNamespace;
|
||||||
ELY_RATE_LIMITER: ElyRateLimit;
|
ELY_RATE_LIMITER: ElyRateLimit;
|
||||||
ELY_API_AUDIT: ElyAnalyticsDataset;
|
ELY_API_AUDIT: ElyAnalyticsDataset;
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function testEnv(options: TestEnvOptions = {}): Env {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ELY_ENVIRONMENT: "local",
|
ELY_ENVIRONMENT: "local",
|
||||||
|
ELY_DB: testD1Database(),
|
||||||
ELY_KV: {
|
ELY_KV: {
|
||||||
get(key: string): Promise<string | null> {
|
get(key: string): Promise<string | null> {
|
||||||
options.kvReads?.push(key);
|
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<Env["ELY_DB"]["prepare"]> {
|
||||||
|
return {
|
||||||
|
bind() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
first() {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return Promise.resolve({ results: [] });
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
return Promise.resolve({});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ function testEnv(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ELY_ENVIRONMENT: "local",
|
ELY_ENVIRONMENT: "local",
|
||||||
|
ELY_DB: testD1Database(),
|
||||||
ELY_KV: {
|
ELY_KV: {
|
||||||
get(key: string): Promise<string | null> {
|
get(key: string): Promise<string | null> {
|
||||||
return Promise.resolve(values.get(key) ?? null);
|
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<Env["ELY_DB"]["prepare"]> {
|
||||||
|
return {
|
||||||
|
bind() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
first() {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return Promise.resolve({ results: [] });
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
return Promise.resolve({});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pluginRegistryDocument(): string {
|
function pluginRegistryDocument(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -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<T>(databasePath: string, sql: string): T[] {
|
||||||
|
const output = execFileSync("sqlite3", ["-json", databasePath, sql], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
return JSON.parse(output) as T[];
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ name = "ely-browser-cloud"
|
|||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2026-05-08"
|
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]]
|
[[kv_namespaces]]
|
||||||
binding = "ELY_KV"
|
binding = "ELY_KV"
|
||||||
id = "5eff92ba31c94fcf83e1b6d5e79ce070"
|
id = "5eff92ba31c94fcf83e1b6d5e79ce070"
|
||||||
|
|||||||
Reference in New Issue
Block a user