diff --git a/README.md b/README.md index d6136d9..3c067ed 100644 --- a/README.md +++ b/README.md @@ -45,17 +45,18 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config ## Packages -| Package | What it is | -| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | -| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | -| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | -| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | -| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | -| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. | -| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues as bound activities, `blockedBy` as transitions, sub-issues, repositories, organisations and assignees, across organisations and repositories. | -| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items from a source, structural ready, blocked-chain and impact queries, and a change feed; with an in-memory fake and a conformance kit. | -| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | +| Package | What it is | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | +| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | +| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | +| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | +| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | +| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. | +| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues as bound activities, `blockedBy` as transitions, sub-issues, repositories, organisations and assignees, across organisations and repositories. | +| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items from a source, structural ready, blocked-chain and impact queries, and a change feed; with an in-memory fake and a conformance kit. | +| [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. | +| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | ## Conventions diff --git a/packages/adapters/graph/store-sqlite/.gitignore b/packages/adapters/graph/store-sqlite/.gitignore new file mode 100644 index 0000000..d3db8f9 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/.gitignore @@ -0,0 +1 @@ +src/generated/ diff --git a/packages/adapters/graph/store-sqlite/AGENTS.md b/packages/adapters/graph/store-sqlite/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/adapters/graph/store-sqlite/CLAUDE.md b/packages/adapters/graph/store-sqlite/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/adapters/graph/store-sqlite/README.md b/packages/adapters/graph/store-sqlite/README.md new file mode 100644 index 0000000..d293c24 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/README.md @@ -0,0 +1,39 @@ +# @exadev/process-graph-store-sqlite + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-store-sqlite) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +The `GraphStore` port from `@exadev/process-graph-store-contract` on SQLite through [Drizzle](https://orm.drizzle.team) (`drizzle-orm` 1.0.0-rc.4). It is written against Drizzle's async SQLite database type, so any Drizzle SQLite driver plugs in: `@exadev/process-graph-sqlite-node` supplies `node:sqlite`, `@exadev/process-graph-sqlite-wasm` supplies SQLite in a browser worker on OPFS. `src/` imports nothing Node-only. + +```ts +import { createGraphStoreRouter } from "@exadev/process-graph-store-contract"; +import { createSqliteGraphStore } from "@exadev/process-graph-store-sqlite"; +import { createRouterClient } from "@orpc/server"; + +const store = createRouterClient( + createGraphStoreRouter(createSqliteGraphStore({ db })), +); +``` + +## Schema + +`src/tables.ts` is the one table definition. Every record a model declares is a row keyed by model id and record id, with a `position` column keeping the declared order; enumerated columns take their values from graph-schema's own vocabulary, and nested values are JSON text. Rows never reach the contract: `src/rows.ts` flattens a model into rows and reassembles it, parsing the result with graph-schema's `modelSchema`, so what leaves the store is checked to be a model. A unit test derives the row schemas with `drizzle-orm/zod` and asserts they agree with the model schema on every shared scalar field. + +Each transition row records whether graph-domain's `loopTransitions` counts it as a decision's loop edge, so the structural queries can leave loop edges out without loading the model. No foreign keys are declared, because SQLite enforces them only on connections that enable them; the store writes and deletes a model's rows together in one transaction instead. + +## Migrations + +The SQL migrations under `drizzle/` are generated by drizzle-kit from `src/tables.ts` and committed: once data exists they are the schema's history. After changing the tables, run `pnpm run migrations:generate` and commit the new folder. + +The build embeds them in the package (`scripts/embed-migrations.ts`, parsed by drizzle's own `readMigrationFiles`; the generated module is build output and never committed), and `migrate` applies the missing ones in one transaction, recording each in drizzle's `__drizzle_migrations` journal by the same name and hash drizzle's migrators use. The store migrates on its first operation. Drizzle's own migrators cannot serve every runtime: `drizzle-orm/migrator` reads the folder through `node:fs`, which a browser worker does not have, and `migrateAsync` reads its journal in object mode, which the `sqlite-proxy` session refuses. A unit test checks that `migrate` builds the same schema and journal as drizzle's `node-sqlite` migrator reading the committed folder, and that each finds nothing left to apply after the other. The decision and the evidence are in [#22](https://github.com/ExaDev/process-graph/issues/22). + +## Writes and queries + +Every write runs `checkStored` from the contract before it commits, takes the next value of the store-wide revision sequence, rewrites the model's rows in one transaction and publishes the change to the store's `ChangeFeed` once it has committed. A failed check or an aborted signal rolls the transaction back. + +`query.ready`, `query.blockedChain` and `query.impact` are single `WITH RECURSIVE` queries through Drizzle's `sql` template over `transitions`, `elements` and `overlay`, with the contract's semantics (control elements passed through, loop edges left out, traversal stopping at done work). The walks cap distances at the model's element count, which is longer than any shortest path and ends the recursion on a cycle no decision closes. + +Transactions are opened with plain `BEGIN IMMEDIATE`, `COMMIT` and `ROLLBACK` statements rather than Drizzle's `transaction`, whose callback has to be synchronous on a synchronous driver and asynchronous on an async one. Each store operation therefore runs with the connection to itself: by default the store serialises its own operations, and a host that shares the database across contexts passes `exclusive` to hold its cross-context lock as well, and `feed` to bridge the other contexts' changes. + +## Tests + +The contract's conformance kit runs against the store over `node:sqlite` in memory, and further tests cover reopening a database file, the migrations and the row schemas. diff --git a/packages/adapters/graph/store-sqlite/drizzle.config.ts b/packages/adapters/graph/store-sqlite/drizzle.config.ts new file mode 100644 index 0000000..37ad425 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "drizzle-kit"; + +/** drizzle-kit reads the one table definition and writes each migration as SQL under `drizzle/`, which is committed: once data exists, the migrations are the schema's history and cannot be rebuilt from the schema alone. */ +export default defineConfig({ + dialect: "sqlite", + schema: "./src/tables.ts", + out: "./drizzle", +}); diff --git a/packages/adapters/graph/store-sqlite/drizzle/20261010154229_init/migration.sql b/packages/adapters/graph/store-sqlite/drizzle/20261010154229_init/migration.sql new file mode 100644 index 0000000..909b664 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/drizzle/20261010154229_init/migration.sql @@ -0,0 +1,112 @@ +CREATE TABLE `actions` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `name` text NOT NULL, + CONSTRAINT `actions_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE TABLE `artefacts` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `name` text NOT NULL, + `deliverable` integer, + CONSTRAINT `artefacts_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE TABLE `elements` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `kind` text NOT NULL, + `label` text, + `actor` text, + `action` text, + `source` text, + `affects` text, + `consumes` text, + `produces` text, + `parent` text, + `entry` text, + `exit` text, + `role` text, + `from_actor` text, + `to_actor` text, + `mode` text, + `event` text, + `extensions` text, + CONSTRAINT `elements_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE TABLE `events` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `name` text NOT NULL, + `payload` text, + CONSTRAINT `events_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE TABLE `fragments` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `operator` text NOT NULL, + `operands` text NOT NULL, + CONSTRAINT `fragments_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE TABLE `models` ( + `id` text PRIMARY KEY, + `revision` integer NOT NULL, + `views` text +); +--> statement-breakpoint +CREATE TABLE `overlay` ( + `model_id` text NOT NULL, + `element` text NOT NULL, + `status` text NOT NULL, + CONSTRAINT `overlay_pk` PRIMARY KEY(`model_id`, `element`) +); +--> statement-breakpoint +CREATE TABLE `participants` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `label` text NOT NULL, + `kind` text NOT NULL, + CONSTRAINT `participants_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE TABLE `projections` ( + `model_id` text NOT NULL, + `position` integer NOT NULL, + `id` text NOT NULL, + `notation` text NOT NULL, + `subject` text, + `scenario` text, + CONSTRAINT `projections_pk` PRIMARY KEY(`model_id`, `position`) +); +--> statement-breakpoint +CREATE TABLE `sequences` ( + `name` text PRIMARY KEY, + `value` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `transitions` ( + `model_id` text NOT NULL, + `id` text NOT NULL, + `position` integer NOT NULL, + `source` text NOT NULL, + `target` text NOT NULL, + `trigger` text, + `guard` text, + `condition` text, + `effect` text, + `loop` integer NOT NULL, + CONSTRAINT `transitions_pk` PRIMARY KEY(`model_id`, `id`) +); +--> statement-breakpoint +CREATE INDEX `transitions_by_target` ON `transitions` (`model_id`,`target`);--> statement-breakpoint +CREATE INDEX `transitions_by_source` ON `transitions` (`model_id`,`source`); \ No newline at end of file diff --git a/packages/adapters/graph/store-sqlite/drizzle/20261010154229_init/snapshot.json b/packages/adapters/graph/store-sqlite/drizzle/20261010154229_init/snapshot.json new file mode 100644 index 0000000..e532095 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/drizzle/20261010154229_init/snapshot.json @@ -0,0 +1,846 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "b3109b41-ac6f-47ae-adbc-359bd373f3d3", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "name": "actions", + "entityType": "tables" + }, + { + "name": "artefacts", + "entityType": "tables" + }, + { + "name": "elements", + "entityType": "tables" + }, + { + "name": "events", + "entityType": "tables" + }, + { + "name": "fragments", + "entityType": "tables" + }, + { + "name": "models", + "entityType": "tables" + }, + { + "name": "overlay", + "entityType": "tables" + }, + { + "name": "participants", + "entityType": "tables" + }, + { + "name": "projections", + "entityType": "tables" + }, + { + "name": "sequences", + "entityType": "tables" + }, + { + "name": "transitions", + "entityType": "tables" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "actions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "actions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "actions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "actions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deliverable", + "entityType": "columns", + "table": "artefacts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "elements" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "affects", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "consumes", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "produces", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "entry", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "exit", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "from_actor", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "to_actor", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mode", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "event", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extensions", + "entityType": "columns", + "table": "elements" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "events" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "events" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operator", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operands", + "entityType": "columns", + "table": "fragments" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "models" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "models" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "views", + "entityType": "columns", + "table": "models" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "overlay" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "element", + "entityType": "columns", + "table": "overlay" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "overlay" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "participants" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "participants" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "projections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "notation", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scenario", + "entityType": "columns", + "table": "projections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "sequences" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "sequences" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trigger", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "guard", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "condition", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "effect", + "entityType": "columns", + "table": "transitions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "loop", + "entityType": "columns", + "table": "transitions" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "actions_pk", + "entityType": "pks", + "table": "actions" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "artefacts_pk", + "entityType": "pks", + "table": "artefacts" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "elements_pk", + "entityType": "pks", + "table": "elements" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "events_pk", + "entityType": "pks", + "table": "events" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "fragments_pk", + "entityType": "pks", + "table": "fragments" + }, + { + "columns": ["model_id", "element"], + "nameExplicit": false, + "name": "overlay_pk", + "entityType": "pks", + "table": "overlay" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "participants_pk", + "entityType": "pks", + "table": "participants" + }, + { + "columns": ["model_id", "position"], + "nameExplicit": false, + "name": "projections_pk", + "entityType": "pks", + "table": "projections" + }, + { + "columns": ["model_id", "id"], + "nameExplicit": false, + "name": "transitions_pk", + "entityType": "pks", + "table": "transitions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "models_pk", + "table": "models", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "sequences_pk", + "table": "sequences", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "model_id", + "isExpression": false + }, + { + "value": "target", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "transitions_by_target", + "entityType": "indexes", + "table": "transitions" + }, + { + "columns": [ + { + "value": "model_id", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "transitions_by_source", + "entityType": "indexes", + "table": "transitions" + } + ], + "renames": [] +} diff --git a/packages/adapters/graph/store-sqlite/eslint.config.ts b/packages/adapters/graph/store-sqlite/eslint.config.ts new file mode 100644 index 0000000..220f584 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/eslint.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "eslint/config"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + // Written by scripts/embed-migrations.ts from the committed drizzle/ folder; build output, not source. + additionalIgnores: ["src/generated"], + }), +); diff --git a/packages/adapters/graph/store-sqlite/package.json b/packages/adapters/graph/store-sqlite/package.json new file mode 100644 index 0000000..66e1a03 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/package.json @@ -0,0 +1,80 @@ +{ + "name": "@exadev/process-graph-store-sqlite", + "version": "0.0.0", + "description": "The GraphStore port on SQLite through Drizzle: one schema, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, written against Drizzle's async SQLite database so any driver plugs in.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/adapters/graph/store-sqlite" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/adapters/graph/store-sqlite#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "sideEffects": false, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "node scripts/embed-migrations.ts && tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only", + "test": "turbo run _test", + "_test": "node scripts/embed-migrations.ts && vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "node scripts/embed-migrations.ts && vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "node scripts/embed-migrations.ts && stryker run stryker.config.ts", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only", + "migrations:generate": "drizzle-kit generate" + }, + "keywords": [ + "process-model", + "store", + "sqlite", + "drizzle", + "orpc" + ], + "dependencies": { + "@exadev/process-graph-domain": "1.0.2", + "@exadev/process-graph-schema": "1.0.2", + "@exadev/process-graph-store-contract": "1.0.0", + "@orpc/server": "2.0.0-beta.41", + "drizzle-orm": "1.0.0-rc.4", + "zod": "4.6.5" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@types/node": "26.6.4", + "@vitest/coverage-v8": "5.0.3", + "drizzle-kit": "1.0.0-rc.4", + "eslint": "10.12.0", + "publint": "0.3.25", + "typescript": "6.0.3", + "vitest": "5.0.3" + } +} diff --git a/packages/adapters/graph/store-sqlite/scripts/embed-migrations.ts b/packages/adapters/graph/store-sqlite/scripts/embed-migrations.ts new file mode 100644 index 0000000..16aec94 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/scripts/embed-migrations.ts @@ -0,0 +1,25 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { readMigrationFiles } from "drizzle-orm/migrator"; + +/** + * Embeds the committed drizzle-kit migrations in the package as data, so every runtime applies the same statements without reading the filesystem: drizzle's own migrators read the `drizzle/` folder through `node:fs`, which a browser worker does not have. + * + * The migrations are parsed by drizzle's own `readMigrationFiles`, so the names, statement splits and hashes are exactly what drizzle's migrators would record. The output is build output, never committed; the `_build` and `_test` scripts run this first. + */ +const root = join(import.meta.dirname, ".."); +const migrations = readMigrationFiles({ + migrationsFolder: join(root, "drizzle"), +}); +const directory = join(root, "src", "generated"); +mkdirSync(directory, { recursive: true }); +writeFileSync( + join(directory, "migrations.ts"), + [ + "// Generated by scripts/embed-migrations.ts from drizzle/. Do not edit.", + 'import type { MigrationMeta } from "drizzle-orm/migrator";', + "", + `export const migrations: readonly MigrationMeta[] = ${JSON.stringify(migrations, null, 2)};`, + "", + ].join("\n"), +); diff --git a/packages/adapters/graph/store-sqlite/src/database.ts b/packages/adapters/graph/store-sqlite/src/database.ts new file mode 100644 index 0000000..79c93f4 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/database.ts @@ -0,0 +1,68 @@ +import { sql } from "drizzle-orm"; +import type { SQLiteAsyncDatabase } from "drizzle-orm/sqlite-core"; + +/** + * Any Drizzle SQLite database: the async drivers (`sqlite-proxy`, libSQL) and the synchronous ones Drizzle wraps in the same async API (`node-sqlite`, `better-sqlite3`). The store awaits every result, which covers both. + */ +export type SqliteDatabase = SQLiteAsyncDatabase<"sync" | "async", unknown>; + +/** + * Runs steps one after another, each starting once the one before has finished: the statements of one transaction, which must not overlap on a connection. + * @param steps - The steps, in order. + */ +export const sequentially = async ( + steps: readonly (() => Promise)[], +): Promise => { + await steps.reduce>(async (previous, step) => { + await previous; + + return step(); + }, Promise.resolve()); +}; + +/** + * Runs work inside one immediate transaction: committed when the work resolves, rolled back when it throws, and the error rethrown. + * + * The transaction is opened with plain statements rather than Drizzle's `transaction`, whose callback must be synchronous on a synchronous driver and asynchronous on an async one. The caller must hold the connection exclusively for the duration, so no other work interleaves between the statements. + * @param db - The database. + * @param work - The work to run. + * @returns What the work returns. + */ +export const inTransaction = async ( + db: SqliteDatabase, + work: () => Promise, +): Promise => { + await db.run(sql`BEGIN IMMEDIATE`); + try { + const result = await work(); + await db.run(sql`COMMIT`); + + return result; + } catch (error) { + await db.run(sql`ROLLBACK`); + throw error; + } +}; + +/** + * Runs one store operation with the database to itself. The store calls it around every operation, read or write, so the statements of one operation never interleave with another's on the same connection. + */ +export type Exclusive = (operation: () => Promise) => Promise; + +/** + * An `Exclusive` for one process: operations wait for the one before to settle, in call order. + * @returns The serialiser. + */ +export const serialised = (): Exclusive => { + let tail: Promise = Promise.resolve(); + + return async (operation: () => Promise): Promise => { + const result = tail.then(operation); + tail = result.then( + () => undefined, + () => undefined, + ); + + return result; + }; +}; diff --git a/packages/adapters/graph/store-sqlite/src/index.ts b/packages/adapters/graph/store-sqlite/src/index.ts new file mode 100644 index 0000000..d1edcb0 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/index.ts @@ -0,0 +1,10 @@ +/** + * The `GraphStore` port on SQLite through Drizzle. `createSqliteGraphStore` takes any Drizzle SQLite database (a driver package supplies one) and returns a store to serve with the contract's `createGraphStoreRouter`. The tables, their committed drizzle-kit migrations and `migrate` are exported for hosts and tools that work with the database directly. + */ +export { + createSqliteGraphStore, + type SqliteGraphStoreOptions, +} from "./store.ts"; +export { serialised, type Exclusive, type SqliteDatabase } from "./database.ts"; +export { migrate } from "./migrate.ts"; +export * as tables from "./tables.ts"; diff --git a/packages/adapters/graph/store-sqlite/src/migrate.ts b/packages/adapters/graph/store-sqlite/src/migrate.ts new file mode 100644 index 0000000..dbcb98a --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/migrate.ts @@ -0,0 +1,62 @@ +import { sql } from "drizzle-orm"; +import type { MigrationMeta } from "drizzle-orm/migrator"; +import { z } from "zod"; +import { + inTransaction, + sequentially, + type SqliteDatabase, +} from "./database.ts"; +import { migrations } from "./generated/migrations.ts"; + +/** The journal table drizzle's own migrators keep, so a database migrated here is one drizzle-kit recognises, and the reverse. */ +const journal = "__drizzle_migrations"; + +const appliedRow = z.tuple([z.string().nullable()]); + +/** What `migrate` applies and how it stamps the journal. */ +export interface MigrateOptions { + /** The migrations to apply where missing; the package's own by default. */ + readonly migrations?: readonly MigrationMeta[]; + /** The clock that stamps each journal row's `applied_at`; the system clock by default. */ + readonly now?: () => Date; +} + +/** + * Brings a database up to the package's schema by applying, in one transaction, every embedded drizzle-kit migration its journal does not name, and recording each in drizzle's `__drizzle_migrations` journal with the name, hash and folder time drizzle's own migrators record. A migration is matched by name, as drizzle's `getMigrationsToRun` matches it. + * + * Drizzle's migrators cannot serve every runtime: `drizzle-orm/migrator` reads the migrations folder through `node:fs`, and `migrateAsync` reads its journal in object mode, which the `sqlite-proxy` session refuses. This one reads the journal through `values`, which every async SQLite driver answers, and takes the migrations from the package itself, so a Node process and a browser worker apply exactly the same statements. + * @param db - The database to migrate. The caller holds it exclusively for the duration. + * @param options - What to apply and how to stamp it. + */ +export const migrate = async ( + db: SqliteDatabase, + options: MigrateOptions = {}, +): Promise => { + const { migrations: available = migrations, now = () => new Date() } = + options; + await db.run( + sql`CREATE TABLE IF NOT EXISTS ${sql.identifier(journal)} (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ); + const applied = new Set( + (await db.values(sql`SELECT name FROM ${sql.identifier(journal)}`)).map( + (row) => appliedRow.parse(row)[0], + ), + ); + const pending = available.filter(({ name }) => !applied.has(name)); + if (pending.length === 0) { + return; + } + + await inTransaction(db, async () => + sequentially( + pending + .flatMap((migration) => [ + ...migration.sql.map((statement) => sql.raw(statement)), + sql`INSERT INTO ${sql.identifier(journal)} ("hash", "created_at", "name", "applied_at") VALUES (${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${now().toISOString()})`, + ]) + .map((statement) => async () => { + await db.run(statement); + }), + ), + ); +}; diff --git a/packages/adapters/graph/store-sqlite/src/migrate.unit.test.ts b/packages/adapters/graph/store-sqlite/src/migrate.unit.test.ts new file mode 100644 index 0000000..89f74fa --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/migrate.unit.test.ts @@ -0,0 +1,62 @@ +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-sqlite"; +import { migrate as drizzleMigrate } from "drizzle-orm/node-sqlite/migrator"; +import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { migrate } from "./migrate.ts"; + +const migrationsFolder = join(import.meta.dirname, "..", "drizzle"); +const fixedClock = () => new Date("2000-01-01T00:00:00.000Z"); + +const fresh = () => drizzle({ client: new DatabaseSync(":memory:") }); + +/** Every table and index definition of the store's schema, in a stable order, and the journal's columns, whose DDL differs only in whitespace between drizzle's migrators and this one. */ +const schemaOf = (db: ReturnType) => ({ + definitions: db.values( + sql`SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND name <> '__drizzle_migrations' ORDER BY type, name`, + ), + journal: db.values( + sql`SELECT name, type, "notnull", pk FROM pragma_table_info('__drizzle_migrations') ORDER BY cid`, + ), +}); + +/** What each journal row records about its migration, leaving out when it was applied. */ +const journalOf = (db: ReturnType) => + db.values( + sql`SELECT name, hash, created_at FROM __drizzle_migrations ORDER BY id`, + ); + +describe("migrate", () => { + test("builds the same schema and journal as drizzle's own node-sqlite migrator from the committed folder", async () => { + const theirs = fresh(); + drizzleMigrate(theirs, { migrationsFolder }); + const ours = fresh(); + await migrate(ours, { now: fixedClock }); + + expect(schemaOf(ours)).toEqual(schemaOf(theirs)); + expect(journalOf(ours)).toEqual(journalOf(theirs)); + expect(journalOf(ours).length).toBeGreaterThan(0); + }); + + test("applies nothing a second time, and drizzle's migrator finds nothing left to apply after it", async () => { + const db = fresh(); + await migrate(db, { now: fixedClock }); + const journal = journalOf(db); + + await migrate(db, { now: fixedClock }); + drizzleMigrate(db, { migrationsFolder }); + + expect(journalOf(db)).toEqual(journal); + }); + + test("applies only what drizzle's migrator left out", async () => { + const db = fresh(); + drizzleMigrate(db, { migrationsFolder }); + const journal = journalOf(db); + + await migrate(db, { now: fixedClock }); + + expect(journalOf(db)).toEqual(journal); + }); +}); diff --git a/packages/adapters/graph/store-sqlite/src/queries.ts b/packages/adapters/graph/store-sqlite/src/queries.ts new file mode 100644 index 0000000..12dee6d --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/queries.ts @@ -0,0 +1,146 @@ +import type { Reach } from "@exadev/process-graph-store-contract"; +import { sql, type SQL } from "drizzle-orm"; +import { z } from "zod"; +import type { SqliteDatabase } from "./database.ts"; + +/** + * The structural queries of the `GraphStore` contract in SQL, each one recursive query over `transitions`, `elements` and `overlay` through Drizzle's `sql` template. They read the stored `loop` flag rather than the model, so they never load or evaluate it, and they answer exactly as the contract's reference implementation (`readyOf`, `blockedChainOf`, `impactOf`) does, which the conformance kit checks. + */ + +/** A query about one element of one model. */ +interface ElementQuery { + readonly modelId: string; + readonly element: string; +} + +/** The element kinds the queries pass through and never report. */ +const control = sql`('decision', 'pseudo')`; + +/** Whether the overlay records `element` (an SQL expression) as done in `modelId`. */ +const isDone = (modelId: string, element: SQL): SQL => + sql`EXISTS (SELECT 1 FROM overlay o WHERE o.model_id = ${modelId} AND o.element = ${element} AND o.status = 'done')`; + +const reachRow = z.tuple([z.string(), z.number().int().positive()]); +const idRow = z.tuple([z.string()]); + +/** Which way a walk follows transitions: from target to source (upstream) or from source to target (downstream). */ +interface Direction { + readonly from: SQL; + readonly to: SQL; +} + +const upstream: Direction = { from: sql.raw("target"), to: sql.raw("source") }; +const downstream: Direction = { + from: sql.raw("source"), + to: sql.raw("target"), +}; + +/** + * Every element not done and not a control element reachable from `element` along non-loop transitions in one direction, expanding only elements that are not done, with the fewest transitions it takes, by that distance and then model order. + * + * Distances are capped at the model's element count: no shortest path is longer, and the cap ends the recursion on a model whose transitions cycle without a decision. + */ +const walk = async ( + db: SqliteDatabase, + { modelId, element }: ElementQuery, + { from, to }: Direction, +): Promise => { + const rows = await db.values(sql` + WITH RECURSIVE + bound(n) AS (SELECT count(*) FROM elements WHERE model_id = ${modelId}), + walk(element, distance) AS ( + SELECT t.${to}, 1 FROM transitions t + WHERE t.model_id = ${modelId} AND t.${from} = ${element} AND t.loop = 0 + UNION + SELECT t.${to}, w.distance + 1 FROM walk w + JOIN transitions t ON t.model_id = ${modelId} AND t.${from} = w.element AND t.loop = 0 + WHERE w.distance < (SELECT n FROM bound) + AND NOT ${isDone(modelId, sql`w.element`)} + ) + SELECT w.element, min(w.distance) AS distance + FROM walk w + JOIN elements e ON e.model_id = ${modelId} AND e.id = w.element + WHERE w.element <> ${element} + AND e.kind NOT IN ${control} + AND NOT ${isDone(modelId, sql`w.element`)} + GROUP BY w.element, e.position + ORDER BY distance, e.position + `); + + return rows.map((row) => { + const [reached, distance] = reachRow.parse(row); + + return { element: reached, distance }; + }); +}; + +/** + * The unfinished work upstream of an element; nothing when the element is done. + * @param db - The database. + * @param request - The model and the element, which the caller has checked exist. + * @returns The chain, by distance and then model order. + */ +export const blockedChain = async ( + db: SqliteDatabase, + request: ElementQuery, +): Promise => { + const [done] = await db.values( + sql`SELECT ${isDone(request.modelId, sql`${request.element}`)}`, + ); + + return z.tuple([z.number()]).parse(done)[0] === 1 + ? [] + : walk(db, request, upstream); +}; + +/** + * The unfinished work downstream of an element, whatever its own state. + * @param db - The database. + * @param request - The model and the element, which the caller has checked exist. + * @returns The impacted elements, by distance and then model order. + */ +export const impact = async ( + db: SqliteDatabase, + request: ElementQuery, +): Promise => walk(db, request, downstream); + +/** + * The work that can start now: every element that is not a control element, whose run state is absent or `open`, and from which no unfinished work is reachable upstream through control elements that are not done. That is exactly "its blocked chain is empty": the first unfinished work on any upstream path is reached through control elements alone. + * @param db - The database. + * @param modelId - The model, which the caller has checked exists. + * @returns The ready elements, in model order. + */ +export const ready = async ( + db: SqliteDatabase, + modelId: string, +): Promise => { + const rows = await db.values(sql` + WITH RECURSIVE + behind(start, element) AS ( + SELECT t.target, t.source FROM transitions t + WHERE t.model_id = ${modelId} AND t.loop = 0 + UNION + SELECT b.start, t.source FROM behind b + JOIN elements c ON c.model_id = ${modelId} AND c.id = b.element AND c.kind IN ${control} + JOIN transitions t ON t.model_id = ${modelId} AND t.target = b.element AND t.loop = 0 + WHERE NOT ${isDone(modelId, sql`b.element`)} + ) + SELECT e.id FROM elements e + WHERE e.model_id = ${modelId} + AND e.kind NOT IN ${control} + AND NOT EXISTS ( + SELECT 1 FROM overlay o + WHERE o.model_id = ${modelId} AND o.element = e.id AND o.status IN ('done', 'unknown') + ) + AND NOT EXISTS ( + SELECT 1 FROM behind b + JOIN elements p ON p.model_id = ${modelId} AND p.id = b.element + WHERE b.start = e.id + AND p.kind NOT IN ${control} + AND NOT ${isDone(modelId, sql`b.element`)} + ) + ORDER BY e.position + `); + + return rows.map((row) => idRow.parse(row)[0]); +}; diff --git a/packages/adapters/graph/store-sqlite/src/rows.ts b/packages/adapters/graph/store-sqlite/src/rows.ts new file mode 100644 index 0000000..e487b28 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/rows.ts @@ -0,0 +1,273 @@ +import { + modelSchema, + overlaySchema, + type ElementKind, + type Model, + type OpenIds, + type Overlay, +} from "@exadev/process-graph-schema"; +import type { + actions, + artefacts, + elements, + events, + fragments, + models, + overlay, + participants, + projections, + transitions, +} from "./tables.ts"; + +/** + * The mapping layer between the model and its rows. Writing flattens each record into its table's columns, with an absent optional field stored as null. Reading puts back only the fields that are not null and then parses the result with graph-schema's `modelSchema`, so what leaves the store is checked to be a model and storage layout never reaches the contract. + */ + +/** Every row of one model, table by table, in the model's declared order. */ +export interface ModelRows { + readonly model: typeof models.$inferSelect; + readonly participants: readonly (typeof participants.$inferSelect)[]; + readonly events: readonly (typeof events.$inferSelect)[]; + readonly actions: readonly (typeof actions.$inferSelect)[]; + readonly artefacts: readonly (typeof artefacts.$inferSelect)[]; + readonly elements: readonly (typeof elements.$inferSelect)[]; + readonly transitions: readonly (typeof transitions.$inferSelect)[]; + readonly fragments: readonly (typeof fragments.$inferSelect)[]; + readonly projections: readonly (typeof projections.$inferSelect)[]; +} + +/** The rows to insert for one model, table by table. */ +export interface ModelInserts { + readonly model: typeof models.$inferInsert; + readonly participants: readonly (typeof participants.$inferInsert)[]; + readonly events: readonly (typeof events.$inferInsert)[]; + readonly actions: readonly (typeof actions.$inferInsert)[]; + readonly artefacts: readonly (typeof artefacts.$inferInsert)[]; + readonly elements: readonly (typeof elements.$inferInsert)[]; + readonly transitions: readonly (typeof transitions.$inferInsert)[]; + readonly fragments: readonly (typeof fragments.$inferInsert)[]; + readonly projections: readonly (typeof projections.$inferInsert)[]; +} + +/** A field that is present only when its column holds a value. Its type is loose on purpose: `fromRows` hands everything to `modelSchema`, which is the check. */ +const present = ( + key: string, + value: V | null, +): Readonly> => + value === null ? {} : Object.fromEntries([[key, value]]); + +/** + * Flattens a model into rows. + * @param modelId - The model's id in the store. + * @param model - A valid model. + * @param revision - The revision this write takes. + * @param loops - The ids of the transitions graph-domain counts as loop edges. + * @returns The rows to insert. + */ +export const toRows = ( + modelId: string, + model: Model, + revision: number, + loops: ReadonlySet, +): ModelInserts => ({ + model: { id: modelId, revision, views: model.views ?? null }, + participants: model.participants.map(({ id, label, kind }, position) => ({ + modelId, + id, + position, + label, + kind, + })), + events: model.events.map(({ id, name, payload }, position) => ({ + modelId, + id, + position, + name, + payload: payload ?? null, + })), + actions: model.actions.map(({ id, name }, position) => ({ + modelId, + id, + position, + name, + })), + artefacts: model.artefacts.map(({ id, name, deliverable }, position) => ({ + modelId, + id, + position, + name, + deliverable: deliverable ?? null, + })), + elements: model.elements.map((element, position) => { + const common = { + modelId, + id: element.id, + position, + kind: element.kind, + extensions: element.extensions ?? null, + }; + if (element.kind === "activity") { + return { + ...common, + label: element.label, + actor: element.actor ?? null, + action: element.action ?? null, + source: element.source ?? null, + affects: element.affects ?? null, + consumes: element.consumes ?? null, + produces: element.produces ?? null, + }; + } + if (element.kind === "decision") { + return { ...common, label: element.label, actor: element.actor ?? null }; + } + if (element.kind === "state") { + return { + ...common, + label: element.label, + parent: element.parent ?? null, + entry: element.entry ?? null, + exit: element.exit ?? null, + }; + } + if (element.kind === "pseudo") { + return { ...common, role: element.role, parent: element.parent ?? null }; + } + + return { + ...common, + label: element.label, + from: element.from, + to: element.to, + mode: element.mode, + event: element.event ?? null, + }; + }), + transitions: model.transitions.map((transition, position) => ({ + modelId, + id: transition.id, + position, + source: transition.source, + target: transition.target, + trigger: transition.trigger ?? null, + guard: transition.guard ?? null, + condition: transition.condition ?? null, + effect: transition.effect ?? null, + loop: loops.has(transition.id), + })), + fragments: model.fragments.map(({ id, operator, operands }, position) => ({ + modelId, + id, + position, + operator, + operands, + })), + projections: model.projections.map((projection, position) => ({ + modelId, + position, + id: projection.id, + notation: projection.notation, + subject: "subject" in projection ? projection.subject : null, + scenario: "scenario" in projection ? projection.scenario : null, + })), +}); + +type ElementRow = typeof elements.$inferSelect; + +/** The fields each element kind takes from its row, beyond the kind, id and extensions every element has. Keyed by every kind, so a new kind is a compile error here until it is mapped. */ +const kindFields: Readonly< + Record Readonly>> +> = { + activity: (row) => ({ + label: row.label, + ...present("actor", row.actor), + ...present("action", row.action), + ...present("source", row.source), + ...present("affects", row.affects), + ...present("consumes", row.consumes), + ...present("produces", row.produces), + }), + decision: (row) => ({ label: row.label, ...present("actor", row.actor) }), + state: (row) => ({ + label: row.label, + ...present("parent", row.parent), + ...present("entry", row.entry), + ...present("exit", row.exit), + }), + pseudo: (row) => ({ role: row.role, ...present("parent", row.parent) }), + message: (row) => ({ + label: row.label, + from: row.from, + to: row.to, + mode: row.mode, + ...present("event", row.event), + }), +}; + +/** Puts back the fields of one element row that belong to its kind. */ +const elementOf = (row: ElementRow): unknown => ({ + kind: row.kind, + id: row.id, + ...present("extensions", row.extensions), + ...kindFields[row.kind](row), +}); + +/** + * Reassembles a model from its rows. + * @param rows - Every row of the model, each table in position order. + * @returns The model. + * @throws ZodError when the rows do not make a model, which only a database changed outside the store can cause. + */ +export const fromRows = (rows: ModelRows): Model => + modelSchema.parse({ + participants: rows.participants.map(({ id, label, kind }) => ({ + id, + label, + kind, + })), + events: rows.events.map(({ id, name, payload }) => ({ + id, + name, + ...present("payload", payload), + })), + actions: rows.actions.map(({ id, name }) => ({ id, name })), + artefacts: rows.artefacts.map(({ id, name, deliverable }) => ({ + id, + name, + ...present("deliverable", deliverable), + })), + elements: rows.elements.map(elementOf), + transitions: rows.transitions.map((row) => ({ + id: row.id, + source: row.source, + target: row.target, + ...present("trigger", row.trigger), + ...present("guard", row.guard), + ...present("condition", row.condition), + ...present("effect", row.effect), + })), + fragments: rows.fragments.map(({ id, operator, operands }) => ({ + id, + operator, + operands, + })), + projections: rows.projections.map((row) => ({ + id: row.id, + notation: row.notation, + ...present("subject", row.subject), + ...present("scenario", row.scenario), + })), + ...present("views", rows.model.views), + }); + +/** + * Reassembles an overlay from its rows. + * @param rows - The model's overlay rows. + * @returns The overlay. + */ +export const overlayFromRows = ( + rows: readonly (typeof overlay.$inferSelect)[], +): Overlay => + overlaySchema.parse( + Object.fromEntries(rows.map(({ element, status }) => [element, status])), + ); diff --git a/packages/adapters/graph/store-sqlite/src/store.ts b/packages/adapters/graph/store-sqlite/src/store.ts new file mode 100644 index 0000000..bf15e60 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/store.ts @@ -0,0 +1,407 @@ +import { loopTransitions } from "@exadev/process-graph-domain"; +import type { Model, OpenIds, Overlay } from "@exadev/process-graph-schema"; +import { + ChangeFeed, + checkStored, + emptyModel, + GraphStoreError, + mergeBoundItems, + overlayWithin, + type ChangeKind, + type GraphStore, + type StoredModel, +} from "@exadev/process-graph-store-contract"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { z } from "zod"; +import { + inTransaction, + sequentially, + serialised, + type Exclusive, + type SqliteDatabase, +} from "./database.ts"; +import { migrate } from "./migrate.ts"; +import { blockedChain, impact, ready } from "./queries.ts"; +import { + fromRows, + overlayFromRows, + toRows, + type ModelInserts, +} from "./rows.ts"; +import { + actions, + artefacts, + elements, + events, + fragments, + models, + overlay as overlayTable, + participants, + projections, + sequences, + transitions, +} from "./tables.ts"; + +/** The tables that hold a model's own records, every one keyed by `modelId`. */ +const recordTables = [ + participants, + events, + actions, + artefacts, + elements, + transitions, + fragments, + projections, +] as const; + +/** The name of the store-wide revision sequence's row in `sequences`. */ +const revisionSequence = "revision"; + +/** What `createSqliteGraphStore` needs. */ +export interface SqliteGraphStoreOptions { + /** Any Drizzle SQLite database, migrated or not. */ + readonly db: SqliteDatabase; + /** + * Runs each store operation with the database to itself. Defaults to serialising this store's operations in call order, which is enough when one store owns the connection. A host that shares the database across contexts (tabs, processes) supplies one that also holds its cross-context lock. + */ + readonly exclusive?: Exclusive; + /** The feed committed changes are published to. A host that bridges other contexts' changes in passes its own. */ + readonly feed?: ChangeFeed; +} + +/** + * A `GraphStore` over SQLite through Drizzle. It migrates the database on its first operation, keeps every model as rows (see `tables.ts` and `rows.ts`), checks every write with the contract's `checkStored` before committing it, and answers the structural queries with recursive SQL. + * + * Writes rewrite the model's rows inside one transaction, so a failed check or an aborted signal leaves the previous rows in place. + * @param options - The database, and optionally the exclusivity and feed a host provides. + * @returns The store, ready to serve with `createGraphStoreRouter`. + */ +export const createSqliteGraphStore = ( + options: SqliteGraphStoreOptions, +): GraphStore => { + const { db, exclusive = serialised(), feed = new ChangeFeed() } = options; + let migrated: Promise | undefined; + + /** Runs an operation exclusively on a migrated database. */ + const operation = async (work: () => Promise): Promise => + exclusive(async () => { + migrated ??= migrate(db); + await migrated; + + return work(); + }); + + const nextRevision = async (): Promise => { + const [row] = await db + .insert(sequences) + .values({ name: revisionSequence, value: 1 }) + .onConflictDoUpdate({ + target: sequences.name, + set: { value: sql`${sequences.value} + 1` }, + }) + .returning({ value: sequences.value }); + + return z.object({ value: z.number() }).parse(row).value; + }; + + const revisionOf = async (modelId: string): Promise => { + const [row] = await db + .select({ revision: models.revision }) + .from(models) + .where(eq(models.id, modelId)); + + return row?.revision; + }; + + const requireModel = async (modelId: string): Promise => { + if ((await revisionOf(modelId)) === undefined) { + throw new GraphStoreError({ kind: "not-found", modelId }); + } + }; + + const requireElement = async ( + modelId: string, + element: string, + ): Promise => { + await requireModel(modelId); + const [row] = await db + .select({ id: elements.id }) + .from(elements) + .where(and(eq(elements.modelId, modelId), eq(elements.id, element))); + if (row === undefined) { + throw new GraphStoreError({ kind: "not-found", modelId, element }); + } + }; + + const readOverlay = async (modelId: string): Promise> => + overlayFromRows( + await db + .select() + .from(overlayTable) + .where(eq(overlayTable.modelId, modelId)), + ); + + const read = async (modelId: string): Promise => { + const [model] = await db + .select() + .from(models) + .where(eq(models.id, modelId)); + if (model === undefined) { + throw new GraphStoreError({ kind: "not-found", modelId }); + } + const rows = { + model, + participants: await db + .select() + .from(participants) + .where(eq(participants.modelId, modelId)) + .orderBy(asc(participants.position)), + events: await db + .select() + .from(events) + .where(eq(events.modelId, modelId)) + .orderBy(asc(events.position)), + actions: await db + .select() + .from(actions) + .where(eq(actions.modelId, modelId)) + .orderBy(asc(actions.position)), + artefacts: await db + .select() + .from(artefacts) + .where(eq(artefacts.modelId, modelId)) + .orderBy(asc(artefacts.position)), + elements: await db + .select() + .from(elements) + .where(eq(elements.modelId, modelId)) + .orderBy(asc(elements.position)), + transitions: await db + .select() + .from(transitions) + .where(eq(transitions.modelId, modelId)) + .orderBy(asc(transitions.position)), + fragments: await db + .select() + .from(fragments) + .where(eq(fragments.modelId, modelId)) + .orderBy(asc(fragments.position)), + projections: await db + .select() + .from(projections) + .where(eq(projections.modelId, modelId)) + .orderBy(asc(projections.position)), + }; + + return { + modelId, + revision: model.revision, + model: fromRows(rows), + overlay: await readOverlay(modelId), + }; + }; + + const removeRecords = async (modelId: string): Promise => + sequentially( + recordTables.map( + (table) => async () => + db.delete(table).where(eq(table.modelId, modelId)), + ), + ); + + /** Inserts a model's record rows, skipping the tables it has no records for (an insert needs at least one row). */ + const insertAll = async (rows: ModelInserts): Promise => + sequentially([ + ...(rows.participants.length > 0 + ? [async () => db.insert(participants).values([...rows.participants])] + : []), + ...(rows.events.length > 0 + ? [async () => db.insert(events).values([...rows.events])] + : []), + ...(rows.actions.length > 0 + ? [async () => db.insert(actions).values([...rows.actions])] + : []), + ...(rows.artefacts.length > 0 + ? [async () => db.insert(artefacts).values([...rows.artefacts])] + : []), + ...(rows.elements.length > 0 + ? [async () => db.insert(elements).values([...rows.elements])] + : []), + ...(rows.transitions.length > 0 + ? [async () => db.insert(transitions).values([...rows.transitions])] + : []), + ...(rows.fragments.length > 0 + ? [async () => db.insert(fragments).values([...rows.fragments])] + : []), + ...(rows.projections.length > 0 + ? [async () => db.insert(projections).values([...rows.projections])] + : []), + ]); + + /** Everything one write stores and reports. */ + interface Write { + readonly modelId: string; + readonly kind: ChangeKind; + readonly model: Model; + readonly overlay: Overlay; + readonly signal: AbortSignal | undefined; + } + + /** Replaces a model's rows and overlay in one transaction and publishes the change once it commits. */ + const write = async ({ + modelId, + kind, + model, + overlay, + signal, + }: Write): Promise => { + checkStored(model, overlay); + const revision = await inTransaction(db, async () => { + const taken = await nextRevision(); + const rows = toRows( + modelId, + model, + taken, + new Set(loopTransitions(model)), + ); + await removeRecords(modelId); + await db.delete(overlayTable).where(eq(overlayTable.modelId, modelId)); + await db + .insert(models) + .values(rows.model) + .onConflictDoUpdate({ + target: models.id, + set: { revision: rows.model.revision, views: rows.model.views }, + }); + await insertAll(rows); + const entries = Object.entries(overlay).flatMap(([element, status]) => + status === undefined ? [] : [{ modelId, element, status }], + ); + if (entries.length > 0) { + await db.insert(overlayTable).values(entries); + } + signal?.throwIfAborted(); + + return taken; + }); + feed.publish({ modelId, kind, revision }); + + return revision; + }; + + return { + feed, + listModels: async () => + operation(async () => + db + .select({ modelId: models.id, revision: models.revision }) + .from(models) + .orderBy(asc(models.id)), + ), + saveModel: async ({ modelId, model, signal }) => + operation(async () => { + const existing = + (await revisionOf(modelId)) === undefined + ? {} + : await readOverlay(modelId); + + return write({ + modelId, + kind: "saved", + model, + overlay: overlayWithin(model, existing), + signal, + }); + }), + loadModel: async ({ modelId }) => operation(async () => read(modelId)), + deleteModel: async ({ modelId, signal }) => + operation(async () => { + if ((await revisionOf(modelId)) === undefined) { + return undefined; + } + const revision = await inTransaction(db, async () => { + const taken = await nextRevision(); + await removeRecords(modelId); + await db + .delete(overlayTable) + .where(eq(overlayTable.modelId, modelId)); + await db.delete(models).where(eq(models.id, modelId)); + signal?.throwIfAborted(); + + return taken; + }); + feed.publish({ modelId, kind: "deleted", revision }); + + return revision; + }), + applyOverlay: async ({ modelId, overlay, signal }) => + operation(async () => { + const stored = await read(modelId); + const merged = { ...stored.overlay, ...overlay }; + checkStored(stored.model, merged); + const named = Object.keys(overlay); + const revision = await inTransaction(db, async () => { + const taken = await nextRevision(); + await db + .update(models) + .set({ revision: taken }) + .where(eq(models.id, modelId)); + await db + .delete(overlayTable) + .where( + and( + eq(overlayTable.modelId, modelId), + inArray(overlayTable.element, named), + ), + ); + const entries = Object.entries(overlay).flatMap( + ([element, status]) => + status === undefined ? [] : [{ modelId, element, status }], + ); + if (entries.length > 0) { + await db.insert(overlayTable).values(entries); + } + signal?.throwIfAborted(); + + return taken; + }); + feed.publish({ modelId, kind: "overlay", revision }); + + return revision; + }), + upsertItems: async ({ modelId, items, signal }) => + operation(async () => { + const stored = + (await revisionOf(modelId)) === undefined + ? { model: emptyModel, overlay: {} } + : await read(modelId); + + return write({ + modelId, + kind: "items", + ...mergeBoundItems(stored.model, stored.overlay, items), + signal, + }); + }), + ready: async ({ modelId }) => + operation(async () => { + await requireModel(modelId); + + return ready(db, modelId); + }), + blockedChain: async ({ modelId, element }) => + operation(async () => { + await requireElement(modelId, element); + + return blockedChain(db, { modelId, element }); + }), + impact: async ({ modelId, element }) => + operation(async () => { + await requireElement(modelId, element); + + return impact(db, { modelId, element }); + }), + revisionOf: async ({ modelId }) => + operation(async () => revisionOf(modelId)), + }; +}; diff --git a/packages/adapters/graph/store-sqlite/src/store.unit.test.ts b/packages/adapters/graph/store-sqlite/src/store.unit.test.ts new file mode 100644 index 0000000..7c88090 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/store.unit.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createGraphStoreRouter } from "@exadev/process-graph-store-contract"; +import { + everyFieldModel, + graphStoreConformance, +} from "@exadev/process-graph-store-contract/conformance"; +import { createRouterClient } from "@orpc/server"; +import { drizzle } from "drizzle-orm/node-sqlite"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, test } from "vitest"; +import { createSqliteGraphStore } from "./store.ts"; + +graphStoreConformance({ + name: "SQLite through drizzle-orm/node-sqlite, in memory", + createClient: async () => { + const client = new DatabaseSync(":memory:"); + + return Promise.resolve({ + client: createRouterClient( + createGraphStoreRouter( + createSqliteGraphStore({ db: drizzle({ client }) }), + ), + ), + close: async () => { + client.close(); + + return Promise.resolve(); + }, + }); + }, +}); + +describe("a store on a database file", () => { + test("keeps its models and revisions when the file is opened again", async () => { + const directory = mkdtempSync(join(tmpdir(), "graph-store-sqlite-")); + const path = join(directory, "graph.sqlite3"); + try { + const first = new DatabaseSync(path); + const writer = createRouterClient( + createGraphStoreRouter( + createSqliteGraphStore({ db: drizzle({ client: first }) }), + ), + ); + const { revision } = await writer.v1.models.save({ + modelId: "orders", + model: everyFieldModel, + }); + await writer.v1.overlay.set({ + modelId: "orders", + overlay: { ship: "done" }, + }); + first.close(); + + const second = new DatabaseSync(path); + const reader = createRouterClient( + createGraphStoreRouter( + createSqliteGraphStore({ db: drizzle({ client: second }) }), + ), + ); + const stored = await reader.v1.models.load({ modelId: "orders" }); + const next = await reader.v1.models.save({ + modelId: "other", + model: everyFieldModel, + }); + second.close(); + + expect(stored).toStrictEqual({ + modelId: "orders", + revision: revision + 1, + model: everyFieldModel, + overlay: { ship: "done" }, + }); + expect(next.revision).toBeGreaterThan(stored.revision); + } finally { + rmSync(directory, { recursive: true }); + } + }); +}); diff --git a/packages/adapters/graph/store-sqlite/src/tables.ts b/packages/adapters/graph/store-sqlite/src/tables.ts new file mode 100644 index 0000000..fd9ea8a --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/tables.ts @@ -0,0 +1,172 @@ +import { + elementKinds, + fragmentOperators, + messageModes, + notations, + overlayStatuses, + participantKinds, + pseudoRoles, +} from "@exadev/process-graph-schema"; +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; + +/** + * The storage projection of the process model. Every record a model declares is one row keyed by its model and its id, with `position` keeping the model's declared order, so a model loads back exactly as it was saved. Enumerated columns take their values from graph-schema's own vocabulary, and nested values (lists, extensions, conditions) are JSON text, typed `unknown` here because `rows.ts` checks them with the model schema on every read. Rows are never the contract's shape: `rows.ts` maps them to the model and back. + * + * No foreign keys are declared: SQLite enforces them only on connections that enable them, which a store cannot assume of the driver it is given, so the store keeps a model's rows together itself by writing and deleting them in one transaction. + */ + +/** The store-wide revision sequence: one row, named so it reads as what it is. */ +export const sequences = sqliteTable("sequences", { + name: text("name").primaryKey(), + value: integer("value").notNull(), +}); + +/** One row per stored model: its revision and its layout. */ +export const models = sqliteTable("models", { + id: text("id").primaryKey(), + revision: integer("revision").notNull(), + views: text("views", { mode: "json" }), +}); + +export const participants = sqliteTable( + "participants", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + label: text("label").notNull(), + kind: text("kind", { enum: participantKinds }).notNull(), + }, + (table) => [primaryKey({ columns: [table.modelId, table.id] })], +); + +export const events = sqliteTable( + "events", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + name: text("name").notNull(), + payload: text("payload", { mode: "json" }), + }, + (table) => [primaryKey({ columns: [table.modelId, table.id] })], +); + +export const actions = sqliteTable( + "actions", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + name: text("name").notNull(), + }, + (table) => [primaryKey({ columns: [table.modelId, table.id] })], +); + +export const artefacts = sqliteTable( + "artefacts", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + name: text("name").notNull(), + deliverable: integer("deliverable", { mode: "boolean" }), + }, + (table) => [primaryKey({ columns: [table.modelId, table.id] })], +); + +/** + * Every element kind in one table, so the structural queries traverse a single relation. A column a kind does not have is null on its rows; `rows.ts` reads only the columns of the row's kind. + */ +export const elements = sqliteTable( + "elements", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + kind: text("kind", { enum: elementKinds }).notNull(), + label: text("label"), + actor: text("actor"), + action: text("action"), + source: text("source"), + affects: text("affects", { mode: "json" }), + consumes: text("consumes", { mode: "json" }), + produces: text("produces", { mode: "json" }), + parent: text("parent"), + entry: text("entry"), + exit: text("exit"), + role: text("role", { enum: pseudoRoles }), + from: text("from_actor"), + to: text("to_actor"), + mode: text("mode", { enum: messageModes }), + event: text("event"), + extensions: text("extensions", { mode: "json" }), + }, + (table) => [primaryKey({ columns: [table.modelId, table.id] })], +); + +/** Transitions, with `loop` recording whether graph-domain counts the transition as a decision's loop edge, so the recursive queries can leave it out without loading the model. */ +export const transitions = sqliteTable( + "transitions", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + source: text("source").notNull(), + target: text("target").notNull(), + trigger: text("trigger"), + guard: text("guard"), + condition: text("condition", { mode: "json" }), + effect: text("effect"), + loop: integer("loop", { mode: "boolean" }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.modelId, table.id] }), + // The recursive queries walk transitions both ways from an element. + index("transitions_by_target").on(table.modelId, table.target), + index("transitions_by_source").on(table.modelId, table.source), + ], +); + +export const fragments = sqliteTable( + "fragments", + { + modelId: text("model_id").notNull(), + id: text("id").notNull(), + position: integer("position").notNull(), + operator: text("operator", { enum: fragmentOperators }).notNull(), + operands: text("operands", { mode: "json" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.modelId, table.id] })], +); + +/** Projections, keyed by position because the model does not require their ids to be unique. */ +export const projections = sqliteTable( + "projections", + { + modelId: text("model_id").notNull(), + position: integer("position").notNull(), + id: text("id").notNull(), + notation: text("notation", { enum: notations }).notNull(), + subject: text("subject"), + scenario: text("scenario", { mode: "json" }), + }, + (table) => [primaryKey({ columns: [table.modelId, table.position] })], +); + +/** Run state, one row per element that has any. */ +export const overlay = sqliteTable( + "overlay", + { + modelId: text("model_id").notNull(), + element: text("element").notNull(), + status: text("status", { enum: overlayStatuses }).notNull(), + }, + (table) => [primaryKey({ columns: [table.modelId, table.element] })], +); diff --git a/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts b/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts new file mode 100644 index 0000000..354ec51 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/src/tables.unit.test.ts @@ -0,0 +1,176 @@ +import { modelSchema, overlaySchema } from "@exadev/process-graph-schema"; +import { createSelectSchema } from "drizzle-orm/zod"; +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { + actions, + artefacts, + elements, + events, + fragments, + overlay, + participants, + projections, + transitions, +} from "./tables.ts"; + +/** + * Strips what storage and the model express differently for the same field: a nullable column against an optional or exact-optional field, and the model's readonly wrappers. What is left is the value's own type, which must agree. + */ +const valueOf = (schema: z.core.$ZodType): z.core.$ZodType => { + if ( + schema instanceof z.ZodNullable || + schema instanceof z.ZodOptional || + schema instanceof z.ZodExactOptional || + schema instanceof z.ZodReadonly + ) { + return valueOf(schema.unwrap()); + } + + return schema; +}; + +/** The JSON Schema of a field's value, the comparable description of what it accepts. */ +const described = (schema: z.core.$ZodType): unknown => + z.toJSONSchema(valueOf(schema)); + +const model = modelSchema.unwrap().shape; + +/** One field of an object schema's shape, or `undefined` when it has none of that name. */ +const fieldOf = ( + shape: Readonly>, + field: string, +): z.core.$ZodType | undefined => shape[field]; +const recordShape = (list: z.ZodReadonly>) => + list.unwrap().element; + +const variants = model.elements + .unwrap() + .element.options.map((option) => option.unwrap().shape); + +/** The shape of one element variant, by its kind. */ +const variant = (kind: string): (typeof variants)[number] => { + const shape = variants.find((candidate) => candidate.kind.value === kind); + if (shape === undefined) { + throw new Error(`No element variant "${kind}"`); + } + + return shape; +}; + +/** + * Each table against the model record it stores: every scalar column whose field the record also has. JSON columns (lists, payloads, extensions, conditions, operands, scenarios, views) are left out here because Drizzle derives an untyped JSON schema for them; `fromRows` parses them with the model schema on every read instead. + */ +const pairs = [ + { + name: "participants", + row: createSelectSchema(participants).shape, + record: recordShape(model.participants).unwrap().shape, + fields: ["id", "label", "kind"], + }, + { + name: "events", + row: createSelectSchema(events).shape, + record: recordShape(model.events).unwrap().shape, + fields: ["id", "name"], + }, + { + name: "actions", + row: createSelectSchema(actions).shape, + record: recordShape(model.actions).unwrap().shape, + fields: ["id", "name"], + }, + { + name: "artefacts", + row: createSelectSchema(artefacts).shape, + record: recordShape(model.artefacts).unwrap().shape, + fields: ["id", "name", "deliverable"], + }, + { + name: "activity elements", + row: createSelectSchema(elements).shape, + record: variant("activity"), + fields: ["id", "label", "actor", "action", "source"], + }, + { + name: "decision elements", + row: createSelectSchema(elements).shape, + record: variant("decision"), + fields: ["id", "label", "actor"], + }, + { + name: "state elements", + row: createSelectSchema(elements).shape, + record: variant("state"), + fields: ["id", "label", "parent", "entry", "exit"], + }, + { + name: "pseudo elements", + row: createSelectSchema(elements).shape, + record: variant("pseudo"), + fields: ["id", "role", "parent"], + }, + { + name: "message elements", + row: createSelectSchema(elements).shape, + record: variant("message"), + fields: ["id", "label", "from", "to", "mode", "event"], + }, + { + name: "transitions", + row: createSelectSchema(transitions).shape, + record: recordShape(model.transitions).unwrap().shape, + fields: ["id", "source", "target", "trigger", "guard", "effect"], + }, + { + name: "fragments", + row: createSelectSchema(fragments).shape, + record: recordShape(model.fragments).unwrap().shape, + fields: ["id", "operator"], + }, +] as const; + +describe("the row schemas Drizzle derives and the model schema", () => { + test.each(pairs)( + "agree on the shared fields of $name", + ({ row, record, fields }) => { + for (const field of fields) { + const rowField = fieldOf(row, field); + const recordField = fieldOf(record, field); + + expect({ field, row: rowField && described(rowField) }).toEqual({ + field, + row: recordField && described(recordField), + }); + } + }, + ); + + test("the element kind column holds exactly the element variants' kinds", () => { + expect(described(createSelectSchema(elements).shape.kind)).toEqual( + z.toJSONSchema(z.enum(variants.map((shape) => shape.kind.value))), + ); + }); + + test("the projection notation column holds exactly the projection variants' notations", () => { + const notations = model.projections + .unwrap() + .element.options.flatMap((option) => { + const { notation } = option.unwrap().shape; + + return notation instanceof z.ZodEnum + ? notation.options + : [notation.value]; + }); + + expect(described(createSelectSchema(projections).shape.notation)).toEqual( + z.toJSONSchema(z.enum(notations)), + ); + }); + + test("the overlay status column holds exactly the overlay's statuses", () => { + expect(described(createSelectSchema(overlay).shape.status)).toEqual( + z.toJSONSchema(overlaySchema.unwrap().valueType), + ); + }); +}); diff --git a/packages/adapters/graph/store-sqlite/stryker.config.ts b/packages/adapters/graph/store-sqlite/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/adapters/graph/store-sqlite/test/package.smoke.test.ts b/packages/adapters/graph/store-sqlite/test/package.smoke.test.ts new file mode 100644 index 0000000..5d373a4 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/test/package.smoke.test.ts @@ -0,0 +1,23 @@ +import { createGraphStoreRouter } from "@exadev/process-graph-store-contract"; +import { everyFieldModel } from "@exadev/process-graph-store-contract/conformance"; +import { createRouterClient } from "@orpc/server"; +import { drizzle } from "drizzle-orm/node-sqlite"; +import { DatabaseSync } from "node:sqlite"; +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map, or migrations missing from the build, that a unit test importing straight from src/ never touches. +import { createSqliteGraphStore } from "../dist/index.js"; + +test("the built package migrates a new database and round-trips a model", async () => { + const client = createRouterClient( + createGraphStoreRouter( + createSqliteGraphStore({ + db: drizzle({ client: new DatabaseSync(":memory:") }), + }), + ), + ); + await client.v1.models.save({ modelId: "orders", model: everyFieldModel }); + + await expect( + client.v1.models.load({ modelId: "orders" }), + ).resolves.toMatchObject({ model: everyFieldModel, overlay: {} }); +}); diff --git a/packages/adapters/graph/store-sqlite/tsconfig.build.json b/packages/adapters/graph/store-sqlite/tsconfig.build.json new file mode 100644 index 0000000..7159019 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../../core/graph/schema/tsconfig.build.json" + }, + { + "path": "../../../core/graph/domain/tsconfig.build.json" + }, + { + "path": "../../../core/graph/store-contract/tsconfig.build.json" + } + ] +} diff --git a/packages/adapters/graph/store-sqlite/tsconfig.json b/packages/adapters/graph/store-sqlite/tsconfig.json new file mode 100644 index 0000000..8895dd1 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "WebWorker"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/adapters/graph/store-sqlite/tsconfig.node.json b/packages/adapters/graph/store-sqlite/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/adapters/graph/store-sqlite/vitest.config.ts b/packages/adapters/graph/store-sqlite/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/adapters/graph/store-sqlite/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index bffebbb..395eced 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -6,12 +6,20 @@ * Participants are split further by their declared kind. An `actor` (who performs a step, or sends or receives a message) must be a role or a system. A state machine `subject` must be an entity. Naming a participant of the wrong kind is a compile error too. */ export type { IdSpace, OpenIds } from "./ids.ts"; -export type { - FragmentOperator, - MessageMode, - Notation, - ParticipantKind, - PseudoRole, +export { + elementKinds, + fragmentOperators, + messageModes, + notations, + overlayStatuses, + participantKinds, + pseudoRoles, + type ElementKind, + type FragmentOperator, + type MessageMode, + type Notation, + type ParticipantKind, + type PseudoRole, } from "./vocabulary.ts"; export type { ActionDef, diff --git a/packages/core/graph/schema/src/vocabulary.ts b/packages/core/graph/schema/src/vocabulary.ts index e739725..d88e75e 100644 --- a/packages/core/graph/schema/src/vocabulary.ts +++ b/packages/core/graph/schema/src/vocabulary.ts @@ -40,3 +40,13 @@ export const notations = [ "sequence", ] as const; export type Notation = (typeof notations)[number]; + +/** The kinds of element a model declares, one per variant of `Element`. */ +export const elementKinds = [ + "activity", + "decision", + "state", + "pseudo", + "message", +] as const; +export type ElementKind = (typeof elementKinds)[number]; diff --git a/packages/core/graph/schema/src/vocabulary.unit.test.ts b/packages/core/graph/schema/src/vocabulary.unit.test.ts new file mode 100644 index 0000000..52dd097 --- /dev/null +++ b/packages/core/graph/schema/src/vocabulary.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expectTypeOf, test } from "vitest"; +import type { Element } from "./declarations.ts"; +import type { OpenIds } from "./ids.ts"; +import type { ElementKind } from "./vocabulary.ts"; + +describe("elementKinds", () => { + test("names exactly the variants of Element", () => { + expectTypeOf().toEqualTypeOf< + Element["kind"] + >(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7ea062..20ecee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,61 @@ importers: specifier: 5.0.3 version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/adapters/graph/store-sqlite: + dependencies: + '@exadev/process-graph-domain': + specifier: 1.0.2 + version: link:../../../core/graph/domain + '@exadev/process-graph-schema': + specifier: 1.0.2 + version: link:../../../core/graph/schema + '@exadev/process-graph-store-contract': + specifier: 1.0.0 + version: link:../../../core/graph/store-contract + '@orpc/server': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + drizzle-orm: + specifier: 1.0.0-rc.4 + version: 1.0.0-rc.4(zod@4.6.5) + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@5.0.3) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(vitest@5.0.3) + drizzle-kit: + specifier: 1.0.0-rc.4 + version: 1.0.0-rc.4 + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/dag: devDependencies: '@arethetypeswrong/cli': @@ -989,6 +1044,9 @@ packages: resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==} engines: {node: '>=22'} + '@drizzle-team/brocli@0.12.1': + resolution: {integrity: sha512-yHwomvolVafvUXhy5TdiJVkuNCxXn+bKOs2aWiWEQh/5YaV97Oa/abJz3dmx7lxgIUsUlS1Jl4mEfvJPo3YsVw==} + '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -1006,6 +1064,162 @@ packages: resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1274,6 +1488,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -2463,6 +2681,156 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + drizzle-kit@1.0.0-rc.4: + resolution: {integrity: sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA==} + hasBin: true + + drizzle-orm@1.0.0-rc.4: + resolution: {integrity: sha512-BT+pf+qoiYHqltoA88Jmf6ilGMXPlpfE0hEJKc2adRtMCAl25Swk/t5gXcWxZNAwdtf3F5gCd2FpeOyP/pT0Hw==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@effect/sql-d1': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-libsql': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-mysql2': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-pg': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-pglite': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-sqlite-bun': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-sqlite-do': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-sqlite-node': '>=4.0.0-beta.83 || >=4.0.0' + '@effect/sql-sqlite-wasm': '>=4.0.0-beta.83 || >=4.0.0' + '@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' + '@sinclair/typebox': '>=0.34.8' + '@sqlitecloud/drivers': '>=1.0.653' + '@tidbcloud/serverless': '*' + '@tursodatabase/database': '>=0.6.0-pre.28 || >=0.6.0' + '@tursodatabase/database-common': '>=0.6.0-pre.28 || >=0.6.0' + '@tursodatabase/database-wasm': '>=0.6.0-pre.28 || >=0.6.0' + '@tursodatabase/serverless': '>=1.1.3' + '@tursodatabase/sync': '>=0.6.0-pre.28 || >=0.6.0' + '@types/better-sqlite3': '*' + '@types/mssql': ^9.1.4 + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + arktype: '>=2.0.0' + better-sqlite3: '>=9.3.0' + bun-types: '*' + effect: '>=4.0.0-beta.83 || >=4.0.0' + expo-sqlite: '>=14.0.0' + mssql: ^11.0.1 + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + sql.js: '>=1' + sqlite3: '>=5' + typebox: '>=1.0.0' + valibot: '>=1.0.0-beta.7' + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@effect/sql-d1': + optional: true + '@effect/sql-libsql': + optional: true + '@effect/sql-mysql2': + optional: true + '@effect/sql-pg': + optional: true + '@effect/sql-pglite': + optional: true + '@effect/sql-sqlite-bun': + optional: true + '@effect/sql-sqlite-do': + optional: true + '@effect/sql-sqlite-node': + optional: true + '@effect/sql-sqlite-wasm': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@sinclair/typebox': + optional: true + '@sqlitecloud/drivers': + optional: true + '@tidbcloud/serverless': + optional: true + '@tursodatabase/database': + optional: true + '@tursodatabase/database-common': + optional: true + '@tursodatabase/database-wasm': + optional: true + '@tursodatabase/serverless': + optional: true + '@tursodatabase/sync': + optional: true + '@types/better-sqlite3': + optional: true + '@types/mssql': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + arktype: + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + effect: + optional: true + expo-sqlite: + optional: true + mssql: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2522,6 +2890,11 @@ packages: es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3022,6 +3395,9 @@ packages: resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + jsdoc-type-pratt-parser@9.2.2: resolution: {integrity: sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==} engines: {node: ^22.22.2 || >=24.15.0} @@ -4853,6 +5229,8 @@ snapshots: '@conventional-changelog/template@1.4.0': {} + '@drizzle-team/brocli@0.12.1': {} + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -4879,6 +5257,84 @@ snapshots: '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) @@ -5140,6 +5596,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.5.1 @@ -6299,6 +6759,18 @@ snapshots: dependencies: is-obj: 2.0.0 + drizzle-kit@1.0.0-rc.4: + dependencies: + '@drizzle-team/brocli': 0.12.1 + '@js-temporal/polyfill': 0.5.1 + esbuild: 0.25.12 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + + drizzle-orm@1.0.0-rc.4(zod@4.6.5): + optionalDependencies: + zod: 4.6.5 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6346,6 +6818,35 @@ snapshots: es-toolkit@1.52.0: {} + 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 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -6856,6 +7357,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbi@4.3.2: {} + jsdoc-type-pratt-parser@9.2.2: dependencies: '@types/estree': 1.0.9 diff --git a/tsconfig.build.json b/tsconfig.build.json index d9f7bd8..db63477 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,6 +25,9 @@ { "path": "packages/core/graph/store-contract/tsconfig.build.json" }, + { + "path": "packages/adapters/graph/store-sqlite/tsconfig.build.json" + }, { "path": "packages/targets/process-graph/tsconfig.build.json" }