From 5e42ee88690ecc67bb7d4f3700590244d7a72d2c Mon Sep 17 00:00:00 2001 From: vince Date: Thu, 18 Jun 2026 18:09:17 +0200 Subject: [PATCH] feat: add dashboard persistence store --- README.md | 47 ++-- bun.lock | 183 +++++++++++++++ drizzle.config.ts | 10 + drizzle/0000_dashboard_persistence.sql | 20 ++ drizzle/meta/0000_snapshot.json | 138 +++++++++++ drizzle/meta/_journal.json | 13 + package.json | 9 +- src/lib/server/dashboard.test.ts | 42 ++++ src/lib/server/dashboard.ts | 34 +++ src/lib/server/db/connection.ts | 47 ++++ src/lib/server/db/dashboard-store.test.ts | 205 ++++++++++++++++ src/lib/server/db/dashboard-store.ts | 261 +++++++++++++++++++++ src/lib/server/db/migrations.ts | 52 ++++ src/lib/server/db/model-migrations.test.ts | 31 +++ src/lib/server/db/model-migrations.ts | 62 +++++ src/lib/server/db/schema.ts | 39 +++ src/routes/+page.server.ts | 4 +- tsconfig.json | 3 +- 18 files changed, 1181 insertions(+), 19 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_dashboard_persistence.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 src/lib/server/dashboard.test.ts create mode 100644 src/lib/server/db/connection.ts create mode 100644 src/lib/server/db/dashboard-store.test.ts create mode 100644 src/lib/server/db/dashboard-store.ts create mode 100644 src/lib/server/db/migrations.ts create mode 100644 src/lib/server/db/model-migrations.test.ts create mode 100644 src/lib/server/db/model-migrations.ts create mode 100644 src/lib/server/db/schema.ts diff --git a/README.md b/README.md index 352d1a3..3dadcbd 100644 --- a/README.md +++ b/README.md @@ -9,27 +9,46 @@ in validated dashboard model state. ## Development -```sh -npm install -npm run dev -``` - -The current workspace has Bun available, so local verification can also use: - ```sh bun install bun run dev ``` +This MVP uses Drizzle with Bun SQLite for local file-backed persistence. + ## Scripts -- `npm run dev`: start the local development server. -- `npm run check`: run Svelte and TypeScript checks. -- `npm run build`: build the production app. -- `npm run preview`: preview the production build. +- `bun run dev`: start the local development server. +- `bun run check`: run Svelte and TypeScript checks. +- `bun run test`: run Vitest. +- `bun run build`: build the production app. +- `bun run preview`: preview the production build. +- `bun run db:generate`: generate Drizzle migrations from the server schema. +- `bun run db:check`: validate migration consistency. + +## Persistence + +Dashboard documents are stored in SQLite through Drizzle. The default database +URL is: + +```sh +DATABASE_URL=file:./data/dimensionlab.sqlite +``` + +SQLite files under `data/` are ignored. Drizzle schema lives in +`src/lib/server/db/schema.ts`; tracked migrations live in `drizzle/`. Runtime +startup applies the checked-in dashboard migrations before reads or writes. If +the app is launched from outside the repo tree, set `DASHBOARD_MIGRATIONS_DIR` +to the tracked migrations directory. The current driver is `bun:sqlite`, which +keeps this repo installable in the Bun workflow. The store boundary is isolated +so a later Postgres driver can replace the SQLite connection without changing +the dashboard model or renderer. +Stored dashboard documents pass through a version migration boundary before +reads or writes; the MVP supports `dashboard.v1` and fails unsupported versions +with an explicit migration error. ## Runtime Shape -The first deployment target is a Node/container-friendly SvelteKit build. Later -issues add the versioned dashboard model, SQLite persistence, component system, -Storybook, seed data, and rendering pipeline. +The SvelteKit build uses the Node adapter, and the current persistence runtime +is Bun because the MVP SQLite driver is `bun:sqlite`. Later issues add the +component system, Storybook, seed data expansion, and rendering pipeline. diff --git a/bun.lock b/bun.lock index 267f7f4..6bf41c5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,15 @@ "@sinclair/typebox": "^0.34.49", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "drizzle-orm": "^0.45.2", }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.65.2", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/bun": "^1.3.14", "@types/node": "^25.9.3", + "drizzle-kit": "^0.31.10", "svelte": "^5.56.3", "svelte-check": "^4.6.0", "typescript": "^6.0.3", @@ -23,12 +26,70 @@ }, }, "packages": { + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -151,6 +212,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -191,6 +254,10 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -209,10 +276,16 @@ "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esrap": ["esrap@2.2.11", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="], @@ -231,6 +304,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], @@ -295,6 +370,8 @@ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "rollup": ["rollup@4.62.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.0", "@rollup/rollup-android-arm64": "4.62.0", "@rollup/rollup-darwin-arm64": "4.62.0", "@rollup/rollup-darwin-x64": "4.62.0", "@rollup/rollup-freebsd-arm64": "4.62.0", "@rollup/rollup-freebsd-x64": "4.62.0", "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", "@rollup/rollup-linux-arm-musleabihf": "4.62.0", "@rollup/rollup-linux-arm64-gnu": "4.62.0", "@rollup/rollup-linux-arm64-musl": "4.62.0", "@rollup/rollup-linux-loong64-gnu": "4.62.0", "@rollup/rollup-linux-loong64-musl": "4.62.0", "@rollup/rollup-linux-ppc64-gnu": "4.62.0", "@rollup/rollup-linux-ppc64-musl": "4.62.0", "@rollup/rollup-linux-riscv64-gnu": "4.62.0", "@rollup/rollup-linux-riscv64-musl": "4.62.0", "@rollup/rollup-linux-s390x-gnu": "4.62.0", "@rollup/rollup-linux-x64-gnu": "4.62.0", "@rollup/rollup-linux-x64-musl": "4.62.0", "@rollup/rollup-openbsd-x64": "4.62.0", "@rollup/rollup-openharmony-arm64": "4.62.0", "@rollup/rollup-win32-arm64-msvc": "4.62.0", "@rollup/rollup-win32-ia32-msvc": "4.62.0", "@rollup/rollup-win32-x64-gnu": "4.62.0", "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA=="], @@ -307,8 +384,12 @@ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], @@ -331,6 +412,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -345,8 +428,108 @@ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], } } diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..2f23f47 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/lib/server/db/schema.ts", + out: "./drizzle", + dialect: "sqlite", + dbCredentials: { + url: process.env.DATABASE_URL?.replace(/^file:/, "") || "./data/dimensionlab.sqlite", + }, +}); diff --git a/drizzle/0000_dashboard_persistence.sql b/drizzle/0000_dashboard_persistence.sql new file mode 100644 index 0000000..fb321c5 --- /dev/null +++ b/drizzle/0000_dashboard_persistence.sql @@ -0,0 +1,20 @@ +CREATE TABLE `dashboard_documents` ( + `id` text PRIMARY KEY NOT NULL, + `current_revision_id` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `dashboard_revisions` ( + `id` text PRIMARY KEY NOT NULL, + `dashboard_id` text NOT NULL, + `schema_version` text NOT NULL, + `document` text NOT NULL, + `actor` text NOT NULL, + `message` text, + `operation` text NOT NULL, + `source_revision_id` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_dashboard_revisions_dashboard_created` ON `dashboard_revisions` (`dashboard_id`,`created_at`); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..e184f8e --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,138 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "00000000-0000-0000-0000-dashboard0000", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "dashboard_documents": { + "name": "dashboard_documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_revision_id": { + "name": "current_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dashboard_revisions": { + "name": "dashboard_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision_id": { + "name": "source_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_dashboard_revisions_dashboard_created": { + "name": "idx_dashboard_revisions_dashboard_created", + "columns": [ + "dashboard_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "enums": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..d19b757 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1781805600000, + "tag": "0000_dashboard_persistence", + "breakpoints": true + } + ] +} diff --git a/package.json b/package.json index 0cdc69e..fcf2270 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,23 @@ "build": "vite build", "preview": "vite preview --host 0.0.0.0", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run" + "test": "vitest run", + "db:generate": "drizzle-kit generate", + "db:check": "drizzle-kit check" }, "dependencies": { "@sinclair/typebox": "^0.34.49", "ajv": "^8.20.0", - "ajv-formats": "^3.0.1" + "ajv-formats": "^3.0.1", + "drizzle-orm": "^0.45.2" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.65.2", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/bun": "^1.3.14", "@types/node": "^25.9.3", + "drizzle-kit": "^0.31.10", "svelte": "^5.56.3", "svelte-check": "^4.6.0", "typescript": "^6.0.3", diff --git a/src/lib/server/dashboard.test.ts b/src/lib/server/dashboard.test.ts new file mode 100644 index 0000000..91a5ffb --- /dev/null +++ b/src/lib/server/dashboard.test.ts @@ -0,0 +1,42 @@ +import { rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import { createDashboardStore, type DashboardStore } from "./db/dashboard-store"; +import { loadDashboardRuntime } from "./dashboard"; + +const stores: DashboardStore[] = []; +const tempRoots: string[] = []; + +afterEach(() => { + stores.splice(0).forEach((store) => store.close()); + tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true })); +}); + +describe("dashboard runtime loader", () => { + test("seeds and loads the active dashboard from sqlite", async () => { + const store = await createTestStore(); + + const runtime = loadDashboardRuntime(store); + + expect(runtime.title).toBe(dimensionLabDashboardFixture.metadata.title); + expect(runtime.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle); + expect(runtime.status).toBe("building"); + expect(runtime.schemaVersion).toBe(dimensionLabDashboardFixture.schemaVersion); + expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId); + expect(store.listRevisions()).toHaveLength(1); + }); +}); + +async function createTestStore() { + const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-runtime-")); + tempRoots.push(root); + const store = createDashboardStore({ + databaseUrl: `file:${join(root, "dashboard.sqlite")}`, + }); + stores.push(store); + + return store; +} diff --git a/src/lib/server/dashboard.ts b/src/lib/server/dashboard.ts index 0dcd533..244e97b 100644 --- a/src/lib/server/dashboard.ts +++ b/src/lib/server/dashboard.ts @@ -1,3 +1,9 @@ +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import { + createDashboardStore, + type DashboardStore, +} from "$lib/server/db/dashboard-store"; + export type PlaceholderStatus = "ready" | "building"; export interface PlaceholderDashboard { @@ -5,6 +11,8 @@ export interface PlaceholderDashboard { subtitle: string; status: PlaceholderStatus; message: string; + schemaVersion?: string; + currentRevisionId?: string; } export function loadPlaceholderDashboard(): PlaceholderDashboard { @@ -16,3 +24,29 @@ export function loadPlaceholderDashboard(): PlaceholderDashboard { "SvelteKit is running. Later issues will replace this placeholder with the validated dashboard model.", }; } + +export function loadDashboardRuntime(store?: DashboardStore): PlaceholderDashboard { + const dashboardStore = store || createDashboardStore(); + + try { + const seeded = dashboardStore.seedDashboardIfEmpty(dimensionLabDashboardFixture, { + actor: "dimensionlab-seed", + message: "load initial dashboard seed", + }); + const active = dashboardStore.getActiveDashboard(); + const document = active?.document || seeded.document; + const currentRevisionId = active?.currentRevisionId || seeded.id; + + return { + title: document.metadata.title, + subtitle: document.metadata.subtitle || "", + status: "building", + message: + "Active dashboard document loaded from SQLite. Later issues will replace this placeholder with model-driven rendering.", + schemaVersion: document.schemaVersion, + currentRevisionId, + }; + } finally { + if (!store) dashboardStore.close(); + } +} diff --git a/src/lib/server/db/connection.ts b/src/lib/server/db/connection.ts new file mode 100644 index 0000000..188da4c --- /dev/null +++ b/src/lib/server/db/connection.ts @@ -0,0 +1,47 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { Database } from "bun:sqlite"; +import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; +import { dashboardDbSchema } from "./schema"; +import { applyDashboardMigrations } from "./migrations"; + +export const DEFAULT_DATABASE_URL = "file:./data/dimensionlab.sqlite"; + +export type DashboardDatabase = BunSQLiteDatabase & { + $client: Database; +}; + +export interface DashboardDatabaseConnection { + db: DashboardDatabase; + sqlite: Database; + filename: string; + close(): void; +} + +export function openDashboardDatabase( + databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL, +): DashboardDatabaseConnection { + const filename = resolveSqliteFilename(databaseUrl); + mkdirSync(dirname(filename), { recursive: true }); + + const sqlite = new Database(filename, { create: true, readwrite: true }); + const db = drizzle(sqlite, { schema: dashboardDbSchema }) as DashboardDatabase; + applyDashboardMigrations(db); + + return { + db, + sqlite, + filename, + close() { + sqlite.close(); + }, + }; +} + +export function resolveSqliteFilename(databaseUrl: string): string { + if (!databaseUrl.startsWith("file:")) { + throw new Error(`Only file: SQLite DATABASE_URL values are supported: ${databaseUrl}`); + } + + return resolve(databaseUrl.slice("file:".length)); +} diff --git a/src/lib/server/db/dashboard-store.test.ts b/src/lib/server/db/dashboard-store.test.ts new file mode 100644 index 0000000..ecb80f3 --- /dev/null +++ b/src/lib/server/db/dashboard-store.test.ts @@ -0,0 +1,205 @@ +import { existsSync, rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import type { DashboardDocument } from "$lib/model"; +import { + DashboardPersistenceValidationError, + createDashboardStore, + type DashboardStore, +} from "./dashboard-store"; + +const stores: DashboardStore[] = []; +const tempRoots: string[] = []; +const originalCwd = process.cwd(); + +afterEach(() => { + vi.useRealTimers(); + process.chdir(originalCwd); + stores.splice(0).forEach((store) => store.close()); + tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true })); +}); + +describe("dashboard persistence store", () => { + test("seeds the first active dashboard and creates the sqlite file", async () => { + const { dbPath, store } = await createTestStore(); + + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "test-seed", + message: "initial fixture", + }); + + expect(existsSync(dbPath)).toBe(true); + expect(seed.operation).toBe("seed"); + expect(seed.actor).toBe("test-seed"); + expect(seed.schemaVersion).toBe(genericDashboardFixture.schemaVersion); + expect(seed.document.metadata.title).toBe("Operations Console"); + + const active = store.getActiveDashboard(); + expect(active?.currentRevisionId).toBe(seed.id); + expect(active?.document.metadata.title).toBe("Operations Console"); + expect(store.listRevisions()).toHaveLength(1); + }); + + test("commits validated updates and records revision metadata", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "test-seed", + }); + const updated = cloneDashboard({ + ...genericDashboardFixture, + metadata: { + ...genericDashboardFixture.metadata, + title: "Operations Console Updated", + }, + }); + + const revision = store.commitDashboard(updated, { + actor: "agent", + message: "rename dashboard", + }); + + expect(revision.operation).toBe("commit"); + expect(revision.actor).toBe("agent"); + expect(revision.message).toBe("rename dashboard"); + expect(revision.document.metadata.title).toBe("Operations Console Updated"); + expect(store.getActiveDashboard()?.currentRevisionId).toBe(revision.id); + expect(store.getRevision(seed.id)?.document.metadata.title).toBe("Operations Console"); + expect(store.listRevisions().map((item) => item.id)).toEqual([ + revision.id, + seed.id, + ]); + }); + + test("rejects invalid writes without changing the active revision", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "test-seed", + }); + const invalid = cloneDashboard({ + ...genericDashboardFixture, + layout: { + ...genericDashboardFixture.layout, + telemetry: ["missing-card"], + }, + }); + + expect(() => + store.commitDashboard(invalid, { + actor: "agent", + message: "invalid update", + }), + ).toThrow(DashboardPersistenceValidationError); + + const active = store.getActiveDashboard(); + expect(active?.currentRevisionId).toBe(seed.id); + expect(active?.document.layout.telemetry).toEqual(genericDashboardFixture.layout.telemetry); + expect(store.listRevisions()).toHaveLength(1); + }); + + test("loads specific revisions and rolls back to a prior valid document", async () => { + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "test-seed", + }); + const updated = cloneDashboard({ + ...genericDashboardFixture, + metadata: { + ...genericDashboardFixture.metadata, + title: "Changed Dashboard", + }, + }); + const change = store.commitDashboard(updated, { + actor: "agent", + message: "change title", + }); + + const rollback = store.rollbackToRevision(seed.id, { + actor: "operator", + message: "restore seed", + }); + + expect(store.getRevision(change.id)?.document.metadata.title).toBe("Changed Dashboard"); + expect(rollback.operation).toBe("rollback"); + expect(rollback.sourceRevisionId).toBe(seed.id); + expect(rollback.document.metadata.title).toBe("Operations Console"); + expect(store.getActiveDashboard()?.currentRevisionId).toBe(rollback.id); + expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console"); + expect(store.listRevisions().map((item) => item.operation)).toEqual([ + "rollback", + "commit", + "seed", + ]); + }); + + test("assigns monotonic revision timestamps for stable history ordering", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-18T12:00:00.000Z")); + const { store } = await createTestStore(); + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "test-seed", + }); + const updated = cloneDashboard({ + ...genericDashboardFixture, + metadata: { + ...genericDashboardFixture.metadata, + title: "Changed Dashboard", + }, + }); + + const commit = store.commitDashboard(updated, { actor: "agent" }); + const rollback = store.rollbackToRevision(seed.id, { actor: "operator" }); + + expect([ + seed.createdAt.toISOString(), + commit.createdAt.toISOString(), + rollback.createdAt.toISOString(), + ]).toEqual([ + "2026-06-18T12:00:00.000Z", + "2026-06-18T12:00:00.001Z", + "2026-06-18T12:00:00.002Z", + ]); + expect(store.listRevisions().map((item) => item.id)).toEqual([ + rollback.id, + commit.id, + seed.id, + ]); + }); + + test("applies migrations when launched outside the repository root", async () => { + const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-cwd-")); + tempRoots.push(root); + process.chdir(root); + vi.resetModules(); + const { createDashboardStore: createStore } = await import("./dashboard-store"); + const store = createStore({ + databaseUrl: `file:${join(root, "dashboard.sqlite")}`, + }); + stores.push(store); + + const seed = store.seedDashboardIfEmpty(genericDashboardFixture, { + actor: "test-seed", + }); + + expect(seed.document.metadata.title).toBe("Operations Console"); + expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id); + }); +}); + +async function createTestStore() { + const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-store-")); + tempRoots.push(root); + const dbPath = join(root, "nested", "dashboard.sqlite"); + const store = createDashboardStore({ + databaseUrl: `file:${dbPath}`, + }); + stores.push(store); + + return { dbPath, store }; +} + +function cloneDashboard(document: DashboardDocument): DashboardDocument { + return structuredClone(document); +} diff --git a/src/lib/server/db/dashboard-store.ts b/src/lib/server/db/dashboard-store.ts new file mode 100644 index 0000000..92c7a73 --- /dev/null +++ b/src/lib/server/db/dashboard-store.ts @@ -0,0 +1,261 @@ +import { randomUUID } from "node:crypto"; +import { desc, eq } from "drizzle-orm"; +import { + type DashboardDocument, + type DashboardValidationFailure, +} from "$lib/model"; +import { + type DashboardDatabaseConnection, + openDashboardDatabase, +} from "./connection"; +import { + dashboardDocuments, + dashboardRevisions, + type DashboardRevisionOperation, +} from "./schema"; +import { migrateDashboardDocumentForPersistence } from "./model-migrations"; + +const DEFAULT_DASHBOARD_ID = "primary"; +const DEFAULT_ACTOR = "system"; + +export interface DashboardRevision { + id: string; + dashboardId: string; + schemaVersion: string; + document: DashboardDocument; + actor: string; + message: string | null; + operation: DashboardRevisionOperation; + sourceRevisionId: string | null; + createdAt: Date; +} + +export interface ActiveDashboard { + dashboardId: string; + currentRevisionId: string; + document: DashboardDocument; + revision: DashboardRevision; + createdAt: Date; + updatedAt: Date; +} + +export interface DashboardWriteMetadata { + actor?: string; + message?: string; +} + +export interface DashboardStoreOptions { + databaseUrl?: string; + dashboardId?: string; +} + +export interface DashboardStore { + getActiveDashboard(): ActiveDashboard | null; + getRevision(revisionId: string): DashboardRevision | null; + listRevisions(limit?: number): DashboardRevision[]; + seedDashboardIfEmpty( + document: DashboardDocument, + metadata?: DashboardWriteMetadata, + ): DashboardRevision; + commitDashboard( + document: DashboardDocument, + metadata?: DashboardWriteMetadata, + ): DashboardRevision; + rollbackToRevision( + revisionId: string, + metadata?: DashboardWriteMetadata, + ): DashboardRevision; + close(): void; +} + +export class DashboardPersistenceValidationError extends Error { + readonly failure: DashboardValidationFailure; + + constructor(failure: DashboardValidationFailure) { + super(`Invalid dashboard document: ${failure.errors.join("; ")}`); + this.name = "DashboardPersistenceValidationError"; + this.failure = failure; + } +} + +export class DashboardRevisionNotFoundError extends Error { + constructor(revisionId: string) { + super(`Dashboard revision not found: ${revisionId}`); + this.name = "DashboardRevisionNotFoundError"; + } +} + +export function createDashboardStore( + options: DashboardStoreOptions = {}, +): DashboardStore { + const connection = openDashboardDatabase(options.databaseUrl); + return new SqliteDashboardStore(connection, options.dashboardId || DEFAULT_DASHBOARD_ID); +} + +class SqliteDashboardStore implements DashboardStore { + constructor( + private readonly connection: DashboardDatabaseConnection, + private readonly dashboardId: string, + ) {} + + getActiveDashboard(): ActiveDashboard | null { + const dashboard = this.connection.db + .select() + .from(dashboardDocuments) + .where(eq(dashboardDocuments.id, this.dashboardId)) + .get(); + + if (!dashboard?.currentRevisionId) return null; + + const revision = this.getRevision(dashboard.currentRevisionId); + if (!revision) return null; + + return { + dashboardId: dashboard.id, + currentRevisionId: dashboard.currentRevisionId, + document: revision.document, + revision, + createdAt: dashboard.createdAt, + updatedAt: dashboard.updatedAt, + }; + } + + getRevision(revisionId: string): DashboardRevision | null { + const row = this.connection.db + .select() + .from(dashboardRevisions) + .where(eq(dashboardRevisions.id, revisionId)) + .get(); + + return row ? toRevision(row) : null; + } + + listRevisions(limit = 50): DashboardRevision[] { + return this.connection.db + .select() + .from(dashboardRevisions) + .where(eq(dashboardRevisions.dashboardId, this.dashboardId)) + .orderBy(desc(dashboardRevisions.createdAt)) + .limit(limit) + .all() + .map(toRevision); + } + + seedDashboardIfEmpty( + document: DashboardDocument, + metadata: DashboardWriteMetadata = {}, + ): DashboardRevision { + const active = this.getActiveDashboard(); + if (active) return active.revision; + + return this.writeRevision(document, "seed", metadata); + } + + commitDashboard( + document: DashboardDocument, + metadata: DashboardWriteMetadata = {}, + ): DashboardRevision { + return this.writeRevision(document, "commit", metadata); + } + + rollbackToRevision( + revisionId: string, + metadata: DashboardWriteMetadata = {}, + ): DashboardRevision { + const revision = this.getRevision(revisionId); + if (!revision || revision.dashboardId !== this.dashboardId) { + throw new DashboardRevisionNotFoundError(revisionId); + } + + return this.writeRevision(revision.document, "rollback", metadata, revision.id); + } + + close() { + this.connection.close(); + } + + private writeRevision( + document: DashboardDocument, + operation: DashboardRevisionOperation, + metadata: DashboardWriteMetadata, + sourceRevisionId: string | null = null, + ): DashboardRevision { + const migration = migrateDashboardDocumentForPersistence(document); + if (!migration.valid) { + throw new DashboardPersistenceValidationError(migration.failure); + } + + const now = this.nextRevisionTimestamp(); + const revision: DashboardRevision = { + id: randomUUID(), + dashboardId: this.dashboardId, + schemaVersion: migration.document.schemaVersion, + document: structuredClone(migration.document), + actor: metadata.actor || DEFAULT_ACTOR, + message: metadata.message || null, + operation, + sourceRevisionId, + createdAt: now, + }; + + this.connection.db.transaction((tx) => { + tx.insert(dashboardRevisions).values({ + id: revision.id, + dashboardId: revision.dashboardId, + schemaVersion: revision.schemaVersion, + document: revision.document, + actor: revision.actor, + message: revision.message, + operation: revision.operation, + sourceRevisionId: revision.sourceRevisionId, + createdAt: revision.createdAt, + }).run(); + + tx.insert(dashboardDocuments) + .values({ + id: this.dashboardId, + currentRevisionId: revision.id, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: dashboardDocuments.id, + set: { + currentRevisionId: revision.id, + updatedAt: now, + }, + }) + .run(); + }); + + return revision; + } + + private nextRevisionTimestamp(): Date { + const active = this.getActiveDashboard(); + const activeUpdatedAtMs = active?.updatedAt.getTime() || 0; + + return new Date(Math.max(Date.now(), activeUpdatedAtMs + 1)); + } +} + +type DashboardRevisionRow = typeof dashboardRevisions.$inferSelect; + +function toRevision(row: DashboardRevisionRow): DashboardRevision { + const migration = migrateDashboardDocumentForPersistence(row.document); + if (!migration.valid) { + throw new DashboardPersistenceValidationError(migration.failure); + } + + return { + id: row.id, + dashboardId: row.dashboardId, + schemaVersion: row.schemaVersion, + document: migration.document, + actor: row.actor, + message: row.message, + operation: row.operation as DashboardRevisionOperation, + sourceRevisionId: row.sourceRevisionId, + createdAt: row.createdAt, + }; +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts new file mode 100644 index 0000000..9e34ba0 --- /dev/null +++ b/src/lib/server/db/migrations.ts @@ -0,0 +1,52 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; + +const MIGRATION_JOURNAL_PATH = join("meta", "_journal.json"); + +export function applyDashboardMigrations( + database: BunSQLiteDatabase>, + migrationsFolder = resolveDashboardMigrationsFolder(), +) { + migrate(database, { migrationsFolder }); +} + +export function resolveDashboardMigrationsFolder( + explicitFolder = process.env.DASHBOARD_MIGRATIONS_DIR, +): string { + if (explicitFolder) return assertMigrationFolder(resolve(explicitFolder)); + + const cwdFolder = findMigrationFolder(process.cwd()); + if (cwdFolder) return cwdFolder; + + const moduleFolder = findMigrationFolder(dirname(fileURLToPath(import.meta.url))); + if (moduleFolder) return moduleFolder; + + throw new Error( + "Unable to locate dashboard Drizzle migrations. Set DASHBOARD_MIGRATIONS_DIR.", + ); +} + +function findMigrationFolder(startPath: string): string | null { + let currentPath = resolve(startPath); + + while (true) { + const candidate = join(currentPath, "drizzle"); + if (hasMigrationJournal(candidate)) return candidate; + + const parentPath = dirname(currentPath); + if (parentPath === currentPath) return null; + currentPath = parentPath; + } +} + +function assertMigrationFolder(folder: string): string { + if (hasMigrationJournal(folder)) return folder; + throw new Error(`Dashboard migrations not found in ${folder}`); +} + +function hasMigrationJournal(folder: string): boolean { + return existsSync(join(folder, MIGRATION_JOURNAL_PATH)); +} diff --git a/src/lib/server/db/model-migrations.test.ts b/src/lib/server/db/model-migrations.test.ts new file mode 100644 index 0000000..eb16b8f --- /dev/null +++ b/src/lib/server/db/model-migrations.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { DASHBOARD_SCHEMA_VERSION } from "$lib/model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import { + UnsupportedDashboardModelVersionError, + migrateDashboardDocumentForPersistence, +} from "./model-migrations"; + +describe("dashboard model migrations", () => { + test("accepts the current dashboard model version without migration", () => { + const result = migrateDashboardDocumentForPersistence(genericDashboardFixture); + + expect(result.valid).toBe(true); + if (!result.valid) throw new Error("expected current fixture to be valid"); + expect(result.document).toEqual(genericDashboardFixture); + expect(result.fromVersion).toBe(DASHBOARD_SCHEMA_VERSION); + expect(result.toVersion).toBe(DASHBOARD_SCHEMA_VERSION); + expect(result.migrated).toBe(false); + }); + + test("fails unsupported model versions with an explicit migration error", () => { + const previousVersion = { + ...genericDashboardFixture, + schemaVersion: "dashboard.v0", + }; + + expect(() => migrateDashboardDocumentForPersistence(previousVersion)).toThrow( + UnsupportedDashboardModelVersionError, + ); + }); +}); diff --git a/src/lib/server/db/model-migrations.ts b/src/lib/server/db/model-migrations.ts new file mode 100644 index 0000000..92550e6 --- /dev/null +++ b/src/lib/server/db/model-migrations.ts @@ -0,0 +1,62 @@ +import { + DASHBOARD_SCHEMA_VERSION, + validateDashboardDocument, + type DashboardDocument, + type DashboardValidationFailure, +} from "$lib/model"; + +export interface DashboardModelMigrationSuccess { + valid: true; + document: DashboardDocument; + fromVersion: string; + toVersion: typeof DASHBOARD_SCHEMA_VERSION; + migrated: boolean; +} + +export interface DashboardModelMigrationFailure { + valid: false; + failure: DashboardValidationFailure; +} + +export type DashboardModelMigrationResult = + | DashboardModelMigrationFailure + | DashboardModelMigrationSuccess; + +export class UnsupportedDashboardModelVersionError extends Error { + constructor( + readonly fromVersion: string, + readonly toVersion: string, + ) { + super(`No dashboard model migration from ${fromVersion} to ${toVersion}`); + this.name = "UnsupportedDashboardModelVersionError"; + } +} + +export function migrateDashboardDocumentForPersistence( + value: unknown, +): DashboardModelMigrationResult { + const version = readSchemaVersion(value); + if (version && version !== DASHBOARD_SCHEMA_VERSION) { + throw new UnsupportedDashboardModelVersionError(version, DASHBOARD_SCHEMA_VERSION); + } + + const validation = validateDashboardDocument(value); + if (!validation.valid) { + return { valid: false, failure: validation }; + } + + return { + valid: true, + document: validation.data, + fromVersion: DASHBOARD_SCHEMA_VERSION, + toVersion: DASHBOARD_SCHEMA_VERSION, + migrated: false, + }; +} + +function readSchemaVersion(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + + const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion; + return typeof schemaVersion === "string" ? schemaVersion : null; +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts new file mode 100644 index 0000000..c148035 --- /dev/null +++ b/src/lib/server/db/schema.ts @@ -0,0 +1,39 @@ +import type { DashboardDocument } from "$lib/model"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const dashboardDocuments = sqliteTable("dashboard_documents", { + id: text("id").primaryKey(), + currentRevisionId: text("current_revision_id"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), +}); + +export const dashboardRevisions = sqliteTable( + "dashboard_revisions", + { + id: text("id").primaryKey(), + dashboardId: text("dashboard_id").notNull(), + schemaVersion: text("schema_version").notNull(), + document: text("document", { mode: "json" }).$type().notNull(), + actor: text("actor").notNull(), + message: text("message"), + operation: text("operation", { + enum: ["seed", "commit", "rollback"], + }).notNull(), + sourceRevisionId: text("source_revision_id"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [ + index("idx_dashboard_revisions_dashboard_created").on( + table.dashboardId, + table.createdAt, + ), + ], +); + +export const dashboardDbSchema = { + dashboardDocuments, + dashboardRevisions, +}; + +export type DashboardRevisionOperation = "seed" | "commit" | "rollback"; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 2a54c0c..114bfe5 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,7 +1,7 @@ -import { loadPlaceholderDashboard } from "$lib/server/dashboard"; +import { loadDashboardRuntime } from "$lib/server/dashboard"; export function load() { return { - dashboard: loadPlaceholderDashboard(), + dashboard: loadDashboardRuntime(), }; } diff --git a/tsconfig.json b/tsconfig.json index 4344710..c08a57f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["node", "bun-types"] } } -- 2.49.1