diff --git a/.changeset/bright-wolves-explain.md b/.changeset/bright-wolves-explain.md index b3d11dd8..3ff5f05c 100644 --- a/.changeset/bright-wolves-explain.md +++ b/.changeset/bright-wolves-explain.md @@ -1,6 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-cli": minor --- Expose raw transaction preview through the database subpath, sharing the client's constraint diff --git a/.changeset/calm-http-diagnostics.md b/.changeset/calm-http-diagnostics.md index 3fa66180..5f31cc3e 100644 --- a/.changeset/calm-http-diagnostics.md +++ b/.changeset/calm-http-diagnostics.md @@ -1,6 +1,5 @@ --- -"@triplex-build/triplex-cli": minor -"@triplex-build/triplex-http": minor +"@triplex-build/triplex": minor --- Replace CLI and HTTP failure bodies with stable core Diagnostic envelopes. diff --git a/.changeset/cheap-live-preconditions.md b/.changeset/cheap-live-preconditions.md index 0b5606db..98dc9489 100644 --- a/.changeset/cheap-live-preconditions.md +++ b/.changeset/cheap-live-preconditions.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-testkit": minor --- Add atomic `AttributeValue`, `AttributeAbsent`, and `EntityAbsent` transaction diff --git a/.changeset/cli-config-help.md b/.changeset/cli-config-help.md index c896bc17..24164556 100644 --- a/.changeset/cli-config-help.md +++ b/.changeset/cli-config-help.md @@ -1,5 +1,5 @@ --- -"@triplex-build/triplex-cli": patch +"@triplex-build/triplex": patch --- Fix the `config` command description in CLI help, which read "Splunk configuration objects". diff --git a/.changeset/commit-validators.md b/.changeset/commit-validators.md index ae42925c..c34304b1 100644 --- a/.changeset/commit-validators.md +++ b/.changeset/commit-validators.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-testkit": minor -"@triplex-build/triplex-http": patch --- Add commit validators: host checks that run inside `Triples.transact` after operations and the diff --git a/.changeset/config.json b/.changeset/config.json index b8213321..c20d4589 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,18 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [ + "@triplex-internal/core", + "@triplex-build/triplex-sql", + "@triplex-build/triplex-sqlite", + "@triplex-build/triplex-postgres", + "@triplex-build/triplex-cloudflare", + "@triplex-build/triplex-foundationdb", + "@triplex-build/triplex-host", + "@triplex-build/triplex-http", + "@triplex-build/triplex-testkit", + "@triplex-build/triplex-cli", + "@triplex-build/triplex-mcp", + "@triplex-build/triplex-dashboard" + ] } diff --git a/.changeset/datalog-valid-intervals.md b/.changeset/datalog-valid-intervals.md index 41807ddb..f3fa176d 100644 --- a/.changeset/datalog-valid-intervals.md +++ b/.changeset/datalog-valid-intervals.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-testkit": minor -"@triplex-build/triplex-sqlite": minor -"@triplex-build/triplex-postgres": minor --- Add Datalog interval patterns (`{ pattern, valid: { from?, to? } }`) and query-wide diff --git a/.changeset/durable-derivation-runner.md b/.changeset/durable-derivation-runner.md new file mode 100644 index 00000000..d65ae590 --- /dev/null +++ b/.changeset/durable-derivation-runner.md @@ -0,0 +1,11 @@ +--- +"@triplex-build/triplex": minor +--- + +Add the browser-safe `/runner` subpath for durable derivation diff handlers, acknowledged candidate +snapshots, Outbox recovery, bounded retries, scoped supervision and offline temporal boundary replay. +Publish the ordinary runner control schema, runner conformance helpers and a compliance host using +schema-bound commands. Add `KvTriples.layerWithClock` for deterministic commit/runner clock wiring. + +Consumer checkpoints now read unretracted facts and write `validFrom: 0`, preserving durable +progress when the wall clock rolls backwards. diff --git a/.changeset/effect-foldkit-411.md b/.changeset/effect-foldkit-411.md index 59106964..3f4346ec 100644 --- a/.changeset/effect-foldkit-411.md +++ b/.changeset/effect-foldkit-411.md @@ -1,11 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": minor -"@triplex-build/triplex-postgres": minor -"@triplex-build/triplex-testkit": minor -"@triplex-build/triplex-cli": minor -"@triplex-build/triplex-http": minor --- Require the tested Effect 4.1.1 peer and align the platform, SQL, and Vitest diff --git a/.changeset/effect-foldkit-stable.md b/.changeset/effect-foldkit-stable.md index 3c78002d..10cb6f3f 100644 --- a/.changeset/effect-foldkit-stable.md +++ b/.changeset/effect-foldkit-stable.md @@ -1,11 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": minor -"@triplex-build/triplex-postgres": minor -"@triplex-build/triplex-testkit": minor -"@triplex-build/triplex-cli": minor -"@triplex-build/triplex-http": minor --- Require the tested Effect 4.0.3 peer version and migrate SQL, HTTP, HTTP API, CLI, diff --git a/.changeset/entity-prefix-transaction-feed.md b/.changeset/entity-prefix-transaction-feed.md index 286360e0..6ed3144a 100644 --- a/.changeset/entity-prefix-transaction-feed.md +++ b/.changeset/entity-prefix-transaction-feed.md @@ -1,10 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-host": minor -"@triplex-build/triplex-testkit": minor --- Add `entityIdPrefix` to `transactions({ after, limit })` and tenant-protocol diff --git a/.changeset/exact-position-reads.md b/.changeset/exact-position-reads.md index dae0e48d..b0a12c99 100644 --- a/.changeset/exact-position-reads.md +++ b/.changeset/exact-position-reads.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-testkit": minor --- Expose `recordedPosition` on the shared temporal read basis for entity, batch, diff --git a/.changeset/explain-assertion-history.md b/.changeset/explain-assertion-history.md index a925a455..647f6348 100644 --- a/.changeset/explain-assertion-history.md +++ b/.changeset/explain-assertion-history.md @@ -1,6 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-testkit": minor --- Add the `why` subpath with the `Why` service for exact-cut explanations of facts, diff --git a/.changeset/host-position-basis.md b/.changeset/host-position-basis.md index 716ba70e..014a8421 100644 --- a/.changeset/host-position-basis.md +++ b/.changeset/host-position-basis.md @@ -1,6 +1,5 @@ --- -"@triplex-build/triplex-host": patch -"@triplex-build/triplex-http": minor +"@triplex-build/triplex": minor --- Preserve `recordedPosition` when forwarding tenant-protocol `Query` and `QueryPage` diff --git a/.changeset/indexed-live-facts.md b/.changeset/indexed-live-facts.md index ce4fde17..e96024de 100644 --- a/.changeset/indexed-live-facts.md +++ b/.changeset/indexed-live-facts.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-testkit": patch --- Add `Triples.liveFacts(entityId)`, an indexed read of unretracted assertions across all valid time, and use it for command `tx.get` entity-state observations. Sensitive and commit-validation decorators preserve sealed values through this read. Existing KV stores require the existing `KvTriples.migrateLiveFacts` backfill. diff --git a/.changeset/live-query-async-corpus.md b/.changeset/live-query-async-corpus.md index 6310b5a1..60f89c6e 100644 --- a/.changeset/live-query-async-corpus.md +++ b/.changeset/live-query-async-corpus.md @@ -1,5 +1,5 @@ --- -"@triplex-build/triplex-testkit": patch +"@triplex-build/triplex": patch --- Drive the live-query coalescing case's test clock until its pull completes so asynchronous backends can finish reading the head before scheduling the coalescing delay. Keep the existing coverage and result assertions unchanged. diff --git a/.changeset/live-query-observations.md b/.changeset/live-query-observations.md index 9e790fea..2002d99b 100644 --- a/.changeset/live-query-observations.md +++ b/.changeset/live-query-observations.md @@ -1,6 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-testkit": minor --- Add the browser-safe `live-query` subpath with scoped `LiveQuery.watch`, complete exact-cut snapshots, resume tokens, deadline scheduling, optional coalescing and pull backpressure. Export a shared KV/SQLite/PostgreSQL observation corpus from testkit. diff --git a/.changeset/live-release-describe.md b/.changeset/live-release-describe.md index b0652da1..5783b8a1 100644 --- a/.changeset/live-release-describe.md +++ b/.changeset/live-release-describe.md @@ -1,5 +1,5 @@ --- -"@triplex-build/triplex-cli": minor +"@triplex-build/triplex": minor --- Read the selected database release in `describe`, defaulting to `live` with a `--ref` override. diff --git a/.changeset/narrow-backend-layer-outputs.md b/.changeset/narrow-backend-layer-outputs.md index 459aa677..c86b7abc 100644 --- a/.changeset/narrow-backend-layer-outputs.md +++ b/.changeset/narrow-backend-layer-outputs.md @@ -1,6 +1,5 @@ --- -"@triplex-build/triplex-sqlite": minor -"@triplex-build/triplex-postgres": minor +"@triplex-build/triplex": minor --- Expose only the documented services from backend convenience layers. This is a breaking type and runtime change: SQLite's `layer`, `layerMemory`, and `layerMemoryWithScope`, and PostgreSQL's `layer` and `layerFromUrl`, now output only `Triples`. SQLite no longer exposes its SQL client, storage adapter, query executor, dialect, clock, or ID generator through these layers. PostgreSQL standalone layers no longer expose their SQL client or backend-specific client. diff --git a/.changeset/numeric-due-work-index.md b/.changeset/numeric-due-work-index.md index f8e1e42d..0ecc31a5 100644 --- a/.changeset/numeric-due-work-index.md +++ b/.changeset/numeric-due-work-index.md @@ -1,6 +1,5 @@ --- -"@triplex-build/triplex-sql": patch -"@triplex-build/triplex-testkit": patch +"@triplex-build/triplex": patch --- Provision a shared numeric/datetime expression index through an additive SQL migration, enabling diff --git a/.changeset/one-triplex-package.md b/.changeset/one-triplex-package.md new file mode 100644 index 00000000..451ce388 --- /dev/null +++ b/.changeset/one-triplex-package.md @@ -0,0 +1,25 @@ +--- +"@triplex-build/triplex": minor +--- + +Consolidate all published APIs into one package. Install `@triplex-build/triplex effect` +and the driver for your backend. Replace imports as follows: + +- `@triplex-build/triplex-sql` → `@triplex-build/triplex/sql` +- `@triplex-build/triplex-sqlite` → `@triplex-build/triplex/sqlite` +- `@triplex-build/triplex-postgres` → `@triplex-build/triplex/postgres` +- `@triplex-build/triplex-cloudflare` → `@triplex-build/triplex/cloudflare` +- `@triplex-build/triplex-foundationdb` → `@triplex-build/triplex/foundationdb` +- `@triplex-build/triplex-host` → `@triplex-build/triplex/host` +- `@triplex-build/triplex-http` → `@triplex-build/triplex/http` +- `@triplex-build/triplex-testkit` → `@triplex-build/triplex/testkit` +- `@triplex-build/triplex-testkit/postgres` → `@triplex-build/triplex/testkit/postgres` +- `@triplex-build/triplex-mcp` → `@triplex-build/triplex/mcp` + +The `triplex` bin replaces installation of `@triplex-build/triplex-cli`. +Run the former `triplex-mcp` executable as `triplex mcp`; run the prebuilt explorer +with `triplex dashboard`. CLI library exports are internal; MCP's `tools` and +`registrations` are available from `/mcp`. Drivers and Node platform adapters are +optional peers loaded only by their backend subpath or executable. Existing root +and core subpaths retain their paths. Cloudflare and FoundationDB ship as frozen, +experimental backends. Only the unified package is versioned and published. diff --git a/.changeset/package-readmes.md b/.changeset/package-readmes.md index 923d8a81..a5a61ee2 100644 --- a/.changeset/package-readmes.md +++ b/.changeset/package-readmes.md @@ -1,11 +1,5 @@ --- "@triplex-build/triplex": patch -"@triplex-build/triplex-sql": patch -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-testkit": patch -"@triplex-build/triplex-cli": patch -"@triplex-build/triplex-http": patch --- Update the published package READMEs to reflect npm availability and supported installation paths. diff --git a/.changeset/retained-assertion-read.md b/.changeset/retained-assertion-read.md index 08975609..7a704e10 100644 --- a/.changeset/retained-assertion-read.md +++ b/.changeset/retained-assertion-read.md @@ -1,8 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-cloudflare": patch --- Add `Triples.assertion(id)` for retained assertions, including retraction metadata. diff --git a/.changeset/retryable-transaction-conflicts.md b/.changeset/retryable-transaction-conflicts.md index 2301a56d..85c7ef13 100644 --- a/.changeset/retryable-transaction-conflicts.md +++ b/.changeset/retryable-transaction-conflicts.md @@ -1,7 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-postgres": minor -"@triplex-build/triplex-testkit": patch --- Surface structured PostgreSQL serialization and deadlock failures as diff --git a/.changeset/runtime-peer-ranges.md b/.changeset/runtime-peer-ranges.md index c937a6b9..a47a11a3 100644 --- a/.changeset/runtime-peer-ranges.md +++ b/.changeset/runtime-peer-ranges.md @@ -1,11 +1,5 @@ --- "@triplex-build/triplex": patch -"@triplex-build/triplex-sql": patch -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-testkit": patch -"@triplex-build/triplex-cli": patch -"@triplex-build/triplex-http": patch --- Declare Effect and its platform and SQL companions as compatible peer ranges so diff --git a/.changeset/sensitive-values.md b/.changeset/sensitive-values.md index 174fba36..51d1153a 100644 --- a/.changeset/sensitive-values.md +++ b/.changeset/sensitive-values.md @@ -1,11 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": minor -"@triplex-build/triplex-postgres": minor -"@triplex-build/triplex-testkit": minor -"@triplex-build/triplex-http": minor -"@triplex-build/triplex-cli": minor --- Add opt-in sensitive attribute values. `Attribute.sensitive` marks an attribute in its diff --git a/.changeset/shared-http-entity-decoder.md b/.changeset/shared-http-entity-decoder.md index 54cd1c8e..1a20184e 100644 --- a/.changeset/shared-http-entity-decoder.md +++ b/.changeset/shared-http-entity-decoder.md @@ -1,6 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-http": patch --- Add entity decoding options for distinct-value scalar cardinality, aggregate type membership validation, and detailed value diagnostics while keeping strict defaults. Reuse the core entity decoder for HTTP reads with unchanged response bodies and diagnostics. diff --git a/.changeset/tidy-command-invocation.md b/.changeset/tidy-command-invocation.md index 56d2c7d9..1a892a14 100644 --- a/.changeset/tidy-command-invocation.md +++ b/.changeset/tidy-command-invocation.md @@ -1,5 +1,5 @@ --- -"@triplex-build/triplex-cli": patch +"@triplex-build/triplex": patch --- Derive failure-envelope command names and value-taking flags from the declared command tree, including aliases and options before subcommands. diff --git a/.changeset/tidy-recorded-reads.md b/.changeset/tidy-recorded-reads.md index 3f313a17..4c5182cf 100644 --- a/.changeset/tidy-recorded-reads.md +++ b/.changeset/tidy-recorded-reads.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": minor -"@triplex-build/triplex-postgres": minor -"@triplex-build/triplex-testkit": minor --- Add indexed `Triples.resolveRecordedBasis` and `Triples.dependencyStateAll` reads. diff --git a/.changeset/tidy-schema-fixtures.md b/.changeset/tidy-schema-fixtures.md index 43fa8b48..f8b50c54 100644 --- a/.changeset/tidy-schema-fixtures.md +++ b/.changeset/tidy-schema-fixtures.md @@ -1,6 +1,5 @@ --- -"@triplex-build/triplex-postgres": minor -"@triplex-build/triplex-testkit": minor +"@triplex-build/triplex": minor --- Check the current schema version when acquiring an unmigrated PostgreSQL adapter diff --git a/.changeset/transactional-operational-entries.md b/.changeset/transactional-operational-entries.md index d7ca33a4..1d08ea5a 100644 --- a/.changeset/transactional-operational-entries.md +++ b/.changeset/transactional-operational-entries.md @@ -1,9 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-sql": minor -"@triplex-build/triplex-sqlite": patch -"@triplex-build/triplex-postgres": patch -"@triplex-build/triplex-testkit": minor --- Add indexed inbox/outbox entries that compose with facts in `transact`, deduplicate diff --git a/.changeset/validator-query-response.md b/.changeset/validator-query-response.md index f3d228fc..60f27b30 100644 --- a/.changeset/validator-query-response.md +++ b/.changeset/validator-query-response.md @@ -1,6 +1,5 @@ --- "@triplex-build/triplex": minor -"@triplex-build/triplex-testkit": patch --- Return `QueryResponse` from `CommitValidation.View.query`, matching the complete diff --git a/.gitignore b/.gitignore index 4fab21aa..c1c08e44 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ coverage/ playwright-report/ test-results/ **/.openai/hosting.json +dist-server/ +packages/triplex/publish/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8881e1e5..a115da60 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,13 @@ # Architecture -Triplex separates the storage-independent data model from backend implementations and now -hosts typed configuration as a modular layer over the same core. +Triplex publishes one package, `@triplex-build/triplex`, assembled by `packages/triplex` +from private implementation workspaces. The assembly rewrites cross-workspace imports +to relative ESM paths so service identities are shared across subpaths. Core remains +browser-safe and independent of backends; drivers and Node platform adapters are optional +peers imported only by the owning backend subpath or bin. The dashboard ships as prebuilt +assets, without UI runtime dependencies in the published manifest. + +The storage-independent data model and backend implementations retain this module graph: - `@triplex-build/triplex` owns triples, typed values, Effect service contracts, the in-memory ordered-KV hexastore, Datalog schemas and engines, snapshots, and subscriptions. It also owns @@ -137,27 +143,27 @@ hosts typed configuration as a modular layer over the same core. is reported to the caller but cannot roll back the already-committed source transaction. They do not maintain a second transaction log: causal audit reads belong to the authoritative `Triples` journal APIs. -- `@triplex-build/triplex-sql` is now migrations and `SqlQueryExecutor` (the SQL implementation of the +- `@triplex-build/triplex/sql` is now migrations and `SqlQueryExecutor` (the SQL implementation of the `QueryExecutor` SPI) shared by SQLite and PostgreSQL. Datalog SQL projections retain hidden canonical scalar-family columns until result decoding, and scalar-family joins and typed keyset ordering compare values consistently with the KV executor. A shared KV/SQLite/PostgreSQL conformance corpus is the regression boundary for this contract. - Backend packages construct the storage adapters and runtime layers for their platforms. -- `@triplex-build/triplex-cloudflare` composes its synchronous Durable Object SQLite adapter with the +- `@triplex-build/triplex/cloudflare` composes its synchronous Durable Object SQLite adapter with the shared SQL compiler/row decoder through the narrow `SqlStatementRunner` contract. Its public `CloudflareTriples.layer({ state, scope })` binds migrations, Datalog, writes, journal positions, command receipts, and opaque cursors to the same object-owned storage handle. It remains private and experimental. The shared backend corpus runs in workerd against SQLite-backed Durable Object storage by default; credentialed provider and operational testing is still incomplete. -- `@triplex-build/triplex-host` is the portable layer above public core APIs. It owns immutable +- `@triplex-build/triplex/host` is the portable layer above public core APIs. It owns immutable environment/tenant/database/generation identity, provider-safe keys, lifecycle and CAS registry contracts, authorization, the versioned data protocol, and target-side route fencing. It does not import a backend. Concrete gateway, registry, and provisioner wiring depends on both host and backend packages and initially lives in examples. -- `@triplex-build/triplex-cli` is an application package over the public core, config, SQLite, and +- The `triplex` bin is an application over the public core, config, SQLite, and PostgreSQL surfaces. It uses Effect v4's CLI modules for typed parsing and service composition; it does not expose or depend on backend adapters or `@triplex-build/triplex/internal`. -- `@triplex-build/triplex-http` is an optional application-facing package over public core and config +- `@triplex-build/triplex/http` is an optional application-facing package over public core and config exports. It compiles immutable releases into runtime schemas, Effect `HttpApi` contracts, OpenAPI, and backend-neutral handlers. Hosts own database selection, authorization, and server allocation; core and backend packages never import HTTP. @@ -169,24 +175,24 @@ hosts typed configuration as a modular layer over the same core. - Backend maturity is explicit rather than inferred from package existence. In-memory KV and SQLite are the supported baseline. PostgreSQL passes the shared conformance and isolation integration suite in CI but remains a pre-1.0 production candidate. Cloudflare and FoundationDB - are private, experimental and frozen workspace packages: they must keep compiling and their + ship as experimental and frozen subpaths from private workspaces: they must keep compiling and their existing tests must keep passing, but new surface targets only in-memory KV, SQLite and PostgreSQL and does not have to cover them. Cloudflare's workerd conformance is part of the default check; FoundationDB's native suite also runs in CI and remains opt-in locally. Triplex is greenfield before 1.0: breaking public API changes are allowed when they make the API right; update callers and record breaks in Changesets instead of keeping compatibility shims. -- `@triplex-build/triplex-testkit` is the public home for reusable backend conformance helpers. +- `@triplex-build/triplex/testkit` is the public home for reusable backend conformance helpers. - `examples/tenant-host-cloudflare` is the first concrete isolated host: a singleton registry Durable Object plus one SQLite Durable Object and scoped Effect runtime per database. Its Alchemy v2 stack is separate from the documentation stack and retains the Worker/namespace on removal. A workerd suite verifies two-tenant authorization, storage and cursor isolation, lifecycle fencing, restart persistence, and recreation. Provider credentials and tenant lifecycle remain control-plane concerns. -- `test/integration` owns tests that intentionally compose multiple publishable packages. +- `test/integration` owns tests that intentionally compose multiple public subpaths. - `test/stress` owns opt-in performance and scale tests. The core package may not depend on a backend package. SQL and backend packages may depend on core, -and concrete SQL backends may depend on `@triplex-build/triplex-sql`. This one-way graph keeps the core +and concrete SQL backends may depend on `@triplex-build/triplex/sql`. This one-way graph keeps the core usable in browsers and edge runtimes. The core package must not use `node:crypto` or another Node-only hashing API. Canonical encoding @@ -195,5 +201,5 @@ content-addressing semantics in Node.js, browsers, and edge runtimes. Repository and release maturity are tracked separately in [`docs/current-state.md`](docs/current-state.md). The GitHub repository has moved to -`bjacobso/triplex`; npm publication is still pending and is governed by +`bjacobso/triplex`; npm publication is governed by [`docs/releasing.md`](docs/releasing.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa8bf9d9..4be1abdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,9 +40,10 @@ Add a Changeset for a publishable package change. Release checks include a packe and the PostgreSQL integration gate in CI. Current maturity and release gates live in [`docs/current-state.md`](docs/current-state.md). -The seven public packages were bootstrapped with a short-lived npm token. Subsequent releases use +The single published package is `@triplex-build/triplex`; all implementation workspaces are +private. The original seven-package family was bootstrapped with a short-lived npm token. Releases use npm trusted publishing from GitHub owner `bjacobso`, repository `triplex`, workflow `release.yml`, -and the approval-protected `npm-publish` environment. Each package needs its own trusted publisher +and the approval-protected `npm-publish` environment. Only `@triplex-build/triplex` needs a trusted publisher connection with direct publishing allowed. The workflow grants `id-token: write` and uses an OIDC-capable npm CLI. diff --git a/PLAN.md b/PLAN.md index 33ef8e58..9a0d1968 100644 --- a/PLAN.md +++ b/PLAN.md @@ -416,15 +416,15 @@ the P2.8-P2.10 wave merges. Part A runs alone (it touches every manifest and imp homepage rewrite (W2.4) follows Part A for the single install line; the rest of Part B can run beside it. -| ID | Item | Lane | Depends | Status | Evidence | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------------------------- | ------- | ------------------------------------------------------------------------------------------ | -| W1 | One published package `@triplex-build/triplex` with subpaths `/sql`, `/sqlite`, `/postgres`, `/cloudflare`, `/foundationdb`, `/host`, `/http`, `/testkit`, `/mcp`, `/runner` (later); one `triplex` bin with `triplex mcp` and `triplex dashboard`; drivers as optional peers; `pnpm pack` of `main` installs alone; npm names to deprecate in `docs/releasing.md`; version bump; WorldVM import rewrites listed in the ask | alone | P2.8-P2.10 merged | active | 2026-11-11: `ergo/w1` from `ergonomics/next-2` | -| W2.1 | S3: `docs/current-state.md` and `docs/roadmap.md` match the shipped ergonomics work; Database client in the nav; exclude `docs/rfcs/**` from the public build | C | | pr-open | 2026-11-11: #83 (`site/wave-one`), preview https://triplex-docs-pr-83.bjacobso.workers.dev | -| W2.2 | S4: Forma link in the blog series index to forma-lang.com; `explorer.md` links back to the site | C | | pr-open | 2026-11-11: #83 | -| W2.3 | Blog dates, generated blog index, Atom feed `/feed.xml`, `/sitemap.xml`, `/robots.txt`, PNG share image with `og:*` tags | C | | pr-open | 2026-11-11: #83; build verifies feed, sitemap, robots, share image, RFC exclusion | -| W2.4 | S10: homepage steps and modeling table on the `Database` client with the single-package install line; README "Start here" (D12) | C | W1 | pending | | -| W2.5 | S8: replace the incubation banner with the proof line and the family strip | C | WorldVM `rollout/family.json` | blocked | The canonical family file does not exist yet in WorldVM | -| W3 | Runner implementation (D14) as the `/runner` subpath | B | W1 | pending | | +| ID | Item | Lane | Depends | Status | Evidence | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| W1 | One published package `@triplex-build/triplex` with subpaths `/sql`, `/sqlite`, `/postgres`, `/cloudflare`, `/foundationdb`, `/host`, `/http`, `/testkit`, `/mcp`, `/runner` (later); one `triplex` bin with `triplex mcp` and `triplex dashboard`; drivers as optional peers; `pnpm pack` of `main` installs alone; npm names to deprecate in `docs/releasing.md`; version bump; WorldVM import rewrites listed in the ask | alone | P2.8-P2.10 merged | pr-open | 2026-11-11: #84 (`ergonomics/one-package`, merge `21075a4`); check, Postgres 61/61 and pack check pass; rewrites in the ask Status | +| W2.1 | S3: `docs/current-state.md` and `docs/roadmap.md` match the shipped ergonomics work; Database client in the nav; exclude `docs/rfcs/**` from the public build | C | | pr-open | 2026-11-11: #83 (`site/wave-one`), preview https://triplex-docs-pr-83.bjacobso.workers.dev | +| W2.2 | S4: Forma link in the blog series index to forma-lang.com; `explorer.md` links back to the site | C | | pr-open | 2026-11-11: #83 | +| W2.3 | Blog dates, generated blog index, Atom feed `/feed.xml`, `/sitemap.xml`, `/robots.txt`, PNG share image with `og:*` tags | C | | pr-open | 2026-11-11: #83; build verifies feed, sitemap, robots, share image, RFC exclusion | +| W2.4 | S10: homepage steps and modeling table on the `Database` client with the single-package install line; README "Start here" (D12) | C | W1 | pr-open | 2026-11-11: #85 (`site/client-home`, on #84), preview https://triplex-docs-pr-85.bjacobso.workers.dev | +| W2.5 | S8: replace the incubation banner with the proof line and the family strip | C | WorldVM `rollout/family.json` | blocked | The canonical family file does not exist yet in WorldVM | +| W3 | Runner implementation (D14) as the `/runner` subpath | B | W1 | pr-open | 2026-11-11: #86 (`ergonomics/runner`, on #84); corpus 34/34 KV+SQLite, 16/16 Postgres; fixed a lost-wakeup race | Proof line check (2026-11-11): WorldVM's current runtime keeps each world's subject records and bases in Triplex (`worldvm/packages/runtime/src/subject/triplex-records.ts`, `triplex-basis.ts`; one @@ -479,6 +479,11 @@ Update this section last in every session. Newest first. One line per event: date, item, what happened, link. +- 2026-11-11: W3 merged on `ergonomics/runner`; Postgres found a coordinator lost-wakeup race and a + test-clock drift in the corpus helper, both fixed; check, Postgres 77/77, pack check pass. Opened #86. +- 2026-11-11: W2.4 done; opened #85 with preview. Stack: #82 ← #84 ← {#85, #86}; #83 on #82. +- 2026-11-11: W1 merged into `ergonomics/one-package`; check, Postgres 61/61 and pack check pass; + opened #84 (stacked on #82); import rewrites written to the ask's Status. Started W2.4 and W3. - 2026-11-11: P2.9 and P2.10 merged; Postgres 61/61 and pack check pass; one core test timed out only under shared-machine load (passes alone in 16 s). Opened #83 (site). Started W1. - 2026-11-11: #82 CI green (8/8). P2.8 merged. Started W2.1-W2.3 (site) on `site/wave-one`. diff --git a/README.md b/README.md index f3be1e86..e3a7592f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # Triplex > [!WARNING] -> Triplex is pre-1.0. The published `@triplex-build` packages require `effect@4.1.1`; +> Triplex is pre-1.0. The published `@triplex-build/triplex` package requires `effect@4.1.1`; > Effect 3 is not compatible. KV > and SQLite are the supported baseline, PostgreSQL is a production candidate, and Cloudflare and > FoundationDB are experimental. See [Current state](docs/current-state.md) for the exact maturity @@ -30,6 +30,24 @@ work the data implies. One store answers "what does this record look like today" believe last Tuesday", "who changed it and under which policy", and "what tasks should exist right now", without stitching together a database, an audit log, a config service, and a job queue. +## Start here + +Use the [Database client](https://triplex.build/database) for application commands and typed +reads. Declare your schema, publish a release, then use `db.command` with an actor and stable +command ID. It pins and enforces the release and returns a persisted receipt; retries return the +same receipt. Read with `db.get` and `db.query`, capture historical views with `db.asOf`, and +explain their facts with [`why`](https://triplex.build/why). + +```sh +npm install @triplex-build/triplex effect +``` + +For SQLite, add `@effect/sql-sqlite-node` and import the backend from +`@triplex-build/triplex/sqlite`. Start with the +[checked client quickstart](https://triplex.build/getting-started), then follow the +[modeling guide](https://triplex.build/agents) to map your domain to commands, temporal changes, +and derived work. The [documentation](https://triplex.build) covers the complete API. + ## Why Triplex? Most applications keep state in a database and everything that explains that state somewhere else: @@ -72,10 +90,10 @@ and never ask about history or provenance. Triplex is a system of record, not a ## Installation and evaluation -Install the core package and its Effect peer dependency from npm: +Install the single package and its Effect peer dependency from npm: ```sh -npm install @triplex-build/triplex effect@4.1.1 +npm install @triplex-build/triplex effect ``` To run the checked repository examples from a source checkout: @@ -85,7 +103,7 @@ git clone https://github.com/bjacobso/triplex.git cd triplex corepack enable pnpm install --frozen-lockfile -pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/getting-started.ts +pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/client-getting-started.ts ``` Do not use the superseded `@bjacobso` canaries for new work. Triplex is ESM-only, targets Node.js @@ -96,8 +114,9 @@ in [Releasing Triplex](docs/releasing.md). ## Quick start -`Triples` is the primary service. The core package includes an in-memory ordered-KV layer, so the -same program can write facts, read patterns, and run Datalog without another package: +`Triples` is the primitive service beneath the Database client, useful for host infrastructure +and migrations. The package includes an in-memory ordered-KV layer, so this lower-level program +can write facts, read patterns, and run Datalog: ```ts check import { Effect } from "effect"; @@ -388,7 +407,7 @@ while `@foldkit/ui` supplies accessible controls and Tailwind supplies styling. ## Configuration-derived HTTP API -`@triplex-build/triplex-http` compiles persisted entity configuration into validated REST handlers and +`@triplex-build/triplex/http` compiles persisted entity configuration into validated REST handlers and OpenAPI 3.1 without retaining the TypeScript DSL declarations. Hosts provide `Triples`, `ConfigStore`, an authorization layer, and their chosen HTTP server; KV and SQLite share the same Fetch-level integration suite. Wire objects use full global keywords, writes are atomic and pinned @@ -398,13 +417,13 @@ See the [HTTP API guide](docs/http-api.md) and [standalone host](examples/http-a ## Agent CLI -`@triplex-build/triplex-cli` exposes the same database and configuration services through Effect v4's +The `triplex` bin exposes the same database and configuration services through Effect v4's typed CLI modules. It is JSON-first, non-interactive, schema-validates external input, and supports SQLite, PostgreSQL, and database-scoped PostgreSQL: ```sh -pnpm turbo run build --filter=@triplex-build/triplex-cli... -triplex() { node --disable-warning=ExperimentalWarning packages/cli/dist/cli.js "$@"; } +pnpm turbo run build --filter=@triplex-build/triplex +triplex() { node --disable-warning=ExperimentalWarning packages/triplex/dist/bin.js "$@"; } triplex --sqlite ./app.db describe triplex --sqlite ./app.db entity types triplex --sqlite ./app.db --pretty config object form quiz/bitemporal-facts @@ -467,21 +486,21 @@ pagination, projection, and host/runtime boundaries. | Package | Surface | Status | | ------------------------------------- | ----------------------------------- | ------------------------------------------------------------- | | `@triplex-build/triplex` | `KvTriples.layer` | Supported in-memory baseline | -| `@triplex-build/triplex-sqlite` | `SqliteTriples.layer({ filename })` | Supported durable baseline | -| `@triplex-build/triplex-postgres` | `PgTriples.layer(config)` | Candidate; shared integration/conformance is currently opt-in | -| `@triplex-build/triplex-cloudflare` | `CloudflareTriples.layer(...)` | Experimental | -| `@triplex-build/triplex-foundationdb` | `FdbTriples.layer(config)` | Experimental | - -`@triplex-build/triplex-sql` contains shared migrations and SQL query execution rather than a database. -`@triplex-build/triplex-testkit` contains the behavioral conformance corpus used by adapters. -`@triplex-build/triplex-host` is a private experimental package for portable tenant identity, +| `@triplex-build/triplex/sqlite` | `SqliteTriples.layer({ filename })` | Supported durable baseline | +| `@triplex-build/triplex/postgres` | `PgTriples.layer(config)` | Candidate; shared integration/conformance is currently opt-in | +| `@triplex-build/triplex/cloudflare` | `CloudflareTriples.layer(...)` | Experimental | +| `@triplex-build/triplex/foundationdb` | `FdbTriples.layer(config)` | Experimental | + +`@triplex-build/triplex/sql` contains shared migrations and SQL query execution rather than a database. +`@triplex-build/triplex/testkit` contains the behavioral conformance corpus used by adapters. +`@triplex-build/triplex/host` is an experimental subpath for portable tenant identity, authorization, lifecycle, and protocol contracts. The deployable reference wiring is in [`examples/tenant-host-cloudflare`](examples/tenant-host-cloudflare). Switching the supported quick start to SQLite changes only the provided layer: ```ts -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; const SqliteLive = SqliteTriples.layer({ filename: "app.db" }); ``` @@ -491,7 +510,7 @@ const SqliteLive = SqliteTriples.layer({ filename: "app.db" }); The standalone layer owns its pool and applies Triplex migrations as a convenience: ```ts -import { PgTriples } from "@triplex-build/triplex-postgres"; +import { PgTriples } from "@triplex-build/triplex/postgres"; const TriplexLive = PgTriples.layerFromUrl(process.env.DATABASE_URL!); ``` @@ -503,7 +522,7 @@ This path creates no pool and runs no migration: import { Effect, Layer } from "effect"; import { SqlClient } from "effect/sql"; import { EntityId, Triples, string } from "@triplex-build/triplex"; -import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex-postgres"; +import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex/postgres"; const HostSql = makePostgresqlLayerUnmigratedFromUrl(process.env.DATABASE_URL!); const AppDatabase = PgTriples.layerFromSqlClient({ scope: "app/main" }).pipe( @@ -544,7 +563,7 @@ in that pool starts in the deterministic Triplex schema, and its scope is embedd cursors. The schema and v1 migration must already exist; `layerForDatabaseMigrated` is the explicit provisioning convenience for setup tools and tests. -Triplex exports the ordered `migrations` and `runMigrations` from `@triplex-build/triplex-sql`. +Triplex exports the ordered `migrations` and `runMigrations` from `@triplex-build/triplex/sql`. Production hosts should run them from their own deployment process against the same database or schema before constructing an unmigrated runtime. Triplex uses `triplex_schema_migrations`, not the host application's migration table. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c6c3489f..f32825dc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -27,6 +27,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [ { text: "Configuration walkthrough", link: "/configuration-versioning" }, { text: "Derivations", link: "/derivations" }, { text: "Live queries", link: "/live-queries" }, + { text: "Derivation runner", link: "/runner" }, { text: "Explain with why", link: "/why" }, { text: "Provenance", link: "/provenance" }, ], @@ -139,6 +140,7 @@ export default defineConfig({ { text: "Configuration walkthrough", link: "/configuration-versioning" }, { text: "Derivations", link: "/derivations" }, { text: "Live queries", link: "/live-queries" }, + { text: "Derivation runner", link: "/runner" }, { text: "Explain with why", link: "/why" }, { text: "Provenance", link: "/provenance" }, { text: "Current state", link: "/current-state" }, diff --git a/docs/agents.md b/docs/agents.md index 0f64f6e5..51a6edd2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -11,10 +11,10 @@ you have a specific reason not to, and each section links to the reference behin Start with `@triplex-build/triplex/database`: declare your model, publish it, then open the [schema-bound client](/database). The examples below come from the checked, executable [client site-safety scenario](https://github.com/bjacobso/triplex/blob/main/docs/snippets/client-site-safety.ts). -CI type-checks and runs it alongside the lower-level homepage scenario. This page is also available as +CI type-checks and runs it alongside the lower-level site-safety scenario. This page is also available as [Markdown](/agents.md), and the full documentation is available as [`/llms-full.txt`](/llms-full.txt). -Agents can also inspect and write a database through the private, experimental [MCP server](/mcp). +Agents can also inspect and write a database through the experimental [MCP server](/mcp). Start with its `describe` tool to discover the live release and example transactions, then use the same CLI operations through MCP with an actor and command ID on every write. @@ -202,7 +202,7 @@ Raw duplicate commands fail with `CommandAlreadyCommittedError`; recover the rec `transactionByCommand`. Raw read-decide-write commands must pass `EntityState` preconditions, and `Change.correct`, `Change.replace` and `Change.end` return operations and preconditions that must be submitted together. Install [SchemaBound](/datalog#query-shape) for release-checked raw queries. -The [homepage scenario](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety) +The [primitive site-safety scenario](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety) executes this primitive form and its receipt recovery as a separate checked example. ## Anti-patterns @@ -233,7 +233,8 @@ Only retry a retryable conflict after reloading facts. Add `--debug` to include `cause` string; see [Error diagnostics](/errors). Writes require `meta.actor` and `meta.commandId`. ```sh -npm install --global @triplex-build/triplex-cli +npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ + @effect/platform-node @effect/platform-node-shared triplex --sqlite ./app.db describe # capabilities and live release schema triplex --sqlite ./app.db entity types triplex --sqlite ./app.db entity facts worker:maria diff --git a/docs/backends.md b/docs/backends.md index 20d9e416..bb7c7b76 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -79,7 +79,7 @@ original results even after the correction. cursor identity; physical partitioning and authorization are separate host responsibilities. 2. Implement the base storage and query contract with `Capabilities.none`. Establish rollback, duplicate-command behavior, typed values, history, and pinned pages before adding projections. -3. Run `runtimeConformance` from `@triplex-build/triplex-testkit` against a fresh isolated database. +3. Run `runtimeConformance` from `@triplex-build/triplex/testkit` against a fresh isolated database. Add backend-specific concurrency, interruption, crash, and migration exercises. 4. Add durable snapshot or change-emission providers explicitly if your deployment needs them. Measure their failure behavior separately from source transaction correctness. diff --git a/docs/commit-validators.md b/docs/commit-validators.md index e8a73db1..fad97146 100644 --- a/docs/commit-validators.md +++ b/docs/commit-validators.md @@ -254,7 +254,7 @@ rolls back the host rows. Tests for both backends cover this. KV backends have n | Cloudflare | Same (experimental). Validators **must be synchronous**: the Durable Object `transactionSync` boundary runs the transaction with `Effect.runSyncExit`. | | FoundationDB | Same code path as KV (experimental). Validator reads add read-conflict ranges, and their time counts toward FoundationDB's 5-second transaction limit. | -The shared `@triplex-build/triplex-testkit` conformance cases cover validators. They run on KV, +The shared `@triplex-build/triplex/testkit` conformance cases cover validators. They run on KV, SQLite, PostgreSQL, and both Cloudflare harnesses. ### Performance diff --git a/docs/current-state.md b/docs/current-state.md index 015c5a0a..93e86021 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -38,6 +38,9 @@ work that is not complete. - Content-addressed Datalog derivations with explicit candidate identity, source provenance, immutable materialization runs, freshness positions, temporal wakeups, pure reconciliation, and read-only hypothetical overlays. +- A [durable derivation runner](/runner) with acknowledged candidate snapshots, prepared batches, + Outbox delivery, bounded retries, poison pause/resume, checkpoints and offline temporal replay. + The compliance host uses runner handlers and schema-bound commands for open → satisfied → reopened. - Standalone demos for basic linked facts and the application-owned compliance/work boundary, plus a Foldkit/Tailwind browser dashboard that exercises facts, reflected entity-type tables, config-rendered form previews, raw Datalog, derivations, the journal, and full configuration @@ -70,7 +73,7 @@ work that is not complete. back together without an internal second pool. - The VitePress documentation is published as a Cloudflare assets-only Worker through the repository's Effect-native Alchemy stack at . -- A private portable host package now defines immutable tenant database identity, authorization, +- A portable host subpath now defines immutable tenant database identity, authorization, CAS lifecycle contracts, route fencing, and a schema-decoded v1 data protocol. The Cloudflare package exposes a complete `CloudflareTriples` layer using one Durable Object SQLite handle for storage and Datalog, and passes the shared backend corpus both in its native-SQL harness and in @@ -120,8 +123,8 @@ It remains experimental; these tests do not establish production operations or s ## Honest limitations -- Seven public packages are published under the `@triplex-build` organization scope. The GitHub - repository is `bjacobso/triplex`. +- The single `@triplex-build/triplex` package replaces the original seven public packages. + The GitHub repository is `bjacobso/triplex`; see [releasing](/releasing) for old-name deprecations. - The superseded `@bjacobso` bootstrap packages are not the installation path for new consumers. - `SubscriptionManager` discovers dependencies and reports possible invalidations. It does not push result deltas or automatically re-run queries. @@ -142,7 +145,7 @@ It remains experimental; these tests do not establish production operations or s - Config decision verification proves internal content integrity given a trusted decision root. It does not independently prove that an external actor chose the right rule or answer. - Constraint enforcement is opt-in and serialized through the commit-position boundary. Direct - adapter writes, authorization, general Datalog invariants, inbox/outbox delivery, timers, retries, + adapter writes, authorization, general Datalog invariants, external inbox/outbox transport, and application workflow lifecycle are host responsibilities. Hosts can enforce authorization and invariants atomically with commit validators, but Triplex ships no policy grammar. Validators run while the commit is serialized, and on Cloudflare they must be synchronous. @@ -156,14 +159,14 @@ It remains experimental; these tests do not establish production operations or s ## Release follow-up -1. Configure npm trusted publishing for all seven packages and verify a tokenless `next` publish. -2. Verify the published packages in a clean registry-only consumer, including peer dependencies, +1. Configure npm trusted publishing for the single package and verify a tokenless `next` publish. +2. Verify the single package in a clean registry-only consumer, including peer dependencies, provenance, CLI, and exports. 3. Deprecate the superseded `@bjacobso` canaries with migration guidance. -Cloudflare and FoundationDB are private for the first release. Their source stays in the monorepo -and continues to compile, but Changesets cannot publish them accidentally. +Cloudflare and FoundationDB ship as experimental, frozen subpaths in the single package. +Their implementation workspaces stay private and are excluded from Changesets. The six pre-stable `@bjacobso` canary packages must be deprecated after the corresponding -`@triplex-build` stable packages are available. The old artifacts remain available so existing +single `@triplex-build/triplex` release is available. The old artifacts remain available so existing canary installs receive a migration message instead of breaking. diff --git a/docs/custom-runtimes.md b/docs/custom-runtimes.md index 9bfb4fa5..925b08d5 100644 --- a/docs/custom-runtimes.md +++ b/docs/custom-runtimes.md @@ -69,7 +69,7 @@ query result types, adapter errors, snapshot and emission contracts, and `StoreC `TriplesLive`, SQL compilation functions, and hexastore key encoders remain implementation details. `StorageAdapterService` has no `rawQuery` requirement. SQL adapters can implement the separate -`SqlStorageAdapterService` refinement exported by `@triplex-build/triplex-sql`. +`SqlStorageAdapterService` refinement exported by `@triplex-build/triplex/sql`. Custom `StorageAdapterService` implementations must provide `recordedPositionAt(instant, head)` and `dependencyStateAll(resolvedBasis)`. The former returns the greatest committed journal @@ -139,8 +139,8 @@ import { changeEmission, type ChangeEmitterService, } from "@triplex-build/triplex/runtime"; -import { SqlSnapshotsLive } from "@triplex-build/triplex-sql"; -import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare"; +import { SqlSnapshotsLive } from "@triplex-build/triplex/sql"; +import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex/cloudflare"; declare const state: DOState; declare const emitter: ChangeEmitterService; @@ -244,8 +244,8 @@ Run the shared suite in one expression using testkit: ```ts check import { Effect } from "effect"; import { DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; -import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare"; -import { runtimeConformance } from "@triplex-build/triplex-testkit"; +import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex/cloudflare"; +import { runtimeConformance } from "@triplex-build/triplex/testkit"; declare const isolatedState: DOState; await Effect.runPromise( diff --git a/docs/datalog-performance.md b/docs/datalog-performance.md index 0e12edd8..fc350a89 100644 --- a/docs/datalog-performance.md +++ b/docs/datalog-performance.md @@ -80,7 +80,7 @@ bounded Datalog contract; it does not make that broader claim. The benchmark lives in `test/stress/scripts/datalog-pagination.ts` and is opt-in: ```sh -pnpm exec turbo run build --filter=@triplex-build/triplex-sqlite --filter=@triplex-build/triplex-postgres +pnpm exec turbo run build --filter=@triplex-build/triplex pnpm --filter triplex-stress benchmark:datalog PG_BENCH_URL=postgresql://localhost/triplex_bench pnpm --filter triplex-stress benchmark:datalog ``` @@ -156,8 +156,8 @@ To inspect the plans, run these tests with `TRIPLEX_EXPLAIN=1` (add `--disableCo if the test reporter suppresses output): ```sh -TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex-sqlite exec vitest run test/numeric-index.test.ts -PG_TEST_URL=postgresql://localhost/disposable_test TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex-postgres exec vitest run test/integration/postgresql.test.ts -t 'expression range' --disableConsoleIntercept +TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex/sqlite exec vitest run test/numeric-index.test.ts +PG_TEST_URL=postgresql://localhost/disposable_test TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/postgresql.test.ts -t 'expression range' --disableConsoleIntercept ``` ### Scope of consumer indexing diff --git a/docs/derivations.md b/docs/derivations.md index 93b994ca..1bc6d448 100644 --- a/docs/derivations.md +++ b/docs/derivations.md @@ -116,19 +116,15 @@ ordinary Datalog. It returns the latest assertion-or-retraction commit position and the earliest recorded future `validFrom` or `validTo` for the fixed attribute set. -Every evaluation and materialized run exposes the same conservative `nextTemporalBoundary`. A host -scheduler should wake and rematerialize at that instant: - -```ts -if (run.nextTemporalBoundary !== undefined) { - scheduler.wakeAt(run.nextTemporalBoundary, openTraining.id); -} -``` +Every evaluation and materialized run exposes the conservative `nextTemporalBoundary`, including +empty runs suppressed by negation. The [durable runner](/runner) owns delivery, retries and +boundary replay for registered definitions. It uses the same public dependency planner as live +queries; it does not consume their observation stream. The schedule is attribute-conservative: an unrelated entity sharing a dependency attribute may -cause an extra wakeup, but recorded future-effective or expiring evidence is not omitted. Dynamic -attribute definitions use an explicit journal fallback and should not be hot-path materializers. -Triplex owns boundary discovery, not timer delivery or retry policy. +cause an extra wakeup, but recorded future-effective or expiring evidence is not omitted. +Manual materialization remains available for previews and audit projections; callers using it +without the runner own their timer delivery. ## Hypothetical overlays @@ -166,7 +162,7 @@ wakeups, and pure diffs. The host remains responsible for: - durable workflow or requirement occurrences; - assignments, conversations, evidence disposition, and authorization; - command naming and response caching; -- timer delivery, integrations, retries, inboxes, and outboxes; and +- integrations, idempotent outgoing effects, inboxes, and application outboxes; and - interpreting candidate addition or removal as product behavior. The standalone [compliance host demo](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) composes those responsibilities diff --git a/docs/getting-started.md b/docs/getting-started.md index 00007067..9f80bbaa 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,8 +4,8 @@ Start with the [Database client](/database) from `@triplex-build/triplex/databas declares entity types, publishes a release, opens the client, and runs one command, a typed read, a Schema-projected query and a historical read. -::: warning Pre-1.0 packages -The `@triplex-build` packages are available from npm and require `effect@4.1.1`. +::: warning Pre-1.0 package +The `@triplex-build/triplex` package is available from npm and requires `effect@4.1.1`. Effect 3 is incompatible. Do not use the superseded `@bjacobso` package names for new work. ::: @@ -15,10 +15,10 @@ Effect 3 is incompatible. Do not use the superseded `@bjacobso` package names fo - Node.js 22.19.0 or newer - Corepack and pnpm 10.11.0 (the repository declares the exact package-manager version) -For an application, install the published core package and its Effect peer dependency: +For an application, install the single published package and its Effect peer dependency: ```sh -npm install @triplex-build/triplex effect@4.1.1 +npm install @triplex-build/triplex effect ``` ## Run the schema-bound client example @@ -64,6 +64,10 @@ It prints: 6. `db.asOf` captures a stable read-only view. The receipt's exact position selects what was known at that command; `validAt` selects business time. +Use the [`Why` service](/why) at the same basis to explain actors, commands, and pinned +releases. The [homepage example](https://github.com/bjacobso/triplex/blob/main/docs/snippets/home/client.ts) +demonstrates a correction followed by a historical read and its explanation. + `Effect.gen` sequences Effects with generator syntax. `Effect.provide` supplies services and `Effect.runPromise` runs the fully provided program. Publishing precedes client acquisition and both share one `Store`. Real hosts generally publish during deployment. @@ -91,10 +95,10 @@ entity, or recover retry receipts. Prefer the client for application commands an SQLite is the supported local persistent backend. Install it alongside core and Effect: ```sh -npm install @triplex-build/triplex-sqlite +npm install @triplex-build/triplex effect @effect/sql-sqlite-node ``` -In the first program, import `SqliteTriples` from `@triplex-build/triplex-sqlite` and replace +In the first program, import `SqliteTriples` from `@triplex-build/triplex/sqlite` and replace `KvTriples.layer` inside `Store` with `SqliteTriples.layer({ filename: "./triplex.db" })`. Keep `ConfigStore.layer` over that same store and provide the client after publishing. On a persistent database, issue a new command ID only for a new domain command; rerunning the example @@ -106,33 +110,29 @@ the explicit composition in [Host integration](/host-integration). ## Use Triplex from a source checkout -To try unreleased changes from a checkout in a project outside the repository workspace, build the -packages and link their built output. Package exports point to generated `dist` files, so an -unbuilt checkout cannot be imported. Build the packages you need and their dependencies: +To try unreleased changes in an outside project, build and pack the single public package from +`main`. Its exports point to generated files, so build before packing: ```sh cd triplex pnpm install --frozen-lockfile -pnpm turbo run build --filter=@triplex-build/triplex-sqlite... +pnpm turbo run build --filter=@triplex-build/triplex +pnpm --dir packages/triplex pack --pack-destination /tmp ``` -Then link them into your project. Link Effect from the copy core resolves too, so your code and -Triplex share one Effect instance; two copies make service tags disagree: +Install the resulting tarball in your project alongside Effect (and the SQLite driver if needed): ```sh cd ../my-project -mkdir -p node_modules/@triplex-build -ln -sfn "$PWD/../triplex/packages/core" node_modules/@triplex-build/triplex -ln -sfn "$PWD/../triplex/packages/sqlite" node_modules/@triplex-build/triplex-sqlite -ln -sfn "$PWD/../triplex/packages/core/node_modules/effect" node_modules/effect +npm install /tmp/triplex-build-triplex-*.tgz effect @effect/sql-sqlite-node ``` -Rebuild after changing the checkout. For a copy that installs like the published packages, run -`pnpm pack` in each package directory and install the tarballs with `effect@4.1.1` instead. +Rebuild and pack again after changing the checkout. One tarball contains all public subpaths; +there are no separate backend packages to link or install. -Don't use `docs/snippets/tsconfig.json` for an outside project. It maps the package names to -`packages/*/src` so the documentation can type-check against unbuilt sources, which bypasses the -public exports your application will use. +Don't use `docs/snippets/tsconfig.json` for an outside project. It maps package names to source +so repository examples can type-check unbuilt changes, bypassing the public exports your +application will use. ## Next steps diff --git a/docs/host-integration.md b/docs/host-integration.md index 132a9d12..dc30ab57 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -106,7 +106,7 @@ A host-owned Effect SQL client can back Triplex without a hidden second pool: import { Effect, Layer } from "effect"; import { SqlClient } from "effect/sql"; import { EntityId, Triples, string } from "@triplex-build/triplex"; -import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex-postgres"; +import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex/postgres"; const HostSql = makePostgresqlLayerUnmigratedFromUrl(process.env.DATABASE_URL!); const DatabaseLive = PgTriples.layerFromSqlClient({ scope: "host/main" }).pipe( @@ -164,7 +164,7 @@ boundary. ## Host-controlled migrations -`@triplex-build/triplex-sql` exports the ordered `migrations` definitions and `runMigrations`. Run them +`@triplex-build/triplex/sql` exports the ordered `migrations` definitions and `runMigrations`. Run them from the host's deployment process against the same database or scoped schema before constructing an unmigrated runtime. Triplex records its state in `triplex_schema_migrations`, avoiding the host's migration table. diff --git a/docs/http-api.md b/docs/http-api.md index 79a1d1ce..84dc885f 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -1,6 +1,6 @@ # Configuration-derived HTTP API -`@triplex-build/triplex-http` turns a persisted `ConfigSnapshot` into runtime request/response schemas, +`@triplex-build/triplex/http` turns a persisted `ConfigSnapshot` into runtime request/response schemas, an Effect `HttpApi`, OpenAPI 3.1, and REST handlers. It never needs the original `Attribute` or `EntityType` TypeScript values after deployment. The package depends only on public Triplex core and config APIs; the host chooses and provides its backend and server. @@ -9,8 +9,8 @@ and config APIs; the host chooses and provides its backend and server. ```ts import { ConfigStore } from "@triplex-build/triplex/config"; -import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex/http"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { Layer } from "effect"; const database = ConfigStore.layer.pipe( diff --git a/docs/index.md b/docs/index.md index 80bb4ccf..df8288ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,16 +15,14 @@ pageClass: triplex-index

Triplex · embedded fact database · pre-1.0

The database that remembers why.

Facts change. Rules change. Your record of what happened shouldn’t.

-

Triplex keeps facts, time, and versioned rules together in your TypeScript process. Every write records who acted, which rules applied, and when it was true. Audit questions and open work become queries instead of investigations.

+

Triplex is a bitemporal fact database that keeps facts, time, and versioned rules together in your TypeScript process. Every write records who acted, which rules applied, and when it was true. Audit questions and open work become queries instead of investigations.

Get started See the whole stack ↓
-```sh -npm install @triplex-build/triplex effect@4.1.1 -``` +
npm install @triplex-build/triplex effect

Effect 4 required · MIT licensed · In-memory and SQLite supported · PostgreSQL is a production candidate · Current state

@@ -67,7 +65,7 @@ npm install @triplex-build/triplex effect@4.1.1
t₁ / t₂
Two clocks. What was true, and what you knew when.
#
Pinned rules. The release behind every decision.
-
=>
Derived work. What should happen next.
+
=>
Derived work. What should happen next.
@@ -75,16 +73,12 @@ npm install @triplex-build/triplex effect@4.1.1

02

-

From schema to REST API in one file.

+

From schema to an answer you can explain.

-

Model, versioned rules, a command, an audit, open work, storage, and HTTP. Each step below is the next region of the same file, in order. Every block is type-checked, and CI runs the file to produce every output on this page.

-

The story. Maria is trained for Harbor from March 1 to June 1 and placed there on March 2 under release hr-2026.1. The rules then change: hr-2026.2 requires a supervisor. Later, an auditor finds that the certificate actually began on March 5.

-
-
- - - +

Start with the Database client: declare a schema, publish it, and handle each action as one command. Every TypeScript block below comes from the same checked, executable file. CI runs it to verify receipts, typed reads, historical state, and attribution.

+

The story. Dana records Maria under release workers-v1. An auditor later corrects her name. Today’s read shows the correction; a view at Dana’s receipt still shows what Dana recorded, with the actor and rules that explain it.

+

For durable SQLite, add @effect/sql-sqlite-node to the install line. Follow Getting started for a complete first program.

@@ -94,14 +88,13 @@ npm install @triplex-build/triplex effect@4.1.1

Step 01 · Model

Declare the domain once.

-

Attributes are global, typed facts. Entity types say how each one is used: required, cardinality, and references.

-

Changing the rules means a new version of the type, not a migration. Old facts stay valid under the release that accepted them.

+

Attributes own global keys and value types. Entity types declare aliases, required fields, cardinality, and reference targets. The same declarations drive command inputs and typed reads.

Typed configuration →
-
app.tsmodel
+
client.tsmodel
-<<< @/snippets/home/site-safety/app.ts#model +<<< @/snippets/home/client.ts#model
@@ -110,13 +103,13 @@ npm install @triplex-build/triplex effect@4.1.1

Step 02 · Rules

Publish the rules as a release.

-

A release is immutable and content-addressed. The story publishes hr-2026.1 with Placement, then hr-2026.2 with PlacementV2. Each time, live moves and every earlier release stays exactly as it was.

+

The host publishes an immutable, content-addressed release before acquiring the client. Database.layer checks the model against live; every new command pins and enforces the release it admits.

Releases, refs, and rollback →
-
app.tsrelease
+
client.tsrelease
-<<< @/snippets/home/site-safety/app.ts#release +<<< @/snippets/home/client.ts#release
@@ -125,95 +118,62 @@ npm install @triplex-build/triplex effect@4.1.1

Step 03 · Command

One command, one atomic transaction.

-

Placement.assert turns typed attribute values into facts. Dana’s placement records who acted, a command ID that makes retries safe, and, through GraphConstraint.pinned, the release whose constraints were checked in the same transaction.

-

Under hr-2026.2, the same command is rejected. It writes no facts, no receipt, and no journal entry.

-Transactions and the journal → +

db.command returns a persisted receipt with actor, command ID, release, and commit position. The builder’s tx.create encodes typed values and checks that the entity is new. Retrying the same command ID returns the original receipt.

+Commands and receipts →
-
app.tscommand
- -<<< @/snippets/home/site-safety/app.ts#command - -
The same command under hr-2026.2rejected
+
client.tscommand
-<<< @/snippets/home/site-safety/output/rejected.json +<<< @/snippets/home/client.ts#command
-
+
-

Journal

-

After the whole story, the journal reads:

-
-
- - - -
-

Nothing is overwritten. The auditor’s correction at position 6 retracts the old belief and asserts the new one, and the journal keeps both. The rest of the story is in story.ts.

-
- -
-
-

Step 04 · Audit

-

Ask what you knew, and under which rules.

-

validAt is the day in question. recordedAt is the instant of Dana’s receipt. At that instant, the record said Maria was trained. After the correction, it says she was not.

-

Both answers can be proven, and the receipt still points at hr-2026.1 even though live has moved.

-Recorded time and valid time → +

Step 04 · Read

+

Read through your schema.

+

db.get returns alias-keyed values or null. db.list returns a bounded page and a cursor. For joins, db.query checks attributes against the release and decodes each binding with an Effect Schema projection.

+Typed reads and queries →
-
app.tsask
+
client.tsread
-<<< @/snippets/home/site-safety/app.ts#ask - -
Outputaudit.json
- -<<< @/snippets/home/site-safety/output/audit.json +<<< @/snippets/home/client.ts#read
-
+
-

Step 05 · Open work

-

Derive what needs to happen.

-

The obligation is a Datalog query, not a queue. On March 3, the corrected record exposes a gap. On April 1, nothing is open. On June 1, the training expires and the task reopens, even though nobody wrote anything.

-

recheckAt comes from nextTemporalBoundary. Schedule a wake-up for it instead of polling.

-Derivations and reconciliation → +

Step 05 · Audit

+

Keep the correction and the original belief.

+

tx.correct replaces a fact over its original valid interval and keeps the recorded history. db.asOf at the receipt’s exact position returns a stable, read-only view: what was known then, for the business date in question.

+Recorded time and valid time →
-
app.tsderive
- -<<< @/snippets/home/site-safety/app.ts#derive - -
Evaluated on March 3, April 1, and June 1open-work.json
+
client.tsaudit
-<<< @/snippets/home/site-safety/output/open-work.json +<<< @/snippets/home/client.ts#audit
-

Step 06 · Storage + HTTP

-

Pick a store. Get an API.

-

Storage is a layer. The same program runs in memory for tests and the browser, on SQLite, or on PostgreSQL.

-

EntityHttp serves the live release as REST routes with OpenAPI docs. Every read accepts both clocks, so the audit question works over HTTP too.

-Configuration-derived HTTP API → +

Step 06 · Explain + run

+

Ask why, at the same cut.

+

The Why service explains the visible facts, their asserting transactions, actors, and pinned releases. Pass the historical view’s basis so the explanation and typed read agree. Compose the client and explanation services over one shared store.

+Explain a fact with why →
-
app.tsrun
- -<<< @/snippets/home/site-safety/app.ts#run - -
Launch itserve.ts
+
client.tswhy
-<<< @/snippets/home/site-safety/serve.ts +<<< @/snippets/home/client.ts#why -
GET /api/rest/latest/workers/worker:maria?validAt=…, with and without recordedAthttp.json
+
One store for the application lifetimerun
-<<< @/snippets/home/site-safety/output/http.json +<<< @/snippets/home/client.ts#run
@@ -253,7 +213,7 @@ npm install @triplex-build/triplex effect@4.1.1

04

-

From a back-office domain to Triplex primitives.

+

From a back-office domain to client commands.

Most back-office systems are built from the same parts. This is where each part goes, and where it shows up in Maria’s story.

@@ -262,17 +222,18 @@ npm install @triplex-build/triplex effect@4.1.1
-| In your domain | In Triplex | In the scenario | -| --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------- | -| Things: workers, sites, cases, accounts | Stable `EntityId`s and an `EntityType` per kind | `worker:maria`, `Worker` | -| Properties and relationships | Global `Attribute` keys, with `Attribute.ref` for relationships | `:placement/site` | -| Schemas, policies, forms | Config nodes committed as an immutable release, pointed to by `live` | `hr-2026.1`, `hr-2026.2` | -| User and system actions | One `transact` per command with `actor`, `commandId`, `configSnapshot` | `create:placement:maria-harbor` | -| Evidence that starts or expires | `validFrom` and `validTo` on the assertion | Training valid March 1 to June 1 | -| Corrections | Retract and assert in one transaction; history is kept | The auditor’s fix | -| Obligations and to-dos | A `Derivation` whose candidates you reconcile | `task.site-safety-training` | -| Tickets, notifications, SLAs | Your code, driven by reconciliation diffs and wake-up boundaries | Host-owned | -| Audit questions | A temporal basis, command receipts, and the journal | Step 04, Audit | +| In your domain | In Triplex | In the example or guide | +| --------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- | +| Things: workers, sites, cases, accounts | Declare an `EntityType`; create with `tx.create` | `Worker`, `worker:maria` | +| Properties and relationships | Typed `Attribute` keys and `Attribute.ref` | `:worker/name` | +| Schemas, policies, forms | Publish a release; acquire `Database.layer` | `workers-v1` | +| User and system actions | One `db.command` per action, with actor and stable command ID | Dana’s create command and receipt | +| Evidence that starts or expires | `validFrom` and `validTo` on builder writes | Maria’s facts start on March 2 | +| Corrections and changes from a date | `tx.correct`, `tx.replace`, or `tx.end` inside a command | The auditor’s name correction | +| Current state and lists | `db.get`, `db.list`, and Schema-projected `db.query` | Step 04, Read | +| Audit questions | `db.asOf`, `db.receipt`, `db.history`, and `why.why` | Steps 05–06 | +| Obligations and to-dos | A `Derivation` over `Triples`, with an explicit release | [Modeling guide](/agents#_4-model-obligations-as-derivations) | +| Tickets, notifications, SLAs | Host code driven by reconciliation diffs and wake-up boundaries | [Host integration](/host-integration) |
@@ -288,10 +249,10 @@ npm install @triplex-build/triplex effect@4.1.1

On PostgreSQL, Triplex can share your application’s transaction, so your own rows, Triplex facts, the journal, and an outbox row commit or roll back together.

  • In-memory KV@triplex-build/triplex · tests, browser, edgeSupported
  • -
  • SQLite@triplex-build/triplex-sqlite · durable baselineSupported
  • -
  • PostgreSQL@triplex-build/triplex-postgresPre-1.0 production candidate
  • -
  • Cloudflare Durable ObjectsPrivate workspace packageExperimental
  • -
  • FoundationDBPrivate workspace packageExperimental
  • +
  • SQLite@triplex-build/triplex/sqlite · durable baselineSupported
  • +
  • PostgreSQL@triplex-build/triplex/postgresPre-1.0 production candidate
  • +
  • Cloudflare Durable Objects@triplex-build/triplex/cloudflareExperimental
  • +
  • FoundationDB@triplex-build/triplex/foundationdbExperimental

Want to build on another storage engine? Explore the backend blueprints: working SQLite, experimental FoundationDB, and proposed Datomic and XTDB adapters. Each maps storage primitives to the Triplex contract and identifies what still needs to be built.

Also in the box: a JSON-first CLI for agents, a browser data explorer, a configuration-derived HTTP API with OpenAPI, and an in-browser playground.

@@ -328,6 +289,7 @@ npm install @triplex-build/triplex effect@4.1.1

Datomic

Facts, Datalog, and historical database views.

+

WorldVM

WorldVM keeps each world’s subject records in Triplex. Its runtime uses recorded state and bases to explain what it knows.

Event sourcing

Gives you history, but you hand-write every projection.

Workflow engines

Run durable code. Triplex decides what work should exist, and the two compose.

diff --git a/docs/mcp.md b/docs/mcp.md index da2486b2..2640b00c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,34 +1,31 @@ # MCP server -`@triplex-build/triplex-mcp` is a **private, experimental** workspace package. It exposes one -Triplex database per process through MCP over stdio, using Effect's MCP server. Build it from a -source checkout; it is not published to npm: +`triplex mcp` exposes one database per process through MCP over stdio, using Effect's +MCP server. The server is experimental and ships in `@triplex-build/triplex`. +The `/mcp` library entry exports `tools`, `registrations` and `TransactionInput`. ```sh -pnpm turbo run build --filter=@triplex-build/triplex-mcp... -node packages/mcp/dist/bin.js --sqlite /absolute/path/to/app.db +npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ + @effect/platform-node @effect/platform-node-shared +npx triplex mcp --sqlite /absolute/path/to/app.db ``` -Register it with an MCP client using the client's server configuration. Replace both absolute -paths with your checkout and database locations: +Register it with an MCP client using the client's server configuration. Replace +the database path and use the installed binary's absolute path if it is not on PATH: ```json { "mcpServers": { "triplex": { - "command": "node", - "args": [ - "/absolute/path/to/triplex/packages/mcp/dist/bin.js", - "--sqlite", - "/absolute/path/to/app.db" - ] + "command": "triplex", + "args": ["mcp", "--sqlite", "/absolute/path/to/app.db"] } } } ``` -The built package also defines a `triplex-mcp` binary. The client must support MCP protocol -version `2025-11-25`. Stdout carries only MCP messages; startup diagnostics go to stderr. +The client must support MCP protocol version `2025-11-25`. Stdout carries only MCP +messages; startup diagnostics go to stderr. Backend flags follow the [CLI](/tools): `--sqlite ` defaults to `./triplex.sqlite`; `--postgres-url ` selects PostgreSQL (or use the `TRIPLEX_POSTGRES_URL` environment variable). @@ -39,7 +36,7 @@ process's working directory. Opening SQLite can apply pending migrations. ## Tools Each tool advertises an Effect-derived input schema through `tools/list`. Inputs use the same -field names and wire values as the CLI's public `execute` operations, without the `_tag` field. +field names and wire values as the CLI's internal `execute` operations, without the `_tag` field. | Tool | Input | | --------------------- | --------------------------------------------------------------------------------------- | diff --git a/docs/operational-primitives.md b/docs/operational-primitives.md index 546df99b..43604b07 100644 --- a/docs/operational-primitives.md +++ b/docs/operational-primitives.md @@ -564,7 +564,7 @@ ambient host-owned `SqlClient`, creates no pool, and runs no DDL; both host stat `layerForDatabase` creates a pool whose every connection is bound at startup to the schema derived from a validated, server-resolved `DatabaseId`, and provides both that scoped client and `Triples`. The `*Migrated` variants are explicit provisioning conveniences. Production migration execution -remains host-controlled through the ordered exports in `@triplex-build/triplex-sql`. +remains host-controlled through the ordered exports in `@triplex-build/triplex/sql`. Subscriptions remain conservative invalidation hints rather than an automatic live-query runtime. Entity snapshots, validation observations, and derivation runs remain checkpointed projections; diff --git a/docs/releasing.md b/docs/releasing.md index de22c985..0a775f6c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,26 +1,56 @@ # Releasing Triplex -Triplex uses Changesets for one coordinated package-family release. Publishing is performed by -GitHub Actions; routine releases should not depend on a maintainer's local npm configuration. +Triplex publishes one npm package, `@triplex-build/triplex`, through Changesets and +GitHub Actions. All implementation workspaces are private and ignored by Changesets. -## Release set +## Release set and packing from main -The public release set contains: +After cloning `main`, install and pack from the repository root: -- `@triplex-build/triplex` -- `@triplex-build/triplex-sql` -- `@triplex-build/triplex-sqlite` -- `@triplex-build/triplex-postgres` -- `@triplex-build/triplex-testkit` -- `@triplex-build/triplex-cli` -- `@triplex-build/triplex-http` +```sh +corepack enable +pnpm install --frozen-lockfile +pnpm pack +``` + +The prepack hook builds the private module graph and dashboard, then emits one +`triplex-build-triplex-.tgz`. It contains the existing core exports plus +`/sql`, `/sqlite`, `/postgres`, `/cloudflare`, `/foundationdb`, `/host`, `/http`, +`/testkit`, `/testkit/postgres` and `/mcp`, and the `triplex` executable. Dashboard +JavaScript, CSS and fonts are prebuilt assets; consumers install no UI runtime. +Cloudflare and FoundationDB remain **experimental and frozen** even though their +subpaths now ship. `/runner` is reserved for later work and is not exported yet. + +Only `effect` is a required peer. Install the backend driver separately: + +```sh +npm install @triplex-build/triplex effect @effect/sql-sqlite-node +# PostgreSQL instead: +npm install @triplex-build/triplex effect @effect/sql-pg +``` + +The bin's Node services also need `@effect/platform-node` and +`@effect/platform-node-shared`, both optional peers. Library-only and browser +consumers do not need them. `pnpm pack:check` verifies the effect-only consumer, +then installs the SQLite driver and Node adapters to exercise the bin and server. + +## Deprecate the old npm names after the consolidated release + +Ben should publish and verify the unified release before deprecating all versions +of these six names. Do not unpublish any artifacts. Use these exact messages: -`@triplex-build/triplex-cloudflare` and `@triplex-build/triplex-foundationdb` remain private workspace -packages until they pass the supported backend conformance contract. The dashboard and examples -are also private. +| Old npm package | Deprecation message | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `@triplex-build/triplex-sql` | Use @triplex-build/triplex/sql from the single @triplex-build/triplex package. | +| `@triplex-build/triplex-sqlite` | Use @triplex-build/triplex/sqlite from the single @triplex-build/triplex package; install @effect/sql-sqlite-node separately. | +| `@triplex-build/triplex-postgres` | Use @triplex-build/triplex/postgres from the single @triplex-build/triplex package; install @effect/sql-pg separately. | +| `@triplex-build/triplex-http` | Use @triplex-build/triplex/http from the single @triplex-build/triplex package. | +| `@triplex-build/triplex-testkit` | Use @triplex-build/triplex/testkit (or /testkit/postgres) from the single @triplex-build/triplex package. | +| `@triplex-build/triplex-cli` | Install @triplex-build/triplex for the triplex bin; use triplex mcp and triplex dashboard for the bundled tools. | -The first release published all seven packages in September 2026. Review pending Changesets and -the coordinated release set before each subsequent publication. +For each row, the owner can run `npm deprecate '@*' ''` after +verifying the new paths. Cloudflare, FoundationDB, host, MCP and dashboard were +never published under the old workspace names and need no npm deprecation. ## Required repository configuration @@ -29,7 +59,7 @@ Create a GitHub environment named `npm-publish` and require maintainer approval Repository Actions settings must allow GitHub Actions to create pull requests so the version job can maintain the release PR. -The release workflow uses npm trusted publishing. Each public package needs this trusted publisher: +The release workflow uses npm trusted publishing. The single public package needs this trusted publisher: | Field | Value | | ----------- | ------------- | @@ -113,29 +143,29 @@ so CI can provision or upgrade the existing state store when required. Install from the registry in a clean directory outside this monorepo: ```sh -pnpm init -pnpm add effect@4.1.1 \ - @triplex-build/triplex@next \ - @triplex-build/triplex-sqlite@next \ - @triplex-build/triplex-cli@next -pnpm exec triplex --help -pnpm exec triplex --sqlite ./canary.sqlite describe +npm install @triplex-build/triplex@next effect @effect/sql-sqlite-node \ + @effect/platform-node @effect/platform-node-shared +npx triplex --help +npx triplex --sqlite ./canary.sqlite describe +npx triplex mcp --sqlite ./canary.sqlite +# In another terminal, verify the local dashboard: +npx triplex dashboard --sqlite ./canary.sqlite --port 4174 ``` Also execute a small Effect program that asserts and queries a fact through SQLite. Confirm the installed core contains only documented `dist` exports, there is one Effect runtime, and npm shows -provenance for every canary package. +provenance for the single canary package. ## Stable release Every public API change must include a Changeset. On pushes to `main`, the release workflow either -updates a version PR or publishes the already-versioned packages: +updates a version PR or publishes the already-versioned package: -1. Review the generated versions, changelogs, lockfile changes, and package set in the release PR. +1. Review the generated versions, changelogs, lockfile changes, and single publishable package in the release PR. 2. Require a green CI run, including PostgreSQL and the packed external-consumer test. 3. Merge the release PR. 4. Approve the `npm-publish` environment deployment. -5. Verify the `latest` dist-tags, provenance, package contents, Git tags, and GitHub releases. +5. Verify the `latest` dist-tag, provenance, package contents, Git tags, and GitHub releases. `pnpm release:version`, `pnpm release:publish`, and `pnpm release:canary` are the corresponding low-level commands. The stable commands are intentionally separated so the version changes are diff --git a/docs/rfcs/runner.md b/docs/rfcs/runner.md index 771d55ba..3c46c92b 100644 --- a/docs/rfcs/runner.md +++ b/docs/rfcs/runner.md @@ -1,12 +1,11 @@ # RFC: durable derivation runner -Status: proposed for P4.2; owner approval is required before implementation. This -RFC changes no package source or existing exports. All runner names and code -below are proposed API fragments, not runnable examples of a shipped package. +Status: accepted; implemented as `@triplex-build/triplex/runner`. The approved design below +records the delivery contract; see [Runner](../runner.md) for the shipped API. ## 1. Recommendation, API and compliance host -Add `@triplex-build/triplex-runner` beside HTTP and CLI. It follows the global +Add `@triplex-build/triplex/runner` beside HTTP and CLI. It follows the global ordered journal, materializes affected definitions, delivers pure candidate diffs to Effect handlers, and schedules dependency boundaries. It owns catch-up and wakeups; the host still decides what a task means. It must never infer that a @@ -18,7 +17,7 @@ host code can keep additions, removals and revisions in one application module. There is no Promise interface. ```ts -import * as Runner from "@triplex-build/triplex-runner"; +import * as Runner from "@triplex-build/triplex/runner"; type Delivery = { readonly version: 1; @@ -79,7 +78,7 @@ all `ConsumerCheckpoint` calls with this composition: ```ts import { Effect } from "effect"; import * as Database from "@triplex-build/triplex/database"; -import * as Runner from "@triplex-build/triplex-runner"; +import * as Runner from "@triplex-build/triplex/runner"; // Fragment inside the host's scoped Effect, after deployment and definition creation. const serve = Effect.gen(function* () { @@ -425,7 +424,7 @@ continue to pass, but this RFC adds no certification for them. ## 7. Package and implementation gate -The package is `@triplex-build/triplex-runner`, directory `packages/runner`. +The package is `@triplex-build/triplex/runner`, a browser-safe module assembled into the single published package. Runtime dependencies are public exports of `@triplex-build/triplex` and Effect only, with versions in the root catalog and Effect's peer range matching core. No dependency on HTTP, CLI, host, SQL or backend internals is needed. It can stay @@ -435,8 +434,7 @@ embedding host; a future Node adapter would be an explicit separate subpath. Implementation must add package exports/build wiring, the runner schema and acquisition checks, durable protocol/recovery tests, a complete compliance-host -rewrite, checked documentation and a Changeset. None is shipped by this design -item. An implementation that needs another public core capability must specify +rewrite, checked documentation and a Changeset. The implementation ships these surfaces. An implementation that needs another public core capability must specify and test it rather than import internals or weaken delivery semantics. ## 8. Conformance corpus and owner questions @@ -503,6 +501,5 @@ Owner approval should settle these proposed policies before implementation: extraction until duplicate feed/timer cost is measured. This does not reopen the settled separate-package/core-browser-safety decision. -These are approval questions for the subsequent implementation, not claims that -the RFC or the runner has already been approved. The Phase 4 gate remains the +These policies were approved in D14. The Phase 4 gate remains the working compliance host and shared conformance evidence, not this document alone. diff --git a/docs/rfcs/tx1-invariant-scope-investigation.md b/docs/rfcs/tx1-invariant-scope-investigation.md index 033fe0d9..8651cf29 100644 --- a/docs/rfcs/tx1-invariant-scope-investigation.md +++ b/docs/rfcs/tx1-invariant-scope-investigation.md @@ -35,7 +35,7 @@ The first two plain-policy reads were tested before changing the helper. Exact command: ```sh -pnpm --filter @triplex-build/triplex-postgres exec vitest run test/integration/invariant-scopes.test.ts +pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/invariant-scopes.test.ts ``` These experiments establish lock/claim mechanisms, **not a complete scope planner**. diff --git a/docs/rfcs/tx1-late-allocation.md b/docs/rfcs/tx1-late-allocation.md index a4de4ce1..52f372e8 100644 --- a/docs/rfcs/tx1-late-allocation.md +++ b/docs/rfcs/tx1-late-allocation.md @@ -107,7 +107,7 @@ lock window alone is not evidence of eliminating snapshot conflicts. Exact diagnostic command: ```sh -pnpm --filter @triplex-build/triplex-postgres exec vitest run test/integration/postgresql.test.ts -t 'final-statement counter' +pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/postgresql.test.ts -t 'final-statement counter' ``` It passes both isolation cases on Postgres 17.8 in the existing testcontainer diff --git a/docs/rfcs/tx1-ordered-publication-investigation.md b/docs/rfcs/tx1-ordered-publication-investigation.md index a144c26f..695a309d 100644 --- a/docs/rfcs/tx1-ordered-publication-investigation.md +++ b/docs/rfcs/tx1-ordered-publication-investigation.md @@ -54,7 +54,7 @@ three tests include the reader counterexample that rules out ambient substitutio Exact command: ```sh -pnpm --filter @triplex-build/triplex-postgres exec vitest run test/integration/staged-publication.test.ts +pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/staged-publication.test.ts ``` The candidate matches PostgreSQL's documented snapshot behavior: repeatable reads diff --git a/docs/roadmap.md b/docs/roadmap.md index 4b1dae02..0eaf24c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,15 +36,15 @@ shims, deprecations or opt-in flags for old shapes. - Delivered: a Changesets v3 GitHub workflow maintains the version PR, publishes stable releases, and can manually publish `next` snapshots without committing snapshot versions. -- Delivered: package checks install all tarballs and exercise the CLI as an external consumer. +- Delivered: package checks install one tarball and exercise the CLI as an external consumer. - Delivered: the PostgreSQL multi-connection isolation and shared conformance suite run in CI. -- Delivered: Cloudflare and FoundationDB are private experimental workspace packages and cannot be - included in the first release accidentally. +- Delivered: Cloudflare and FoundationDB ship as experimental, frozen subpaths; their implementation + workspaces remain private. - Delivered: the GitHub repository cutover to `bjacobso/triplex`. - Delivered: the initial version PR and the first publication of all seven public packages under `@triplex-build`. -- Remaining: verify trusted publishing for all seven public packages with a tokenless `next` - release and test the registry packages in a clean external consumer. +- Remaining: verify trusted publishing for the single public package with a tokenless `next` + release and test the registry package in a clean external consumer. ## Immediate correctness gate: backend parity @@ -85,7 +85,7 @@ shims, deprecations or opt-in flags for old shapes. binary and identity-only. Same-named definitions union, repeated variables unify, and unsupported recursion fails during typed preflight. - Keep PostgreSQL labelled as a pre-1.0 production candidate while its now-required CI corpus gains - operational history. Keep FoundationDB and Cloudflare private, experimental and frozen, preserving compilation + operational history. Keep FoundationDB and Cloudflare experimental and frozen, preserving compilation and existing tests without requiring new surface coverage. ## 1. Schema-Aware Constraints & Enforcement @@ -159,8 +159,8 @@ shims, deprecations or opt-in flags for old shapes. negation. - Structural derivations expose the earliest recorded future `validFrom` or `validTo` across their dependency attributes. Immutable materialization runs persist and content-bind that schedule, - including zero-candidate runs suppressed by negated evidence. A host scheduler can therefore - reopen expiry-driven projections without a daily full scan. Fixed attribute sets use backend + including zero-candidate runs suppressed by negated evidence. The [durable runner](/runner) + delivers diffs, retries handlers and replays expiry-driven transitions without a daily full scan. Fixed attribute sets use backend dependency indexes; dynamic-attribute definitions retain an explicit journal fallback. ## 4. Integrated Full-Text Search (FTS) diff --git a/docs/runner.md b/docs/runner.md new file mode 100644 index 00000000..4d451012 --- /dev/null +++ b/docs/runner.md @@ -0,0 +1,132 @@ +--- +description: Durable derivation diffs, retries and temporal replay through scoped Effect handlers. +--- + +# Derivation runner + +`@triplex-build/triplex/runner` follows the ordered journal and delivers candidate diffs to +Effect handlers. It persists prepared evaluations, immutable Outbox payloads and acknowledged +candidate snapshots. A materialization by another caller never becomes the delivered base. + +Publish `Runner.schema` with your application's release before acquisition. The schema uses +ordinary `runner/control` entities, `runner:` IDs and `:runner/*` attributes. Host validators +must authorize these attributed infrastructure writes; the runner has no authorization bypass. + +```ts check +import { Clock, Effect, Layer } from "effect"; +import { KvTriples } from "@triplex-build/triplex"; +import { ConfigStore } from "@triplex-build/triplex/config"; +import * as Derivation from "@triplex-build/triplex/derivation"; +import * as Runner from "@triplex-build/triplex/runner"; +import { DatabaseScope } from "@triplex-build/triplex/runtime"; + +const application = Effect.gen(function* () { + const config = yield* ConfigStore.ConfigStore; + const release = yield* config.commit({ + label: "runner-example", + objects: yield* Runner.schema, + ref: "live", + }); + const definition = yield* Derivation.make({ + name: "untrained-workers", + configSnapshot: release.snapshot.id, + identity: ["?worker"], + query: { + find: ["?worker"], + where: [ + ["?worker", ":worker/active", true], + ["not", ["?worker", ":worker/trained", true]], + ], + }, + }); + const handle = yield* Runner.start( + [ + { + key: "training", + definition, + onDiff: (delivery) => + Effect.log({ + batch: delivery.batchId, + added: delivery.diff.added.length, + removed: delivery.diff.removed.length, + changed: delivery.diff.changed.length, + }), + }, + ], + { + scope: DatabaseScope.make({ env: "prod", tenant: "acme", database: "work", generation: 1 }), + id: "requirements", + }, + ); + yield* handle.await; +}).pipe(Effect.scoped); +const Store = ConfigStore.layer.pipe( + Layer.provideMerge(KvTriples.layerWithClock(Clock.currentTimeMillis)), +); +const main = application.pipe(Effect.provide(Store)); +void main; +``` + +The logging handler illustrates the envelope. Application handlers normally use +[Database commands](/database) with `delivery.commandId(kind, candidate.id)` as the command ID +and `delivery.batchId` as the correlation ID. Record outgoing integration intents inside that +command, then deliver them with an idempotent transport. See the complete +[compliance host](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host). + +## Delivery and recovery + +Delivery is at least once and ordered per registration. Bootstrap captures the current journal +head and valid time, then compares current candidates with an empty set. Every relevant later +commit evaluates its exact recorded position; unchanged candidates produce no callback. Removed +candidates retain their old explanation, while `delivery.basis` identifies the cut that observed +absence. Hosts decide whether a removal means satisfaction or cancellation. + +Batch identity includes the generation, runner ID, registration key, predecessor delivery, +definition and target basis. Attempts and retry times never change command IDs. A reopened +candidate receives a new batch identity even when its logical candidate ID is unchanged. +Handlers must remain replay-compatible across restarts and deployment. + +Prepared and ready batches survive interruption. Acknowledgement atomically updates the control +head and acknowledges the Outbox entry; a separate completed-cut marker repairs a lagging consumer +checkpoint. No arbitrary application effect is atomic with runner acknowledgement. + +## Time and supervision + +Future starts and expiries, including empty results suppressed by negation, drive one minimum +deadline timer. Offline catch-up replays every recorded boundary at its exact instant. Commits +win ties with boundaries; equal or decreasing timestamps still follow journal position order. +The durable valid-time frontier never rewinds after clock rollback. A bounded head check +(default one second) discovers other-process writes and lost hints. This check does not poll +queries for expiry. Process suspension can delay callbacks. + +`handle.status` reports scanned/delivered positions, lag, acknowledged runs, pending attempts, +retry deadlines, failures, evaluation duration and timer lateness. Typed handler failures retry +with jittered exponential backoff (eight attempts, one second to one minute by default). +Exhaustion pauses that registration; others continue, while the shared checkpoint remains at +the minimum delivered coverage. `handle.retryPaused(key)` starts another retry cycle for the +same batch. Defects and storage/scheduler failures reach `handle.await`. Scope closure and +`handle.stop` interrupt and join work without acknowledging an interrupted handler. + +Acquisition rejects duplicate keys, unsupported definitions, changed database generations, +missing recovery definitions and malformed control records. Keep old definitions in +`recoveryDefinitions` while prepared or ready work still references them. After that work +finishes, a revised definition produces a definition-change evaluation at the captured current +cut. Drain before removing registrations or making incompatible handler/schema changes. + +## Ownership and limits + +Use one active process per world/database generation. Local acquisition rejects a second owner; +this registry is not a distributed lease. Hosts must stop or fence old processes before failover. +CAS detects conflicting control transitions but cannot prevent duplicate external effects. +There is no poison-discard API. + +Evaluation concurrency defaults to four. Complete candidate snapshots default to a 10,000-candidate +and 16 MB payload budget. Exceeding a budget pauses the prepared batch before application delivery; +increase the budget and resume it after restart. Nothing is truncated. Compaction and chunked +payloads are not provided. Sensitive results in infrastructure snapshots are not automatically +encrypted; hosts control storage access and payload protection. + +KV and SQLite are supported targets; PostgreSQL remains a pre-1.0 production candidate. In-memory +KV supports runner restarts while its backend remains alive, and does not promise process-crash +persistence. Persistent deployments need durable storage. Cloudflare and FoundationDB remain +experimental and are not certified for the runner. diff --git a/docs/sensitive-values.md b/docs/sensitive-values.md index 117eabbb..25ab3e4e 100644 --- a/docs/sensitive-values.md +++ b/docs/sensitive-values.md @@ -436,7 +436,7 @@ Design, partially built: | Cloudflare | Yes (migration v4) | Yes | Same in the native-SQL harness; both corpora in workerd | | FoundationDB | Yes (shared KV encoding) | Yes | Native sealed/decorator corpora, full Triples corpus through the decorator, raw subspace scan | -The shared corpora are exported from `@triplex-build/triplex-testkit` as `sealedStorageCases`, +The shared corpora are exported from `@triplex-build/triplex/testkit` as `sealedStorageCases`, `sensitiveConformanceCases`, `sensitiveConformanceLayer` and `findPlaintext`. ## Proposed host-sealing integration diff --git a/docs/snippets/backend-layer-types.ts b/docs/snippets/backend-layer-types.ts index 54c7aea4..3693c782 100644 --- a/docs/snippets/backend-layer-types.ts +++ b/docs/snippets/backend-layer-types.ts @@ -2,8 +2,8 @@ import { Layer } from "effect"; import type { SqlClient } from "effect/sql"; import { DatabaseId, KvTriples, Triples } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; -import { PgTriples } from "@triplex-build/triplex-postgres"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; +import { PgTriples } from "@triplex-build/triplex/postgres"; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; diff --git a/docs/snippets/blog/client.test.ts b/docs/snippets/blog/client.test.ts index 94d76b75..7153cde6 100644 --- a/docs/snippets/blog/client.test.ts +++ b/docs/snippets/blog/client.test.ts @@ -19,6 +19,22 @@ it("runs declare/publish/command/get/projected query/asOf in the first program", }); }); +it("runs the homepage client with a retry receipt, typed reads and historical attribution", () => { + const result = run("home/client-run.ts"); + expect(result.retry).toEqual(result.receipt); + expect(result.worker).toEqual({ name: "Maria" }); + expect(result.current).toEqual({ name: "Maria Rivera" }); + expect(result.knownThen).toEqual({ name: "Maria" }); + expect(result.explanation.basis.recordedPosition).toBe(result.receipt.position); + expect(result.explanation.transactions).toEqual([ + expect.objectContaining({ + actor: "user:dana", + commandId: "create:worker:maria", + release: expect.objectContaining({ status: "found", label: "workers-v1" }), + }), + ]); +}); + it("runs the agent guide with enforcement, retry receipts, audit and derived work", () => { const result = run("client-site-safety-run.ts"); expect(result.retry).toEqual(result.receipt); diff --git a/docs/snippets/foundationdb.ts b/docs/snippets/foundationdb.ts index 74a9e3af..425a98ac 100644 --- a/docs/snippets/foundationdb.ts +++ b/docs/snippets/foundationdb.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Capabilities, DatabaseScope, Runtime } from "@triplex-build/triplex/runtime"; -import { makeFdbKvBackend } from "@triplex-build/triplex-foundationdb"; +import { makeFdbKvBackend } from "@triplex-build/triplex/foundationdb"; const FoundationDbRuntime = Runtime.fromKv({ name: "foundationdb", diff --git a/docs/snippets/home/client-run.ts b/docs/snippets/home/client-run.ts new file mode 100644 index 00000000..e036a577 --- /dev/null +++ b/docs/snippets/home/client-run.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect"; +import { main } from "./client.ts"; + +const result = await Effect.runPromise(main); +console.log( + JSON.stringify({ + receipt: result.receipt, + retry: result.retry, + worker: result.worker?.values, + current: result.current?.values, + knownThen: result.knownThen?.values, + explanation: result.explanation, + }), +); diff --git a/docs/snippets/home/client.ts b/docs/snippets/home/client.ts new file mode 100644 index 00000000..77c5293a --- /dev/null +++ b/docs/snippets/home/client.ts @@ -0,0 +1,85 @@ +// #region model +import { Effect, Layer } from "effect"; +import { EntityId, KvTriples } from "@triplex-build/triplex"; +import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; +import * as Database from "@triplex-build/triplex/database"; +import { Why } from "@triplex-build/triplex/why"; + +const Worker = EntityType.make("Worker", { + name: Attribute.text(":worker/name").required(), +}); +const Model = { types: [Worker] } as const; +const maria = EntityId.make("worker:maria"); +const MAR_2 = Date.UTC(2026, 2, 2); +// #endregion model + +// #region release +const publish = Effect.gen(function* () { + const config = yield* ConfigStore.ConfigStore; + return yield* config.commit({ + label: "workers-v1", + objects: yield* EntityType.nodesOf(...Model.types), + ref: "live", + }); +}); +const Client = Database.layer({ schema: Model }); +// #endregion release + +const program = Effect.gen(function* () { + // #region command + const db = yield* Database.Database; + const createWorker = () => + db.command({ actor: "user:dana", commandId: "create:worker:maria" }, (tx) => + tx.create(Worker, maria, { name: "Maria" }, { validFrom: MAR_2 }), + ); + const receipt = yield* createWorker(); + const retry = yield* createWorker(); // same receipt, no second write + // #endregion command + + // #region read + const worker = yield* db.get(Worker, maria); // { entityId, values } | null + const page = yield* db.list(Worker, { limit: 25 }); + // worker?.values.name is typed from Worker; page.nextCursor continues the list. + // #endregion read + + // #region audit + // Correct the original belief without losing its history. + const nameFact = receipt.changes.find( + (change) => change.op === "assert" && change.attribute === Worker.attributes.name.key, + ); + if (nameFact === undefined) return yield* Effect.die("Missing name assertion"); + yield* db.command({ actor: "user:auditor", commandId: "correct:worker:maria:name" }, (tx) => + Effect.gen(function* () { + const fact = yield* tx.fact(Worker, nameFact.tripleId); + yield* tx.correct(Worker, fact, { name: "Maria Rivera" }); + }), + ); + const basis = { recordedPosition: receipt.position, validAt: MAR_2 }; + const then = yield* db.asOf(basis); + const knownThen = yield* then.get(Worker, maria); // name: "Maria" + const current = yield* db.get(Worker, maria); // name: "Maria Rivera" + // #endregion audit + + // #region why + const why = yield* Why; + const explanation = yield* why.why( + { kind: "entity", entityId: maria }, + { basis }, // explain the same recorded cut and business time + ); + // explanation.transactions includes Dana's command and its workers-v1 release. + // #endregion why + return { receipt, retry, worker, page, knownThen, current, explanation }; +}); + +// #region run +const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); +const Services = Layer.merge(Client, Why.layer); +// Publish before acquiring the client. Both effects share one Store instance. +export const main = publish.pipe( + Effect.andThen(program.pipe(Effect.provide(Services))), + Effect.provide(Store), +); +// await Effect.runPromise(main) at your application boundary. +// For SQLite, import SqliteTriples from "@triplex-build/triplex/sqlite" and use +// SqliteTriples.layer({ filename: "./app.db" }) in place of KvTriples.layer. +// #endregion run diff --git a/docs/snippets/home/site-safety/app.ts b/docs/snippets/home/site-safety/app.ts index 496702ca..0baf95e7 100644 --- a/docs/snippets/home/site-safety/app.ts +++ b/docs/snippets/home/site-safety/app.ts @@ -18,9 +18,9 @@ import { type InMemoryConfigStore, } from "@triplex-build/triplex/config"; import * as Derivation from "@triplex-build/triplex/derivation"; -import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; -import { PgTriples } from "@triplex-build/triplex-postgres"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex/http"; +import { PgTriples } from "@triplex-build/triplex/postgres"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; export const MAR_2 = Date.UTC(2026, 2, 2); export const maria = EntityId.make("worker:maria"); diff --git a/docs/snippets/tsconfig.json b/docs/snippets/tsconfig.json index 3176a87b..6328d49e 100644 --- a/docs/snippets/tsconfig.json +++ b/docs/snippets/tsconfig.json @@ -1,25 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "baseUrl": "../..", "lib": ["ES2024", "ESNext.Iterator", "DOM"], "noEmit": true, - "paths": { - "@triplex-build/triplex": ["packages/core/src/index.ts"], - "@triplex-build/triplex/database": ["packages/core/src/database/index.ts"], - "@triplex-build/triplex/config": ["packages/core/src/config/index.ts"], - "@triplex-build/triplex-sqlite": ["packages/sqlite/src/index.ts"], - "@triplex-build/triplex-foundationdb": ["packages/foundationdb/src/index.ts"], - "@triplex-build/triplex/runtime": ["packages/core/src/runtime/index.ts"], - "@triplex-build/triplex/content": ["packages/core/src/content/index.ts"], - "@triplex-build/triplex/derivation": ["packages/core/src/derivation/index.ts"], - "@triplex-build/triplex-postgres": ["packages/postgres/src/index.ts"], - "@triplex-build/triplex/internal": ["packages/core/src/internal.ts"], - "@triplex-build/triplex-sql": ["packages/sql/src/index.ts"], - "@triplex-build/triplex-http": ["packages/http/src/index.ts"] - }, "allowImportingTsExtensions": true, - "types": ["node"] + "types": ["node"], + "declaration": false, + "declarationMap": false }, "include": ["**/*.ts"] } diff --git a/docs/sqlite.md b/docs/sqlite.md index 85bd8444..01013f2b 100644 --- a/docs/sqlite.md +++ b/docs/sqlite.md @@ -27,7 +27,7 @@ default shared conformance suite. Triplex remains pre-1.0; see [current state](/ Install the SQLite adapter with the compatible Effect version on Node.js 22 or newer: ```sh -npm install @triplex-build/triplex @triplex-build/triplex-sqlite effect@4.1.1 +npm install @triplex-build/triplex effect @effect/sql-sqlite-node ``` This writes a training fact and reads it through the same `Triples` service used by other backends: @@ -35,7 +35,7 @@ This writes a training fact and reads it through the same `Triples` service used ```ts check import { Effect } from "effect"; import { EntityId, Triples, string } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; const DatabaseLive = SqliteTriples.layer({ filename: "training.db" }); const maria = EntityId.make("worker:maria"); @@ -115,7 +115,7 @@ Follow these modules in order: [migrations](https://github.com/bjacobso/triplex/blob/main/packages/sql/src/migrations.ts) 4. [Triples composition](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteTriples.ts) -Run `pnpm --filter @triplex-build/triplex-sqlite test` from a repository checkout for the adapter's +Run `pnpm --filter @triplex-build/triplex/sqlite test` from a repository checkout for the adapter's tests; `pnpm --filter triplex-integration-tests test` runs the shared backend corpus. Conformance is a behavioral boundary, not a promise about every workload or storage failure. Backup, restore, deployment migrations, and process lifecycle belong to your application. diff --git a/docs/tools.md b/docs/tools.md index 7f776fd3..9c14202f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,10 +1,10 @@ # CLI and dashboard Triplex includes two operator tools: a published JSON-first CLI for repeatable commands and a -browser dashboard for interactive exploration. Both are pre-1.0. The dashboard is a private -workspace package rather than a published application. +browser dashboard for interactive exploration. Both are pre-1.0 and ship in the single +`@triplex-build/triplex` package. The dashboard includes prebuilt static assets. -The private, experimental [MCP server](/mcp) exposes the CLI's implementation as stdio tools for +The experimental [MCP server](/mcp) exposes the CLI's implementation as stdio tools for agent clients. It serves one database per process, advertises input schemas, requires an actor and command ID on writes, and returns the same data and runtime diagnostics as the CLI. @@ -14,14 +14,16 @@ After following [Getting started](/getting-started), build the CLI and its works then define a short shell function for the built binary: ```sh -pnpm turbo run build --filter=@triplex-build/triplex-cli... -triplex() { node --disable-warning=ExperimentalWarning packages/cli/dist/cli.js "$@"; } +pnpm turbo run build --filter=@triplex-build/triplex +triplex() { node --disable-warning=ExperimentalWarning packages/triplex/dist/bin.js "$@"; } ``` -The examples below use the repository command. Install `@triplex-build/triplex-cli` from npm to +The examples below use the repository command. Install `@triplex-build/triplex` from npm to use the same `triplex` binary in an application: ```sh +npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ + @effect/platform-node @effect/platform-node-shared triplex --sqlite ./app.db describe triplex --sqlite ./app.db status ``` @@ -219,7 +221,7 @@ Edits are real Triplex writes but disappear when the app runtime restarts. To inspect a persistent database, build and run the local HTTP host: ```sh -pnpm dashboard:serve -- --sqlite /absolute/path/to/triplex.db +npx triplex dashboard --sqlite /absolute/path/to/triplex.db ``` The dashboard includes a spreadsheet-like entity grid with managed/runtime type filters, typed form diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ba831de6..19f8cec8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,12 +8,11 @@ For machine-readable error codes and recovery hints, see [Error diagnostics](/er ## Why does npm return 404 for a Triplex package? -The seven public packages use the `@triplex-build` scope. Check the full package name and clear a -stale local npm cache if a newly published version returns 404. Cloudflare and FoundationDB remain -private workspace packages. Do not switch new examples back to the superseded `@bjacobso` scope. +Install `@triplex-build/triplex`; backend imports are subpaths such as `/sqlite` and `/postgres`, +not separate npm packages. Cloudflare and FoundationDB ship as experimental, frozen subpaths. +Check the full package name and clear a stale local npm cache if a newly published version returns 404. Do not switch new examples back to the superseded `@bjacobso` scope. -Keep every Triplex package on one coordinated release and use the exact Effect -peer version declared by that release. The current source tree targets Node.js 22+ and +Use a compatible Effect peer version for the installed Triplex release. The current source tree targets Node.js 22+ and `effect@4.1.1`; Effect 3 is incompatible. Multiple Effect copies or a version mismatch can make service tags and runtime types disagree even when imports look correct. diff --git a/e2e/docs-home.spec.ts b/e2e/docs-home.spec.ts index 5553dd26..978b631c 100644 --- a/e2e/docs-home.spec.ts +++ b/e2e/docs-home.spec.ts @@ -28,20 +28,16 @@ for (const colorScheme of ["light", "dark"] as const) { page.getByRole("heading", { name: "Reading this as a coding agent?" }), ).toBeVisible(); - // The stack map, journal table, and every step are rendered from verified scenario outputs. - const stack = page.locator(".stack-map"); - await expect(stack.getByRole("link")).toHaveCount(6); - await expect(stack).toContainText(/\d+ lines from schema to REST API/); - const timeline = page.locator(".th-journal table"); - await expect(timeline.locator("tbody tr")).toHaveCount(6); - await expect(timeline).toContainText("create:placement:maria-harbor"); + // The six client steps render every region of the checked homepage program. + await expect(content).toContainText("npm install @triplex-build/triplex effect"); + await expect(page.locator(".th-steps .th-step")).toHaveCount(6); const examples = page.locator('.th-steps .th-window div[class*="language-"]'); - await expect(examples).toHaveCount(11); + await expect(examples).toHaveCount(7); for (const example of await examples.all()) { await expect(example).toBeVisible(); } - await expect(content).toContainText('"trainedAsKnownThen": true'); - await expect(content).toContainText('"governedBy": "hr-2026.1"'); + await expect(content).toContainText("db.command"); + await expect(content).toContainText("db.asOf"); await expect(page.getByRole("tab")).toHaveCount(0); await expect(page).toHaveScreenshot(`home-${colorScheme}.png`, { @@ -55,7 +51,7 @@ test("home examples remain visible on mobile without page overflow", async ({ pa await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); const examples = page.locator('.th-steps .th-window div[class*="language-"]'); - await expect(examples).toHaveCount(11); + await expect(examples).toHaveCount(7); for (const example of await examples.all()) { await expect(example).toBeVisible(); } diff --git a/e2e/docs-home.spec.ts-snapshots/home-dark.png b/e2e/docs-home.spec.ts-snapshots/home-dark.png index 1df9e00f..d905ca53 100644 Binary files a/e2e/docs-home.spec.ts-snapshots/home-dark.png and b/e2e/docs-home.spec.ts-snapshots/home-dark.png differ diff --git a/e2e/docs-home.spec.ts-snapshots/home-light.png b/e2e/docs-home.spec.ts-snapshots/home-light.png index 1b09c4ff..d129b5bf 100644 Binary files a/e2e/docs-home.spec.ts-snapshots/home-light.png and b/e2e/docs-home.spec.ts-snapshots/home-light.png differ diff --git a/examples/compliance-host/README.md b/examples/compliance-host/README.md index 5cb8a0da..3d12c162 100644 --- a/examples/compliance-host/README.md +++ b/examples/compliance-host/README.md @@ -1,35 +1,15 @@ -# Triplex compliance host demo +# Triplex compliance host -This standalone example shows the application boundary around Triplex's portable derivation -primitives. A generic site-safety policy requires training evidence for a worker assigned to a -site. The host application: +Run `pnpm example:compliance-host`. The self-verifying example also runs in `pnpm test`. -- publishes a content-addressed configuration release; -- compiles a host entity schema into content-addressed graph constraints and enforces them in the - same transaction as operational writes; -- writes operational placement and evidence facts with causal metadata; -- consumes the ordered transaction feed from a durable `ConsumerCheckpoint`; -- materializes a Datalog derivation and reconciles it into durable requirement occurrences; -- previews a submission through a read-only hypothetical overlay; -- schedules the materializer at `nextTemporalBoundary`; and -- reopens work when the evidence expires, without another source transaction. +The host publishes the complete placement, evidence and requirement-occurrence schemas alongside +`Runner.schema`. A scoped runner delivers candidate diffs to handlers that use guarded +`Database.command` builders and stable per-batch command IDs. A read-only overlay previews evidence +reuse. Effect TestClock drives open → satisfied → reopened: training valid on `[120, 200)` closes +occurrence 1 at 150, and expiry opens occurrence 2 at 200 without another source command. -Run it from the repository root: - -```sh -pnpm example:compliance-host -``` - -The demo is self-verifying and also runs during `pnpm test`. - -## What belongs where - -Triplex owns facts, temporal Datalog, configuration identity, candidate provenance, immutable -materialization runs, reconciliation diffs, atomically unique command receipts, durable consumer -checkpoints, and temporal-boundary discovery. The example host owns timer delivery, requirement -occurrence lifecycle, command naming, and the meaning of "satisfied" versus "open". - -The in-memory scheduler makes that boundary easy to see. A production host should replace it with -an at-least-once job system. The feed cursor is already a reserved Triplex fact and advances only -after reconciliation succeeds. Reconciliation remains idempotent: it inspects durable requirement -facts before opening, closing, or revising work. +The runner owns journal catch-up, acknowledged snapshots, retries, checkpoints and temporal replay. +The application owns occurrence identity and the meaning of addition, removal and revision. A +production host assigns one runner owner per database and uses durable storage; in-memory KV does +not survive process loss. External integrations should append intents inside commands and use a +separate idempotent transport. diff --git a/examples/compliance-host/package.json b/examples/compliance-host/package.json index 22c10036..cf98046d 100644 --- a/examples/compliance-host/package.json +++ b/examples/compliance-host/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/demo.ts", - "test": "tsx src/demo.ts --verify", + "start": "node --import tsx src/demo.ts", + "test": "node --import tsx src/demo.ts --verify", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/compliance-host/src/demo.ts b/examples/compliance-host/src/demo.ts index 3cdcbeb6..dcde8270 100644 --- a/examples/compliance-host/src/demo.ts +++ b/examples/compliance-host/src/demo.ts @@ -1,574 +1,254 @@ -import { Effect, Layer } from "effect"; - -import { - EntityId, - KvTriples, - Triples, - datetime, - number, - ref, - string, - type TransactOp, - type Triple, -} from "@triplex-build/triplex"; -import { - Attribute, - ConfigNode, - ConfigStore, - EntityType, - GraphConstraint, - TypeExpr, -} from "@triplex-build/triplex/config"; +import { Clock, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { EntityId, KvTriples, Triples } from "@triplex-build/triplex"; +import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; +import * as Database from "@triplex-build/triplex/database"; import * as Derivation from "@triplex-build/triplex/derivation"; -import { ConsumerCheckpoint } from "@triplex-build/triplex/operational"; - -const PLACEMENT_WORKER = ":placement/worker"; -const PLACEMENT_SITE = ":placement/site"; -const TRAINING_EVIDENCE = ":evidence/site-safety"; +import * as Runner from "@triplex-build/triplex/runner"; +import { DatabaseScope } from "@triplex-build/triplex/runtime"; -const Requirement = { - entityType: "DemoRequirementOccurrence", - attribute: { - candidate: ":requirement/candidate", - occurrence: ":requirement/occurrence", - status: ":requirement/status", - revision: ":requirement/candidate-revision", - definition: ":requirement/definition", - configSnapshot: ":requirement/config-snapshot", - materializationRun: ":requirement/materialization-run", - sourcePosition: ":requirement/source-position", - openedAt: ":requirement/opened-at", - closedAt: ":requirement/closed-at", - }, -} as const; - -const RequirementStatus = Attribute.text(Requirement.attribute.status); -const RequirementSchema = EntityType.make(Requirement.entityType, { - status: Attribute.use(RequirementStatus, { required: true }), +const Placement = EntityType.make("Placement", { + worker: Attribute.text(":placement/worker").required(), + site: Attribute.text(":placement/site").required(), }); - -interface RequirementOccurrence { - readonly entityId: EntityId; - readonly candidate: string; - readonly occurrence: number; - readonly status: string; - readonly revision: string; -} - -function ensure(condition: unknown, message: string): asserts condition { +const Worker = EntityType.make("Worker", { + training: Attribute.text(":evidence/site-safety"), +}); +// The complete occurrence model is published, including every audit field. +const Requirement = EntityType.make("DemoRequirementOccurrence", { + candidate: Attribute.text(":requirement/candidate").required(), + occurrence: Attribute.number(":requirement/occurrence").required(), + status: Attribute.text(":requirement/status").required(), + revision: Attribute.text(":requirement/candidate-revision").required(), + definition: Attribute.text(":requirement/definition").required(), + configSnapshot: Attribute.text(":requirement/config-snapshot").required(), + run: Attribute.text(":requirement/materialization-run").required(), + sourcePosition: Attribute.number(":requirement/source-position").required(), + openedAt: Attribute.instant(":requirement/opened-at").required(), + closedAt: Attribute.instant(":requirement/closed-at"), +}); +const model = { types: [Placement, Worker, Requirement] } as const; +const worker = EntityId.make("worker:maria"); +const site = "site:harbor"; +const ensure = (condition: unknown, message: string) => { if (!condition) throw new Error(`Demo verification failed: ${message}`); -} - -const scalar = (triple: Triple | undefined): string | number | undefined => { - if (!triple) return undefined; - switch (triple.value.type) { - case "string": - case "ref": - case "number": - case "datetime": - return triple.value.value; - default: - return undefined; - } }; -const latestAt = (rows: readonly Triple[], attribute: string): Triple | undefined => - rows - .filter((row) => row.attribute === attribute) - .sort( - (left, right) => right.recordedAt - left.recordedAt || right.id.localeCompare(left.id), - )[0]; - -const groupByEntity = (rows: readonly Triple[]): ReadonlyMap => { - const grouped = new Map(); - for (const row of rows) { - const entity = grouped.get(row.entityId) ?? []; - entity.push(row); - grouped.set(row.entityId, entity); - } - return grouped; +const occurrenceNumber = (value: unknown): number => { + if (typeof value !== "number") throw new Error("Occurrence number is not open numeric data"); + return value; }; -const program = Effect.gen(function* () { - const triples = yield* Triples; +const application = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; - const placementId = EntityId.make("placement:one"); - const workerId = EntityId.make("worker:maria"); - const siteId = EntityId.make("site:harbor"); - - // The application compiler emits independently identified, typed config - // nodes. References express deploy impact without merging config and facts. - const Descriptor = TypeExpr.struct({ - label: TypeExpr.required(TypeExpr.text), - version: TypeExpr.required(TypeExpr.number), - }); - const node = (input: { - readonly kind: string; - readonly key: string; - readonly label: string; - readonly refs?: readonly ConfigNode.ConfigRef[]; - }) => - ConfigNode.makeTyped({ - kind: input.kind, - key: input.key, - type: Descriptor, - attrs: { label: input.label, version: 1 }, - ...(input.refs === undefined ? {} : { refs: input.refs }), - }); - - const placementWorker = yield* node({ - kind: "attribute", - key: PLACEMENT_WORKER, - label: "Placement worker", - }); - const placementSite = yield* node({ - kind: "attribute", - key: PLACEMENT_SITE, - label: "Placement site", - }); - const evidence = yield* node({ - kind: "attribute", - key: TRAINING_EVIDENCE, - label: "Valid site-safety training", - }); - const placementType = yield* node({ - kind: "entity-type", - key: "Placement", - label: "Placement", - refs: [ - { rel: "uses", kind: "attribute", key: PLACEMENT_WORKER }, - { rel: "uses", kind: "attribute", key: PLACEMENT_SITE }, - ], - }); - const trainingForm = yield* node({ - kind: "form", - key: "site-safety-training", - label: "Site safety training", - refs: [{ rel: "writes", kind: "attribute", key: TRAINING_EVIDENCE }], - }); - const policy = yield* node({ - kind: "policy", - key: "site-safety-required", - label: "Workers need current safety training for each site", - refs: [ - { rel: "reads", kind: "entity-type", key: "Placement" }, - { rel: "reads", kind: "attribute", key: TRAINING_EVIDENCE }, - { rel: "collects", kind: "form", key: "site-safety-training" }, - ], - }); - const routine = yield* node({ - kind: "routine", - key: "reconcile-site-safety", - label: "Reconcile site-safety work", - refs: [ - { rel: "evaluates", kind: "policy", key: "site-safety-required" }, - { rel: "opens", kind: "form", key: "site-safety-training" }, - ], - }); - const requirementNodes = yield* RequirementSchema.nodes; - const release = yield* config.commit({ label: "safety-2026.1", - objects: [ - placementWorker, - placementSite, - evidence, - placementType, - trainingForm, - policy, - routine, - ...requirementNodes, - ], + objects: [...(yield* EntityType.nodesOf(...model.types)), ...(yield* Runner.schema)], ref: "live", }); - const enforcement = GraphConstraint.enforcement(RequirementSchema.constraints); - const definition = yield* Derivation.make({ name: "task.site-safety-training", query: { find: ["?worker", "?site"], where: [ - ["?placement", PLACEMENT_WORKER, "?worker"], - ["?placement", PLACEMENT_SITE, "?site"], - ["not", ["?worker", TRAINING_EVIDENCE, "?site"]], + ["?placement", Placement.worker.key, "?worker"], + ["?placement", Placement.site.key, "?site"], + ["not", ["?worker", Worker.training.key, "?site"]], ], }, identity: ["?worker", "?site"], configSnapshot: release.snapshot.id, }); - - const loadOccurrences = (candidate?: string) => - triples.match({ entityType: Requirement.entityType }).pipe( - Effect.map((rows) => - [...groupByEntity(rows)].flatMap(([entityId, entityRows]) => { - const candidateValue = scalar(latestAt(entityRows, Requirement.attribute.candidate)); - const occurrence = scalar(latestAt(entityRows, Requirement.attribute.occurrence)); - const status = scalar(latestAt(entityRows, Requirement.attribute.status)); - const revision = scalar(latestAt(entityRows, Requirement.attribute.revision)); - if ( - typeof candidateValue !== "string" || - typeof occurrence !== "number" || - typeof status !== "string" || - typeof revision !== "string" || - (candidate !== undefined && candidateValue !== candidate) - ) { - return []; - } - return [ + return yield* Effect.gen(function* () { + const db = yield* Database.Database; + const triples = yield* Triples; + const occurrences = () => db.list(Requirement, { limit: 100 }); + const prior = (candidate: string) => + occurrences().pipe( + Effect.map((page) => + page.entities + .filter((e) => e.values.candidate === candidate) + .sort( + (a, b) => + occurrenceNumber(a.values.occurrence) - occurrenceNumber(b.values.occurrence), + ), + ), + ); + const meta = ( + delivery: Runner.Delivery, + kind: "added" | "removed" | "changed", + candidate: Derivation.Candidate, + ) => ({ + actor: "demo/requirement-reconciler", + commandId: delivery.commandId(kind, candidate.id), + correlationId: delivery.batchId, + }); + const openRequirement = (candidate: Derivation.Candidate, delivery: Runner.Delivery) => + db.command(meta(delivery, "added", candidate), (tx) => + Effect.gen(function* () { + const history = yield* prior(candidate.id); + for (const item of history) yield* tx.get(Requirement, item.entityId); + if (history.some((item) => item.values.status === "open")) return; + const occurrence = occurrenceNumber(history.at(-1)?.values.occurrence ?? 0) + 1; + yield* tx.create( + Requirement, + EntityId.make(`requirement:${candidate.id}:${occurrence}`), { - entityId, - candidate: candidateValue, + candidate: candidate.id, occurrence, - status, - revision, - } satisfies RequirementOccurrence, - ]; + status: "open", + revision: candidate.revision, + definition: delivery.definitionId, + configSnapshot: delivery.configSnapshot, + run: delivery.runId, + sourcePosition: delivery.sourcePosition, + openedAt: delivery.basis.validAt, + }, + { validFrom: 0 }, + ); + }), + ); + const editRequirement = ( + candidate: Derivation.Candidate, + delivery: Runner.Delivery, + kind: "removed" | "changed", + ) => + db.command(meta(delivery, kind, candidate), (tx) => + Effect.gen(function* () { + const open = (yield* prior(candidate.id)).findLast( + (item) => item.values.status === "open", + ); + if (!open) return; + yield* tx.get(Requirement, open.entityId); + const rows = yield* triples.liveFacts(open.entityId); + const attribute = kind === "removed" ? Requirement.status.key : Requirement.revision.key; + const row = rows.find((row) => row.attribute === attribute); + ensure(row, "open occurrence must have status and revision"); + const fact = yield* tx.fact(Requirement, row!.id); + if (kind === "removed") { + yield* tx.correct(Requirement, fact, { status: "satisfied" }); + yield* tx.put( + Requirement, + open.entityId, + { closedAt: delivery.basis.validAt }, + { validFrom: 0 }, + ); + } else yield* tx.correct(Requirement, fact, { revision: candidate.revision }); }), - ), + ); + const delivered: Runner.Delivery[] = []; + const handle = yield* Runner.start( + [ + { + key: "site-safety", + definition, + onDiff: (delivery) => + Effect.gen(function* () { + for (const c of delivery.diff.added) yield* openRequirement(c, delivery); + for (const c of delivery.diff.removed) yield* editRequirement(c, delivery, "removed"); + for (const { after } of delivery.diff.changed) + yield* editRequirement(after, delivery, "changed"); + delivered.push(delivery); + }), + }, + ], + { scope: DatabaseScope.test("compliance"), id: "compliance", idleCheckMs: 1 }, ); - - const reconcile = (run: Derivation.Materialization.MaterializationRun, validAt: number) => - Effect.gen(function* () { - for (const candidate of run.reconciliation.added) { - const prior = yield* loadOccurrences(candidate.id); - if (prior.some((occurrence) => occurrence.status === "open")) continue; - const occurrence = Math.max(0, ...prior.map((item) => item.occurrence)) + 1; - const entityId = EntityId.make(`requirement:${candidate.id}:${occurrence}`); - const operations: readonly TransactOp[] = [ - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.candidate, - value: string(candidate.id), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.occurrence, - value: number(occurrence), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.status, - value: string("open"), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.revision, - value: string(candidate.revision), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.definition, - value: string(definition.id), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.configSnapshot, - value: string(definition.configSnapshot), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.materializationRun, - value: string(run.id), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.sourcePosition, - value: number(run.sourcePosition), - }, - { - op: "assert", - entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.openedAt, - value: datetime(validAt), - }, - ]; - yield* triples.transact(operations, { - actor: "demo/requirement-reconciler", - commandId: `requirement:${run.id}:open:${candidate.id}`, - correlationId: run.id, - configSnapshot: definition.configSnapshot, - enforce: enforcement, - }); - } - - for (const candidate of run.reconciliation.removed) { - const open = (yield* loadOccurrences(candidate.id)) - .filter((occurrence) => occurrence.status === "open") - .sort((left, right) => right.occurrence - left.occurrence)[0]; - if (!open) continue; - const rows = yield* triples.match({ entityId: open.entityId }); - const status = latestAt(rows, Requirement.attribute.status); - if (!status) continue; - yield* triples.transact( - [ - { op: "retract", id: status.id }, - { - op: "assert", - entityId: open.entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.status, - value: string("satisfied"), - // This replaces the recorded value for the same business-time - // interval; the journal retains the status transition. - validFrom: status.validFrom, - }, - { - op: "assert", - entityId: open.entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.closedAt, - value: datetime(validAt), - }, - ], - { - actor: "demo/requirement-reconciler", - commandId: `requirement:${run.id}:satisfy:${candidate.id}`, - correlationId: run.id, - configSnapshot: definition.configSnapshot, - enforce: enforcement, - preconditions: [{ _tag: "TripleLive", id: status.id }], - }, - ); - } - - for (const change of run.reconciliation.changed) { - const open = (yield* loadOccurrences(change.after.id)).find( - (occurrence) => occurrence.status === "open", + // Deterministic scenario harness: the application above contains only domain handlers. + const waitFor = (count: number) => + Effect.gen(function* () { + for (let i = 0; i < 1000; i++) { + if ( + delivered.length >= count && + (yield* handle.status).registrations[0]!.pending === null + ) + return; + yield* Effect.raceFirst(Effect.yieldNow, handle.await); + yield* TestClock.adjust(1); + } + throw new Error( + `Runner did not deliver scenario transition ${count}: ${JSON.stringify(yield* handle.status)}`, ); - if (!open || open.revision === change.after.revision) continue; - const rows = yield* triples.match({ entityId: open.entityId }); - const revision = latestAt(rows, Requirement.attribute.revision); - if (!revision) continue; - yield* triples.transact( - [ - { op: "retract", id: revision.id }, - { - op: "assert", - entityId: open.entityId, - entityType: Requirement.entityType, - attribute: Requirement.attribute.revision, - value: string(change.after.revision), - validFrom: revision.validFrom, - }, - ], + }); + yield* db.command({ actor: "demo/placement-api", commandId: "placement:one" }, (tx) => + tx.create(Placement, EntityId.make("placement:one"), { worker, site }, { validFrom: 100 }), + ); + yield* waitFor(1); + ensure( + (yield* occurrences()).entities[0]?.values.status === "open", + "placement opens occurrence 1", + ); + const preview = yield* Derivation.Overlay.evaluateOverlay(triples, definition, { + basis: { validAt: 150 }, + overlay: { + assertions: [ { - actor: "demo/requirement-reconciler", - commandId: `requirement:${run.id}:revise:${change.after.id}`, - correlationId: run.id, - configSnapshot: definition.configSnapshot, - enforce: enforcement, - preconditions: [{ _tag: "TripleLive", id: revision.id }], + entityId: worker, + entityType: "Worker", + attribute: Worker.training.key, + value: Worker.training.definition.encode(site), + validFrom: 120, + validTo: 200, }, - ); - } - }); - - // Timer delivery remains host-owned. The feed cursor itself is a durable - // Triplex fact with optimistic concurrency. - const consumer = "demo/site-safety-materializer/v1"; - const scheduled = new Map(); - - const materializeAt = (validAt: number) => - Effect.gen(function* () { - const run = yield* Derivation.Materialization.materialize(triples, definition, { - basis: { validAt }, - }); - yield* reconcile(run, validAt); - if (run.nextTemporalBoundary === undefined) scheduled.delete(definition.id); - else scheduled.set(definition.id, run.nextTemporalBoundary); - return run; - }); - - const consumeTransactions = (validAt: number) => - Effect.gen(function* () { - const checkpoint = yield* ConsumerCheckpoint.get(triples, consumer); - const expectedPosition = checkpoint?.position ?? 0; - let cursor = expectedPosition; - let relevant = false; - while (true) { - const page = yield* triples.transactions({ after: cursor, limit: 100 }); - relevant ||= page.transactions.some((transaction) => - transaction.changes.some((change) => - definition.dependencies.attributes.includes(change.attribute), - ), - ); - if (page.next !== undefined) cursor = page.next; - if (page.transactions.length < 100 || page.next === undefined) break; - } - const run = relevant ? yield* materializeAt(validAt) : undefined; - if (cursor > expectedPosition) { - yield* ConsumerCheckpoint.advance(triples, { - consumer, - expectedPosition, - nextPosition: cursor, - meta: { actor: "demo/transaction-consumer" }, - }); - } - return run; - }); - - const runDueWakeups = (validAt: number) => - Effect.gen(function* () { - const due = [...scheduled].filter(([, at]) => at <= validAt); - for (const [definitionId] of due) { - scheduled.delete(definitionId); - if (definitionId === definition.id) yield* materializeAt(validAt); - } - return due.length; - }); - - // 1. A source command creates a placement. The host feed consumer catches - // up and opens occurrence 1 from the added derivation candidate. - yield* triples.transact( - [ - { - op: "assert", - entityId: placementId, - entityType: "Placement", - attribute: PLACEMENT_WORKER, - value: ref(workerId), - validFrom: 100, - }, - { - op: "assert", - entityId: placementId, - entityType: "Placement", - attribute: PLACEMENT_SITE, - value: ref(siteId), - validFrom: 100, - }, - ], - { - actor: "demo/placement-api", - commandId: "placement:create:one", - correlationId: "demo:placement-one", - configSnapshot: release.snapshot.id, - enforce: enforcement, - }, - ); - const opened = yield* consumeTransactions(110); - ensure(opened?.reconciliation.added.length === 1, "placement should open one requirement"); - - // 2. A planner previews reusable evidence without mutating facts, work, the - // transaction journal, or the scheduler. - const preview = yield* Derivation.Overlay.evaluateOverlay(triples, definition, { - basis: { validAt: 150 }, - overlay: { - assertions: [ - { - entityId: workerId, - entityType: "Worker", - attribute: TRAINING_EVIDENCE, - value: ref(siteId), - validFrom: 120, - validTo: 200, - }, - ], - }, - }); - ensure(preview.candidates.length === 0, "hypothetical evidence should satisfy the task"); - ensure(preview.nextTemporalBoundary === 200, "preview should expose evidence expiry"); - ensure((yield* loadOccurrences())[0]?.status === "open", "preview must not close durable work"); - - // 3. The real evidence command closes occurrence 1. The zero-candidate run - // still persists validTo=200 as its next temporal wakeup. - yield* triples.transact( - [ - { - op: "assert", - entityId: workerId, - entityType: "Worker", - attribute: TRAINING_EVIDENCE, - value: ref(siteId), - validFrom: 120, - validTo: 200, + ], }, - ], - { - actor: "demo/evidence-api", - commandId: "training:submit:maria:harbor", - causationId: "placement:create:one", - correlationId: "demo:placement-one", - configSnapshot: release.snapshot.id, - enforce: enforcement, - }, - ); - const satisfied = yield* consumeTransactions(150); - ensure(satisfied?.candidates.length === 0, "valid evidence should suppress the task"); - ensure(scheduled.get(definition.id) === 200, "host should schedule the expiry boundary"); - ensure((yield* loadOccurrences())[0]?.status === "satisfied", "occurrence 1 should close"); - - // 4. No source transaction happens at expiry. Timer delivery invokes the - // materializer at 200, and the host opens occurrence 2 while preserving 1. - ensure((yield* runDueWakeups(199)) === 0, "scheduler must not fire early"); - ensure((yield* runDueWakeups(200)) === 1, "scheduler should fire at evidence expiry"); - yield* consumeTransactions(200); // advance past the host's own projection writes - - const occurrences = (yield* loadOccurrences()).sort( - (left, right) => left.occurrence - right.occurrence, - ); - ensure(occurrences.length === 2, "expiry should retain occurrence 1 and open occurrence 2"); - ensure(occurrences[0]?.status === "satisfied", "occurrence 1 history must remain satisfied"); - ensure(occurrences[1]?.status === "open", "occurrence 2 should be open after expiry"); - ensure(scheduled.size === 0, "there should be no boundary after the evidence has expired"); - - const queryableWork = yield* triples.query({ - find: ["?requirement", "?occurrence", "?status"], - where: [ - ["?requirement", Requirement.attribute.occurrence, "?occurrence"], - ["?requirement", Requirement.attribute.status, "?status"], - ], - }); - ensure(queryableWork.results.length === 2, "requirement history should be queryable by Datalog"); - - const journal = yield* triples.transactions({ after: 0, limit: 1_000 }); - const reconcilerTransactions = journal.transactions.filter( - (transaction) => transaction.actor === "demo/requirement-reconciler", - ); - ensure(reconcilerTransactions.length === 3, "open, satisfy, and reopen must be auditable"); - const consumerCheckpoint = yield* ConsumerCheckpoint.get(triples, consumer); - ensure(consumerCheckpoint !== null, "the transaction consumer must persist its checkpoint"); - - return { - release: release.snapshot.id, - definition: definition.id, - consumerPosition: consumerCheckpoint.position, - previewCandidates: preview.candidates.length, - occurrences, - reconcilerTransactions: reconcilerTransactions.map((transaction) => ({ - position: transaction.position, - commandId: transaction.commandId, - })), - }; + }); + ensure( + preview.candidates.length === 0 && preview.nextTemporalBoundary === 200, + "read-only preview reuses expiring evidence", + ); + ensure( + (yield* occurrences()).entities[0]?.values.status === "open", + "preview leaves durable work open", + ); + yield* TestClock.setTime(150); + yield* db.command({ actor: "demo/evidence-api", commandId: "training:maria:harbor" }, (tx) => + tx.put(Worker, worker, { training: site }, { validFrom: 120, validTo: 200 }), + ); + yield* waitFor(2); + ensure( + (yield* occurrences()).entities[0]?.values.status === "satisfied", + "evidence satisfies occurrence 1", + ); + ensure( + (yield* handle.status).registrations[0]!.nextDeadline === 200, + "empty evaluation retains expiry", + ); + yield* TestClock.setTime(199); + ensure(delivered.length === 2, "expiry does not fire early"); + yield* TestClock.setTime(200); + yield* waitFor(3); + const history = [...(yield* occurrences()).entities].sort( + (a, b) => occurrenceNumber(a.values.occurrence) - occurrenceNumber(b.values.occurrence), + ); + ensure( + history.length === 2 && + history[0]!.values.status === "satisfied" && + history[1]!.values.status === "open", + "open → satisfied → reopened", + ); + ensure( + delivered[0]!.batchId !== delivered[2]!.batchId, + "expiry creates a new occurrence identity", + ); + const status = yield* handle.status; + yield* handle.stop; + return { + release: release.snapshot.id, + definition: definition.id, + consumerPosition: status.deliveredPosition, + previewCandidates: preview.candidates.length, + occurrences: history.map((e) => e.values), + }; + }).pipe(Effect.provide(Database.layer({ schema: model }))); }); - -const HostLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); -const result = await Effect.runPromise(program.pipe(Effect.provide(HostLayer))); - -console.log("Triplex compliance host demo"); -console.log(" config release ", result.release); -console.log(" derivation ", result.definition); -console.log(" preview candidates ", result.previewCandidates, "(read-only evidence reuse)"); -console.log(" consumer position ", result.consumerPosition); -console.log( - " requirement history", - result.occurrences.map((item) => `#${item.occurrence} ${item.status}`).join(" -> "), +const HostLayer = ConfigStore.layer.pipe( + Layer.provideMerge(KvTriples.layerWithClock(Clock.currentTimeMillis)), +); +const result = await Effect.runPromise( + Effect.gen(function* () { + yield* TestClock.setTime(110); + return yield* application; + }).pipe(Effect.scoped, Effect.provide(HostLayer), Effect.provide(TestClock.layer())), ); -console.log(" reconciler audit ", result.reconcilerTransactions); +console.log("Triplex compliance host demo", result); diff --git a/examples/http-api/package.json b/examples/http-api/package.json index ab52edda..720ea215 100644 --- a/examples/http-api/package.json +++ b/examples/http-api/package.json @@ -14,8 +14,6 @@ "dependencies": { "@effect/platform-node": "catalog:", "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-http": "workspace:^", - "@triplex-build/triplex-sqlite": "workspace:^", "effect": "catalog:" }, "devDependencies": { diff --git a/examples/http-api/src/api.ts b/examples/http-api/src/api.ts index c1bdbaf2..3526d0dc 100644 --- a/examples/http-api/src/api.ts +++ b/examples/http-api/src/api.ts @@ -1,5 +1,5 @@ import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; -import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; +import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex/http"; import { Effect, Layer } from "effect"; const EmployeeName = Attribute.text(":employee/name"); diff --git a/examples/http-api/src/server.ts b/examples/http-api/src/server.ts index 0bfd909d..eae742a1 100644 --- a/examples/http-api/src/server.ts +++ b/examples/http-api/src/server.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeRuntime, NodeServices } from "@effect/platform-node"; import { KvTriples } from "@triplex-build/triplex"; import { ConfigStore } from "@triplex-build/triplex/config"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { Effect, Layer, Option, Path, Schema } from "effect"; import { Command, Flag } from "effect/cli"; import { HttpRouter, HttpServer } from "effect/http"; diff --git a/examples/tenant-host-cloudflare/package.json b/examples/tenant-host-cloudflare/package.json index 389c4a71..8d1ecaea 100644 --- a/examples/tenant-host-cloudflare/package.json +++ b/examples/tenant-host-cloudflare/package.json @@ -15,8 +15,6 @@ }, "dependencies": { "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-cloudflare": "workspace:^", - "@triplex-build/triplex-host": "workspace:^", "effect": "catalog:" }, "devDependencies": { diff --git a/examples/tenant-host-cloudflare/src/worker.ts b/examples/tenant-host-cloudflare/src/worker.ts index 3bc99033..c8f047ef 100644 --- a/examples/tenant-host-cloudflare/src/worker.ts +++ b/examples/tenant-host-cloudflare/src/worker.ts @@ -1,7 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import { Effect, Layer, ManagedRuntime, Schema } from "effect"; import { Triples } from "@triplex-build/triplex"; -import { CloudflareTriples, type DOState } from "@triplex-build/triplex-cloudflare"; +import { CloudflareTriples, type DOState } from "@triplex-build/triplex/cloudflare"; import { AuthorizationError, InstanceIdentity, @@ -15,7 +15,7 @@ import { type Principal, type TenantDataRequest, type TenantRecord as TenantRecordType, -} from "@triplex-build/triplex-host"; +} from "@triplex-build/triplex/host"; interface HostEnv { readonly TENANT_DATABASES: DurableObjectNamespace; diff --git a/package.json b/package.json index e0db8284..58635a05 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "description": "The database that remembers why: an embedded, bitemporal fact database for TypeScript back-office systems, built on Effect.", "author": "Ben Jacobson", "type": "module", + "publishConfig": { + "directory": "packages/triplex/publish", + "linkDirectory": false + }, "scripts": { "build": "turbo run build && pnpm docs:build", "changeset": "changeset", @@ -39,7 +43,8 @@ "test:foundationdb:integration": "pnpm --filter @triplex-build/triplex-foundationdb test:integration", "test:postgres:integration": "pnpm --filter @triplex-build/triplex-postgres test:integration", "test:watch": "turbo watch test", - "typecheck": "tsc --noEmit -p tsconfig.alchemy.json && turbo run typecheck" + "typecheck": "tsc --noEmit -p tsconfig.alchemy.json && turbo run typecheck", + "prepack": "turbo run build --filter=@triplex-build/triplex" }, "devDependencies": { "@alchemy.run/cloudflare": "catalog:", @@ -50,6 +55,7 @@ "@fontsource/ibm-plex-mono": "catalog:", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", + "@triplex-build/triplex": "workspace:^", "@types/node": "catalog:", "alchemy": "catalog:", "effect": "catalog:", diff --git a/packages/cli/README.md b/packages/cli/README.md index 881cb200..2420c0e0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5,14 +5,21 @@ Effect v4 `Command`, `Flag`, and `Argument` modules and emits stable JSON envelo terminal-oriented tables. ```sh -pnpm turbo run build --filter=@triplex-build/triplex-cli... -triplex() { node --disable-warning=ExperimentalWarning packages/cli/dist/cli.js "$@"; } +pnpm turbo run build --filter=@triplex-build/triplex +triplex() { node --disable-warning=ExperimentalWarning packages/triplex/dist/bin.js "$@"; } triplex --sqlite ./app.db describe triplex --sqlite ./app.db status triplex --sqlite ./app.db entity types ``` -Install `@triplex-build/triplex-cli` from npm to use the `triplex` binary. The repository command +Install the single package and the SQLite/Node optional peers to use the `triplex` binary: + +```sh +npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ + @effect/platform-node @effect/platform-node-shared +``` + +Use `triplex mcp` for stdio MCP and `triplex dashboard --sqlite ./app.db` for the bundled explorer. The repository command above runs the same CLI from a source checkout. The unscoped `triplex` package name on npm belongs to an unrelated project. diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d52d61a..8d12c6d5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,7 @@ { "name": "@triplex-build/triplex-cli", "version": "0.1.1", + "private": true, "description": "Agent-friendly Effect CLI for exploring and operating Triplex databases.", "keywords": [ "agent", @@ -40,9 +41,6 @@ "default": "./dist/index.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown && tsc --declaration --emitDeclarationOnly --rootDir src --outDir dist", "clean": "rm -rf dist", @@ -51,10 +49,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", "@triplex-build/triplex-postgres": "workspace:^", "@triplex-build/triplex-sql": "workspace:^", - "@triplex-build/triplex-sqlite": "workspace:^" + "@triplex-build/triplex-sqlite": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/cli/src/describe.ts b/packages/cli/src/describe.ts index ee56e4c2..a28c4c50 100644 --- a/packages/cli/src/describe.ts +++ b/packages/cli/src/describe.ts @@ -1,4 +1,4 @@ -import { EntityId, type TransactRequest, type TripleValue } from "@triplex-build/triplex"; +import { EntityId, type TransactRequest, type TripleValue } from "@triplex-internal/core"; import { Attribute, ConfigNode, @@ -6,7 +6,7 @@ import { EntityValidation, GraphConstraint, TypeExpr, -} from "@triplex-build/triplex/config"; +} from "@triplex-internal/core/config"; import { Effect, Schema } from "effect"; export const manifest = { diff --git a/packages/cli/src/operations.ts b/packages/cli/src/operations.ts index 32e94867..31a5b84a 100644 --- a/packages/cli/src/operations.ts +++ b/packages/cli/src/operations.ts @@ -9,11 +9,11 @@ import { type TransactRequest, type Triple, type WrappedQuery as WrappedQueryType, -} from "@triplex-build/triplex"; -import { ConfigStore, InMemoryConfigStore } from "@triplex-build/triplex/config"; -import type { ContentId } from "@triplex-build/triplex/content"; -import { Why, type WhyOptions, type WhySubject } from "@triplex-build/triplex/why"; -import { previewTransaction } from "@triplex-build/triplex/database"; +} from "@triplex-internal/core"; +import { ConfigStore, InMemoryConfigStore } from "@triplex-internal/core/config"; +import type { ContentId } from "@triplex-internal/core/content"; +import { Why, type WhyOptions, type WhySubject } from "@triplex-internal/core/why"; +import { previewTransaction } from "@triplex-internal/core/database"; import { Effect, Option } from "effect"; import { describeRelease, manifest } from "./describe.js"; diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index d695ebc8..f5e2967f 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -1,10 +1,5 @@ -import { - makePostgresqlBackend, - PgTriples, - type PostgresqlConfig, -} from "@triplex-build/triplex-postgres"; +import type { PostgresqlConfig } from "@triplex-build/triplex-postgres"; import { DatabaseManagerLive, DatabaseRegistryLive } from "@triplex-build/triplex-sql"; -import { makeSqliteBackend, SqliteTriples } from "@triplex-build/triplex-sqlite"; import { diagnose, type Diagnostic, @@ -18,10 +13,10 @@ import { TransactRequest, WrappedQuery, queryToPattern, -} from "@triplex-build/triplex"; -import { RuntimeServicesLive, TripleStoreRuntimeLayer } from "@triplex-build/triplex/internal"; -import { ConfigStore } from "@triplex-build/triplex/config"; -import { ContentId } from "@triplex-build/triplex/content"; +} from "@triplex-internal/core"; +import { RuntimeServicesLive, TripleStoreRuntimeLayer } from "@triplex-internal/core/internal"; +import { ConfigStore } from "@triplex-internal/core/config"; +import { ContentId } from "@triplex-internal/core/content"; import { Cause, Console, @@ -233,14 +228,24 @@ const databaseLayer = Effect.fn(function* () { const url = fromFlag ?? process.env["TRIPLEX_POSTGRES_URL"]; const triplesLayer = url === undefined - ? Option.match(root.databaseId, { - onNone: () => SqliteTriples.layer({ filename: root.sqlite }), - onSome: (id) => SqliteTriples.layer({ filename: `${root.dataDir}/${id}.db` }), + ? (yield* Effect.promise(() => import("@triplex-build/triplex-sqlite"))).SqliteTriples.layer({ + filename: Option.match(root.databaseId, { + onNone: () => root.sqlite, + onSome: (id) => `${root.dataDir}/${id}.db`, + }), }) : Option.match(root.databaseId, { - onNone: () => PgTriples.layerFromUrl(url), - onSome: (id) => PgTriples.layerForDatabaseMigrated(postgresConfig(url), id), - }); + onNone: () => + Effect.promise(() => import("@triplex-build/triplex-postgres")).pipe( + Effect.map(({ PgTriples }) => PgTriples.layerFromUrl(url)), + ), + onSome: (id) => + Effect.promise(() => import("@triplex-build/triplex-postgres")).pipe( + Effect.map(({ PgTriples }) => + PgTriples.layerForDatabaseMigrated(postgresConfig(url), id), + ), + ), + }).pipe(Layer.unwrap); return ConfigStore.layer.pipe(Layer.provideMerge(triplesLayer)); }); @@ -249,8 +254,12 @@ const databaseManagerLayer = Effect.fn(function* () { const url = optionValue(root.postgresUrl) ?? process.env["TRIPLEX_POSTGRES_URL"]; const backend = url === undefined - ? makeSqliteBackend({ dataDir: root.dataDir }) - : makePostgresqlBackend(postgresConfig(url)); + ? (yield* Effect.promise(() => import("@triplex-build/triplex-sqlite"))).makeSqliteBackend({ + dataDir: root.dataDir, + }) + : (yield* Effect.promise( + () => import("@triplex-build/triplex-postgres"), + )).makePostgresqlBackend(postgresConfig(url)); return DatabaseManagerLive.pipe( Layer.provide(DatabaseRegistryLive), diff --git a/packages/cli/src/why.ts b/packages/cli/src/why.ts index ef50527f..6365e182 100644 --- a/packages/cli/src/why.ts +++ b/packages/cli/src/why.ts @@ -1,8 +1,8 @@ /** Shared CLI/MCP wire schema for stored and derivation subjects. */ -import { DatalogQuery, EntityId, TripleId } from "@triplex-build/triplex"; -import { TypeExpr } from "@triplex-build/triplex/config"; -import { ContentId } from "@triplex-build/triplex/content"; -import type { WhySubject as Subject } from "@triplex-build/triplex/why"; +import { DatalogQuery, EntityId, TripleId } from "@triplex-internal/core"; +import { TypeExpr } from "@triplex-internal/core/config"; +import { ContentId } from "@triplex-internal/core/content"; +import type { WhySubject as Subject } from "@triplex-internal/core/why"; import { Schema } from "effect"; const Definition = Schema.Struct({ diff --git a/packages/cli/test/database-operations.test.ts b/packages/cli/test/database-operations.test.ts index 8eb4b69a..07ed2da7 100644 --- a/packages/cli/test/database-operations.test.ts +++ b/packages/cli/test/database-operations.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; -import { DatabaseManager } from "@triplex-build/triplex"; -import { RuntimeServicesLive, TripleStoreRuntimeLayer } from "@triplex-build/triplex/internal"; +import { DatabaseManager } from "@triplex-internal/core"; +import { RuntimeServicesLive, TripleStoreRuntimeLayer } from "@triplex-internal/core/internal"; import { DatabaseManagerLive, DatabaseRegistryLive } from "@triplex-build/triplex-sql"; import { makeSqliteBackend } from "@triplex-build/triplex-sqlite"; import { Effect, Layer } from "effect"; diff --git a/packages/cli/test/describe.test.ts b/packages/cli/test/describe.test.ts index 150acc30..90dacacf 100644 --- a/packages/cli/test/describe.test.ts +++ b/packages/cli/test/describe.test.ts @@ -1,6 +1,6 @@ -import { Attribute, ConfigNode, ConfigStore, EntityType } from "@triplex-build/triplex/config"; -import { Triples } from "@triplex-build/triplex"; -import * as Derivation from "@triplex-build/triplex/derivation"; +import { Attribute, ConfigNode, ConfigStore, EntityType } from "@triplex-internal/core/config"; +import { Triples } from "@triplex-internal/core"; +import * as Derivation from "@triplex-internal/core/derivation"; import { SqliteTriples } from "@triplex-build/triplex-sqlite"; import { Effect, Layer } from "effect"; import { execFile } from "node:child_process"; diff --git a/packages/cli/test/diagnostics.test.ts b/packages/cli/test/diagnostics.test.ts index 8fe2ecd6..5cffc0dd 100644 --- a/packages/cli/test/diagnostics.test.ts +++ b/packages/cli/test/diagnostics.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { GraphConstraint } from "@triplex-build/triplex/config"; +import { GraphConstraint } from "@triplex-internal/core/config"; import { beforeAll, expect, it } from "vitest"; import { assertBuiltCliIsFresh, cliArgs } from "./builtCli.js"; diff --git a/packages/cli/test/operations.test.ts b/packages/cli/test/operations.test.ts index 838c9f17..bdfd2cf8 100644 --- a/packages/cli/test/operations.test.ts +++ b/packages/cli/test/operations.test.ts @@ -1,5 +1,5 @@ -import { EntityId, KvTriples } from "@triplex-build/triplex"; -import { ConfigNode, ConfigStore } from "@triplex-build/triplex/config"; +import { EntityId, KvTriples } from "@triplex-internal/core"; +import { ConfigNode, ConfigStore } from "@triplex-internal/core/config"; import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { execute } from "../src/operations.js"; diff --git a/packages/cli/test/sensitive.test.ts b/packages/cli/test/sensitive.test.ts index 9d7cfae3..a1d3f496 100644 --- a/packages/cli/test/sensitive.test.ts +++ b/packages/cli/test/sensitive.test.ts @@ -1,6 +1,6 @@ -import { EntityId, KvTriples } from "@triplex-build/triplex"; -import { Attribute, ConfigStore } from "@triplex-build/triplex/config"; -import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; +import { EntityId, KvTriples } from "@triplex-internal/core"; +import { Attribute, ConfigStore } from "@triplex-internal/core/config"; +import { KeyProvider, Sensitive, ValueCodec } from "@triplex-internal/core/sensitive"; import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index fd389279..4ede1ceb 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsdown"; import { readFileSync } from "node:fs"; const packageJson = JSON.parse( - readFileSync(new URL("./package.json", import.meta.url), "utf8"), + readFileSync(new URL("../triplex/package.json", import.meta.url), "utf8"), ) as { readonly version: string }; export default defineConfig({ diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 3c4d8e36..5bb63d9d 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -1,15 +1,15 @@ -# @triplex-build/triplex-cloudflare +# @triplex-build/triplex/cloudflare Cloudflare Durable Object SQLite storage support for Triplex. -This is a private experimental workspace package and is not available from npm. Evaluate it only -from a source checkout with the repository's locked dependencies. +This experimental, frozen backend ships as a subpath of `@triplex-build/triplex`. +Its implementation workspace remains private; new features target KV, SQLite and PostgreSQL. Compose the complete service once in a Durable Object constructor and retain the resulting Effect runtime for the object's activation: ```ts -import { CloudflareTriples } from "@triplex-build/triplex-cloudflare"; +import { CloudflareTriples } from "@triplex-build/triplex/cloudflare"; const layer = CloudflareTriples.layer({ state: durableObjectState, @@ -32,8 +32,8 @@ import { changeEmission, type ChangeEmitterService, } from "@triplex-build/triplex/runtime"; -import { SqlSnapshotsLive } from "@triplex-build/triplex-sql"; -import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare"; +import { SqlSnapshotsLive } from "@triplex-build/triplex/sql"; +import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex/cloudflare"; declare const state: DOState; declare const emitter: ChangeEmitterService; @@ -55,10 +55,10 @@ All work inside a `Triples.transact` boundary must remain synchronous. Effects t timer, network request, or other asynchronous service fail and roll back the native `transactionSync` callback. -`pnpm --filter @triplex-build/triplex-cloudflare test` runs the shared backend corpus twice: once in a +`pnpm --filter @triplex-build/triplex/cloudflare test` runs the shared backend corpus twice: once in a fast native-SQL harness and once inside Cloudflare's workerd Vitest integration with a real SQLite-backed Durable Object. -Status: private experimental workspace package, held from the first npm release. +Status: experimental and frozen; shipped in the single Triplex package. MIT © 2026 Ben Jacobson. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 400a76e1..d0824e04 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -44,8 +44,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-sql": "workspace:^" + "@triplex-build/triplex-sql": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@cloudflare/vitest-plugin": "catalog:", diff --git a/packages/cloudflare/src/CloudflareTriples.ts b/packages/cloudflare/src/CloudflareTriples.ts index db2aa384..9c8422b0 100644 --- a/packages/cloudflare/src/CloudflareTriples.ts +++ b/packages/cloudflare/src/CloudflareTriples.ts @@ -1,7 +1,7 @@ /** Complete `Triples` composition for one Durable Object SQLite database. */ import { Effect } from "effect"; -import { Runtime, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; +import { Runtime, DatabaseScope, Capabilities } from "@triplex-internal/core/runtime"; import { makeSqlQueryExecutorLayer, SqliteDialect, diff --git a/packages/cloudflare/src/adapter-support.ts b/packages/cloudflare/src/adapter-support.ts index 74d72401..2255941b 100644 --- a/packages/cloudflare/src/adapter-support.ts +++ b/packages/cloudflare/src/adapter-support.ts @@ -1,11 +1,11 @@ -import { encodeSealedEnvelope, type TripleValue, type ValueType } from "@triplex-build/triplex"; +import { encodeSealedEnvelope, type TripleValue, type ValueType } from "@triplex-internal/core"; import { MigrationError, ReadError, StorageAdapter, type StorageAdapterService, WriteError, -} from "@triplex-build/triplex/runtime"; +} from "@triplex-internal/core/runtime"; export { StorageAdapter, type StorageAdapterService, MigrationError, ReadError, WriteError }; export type { SqlStorageAdapterService } from "@triplex-build/triplex-sql"; diff --git a/packages/cloudflare/src/services/CloudflareDatabaseManager.ts b/packages/cloudflare/src/services/CloudflareDatabaseManager.ts index fec42b19..80a98e73 100644 --- a/packages/cloudflare/src/services/CloudflareDatabaseManager.ts +++ b/packages/cloudflare/src/services/CloudflareDatabaseManager.ts @@ -18,7 +18,7 @@ import { type ClearResult, type TriplesService, InternalError, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; // Re-export for convenience of internal callers export { DatabaseManager }; diff --git a/packages/cloudflare/src/storage/CloudflareAdapter.ts b/packages/cloudflare/src/storage/CloudflareAdapter.ts index e8fd90c6..90b313c8 100644 --- a/packages/cloudflare/src/storage/CloudflareAdapter.ts +++ b/packages/cloudflare/src/storage/CloudflareAdapter.ts @@ -31,7 +31,7 @@ import { makeSqlTransactionPrefixLookup, SqliteDialect, } from "@triplex-build/triplex-sql"; -import type { TripleRow } from "@triplex-build/triplex/runtime"; +import type { TripleRow } from "@triplex-internal/core/runtime"; // ============================================================================= // Durable Object Types diff --git a/packages/cloudflare/test/conformance.test.ts b/packages/cloudflare/test/conformance.test.ts index 62f5bf5d..bb61e460 100644 --- a/packages/cloudflare/test/conformance.test.ts +++ b/packages/cloudflare/test/conformance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Cause, Effect, Exit } from "effect"; -import { Triples } from "@triplex-build/triplex"; +import { Triples } from "@triplex-internal/core"; import { Layer } from "effect"; import { findPlaintext, diff --git a/packages/cloudflare/test/runtime.test.ts b/packages/cloudflare/test/runtime.test.ts index c5d87c27..48fcc387 100644 --- a/packages/cloudflare/test/runtime.test.ts +++ b/packages/cloudflare/test/runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Effect, Layer } from "effect"; -import { EntityId, Triples, number } from "@triplex-build/triplex"; +import { EntityId, Triples, number } from "@triplex-internal/core"; import { Capabilities, DatabaseScope, @@ -10,7 +10,7 @@ import { changeEmission, entitySnapshots, type ChangeEvent, -} from "@triplex-build/triplex/runtime"; +} from "@triplex-internal/core/runtime"; import { SqlSnapshotsLive } from "@triplex-build/triplex-sql"; import { runtimeConformance } from "@triplex-build/triplex-testkit"; import { makeCloudflareRuntime, makeCloudflareAdapter, migrations } from "../src/index.js"; diff --git a/packages/core/README.md b/packages/core/README.md index d590274f..902b0af5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -14,7 +14,7 @@ and Effect services. ## Availability -Install `@triplex-build/triplex` from npm with Node.js 22+ and its `effect@4.1.1` peer +Install `@triplex-build/triplex` from npm with Node.js 22.19.0+ and its `effect` peer dependency. See the [quickstart](../../docs/getting-started.md). Do not use the superseded `@bjacobso` canaries for new work. @@ -43,7 +43,7 @@ const program = Effect.gen(function* () { Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer))); ``` -SQL implementations live in `@triplex-build/triplex-sql` and the backend-specific packages. +SQL implementations live in `@triplex-build/triplex/sql` and the backend-specific packages. ## Custom runtimes diff --git a/packages/core/package.json b/packages/core/package.json index 92ba2c63..336393f4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,7 @@ { - "name": "@triplex-build/triplex", + "name": "@triplex-internal/core", "version": "0.2.0", + "private": true, "description": "The database that remembers why: an embedded, bitemporal fact database for TypeScript back-office systems, built on Effect.", "keywords": [ "configuration", @@ -96,11 +97,13 @@ "types": "./dist/why/index.d.ts", "import": "./dist/why/index.js", "default": "./dist/why/index.js" + }, + "./runner": { + "types": "./dist/runner/index.d.ts", + "import": "./dist/runner/index.js", + "default": "./dist/runner/index.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown", "dev": "tsdown --watch", diff --git a/packages/core/src/kv/layers/KvTriplesLive.ts b/packages/core/src/kv/layers/KvTriplesLive.ts index d03a5774..72e3c656 100644 --- a/packages/core/src/kv/layers/KvTriplesLive.ts +++ b/packages/core/src/kv/layers/KvTriplesLive.ts @@ -1248,6 +1248,18 @@ export const KvTriples = { Layer.provide(TripleStoreRuntimeLayer), ), + /** Fresh in-memory store using an injected Effect clock for commit instants. */ + layerWithClock: (now: Effect.Effect) => + KvTriplesLive.pipe( + Layer.provide(Layer.sync(KvBackend, makeTestKvBackend)), + Layer.provide( + Layer.effect( + TripleStoreRuntime, + Effect.map(TripleStoreRuntime, (runtime) => ({ ...runtime, now })), + ).pipe(Layer.provide(TripleStoreRuntimeLayer)), + ), + ), + /** Fresh in-memory store with an explicit opaque-cursor scope identity. */ layerWithScope: (scope: string) => KvTriplesLive.pipe( diff --git a/packages/core/src/operational/ConsumerCheckpoint.ts b/packages/core/src/operational/ConsumerCheckpoint.ts index cbc03566..5b8865d2 100644 --- a/packages/core/src/operational/ConsumerCheckpoint.ts +++ b/packages/core/src/operational/ConsumerCheckpoint.ts @@ -111,7 +111,7 @@ const readLoaded = ( triples: TriplesService, consumer: string, ): Effect.Effect => - triples.match({ entityId: entityId(consumer) }).pipe( + triples.liveFacts(entityId(consumer)).pipe( Effect.flatMap((rows) => { if (rows.length === 0) return Effect.succeed(null); @@ -175,6 +175,7 @@ const assertPosition = (consumer: string, position: number): TransactOp => ({ entityType: System.entityType, attribute: System.attribute.position, value: { type: "number", value: position }, + validFrom: 0, }); /** @@ -230,6 +231,7 @@ export const advance = ( entityType: System.entityType, attribute: System.attribute.consumer, value: { type: "string", value: request.consumer }, + validFrom: 0, }, assertPosition(request.consumer, request.nextPosition), ], diff --git a/packages/core/src/runner/index.ts b/packages/core/src/runner/index.ts new file mode 100644 index 00000000..9397e564 --- /dev/null +++ b/packages/core/src/runner/index.ts @@ -0,0 +1,1095 @@ +/** Durable, scoped derivation delivery. One host-assigned owner per database. */ +import { Clock, Data, Deferred, Effect, Fiber, Schema, Semaphore, type Scope } from "effect"; +import { Triples, type TransactionRecord } from "../store/Triples.js"; +import { EntityId, TripleId, TransactionId, unsafe } from "../Branded.js"; +import type { TransactOp, Triple } from "../Triple.js"; +import * as Derivation from "../derivation/Derivation.js"; +import * as Materialization from "../derivation/Materialization.js"; +import { Outbox, ConsumerCheckpoint } from "../operational/index.js"; +import { encodeOrThrow, type CanonicalValue } from "../content/CanonicalJson.js"; +import { hash, ContentIdSchema, Domain } from "../content/ContentId.js"; +import { validateDatabaseScope, type DatabaseScope } from "../runtime/DatabaseScope.js"; +import { Constant as ConstantSchema } from "../datalog/schema.js"; +import * as Attribute from "../config/Attribute.js"; +import * as ConfigNode from "../config/ConfigNode.js"; +import * as TypeExpr from "../config/TypeExpr.js"; + +export interface Delivery { + readonly version: 1; + readonly batchId: string; + readonly registration: string; + readonly definitionId: string; + readonly configSnapshot: string; + readonly previousRunId: string | null; + readonly runId: string; + readonly position: number; + readonly sourcePosition: number; + readonly basis: { readonly recordedPosition: number; readonly validAt: number }; + readonly reason: "initial" | "commit" | "time" | "definition"; + readonly diff: Derivation.Reconciliation; + readonly attempt: number; + readonly commandId: (kind: "added" | "removed" | "changed", candidateId: string) => string; +} +export interface Registration { + readonly key: string; + readonly definition: Derivation.Definition; + readonly onDiff: (delivery: Delivery) => Effect.Effect; +} +export class RunnerError extends Data.TaggedError("RunnerError")<{ + readonly code: + | "Options" + | "AlreadyRunning" + | "DuplicateKey" + | "UnsupportedDefinition" + | "GenerationChanged" + | "MissingDefinition" + | "CorruptState" + | "Store" + | "PayloadLimit"; + readonly message: string; + readonly cause?: unknown; +}> {} +export interface RegistrationStatus { + readonly key: string; + readonly deliveredPosition: number; + readonly acknowledgedRun: string | null; + readonly pending: { + readonly batchId: string; + readonly attempt: number; + readonly paused: boolean; + readonly retryAt: number | null; + readonly error: unknown; + } | null; + readonly nextDeadline: number | null; +} +export interface RunnerStatus { + readonly consumer: string; + readonly scannedPosition: number; + readonly deliveredPosition: number; + readonly lag: number; + readonly evaluationTimeMs: number; + readonly timerLatenessMs: number; + readonly registrations: readonly RegistrationStatus[]; +} +export interface Handle { + readonly await: Effect.Effect; + readonly stop: Effect.Effect; + readonly status: Effect.Effect; + readonly retryPaused: (registration: string) => Effect.Effect; +} +export interface Options { + readonly scope: DatabaseScope; + readonly id: string; + readonly idleCheckMs?: number; + readonly evaluationConcurrency?: number; + readonly maxCandidates?: number; + readonly maxPayloadBytes?: number; + readonly retry?: { + readonly maxAttempts?: number; + readonly initialDelayMs?: number; + readonly maxDelayMs?: number; + readonly random?: () => number; + }; + /** Old deployed definitions required to finish prepared work after a revision. */ + readonly recoveryDefinitions?: readonly Derivation.Definition[]; +} + +/** Publish these ordinary infrastructure schema nodes with the host's release. */ +const Body = Attribute.text(":runner/body"); +const RecordHash = Attribute.text(":runner/hash"); +export const Control = { + entityType: "runner/control", + nodes: Effect.all([ + Body.node, + RecordHash.node, + ConfigNode.makeTyped({ + kind: "entity-schema", + key: "runner/control", + type: TypeExpr.struct({ + entityType: TypeExpr.required(TypeExpr.text), + type: TypeExpr.required(TypeExpr.any), + attributes: TypeExpr.required(TypeExpr.any), + }), + attrs: { + entityType: "runner/control", + type: TypeExpr.struct({ + ":runner/body": TypeExpr.required(TypeExpr.text), + ":runner/hash": TypeExpr.required(TypeExpr.text), + }), + attributes: { + body: { attribute: Body.key, required: true, cardinality: "one", unique: false }, + hash: { attribute: RecordHash.key, required: true, cardinality: "one", unique: false }, + }, + }, + refs: [ + { rel: "uses-attribute", kind: Attribute.KIND, key: Body.key }, + { rel: "uses-attribute", kind: Attribute.KIND, key: RecordHash.key }, + ], + }), + ]), +} as const; +export const schema = Control.nodes; +const canonical = (value: unknown) => encodeOrThrow(value as CanonicalValue); +const digest = (kind: string, value: unknown) => hash(`triplex/runner/${kind}`, canonical(value)); +const error = (code: RunnerError["code"], message: string, cause?: unknown) => + new RunnerError({ code, message, ...(cause === undefined ? {} : { cause }) }); +const store = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => + cause instanceof RunnerError + ? cause + : error("Store", "Runner storage operation failed", cause), + ), + ); +const owners = new Map(); +const Basis = Schema.Struct({ recordedPosition: Schema.Number, validAt: Schema.Number }); +const CandidateSchema = Schema.Struct({ + id: ContentIdSchema, + revision: ContentIdSchema, + definitionId: ContentIdSchema, + configSnapshot: Schema.String, + identity: Schema.Record(Schema.String, Schema.NullOr(ConstantSchema)), + result: Schema.Record(Schema.String, Schema.NullOr(ConstantSchema)), + basis: Basis, + sources: Schema.Array( + Schema.Struct({ + tripleId: TripleId, + transactionId: Schema.optionalKey(TransactionId), + transactionPosition: Schema.optionalKey(Schema.Number), + entityId: EntityId, + attribute: Schema.String, + validFrom: Schema.Number, + validTo: Schema.optionalKey(Schema.Number), + }), + ), + nextTemporalBoundary: Schema.optionalKey(Schema.Number), +}); +// Prepared metadata pins identity before evaluation; ready payloads live in the Outbox. +const PendingSchema = Schema.Struct({ + batchId: Schema.String, + predecessor: Schema.NullOr(Schema.String), + definitionId: Schema.String, + basis: Basis, + reason: Schema.Literals(["initial", "commit", "time", "definition"]), + phase: Schema.Literals(["prepared", "ready"]), + attempt: Schema.Number, + cycleAttempt: Schema.Number, + paused: Schema.Boolean, + retryAt: Schema.NullOr(Schema.Number), + failure: Schema.NullOr(Schema.String), + payload: Schema.NullOr(Schema.String), +}); +const StateSchema = Schema.Struct({ + version: Schema.Literal(1), + key: Schema.String, + name: Schema.String, + definitionId: Schema.String, + configSnapshot: Schema.String, + runId: Schema.NullOr(ContentIdSchema), + headBatch: Schema.NullOr(ContentIdSchema), + candidates: Schema.Array(CandidateSchema), + basis: Basis, + coverage: Schema.Number, + frontier: Schema.Number, + deadline: Schema.NullOr(Schema.Number), + pending: Schema.NullOr(PendingSchema), +}); +type Pending = typeof PendingSchema.Type; +type State = Omit & { + readonly candidates: readonly Derivation.Candidate[]; +}; +interface Payload { + readonly version: 1; + readonly batchId: string; + readonly registration: string; + readonly definitionId: string; + readonly configSnapshot: string; + readonly previousRunId: string | null; + readonly runId: string; + readonly position: number; + readonly sourcePosition: number; + readonly basis: typeof Basis.Type; + readonly reason: Delivery["reason"]; + readonly before: readonly Derivation.Candidate[]; + readonly after: readonly Derivation.Candidate[]; + readonly deadline: number | null; +} +const PayloadSchema = Schema.Struct({ + version: Schema.Literal(1), + batchId: ContentIdSchema, + registration: Schema.String, + definitionId: ContentIdSchema, + configSnapshot: Schema.String, + previousRunId: Schema.NullOr(ContentIdSchema), + runId: ContentIdSchema, + position: Schema.Number, + sourcePosition: Schema.Number, + basis: Basis, + reason: Schema.Literals(["initial", "commit", "time", "definition"]), + before: Schema.Array(CandidateSchema), + after: Schema.Array(CandidateSchema), + deadline: Schema.NullOr(Schema.Number), +}); +const runIdentity = (b: Payload) => + hash( + Domain.derivationRun, + canonical({ + v: 2, + definition: b.definitionId, + configSnapshot: b.configSnapshot, + sourcePosition: b.sourcePosition, + basis: b.basis, + candidates: b.after.map((c) => c.revision).sort(), + nextTemporalBoundary: b.deadline, + }), + ); +const maintenance = (tx: TransactionRecord) => + (tx.actor === "triplex/runner" && tx.changes.every((c) => c.attribute.startsWith(":runner/"))) || + (tx.actor === "triplex/derivation-materializer" && + tx.changes.every( + (c) => + c.attribute.startsWith(":triplex/derivation-") || c.attribute === ":triplex/content-id", + )); +const relevant = (d: Derivation.Definition, tx: TransactionRecord) => + !maintenance(tx) && + (d.dependencies.hasDynamicAttributes || + tx.changes.some((c) => d.dependencies.attributes.includes(c.attribute))); +const hasDiff = (diff: Derivation.Reconciliation) => + diff.added.length + diff.removed.length + diff.changed.length > 0; +const freeze = (value: A): A => { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +}; +const finite = (n: number) => Number.isSafeInteger(n) && n >= 0; +const candidatesValid = (name: string, candidates: readonly Derivation.Candidate[]) => + Array.isArray(candidates) && + new Set(candidates.map((c) => c.id)).size === candidates.length && + candidates.every( + (c) => + c && + c.id === Derivation.candidateIdentityId(name, c.identity) && + c.revision === Derivation.candidateRevisionId(c) && + finite(c.basis.validAt), + ); + +/** Acquisition validates all retained state before starting any callbacks. */ +export const start = ( + registrations: readonly Registration[], + options: Options, +): Effect.Effect, RunnerError, Triples | Scope.Scope | R> => + Effect.suspend(() => { + let acquisitionCleanup = () => {}; + return Effect.gen(function* () { + yield* Effect.try({ + try: () => validateDatabaseScope(options.scope), + catch: (cause) => error("Options", "A server-resolved DatabaseScope is required", cause), + }); + const idle = options.idleCheckMs ?? 1000; + const concurrency = options.evaluationConcurrency ?? 4; + const maxCandidates = options.maxCandidates ?? 10_000; + const maxPayload = options.maxPayloadBytes ?? 16_000_000; + const maxAttempts = options.retry?.maxAttempts ?? 8; + const initialDelay = options.retry?.initialDelayMs ?? 1000; + const maxDelay = options.retry?.maxDelayMs ?? 60_000; + if ( + !options.id || + ![idle, concurrency, maxCandidates, maxPayload, maxAttempts, initialDelay, maxDelay].every( + (n) => finite(n) && n > 0, + ) || + idle > 1000 || + initialDelay > maxDelay + ) + return yield* error("Options", "Invalid runner ID, limits or retry policy"); + if (new Set(registrations.map((r) => r.key)).size !== registrations.length) + return yield* error("DuplicateKey", "Registration keys must be unique"); + const definitions = new Map(); + for (const d of [ + ...registrations.map((r) => r.definition), + ...(options.recoveryDefinitions ?? []), + ]) { + const cloned = yield* Effect.try({ + try: () => JSON.parse(canonical(d)) as Derivation.Definition, + catch: (cause) => + error("UnsupportedDefinition", "Definition must be canonical JSON", cause), + }); + const checked = yield* Derivation.make(cloned).pipe( + Effect.mapError((cause) => + error("UnsupportedDefinition", "Unsupported derivation", cause), + ), + ); + if ( + checked.dependencies.hasDynamicAttributes || + checked.query.where.some( + (clause) => + Array.isArray(clause) && + (clause.length === 4 || + (clause[0] === "not" && Array.isArray(clause[1]) && clause[1].length === 4)), + ) + ) + return yield* error( + "UnsupportedDefinition", + "Runner derivations require fixed attributes and no transaction-binding clauses", + ); + if (checked.id !== d.id) + return yield* error( + "UnsupportedDefinition", + "Definition content ID does not match its body", + ); + definitions.set(d.id, checked); + } + registrations = registrations.map((r) => ({ + ...r, + definition: definitions.get(r.definition.id)!, + })); + if (registrations.some((r) => !r.key)) + return yield* error("Options", "Registration keys must not be empty"); + const identity = JSON.parse(options.scope) as [string, string, string, string, number]; + const owner = canonical(identity.slice(0, 4)); + const localScope = options.scope; + if (owners.has(localScope)) + return yield* error( + "AlreadyRunning", + "A runner already owns this database scope in this process", + ); + const ownership = {}; + acquisitionCleanup = () => { + if (owners.get(localScope) === ownership) owners.delete(localScope); + }; + const releaseOwner = () => { + if (owners.get(localScope) === ownership) owners.delete(localScope); + }; + yield* Effect.acquireRelease( + Effect.sync(() => owners.set(localScope, ownership)), + () => Effect.sync(releaseOwner), + ); + const t = yield* Triples; + const root = unsafe.entityId(`runner:${digest("control", [owner, options.id])}`); + const consumer = `runner:${digest("consumer", [options.scope, options.id])}`; + const locks = yield* Semaphore.make(1); + const permits = yield* Semaphore.make(concurrency); + const fatal = Deferred.makeUnsafe(); + let pulse = Deferred.makeUnsafe(); + const wake = () => { + Deferred.doneUnsafe(pulse, Effect.void); + pulse = Deferred.makeUnsafe(); + }; + let stopped = false; + let head = yield* store(t.currentPosition()); + let scannedPosition = head; + let evaluationTimeMs = 0; + let timerLatenessMs = 0; + let checkpoint = (yield* store(ConsumerCheckpoint.get(t, consumer)))?.position ?? 0; + const load = (id: string) => store(t.liveFacts(unsafe.entityId(id))); + const decode = (rows: readonly Triple[]): Effect.Effect => + Effect.try({ + try: () => { + const bodies = rows.filter((r) => r.attribute === ":runner/body"); + const hashes = rows.filter((r) => r.attribute === ":runner/hash"); + if ( + rows.length !== 2 || + bodies.length !== 1 || + hashes.length !== 1 || + bodies[0]!.value.type !== "string" || + hashes[0]!.value.type !== "string" + ) + throw new Error("Malformed control facts"); + const value: unknown = JSON.parse(bodies[0]!.value.value); + if (digest("record", value) !== hashes[0]!.value.value) + throw new Error("Control content hash mismatch"); + return value; + }, + catch: (cause) => error("CorruptState", "Malformed runner control record", cause), + }); + const save = ( + id: string, + rows: readonly Triple[], + body: unknown, + extra: readonly TransactOp[] = [], + ) => + store( + t.transact( + [ + ...rows.map((r) => ({ op: "retract" as const, id: r.id })), + { + op: "assert", + entityId: unsafe.entityId(id), + entityType: Control.entityType, + attribute: ":runner/body", + value: { type: "string", value: canonical(body) }, + validFrom: 0, + }, + { + op: "assert", + entityId: unsafe.entityId(id), + entityType: Control.entityType, + attribute: ":runner/hash", + value: { type: "string", value: digest("record", body) }, + validFrom: 0, + }, + ...extra, + ], + { + actor: "triplex/runner", + commandId: digest("transition", [id, rows.map((r) => r.id).sort(), body, extra]), + preconditions: rows.length + ? [ + { + _tag: "EntityState", + entityId: unsafe.entityId(id), + tripleIds: rows.map((r) => r.id), + }, + ] + : [{ _tag: "EntityAbsent", entityId: unsafe.entityId(id) }], + }, + ), + ); + const rootRows = yield* load(root); + const manifest = rootRows.length + ? yield* decode(rootRows).pipe( + Effect.flatMap( + Schema.decodeUnknownEffect( + Schema.Struct({ + version: Schema.Literal(1), + scope: Schema.String, + keys: Schema.Array(Schema.String), + completed: Schema.Number, + }), + ), + ), + Effect.mapError((cause) => error("CorruptState", "Malformed runner manifest", cause)), + ) + : { + version: 1 as const, + scope: options.scope, + keys: [] as readonly string[], + completed: 0, + }; + if (manifest.scope !== options.scope) + return yield* error( + "GenerationChanged", + "Database generation changed; explicit migration is required", + ); + if ( + !finite(manifest.completed) || + checkpoint > manifest.completed || + manifest.completed > head + ) + return yield* error("CorruptState", "Ahead checkpoint or invalid completed-cut marker"); + if (checkpoint < manifest.completed) { + yield* store( + ConsumerCheckpoint.advance(t, { + consumer, + expectedPosition: checkpoint, + nextPosition: manifest.completed, + }), + ); + checkpoint = manifest.completed; + } + type Local = { + registration: Registration; + id: string; + rows: readonly Triple[]; + state: State; + cursor: number; + busy: boolean; + failure: unknown; + }; + const locals: Local[] = []; + const validateState = (value: unknown) => + Schema.decodeUnknownEffect(StateSchema)(value).pipe( + Effect.flatMap((s) => + Effect.try({ + try: () => { + if ( + !finite(s.coverage) || + (s.runId === null) !== (s.headBatch === null) || + (s.runId === null && (s.candidates.length > 0 || s.pending === null)) || + (s.runId !== null && s.basis.recordedPosition > s.coverage) || + s.frontier < s.basis.validAt || + s.coverage > head || + !finite(s.frontier) || + !finite(s.basis.recordedPosition) || + s.basis.recordedPosition > head || + !finite(s.basis.validAt) || + (s.deadline !== null && (!finite(s.deadline) || s.deadline <= s.frontier)) || + !candidatesValid(s.name, s.candidates as readonly Derivation.Candidate[]) + ) + throw new Error("Invalid head or candidate snapshot"); + const p = s.pending; + if ( + p && + (!finite(p.attempt) || + !finite(p.cycleAttempt) || + (p.retryAt !== null && !finite(p.retryAt)) || + p.cycleAttempt > p.attempt || + (p.phase === "prepared" && p.payload !== null) || + (p.phase === "ready" && p.payload === null) || + !finite(p.basis.recordedPosition) || + p.basis.recordedPosition > head || + !finite(p.basis.validAt) || + p.predecessor !== s.headBatch || + p.batchId !== + digest("batch", [ + 1, + options.scope, + options.id, + s.key, + p.predecessor, + p.definitionId, + p.basis, + ])) + ) + throw new Error("Inconsistent pending predecessor or basis"); + return s as State; + }, + catch: (cause) => error("CorruptState", "Invalid registration control", cause), + }), + ), + Effect.mapError((cause) => + cause instanceof RunnerError + ? cause + : error("CorruptState", "Malformed registration control", cause), + ), + ); + for (const key of manifest.keys) + if (!registrations.some((r) => r.key === key)) { + const rows = yield* load(`${root}:${digest("key", key)}`); + const s = yield* validateState(yield* decode(rows)); + if (s.pending) + return yield* error( + "MissingDefinition", + `Drain registration ${key} before removing it`, + ); + let after = s.coverage; + while (after < head) { + const page = yield* store(t.transactions({ after, limit: 1000 })); + const retained = page.transactions.filter((tx) => tx.position <= head); + if (retained.some((tx) => !maintenance(tx))) + return yield* error( + "MissingDefinition", + `Drain registration ${key} before removing it`, + ); + if (!retained.length) break; + after = retained[retained.length - 1]!.position; + } + } + const now = yield* Clock.currentTimeMillis; + for (const registration of registrations) { + const id = `${root}:${digest("key", registration.key)}`; + const rows = yield* load(id); + const state: State = rows.length + ? yield* validateState(yield* decode(rows)) + : { + version: 1, + key: registration.key, + name: registration.definition.name, + definitionId: registration.definition.id, + configSnapshot: registration.definition.configSnapshot, + runId: null, + headBatch: null, + candidates: [], + basis: { recordedPosition: head, validAt: now }, + coverage: checkpoint, + frontier: now, + deadline: null, + pending: null, + }; + if (state.key !== registration.key || state.name !== registration.definition.name) + return yield* error( + "MissingDefinition", + "Registration name cannot change under an existing key", + ); + if (state.pending && !definitions.has(state.pending.definitionId)) + return yield* error( + "MissingDefinition", + `Deploy definition ${state.pending.definitionId} for recovery`, + ); + locals.push({ + registration, + id, + rows, + state, + cursor: state.coverage, + busy: false, + failure: null, + }); + } + const update = (l: Local, s: State, extra: readonly TransactOp[] = []) => + Effect.gen(function* () { + yield* save(l.id, l.rows, s, extra); + l.rows = yield* load(l.id); + l.state = s; + }); + const prepare = (l: Local, basis: typeof Basis.Type, reason: Delivery["reason"]) => { + const definitionId = l.registration.definition.id; + const pending: Pending = { + batchId: digest("batch", [ + 1, + options.scope, + options.id, + l.state.key, + l.state.headBatch, + definitionId, + basis, + ]), + predecessor: l.state.headBatch, + definitionId, + basis, + reason, + phase: "prepared", + attempt: 0, + cycleAttempt: 0, + paused: false, + retryAt: null, + failure: null, + payload: null, + }; + return update(l, { ...l.state, pending }); + }; + // Persist bootstrap bases before returning, including empty relations. + for (const l of locals) + if (!l.rows.length) { + yield* prepare(l, l.state.basis, "initial"); + } + yield* save(root, rootRows, { ...manifest, keys: registrations.map((r) => r.key) }); + // The completed-cut marker precedes the unjournaled checkpoint write for crash repair. + const advanceCheckpoint = locks.withPermits(1)( + Effect.gen(function* () { + const completed = locals.length + ? Math.min(...locals.map((l) => l.state.coverage)) + : checkpoint; + if (completed <= checkpoint) return; + const rows = yield* load(root); + yield* save(root, rows, { + version: 1, + scope: options.scope, + keys: registrations.map((r) => r.key), + completed, + }); + const current = (yield* store(ConsumerCheckpoint.get(t, consumer)))?.position ?? 0; + if (current > completed) + return yield* error("CorruptState", "Checkpoint advanced beyond completed coverage"); + if (current < completed) + yield* store( + ConsumerCheckpoint.advance(t, { + consumer, + expectedPosition: current, + nextPosition: completed, + }), + ); + checkpoint = completed; + }), + ); + const payloadOf = (l: Local, p: Pending) => + Effect.gen(function* () { + const entry = yield* store(Outbox.get(t, Outbox.id(root, p.batchId))); + if ( + !entry || + entry.acknowledgedPosition !== undefined || + p.payload === null || + canonical(entry.payload) !== p.payload + ) + return yield* error("CorruptState", "Missing or conflicting pending Outbox payload"); + return yield* Effect.try({ + try: () => { + const b = Schema.decodeUnknownSync(PayloadSchema)(JSON.parse(p.payload!)); + if ( + b.version !== 1 || + b.runId !== runIdentity(b) || + b.after.some( + (c) => c.definitionId !== b.definitionId || c.configSnapshot !== b.configSnapshot, + ) || + b.batchId !== p.batchId || + b.registration !== l.state.key || + b.previousRunId !== l.state.runId || + b.definitionId !== p.definitionId || + b.reason !== p.reason || + b.position !== p.basis.recordedPosition || + !finite(b.sourcePosition) || + b.sourcePosition > b.position || + (b.deadline !== null && (!finite(b.deadline) || b.deadline <= b.basis.validAt)) || + canonical(b.basis) !== canonical(p.basis) || + canonical(b.before) !== canonical(l.state.candidates) || + !candidatesValid(l.state.name, b.before) || + !candidatesValid(l.state.name, b.after) + ) + throw new Error("Invalid delivery payload"); + return b; + }, + catch: (cause) => error("CorruptState", "Malformed delivery payload", cause), + }); + }); + const verifySources = (candidates: readonly Derivation.Candidate[]) => + Effect.gen(function* () { + const byEntity = new Map>(); + for (const c of candidates) + for (const source of c.sources) { + const ids = byEntity.get(source.entityId) ?? new Set(); + ids.add(source.tripleId); + byEntity.set(source.entityId, ids); + } + for (const [entity, ids] of byEntity) { + const history = yield* store(t.history(unsafe.entityId(entity))); + if ([...ids].some((id) => !history.some((row) => row.id === id))) + return yield* error("CorruptState", "Required candidate source history is missing"); + } + }); + for (const l of locals) { + yield* verifySources(l.state.candidates); + if (l.state.pending?.phase === "ready") { + const payload = yield* payloadOf(l, l.state.pending); + yield* verifySources([...payload.before, ...payload.after]); + } + } + // Evaluation and delivery always reconcile against the acknowledged control snapshot. + const finish = (l: Local): Effect.Effect => + Effect.gen(function* () { + let p = l.state.pending!; + if (p.paused || (p.retryAt !== null && p.retryAt > (yield* Clock.currentTimeMillis))) + return false; + if (p.phase === "prepared") { + const started = yield* Clock.currentTimeMillis; + const definition = definitions.get(p.definitionId)!; + const run = yield* permits.withPermits(1)( + store(Materialization.materialize(t, definition, { basis: p.basis })), + ); + evaluationTimeMs = Math.max(0, (yield* Clock.currentTimeMillis) - started); + const payload: Payload = { + version: 1, + batchId: p.batchId, + registration: l.state.key, + definitionId: run.definitionId, + configSnapshot: run.configSnapshot, + previousRunId: l.state.runId, + runId: run.id, + position: p.basis.recordedPosition, + sourcePosition: run.sourcePosition, + basis: p.basis, + reason: p.reason, + before: l.state.candidates, + after: run.candidates, + deadline: run.nextTemporalBoundary ?? null, + }; + const bytes = canonical(payload); + if ( + run.candidates.length > maxCandidates || + new TextEncoder().encode(bytes).length > maxPayload + ) { + yield* update(l, { + ...l.state, + pending: { ...p, paused: true, failure: "PayloadLimit" }, + }); + return false; + } + p = { ...p, phase: "ready", payload: bytes }; + yield* update(l, { ...l.state, pending: p }, [ + Outbox.append({ + scope: root, + idempotencyKey: p.batchId, + payload: JSON.parse(bytes) as CanonicalValue, + }), + ]); + } + const b = yield* payloadOf(l, p); + const diff = Derivation.reconcile(b.before, b.after); + if (hasDiff(diff)) { + p = { ...p, attempt: p.attempt + 1, cycleAttempt: p.cycleAttempt + 1, retryAt: null }; + yield* update(l, { ...l.state, pending: p }); + const delivery: Delivery = { + version: 1, + batchId: b.batchId, + registration: b.registration, + definitionId: b.definitionId, + configSnapshot: b.configSnapshot, + previousRunId: b.previousRunId, + runId: b.runId, + position: b.position, + sourcePosition: b.sourcePosition, + basis: b.basis, + reason: b.reason, + diff, + attempt: p.attempt, + commandId: (kind, id) => digest("command", [b.batchId, kind, id]), + }; + const result = yield* Effect.result(l.registration.onDiff(freeze(delivery))); + if (result._tag === "Failure") { + l.failure = result.failure; + const random = options.retry?.random?.() ?? Math.random(); + const delay = + Math.min(maxDelay, initialDelay * 2 ** Math.min(p.cycleAttempt - 1, 30)) * + (0.5 + Math.max(0, Math.min(1, random)) * 0.5); + const retryAt = (yield* Clock.currentTimeMillis) + Math.ceil(delay); + yield* update(l, { + ...l.state, + pending: { + ...p, + paused: p.cycleAttempt >= maxAttempts, + retryAt, + failure: "Handler failed", + }, + }); + return false; + } + } + yield* update( + l, + { + ...l.state, + definitionId: b.definitionId, + configSnapshot: b.configSnapshot, + runId: b.runId, + headBatch: b.batchId, + candidates: b.after, + basis: b.basis, + coverage: Math.max(l.state.coverage, b.position), + frontier: Math.max(l.state.frontier, b.basis.validAt), + deadline: b.deadline, + pending: null, + }, + [Outbox.acknowledge(Outbox.id(root, b.batchId))], + ); + l.failure = null; + return true; + }); + let feedCache: + | { after: number; page: import("../store/Triples.js").TransactionPage } + | undefined; + const readPage = ( + after: number, + ): Effect.Effect => + store(t.transactions({ after, limit: 1000 })); + const feed = (after: number) => + Effect.gen(function* () { + if (feedCache?.after === after) return feedCache.page; + const page = yield* readPage(after); + // Never cache an empty tail: another process can append after capture. + if (page.transactions.length) feedCache = { after, page }; + return page; + }); + const verifyGap = (after: number, before: number) => + Effect.gen(function* () { + if (before <= after + 1) return; + const metadata = yield* store( + t.queryAll({ + find: ["?p"], + where: [ + ["?tx", ":_tx/position", "?p"], + [">", "?p", after], + ["<", "?p", before], + ], + }), + ); + if (metadata.results.length) + return yield* error( + "CorruptState", + "Retained journal history is missing from the transaction feed", + ); + }); + const process = (l: Local): Effect.Effect => + Effect.gen(function* () { + if (l.state.pending && !(yield* finish(l))) return; + l.cursor = Math.max(l.cursor, l.state.coverage); + const captured = head; + if (l.state.definitionId !== l.registration.definition.id) { + yield* prepare( + l, + { + recordedPosition: captured, + validAt: Math.max(l.state.frontier, yield* Clock.currentTimeMillis), + }, + "definition", + ); + if (!(yield* finish(l))) return; + l.cursor = captured; + } + let turns = 0; + const boundaries = ( + until: number, + position: number, + inclusive: boolean, + ): Effect.Effect => + Effect.gen(function* () { + while ( + l.state.deadline !== null && + (inclusive ? l.state.deadline <= until : l.state.deadline < until) + ) { + if (++turns > 100) { + wake(); + return false; + } + const at = l.state.deadline; + timerLatenessMs = Math.max(0, (yield* Clock.currentTimeMillis) - at); + yield* prepare(l, { recordedPosition: position, validAt: at }, "time"); + if (!(yield* finish(l))) return false; + yield* Effect.yieldNow; + } + return true; + }); + while (l.cursor < captured) { + const page = yield* feed(l.cursor); + const rows = page.transactions.filter((tx) => tx.position <= captured); + if (!rows.length) { + yield* verifyGap(l.cursor, captured + 1); + l.cursor = captured; + break; + } // Unjournaled checkpoint positions are legitimate gaps. + for (const tx of rows) { + yield* verifyGap(l.cursor, tx.position); + if (maintenance(tx)) { + l.cursor = tx.position; + continue; + } + const at = Math.max(l.state.frontier, tx.instant); + if (!(yield* boundaries(at, l.cursor, false))) return; + const due = l.state.deadline !== null && l.state.deadline <= at; + if (relevant(l.registration.definition, tx) || due) { + yield* prepare(l, { recordedPosition: tx.position, validAt: at }, "commit"); + if (!(yield* finish(l))) return; + } else yield* update(l, { ...l.state, coverage: tx.position, frontier: at }); + l.cursor = tx.position; + } + yield* Effect.yieldNow; + } + if ( + !(yield* boundaries( + Math.max(l.state.frontier, yield* Clock.currentTimeMillis), + captured, + true, + )) + ) + return; + yield* advanceCheckpoint; + }); + const fibers = new Set>(); + const launch = (l: Local) => + Effect.gen(function* () { + if (l.busy || stopped) return; + l.busy = true; + const work = process(l).pipe( + Effect.ensuring( + Effect.sync(() => { + l.busy = false; + wake(); + }), + ), + Effect.onExit((exit) => + exit._tag === "Failure" + ? Effect.sync(() => { + Deferred.doneUnsafe(fatal, Effect.failCause(exit.cause)); + }) + : Effect.void, + ), + ); + const fiber = yield* Effect.forkScoped(work); + fibers.add(fiber); + for (const f of fibers) if (f.pollUnsafe() !== undefined) fibers.delete(f); + }); + const coordinator = Effect.gen(function* () { + while (!stopped) { + // Capture the wake signal before reading the head: a commit or finished + // delivery that wakes the runner during the reads below must not be lost. + const signal = pulse; + head = yield* store(t.currentPosition()); + scannedPosition = head; + const launchTime = yield* Clock.currentTimeMillis; + for (const l of locals) { + const p = l.state.pending; + if ( + p + ? !p.paused && (p.retryAt === null || p.retryAt <= launchTime) + : l.cursor < head || + l.state.definitionId !== l.registration.definition.id || + (l.state.deadline !== null && l.state.deadline <= launchTime) + ) + yield* launch(l); + } + const time = yield* Clock.currentTimeMillis; + const deadlines = locals.flatMap((l) => + l.busy + ? [] + : l.state.pending + ? l.state.pending.paused + ? [] + : [l.state.pending.retryAt ?? time] + : l.state.deadline === null + ? [] + : [l.state.deadline], + ); + const deadline = deadlines.length ? Math.min(...deadlines) : Infinity; + yield* Effect.raceFirst( + Effect.sleep(Math.max(1, Math.min(idle, deadline - time))), + Deferred.await(signal), + ); + } + }).pipe( + Effect.onExit((exit) => + exit._tag === "Failure" + ? Effect.sync(() => { + Deferred.doneUnsafe(fatal, Effect.failCause(exit.cause)); + }) + : Effect.void, + ), + ); + const coordinatorFiber = yield* Effect.forkScoped(coordinator); + const stop = Effect.gen(function* () { + stopped = true; + yield* Fiber.interrupt(coordinatorFiber); + for (const f of fibers) yield* Fiber.interrupt(f); + releaseOwner(); + }); + yield* Effect.addFinalizer(() => stop); + return { + await: Deferred.await(fatal), + stop, + status: Effect.sync(() => ({ + consumer, + scannedPosition, + deliveredPosition: checkpoint, + lag: Math.max(0, head - checkpoint), + evaluationTimeMs, + timerLatenessMs, + registrations: locals.map((l) => ({ + key: l.state.key, + deliveredPosition: l.state.coverage, + acknowledgedRun: l.state.runId, + pending: l.state.pending + ? { + batchId: l.state.pending.batchId, + attempt: l.state.pending.attempt, + paused: l.state.pending.paused, + retryAt: l.state.pending.retryAt, + error: l.failure ?? l.state.pending.failure, + } + : null, + nextDeadline: l.state.deadline, + })), + })), + retryPaused: (key: string) => + locks.withPermits(1)( + Effect.gen(function* () { + const l = locals.find((l) => l.state.key === key); + if (!l) return yield* error("Options", `Unknown registration ${key}`); + if (l.state.pending?.paused) + yield* update(l, { + ...l.state, + pending: { + ...l.state.pending, + paused: false, + cycleAttempt: 0, + retryAt: null, + failure: null, + }, + }); + wake(); + }), + ), + } satisfies Handle; + }).pipe( + Effect.onExit((exit) => + exit._tag === "Failure" ? Effect.sync(acquisitionCleanup) : Effect.void, + ), + ); + }); diff --git a/packages/core/test/kv/datalog/executor.test.ts b/packages/core/test/kv/datalog/executor.test.ts index eebba013..efc944cc 100644 --- a/packages/core/test/kv/datalog/executor.test.ts +++ b/packages/core/test/kv/datalog/executor.test.ts @@ -8,7 +8,7 @@ import { import { makeTestKvBackend } from "../../../src/kv/kv/InMemoryKvBackend.js"; import type { KvBackendService } from "../../../src/kv/kv/KvBackend.js"; import { executeQuery, executeWrappedQuery } from "../../../src/kv/datalog/executor.js"; -import type { TripleValue } from "@triplex-build/triplex"; +import type { TripleValue } from "@triplex-internal/core"; import type { DatalogQuery, WrappedQuery } from "../../../src/kv/datalog/types.js"; // ─── Helpers ─────────────────────────────────────────────────────────────── diff --git a/packages/core/test/kv/hexastore/KvTripleStore.test.ts b/packages/core/test/kv/hexastore/KvTripleStore.test.ts index db0acd89..f4b30884 100644 --- a/packages/core/test/kv/hexastore/KvTripleStore.test.ts +++ b/packages/core/test/kv/hexastore/KvTripleStore.test.ts @@ -7,7 +7,7 @@ import { } from "../../../src/kv/hexastore/KvTripleStore.js"; import { makeTestKvBackend } from "../../../src/kv/kv/InMemoryKvBackend.js"; import type { KvBackendService } from "../../../src/kv/kv/KvBackend.js"; -import type { TripleValue } from "@triplex-build/triplex"; +import type { TripleValue } from "@triplex-internal/core"; // ─── Helpers ─────────────────────────────────────────────────────────────── diff --git a/packages/core/test/kv/integration/triples-coherence.test.ts b/packages/core/test/kv/integration/triples-coherence.test.ts index f352fa5d..ba8c7817 100644 --- a/packages/core/test/kv/integration/triples-coherence.test.ts +++ b/packages/core/test/kv/integration/triples-coherence.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect } from "vitest"; import { Effect, Layer } from "effect"; import { Triples } from "../../../src/store/Triples.js"; -import type { EntityId } from "@triplex-build/triplex"; +import type { EntityId } from "@triplex-internal/core"; import { KvTriplesLive } from "../../../src/kv/layers/KvTriplesLive.js"; import { KvBackend } from "../../../src/kv/kv/KvBackend.js"; import { makeTestKvBackend } from "../../../src/kv/kv/InMemoryKvBackend.js"; diff --git a/packages/core/test/kv/layers/KvTriples.store.test.ts b/packages/core/test/kv/layers/KvTriples.store.test.ts index e507aa7e..9dee5862 100644 --- a/packages/core/test/kv/layers/KvTriples.store.test.ts +++ b/packages/core/test/kv/layers/KvTriples.store.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from "vitest"; import { Effect, Layer, Option, Stream } from "effect"; import { Triples } from "../../../src/store/Triples.js"; -import type { TripleId, EntityId } from "@triplex-build/triplex"; +import type { TripleId, EntityId } from "@triplex-internal/core"; import { KvTriplesLive } from "../../../src/kv/layers/KvTriplesLive.js"; import { KvBackend } from "../../../src/kv/kv/KvBackend.js"; import { makeTestKvBackend } from "../../../src/kv/kv/InMemoryKvBackend.js"; diff --git a/packages/core/test/runner/Runner.test.ts b/packages/core/test/runner/Runner.test.ts new file mode 100644 index 00000000..6a1a0d8e --- /dev/null +++ b/packages/core/test/runner/Runner.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from "vitest"; +import { Clock, Effect } from "effect"; +import { TestClock } from "effect/testing"; +import { EntityId, KvTriples, Triples, string } from "../../src/index.js"; +import { DatabaseScope } from "../../src/runtime/index.js"; +import * as Derivation from "../../src/derivation/index.js"; +import * as Runner from "../../src/runner/index.js"; + +it("delivers bootstrap from the captured cut and rejects a second local owner", async () => { + await Effect.runPromise( + Effect.gen(function* () { + yield* TestClock.setTime(110); + const t = yield* Triples; + const tx = yield* t.transact([ + { + op: "assert", + entityId: EntityId.make("a"), + attribute: ":test/value", + value: string("a"), + validFrom: 0, + }, + ]); + const definition = yield* Derivation.make({ + name: "test", + query: { find: ["?e"], where: [["?e", ":test/value", "?v"]] }, + identity: ["?e"], + configSnapshot: "test", + }); + const deliveries: Runner.Delivery[] = []; + const options = { scope: DatabaseScope.test("runner"), id: "test" }; + const registrations = [ + { + key: "test", + definition, + onDiff: (d: Runner.Delivery) => + Effect.sync(() => { + deliveries.push(d); + }), + }, + ]; + const handle = yield* Runner.start(registrations, options); + while (deliveries.length === 0) yield* Effect.yieldNow; + expect((yield* t.transaction(tx.txId))?.instant).toBe(110); + expect(deliveries[0]?.position).toBe(tx.position); + expect(deliveries[0]?.diff.added).toHaveLength(1); + const duplicate = yield* Effect.result(Runner.start(registrations, options)); + expect(duplicate._tag === "Failure" && duplicate.failure.code).toBe("AlreadyRunning"); + yield* handle.stop; + }).pipe( + Effect.scoped, + Effect.provide(KvTriples.layerWithClock(Clock.currentTimeMillis)), + Effect.provide(TestClock.layer()), + ), + ); +}); + +it("reads durable consumer progress after the wall clock moves backwards", async () => { + const { ConsumerCheckpoint } = await import("../../src/operational/index.js"); + await Effect.runPromise( + Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + const first = yield* t.transact([]); + yield* ConsumerCheckpoint.advance(t, { + consumer: "rollback", + expectedPosition: 0, + nextPosition: first.position, + }); + yield* TestClock.setTime(20); + const second = yield* t.transact([]); + yield* ConsumerCheckpoint.advance(t, { + consumer: "rollback", + expectedPosition: first.position, + nextPosition: second.position, + }); + yield* TestClock.setTime(15); + expect((yield* ConsumerCheckpoint.get(t, "rollback"))?.position).toBe(second.position); + }).pipe( + Effect.provide(KvTriples.layerWithClock(Clock.currentTimeMillis)), + Effect.provide(TestClock.layer()), + ), + ); +}); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 38fcc606..7031b318 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -7,8 +7,8 @@ "author": "Ben Jacobson", "type": "module", "scripts": { - "build": "vite build", - "clean": "rm -rf dist", + "build": "vite build && tsdown", + "clean": "rm -rf dist dist-server", "dev": "vite --host 0.0.0.0 --port 4173", "serve": "tsx src/server.ts", "test": "vitest run", @@ -17,9 +17,9 @@ "dependencies": { "@fontsource-variable/ibm-plex-sans": "catalog:", "@fontsource/ibm-plex-mono": "catalog:", - "@triplex-build/triplex": "workspace:^", "@triplex-build/triplex-postgres": "workspace:^", - "@triplex-build/triplex-sqlite": "workspace:^" + "@triplex-build/triplex-sqlite": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@effect/platform-browser": "catalog:", diff --git a/packages/dashboard/src/api.ts b/packages/dashboard/src/api.ts index 91d970f4..323cce94 100644 --- a/packages/dashboard/src/api.ts +++ b/packages/dashboard/src/api.ts @@ -2,8 +2,8 @@ import { TemporalBasis, Triples, type TemporalBasis as TemporalBasisType, -} from "@triplex-build/triplex"; -import { ConfigStore } from "@triplex-build/triplex/config"; +} from "@triplex-internal/core"; +import { ConfigStore } from "@triplex-internal/core/config"; import { Context, Effect, Layer, Schema } from "effect"; import { diff --git a/packages/dashboard/src/data.ts b/packages/dashboard/src/data.ts index 54b83678..4c436105 100644 --- a/packages/dashboard/src/data.ts +++ b/packages/dashboard/src/data.ts @@ -8,9 +8,9 @@ import { type TransactionRecord, type Triple, type TripleValue, -} from "@triplex-build/triplex"; -import { ConfigNode, ConfigStore, InMemoryConfigStore } from "@triplex-build/triplex/config"; -import * as Derivation from "@triplex-build/triplex/derivation"; +} from "@triplex-internal/core"; +import { ConfigNode, ConfigStore, InMemoryConfigStore } from "@triplex-internal/core/config"; +import * as Derivation from "@triplex-internal/core/derivation"; import { Effect, Option, Schema } from "effect"; import type { ConfigObjectView, @@ -509,7 +509,7 @@ export const publishConfigChange = ( : yield* ConfigNode.make({ kind, key, - attrs: attrs as import("@triplex-build/triplex/config").CanonicalJson.CanonicalValue, + attrs: attrs as import("@triplex-internal/core/config").CanonicalJson.CanonicalValue, children: target?.children ?? [], refs, }); @@ -544,7 +544,7 @@ export const moveConfigRef = ( const config = yield* ConfigStore.ConfigStore; const snapshot = yield* config.setRef( name.trim(), - snapshotId as import("@triplex-build/triplex/config").ContentId.ContentId, + snapshotId as import("@triplex-internal/core/config").ContentId.ContentId, ); return `Moved ${name.trim()} to ${snapshot.label}`; }); diff --git a/packages/dashboard/src/demo/layer.ts b/packages/dashboard/src/demo/layer.ts index f94b4c3d..cd4f15fa 100644 --- a/packages/dashboard/src/demo/layer.ts +++ b/packages/dashboard/src/demo/layer.ts @@ -1,5 +1,5 @@ -import { KvTriples } from "@triplex-build/triplex"; -import { ConfigStore } from "@triplex-build/triplex/config"; +import { KvTriples } from "@triplex-internal/core"; +import { ConfigStore } from "@triplex-internal/core/config"; import { Effect, Layer } from "effect"; import { localDashboardApiLayer } from "../api.js"; diff --git a/packages/dashboard/src/demo/learning.ts b/packages/dashboard/src/demo/learning.ts index 85f4e389..5f4caec8 100644 --- a/packages/dashboard/src/demo/learning.ts +++ b/packages/dashboard/src/demo/learning.ts @@ -7,9 +7,9 @@ import { ref, string, type DatalogQuery as DatalogQueryType, -} from "@triplex-build/triplex"; -import { ConfigNode, ConfigStore, TypeExpr } from "@triplex-build/triplex/config"; -import * as Derivation from "@triplex-build/triplex/derivation"; +} from "@triplex-internal/core"; +import { ConfigNode, ConfigStore, TypeExpr } from "@triplex-internal/core/config"; +import * as Derivation from "@triplex-internal/core/derivation"; import { Effect } from "effect"; const DAY = 86_400_000; diff --git a/packages/dashboard/src/main.test.ts b/packages/dashboard/src/main.test.ts index 422cfecd..1eecdc51 100644 --- a/packages/dashboard/src/main.test.ts +++ b/packages/dashboard/src/main.test.ts @@ -1,5 +1,5 @@ -import { EntityId, Triples, string } from "@triplex-build/triplex"; -import { ConfigStore } from "@triplex-build/triplex/config"; +import { EntityId, Triples, string } from "@triplex-internal/core"; +import { ConfigStore } from "@triplex-internal/core/config"; import { Effect, Exit } from "effect"; import { expect as expectScene, given, role, scene, text } from "foldkit/scene"; import { describe, expect, it } from "vitest"; diff --git a/packages/dashboard/src/position-reads.test.ts b/packages/dashboard/src/position-reads.test.ts index 2275f47c..08a8abf0 100644 --- a/packages/dashboard/src/position-reads.test.ts +++ b/packages/dashboard/src/position-reads.test.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { EntityId, Triples, string } from "@triplex-build/triplex"; +import { EntityId, Triples, string } from "@triplex-internal/core"; import { describe, expect, it } from "vitest"; import { loadDashboardAt, loadEntityTypePage } from "./data.js"; import { DashboardDemoLayer } from "./demo/layer.js"; diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index cc1727ca..f2d87e8c 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -1,9 +1,7 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { PgTriples } from "@triplex-build/triplex-postgres"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; -import { ConfigStore } from "@triplex-build/triplex/config"; +import { ConfigStore } from "@triplex-internal/core/config"; import { Effect, Layer, Schema } from "effect"; import { HttpRouter, HttpServerResponse, HttpStaticServer } from "effect/http"; import { createServer } from "node:http"; @@ -20,8 +18,8 @@ interface ServerOptions { } const usage = `Usage: - pnpm --filter @triplex-build/triplex-dashboard serve -- --sqlite [--port 4174] - pnpm --filter @triplex-build/triplex-dashboard serve -- --postgres [--port 4174]`; + triplex dashboard --sqlite [--port 4174] + triplex dashboard --postgres [--port 4174]`; const parseOptions = (args: readonly string[]): ServerOptions => { const valueAfter = (flag: string): string | undefined => { @@ -72,7 +70,9 @@ const ApiRoutes = Layer.mergeAll( ), ); -const distDirectory = fileURLToPath(new URL("../dist", import.meta.url)); +const distDirectory = fileURLToPath( + new URL(import.meta.url.includes("/src/") ? "../dist" : "../dashboard", import.meta.url), +); const StaticRoutes = HttpStaticServer.layer({ root: distDirectory, index: "index.html", @@ -80,13 +80,13 @@ const StaticRoutes = HttpStaticServer.layer({ }); const options = parseOptions(process.argv.slice(2)); -const databaseLayer = ConfigStore.layer.pipe( - Layer.provideMerge( - options.source === "sqlite" - ? SqliteTriples.layer({ filename: options.target }) - : PgTriples.layerFromUrl(options.target), - ), -); +const triplesLayer = + options.source === "sqlite" + ? (await import("@triplex-build/triplex-sqlite")).SqliteTriples.layer({ + filename: options.target, + }) + : (await import("@triplex-build/triplex-postgres")).PgTriples.layerFromUrl(options.target); +const databaseLayer = ConfigStore.layer.pipe(Layer.provideMerge(triplesLayer)); const sourceLabel = options.source === "sqlite" ? `sqlite://${options.target}` diff --git a/packages/dashboard/tsdown.config.ts b/packages/dashboard/tsdown.config.ts new file mode 100644 index 00000000..6b4a0ebe --- /dev/null +++ b/packages/dashboard/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; +export default defineConfig({ + entry: ["src/server.ts", "src/api.ts", "src/data.ts", "src/model.ts", "src/server-security.ts"], + format: "esm", + target: "esnext", + unbundle: true, + fixedExtension: false, + dts: false, + outDir: "dist-server", + clean: true, +}); diff --git a/packages/foundationdb/README.md b/packages/foundationdb/README.md index 4ec1e4b3..381220f2 100644 --- a/packages/foundationdb/README.md +++ b/packages/foundationdb/README.md @@ -1,9 +1,9 @@ -# @triplex-build/triplex-foundationdb +# @triplex-build/triplex/foundationdb An ordered-KV and subscription backend for Triplex using FoundationDB. -This is a private experimental workspace package and is not available from npm. Evaluate it only -from a source checkout with the repository's locked dependencies. +This experimental, frozen backend ships as a subpath of `@triplex-build/triplex`. +Its implementation workspace remains private; new features target KV, SQLite and PostgreSQL. Requires Node.js 22 or newer and compatible FoundationDB client libraries. @@ -37,6 +37,6 @@ when system shell shims strip that variable; see the repository fixture requires a route to Docker container IPs (Linux and OrbStack provide it); its connectivity probe reports failure rather than claiming a skipped native pass. -Status: private experimental workspace package, held from the first npm release. +Status: experimental and frozen; shipped in the single Triplex package. MIT © 2026 Ben Jacobson. diff --git a/packages/foundationdb/package.json b/packages/foundationdb/package.json index fa1f79a6..93894244 100644 --- a/packages/foundationdb/package.json +++ b/packages/foundationdb/package.json @@ -45,7 +45,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", + "@triplex-internal/core": "workspace:^", "foundationdb": "catalog:" }, "devDependencies": { diff --git a/packages/foundationdb/src/FdbKvBackend.ts b/packages/foundationdb/src/FdbKvBackend.ts index ab9df02b..c4274136 100644 --- a/packages/foundationdb/src/FdbKvBackend.ts +++ b/packages/foundationdb/src/FdbKvBackend.ts @@ -34,7 +34,7 @@ import { type KvEntry, type KvTransaction, type RangeOptions, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; // ─── FDB imports (dynamic to allow graceful failure) ─────────────────────── diff --git a/packages/foundationdb/src/FdbSubscriptions.ts b/packages/foundationdb/src/FdbSubscriptions.ts index 78242684..213673ce 100644 --- a/packages/foundationdb/src/FdbSubscriptions.ts +++ b/packages/foundationdb/src/FdbSubscriptions.ts @@ -11,8 +11,8 @@ import { ChangeEmitter, type ChangeEmitterService, type ChangeEvent, -} from "@triplex-build/triplex/internal"; -import { extractEntityType, type QueryDependencies } from "@triplex-build/triplex/subscriptions"; +} from "@triplex-internal/core/internal"; +import { extractEntityType, type QueryDependencies } from "@triplex-internal/core/subscriptions"; import type FdbDatabase from "foundationdb/dist/lib/database.js"; import type FdbTransaction from "foundationdb/dist/lib/transaction.js"; import type { Watch } from "foundationdb/dist/lib/transaction.js"; diff --git a/packages/foundationdb/src/FdbTriples.ts b/packages/foundationdb/src/FdbTriples.ts index f390bf77..1fd91467 100644 --- a/packages/foundationdb/src/FdbTriples.ts +++ b/packages/foundationdb/src/FdbTriples.ts @@ -14,8 +14,8 @@ import { KvTriples, makeTripleStoreRuntimeLayer, RuntimeServicesLive, -} from "@triplex-build/triplex/internal"; -import { Sha256 } from "@triplex-build/triplex/content"; +} from "@triplex-internal/core/internal"; +import { Sha256 } from "@triplex-internal/core/content"; import { makeFdbKvBackend, type FdbKvBackendConfig } from "./FdbKvBackend.js"; export const FdbTriples = { diff --git a/packages/foundationdb/test/FdbKvBackend.test.ts b/packages/foundationdb/test/FdbKvBackend.test.ts index 8d7c4c15..2c22de13 100644 --- a/packages/foundationdb/test/FdbKvBackend.test.ts +++ b/packages/foundationdb/test/FdbKvBackend.test.ts @@ -14,7 +14,7 @@ import { Effect, Stream } from "effect"; import { describe, expect, layer } from "@effect/vitest"; import { createRequire } from "node:module"; -import { KvBackend } from "@triplex-build/triplex"; +import { KvBackend } from "@triplex-internal/core"; import { makeFdbKvBackendService } from "../src/FdbKvBackend.js"; import { FdbClusterFile, FdbContainerLayer, FdbTestLayer } from "./fixtures/FdbTestLayer.js"; diff --git a/packages/foundationdb/test/FdbSubscriptions.keys.test.ts b/packages/foundationdb/test/FdbSubscriptions.keys.test.ts index f828c044..42da80a2 100644 --- a/packages/foundationdb/test/FdbSubscriptions.keys.test.ts +++ b/packages/foundationdb/test/FdbSubscriptions.keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { QueryDependencies } from "@triplex-build/triplex/subscriptions"; +import type { QueryDependencies } from "@triplex-internal/core/subscriptions"; import { fdbSubscriptionAttributeKey, fdbSubscriptionEntityKey, diff --git a/packages/foundationdb/test/FdbSubscriptions.test.ts b/packages/foundationdb/test/FdbSubscriptions.test.ts index d1da7235..38c28308 100644 --- a/packages/foundationdb/test/FdbSubscriptions.test.ts +++ b/packages/foundationdb/test/FdbSubscriptions.test.ts @@ -7,7 +7,7 @@ import { Deferred, Effect, Ref } from "effect"; import { describe, expect, layer } from "@effect/vitest"; import { createRequire } from "node:module"; -import type { QueryDependencies } from "@triplex-build/triplex/subscriptions"; +import type { QueryDependencies } from "@triplex-internal/core/subscriptions"; import { makeFdbChangeEmitterService, makeFdbSubscriptionService, diff --git a/packages/foundationdb/test/fdb-triples.test.ts b/packages/foundationdb/test/fdb-triples.test.ts index f0b18431..fb2e1bef 100644 --- a/packages/foundationdb/test/fdb-triples.test.ts +++ b/packages/foundationdb/test/fdb-triples.test.ts @@ -21,12 +21,12 @@ import { string as str, number as num, ref, -} from "@triplex-build/triplex"; -import { KvBackend, KvTriplesLive, TripleStoreRuntimeLayer } from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core"; +import { KvBackend, KvTriplesLive, TripleStoreRuntimeLayer } from "@triplex-internal/core/internal"; import { FdbTestLayer } from "./fixtures/FdbTestLayer.js"; import { FdbClusterFile } from "./fixtures/FdbTestLayer.js"; import { FdbTriples } from "../src/index.js"; -import { Outbox } from "@triplex-build/triplex/operational"; +import { Outbox } from "@triplex-internal/core/operational"; import { makeTriplesConformanceSuite, makeSealedStorageSuite, diff --git a/packages/foundationdb/test/fixtures/FdbTestLayer.ts b/packages/foundationdb/test/fixtures/FdbTestLayer.ts index a06efd58..f21268fa 100644 --- a/packages/foundationdb/test/fixtures/FdbTestLayer.ts +++ b/packages/foundationdb/test/fixtures/FdbTestLayer.ts @@ -13,7 +13,7 @@ import { Context, Effect, Layer } from "effect"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; -import { KvBackend } from "@triplex-build/triplex/internal"; +import { KvBackend } from "@triplex-internal/core/internal"; import { makeFdbKvBackendService, type FdbKvBackendConfig } from "../../src/FdbKvBackend.js"; // ─── Constants ───────────────────────────────────────────────────────────── diff --git a/packages/host/README.md b/packages/host/README.md index d1c73606..fee91af2 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -1,4 +1,4 @@ -# @triplex-build/triplex-host +# @triplex-build/triplex/host Portable Effect contracts for hosting isolated Triplex databases. This package owns immutable instance identity, authorization, lifecycle registry/provisioner contracts, route fencing, and the @@ -13,6 +13,6 @@ accessing the bound `Triples` service. Unknown tenants never auto-provision thro The initial v1 protocol includes transactions, entity/triple reads, Datalog queries and pages, receipt lookup, and journal polling. Raw SQL and provisioning are intentionally absent. -Status: private experimental workspace package. +Status: experimental subpath in the single Triplex package. MIT © 2026 Ben Jacobson. diff --git a/packages/host/package.json b/packages/host/package.json index 07553eaa..eab3bafd 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -44,7 +44,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^" + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@triplex-build/triplex-sqlite": "workspace:^", diff --git a/packages/host/src/Identity.ts b/packages/host/src/Identity.ts index 877160f6..47a93d03 100644 --- a/packages/host/src/Identity.ts +++ b/packages/host/src/Identity.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { Sha256 } from "@triplex-build/triplex/content"; +import { Sha256 } from "@triplex-internal/core/content"; const OpaqueId = Schema.String.pipe( Schema.check(Schema.isMinLength(1)), diff --git a/packages/host/src/Protocol.ts b/packages/host/src/Protocol.ts index 6856da0b..15ec9f44 100644 --- a/packages/host/src/Protocol.ts +++ b/packages/host/src/Protocol.ts @@ -12,7 +12,7 @@ import { WrappedQuery, type TriplesService, type QueryOptions as TriplesQueryOptions, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; import { InstanceIdentity, sameInstanceIdentity } from "./Identity.js"; import { StaleRouteError, diff --git a/packages/host/test/host.test.ts b/packages/host/test/host.test.ts index ce456de1..873cad24 100644 --- a/packages/host/test/host.test.ts +++ b/packages/host/test/host.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Cause, Effect, Exit, Layer, Schema } from "effect"; -import { EntityId, Triples, KvTriples, type PagedQueryResponse } from "@triplex-build/triplex"; +import { EntityId, Triples, KvTriples, type PagedQueryResponse } from "@triplex-internal/core"; import { SqliteTriples } from "@triplex-build/triplex-sqlite"; import { AuthorizationError, diff --git a/packages/http/README.md b/packages/http/README.md index fd75116b..9cd61a10 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -1,6 +1,6 @@ -# @triplex-build/triplex-http +# @triplex-build/triplex/http -> **Pre-1.0:** install `@triplex-build/triplex-http` from npm with `effect@4.1.1`; +> **Pre-1.0:** install `@triplex-build/triplex/http` from npm with `effect@4.1.1`; > Effect 3 is not compatible. Configuration-derived REST and OpenAPI contracts for Triplex. The package is backend-neutral and @@ -8,7 +8,7 @@ uses Effect services: hosts provide `Triples`, `ConfigStore`, and an authorizati mount `EntityHttp.layer` into their chosen HTTP server. ```ts -import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; +import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex/http"; import { Layer } from "effect"; const routes = EntityHttp.layer({ diff --git a/packages/http/package.json b/packages/http/package.json index f166eff3..ecba734b 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,7 @@ { "name": "@triplex-build/triplex-http", "version": "0.1.1", + "private": true, "description": "Configuration-derived HTTP APIs for Triplex", "keywords": [ "effect", @@ -36,9 +37,6 @@ "default": "./dist/index.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown && tsc --declaration --emitDeclarationOnly --rootDir src --outDir dist", "dev": "tsdown --watch", @@ -47,7 +45,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", + "@triplex-internal/core": "workspace:^", "ulidx": "catalog:" }, "devDependencies": { diff --git a/packages/http/src/ConfigApi.ts b/packages/http/src/ConfigApi.ts index 5808e6ea..3831101a 100644 --- a/packages/http/src/ConfigApi.ts +++ b/packages/http/src/ConfigApi.ts @@ -5,7 +5,7 @@ import { GraphConstraint, TypeExpr, TypeSchema, -} from "@triplex-build/triplex/config"; +} from "@triplex-internal/core/config"; import { Effect, Result, Schema } from "effect"; import { ConfigConflictError, type CompileError, UnsupportedConfigError } from "./Errors.js"; @@ -44,7 +44,7 @@ export interface EntityDescriptor { export interface ConfigApiDescriptor { readonly protocolVersion: 1; - readonly snapshotId: import("@triplex-build/triplex/config").ConfigStore.ConfigSnapshot["id"]; + readonly snapshotId: import("@triplex-internal/core/config").ConfigStore.ConfigSnapshot["id"]; readonly label: string; readonly entities: ReadonlyArray; readonly constraints: ReadonlyArray; @@ -137,7 +137,7 @@ const referenceKinds = (expr: TypeExpr.TypeExpr): ReadonlyArray => { /** Compile only persisted release data; no DSL handles are retained or consulted. */ export const compile = ( - snapshot: import("@triplex-build/triplex/config").ConfigStore.ConfigSnapshot, + snapshot: import("@triplex-internal/core/config").ConfigStore.ConfigSnapshot, options: ExposureOptions = {}, ): Effect.Effect => Effect.gen(function* () { diff --git a/packages/http/src/EntityCodec.ts b/packages/http/src/EntityCodec.ts index d7099b5f..85a899ae 100644 --- a/packages/http/src/EntityCodec.ts +++ b/packages/http/src/EntityCodec.ts @@ -1,5 +1,5 @@ -import { EntityId, type Triple, type TripleValue } from "@triplex-build/triplex"; -import { Attribute, CanonicalJson, EntityType, TypeSchema } from "@triplex-build/triplex/config"; +import { EntityId, type Triple, type TripleValue } from "@triplex-internal/core"; +import { Attribute, CanonicalJson, EntityType, TypeSchema } from "@triplex-internal/core/config"; import { Effect, Schema } from "effect"; import type { AttributeDescriptor, EntityDescriptor } from "./ConfigApi.js"; diff --git a/packages/http/src/EntityHttp.ts b/packages/http/src/EntityHttp.ts index 2ea81741..0ed3e588 100644 --- a/packages/http/src/EntityHttp.ts +++ b/packages/http/src/EntityHttp.ts @@ -78,8 +78,8 @@ export const layer = (options: Options = {}) => { never, never, | HttpRouter.HttpRouter - | import("@triplex-build/triplex").Triples - | import("@triplex-build/triplex/config").ConfigStore.ConfigStore + | import("@triplex-internal/core").Triples + | import("@triplex-internal/core/config").ConfigStore.ConfigStore | HttpAuthorization >; }; diff --git a/packages/http/src/EntityHttpHandlers.ts b/packages/http/src/EntityHttpHandlers.ts index 30174cf9..adfb16f0 100644 --- a/packages/http/src/EntityHttpHandlers.ts +++ b/packages/http/src/EntityHttpHandlers.ts @@ -6,7 +6,7 @@ import { SensitiveValueError, TransactionConflictError, WriteError, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; import { Cause, Effect, Option, Result } from "effect"; import { Base64Url } from "effect/encoding"; import type { HttpServerRequest } from "effect/http/HttpServerRequest"; diff --git a/packages/http/src/EntityStore.ts b/packages/http/src/EntityStore.ts index 7f141dbc..44611e77 100644 --- a/packages/http/src/EntityStore.ts +++ b/packages/http/src/EntityStore.ts @@ -6,13 +6,13 @@ import { type TransactionMeta, type Triple, type TransactOp, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; import { Clock, Context, Effect, Layer, Option } from "effect"; import { ulid } from "ulidx"; import type { ConfigApiDescriptor, EntityDescriptor } from "./ConfigApi.js"; import { decodeInput, encodeEntity, type EntityDocument } from "./EntityCodec.js"; -import { Sensitivity, type SensitivityService } from "@triplex-build/triplex/sensitive"; +import { Sensitivity, type SensitivityService } from "@triplex-internal/core/sensitive"; import { EntityNotFoundHttpError, RequestValidationError, @@ -80,7 +80,7 @@ const assertOperations = ( entity: EntityDescriptor, values: ReadonlyMap< import("./ConfigApi.js").AttributeDescriptor, - ReadonlyArray + ReadonlyArray >, ): ReadonlyArray => [...values].flatMap(([attribute, facts]) => @@ -108,7 +108,7 @@ const transactionMeta = ( }); const rejectScheduledFacts = ( - triples: import("@triplex-build/triplex").TriplesService, + triples: import("@triplex-internal/core").TriplesService, id: ReturnType, ) => Effect.gen(function* () { diff --git a/packages/http/src/Errors.ts b/packages/http/src/Errors.ts index 5cfccaba..7c8a089b 100644 --- a/packages/http/src/Errors.ts +++ b/packages/http/src/Errors.ts @@ -1,4 +1,4 @@ -import { diagnose, type Diagnostic } from "@triplex-build/triplex"; +import { diagnose, type Diagnostic } from "@triplex-internal/core"; import { Data } from "effect"; export class UnsupportedConfigError extends Data.TaggedError("UnsupportedConfigError")<{ diff --git a/packages/http/src/VersionResolver.ts b/packages/http/src/VersionResolver.ts index ef44067d..0a5b8d9e 100644 --- a/packages/http/src/VersionResolver.ts +++ b/packages/http/src/VersionResolver.ts @@ -1,4 +1,4 @@ -import { ConfigStore } from "@triplex-build/triplex/config"; +import { ConfigStore } from "@triplex-internal/core/config"; import { Context, Effect, Layer } from "effect"; import { VersionNotFoundError } from "./Errors.js"; diff --git a/packages/http/test/config-api.test.ts b/packages/http/test/config-api.test.ts index 34078d7e..41159829 100644 --- a/packages/http/test/config-api.test.ts +++ b/packages/http/test/config-api.test.ts @@ -4,7 +4,7 @@ import { EntityValidation, InMemoryConfigStore, TypeExpr, -} from "@triplex-build/triplex/config"; +} from "@triplex-internal/core/config"; import { Effect, Result, Schema } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/packages/http/test/entity-codec.test.ts b/packages/http/test/entity-codec.test.ts index a683b0e4..2e3d255f 100644 --- a/packages/http/test/entity-codec.test.ts +++ b/packages/http/test/entity-codec.test.ts @@ -1,10 +1,10 @@ -import { EntityId, KvTriples, Triples, type Triple } from "@triplex-build/triplex"; +import { EntityId, KvTriples, Triples, type Triple } from "@triplex-internal/core"; import { Attribute, EntityType, InMemoryConfigStore, TypeExpr, -} from "@triplex-build/triplex/config"; +} from "@triplex-internal/core/config"; import { Effect, Option } from "effect"; import { expect, it } from "vitest"; import { compile } from "../src/ConfigApi.js"; diff --git a/packages/http/test/sensitive.test.ts b/packages/http/test/sensitive.test.ts index ff50af1f..ee08f51a 100644 --- a/packages/http/test/sensitive.test.ts +++ b/packages/http/test/sensitive.test.ts @@ -1,6 +1,6 @@ -import { EntityId, KvTriples, Triples, type TriplesService } from "@triplex-build/triplex"; -import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; -import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; +import { EntityId, KvTriples, Triples, type TriplesService } from "@triplex-internal/core"; +import { Attribute, ConfigStore, EntityType } from "@triplex-internal/core/config"; +import { KeyProvider, Sensitive, ValueCodec } from "@triplex-internal/core/sensitive"; import { Effect, Layer } from "effect"; import { HttpRouter } from "effect/http"; import { describe, expect, it } from "vitest"; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 893a9a40..8884e479 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -24,10 +24,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", "@triplex-build/triplex-cli": "workspace:^", "@triplex-build/triplex-postgres": "workspace:^", - "@triplex-build/triplex-sqlite": "workspace:^" + "@triplex-build/triplex-sqlite": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/mcp/src/bin.ts b/packages/mcp/src/bin.ts index 15c4d062..bf2db37d 100644 --- a/packages/mcp/src/bin.ts +++ b/packages/mcp/src/bin.ts @@ -1,7 +1,7 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning import { NodeRuntime, NodeServices } from "@effect/platform-node"; -import { diagnose } from "@triplex-build/triplex"; +import { diagnose } from "@triplex-internal/core"; import { Console, Effect, Layer } from "effect"; import { McpProtocol, McpServer } from "effect/ai"; import { registrations } from "./index.js"; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 9e2ac62b..55378c6c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,10 +7,10 @@ import { Triples, WrappedQuery, diagnose, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; import { WhyInput, execute, type ExecuteOptions } from "@triplex-build/triplex-cli"; -import { ContentId } from "@triplex-build/triplex/content"; -import { ConfigStore } from "@triplex-build/triplex/config"; +import { ContentId } from "@triplex-internal/core/content"; +import { ConfigStore } from "@triplex-internal/core/config"; import { Effect, Layer, Schema } from "effect"; import { McpServer, Tool, Toolkit } from "effect/ai"; diff --git a/packages/mcp/src/startup.ts b/packages/mcp/src/startup.ts index cbfd86c8..81a4b2d3 100644 --- a/packages/mcp/src/startup.ts +++ b/packages/mcp/src/startup.ts @@ -1,7 +1,6 @@ -import { DatabaseId } from "@triplex-build/triplex"; -import { ConfigStore } from "@triplex-build/triplex/config"; -import { PgTriples, type PostgresqlConfig } from "@triplex-build/triplex-postgres"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { DatabaseId } from "@triplex-internal/core"; +import { ConfigStore } from "@triplex-internal/core/config"; +import type { PostgresqlConfig } from "@triplex-build/triplex-postgres"; import { Effect, Layer, Redacted, Schema } from "effect"; import { parseArgs } from "node:util"; @@ -48,20 +47,31 @@ const postgresConfig = (source: string): PostgresqlConfig => { }; }; -export const databaseLayer = (options: Effect.Success>) => { - const triples = - options.postgresUrl === undefined - ? SqliteTriples.layer({ - filename: - options.databaseId === undefined - ? options.sqlite - : `${options.dataDir}/${options.databaseId}.db`, - }) - : options.databaseId === undefined - ? PgTriples.layerFromUrl(options.postgresUrl) - : PgTriples.layerForDatabaseMigrated( - postgresConfig(options.postgresUrl), - options.databaseId, - ); - return ConfigStore.layer.pipe(Layer.provideMerge(triples)); -}; +export const databaseLayer = (options: Effect.Success>) => + Layer.unwrap( + Effect.gen(function* () { + const triples = + options.postgresUrl === undefined + ? (yield* Effect.promise( + () => import("@triplex-build/triplex-sqlite"), + )).SqliteTriples.layer({ + filename: + options.databaseId === undefined + ? options.sqlite + : `${options.dataDir}/${options.databaseId}.db`, + }) + : (() => { + const url = options.postgresUrl; + return Layer.unwrap( + Effect.promise(() => import("@triplex-build/triplex-postgres")).pipe( + Effect.map(({ PgTriples }) => + options.databaseId === undefined + ? PgTriples.layerFromUrl(url) + : PgTriples.layerForDatabaseMigrated(postgresConfig(url), options.databaseId), + ), + ), + ); + })(); + return ConfigStore.layer.pipe(Layer.provideMerge(triples)); + }), + ); diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index d3cf30b3..e090a0a7 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -1,5 +1,5 @@ -import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; -import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; +import { Attribute, ConfigStore, EntityType } from "@triplex-internal/core/config"; +import { KeyProvider, Sensitive, ValueCodec } from "@triplex-internal/core/sensitive"; import { SqliteTriples } from "@triplex-build/triplex-sqlite"; import { Effect, Layer } from "effect"; import { McpProtocol, McpTest } from "effect/ai"; @@ -7,7 +7,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { EntityId } from "@triplex-build/triplex"; +import { EntityId } from "@triplex-internal/core"; import { execute } from "@triplex-build/triplex-cli"; import { registrations } from "../src/index.js"; diff --git a/packages/postgres/README.md b/packages/postgres/README.md index b82e3a80..1f5cf2f5 100644 --- a/packages/postgres/README.md +++ b/packages/postgres/README.md @@ -1,12 +1,12 @@ -# @triplex-build/triplex-postgres +# @triplex-build/triplex/postgres The PostgreSQL backend for Triplex, built on `@effect/sql-pg`. -Install `@triplex-build/triplex-postgres` from npm. It requires Node.js 22 or newer and a +Install `@triplex-build/triplex/postgres` from npm. It requires Node.js 22 or newer and a PostgreSQL connection URL. ```ts -import { PgTriples } from "@triplex-build/triplex-postgres"; +import { PgTriples } from "@triplex-build/triplex/postgres"; const TriplesLive = PgTriples.layerFromUrl(process.env.DATABASE_URL!); ``` @@ -19,7 +19,7 @@ internal adapter SPI: import { Effect, Layer } from "effect"; import { SqlClient } from "effect/sql"; import { Triples } from "@triplex-build/triplex"; -import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex-postgres"; +import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex/postgres"; const HostSql = makePostgresqlLayerUnmigratedFromUrl(process.env.DATABASE_URL!); const DatabaseLive = PgTriples.layerFromSqlClient({ scope: "host/main" }).pipe( @@ -65,7 +65,7 @@ For a server-owned database mapping, use a validated `DatabaseId`: ```ts import { DatabaseId } from "@triplex-build/triplex"; -import { PgTriples } from "@triplex-build/triplex-postgres"; +import { PgTriples } from "@triplex-build/triplex/postgres"; const DatabaseLive = PgTriples.layerForDatabase(postgresConfig, DatabaseId.make("customer-a")); ``` @@ -78,7 +78,7 @@ must resolve `DatabaseId` from authenticated server-owned state, not raw request generic core `DatabaseManager` remains available for Triples-only dynamic databases, while this package-level API is the boundary for hosts that also need the scoped `SqlClient`. -Ordered migration definitions and `runMigrations` are exported by `@triplex-build/triplex-sql` for +Ordered migration definitions and `runMigrations` are exported by `@triplex-build/triplex/sql` for host deployment tooling. Triplex uses its own `triplex_schema_migrations` table. PostgreSQL passes the shared conformance and multi-connection isolation integration suite in CI. diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 5aa0a08a..e705eba7 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,6 +1,7 @@ { "name": "@triplex-build/triplex-postgres", "version": "0.1.1", + "private": true, "description": "PostgreSQL backend for Triplex", "keywords": [ "database", @@ -35,9 +36,6 @@ "default": "./dist/index.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown && tsc --declaration --emitDeclarationOnly --rootDir src --outDir dist", "dev": "tsdown --watch", @@ -47,8 +45,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-sql": "workspace:^" + "@triplex-build/triplex-sql": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@effect/sql-pg": "catalog:", diff --git a/packages/postgres/src/PgTriples.ts b/packages/postgres/src/PgTriples.ts index cae0bf4d..3b242947 100644 --- a/packages/postgres/src/PgTriples.ts +++ b/packages/postgres/src/PgTriples.ts @@ -16,7 +16,7 @@ import { TriplesLive, CurrentDialect, makeTripleStoreRuntimeLayer, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; import { PostgresqlAdapterLive, PostgresqlAdapterUnmigrated } from "./PostgresqlAdapter.js"; import { PostgresqlDialect } from "./dialect.js"; @@ -25,7 +25,7 @@ import { makePostgresqlLayerFromUrl, type PostgresqlConfig, } from "./PostgresqlLayer.js"; -import type { DatabaseId } from "@triplex-build/triplex"; +import type { DatabaseId } from "@triplex-internal/core"; import { makePostgresqlDatabaseSqlLayer, makePostgresqlDatabaseSqlLayerMigrated, diff --git a/packages/postgres/src/PostgresqlAdapter.ts b/packages/postgres/src/PostgresqlAdapter.ts index 5d1891c7..0455b05f 100644 --- a/packages/postgres/src/PostgresqlAdapter.ts +++ b/packages/postgres/src/PostgresqlAdapter.ts @@ -22,7 +22,7 @@ import { MigrationError, createParamCollector, isPatternVariable, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { makeSqlOperationalEntryStorage, makeSqlLivePreconditionLookup, diff --git a/packages/postgres/src/PostgresqlBackend.ts b/packages/postgres/src/PostgresqlBackend.ts index f0027f89..458d4fe3 100644 --- a/packages/postgres/src/PostgresqlBackend.ts +++ b/packages/postgres/src/PostgresqlBackend.ts @@ -10,8 +10,8 @@ import { Context, Effect, Layer, Redacted } from "effect"; import { SqlClient } from "effect/sql"; import { PgClient } from "@effect/sql-pg"; import { createHash } from "node:crypto"; -import type { DatabaseId } from "@triplex-build/triplex"; -import { type SqlDialect } from "@triplex-build/triplex/internal"; +import type { DatabaseId } from "@triplex-internal/core"; +import { type SqlDialect } from "@triplex-internal/core/internal"; import { runMigrations, StorageBackend, diff --git a/packages/postgres/src/TransactionConflict.ts b/packages/postgres/src/TransactionConflict.ts index 0fe692c7..ebd02f56 100644 --- a/packages/postgres/src/TransactionConflict.ts +++ b/packages/postgres/src/TransactionConflict.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { SqlClient } from "effect/sql"; import { isSqlError } from "effect/sql/SqlError"; -import { TransactionConflictError } from "@triplex-build/triplex"; +import { TransactionConflictError } from "@triplex-internal/core"; /** Normalize structured SQL reasons, retaining the original failure for diagnostics. */ export const transactionConflict = (cause: unknown): TransactionConflictError | undefined => { diff --git a/packages/postgres/src/dialect.ts b/packages/postgres/src/dialect.ts index 4dcacd43..2100ef7a 100644 --- a/packages/postgres/src/dialect.ts +++ b/packages/postgres/src/dialect.ts @@ -1,4 +1,4 @@ -import type { SqlDialect } from "@triplex-build/triplex/internal"; +import type { SqlDialect } from "@triplex-internal/core/internal"; export const PostgresqlDialect: SqlDialect = { name: "postgresql", diff --git a/packages/postgres/test/exports.test.ts b/packages/postgres/test/exports.test.ts index 985e0d08..a5e68090 100644 --- a/packages/postgres/test/exports.test.ts +++ b/packages/postgres/test/exports.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { unsafe } from "@triplex-build/triplex"; +import { unsafe } from "@triplex-internal/core"; import { databaseToSchema, type PostgresqlDialect, diff --git a/packages/postgres/test/fixtures/PgTestLayer.ts b/packages/postgres/test/fixtures/PgTestLayer.ts index 2a9207dc..00c49b06 100644 --- a/packages/postgres/test/fixtures/PgTestLayer.ts +++ b/packages/postgres/test/fixtures/PgTestLayer.ts @@ -39,7 +39,7 @@ import { CurrentDialect, TriplesLive, TripleStoreRuntimeLayer, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; import { makePostgresqlLayerFromUrl, diff --git a/packages/postgres/test/integration/layer-output.test.ts b/packages/postgres/test/integration/layer-output.test.ts index 77be48a7..dbb8a226 100644 --- a/packages/postgres/test/integration/layer-output.test.ts +++ b/packages/postgres/test/integration/layer-output.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { Effect, Layer, Redacted } from "effect"; import { SqlClient } from "effect/sql"; -import { DatabaseId, Triples } from "@triplex-build/triplex"; +import { DatabaseId, Triples } from "@triplex-internal/core"; import { PgTriples, makePostgresqlLayerFromUrl } from "@triplex-build/triplex-postgres"; import { checkDockerAvailable, diff --git a/packages/postgres/test/integration/live-query.test.ts b/packages/postgres/test/integration/live-query.test.ts index 1305ad1b..a4b33590 100644 --- a/packages/postgres/test/integration/live-query.test.ts +++ b/packages/postgres/test/integration/live-query.test.ts @@ -1,17 +1,17 @@ import { expect, it } from "vitest"; import { Clock, Effect, Layer } from "effect"; import { TestClock } from "effect/testing"; -import { Triples } from "@triplex-build/triplex"; +import { Triples } from "@triplex-internal/core"; import { CurrentDialect, DeterministicTripleStoreRuntimeLive, TriplesLive, TripleStoreRuntime, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { PostgresqlAdapterLive, PostgresqlDialect } from "@triplex-build/triplex-postgres"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; -import { DatabaseScope } from "@triplex-build/triplex/runtime"; -import { LiveQuery } from "@triplex-build/triplex/live-query"; +import { DatabaseScope } from "@triplex-internal/core/runtime"; +import { LiveQuery } from "@triplex-internal/core/live-query"; import { liveQueryCases } from "@triplex-build/triplex-testkit"; import { postgresSchemaLayer } from "@triplex-build/triplex-testkit/postgres"; import { diff --git a/packages/postgres/test/integration/postgresql.test.ts b/packages/postgres/test/integration/postgresql.test.ts index 49f64540..a971e607 100644 --- a/packages/postgres/test/integration/postgresql.test.ts +++ b/packages/postgres/test/integration/postgresql.test.ts @@ -30,16 +30,16 @@ import { string, number, ref, -} from "@triplex-build/triplex"; -import { Outbox } from "@triplex-build/triplex/operational"; -import { compile } from "@triplex-build/triplex/datalog"; -import { ConfigNode, ConfigStore, GraphConstraint } from "@triplex-build/triplex/config"; +} from "@triplex-internal/core"; +import { Outbox } from "@triplex-internal/core/operational"; +import { compile } from "@triplex-internal/core/datalog"; +import { ConfigNode, ConfigStore, GraphConstraint } from "@triplex-internal/core/config"; import { DatabaseRegistry, IdGeneratorLive, RuntimeClockLive, TripleStoreRuntimeLayer, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { DatabaseManagerLive, DatabaseRegistryLive, diff --git a/packages/postgres/test/integration/recorded-reads.test.ts b/packages/postgres/test/integration/recorded-reads.test.ts index b470ef74..6225f850 100644 --- a/packages/postgres/test/integration/recorded-reads.test.ts +++ b/packages/postgres/test/integration/recorded-reads.test.ts @@ -5,7 +5,7 @@ import { TripleStoreRuntime, CurrentDialect, DeterministicTripleStoreRuntimeLive, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { PostgresqlAdapterLive, PostgresqlDialect } from "@triplex-build/triplex-postgres"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; import { recordedReadsConformance } from "@triplex-build/triplex-testkit"; diff --git a/packages/postgres/test/integration/runner.test.ts b/packages/postgres/test/integration/runner.test.ts new file mode 100644 index 00000000..5cc5d235 --- /dev/null +++ b/packages/postgres/test/integration/runner.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from "vitest"; +import { Clock, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { Triples } from "@triplex-internal/core"; +import { + CurrentDialect, + DeterministicTripleStoreRuntimeLive, + TriplesLive, + TripleStoreRuntime, +} from "@triplex-internal/core/internal"; +import { PostgresqlAdapterLive, PostgresqlDialect } from "@triplex-build/triplex-postgres"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; +import { runnerCases } from "@triplex-build/triplex-testkit"; +import { postgresSchemaLayer } from "@triplex-build/triplex-testkit/postgres"; +import { + PgConnectionInfo, + PgContainerLayer, + checkDockerAvailable, +} from "../fixtures/PgTestLayer.js"; + +const DOCKER_AVAILABLE = checkDockerAvailable(); + +const cases = { + ...runnerCases, + "TestClock drives commit instants": Effect.gen(function* () { + const triples = yield* Triples; + yield* TestClock.setTime(10); + yield* triples.transact([]); + yield* TestClock.setTime(20); + yield* triples.transact([]); + const page = yield* triples.transactions(); + expect(page.transactions.map(({ instant }) => instant)).toEqual([10, 20]); + }), +}; + +for (const [name, program] of Object.entries(cases)) { + it.skipIf(!DOCKER_AVAILABLE)(`postgres: ${name}`, { timeout: 60_000 }, async () => { + // Keep deterministic IDs while sharing the watch's TestClock for commit instants. + const runtime = Layer.effect( + TripleStoreRuntime, + Effect.gen(function* () { + const base = yield* TripleStoreRuntime; + return { ...base, now: Clock.currentTimeMillis }; + }), + ).pipe(Layer.provide(DeterministicTripleStoreRuntimeLive({ now: 0 }))); + const schemaStore = Layer.unwrap( + Effect.gen(function* () { + const { url } = yield* PgConnectionInfo; + return postgresSchemaLayer({ + url, + triples: () => + TriplesLive.pipe( + Layer.provide(SqlQueryExecutorLive), + Layer.provide(PostgresqlAdapterLive), + Layer.provide(Layer.succeed(CurrentDialect, PostgresqlDialect)), + Layer.provide(runtime), + ), + }); + }), + ).pipe(Layer.provide(PgContainerLayer)); + // SQL pool maintenance uses real time; advancing TestClock by months must + // only drive the store and watch, not replay months of pool timers. + const store = Layer.effectContext(TestClock.withLive(Layer.build(schemaStore))); + await Effect.runPromise( + program.pipe(Effect.provide(store), Effect.scoped, Effect.provide(TestClock.layer())), + ); + }); +} diff --git a/packages/postgres/test/integration/why.test.ts b/packages/postgres/test/integration/why.test.ts index 7f813c16..0cd5c57f 100644 --- a/packages/postgres/test/integration/why.test.ts +++ b/packages/postgres/test/integration/why.test.ts @@ -5,7 +5,7 @@ import { TripleStoreRuntime, CurrentDialect, DeterministicTripleStoreRuntimeLive, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { PostgresqlAdapterLive, PostgresqlDialect } from "@triplex-build/triplex-postgres"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; import { diff --git a/packages/postgres/test/transaction-conflict.test.ts b/packages/postgres/test/transaction-conflict.test.ts index c119fff0..8dea295d 100644 --- a/packages/postgres/test/transaction-conflict.test.ts +++ b/packages/postgres/test/transaction-conflict.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { SqlError, SerializationError, DeadlockError, SqlSyntaxError } from "effect/sql/SqlError"; -import { TransactionConflictError, WriteError } from "@triplex-build/triplex"; +import { TransactionConflictError, WriteError } from "@triplex-internal/core"; import { transactionConflict } from "../src/TransactionConflict.js"; describe("Postgres transaction conflicts", () => { diff --git a/packages/sql/README.md b/packages/sql/README.md index d67f869c..762bfb9a 100644 --- a/packages/sql/README.md +++ b/packages/sql/README.md @@ -1,14 +1,14 @@ -# @triplex-build/triplex-sql +# @triplex-build/triplex/sql Shared SQL migrations, database management, and Datalog execution for Triplex. Most applications install this transitively through a concrete backend package. -Install `@triplex-build/triplex-sql` from npm when building a custom SQL runtime. Use Node.js 22+ +Install `@triplex-build/triplex/sql` from npm when building a custom SQL runtime. Use Node.js 22+ and the compatible Effect 4 release candidate. The public surface includes the ordered `migrations`, explicit `runMigrations`, SQL query executors, and SQL-backed `DatabaseManager`/registry layers. Use -`@triplex-build/triplex-sqlite` or `@triplex-build/triplex-postgres` for a concrete client and adapter. +`@triplex-build/triplex/sqlite` or `@triplex-build/triplex/postgres` for a concrete client and adapter. For [custom runtimes](../../docs/custom-runtimes.md), `makeSqlQueryExecutorLayer(runner, dialect)` captures the SQL dialect without an ambient `CurrentDialect` requirement. The existing diff --git a/packages/sql/package.json b/packages/sql/package.json index 2d513803..d3007e7c 100644 --- a/packages/sql/package.json +++ b/packages/sql/package.json @@ -1,6 +1,7 @@ { "name": "@triplex-build/triplex-sql", "version": "0.2.0", + "private": true, "description": "Shared SQL layers and query execution for Triplex", "keywords": [ "database", @@ -35,9 +36,6 @@ "default": "./dist/index.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown && tsc --declaration --emitDeclarationOnly --rootDir src --outDir dist", "dev": "tsdown --watch", @@ -46,7 +44,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^" + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/sql/src/DatabaseManagerLayer.ts b/packages/sql/src/DatabaseManagerLayer.ts index 9808cf0e..6c9c4483 100644 --- a/packages/sql/src/DatabaseManagerLayer.ts +++ b/packages/sql/src/DatabaseManagerLayer.ts @@ -33,8 +33,8 @@ import { DatabaseAlreadyExists, getTripleStoreRuntime, DatabaseId, -} from "@triplex-build/triplex/internal"; -import type { DatabaseId as DatabaseIdType } from "@triplex-build/triplex"; +} from "@triplex-internal/core/internal"; +import type { DatabaseId as DatabaseIdType } from "@triplex-internal/core"; import { SqlQueryExecutorLive } from "./SqlQueryExecutor.js"; import { StorageBackend } from "./StorageBackend.js"; @@ -77,7 +77,7 @@ const validateDatabaseId = (name: string): Effect.Effect => Effect.gen(function* () { diff --git a/packages/sql/src/DatabaseRegistryLayer.ts b/packages/sql/src/DatabaseRegistryLayer.ts index f3af3a9c..61e38abd 100644 --- a/packages/sql/src/DatabaseRegistryLayer.ts +++ b/packages/sql/src/DatabaseRegistryLayer.ts @@ -15,7 +15,7 @@ import { DatabaseAlreadyExists, InternalError, RuntimeClock, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { StorageBackend } from "./StorageBackend.js"; // ============================================================================= diff --git a/packages/sql/src/LivePreconditions.ts b/packages/sql/src/LivePreconditions.ts index 337b46cd..94b12de2 100644 --- a/packages/sql/src/LivePreconditions.ts +++ b/packages/sql/src/LivePreconditions.ts @@ -4,8 +4,8 @@ import { createParamCollector, type SqlDialect, type TripleRow, -} from "@triplex-build/triplex/internal"; -import type { StorageAdapterService } from "@triplex-build/triplex/runtime"; +} from "@triplex-internal/core/internal"; +import type { StorageAdapterService } from "@triplex-internal/core/runtime"; import type { SqlStatementRunner } from "./SqlQueryExecutor.js"; /** Uses the existing partial entity index (only unretracted rows). */ diff --git a/packages/sql/src/OperationalEntries.ts b/packages/sql/src/OperationalEntries.ts index 3678ef2a..a17d1720 100644 --- a/packages/sql/src/OperationalEntries.ts +++ b/packages/sql/src/OperationalEntries.ts @@ -4,8 +4,8 @@ import { WriteError, createParamCollector, type SqlDialect, -} from "@triplex-build/triplex/internal"; -import type { StorageAdapterService, OperationalEntryRecord } from "@triplex-build/triplex/runtime"; +} from "@triplex-internal/core/internal"; +import type { StorageAdapterService, OperationalEntryRecord } from "@triplex-internal/core/runtime"; import type { SqlStatementRunner } from "./SqlQueryExecutor.js"; type EntryStorage = Pick< diff --git a/packages/sql/src/SqlQueryExecutor.ts b/packages/sql/src/SqlQueryExecutor.ts index d53f1d9b..29b62dfe 100644 --- a/packages/sql/src/SqlQueryExecutor.ts +++ b/packages/sql/src/SqlQueryExecutor.ts @@ -26,7 +26,7 @@ import { type CompiledQuery, type CompiledWrappedQuery, SqliteDialect, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; // ============================================================================= // Result Row Type diff --git a/packages/sql/src/SqlSnapshots.ts b/packages/sql/src/SqlSnapshots.ts index 49a84604..676f40dd 100644 --- a/packages/sql/src/SqlSnapshots.ts +++ b/packages/sql/src/SqlSnapshots.ts @@ -1,5 +1,5 @@ import { Layer } from "effect"; -import { SnapshotServiceLive, SnapshotWriterLive } from "@triplex-build/triplex/internal"; +import { SnapshotServiceLive, SnapshotWriterLive } from "@triplex-internal/core/internal"; /** SQL snapshot persistence over the same adapter and raw Triples service as the runtime. */ export const SqlSnapshotsLive = Layer.mergeAll(SnapshotServiceLive, SnapshotWriterLive); diff --git a/packages/sql/src/StorageBackend.ts b/packages/sql/src/StorageBackend.ts index 14fa44c7..e01952a6 100644 --- a/packages/sql/src/StorageBackend.ts +++ b/packages/sql/src/StorageBackend.ts @@ -7,8 +7,8 @@ import { Context, Effect, Layer } from "effect"; import { SqlClient } from "effect/sql"; -import type { SqlDialect, StorageAdapter } from "@triplex-build/triplex/internal"; -import type { DatabaseId } from "@triplex-build/triplex"; +import type { SqlDialect, StorageAdapter } from "@triplex-internal/core/internal"; +import type { DatabaseId } from "@triplex-internal/core"; // ============================================================================= // Service Interface diff --git a/packages/sql/src/TransactionFeed.ts b/packages/sql/src/TransactionFeed.ts index b6b92db3..d0e5312d 100644 --- a/packages/sql/src/TransactionFeed.ts +++ b/packages/sql/src/TransactionFeed.ts @@ -5,8 +5,8 @@ import { transactionPrefixBounds, TxAttributes, type SqlDialect, -} from "@triplex-build/triplex/internal"; -import type { StorageAdapterService } from "@triplex-build/triplex/runtime"; +} from "@triplex-internal/core/internal"; +import type { StorageAdapterService } from "@triplex-internal/core/runtime"; import type { SqlStatementRunner } from "./SqlQueryExecutor.js"; /** Candidate discovery for the shipped SQLite/PostgreSQL adapters. */ diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts index 1c257081..8016dd29 100644 --- a/packages/sql/src/index.ts +++ b/packages/sql/src/index.ts @@ -42,7 +42,7 @@ export { type SqlStorageAdapterService, type SqlDialect, SqliteDialect, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; // Storage backend export { StorageBackend, type StorageBackendService } from "./StorageBackend.js"; diff --git a/packages/sql/src/migrations.ts b/packages/sql/src/migrations.ts index da978b44..efe3ee03 100644 --- a/packages/sql/src/migrations.ts +++ b/packages/sql/src/migrations.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; import { SqlClient } from "effect/sql"; -import { MigrationError } from "@triplex-build/triplex/internal"; +import { MigrationError } from "@triplex-internal/core/internal"; import { PUBLIC_READ_INDEX_DDLS, INTERVAL_HISTORY_INDEX_DDLS, diff --git a/packages/sql/src/queries.ts b/packages/sql/src/queries.ts index d999b9e3..10e95f6d 100644 --- a/packages/sql/src/queries.ts +++ b/packages/sql/src/queries.ts @@ -2,7 +2,7 @@ import { encodeSealedEnvelope, type TripleValue, type ValueType, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; /** Encode a typed Triplex value into the SQL table's value columns. */ export const packValue = ( diff --git a/packages/sql/test/sql-contract.test.ts b/packages/sql/test/sql-contract.test.ts index 92673c1d..dd48792c 100644 --- a/packages/sql/test/sql-contract.test.ts +++ b/packages/sql/test/sql-contract.test.ts @@ -1,4 +1,4 @@ -import { boolean, datetime, json, number, ref, string } from "@triplex-build/triplex"; +import { boolean, datetime, json, number, ref, string } from "@triplex-internal/core"; import { describe, expect, it } from "vitest"; import { INDEX_NAMES, migrations, packValue } from "../src/index.js"; diff --git a/packages/sqlite/README.md b/packages/sqlite/README.md index 8a331f97..51e1fd4f 100644 --- a/packages/sqlite/README.md +++ b/packages/sqlite/README.md @@ -1,13 +1,13 @@ -# @triplex-build/triplex-sqlite +# @triplex-build/triplex/sqlite The Node.js SQLite backend for Triplex, built on `@effect/sql-sqlite-node`. -Install `@triplex-build/triplex-sqlite` from npm with Node.js 22+ and the compatible Effect 4 +Install `@triplex-build/triplex/sqlite` from npm with Node.js 22+ and the compatible Effect 4 release candidate. See the [quickstart](../../docs/getting-started.md#use-durable-sqlite). ```ts -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; const TriplesLive = SqliteTriples.layer({ filename: "app.db" }); // For tests: SqliteTriples.layerMemory @@ -15,7 +15,7 @@ const TriplesLive = SqliteTriples.layer({ filename: "app.db" }); The convenience layer applies Triplex's single v1 migration. Production hosts that own DDL can compose `makeSqliteLayerUnmigrated` and `makeSqliteAdapter({ autoMigrate: false })` with the shared -`migrations`/`runMigrations` exports from `@triplex-build/triplex-sql`. +`migrations`/`runMigrations` exports from `@triplex-build/triplex/sql`. SQLite is part of the default shared conformance suite. diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index a45b7cec..ec38a42d 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -1,6 +1,7 @@ { "name": "@triplex-build/triplex-sqlite", "version": "0.1.1", + "private": true, "description": "SQLite backend for Triplex", "keywords": [ "database", @@ -35,9 +36,6 @@ "default": "./dist/index.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown && tsc --declaration --emitDeclarationOnly --rootDir src --outDir dist", "dev": "tsdown --watch", @@ -46,8 +44,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-sql": "workspace:^" + "@triplex-build/triplex-sql": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@effect/sql-sqlite-node": "catalog:", diff --git a/packages/sqlite/scripts/measure-journal.ts b/packages/sqlite/scripts/measure-journal.ts index cdda9961..fee521ab 100644 --- a/packages/sqlite/scripts/measure-journal.ts +++ b/packages/sqlite/scripts/measure-journal.ts @@ -3,8 +3,8 @@ import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { Effect, Schema } from "effect"; import { SqlClient } from "effect/sql"; -import { EntityId, Triples, string, PatternClause } from "@triplex-build/triplex"; -import { CanonicalJson } from "@triplex-build/triplex/content"; +import { EntityId, Triples, string, PatternClause } from "@triplex-internal/core"; +import { CanonicalJson } from "@triplex-internal/core/content"; import { SqliteTriples } from "@triplex-build/triplex-sqlite"; const result = await Effect.runPromise( diff --git a/packages/sqlite/src/SqliteAdapter.ts b/packages/sqlite/src/SqliteAdapter.ts index 47103f8b..ce40962e 100644 --- a/packages/sqlite/src/SqliteAdapter.ts +++ b/packages/sqlite/src/SqliteAdapter.ts @@ -22,7 +22,7 @@ import { ReadError, MigrationError, isPatternVariable, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { makeSqlOperationalEntryStorage, makeSqlLivePreconditionLookup, diff --git a/packages/sqlite/src/SqliteBackend.ts b/packages/sqlite/src/SqliteBackend.ts index 2657d3b4..96c556af 100644 --- a/packages/sqlite/src/SqliteBackend.ts +++ b/packages/sqlite/src/SqliteBackend.ts @@ -8,8 +8,8 @@ import { Config, Effect, Layer } from "effect"; import { SqliteClient } from "@effect/sql-sqlite-node"; import { FileSystem } from "effect"; -import { SqliteDialect } from "@triplex-build/triplex/internal"; -import type { DatabaseId } from "@triplex-build/triplex"; +import { SqliteDialect } from "@triplex-internal/core/internal"; +import type { DatabaseId } from "@triplex-internal/core"; import { StorageBackend, type StorageBackendService } from "@triplex-build/triplex-sql"; import { SqliteAdapterLive } from "./SqliteAdapter.js"; diff --git a/packages/sqlite/src/SqliteTriples.ts b/packages/sqlite/src/SqliteTriples.ts index d3f7b15d..b3211045 100644 --- a/packages/sqlite/src/SqliteTriples.ts +++ b/packages/sqlite/src/SqliteTriples.ts @@ -19,7 +19,7 @@ import { TriplesLive, CurrentDialect, makeTripleStoreRuntimeLayer, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; import { SqliteAdapterLive } from "./SqliteAdapter.js"; import { SqliteDialect } from "./dialect.js"; diff --git a/packages/sqlite/src/dialect.ts b/packages/sqlite/src/dialect.ts index c42a58f4..3638386c 100644 --- a/packages/sqlite/src/dialect.ts +++ b/packages/sqlite/src/dialect.ts @@ -1,4 +1,4 @@ -import type { SqlDialect } from "@triplex-build/triplex/internal"; +import type { SqlDialect } from "@triplex-internal/core/internal"; export const SqliteDialect: SqlDialect = { name: "sqlite", diff --git a/packages/sqlite/test/SqliteInternalLayer.ts b/packages/sqlite/test/SqliteInternalLayer.ts index 4cd488e0..a6cd7c28 100644 --- a/packages/sqlite/test/SqliteInternalLayer.ts +++ b/packages/sqlite/test/SqliteInternalLayer.ts @@ -4,7 +4,7 @@ import { CurrentDialect, makeTripleStoreRuntimeLayer, TriplesLive, -} from "@triplex-build/triplex/internal"; +} from "@triplex-internal/core/internal"; import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "../src/index.js"; diff --git a/packages/sqlite/test/datalog-time-plan.test.ts b/packages/sqlite/test/datalog-time-plan.test.ts index ba2d691b..77e0c5ae 100644 --- a/packages/sqlite/test/datalog-time-plan.test.ts +++ b/packages/sqlite/test/datalog-time-plan.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { Effect } from "effect"; import { SqlClient } from "effect/sql"; -import { compile } from "@triplex-build/triplex/datalog"; +import { compile } from "@triplex-internal/core/datalog"; import { SqliteTestLayer } from "../src/SqliteLayer.js"; it("uses historical bound indexes for selective expiry and start ranges", async () => { diff --git a/packages/sqlite/test/durability.test.ts b/packages/sqlite/test/durability.test.ts index ac6c53de..20eece04 100644 --- a/packages/sqlite/test/durability.test.ts +++ b/packages/sqlite/test/durability.test.ts @@ -1,4 +1,4 @@ -import { EntityId, Triples, string } from "@triplex-build/triplex"; +import { EntityId, Triples, string } from "@triplex-internal/core"; import { Effect } from "effect"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/sqlite/test/host-transactions.test.ts b/packages/sqlite/test/host-transactions.test.ts index 52cd2c45..9804967d 100644 --- a/packages/sqlite/test/host-transactions.test.ts +++ b/packages/sqlite/test/host-transactions.test.ts @@ -7,7 +7,7 @@ import { EntityId, Triples, string, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; import { sqliteInternalLayer } from "./SqliteInternalLayer.js"; const record = EntityId.make("host:record:1"); diff --git a/packages/sqlite/test/layer-output.test.ts b/packages/sqlite/test/layer-output.test.ts index 5b07184e..21bc5561 100644 --- a/packages/sqlite/test/layer-output.test.ts +++ b/packages/sqlite/test/layer-output.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; import { Effect, Layer } from "effect"; -import { Triples } from "@triplex-build/triplex"; +import { Triples } from "@triplex-internal/core"; import { SqliteTriples } from "../src/SqliteTriples.js"; it("exposes only Triples from each SQLite convenience layer", async () => { diff --git a/packages/sqlite/test/runner.test.ts b/packages/sqlite/test/runner.test.ts new file mode 100644 index 00000000..e27256a2 --- /dev/null +++ b/packages/sqlite/test/runner.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { it } from "vitest"; +import { Clock, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { + KvTriplesLive, + KvBackend, + makeTestKvBackend, + TriplesLive, + CurrentDialect, + TripleStoreRuntime, + DeterministicTripleStoreRuntimeLive, + TripleStoreRuntimeLive, +} from "@triplex-internal/core/internal"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; +import { + SqliteAdapterLive, + SqliteDialect, + SqliteTestLayer, + makeSqliteLayer, +} from "../src/index.js"; +import { + runnerCases, + runnerPersistenceCase, + runnerWorldsCase, +} from "@triplex-build/triplex-testkit"; +for (const backend of ["kv", "sqlite"] as const) + for (const [name, program] of Object.entries(runnerCases)) { + // GitHub's Node 22 runner exceeds the 5 s default for the paged journal corpus. + it(`${backend}: runner ${name}`, { timeout: 60_000 }, async () => { + const runtime = Layer.effect( + TripleStoreRuntime, + Effect.gen(function* () { + const base = yield* TripleStoreRuntime; + return { ...base, now: Clock.currentTimeMillis }; + }), + ).pipe(Layer.provide(DeterministicTripleStoreRuntimeLive({ now: 0 }))); + const store = ( + backend === "kv" + ? KvTriplesLive.pipe(Layer.provide(Layer.sync(KvBackend, makeTestKvBackend))) + : TriplesLive.pipe( + Layer.provide(SqlQueryExecutorLive), + Layer.provide(SqliteAdapterLive), + Layer.provide(Layer.succeed(CurrentDialect, SqliteDialect)), + Layer.provide(SqliteTestLayer), + ) + ).pipe(Layer.provide(runtime)); + await Effect.runPromise( + program.pipe(Effect.scoped, Effect.provide(store), Effect.provide(TestClock.layer())), + ); + }); + } + +for (const backend of ["kv", "sqlite"] as const) + it(`${backend}: close/reopen runner persistence`, async () => { + const directory = mkdtempSync(join(tmpdir(), "triplex-runner-")); + const kv = makeTestKvBackend(); + const runtime = Layer.effect( + TripleStoreRuntime, + Effect.map(TripleStoreRuntime, (base) => ({ ...base, now: Clock.currentTimeMillis })), + ).pipe(Layer.provide(TripleStoreRuntimeLive)); + const open = () => + (backend === "kv" + ? KvTriplesLive.pipe(Layer.provide(Layer.succeed(KvBackend, kv))) + : TriplesLive.pipe( + Layer.provide(SqlQueryExecutorLive), + Layer.provide(SqliteAdapterLive), + Layer.provide(Layer.succeed(CurrentDialect, SqliteDialect)), + Layer.provide(makeSqliteLayer(join(directory, "world.sqlite"))), + ) + ).pipe(Layer.provide(runtime)); + try { + await Effect.runPromise(runnerPersistenceCase(open).pipe(Effect.provide(TestClock.layer()))); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + +for (const backend of ["kv", "sqlite"] as const) + it(`${backend}: sixteen independent worlds`, async () => { + const runtime = Layer.effect( + TripleStoreRuntime, + Effect.map(TripleStoreRuntime, (base) => ({ ...base, now: Clock.currentTimeMillis })), + ).pipe(Layer.provide(TripleStoreRuntimeLive)); + const open = () => + (backend === "kv" + ? KvTriplesLive.pipe(Layer.provide(Layer.sync(KvBackend, makeTestKvBackend))) + : TriplesLive.pipe( + Layer.provide(SqlQueryExecutorLive), + Layer.provide(SqliteAdapterLive), + Layer.provide(Layer.succeed(CurrentDialect, SqliteDialect)), + Layer.provide(Layer.fresh(SqliteTestLayer)), + ) + ).pipe(Layer.provide(runtime)); + await Effect.runPromise(runnerWorldsCase(open).pipe(Effect.provide(TestClock.layer()))); + }); diff --git a/packages/testkit/README.md b/packages/testkit/README.md index 7e37a910..56a1f40a 100644 --- a/packages/testkit/README.md +++ b/packages/testkit/README.md @@ -1,8 +1,8 @@ -# @triplex-build/triplex-testkit +# @triplex-build/triplex/testkit Reusable backend fixture and capability helpers for testing Triplex adapters. -Install `@triplex-build/triplex-testkit` from npm with Node.js 22+ and the compatible Effect 4 +Install `@triplex-build/triplex/testkit` from npm with Node.js 22+ and the compatible Effect 4 release candidate. `triplesConformanceCases` and `makeTriplesConformanceSuite` define the behavioral contract used by @@ -17,13 +17,13 @@ explicitly. This is a Triples contract check, not a guarantee of snapshot/emitte crash durability, cross-process isolation, or migration safety. For Postgres tests, install the compatible `@effect/sql-pg` optional peer and use -the separate `@triplex-build/triplex-testkit/postgres` entrypoint: +the separate `@triplex-build/triplex/testkit/postgres` entrypoint: ```ts import { Effect } from "effect"; import { Triples } from "@triplex-build/triplex"; -import { PgTriples } from "@triplex-build/triplex-postgres"; -import { postgresSchemaLayer } from "@triplex-build/triplex-testkit/postgres"; +import { PgTriples } from "@triplex-build/triplex/postgres"; +import { postgresSchemaLayer } from "@triplex-build/triplex/testkit/postgres"; const TestDatabase = postgresSchemaLayer({ url: process.env.PG_TEST_URL!, diff --git a/packages/testkit/package.json b/packages/testkit/package.json index b277855c..545d0a20 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -1,6 +1,7 @@ { "name": "@triplex-build/triplex-testkit", "version": "0.2.0", + "private": true, "description": "Backend test helpers for Triplex", "keywords": [ "effect", @@ -39,9 +40,6 @@ "default": "./dist/postgres.js" } }, - "publishConfig": { - "access": "public" - }, "scripts": { "build": "tsdown", "dev": "tsdown --watch", @@ -50,8 +48,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-sql": "workspace:^" + "@triplex-build/triplex-sql": "workspace:^", + "@triplex-internal/core": "workspace:^" }, "devDependencies": { "@effect/sql-pg": "catalog:", diff --git a/packages/testkit/src/assertionCases.ts b/packages/testkit/src/assertionCases.ts index 0c2288c4..e335afb9 100644 --- a/packages/testkit/src/assertionCases.ts +++ b/packages/testkit/src/assertionCases.ts @@ -1,5 +1,5 @@ import { Effect, Option } from "effect"; -import { EntityId, TripleId, Triples, string } from "@triplex-build/triplex"; +import { EntityId, TripleId, Triples, string } from "@triplex-internal/core"; /** Public identity reads distinguish live assertions from retained history. */ export const assertionConformance = Effect.gen(function* () { diff --git a/packages/testkit/src/commitValidatorCases.ts b/packages/testkit/src/commitValidatorCases.ts index b56afa54..1209f5d8 100644 --- a/packages/testkit/src/commitValidatorCases.ts +++ b/packages/testkit/src/commitValidatorCases.ts @@ -18,9 +18,9 @@ import { string, type QueryResponse, type TriplesService, -} from "@triplex-build/triplex"; -import { GraphConstraint } from "@triplex-build/triplex/config"; -import { ConsumerCheckpoint, Inbox, Outbox } from "@triplex-build/triplex/operational"; +} from "@triplex-internal/core"; +import { GraphConstraint } from "@triplex-internal/core/config"; +import { ConsumerCheckpoint, Inbox, Outbox } from "@triplex-internal/core/operational"; const eid = EntityId.make; const ref = (value: string) => makeRef(eid(value)); diff --git a/packages/testkit/src/datalogTimeCases.ts b/packages/testkit/src/datalogTimeCases.ts index c07e0527..16ca26fc 100644 --- a/packages/testkit/src/datalogTimeCases.ts +++ b/packages/testkit/src/datalogTimeCases.ts @@ -1,6 +1,6 @@ -import { attributesOf, checkAttributes } from "@triplex-build/triplex/datalog"; -import { extractDependencies } from "@triplex-build/triplex/subscriptions"; -import * as Derivation from "@triplex-build/triplex/derivation"; +import { attributesOf, checkAttributes } from "@triplex-internal/core/datalog"; +import { extractDependencies } from "@triplex-internal/core/subscriptions"; +import * as Derivation from "@triplex-internal/core/derivation"; /** Approved Datalog time contract. Shared unchanged by KV, SQLite and PostgreSQL. */ import { Effect } from "effect"; import { @@ -10,7 +10,7 @@ import { string, EntityId, type DatalogQuery, -} from "@triplex-build/triplex"; +} from "@triplex-internal/core"; import type { ConformanceCase } from "./index.js"; const J = Date.UTC(2026, 0, 1), diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts index 8d472abb..eb99b145 100644 --- a/packages/testkit/src/index.ts +++ b/packages/testkit/src/index.ts @@ -26,11 +26,11 @@ import { Triples, UnboundVariableError, EntityId, -} from "@triplex-build/triplex"; -import { ConsumerCheckpoint, Inbox, Outbox } from "@triplex-build/triplex/operational"; +} from "@triplex-internal/core"; +import { ConsumerCheckpoint, Inbox, Outbox } from "@triplex-internal/core/operational"; import { commitValidatorConformanceCases } from "./commitValidatorCases.js"; -import * as Derivation from "@triplex-build/triplex/derivation"; -import { GraphConstraint } from "@triplex-build/triplex/config"; +import * as Derivation from "@triplex-internal/core/derivation"; +import { GraphConstraint } from "@triplex-internal/core/config"; import { type RuntimeOptions, type RuntimeDefinition, @@ -39,7 +39,7 @@ import { SnapshotWriter, ChangeEmitter, TripleStoreRuntime, -} from "@triplex-build/triplex/runtime"; +} from "@triplex-internal/core/runtime"; const eid = EntityId.make; const ref = (value: string) => makeRef(eid(value)); @@ -140,7 +140,7 @@ export const triplesConformanceCases: readonly ConformanceCase[] = [ "CONF:FEED:", "conf:other:", ]; - let retractId: import("@triplex-build/triplex").TripleId | undefined; + let retractId: import("@triplex-internal/core").TripleId | undefined; for (let i = 0; i < 28; i++) { const first = eid(`${prefixes[i % prefixes.length]}${i}`); const second = eid(`${prefixes[(i * 3 + 1) % prefixes.length]}second:${i}`); @@ -3628,3 +3628,11 @@ export { liveQueryCases } from "./liveQueryCases.js"; export { whyConformance, whyTestLayer } from "./whyCases.js"; export { assertionConformance } from "./assertionCases.js"; export { whyReadBoundaryConformance } from "./whyReadBoundaryCases.js"; + +export { + runnerWorldsCase, + runnerCases, + runnerConformance, + runnerPersistenceCase, + settleRunner, +} from "./runnerCases.js"; diff --git a/packages/testkit/src/liveQueryCases.ts b/packages/testkit/src/liveQueryCases.ts index 6dd5e759..f72c084e 100644 --- a/packages/testkit/src/liveQueryCases.ts +++ b/packages/testkit/src/liveQueryCases.ts @@ -1,10 +1,10 @@ /** Shared browser-safe live-query corpus. Run against a fresh Triples store. */ import { Effect, Fiber, Stream } from "effect"; import { TestClock } from "effect/testing"; -import { EntityId, Triples, string, type TransactOp } from "@triplex-build/triplex"; -import { LiveQuery, type WatchSnapshot } from "@triplex-build/triplex/live-query"; -import { ConsumerCheckpoint, Outbox } from "@triplex-build/triplex/operational"; -import type { DatalogQuery } from "@triplex-build/triplex/datalog"; +import { EntityId, Triples, string, type TransactOp } from "@triplex-internal/core"; +import { LiveQuery, type WatchSnapshot } from "@triplex-internal/core/live-query"; +import { ConsumerCheckpoint, Outbox } from "@triplex-internal/core/operational"; +import type { DatalogQuery } from "@triplex-internal/core/datalog"; const check = (condition: boolean, message: string) => condition ? Effect.void : Effect.fail(new Error(message)); const equal = (a: unknown, b: unknown, message: string) => diff --git a/packages/testkit/src/postgres.ts b/packages/testkit/src/postgres.ts index 7817620f..68f77b50 100644 --- a/packages/testkit/src/postgres.ts +++ b/packages/testkit/src/postgres.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { Context, Effect, Layer, Redacted } from "effect"; import { SqlClient } from "effect/sql"; import { PgClient } from "@effect/sql-pg"; -import { Triples } from "@triplex-build/triplex"; +import { Triples } from "@triplex-internal/core"; import { runMigrations } from "@triplex-build/triplex-sql"; export interface PostgresSchemaOptions { diff --git a/packages/testkit/src/recordedReadsCases.ts b/packages/testkit/src/recordedReadsCases.ts index 08ccfd13..bc6914c6 100644 --- a/packages/testkit/src/recordedReadsCases.ts +++ b/packages/testkit/src/recordedReadsCases.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { EntityId, Triples, string } from "@triplex-build/triplex"; +import { EntityId, Triples, string } from "@triplex-internal/core"; /** Run with a fresh store whose injected runtime clock is controlled by setTime. */ export const recordedReadsConformance = (setTime: (instant: number) => void) => diff --git a/packages/testkit/src/runnerCases.ts b/packages/testkit/src/runnerCases.ts new file mode 100644 index 00000000..c08db698 --- /dev/null +++ b/packages/testkit/src/runnerCases.ts @@ -0,0 +1,913 @@ +/** Durable runner corpus. The caller supplies a fresh persistent Triples layer and TestClock. */ +import { Clock, Deferred, Effect, Fiber, Layer, Option, type Scope } from "effect"; +import { TestClock } from "effect/testing"; +import { + EntityId, + Triples, + string, + type TransactOp, + type TriplesService, +} from "@triplex-internal/core"; +import * as Derivation from "@triplex-internal/core/derivation"; +import * as Runner from "@triplex-internal/core/runner"; +import { DatabaseScope } from "@triplex-internal/core/runtime"; +import { Attribute, EntityType, ConfigStore } from "@triplex-internal/core/config"; +import * as Database from "@triplex-internal/core/database"; +import { Outbox, ConsumerCheckpoint } from "@triplex-internal/core/operational"; + +const check = (condition: unknown, message: string) => + condition ? Effect.void : Effect.fail(new Error(message)); +const equal = (a: unknown, b: unknown, message: string) => + check( + JSON.stringify(a) === JSON.stringify(b), + `${message}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`, + ); +const op = ( + id: string, + value = id, + attribute = ":runner-test/value", +): Extract => ({ + op: "assert", + entityId: EntityId.make(id), + attribute, + value: string(value), + validFrom: 0, +}); +const definition = (name = "test", pin = "test", attribute = ":runner-test/value") => + Derivation.make({ + name, + configSnapshot: pin, + identity: ["?e"], + query: { find: ["?e", "?v"], where: [["?e", attribute, "?v"]] }, + }); +const options = (id = "test") => ({ + scope: DatabaseScope.test(`runner-corpus:${id}`), + id, + idleCheckMs: 1, + retry: { maxAttempts: 2, initialDelayMs: 1, maxDelayMs: 1, random: () => 1 }, +}); +/** + * Wait until the runner status satisfies `predicate`. Backend I/O is awaited in real time + * (bounded at 30 s). The injected clock moves only when the runner is provably waiting on + * it: to reach a scheduled retry, or by one idle-check tick when commits it has not scanned + * (or a due deadline) sit unchanged across several real-time waits. Slow backends therefore + * cannot drift the clock past the valid-time boundaries a case asserts on. + */ +export const settleRunner = ( + h: Runner.Handle, + predicate: (s: Runner.RunnerStatus) => boolean, +): Effect.Effect => + Effect.gen(function* () { + const t = yield* Triples; + const live = TestClock.withLive(Clock.currentTimeMillis); + const deadline = (yield* live) + 30_000; + let previous = ""; + let lastHead = 0; + let unchanged = 0; + while ((yield* live) < deadline) { + const status = yield* h.status; + if (predicate(status)) return; + yield* Effect.raceFirst(Effect.yieldNow, h.await); + const fingerprint = JSON.stringify(status); + unchanged = fingerprint === previous ? unchanged + 1 : 0; + previous = fingerprint; + const now = yield* Clock.currentTimeMillis; + const retryAt = status.registrations + .map((r) => (r.pending && !r.pending.paused ? r.pending.retryAt : null)) + .filter((at): at is number => at !== null && at > now); + if (retryAt.length > 0 && unchanged >= 5) { + yield* TestClock.setTime(Math.min(...retryAt)); + continue; + } + const head = yield* t + .currentPosition() + .pipe( + Effect.mapError( + (error) => new Runner.RunnerError({ code: "Store", message: error.message }), + ), + ); + lastHead = head; + const due = status.registrations.some( + (r) => r.nextDeadline !== null && r.nextDeadline <= now, + ); + if ((head > status.scannedPosition || due) && unchanged >= 5) { + yield* TestClock.adjust(1); + unchanged = 0; + continue; + } + yield* TestClock.withLive(Effect.sleep(1)); + } + return yield* Effect.fail( + new Runner.RunnerError({ + code: "Store", + message: `Runner did not settle (head ${lastHead}, now ${yield* Clock.currentTimeMillis}): ${previous}`, + }), + ); + }); +const caught = (h: Runner.Handle, position: number) => + Effect.gen(function* () { + yield* settleRunner( + h, + (s) => + s.deliveredPosition >= position && + s.registrations.every((r) => r.deliveredPosition >= position && r.pending === null), + ); + const t = yield* Triples; + const persisted = yield* ConsumerCheckpoint.get(t, (yield* h.status).consumer); + yield* check( + (persisted?.position ?? 0) >= position, + "committed consumer checkpoint covers acknowledged effects", + ); + }); +const launch = (d: Derivation.Definition, deliveries: Runner.Delivery[], id = "test") => + Runner.start( + [ + { + key: "work", + definition: d, + onDiff: (b) => + Effect.sync(() => { + deliveries.push(b); + }), + }, + ], + options(id), + ); +const effects = { + "1 bootstrap, exact same-millisecond cuts, stable identities and all diff variants": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* caught(h, 0); + const first = yield* t.transact([op("a", "one")]); + const second = yield* t.transact([ + { op: "retract", id: first.triples[0]!.id }, + op("a", "two"), + ]); + const third = yield* t.transact([{ op: "retract", id: second.triples[0]!.id }]); + yield* caught(h, third.position); + yield* equal( + batches.map((b) => b.position), + [first.position, second.position, third.position], + "every exact source cut", + ); + yield* equal( + batches.map((b) => [b.diff.added.length, b.diff.changed.length, b.diff.removed.length]), + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + "diff variants", + ); + yield* equal( + batches[0]!.diff.added[0]!.id, + batches[1]!.diff.changed[0]!.after.id, + "logical candidate survives revision", + ); + const before = batches.length; + const irrelevant = yield* t.transact([op("b", "other", ":other/value")]); + yield* caught(h, irrelevant.position); + yield* equal(batches.length, before, "unchanged callback omitted"); + yield* h.stop; + const nonempty: Runner.Delivery[] = []; + yield* t.transact([op("nonempty")]); + const h2 = yield* launch(d, nonempty, "nonempty"); + yield* settleRunner(h2, () => nonempty.length === 1); + yield* equal(nonempty[0]!.reason, "initial", "nonempty bootstrap"); + }, + ), + "2 crash at durable protocol boundaries preserves the pinned payload": Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a")]); + const d = yield* definition(); + for (const boundary of [ + "prepared", + "materialized", + "ready", + "handler", + "success", + "head", + "checkpoint", + ] as const) { + const hit = Deferred.makeUnsafe(); + let armed = true; + const intercepted: TriplesService = { + ...t, + transact: (ops, meta) => { + const body = ops.find( + (o) => o.op === "assert" && o.attribute === ":runner/body" && o.value.type === "string", + ); + const value = + body?.op === "assert" && body.value.type === "string" + ? (JSON.parse(body.value.value) as { + pending?: { phase: string; attempt: number }; + runId?: string; + completed?: number; + }) + : undefined; + const at = + boundary === "prepared" + ? value?.pending?.phase === "prepared" + : boundary === "materialized" + ? meta?.actor === "triplex/derivation-materializer" + : boundary === "ready" + ? value?.pending?.phase === "ready" && value.pending.attempt === 0 + : boundary === "success" + ? ops.some((o) => o.op === "acknowledge-entry") + : boundary === "head" + ? value?.pending === null && !!value.runId + : boundary === "checkpoint" + ? ops.some( + (o) => + o.op === "assert" && + o.attribute === ":triplex/consumer-checkpoint-position", + ) + : false; + if (armed && at) { + armed = false; + return (boundary === "success" ? Effect.void : t.transact(ops, meta)).pipe( + Effect.andThen(Deferred.succeed(hit, undefined)), + Effect.andThen(Effect.never), + ); + } + return t.transact(ops, meta); + }, + }; + const observed: Runner.Delivery[] = []; + const registration = { + key: "work", + definition: d, + onDiff: (b: Runner.Delivery) => + Effect.gen(function* () { + observed.push(b); + if (armed && boundary === "handler") { + armed = false; + yield* Deferred.succeed(hit, undefined); + yield* Effect.never; + } + }), + }; + const id = `crash-${boundary}`; + const fiber = yield* Effect.forkChild( + Effect.scoped( + Effect.gen(function* () { + yield* Runner.start([registration], options(id)); + yield* Effect.never; + }).pipe(Effect.provideService(Triples, intercepted)), + ), + ); + yield* Deferred.await(hit); + yield* Fiber.interrupt(fiber); + const recovered: Runner.Delivery[] = []; + const h = yield* launch(d, recovered, id); + yield* caught(h, 1); + if (observed.length && recovered.length) { + yield* equal(observed[0]!.batchId, recovered[0]!.batchId, "stable retry batch"); + yield* equal( + observed[0]!.commandId("added", observed[0]!.diff.added[0]!.id), + recovered[0]!.commandId("added", recovered[0]!.diff.added[0]!.id), + "stable host command", + ); + } + yield* check(observed.length + recovered.length >= 1, "no lost delivery"); + yield* h.stop; + } + }), + "3 partial Database.command effects and application outbox intents deduplicate on redelivery": + Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a"), op("b")]); + const d = yield* definition(); + let attempts = 0; + const Outcome = EntityType.make("RunnerTestOutcome", { + status: Attribute.text(":effect/status").required(), + }); + const config = yield* ConfigStore.ConfigStore; + yield* config.commit({ objects: yield* Outcome.nodes, ref: "live", label: "runner-effects" }); + yield* Effect.gen(function* () { + const db = yield* Database.Database; + const h = yield* Runner.start( + [ + { + key: "work", + definition: d, + onDiff: (b) => + Effect.gen(function* () { + attempts++; + for (const [i, c] of b.diff.added.entries()) { + const commandId = b.commandId("added", c.id); + yield* db.command({ actor: "test/handler", commandId }, (tx) => + Effect.gen(function* () { + yield* tx.create( + Outcome, + EntityId.make(`effect:${c.id}`), + { status: "open" }, + { validFrom: 0 }, + ); + yield* tx.outbox.append({ + scope: EntityId.make("application"), + idempotencyKey: commandId, + payload: { candidate: c.id }, + }); + }), + ); + if (attempts === 1 && i === 0) return yield* Effect.fail("partial"); + } + }), + }, + ], + options(), + ); + yield* caught(h, 1); + yield* equal(attempts, 2, "whole batch retry"); + yield* equal( + (yield* t.match({ attribute: ":effect/status" })).length, + 2, + "no duplicate occurrences", + ); + const entries = (yield* Outbox.pending(t, { scope: EntityId.make("application") })).entries; + yield* equal(entries.length, 2, "deduplicated intents"); + const entry = entries[0]!; + const conflict = yield* Effect.result( + t.transact([ + Outbox.append({ + scope: entry.scope, + idempotencyKey: entry.idempotencyKey, + payload: { different: true }, + }), + ]), + ); + yield* check(conflict._tag === "Failure", "payload conflict rejected"); + }).pipe(Effect.provide(Database.layer({ schema: { types: [Outcome] } }))); + }).pipe(Effect.provide(ConfigStore.layer)), + "4 manual materialization cannot replace acknowledged reconciliation base": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + const source = yield* t.transact([op("a")]); + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* caught(h, source.position); + yield* h.stop; + const removed = yield* t.transact([{ op: "retract", id: source.triples[0]!.id }]); + yield* Derivation.Materialization.materialize(t, d, { + basis: { recordedPosition: removed.position, validAt: 10 }, + }); + const restarted = yield* launch(d, batches); + yield* caught(restarted, removed.position); + yield* equal(batches.length, 2, "pending removal delivered"); + yield* equal(batches[1]!.diff.removed.length, 1, "old explanation retained"); + }, + ), + "5 poison pauses durably, other registrations progress, retry resumes identical batch": + Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + const tx = yield* t.transact([op("a")]); + const d = yield* definition(); + const ids: string[] = []; + const healthy: Runner.Delivery[] = []; + const reg = { + key: "bad", + definition: d, + onDiff: (b: Runner.Delivery) => { + ids.push(b.batchId); + return Effect.fail("poison"); + }, + }; + const h = yield* Runner.start( + [ + reg, + { + key: "healthy", + definition: d, + onDiff: (b) => + Effect.sync(() => { + healthy.push(b); + }), + }, + ], + options(), + ); + yield* settleRunner( + h, + (s) => s.registrations[0]!.pending?.paused === true && s.registrations[1]!.pending === null, + ); + yield* check( + (yield* h.status).deliveredPosition < tx.position, + "poison blocks shared checkpoint", + ); + yield* equal(ids.length, 2, "bounded attempts"); + yield* h.stop; + const restarted = yield* Runner.start( + [ + { + ...reg, + onDiff: (b: Runner.Delivery) => + Effect.sync(() => { + ids.push(b.batchId); + }), + }, + { key: "healthy", definition: d, onDiff: () => Effect.void }, + ], + options(), + ); + yield* check( + (yield* restarted.status).registrations[0]!.pending?.paused, + "restart honors poison", + ); + yield* restarted.retryPaused("bad"); + yield* caught(restarted, tx.position); + yield* check( + ids.every((id) => id === ids[0]), + "retry identity unchanged", + ); + yield* equal(healthy.length, 1, "healthy registration progressed"); + }), + "6 negation, future starts and empty-run expiry produce open satisfied reopened": Effect.gen( + function* () { + // Boundaries sit seconds apart: settling may tick the injected clock by a few + // milliseconds on slow backends, which must never reach the next boundary. + const t = yield* Triples; + yield* TestClock.setTime(110_000); + yield* t.transact([op("worker")]); + const d = yield* Derivation.make({ + name: "safety", + configSnapshot: "test", + identity: ["?e"], + query: { + find: ["?e"], + where: [ + ["?e", ":runner-test/value", "?v"], + ["not", ["?e", ":test/trained", "yes"]], + ], + }, + }); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* settleRunner(h, () => batches.length === 1); + yield* t.transact([ + { ...op("worker", "yes", ":test/trained"), validFrom: 120_000, validTo: 200_000 }, + ]); + yield* TestClock.setTime(150_000); + yield* settleRunner(h, () => batches.length === 2); + yield* equal(batches[1]!.basis.validAt, 120_000, "future start evaluated at exact boundary"); + // The empty run schedules expiry once it is acknowledged. + yield* settleRunner(h, (s) => s.registrations[0]!.nextDeadline === 200_000); + yield* TestClock.setTime(200_000); + yield* settleRunner(h, () => batches.length === 3); + yield* equal( + batches.map((b) => [b.diff.added.length, b.diff.removed.length]), + [ + [1, 0], + [0, 1], + [1, 0], + ], + "open satisfied reopened", + ); + yield* check( + batches[0]!.batchId !== batches[2]!.batchId, + "reopened occurrence gets new identity", + ); + }, + ), + "7 offline replay preserves intermediate boundaries and monotonic frontier on rollback": + Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([ + { ...op("a"), validFrom: 20, validTo: 30 }, + { ...op("b"), validFrom: 40, validTo: 50 }, + ]); + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* caught(h, 1); + yield* h.stop; + yield* TestClock.setTime(60); + const recovered = yield* launch(d, batches); + yield* settleRunner(recovered, () => batches.length === 4); + yield* equal( + batches.map((b) => b.basis.validAt), + [20, 30, 40, 50], + "every offline boundary", + ); + yield* TestClock.setTime(15); + const tx = yield* t.transact([op("c")]); + yield* caught(recovered, tx.position); + yield* check( + batches[batches.length - 1]!.basis.validAt >= 50, + "rollback never rewinds frontier", + ); + }), + "7b commit-at-boundary wins the tie, corrected deadlines and retractions cancel timers": + Effect.gen(function* () { + // Boundaries sit seconds apart so settling ticks cannot reach the next one. + const t = yield* Triples; + yield* TestClock.setTime(10_000); + const initial = yield* t.transact([ + op("worker"), + { ...op("worker", "yes", ":test/trained"), validTo: 20_000 }, + ]); + const evidence = initial.triples.find((row) => row.attribute === ":test/trained")!; + const d = yield* Derivation.make({ + name: "tie", + configSnapshot: "test", + identity: ["?e"], + query: { + find: ["?e"], + where: [ + ["?e", ":runner-test/value", "?v"], + ["not", ["?e", ":test/trained", "yes"]], + ], + }, + }); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* caught(h, initial.position); + yield* h.stop; + yield* TestClock.setTime(20_000); + const replaced = yield* t.transact([ + { op: "retract", id: evidence.id }, + { ...op("worker", "yes", ":test/trained"), validFrom: 20_000, validTo: 40_000 }, + ]); + const recovered = yield* launch(d, batches); + yield* caught(recovered, replaced.position); + yield* equal(batches.length, 0, "replacement at expiry prevents transient reopen"); + // corrected deadline installed: the timer is installed once the run is acknowledged. + yield* settleRunner(recovered, (s) => s.registrations[0]!.nextDeadline === 40_000); + const corrected = yield* t.transact([ + { op: "retract", id: replaced.triples[0]!.id }, + { ...op("worker", "yes", ":test/trained"), validFrom: 20_000, validTo: 30_000 }, + ]); + yield* caught(recovered, corrected.position); + // earlier deadline re-arms minimum timer: the timer is installed once the run is acknowledged. + yield* settleRunner(recovered, (s) => s.registrations[0]!.nextDeadline === 30_000); + const retracted = yield* t.transact([{ op: "retract", id: corrected.triples[0]!.id }]); + yield* caught(recovered, retracted.position); + // retracted schedule cancelled: the timer is installed once the run is acknowledged. + yield* settleRunner(recovered, (s) => s.registrations[0]!.nextDeadline === null); + yield* equal(batches.length, 1, "retraction opens once"); + }), + "1b duplicate proofs merge provenance and unchanged revisions suppress callbacks": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a"), op("a")]); + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* caught(h, 1); + yield* equal(batches[0]!.diff.added.length, 1, "proofs deduplicate candidate"); + yield* equal(batches[0]!.diff.added[0]!.sources.length, 2, "proof sources merged"); + const unrelated = yield* t.transact([op("other", "no", ":other/value")]); + yield* caught(h, unrelated.position); + yield* equal(batches.length, 1, "unchanged callback suppressed"); + }, + ), + "9b missing journal envelopes and infrastructure write rejection are supervised": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + let hide = false; + const wrapped: TriplesService = { + ...t, + transactions: (request) => + t + .transactions(request) + .pipe(Effect.map((page) => (hide ? { ...page, transactions: [] } : page))), + }; + const h = yield* launch(d, batches).pipe(Effect.provideService(Triples, wrapped)); + yield* settleRunner(h, (s) => s.registrations[0]!.acknowledgedRun !== null); + hide = true; + yield* t.transact([op("missing")]); + yield* TestClock.adjust(1); + const failure = yield* Effect.result(h.await); + yield* check( + failure._tag === "Failure" && failure.failure.code === "CorruptState", + "missing retained envelope fails instead of skipping", + ); + yield* h.stop; + }, + ), + "8 lost hints, unfiltered paged coverage and maintenance tails": Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches); + yield* caught(h, 0); + // GitHub's Node 22 runner exceeds the 5 s default for this paged journal case. + for (let i = 0; i < 1005; i++) + yield* t.transact([], { actor: "triplex/derivation-materializer" }); + const source = yield* t.transact([op("outside:projected-prefix")]); + yield* caught(h, source.position); + yield* check( + batches.some((b) => b.position === source.position), + "global feed discovers external write", + ); + const pos = yield* t.currentPosition(); + yield* TestClock.adjust(10); + yield* Effect.yieldNow; + const idle = yield* t.currentPosition(); + yield* TestClock.adjust(10); + yield* Effect.yieldNow; + yield* check( + (yield* t.currentPosition()) <= idle + 1 && idle >= pos, + "maintenance does not recurse forever", + ); + }), + "9 deployment, drain before removal, generation and malformed control rejection": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a")]); + const d = yield* definition(); + const h = yield* Runner.start( + [{ key: "work", definition: d, onDiff: () => Effect.fail("poison") }], + options(), + ); + yield* settleRunner(h, (s) => !!s.registrations[0]!.pending?.paused); + yield* h.stop; + const missing = yield* Effect.result(Runner.start([], options())); + yield* check( + missing._tag === "Failure" && missing.failure.code === "MissingDefinition", + "cannot remove unfinished registration", + ); + const next = yield* definition("test", "release-2"); + const noOld = yield* Effect.result(launch(next, [])); + yield* check( + noOld._tag === "Failure" && noOld.failure.code === "MissingDefinition", + "old prepared definition required", + ); + const batches: Runner.Delivery[] = []; + const recovered = yield* Runner.start( + [ + { + key: "work", + definition: next, + onDiff: (b) => + Effect.sync(() => { + batches.push(b); + }), + }, + ], + { ...options(), recoveryDefinitions: [d] }, + ); + yield* recovered.retryPaused("work"); + yield* settleRunner( + recovered, + (s) => s.registrations[0]!.pending === null && batches.length >= 2, + ); + yield* equal(batches[0]!.configSnapshot, "test", "old batch completed first"); + yield* equal(batches[1]!.reason, "definition", "new release diff"); + yield* recovered.stop; + const generation = DatabaseScope.make({ + env: "test", + tenant: "test", + database: "runner-corpus:test", + generation: 1, + }); + const wrong = yield* Effect.result(Runner.start([], { ...options(), scope: generation })); + yield* check( + wrong._tag === "Failure" && wrong.failure.code === "GenerationChanged", + "generation mismatch", + ); + const rows = yield* t.match({ attribute: ":runner/hash" }); + const row = rows[0]!; + yield* t.transact([ + { op: "retract", id: row.id }, + { + op: "assert", + entityId: row.entityId, + entityType: Option.getOrUndefined(row.entityType), + attribute: row.attribute, + value: string("corrupt"), + validFrom: 0, + }, + ]); + const corrupt = yield* Effect.result(launch(next, [])); + yield* check( + corrupt._tag === "Failure" && corrupt.failure.code === "CorruptState", + "corrupt hash rejected", + ); + }, + ), + "10b overlapping control writer fails CAS and retained ready work recovers": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a")]); + const d = yield* definition(); + let competing = true; + const wrapped: TriplesService = { + ...t, + transact: (ops, meta) => + Effect.gen(function* () { + if (competing && ops.some((o) => o.op === "append-entry")) { + competing = false; + yield* t.transact(ops, { ...meta, commandId: "simulated-overlapping-owner" }); + } + return yield* t.transact(ops, meta); + }), + }; + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches).pipe(Effect.provideService(Triples, wrapped)); + const failed = yield* Effect.result(h.await); + yield* check( + failed._tag === "Failure" && failed.failure.code === "Store", + "stale control CAS fails supervision", + ); + yield* equal(batches.length, 0, "conflict cannot acknowledge handler work"); + yield* h.stop; + const recovered = yield* launch(d, batches); + yield* caught(recovered, 1); + yield* equal(batches.length, 1, "retained ready work survives stale writer"); + }, + ), + "9c host validator rejection leaves the prepared basis unacknowledged": Effect.gen(function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a")]); + const d = yield* definition(); + const wrapped: TriplesService = { + ...t, + transact: (ops, meta) => + t.transact( + ops, + ops.some((o) => o.op === "append-entry") + ? { + ...meta, + validate: [ + { + name: "host-policy", + validate: () => + Effect.succeed([ + { + code: "runner-denied", + message: "Infrastructure writes require host authorization", + }, + ]), + }, + ], + } + : meta, + ), + }; + const batches: Runner.Delivery[] = []; + const h = yield* launch(d, batches).pipe(Effect.provideService(Triples, wrapped)); + const failed = yield* Effect.result(h.await); + yield* check( + failed._tag === "Failure" && failed.failure.code === "Store", + "validator rejection is supervised", + ); + yield* equal(batches.length, 0, "rejection cannot acknowledge or invoke handler"); + yield* h.stop; + const recovered = yield* launch(d, batches); + yield* caught(recovered, 1); + yield* equal(batches.length, 1, "authorized recovery retains prepared work"); + }), + "10 ownership, ordered callbacks, payload backpressure and isolated worlds": Effect.gen( + function* () { + const t = yield* Triples; + yield* TestClock.setTime(10); + yield* t.transact([op("a"), op("b")]); + const d = yield* definition(); + const h = yield* Runner.start([{ key: "work", definition: d, onDiff: () => Effect.void }], { + ...options(), + maxCandidates: 1, + }); + yield* settleRunner(h, (s) => !!s.registrations[0]!.pending?.paused); + const duplicate = yield* Effect.result(launch(d, [])); + yield* check( + duplicate._tag === "Failure" && duplicate.failure.code === "AlreadyRunning", + "second local owner rejected", + ); + yield* h.stop; + const resumed: Runner.Delivery[] = []; + const r = yield* launch(d, resumed); + yield* r.retryPaused("work"); + yield* caught(r, 1); + yield* equal(resumed[0]!.diff.added.length, 2, "budget pause retains full basis"); + yield* r.stop; + for (let i = 0; i < 16; i++) { + const batches: Runner.Delivery[] = []; + const world = yield* launch(d, batches, `world-${i}`); + yield* settleRunner(world, () => batches.length === 1); + yield* world.stop; + yield* equal(batches[0]!.diff.added.length, 2, "isolated world bootstrap"); + } + }, + ), +} as const; +export const runnerCases = effects; + +/** Compose the corpus with a host-owned store and injected Clock layer. */ +export const runnerConformance = (fixture: { + readonly store: Layer.Layer; + readonly clock: Layer.Layer; + readonly reopen?: () => Layer.Layer; +}) => + Object.fromEntries([ + ...Object.entries(effects).map(([name, program]) => [ + name, + program.pipe(Effect.scoped, Effect.provide(fixture.store), Effect.provide(fixture.clock)), + ]), + ...(fixture.reopen + ? [ + [ + "physical store restart", + runnerPersistenceCase(fixture.reopen).pipe(Effect.provide(fixture.clock)), + ], + ] + : []), + ]) as Record>>; + +/** Close/reopen the host's store between phases; KV fixtures retain their backend handle. */ +export const runnerPersistenceCase = (open: () => Layer.Layer) => + Effect.gen(function* () { + const d = yield* definition(); + const batches: Runner.Delivery[] = []; + yield* TestClock.setTime(10); + yield* Effect.gen(function* () { + const t = yield* Triples; + yield* t.transact([{ ...op("persisted"), validFrom: 20, validTo: 30 }]); + const h = yield* launch(d, batches, "persistent"); + yield* settleRunner(h, (s) => s.registrations[0]!.acknowledgedRun !== null); + yield* h.stop; + }).pipe(Effect.provide(open()), Effect.scoped); + yield* TestClock.setTime(40); + yield* Effect.gen(function* () { + const h = yield* launch(d, batches, "persistent"); + yield* settleRunner(h, () => batches.length === 2); + yield* equal( + batches.map((b) => [b.basis.validAt, b.diff.added.length, b.diff.removed.length]), + [ + [20, 1, 0], + [30, 0, 1], + ], + "closed store reopens with retained schedule and head", + ); + yield* h.stop; + }).pipe(Effect.provide(open()), Effect.scoped); + }); + +/** Sixteen independent persistent-store fixtures, each with its own runner scope and effects. */ +export const runnerWorldsCase = (open: (world: number) => Layer.Layer) => + Effect.gen(function* () { + yield* TestClock.setTime(10); + const batches = yield* Effect.all( + Array.from({ length: 16 }, (_, world) => + Effect.gen(function* () { + const t = yield* Triples; + const id = `world:${world}`; + yield* t.transact([op(id)]); + const d = yield* definition(); + const deliveries: Runner.Delivery[] = []; + const h = yield* Runner.start( + [ + { + key: "work", + definition: d, + onDiff: (batch) => + Effect.gen(function* () { + yield* t.transact([op(`effect:${world}`, id, ":world/delivered")], { + commandId: batch.commandId("added", batch.diff.added[0]!.id), + }); + deliveries.push(batch); + }), + }, + ], + options(`isolated-${world}`), + ); + yield* settleRunner( + h, + (s) => deliveries.length === 1 && s.registrations[0]!.pending === null, + ); + yield* equal( + deliveries[0]!.diff.added[0]!.result["?e"], + id, + "candidate belongs to its own world", + ); + yield* equal( + (yield* t.match({ attribute: ":world/delivered" })).length, + 1, + "committed effect isolated to one world", + ); + yield* h.stop; + return deliveries[0]!.batchId; + }).pipe(Effect.provide(open(world)), Effect.scoped), + ), + { concurrency: 4 }, + ); + yield* equal(new Set(batches).size, 16, "world generation participates in batch identity"); + }); diff --git a/packages/testkit/src/sensitive.ts b/packages/testkit/src/sensitive.ts index 44848f21..393d3536 100644 --- a/packages/testkit/src/sensitive.ts +++ b/packages/testkit/src/sensitive.ts @@ -26,16 +26,16 @@ import { type SealedValue, type Triple, type TripleValue, -} from "@triplex-build/triplex"; -import { Attribute, EntityType, GraphConstraint } from "@triplex-build/triplex/config"; -import * as Derivation from "@triplex-build/triplex/derivation"; +} from "@triplex-internal/core"; +import { Attribute, EntityType, GraphConstraint } from "@triplex-internal/core/config"; +import * as Derivation from "@triplex-internal/core/derivation"; import { KeyProvider, Sensitive, Sensitivity, ValueCodec, type SensitiveValueError, -} from "@triplex-build/triplex/sensitive"; +} from "@triplex-internal/core/sensitive"; const eid = EntityId.make; diff --git a/packages/testkit/src/whyCases.ts b/packages/testkit/src/whyCases.ts index d05fe538..10752265 100644 --- a/packages/testkit/src/whyCases.ts +++ b/packages/testkit/src/whyCases.ts @@ -1,8 +1,8 @@ import { Effect, Layer } from "effect"; -import { EntityId, Triples, string, type SealedValue } from "@triplex-build/triplex"; -import { Attribute, EntityType, ConfigStore } from "@triplex-build/triplex/config"; -import * as Derivation from "@triplex-build/triplex/derivation"; -import { Why } from "@triplex-build/triplex/why"; +import { EntityId, Triples, string, type SealedValue } from "@triplex-internal/core"; +import { Attribute, EntityType, ConfigStore } from "@triplex-internal/core/config"; +import * as Derivation from "@triplex-internal/core/derivation"; +import { Why } from "@triplex-internal/core/why"; import { attributeFixture, candidateFixture } from "./whyFixtures.js"; const check = (condition: boolean, message: string) => diff --git a/packages/testkit/src/whyReadBoundaryCases.ts b/packages/testkit/src/whyReadBoundaryCases.ts index ade639a5..181eb0f8 100644 --- a/packages/testkit/src/whyReadBoundaryCases.ts +++ b/packages/testkit/src/whyReadBoundaryCases.ts @@ -6,10 +6,10 @@ import { ReadError, string, type TriplesService, -} from "@triplex-build/triplex"; -import * as Derivation from "@triplex-build/triplex/derivation"; -import { ConfigStore } from "@triplex-build/triplex/config"; -import { Why, type WhySubject, type WhyOptions } from "@triplex-build/triplex/why"; +} from "@triplex-internal/core"; +import * as Derivation from "@triplex-internal/core/derivation"; +import { ConfigStore } from "@triplex-internal/core/config"; +import { Why, type WhySubject, type WhyOptions } from "@triplex-internal/core/why"; /** Fault injection uses public service decorators, shared across backend runtimes. */ export const whyReadBoundaryConformance = Effect.gen(function* () { diff --git a/packages/testkit/test/fixtures.test.ts b/packages/testkit/test/fixtures.test.ts index 5fc66365..9d16707d 100644 --- a/packages/testkit/test/fixtures.test.ts +++ b/packages/testkit/test/fixtures.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Effect } from "effect"; -import { KvTriples } from "@triplex-build/triplex"; +import { KvTriples } from "@triplex-internal/core"; import { defineBackendFixture, diff --git a/packages/testkit/test/sensitive.test.ts b/packages/testkit/test/sensitive.test.ts index 3eb79290..4722d2ef 100644 --- a/packages/testkit/test/sensitive.test.ts +++ b/packages/testkit/test/sensitive.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Effect, Layer } from "effect"; -import { KvTriples } from "@triplex-build/triplex"; +import { KvTriples } from "@triplex-internal/core"; import { makeSealedStorageSuite, diff --git a/packages/triplex/LICENSE b/packages/triplex/LICENSE new file mode 100644 index 00000000..ac737c0f --- /dev/null +++ b/packages/triplex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ben Jacobson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/triplex/README.md b/packages/triplex/README.md new file mode 100644 index 00000000..401cc9bc --- /dev/null +++ b/packages/triplex/README.md @@ -0,0 +1,634 @@ +

+ + + Triplex logo + +

+ +# Triplex + +> [!WARNING] +> Triplex is pre-1.0. The published `@triplex-build/triplex` package requires `effect@4.1.1`; +> Effect 3 is not compatible. KV +> and SQLite are the supported baseline, PostgreSQL is a production candidate, and Cloudflare and +> FoundationDB are experimental. See [Current state](https://github.com/bjacobso/triplex/blob/main/docs/current-state.md) for the exact maturity +> contract. + +**The database that remembers why.** Triplex is an embedded fact database for TypeScript +back-office systems, built on Effect. Every write records who made it, which versioned rules +governed it, and when it was true, so audit questions and open work become queries instead of +investigations. + +[Documentation](https://triplex.build) · +[Modeling guide for agents](https://triplex.build/agents) · +[`llms.txt`](https://triplex.build/llms.txt) · +[GitHub](https://github.com/bjacobso/triplex) · +[Security](https://github.com/bjacobso/triplex/blob/main/SECURITY.md) + +Triplex stores what is true, what was true, which versioned rules governed each write, and what +work the data implies. One store answers "what does this record look like today", "what did we +believe last Tuesday", "who changed it and under which policy", and "what tasks should exist right +now", without stitching together a database, an audit log, a config service, and a job queue. + +## Start here + +Use the [Database client](https://triplex.build/database) for application commands and typed +reads. Declare your schema, publish a release, then use `db.command` with an actor and stable +command ID. It pins and enforces the release and returns a persisted receipt; retries return the +same receipt. Read with `db.get` and `db.query`, capture historical views with `db.asOf`, and +explain their facts with [`why`](https://triplex.build/why). + +```sh +npm install @triplex-build/triplex effect +``` + +For SQLite, add `@effect/sql-sqlite-node` and import the backend from +`@triplex-build/triplex/sqlite`. Start with the +[checked client quickstart](https://triplex.build/getting-started), then follow the +[modeling guide](https://triplex.build/agents) to map your domain to commands, temporal changes, +and derived work. The [documentation](https://triplex.build) covers the complete API. + +## Why Triplex? + +Most applications keep state in a database and everything that explains that state somewhere else: +policy versions in a config service, audit trails in a log, derived to-dos in a queue. The moment a +regulator, customer, or engineer asks _why_ something happened, the answer has to be reconstructed +across systems that never shared identity or time. + +Triplex keeps those concerns separate in the API but gives them one identity model, one clock, and +one provenance chain: + +| Primitive | What it provides | +| ----------- | ------------------------------------------------------------------------------------- | +| Triples | Typed facts, atomic assertions/retractions, history, batching, and pluggable storage | +| Datalog | Structural joins, negation, aggregation, recursion, and stable pagination | +| Journal | Ordered commits with actor, command, causation, correlation, and config provenance | +| Config | Typed Merkle graphs, immutable releases, movable refs, impact, and decision proofs | +| Derivations | Content-addressed candidates, source provenance, reconciliation, and temporal wakeups | +| Projections | Entity snapshots and validation/materialization state with explicit freshness | + +The layers meet at an application command without collapsing into one model: + +```mermaid +flowchart LR + C[Config release] -->|configSnapshot| T[Atomic transaction] + T --> F[Bitemporal facts] + T --> J[Ordered causal journal] + F --> Q[Datalog derivation] + C --> Q + Q --> D[Provenance-carrying candidates] + D --> H[Host-owned workflows and projections] +``` + +**Good fit:** compliance and onboarding systems, policy engines, entitlement and eligibility logic, +back-office workflows, and any domain where "as of when" and "under which rules" are first-class +questions. It also fits agent-driven systems that need a durable, queryable world model with a +causal record of every change. + +**Not a fit:** high-volume telemetry, blob storage, or workloads that only need a relational table +and never ask about history or provenance. Triplex is a system of record, not a cache or a queue. + +## Installation and evaluation + +Install the single package and its Effect peer dependency from npm: + +```sh +npm install @triplex-build/triplex effect +``` + +To run the checked repository examples from a source checkout: + +```sh +git clone https://github.com/bjacobso/triplex.git +cd triplex +corepack enable +pnpm install --frozen-lockfile +pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/client-getting-started.ts +``` + +Do not use the superseded `@bjacobso` canaries for new work. Triplex is ESM-only, targets Node.js +22+, and is aligned to `effect@4.1.1`. The browser-safe core also runs in modern browsers and +edge runtimes. The [website quickstart](https://github.com/bjacobso/triplex/blob/main/docs/getting-started.md) includes expected output, Effect +layer lifetime, and SQLite persistence. The coordinated package publication process is documented +in [Releasing Triplex](https://github.com/bjacobso/triplex/blob/main/docs/releasing.md). + +## Quick start + +`Triples` is the primitive service beneath the Database client, useful for host infrastructure +and migrations. The package includes an in-memory ordered-KV layer, so this lower-level program +can write facts, read patterns, and run Datalog: + +```ts check +import { Effect } from "effect"; +import { EntityId, KvTriples, Triples, ref, string } from "@triplex-build/triplex"; + +const program = Effect.gen(function* () { + const triples = yield* Triples; + const alice = EntityId.make("person:alice"); + const acme = EntityId.make("company:acme"); + + const tx = yield* triples.transact( + [ + { + op: "assert", + entityId: alice, + entityType: "Person", + attribute: ":person/name", + value: string("Alice"), + }, + { + op: "assert", + entityId: alice, + entityType: "Person", + attribute: ":person/employer", + value: ref(acme), + }, + { + op: "assert", + entityId: acme, + entityType: "Company", + attribute: ":company/name", + value: string("Acme"), + }, + ], + { + actor: "user:ben", + commandId: "create-person:alice", + correlationId: "request:123", + }, + ); + + const facts = yield* triples.entity(alice); + + const { results } = yield* triples.query({ + find: ["?personName", "?companyName"], + where: [ + ["?person", ":person/name", "?personName"], + ["?person", ":person/employer", "?company"], + ["?company", ":company/name", "?companyName"], + ], + }); + + return { position: tx.position, facts, results }; +}); + +Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer))); +``` + +Assertions may declare `validFrom` and `validTo`. Reads accept one +`{ recordedAt?, validAt? }` basis, which applies coherently to every clause in a query. Retracting a +fact closes its recorded visibility without deleting its assertion history. + +Direct writes, entity/pattern reads, reference values, and transactional retractions require +`EntityId` or `TripleId` values. Construct or decode them once at the application boundary instead +of scattering unchecked string casts through domain code. Raw Datalog keeps string literals because +one term can represent an entity, attribute, transaction, rule, or scalar value; its runtime schema +validates identity positions before execution. + +Run +`pnpm exec tsx --tsconfig docs/snippets/tsconfig.json examples/demo/demo.ts` +for the smallest executable example from a fresh source checkout. + +## Integrating Triplex into an application + +The quick start shows the store. This walkthrough shows the shape of a real host: a small HR +system that places workers on sites and must make sure each worker has current safety training +for every site they are placed on. The same six steps apply to most Triplex integrations. + +```ts check +import { Effect, Layer } from "effect"; +import { EntityId, KvTriples, Triples } from "@triplex-build/triplex"; +import { Attribute, ConfigStore, EntityType, GraphConstraint } from "@triplex-build/triplex/config"; +import * as Derivation from "@triplex-build/triplex/derivation"; +import { ConsumerCheckpoint } from "@triplex-build/triplex/operational"; +import { Runtime, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; + +// 1. Describe the domain once. Attributes own identity and type; entity types own usage rules. +const WorkerName = Attribute.text(":worker/name"); +const Worker = EntityType.make("Worker", { name: Attribute.use(WorkerName, { required: true }) }); + +const PlacementWorker = Attribute.ref(":placement/worker", Worker); +const PlacementSite = Attribute.text(":placement/site"); +const Placement = EntityType.make("Placement", { + worker: Attribute.use(PlacementWorker, { required: true }), + site: Attribute.use(PlacementSite, { required: true }), +}); + +const SafetyTraining = Attribute.text(":evidence/site-safety"); +const workerId = EntityId.make("worker:maria"); +const placementId = EntityId.make("placement:1"); + +const program = Effect.gen(function* () { + const triples = yield* Triples; + const config = yield* ConfigStore.ConfigStore; + + // 2. Publish the schema as an immutable, content-addressed release. + const release = yield* config.commit({ + label: "hr-2026.1", + objects: [...(yield* EntityType.nodesOf(Worker, Placement)), yield* SafetyTraining.node], + ref: "live", + }); + const enforce = GraphConstraint.enforcement([...Worker.constraints, ...Placement.constraints]); + + // 3. Handle an application command as one atomic, attributed transaction. + yield* triples.transact( + [ + { + op: "assert", + entityId: workerId, + entityType: "Worker", + ...Worker.attributes.name.assertion("Maria"), + }, + { + op: "assert", + entityId: placementId, + entityType: "Placement", + ...Placement.attributes.worker.assertion(workerId), + }, + { + op: "assert", + entityId: placementId, + entityType: "Placement", + ...Placement.attributes.site.assertion("site:harbor"), + }, + ], + { + actor: "user:ben", + commandId: "placement:create:1", // retried commands return the same receipt + correlationId: "request:8f2c", + configSnapshot: release.snapshot.id, // the rules this write was governed by + enforce, // required/unique/ref-target checks in the same transaction + }, + ); + + // 4. Declare the work the data implies, as a content-addressed Datalog derivation. + const training = yield* Derivation.make({ + name: "task.site-safety-training", + query: { + find: ["?worker", "?site"], + where: [ + ["?placement", PlacementWorker.key, "?worker"], + ["?placement", PlacementSite.key, "?site"], + ["not", ["?worker", SafetyTraining.key, "?site"]], + ], + }, + identity: ["?worker", "?site"], + configSnapshot: release.snapshot.id, + }); + + // 5. A background consumer follows the journal and reconciles the derivation. + const consumer = "hr/training-materializer"; + const checkpoint = yield* ConsumerCheckpoint.get(triples, consumer); + const from = checkpoint?.position ?? 0; + const page = yield* triples.transactions({ after: from, limit: 100 }); + if (page.next !== undefined) { + const run = yield* Derivation.Materialization.materialize(triples, training, { + basis: { validAt: Date.now() }, + }); + for (const candidate of run.reconciliation.added) { + // Replace with your own task/notification system; identity and revision are stable. + yield* Effect.log(`open training task ${candidate.id} (revision ${candidate.revision})`); + } + // run.nextTemporalBoundary tells your scheduler when evidence will expire + yield* ConsumerCheckpoint.advance(triples, { + consumer, + expectedPosition: from, + nextPosition: page.next, + meta: { actor: consumer }, + }); + } + + // 6. Answer audit questions later, from the same store. + const receipt = yield* triples.transactionByCommand("placement:create:1"); + const yesterday = Date.now() - 86_400_000; + const asOfYesterday = yield* triples.entity(EntityId.make("placement:1"), { + recordedAt: yesterday, + }); + return { release: release.snapshot.id, receipt, asOfYesterday }; +}); + +const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); +// Production: ConfigStore.layer.pipe(Layer.provideMerge(SqliteTriples.layer({ filename: "app.db" }))) +await Effect.runPromise(program.pipe(Effect.provide(AppLayer))); +``` + +The division of labour is deliberate: + +| Triplex owns | Your application owns | +| -------------------------------------------------------------- | ------------------------------------------------------- | +| Facts, history, and the bitemporal read basis | Domain vocabulary and entity identifiers | +| Atomic transactions, command receipts, and the ordered journal | HTTP/queue handlers that turn requests into commands | +| Config identity, releases, refs, and constraint enforcement | What a release contains and when `live` moves | +| Derivation candidates, provenance, and reconciliation diffs | Task, notification, or workflow lifecycle for each diff | +| Consumer checkpoints and temporal wakeup boundaries | Timer delivery, retries, and worker scheduling | + +Swapping storage changes only the provided layer. The +[compliance host demo](https://github.com/bjacobso/triplex/blob/main/examples/compliance-host) runs the complete version of this scenario, +including hypothetical previews, evidence expiry, and reopening work without a new source +transaction. + +## Typed configuration + +Configuration is a tree-shakeable part of the primary package: + +```ts +import { Attribute, EntityType } from "@triplex-build/triplex/config"; + +export const EmployerName = Attribute.text(":employer/name"); + +export const Employer = EntityType.make("Employer", { + name: Attribute.use(EmployerName, { + required: true, + unique: true, + }), +}); + +Employer.attributes.name.key; // ":employer/name" +Employer.attributes.name.assertion("Acme", { validFrom: Date.now() }); +``` + +The attribute owns its global identity and value type. The entity type owns how it uses that +attribute—requiredness, cardinality, uniqueness, and reference-target constraints. Those nodes can +be committed with forms, policies, routines, and other application-defined config into one +content-addressed release. + +`ConfigStore.layer` persists immutable config objects, revisions, release snapshots, and refs +through the same atomic `Triples` boundary. `ConfigRuntime` evaluates against a pinned release; +`EntityValidation` records queryable observations; `GraphConstraint` can opt a command into atomic +required/cardinality/uniqueness/reference-target enforcement. + +Read the [configuration guide](https://github.com/bjacobso/triplex/blob/main/docs/configuration.md) for releases, refs, validation, enforcement, +and the exact tamper-evidence guarantee. + +## Datalog and derived facts + +Raw Datalog is the query API. Patterns join by shared variables, and the portable contract includes +predicates, negation, disjunction, aggregation, ordering, snapshot-stable keyset pagination, and a +bounded binary recursive-rule form. Queries are schema-decoded and semantically validated before +either the KV or SQL engine runs. + +`@triplex-build/triplex/derivation` pins a structural query, result identity, type, configuration +snapshot, and dependency set into a content-addressed definition. Evaluation returns candidates +with source triple and assertion-transaction provenance. Pure reconciliation reports added, +removed, changed, and unchanged identities; the application decides whether that means opening a +task, cancelling work, or doing nothing. + +Materialized runs retain a source position and return `current`, `stale`, or `unmaterialized`. +Future `validFrom` and `validTo` edges produce a `nextTemporalBoundary`, allowing a host scheduler +to reopen expiry-driven work without polling the whole database. Read-only overlays preview +hypothetical facts without mutating the source store or journal. + +- [Datalog guide](https://github.com/bjacobso/triplex/blob/main/docs/datalog.md) +- [Derivations guide](https://github.com/bjacobso/triplex/blob/main/docs/derivations.md) +- [Compliance host demo](https://github.com/bjacobso/triplex/blob/main/examples/compliance-host) — config, facts, feed catch-up, reconciliation, + hypothetical planning, and expiry-driven reopening in one standalone scenario + +## Explorer dashboard + +Run the standalone Foldkit dashboard to inspect a real browser-local Triplex database: + +```sh +pnpm dashboard +``` + +Open . The standalone layer publishes a typed classroom configuration, +records students, teachers, a course, a quiz form, submissions, and a materialized grading +candidate. The dashboard itself is domain-independent: it discovers entity types, attributes, +references, configured derivations, journal entries, and configuration objects from the supplied +Triplex services. The entity browser lists discovered types and opens each as a reflected, +cursor-paginated table pinned to one temporal basis. The form preview renders nodes that explicitly +opt into the portable `triplex.form/v1` contract. The configuration inspector exposes every logical +object and immutable revision, canonical stored bodies, dependency closures, release ancestry, and +moving refs. Swap the demo layer for a host database without changing the inspector. + +The package lives at [`packages/dashboard`](https://github.com/bjacobso/triplex/blob/main/packages/dashboard). It uses one app-lifetime Effect +layer as a Foldkit resource; named commands resolve `Triples` and `ConfigStore` from that layer, +while `@foldkit/ui` supplies accessible controls and Tailwind supplies styling. + +## Configuration-derived HTTP API + +`@triplex-build/triplex/http` compiles persisted entity configuration into validated REST handlers and +OpenAPI 3.1 without retaining the TypeScript DSL declarations. Hosts provide `Triples`, +`ConfigStore`, an authorization layer, and their chosen HTTP server; KV and SQLite share the same +Fetch-level integration suite. Wire objects use full global keywords, writes are atomic and pinned +to one immutable config snapshot, and historical snapshot URLs are read-only by default. + +See the [HTTP API guide](https://github.com/bjacobso/triplex/blob/main/docs/http-api.md) and [standalone host](https://github.com/bjacobso/triplex/blob/main/examples/http-api). + +## Agent CLI + +The `triplex` bin exposes the same database and configuration services through Effect v4's +typed CLI modules. It is JSON-first, non-interactive, schema-validates external input, and supports +SQLite, PostgreSQL, and database-scoped PostgreSQL: + +```sh +pnpm turbo run build --filter=@triplex-build/triplex +triplex() { node --disable-warning=ExperimentalWarning packages/triplex/dist/bin.js "$@"; } +triplex --sqlite ./app.db describe +triplex --sqlite ./app.db entity types +triplex --sqlite ./app.db --pretty config object form quiz/bitemporal-facts +triplex --sqlite ./app.db query run --input query.json +``` + +Agents can inspect entity types and bitemporal facts, page entity audit history, run or explain raw +Datalog, inspect config objects/revisions/releases/refs and deploy impact, apply attributed atomic +transactions, move refs, and resolve command receipts. Write commands require an actor and unique +command ID. See the [CLI contract and examples](https://github.com/bjacobso/triplex/blob/main/packages/cli/README.md). + +## Temporal operations and audit + +Every successful application transaction receives a monotonically increasing commit position and +a queryable `_Transaction` envelope. It can retain: + +- actor and atomically unique command ID; +- correlation and causation IDs; +- the governing config snapshot; and +- complete typed assertion and retraction changes, including valid intervals and source + transactions. + +`triples.transactions({ after, limit })` is the durable at-least-once catch-up feed. +`ConsumerCheckpoint` persists a compare-and-retract-protected cursor. Best-effort change emitters +are wakeup hints, never a substitute for journal catch-up. + +For one entity's audit timeline, use the indexed journal view rather than filtering the global +feed: + +```ts +const timeline = Effect.gen(function* () { + const first = yield* triples.transactionsForEntity(workerId, { limit: 50 }); + const next = + first.nextBeforePosition === undefined + ? undefined + : yield* triples.transactionsForEntity(workerId, { + limit: 50, + snapshotPosition: first.snapshotPosition, + beforePosition: first.nextBeforePosition, + }); + + return { first, next }; +}); +``` + +Each page contains complete `TransactionRecord` values, newest first. The first page's commit +position pins later pages while concurrent commands continue to commit. + +`queryPage` cursors are versioned, schema-decoded, and bound to the canonical query, ordering, +temporal basis, and database scope. The first page pins an exact commit-position snapshot, so later +writes cannot move rows between pages. + +Read [Operational primitives](https://github.com/bjacobso/triplex/blob/main/docs/operational-primitives.md) for the transaction, concurrency, +pagination, projection, and host/runtime boundaries. + +## Storage backends + +“Supported” describes the current behavioral test boundary, not merely whether a package compiles. + +| Package | Surface | Status | +| ------------------------------------- | ----------------------------------- | ------------------------------------------------------------- | +| `@triplex-build/triplex` | `KvTriples.layer` | Supported in-memory baseline | +| `@triplex-build/triplex/sqlite` | `SqliteTriples.layer({ filename })` | Supported durable baseline | +| `@triplex-build/triplex/postgres` | `PgTriples.layer(config)` | Candidate; shared integration/conformance is currently opt-in | +| `@triplex-build/triplex/cloudflare` | `CloudflareTriples.layer(...)` | Experimental | +| `@triplex-build/triplex/foundationdb` | `FdbTriples.layer(config)` | Experimental | + +`@triplex-build/triplex/sql` contains shared migrations and SQL query execution rather than a database. +`@triplex-build/triplex/testkit` contains the behavioral conformance corpus used by adapters. +`@triplex-build/triplex/host` is an experimental subpath for portable tenant identity, +authorization, lifecycle, and protocol contracts. The deployable reference wiring is in +[`examples/tenant-host-cloudflare`](https://github.com/bjacobso/triplex/blob/main/examples/tenant-host-cloudflare). + +Switching the supported quick start to SQLite changes only the provided layer: + +```ts +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; + +const SqliteLive = SqliteTriples.layer({ filename: "app.db" }); +``` + +### PostgreSQL composition + +The standalone layer owns its pool and applies Triplex migrations as a convenience: + +```ts +import { PgTriples } from "@triplex-build/triplex/postgres"; + +const TriplexLive = PgTriples.layerFromUrl(process.env.DATABASE_URL!); +``` + +Production hosts that already own an Effect SQL client can provide that exact client to Triplex. +This path creates no pool and runs no migration: + +```ts +import { Effect, Layer } from "effect"; +import { SqlClient } from "effect/sql"; +import { EntityId, Triples, string } from "@triplex-build/triplex"; +import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex/postgres"; + +const HostSql = makePostgresqlLayerUnmigratedFromUrl(process.env.DATABASE_URL!); +const AppDatabase = PgTriples.layerFromSqlClient({ scope: "app/main" }).pipe( + Layer.provideMerge(HostSql), +); + +const command = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const triples = yield* Triples; + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`INSERT INTO host_records (id) VALUES ('record:1')`; + yield* triples.transact( + [ + { + op: "assert", + entityId: EntityId.make("record:1"), + attribute: ":record/status", + value: string("accepted"), + }, + ], + { commandId: "command:1", actor: "user:1" }, + ); + yield* sql`INSERT INTO host_outbox (id) VALUES ('delivery:1')`; + }), + ); +}).pipe(Effect.provide(AppDatabase)); +``` + +Effect SQL's fiber-local transaction connection is shared by the host statements and nested +`Triples.transact`, so facts, journal, command claim, commit position, host rows, and outbox commit +or roll back together. Nested transactions use Effect SQL savepoints. + +For a server-owned logical database mapping, validate the stored ID and create a schema-bound +runtime with `PgTriples.layerForDatabase(config, DatabaseId.make("customer-a"))`. Every connection +in that pool starts in the deterministic Triplex schema, and its scope is embedded in pagination +cursors. The schema and v1 migration must already exist; `layerForDatabaseMigrated` is the explicit +provisioning convenience for setup tools and tests. + +Triplex exports the ordered `migrations` and `runMigrations` from `@triplex-build/triplex/sql`. +Production hosts should run them from their own deployment process against the same database or +schema before constructing an unmigrated runtime. Triplex uses `triplex_schema_migrations`, not the +host application's migration table. + +## Package entrypoints + +```ts +import { Triples, KvTriples } from "@triplex-build/triplex"; +import { DatalogQuery } from "@triplex-build/triplex/datalog"; +import { SubscriptionManager } from "@triplex-build/triplex/subscriptions"; +import { ConfigStore, TypeExpr } from "@triplex-build/triplex/config"; +import { ContentId } from "@triplex-build/triplex/content"; +import * as Derivation from "@triplex-build/triplex/derivation"; +import { ConsumerCheckpoint } from "@triplex-build/triplex/operational"; +``` + +Application code should use these public surfaces. Adapter authors use the `runtime` subpath to +compose storage and query layers with a validated database scope and explicit capability providers. +`@triplex-build/triplex/internal` remains unstable implementation support for legacy adapter code. +Public exports resolve only to built `dist` files. + +## Documentation + +| Document | Purpose | +| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| [Getting started](https://github.com/bjacobso/triplex/blob/main/docs/getting-started.md) | First runnable program, output, and persistence | +| [Modeling a back-office domain](https://github.com/bjacobso/triplex/blob/main/docs/agents.md) | Prescriptive guide for agents: mapping, rules, anti-patterns | +| [Playground](https://github.com/bjacobso/triplex/blob/main/docs/playground.md) | Local in-browser database with selectable domains | +| [Core concepts](https://github.com/bjacobso/triplex/blob/main/docs/concepts.md) | Facts, time, configuration, derivation, and ownership | +| [Configuration walkthrough](https://github.com/bjacobso/triplex/blob/main/docs/configuration-versioning.md) | Publish, promote, pin, inspect, and roll back config | +| [CLI and dashboard](https://github.com/bjacobso/triplex/blob/main/docs/tools.md) | Operate and explore a source-checkout database | +| [Troubleshooting](https://github.com/bjacobso/triplex/blob/main/docs/troubleshooting.md) | Common failures and surprising semantics | +| [Current state](https://github.com/bjacobso/triplex/blob/main/docs/current-state.md) | Delivered behavior, maturity, limitations, releases | +| [Datalog](https://github.com/bjacobso/triplex/blob/main/docs/datalog.md) | Query syntax and backend-independent semantics | +| [Configuration reference](https://github.com/bjacobso/triplex/blob/main/docs/configuration.md) | Types, releases, refs, validation, and proofs | +| [Derivations](https://github.com/bjacobso/triplex/blob/main/docs/derivations.md) | Candidates, provenance, materialization, overlays | +| [Operational primitives](https://github.com/bjacobso/triplex/blob/main/docs/operational-primitives.md) | Transactions, journal, concurrency, projections | +| [Host integration](https://github.com/bjacobso/triplex/blob/main/docs/host-integration.md) | Runtime composition and data-migration guidance | +| [Custom runtimes](https://github.com/bjacobso/triplex/blob/main/docs/custom-runtimes.md) | Public runtime builders, capabilities, and backend contracts | +| [Architecture](https://github.com/bjacobso/triplex/blob/main/ARCHITECTURE.md) | Package boundaries and dependency direction | +| [Roadmap](https://github.com/bjacobso/triplex/blob/main/docs/roadmap.md) | Release gates and future work | +| [Source provenance](https://github.com/bjacobso/triplex/blob/main/docs/provenance.md) | Imported repository history | + +The focused configuration explorer remains under [`examples/config-explorer`](https://github.com/bjacobso/triplex/blob/main/examples/config-explorer), +and the full database dashboard lives under [`packages/dashboard`](https://github.com/bjacobso/triplex/blob/main/packages/dashboard). + +## Development + +```sh +pnpm install --frozen-lockfile +pnpm check +pnpm pack:check +``` + +PostgreSQL and FoundationDB integration suites are opt-in: + +```sh +pnpm test:postgres:integration +pnpm test:foundationdb:integration +``` + +See [CONTRIBUTING.md](https://github.com/bjacobso/triplex/blob/main/CONTRIBUTING.md) for the complete contribution contract. + +Part of the [WorldVM](https://worldvm.com) family of experiments. + +## License + +MIT © 2026 Ben Jacobson. diff --git a/packages/triplex/package.json b/packages/triplex/package.json new file mode 100644 index 00000000..437cd861 --- /dev/null +++ b/packages/triplex/package.json @@ -0,0 +1,217 @@ +{ + "name": "@triplex-build/triplex", + "version": "0.2.0", + "description": "The database that remembers why: an embedded, bitemporal fact database for TypeScript back-office systems, built on Effect.", + "keywords": [ + "configuration", + "content-addressed", + "database", + "datalog", + "effect", + "sha-256", + "triplestore" + ], + "homepage": "https://github.com/bjacobso/triplex#readme", + "bugs": { + "url": "https://github.com/bjacobso/triplex/issues" + }, + "license": "MIT", + "author": "Ben Jacobson", + "repository": { + "type": "git", + "url": "git+https://github.com/bjacobso/triplex.git", + "directory": "packages/triplex" + }, + "bin": { + "triplex": "./dist/bin.js" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/core/index.js", + "types": "./dist/core/index.d.ts", + "exports": { + ".": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js", + "default": "./dist/core/index.js" + }, + "./datalog": { + "types": "./dist/core/datalog/index.d.ts", + "import": "./dist/core/datalog/index.js", + "default": "./dist/core/datalog/index.js" + }, + "./live-query": { + "types": "./dist/core/live/index.d.ts", + "import": "./dist/core/live/index.js", + "default": "./dist/core/live/index.js" + }, + "./subscriptions": { + "types": "./dist/core/subscriptions/index.d.ts", + "import": "./dist/core/subscriptions/index.js", + "default": "./dist/core/subscriptions/index.js" + }, + "./config": { + "types": "./dist/core/config/index.d.ts", + "import": "./dist/core/config/index.js", + "default": "./dist/core/config/index.js" + }, + "./content": { + "types": "./dist/core/content/index.d.ts", + "import": "./dist/core/content/index.js", + "default": "./dist/core/content/index.js" + }, + "./derivation": { + "types": "./dist/core/derivation/index.d.ts", + "import": "./dist/core/derivation/index.js", + "default": "./dist/core/derivation/index.js" + }, + "./sensitive": { + "types": "./dist/core/sensitive/index.d.ts", + "import": "./dist/core/sensitive/index.js", + "default": "./dist/core/sensitive/index.js" + }, + "./operational": { + "types": "./dist/core/operational/index.d.ts", + "import": "./dist/core/operational/index.js", + "default": "./dist/core/operational/index.js" + }, + "./runtime": { + "types": "./dist/core/runtime/index.d.ts", + "import": "./dist/core/runtime/index.js", + "default": "./dist/core/runtime/index.js" + }, + "./internal": { + "types": "./dist/core/internal.d.ts", + "import": "./dist/core/internal.js", + "default": "./dist/core/internal.js" + }, + "./database": { + "types": "./dist/core/database/index.d.ts", + "import": "./dist/core/database/index.js", + "default": "./dist/core/database/index.js" + }, + "./why": { + "types": "./dist/core/why/index.d.ts", + "import": "./dist/core/why/index.js", + "default": "./dist/core/why/index.js" + }, + "./sql": { + "types": "./dist/sql/index.d.ts", + "import": "./dist/sql/index.js", + "default": "./dist/sql/index.js" + }, + "./sqlite": { + "types": "./dist/sqlite/index.d.ts", + "import": "./dist/sqlite/index.js", + "default": "./dist/sqlite/index.js" + }, + "./postgres": { + "types": "./dist/postgres/index.d.ts", + "import": "./dist/postgres/index.js", + "default": "./dist/postgres/index.js" + }, + "./cloudflare": { + "types": "./dist/cloudflare/index.d.ts", + "import": "./dist/cloudflare/index.js", + "default": "./dist/cloudflare/index.js" + }, + "./foundationdb": { + "types": "./dist/foundationdb/index.d.ts", + "import": "./dist/foundationdb/index.js", + "default": "./dist/foundationdb/index.js" + }, + "./host": { + "types": "./dist/host/index.d.ts", + "import": "./dist/host/index.js", + "default": "./dist/host/index.js" + }, + "./http": { + "types": "./dist/http/index.d.ts", + "import": "./dist/http/index.js", + "default": "./dist/http/index.js" + }, + "./testkit": { + "types": "./dist/testkit/index.d.ts", + "import": "./dist/testkit/index.js", + "default": "./dist/testkit/index.js" + }, + "./mcp": { + "types": "./dist/mcp/index.d.ts", + "import": "./dist/mcp/index.js", + "default": "./dist/mcp/index.js" + }, + "./testkit/postgres": { + "types": "./dist/testkit/postgres.d.ts", + "import": "./dist/testkit/postgres.js", + "default": "./dist/testkit/postgres.js" + }, + "./runner": { + "types": "./dist/core/runner/index.d.ts", + "import": "./dist/core/runner/index.js", + "default": "./dist/core/runner/index.js" + } + }, + "publishConfig": { + "access": "public", + "directory": "publish", + "linkDirectory": false + }, + "scripts": { + "build": "node ../../scripts/assemble-package.mjs", + "prepack": "pnpm --dir ../.. exec turbo run build --filter=@triplex-build/triplex", + "test": "node --test ../../scripts/check-single-package.test.mjs", + "typecheck": "node --test ../../scripts/check-single-package.test.mjs", + "clean": "rm -rf dist" + }, + "dependencies": { + "ulidx": "catalog:" + }, + "devDependencies": { + "@triplex-build/triplex-cli": "workspace:^", + "@triplex-build/triplex-cloudflare": "workspace:^", + "@triplex-build/triplex-dashboard": "workspace:^", + "@triplex-build/triplex-foundationdb": "workspace:^", + "@triplex-build/triplex-host": "workspace:^", + "@triplex-build/triplex-http": "workspace:^", + "@triplex-build/triplex-mcp": "workspace:^", + "@triplex-build/triplex-postgres": "workspace:^", + "@triplex-build/triplex-sql": "workspace:^", + "@triplex-build/triplex-sqlite": "workspace:^", + "@triplex-build/triplex-testkit": "workspace:^", + "@triplex-internal/core": "workspace:^", + "effect": "catalog:" + }, + "peerDependencies": { + "@effect/platform-node": "catalog:peers", + "@effect/platform-node-shared": "catalog:peers", + "@effect/sql-pg": "catalog:peers", + "@effect/sql-sqlite-node": "catalog:peers", + "effect": "catalog:peers", + "foundationdb": "^2.0.1" + }, + "peerDependenciesMeta": { + "@effect/sql-sqlite-node": { + "optional": true + }, + "@effect/sql-pg": { + "optional": true + }, + "foundationdb": { + "optional": true + }, + "@effect/platform-node": { + "optional": true + }, + "@effect/platform-node-shared": { + "optional": true + } + }, + "engines": { + "node": ">=22.19.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34436aec..263b113b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,19 @@ catalogs: vue: specifier: 3.5.40 version: 3.5.40 + peers: + '@effect/platform-node': + specifier: ^4.1.1 + version: 4.1.1 + '@effect/platform-node-shared': + specifier: ^4.1.1 + version: 4.1.1 + '@effect/sql-pg': + specifier: ^4.1.1 + version: 4.1.1 + '@effect/sql-sqlite-node': + specifier: ^4.1.1 + version: 4.1.1 overrides: '@alchemy.run/cloudflare@2.0.0-beta.84>@effect/sql-d1': 4.1.1 @@ -152,6 +165,9 @@ importers: '@tailwindcss/vite': specifier: 'catalog:' version: 4.3.3(vite@8.2.2(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.19.0)(yaml@2.9.1)) + '@triplex-build/triplex': + specifier: workspace:^ + version: link:packages/triplex '@types/node': specifier: 'catalog:' version: 25.9.1 @@ -178,7 +194,7 @@ importers: version: 4.3.3 tsdown: specifier: 'catalog:' - version: 0.21.7(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(typescript@6.0.2) + version: 0.21.7(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@6.0.2) tsx: specifier: 'catalog:' version: 4.19.0 @@ -197,12 +213,13 @@ importers: vue: specifier: 'catalog:' version: 3.5.40(typescript@6.0.2) + publishDirectory: packages/triplex/publish examples/compliance-host: dependencies: '@triplex-build/triplex': specifier: workspace:^ - version: link:../../packages/core + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -221,7 +238,7 @@ importers: dependencies: '@triplex-build/triplex': specifier: workspace:^ - version: link:../../packages/core + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -231,7 +248,7 @@ importers: version: 25.9.1 tsdown: specifier: 'catalog:' - version: 0.21.7(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@6.0.2) + version: 0.21.7(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(typescript@6.0.2) typescript: specifier: 'catalog:' version: 6.0.2 @@ -240,7 +257,7 @@ importers: dependencies: '@triplex-build/triplex': specifier: workspace:^ - version: link:../../packages/core + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -262,13 +279,7 @@ importers: version: 4.1.1(effect@4.1.1)(redis@6.2.1) '@triplex-build/triplex': specifier: workspace:^ - version: link:../../packages/core - '@triplex-build/triplex-http': - specifier: workspace:^ - version: link:../../packages/http - '@triplex-build/triplex-sqlite': - specifier: workspace:^ - version: link:../../packages/sqlite + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -290,13 +301,7 @@ importers: dependencies: '@triplex-build/triplex': specifier: workspace:^ - version: link:../../packages/core - '@triplex-build/triplex-cloudflare': - specifier: workspace:^ - version: link:../../packages/cloudflare - '@triplex-build/triplex-host': - specifier: workspace:^ - version: link:../../packages/host + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -328,9 +333,6 @@ importers: packages/cli: dependencies: - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-postgres': specifier: workspace:^ version: link:../postgres @@ -340,6 +342,9 @@ importers: '@triplex-build/triplex-sqlite': specifier: workspace:^ version: link:../sqlite + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@effect/platform-node': specifier: 'catalog:' @@ -362,12 +367,12 @@ importers: packages/cloudflare: dependencies: - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-sql': specifier: workspace:^ version: link:../sql + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@cloudflare/vitest-plugin': specifier: 'catalog:' @@ -418,15 +423,15 @@ importers: '@fontsource/ibm-plex-mono': specifier: 'catalog:' version: 5.3.0 - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-postgres': specifier: workspace:^ version: link:../postgres '@triplex-build/triplex-sqlite': specifier: workspace:^ version: link:../sqlite + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@effect/platform-browser': specifier: 'catalog:' @@ -470,7 +475,7 @@ importers: packages/foundationdb: dependencies: - '@triplex-build/triplex': + '@triplex-internal/core': specifier: workspace:^ version: link:../core foundationdb: @@ -498,7 +503,7 @@ importers: packages/host: dependencies: - '@triplex-build/triplex': + '@triplex-internal/core': specifier: workspace:^ version: link:../core devDependencies: @@ -517,7 +522,7 @@ importers: packages/http: dependencies: - '@triplex-build/triplex': + '@triplex-internal/core': specifier: workspace:^ version: link:../core ulidx: @@ -536,9 +541,6 @@ importers: packages/mcp: dependencies: - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-cli': specifier: workspace:^ version: link:../cli @@ -548,6 +550,9 @@ importers: '@triplex-build/triplex-sqlite': specifier: workspace:^ version: link:../sqlite + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@effect/platform-node': specifier: 'catalog:' @@ -567,12 +572,12 @@ importers: packages/postgres: dependencies: - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-sql': specifier: workspace:^ version: link:../sql + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@effect/sql-pg': specifier: 'catalog:' @@ -595,7 +600,7 @@ importers: packages/sql: dependencies: - '@triplex-build/triplex': + '@triplex-internal/core': specifier: workspace:^ version: link:../core devDependencies: @@ -611,12 +616,12 @@ importers: packages/sqlite: dependencies: - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-sql': specifier: workspace:^ version: link:../sql + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@effect/sql-sqlite-node': specifier: 'catalog:' @@ -636,12 +641,12 @@ importers: packages/testkit: dependencies: - '@triplex-build/triplex': - specifier: workspace:^ - version: link:../core '@triplex-build/triplex-sql': specifier: workspace:^ version: link:../sql + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core devDependencies: '@effect/sql-pg': specifier: 'catalog:' @@ -656,29 +661,76 @@ importers: specifier: 'catalog:' version: 5.0.4(@types/node@25.9.1)(vite@8.2.2(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.19.0)(yaml@2.9.1)) - test/integration: + packages/triplex: dependencies: + '@effect/platform-node': + specifier: catalog:peers + version: 4.1.1(effect@4.1.1)(redis@6.2.1) + '@effect/platform-node-shared': + specifier: catalog:peers + version: 4.1.1(effect@4.1.1) + '@effect/sql-pg': + specifier: catalog:peers + version: 4.1.1(effect@4.1.1) '@effect/sql-sqlite-node': - specifier: 'catalog:' + specifier: catalog:peers version: 4.1.1(effect@4.1.1) - '@triplex-build/triplex': + foundationdb: + specifier: ^2.0.1 + version: 2.0.1 + ulidx: + specifier: 'catalog:' + version: 2.4.1 + devDependencies: + '@triplex-build/triplex-cli': specifier: workspace:^ - version: link:../../packages/core + version: link:../cli '@triplex-build/triplex-cloudflare': specifier: workspace:^ - version: link:../../packages/cloudflare + version: link:../cloudflare + '@triplex-build/triplex-dashboard': + specifier: workspace:^ + version: link:../dashboard + '@triplex-build/triplex-foundationdb': + specifier: workspace:^ + version: link:../foundationdb + '@triplex-build/triplex-host': + specifier: workspace:^ + version: link:../host '@triplex-build/triplex-http': specifier: workspace:^ - version: link:../../packages/http + version: link:../http + '@triplex-build/triplex-mcp': + specifier: workspace:^ + version: link:../mcp + '@triplex-build/triplex-postgres': + specifier: workspace:^ + version: link:../postgres '@triplex-build/triplex-sql': specifier: workspace:^ - version: link:../../packages/sql + version: link:../sql '@triplex-build/triplex-sqlite': specifier: workspace:^ - version: link:../../packages/sqlite + version: link:../sqlite '@triplex-build/triplex-testkit': specifier: workspace:^ - version: link:../../packages/testkit + version: link:../testkit + '@triplex-internal/core': + specifier: workspace:^ + version: link:../core + effect: + specifier: 'catalog:' + version: 4.1.1 + publishDirectory: publish + + test/integration: + dependencies: + '@effect/sql-sqlite-node': + specifier: 'catalog:' + version: 4.1.1(effect@4.1.1) + '@triplex-build/triplex': + specifier: workspace:^ + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -703,19 +755,7 @@ importers: version: 4.1.1(effect@4.1.1) '@triplex-build/triplex': specifier: workspace:^ - version: link:../../packages/core - '@triplex-build/triplex-foundationdb': - specifier: workspace:^ - version: link:../../packages/foundationdb - '@triplex-build/triplex-postgres': - specifier: workspace:^ - version: link:../../packages/postgres - '@triplex-build/triplex-sql': - specifier: workspace:^ - version: link:../../packages/sql - '@triplex-build/triplex-sqlite': - specifier: workspace:^ - version: link:../../packages/sqlite + version: link:../../packages/triplex effect: specifier: 'catalog:' version: 4.1.1 @@ -6869,6 +6909,24 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@6.0.2): + dependencies: + '@babel/generator': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + ast-kit: 3.0.0 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.14.1 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + optionalDependencies: + typescript: 6.0.2 + transitivePeerDependencies: + - oxc-resolver + rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.122.0 @@ -7274,7 +7332,7 @@ snapshots: obug: 2.1.4 picomatch: 4.0.5 rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@6.0.2) + rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@6.0.2) semver: 7.8.5 tinyexec: 1.3.0 tinyglobby: 0.2.17 diff --git a/scripts/assemble-package.mjs b/scripts/assemble-package.mjs new file mode 100644 index 00000000..30e18e32 --- /dev/null +++ b/scripts/assemble-package.mjs @@ -0,0 +1,118 @@ +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const output = join(root, "packages/triplex/dist"); +const modules = [ + "core", + "sql", + "sqlite", + "postgres", + "cloudflare", + "foundationdb", + "host", + "http", + "testkit", + "cli", + "mcp", +]; +const targets = new Map(); +for (const name of modules) { + const manifest = JSON.parse(readFileSync(join(root, `packages/${name}/package.json`), "utf8")); + for (const [subpath, entry] of Object.entries(manifest.exports)) { + targets.set( + manifest.name + (subpath === "." ? "" : subpath.slice(1)), + `${name}/${entry.import.replace("./dist/", "")}`, + ); + } +} +rmSync(output, { recursive: true, force: true }); +for (const name of modules) + cpSync(join(root, `packages/${name}/dist`), join(output, name), { recursive: true }); +// The server uses only its API contracts and Node services, never the UI runtime. +for (const name of ["server", "api", "data", "model", "server-security"]) { + cpSync( + join(root, `packages/dashboard/dist-server/${name}.js`), + join(output, `dashboard-server/${name}.js`), + ); +} +cpSync(join(root, "packages/dashboard/dist"), join(output, "dashboard"), { recursive: true }); +const rewrite = (directory) => { + for (const file of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, file.name); + if (file.isDirectory()) { + rewrite(path); + continue; + } + if (!/\.(?:js|ts)$/.test(file.name)) continue; + const text = readFileSync(path, "utf8").replace( + /(["'])(@triplex-(?:build|internal)\/[^"']+)\1/g, + (match, quote, specifier) => { + const target = targets.get(specifier); + if (!target && /^@triplex-build\/triplex(?:\/|$)/.test(specifier)) return match; + if (!target) throw new Error(`Unknown workspace import ${specifier} in ${path}`); + const rel = relative(dirname(path), join(output, target)); + return `${quote}${rel.startsWith(".") ? rel : `./${rel}`}${quote}`; + }, + ); + writeFileSync(path, text); + } +}; +rewrite(output); +const manifest = JSON.parse(readFileSync(join(root, "packages/triplex/package.json"), "utf8")); +const help = execFileSync(process.execPath, [join(root, "packages/cli/dist/cli.js"), "--help"], { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, +}); +writeFileSync( + join(output, "bin.js"), + `#!/usr/bin/env node +const command = process.argv[2]; +if (command === "--help" || command === "-h") { + console.log("Additional commands: mcp (stdio MCP server), dashboard (local explorer)"); + process.stdout.write(${JSON.stringify(help)}); +} else if (command === "mcp" || command === "dashboard") { + process.argv.splice(2, 1); + await import(command === "mcp" ? "./mcp/bin.js" : "./dashboard-server/server.js"); +} else if (command === "--version") { + console.log(${JSON.stringify(manifest.version)}); +} else { + await import("./cli/cli.js"); +} +`, +); +chmodSync(join(output, "bin.js"), 0o755); +for (const entry of Object.values(manifest.exports)) { + for (const target of [entry.import, entry.types]) { + if (!existsSync(resolve(root, "packages/triplex", target))) + throw new Error(`Missing export ${target}`); + } +} +console.log("Assembled one Triplex package with isolated subpaths and dashboard assets."); + +// Stage only the consumer manifest. Build-only workspace dependencies must never +// become registry dependencies or leak into the packed manifest. +const publish = join(root, "packages/triplex/publish"); +rmSync(publish, { recursive: true, force: true }); +cpSync(output, join(publish, "dist"), { recursive: true }); +for (const file of ["README.md", "LICENSE"]) + cpSync(join(root, "packages/triplex", file), join(publish, file)); +const { + devDependencies: _buildInputs, + scripts: _scripts, + publishConfig: _publishConfig, + ...published +} = manifest; +writeFileSync( + join(publish, "package.json"), + JSON.stringify({ ...published, publishConfig: { access: "public" } }, null, 2) + "\n", +); diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs index 1b68a3b2..1ec85e9c 100644 --- a/scripts/check-packages.mjs +++ b/scripts/check-packages.mjs @@ -1,460 +1,270 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import semver from "semver"; +import { createServer } from "node:net"; const root = resolve(import.meta.dirname, ".."); -const packageNames = [ - "@triplex-build/triplex", - "@triplex-build/triplex-sql", - "@triplex-build/triplex-sqlite", - "@triplex-build/triplex-postgres", - "@triplex-build/triplex-cloudflare", - "@triplex-build/triplex-host", - "@triplex-build/triplex-foundationdb", - "@triplex-build/triplex-testkit", - "@triplex-build/triplex-cli", - "@triplex-build/triplex-http", +const work = mkdtempSync(join(tmpdir(), "triplex-pack-")); +const tarDir = join(work, "tarballs"); +const consumer = join(work, "consumer"); +mkdirSync(tarDir); +mkdirSync(consumer); +const run = (cmd, args, cwd = root) => + execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: "pipe" }); +const write = (file, body) => writeFileSync(join(consumer, file), body); +const install = () => + run( + "npm", + ["install", "--omit=optional", "--ignore-scripts", "--no-audit", "--no-fund"], + consumer, + ); +const optionalDrivers = [ + "@effect/sql-sqlite-node", + "@effect/sql-pg", + "foundationdb", + "@effect/platform-node", + "@effect/platform-node-shared", ]; -// The private dashboard ships a Vite app rather than the library file layout below. -const manifestOnlyPackageNames = ["@triplex-build/triplex-dashboard"]; -const isRuntimePeer = (name) => - name === "effect" || - name === "foldkit" || - name.startsWith("@effect/") || - name.startsWith("@foldkit/"); - -const checkRuntimePeers = (manifest) => { - for (const name of Object.keys(manifest.dependencies ?? {})) { - if (isRuntimePeer(name)) { - throw new Error(`${manifest.name} must declare ${name} as a peer dependency`); - } - } - for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) { - if (!isRuntimePeer(name)) continue; - const testedVersion = manifest.devDependencies?.[name]; - if (!testedVersion || !semver.valid(testedVersion)) { - throw new Error(`${manifest.name} must pin a development version of ${name}`); - } - const normalizedRange = semver.validRange(range); - if (!normalizedRange || semver.valid(normalizedRange)) { - throw new Error(`${manifest.name} must declare a range for peer ${name}, received ${range}`); - } - if (!semver.satisfies(testedVersion, range)) { - throw new Error(`${manifest.name} peer ${name}@${range} excludes tested ${testedVersion}`); - } - } +const children = []; +const start = (cli, args) => { + const child = spawn(cli, args, { cwd: consumer, stdio: ["pipe", "pipe", "pipe"] }); + children.push(child); + return child; +}; +const stop = async (child) => { + if (child.exitCode !== null) return; + const exited = new Promise((done) => child.once("exit", done)); + child.kill(); + await exited; }; -const publishPackageNames = new Set([ - "@triplex-build/triplex", - "@triplex-build/triplex-sql", - "@triplex-build/triplex-sqlite", - "@triplex-build/triplex-postgres", - "@triplex-build/triplex-testkit", - "@triplex-build/triplex-cli", - "@triplex-build/triplex-http", -]); -const heldPackageNames = new Set([ - "@triplex-build/triplex-cloudflare", - "@triplex-build/triplex-host", - "@triplex-build/triplex-foundationdb", -]); - -const workDir = mkdtempSync(join(tmpdir(), "triplex-pack-")); -const tarDir = join(workDir, "tarballs"); -const consumerDir = join(workDir, "consumer"); -const packedManifests = []; -mkdirSync(tarDir); -mkdirSync(consumerDir); - -const run = (command, args, cwd = root) => - execFileSync(command, args, { cwd, encoding: "utf8", stdio: "pipe" }); try { - for (const packageName of [...packageNames, ...manifestOnlyPackageNames]) { - run("pnpm", ["--filter", packageName, "pack", "--pack-destination", tarDir]); + run("node", ["--test", "scripts/check-single-package.test.mjs"]); + run("pnpm", ["pack", "--pack-destination", tarDir]); + const tarballs = readdirSync(tarDir).filter((file) => file.endsWith(".tgz")); + assert.equal(tarballs.length, 1); + const tarball = join(tarDir, tarballs[0]); + const manifest = JSON.parse(run("tar", ["-xOzf", tarball, "package/package.json"])); + const entries = run("tar", ["-tzf", tarball]).trim().split("\n"); + assert.equal(manifest.name, "@triplex-build/triplex"); + assert.ok(!manifest.private); + assert.deepEqual(Object.keys(manifest.dependencies), ["ulidx"]); + for (const [name, version] of Object.entries({ + ...manifest.dependencies, + ...manifest.peerDependencies, + })) { + assert.ok(!name.startsWith("@triplex-") && !version.startsWith("workspace:"), name); } - - const tarballs = readdirSync(tarDir) - .filter((file) => file.endsWith(".tgz")) - .map((file) => join(tarDir, file)); - - const expectedTarballs = packageNames.length + manifestOnlyPackageNames.length; - if (tarballs.length !== expectedTarballs) { - throw new Error(`Expected ${expectedTarballs} tarballs, found ${tarballs.length}`); + for (const section of [ + manifest.dependencies, + manifest.devDependencies, + manifest.peerDependencies, + ]) { + for (const range of Object.values(section ?? {})) + assert.ok(!range.startsWith("workspace:"), range); } - - for (const tarball of tarballs) { - const manifest = JSON.parse(run("tar", ["-xOzf", tarball, "package/package.json"])); - checkRuntimePeers(manifest); - if (manifest.peerDependencies?.effect === undefined) { - throw new Error(`${manifest.name} must declare Effect as a peer dependency`); - } - if (manifestOnlyPackageNames.includes(manifest.name)) continue; - const entries = run("tar", ["-tzf", tarball]).trim().split("\n").filter(Boolean); - - for (const required of [ - "package/package.json", - "package/README.md", - "package/LICENSE", - "package/dist/index.js", - "package/dist/index.d.ts", - ]) { - if (!entries.includes(required)) { - throw new Error(`${tarball} is missing ${required}`); - } - } - - const unexpected = entries.filter( - (entry) => - entry !== "package/package.json" && - entry !== "package/README.md" && - entry !== "package/LICENSE" && - !entry.startsWith("package/dist/"), + for (const name of optionalDrivers) + assert.equal(manifest.peerDependenciesMeta[name].optional, true); + for (const file of entries) { + assert.ok( + ["package/package.json", "package/README.md", "package/LICENSE"].includes(file) || + file.startsWith("package/dist/"), + file, ); - if (unexpected.length > 0) { - throw new Error(`${tarball} contains unexpected files:\n${unexpected.join("\n")}`); - } - - packedManifests.push(manifest); - if (publishPackageNames.has(manifest.name)) { - if (manifest.private === true) { - throw new Error(`${manifest.name} is part of the release set but is private`); - } - if (manifest.publishConfig?.access !== "public") { - throw new Error(`${manifest.name} must publish with public access`); - } - } - if (heldPackageNames.has(manifest.name) && manifest.private !== true) { - throw new Error(`${manifest.name} must remain private until its backend is promoted`); - } - if (publishPackageNames.has(manifest.name)) { - for (const dependencyName of Object.keys({ - ...manifest.dependencies, - ...manifest.optionalDependencies, - ...manifest.peerDependencies, - })) { - if (heldPackageNames.has(dependencyName)) { - throw new Error(`${manifest.name} must not depend on held package ${dependencyName}`); - } - } - } } - - const dependencies = Object.fromEntries( - packageNames.map((name) => { - const manifest = packedManifests.find((candidate) => candidate.name === name); - if (!manifest) throw new Error(`Could not locate packed manifest for ${name}`); - const prefix = `${name.replace(/^@/, "").replaceAll("/", "-")}-${manifest.version}.tgz`; - const tarball = tarballs.find((file) => file.endsWith(prefix)); - if (!tarball) throw new Error(`Could not locate tarball for ${name}`); - return [name, `file:${tarball}`]; - }), - ); - const runtimePeerVersions = Object.fromEntries( - packedManifests.flatMap((manifest) => - Object.keys(manifest.peerDependencies) - .filter(isRuntimePeer) - .map((name) => [name, manifest.devDependencies[name]]), - ), - ); - const effectPeerVersions = new Set( - packedManifests.map((manifest) => manifest.peerDependencies.effect), - ); - if (effectPeerVersions.size !== 1) { - throw new Error(`Effect peer versions are not aligned: ${[...effectPeerVersions].join(", ")}`); + assert.ok(entries.includes("package/dist/dashboard/index.html")); + for (const entry of Object.values(manifest.exports)) { + for (const target of [entry.types, entry.import]) + assert.ok(entries.includes(`package/${target.slice(2)}`), target); } - - writeFileSync( - join(consumerDir, "package.json"), - JSON.stringify( - { - name: "triplex-package-consumer", - private: true, - type: "module", - dependencies: { - ...dependencies, - ...runtimePeerVersions, - typescript: "6.0.2", - }, - }, - null, - 2, - ), - ); - - writeFileSync( - join(consumerDir, "consumer.ts"), - `import { DatabaseId, DatalogValidationError, EntityId, KvTriples, TransactionId, TripleId, type TripleInput, type TriplesService } from "@triplex-build/triplex"; -import { validateDatalogQuery, type DatalogQuery } from "@triplex-build/triplex/datalog"; -import { Attribute, ConfigRuntime, ConfigStore, EntityType, EntityValidation, Evaluate, GraphConstraint, TypeExpr } from "@triplex-build/triplex/config"; -import { Runtime, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; -import * as Derivation from "@triplex-build/triplex/derivation"; -import { LiveQuery } from "@triplex-build/triplex/live-query"; -void LiveQuery; -import { Why } from "@triplex-build/triplex/why"; -import * as Database from "@triplex-build/triplex/database"; -import { Inbox, Outbox, OperationalEntryConflictError } from "@triplex-build/triplex/operational"; -import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; -import * as Cloudflare from "@triplex-build/triplex-cloudflare"; -import * as FoundationDb from "@triplex-build/triplex-foundationdb"; -import * as Host from "@triplex-build/triplex-host"; -import * as Postgres from "@triplex-build/triplex-postgres"; -import { PgTriples } from "@triplex-build/triplex-postgres"; -import * as Sql from "@triplex-build/triplex-sql"; -import { makeSqliteLayer } from "@triplex-build/triplex-sqlite"; -import * as Testkit from "@triplex-build/triplex-testkit"; -import { postgresSchemaLayer } from "@triplex-build/triplex-testkit/postgres"; -import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; - -const triple: TripleInput = { - entityId: EntityId.make("person:alice"), - attribute: ":person/name", - value: { type: "string", value: "Alice" }, -}; -const transactionId = TransactionId.make("_tx/00000000000000000000000000"); -const tripleId = TripleId.make("00000000000000000000000000"); -const transactionEntity: EntityId = transactionId; -declare const triplesService: TriplesService; -const filteredFeed = triplesService.transactions({entityIdPrefix: "home:", after: 0, limit: 10}); -const explainedLayer = Why.layer; -const retainedAssertion = triplesService.assertion(tripleId); -const timeline = triplesService.transactionsForEntity(triple.entityId, { limit: 20 }); -const historicalEntity = triplesService.entity(triple.entityId, { recordedPosition: 0 }); -const hostTransaction = PgTriples.withTransaction(triplesService.transact([])); -const intent = Outbox.append({scope: triple.entityId, idempotencyKey: "command:1", payload: {intent: true}}); -const incoming = Inbox.append({scope: triple.entityId, idempotencyKey: "message:1", payload: null}); -const entryTransaction = triplesService.transact([intent, incoming, Outbox.acknowledge(Outbox.id(triple.entityId, "command:1"))]); -const pendingEntries = Outbox.pending(triplesService, {scope: triple.entityId, limit: 10}); -const guardedWrite = triplesService.transact([], {preconditions: [ - {_tag: "AttributeValue", entityId: triple.entityId, attribute: triple.attribute, value: triple.value}, - {_tag: "AttributeAbsent", entityId: triple.entityId, attribute: ":missing"}, - {_tag: "EntityAbsent", entityId: EntityId.make("new:entity")}, -]}); -const kvLiveBackfill = KvTriples.migrateLiveFacts({batchSize: 500}); -const entryHistory = Outbox.get(triplesService, Outbox.id(triple.entityId, "command:1")); -const operationalConflict = new OperationalEntryConflictError({entryId: Outbox.id(triple.entityId, "command:1"), kind: "outbox", scope: triple.entityId, idempotencyKey: "command:1", message: "conflicting payload"}); - -const historicalQuery = triplesService.queryAll({ find: ["?value"], where: [[triple.entityId, ":person/name", "?value"]] }, { basis: { recordedPosition: 0, validAt: 1 } }); -const ambientPostgres = PgTriples.layerFromSqlClient({ scope: "host/database" }); -const postgresFixture = postgresSchemaLayer({ - url: "postgresql://localhost/test", - triples: PgTriples.layerFromSqlClient, -}); -const schemaError = new Postgres.SchemaVersionError({ expectedVersion: 3, actualVersion: 2 }); -const scopedPostgres = PgTriples.layerForDatabase( - { database: "application" }, - DatabaseId.make("tenant-a"), -); -const query: DatalogQuery = { - find: ["?name"], - where: [["?person", ":person/name", "?name"]], -}; -const EmployerName = Attribute.text(":employer/name"); -const Employer = EntityType.make("Employer", { - name: Attribute.use(EmployerName, { required: true }), -}); -const nameAssertion = Employer.attributes.name.assertion("Acme"); -const databaseLayer = Database.layer({schema: {types: [Employer]}}); -void databaseLayer; -const sensitiveLayer = Sensitive.layer({ - database: "tenant-a", - attributes: [Attribute.sensitive(Attribute.text(":employee/ssn"))], -}); -void triple; -void sensitiveLayer; -void ValueCodec.aesGcm; -void KeyProvider.test; -void transactionId; -void tripleId; -void transactionEntity; -void timeline; -void historicalEntity; -void hostTransaction; -void historicalQuery; -void ambientPostgres; -void postgresFixture; -void schemaError; -void scopedPostgres; -void query; -void validateDatalogQuery; -void DatalogValidationError; -void nameAssertion; -void ConfigStore; -void ConfigRuntime; -void Runtime; -void DatabaseScope; -void Capabilities; -void EntityValidation; -void Evaluate; -void GraphConstraint; -void TypeExpr; -void Derivation; -void Derivation.Materialization; -void Derivation.Overlay; -void makeSqliteLayer; -void Cloudflare; -void FoundationDb; -void Host; -void Postgres; -void Sql; -void Testkit; -void EntityHttp; -void HttpAuthorizationAllowAll; -`, - ); - - writeFileSync( - join(consumerDir, "tsconfig.json"), - JSON.stringify( - { - compilerOptions: { - target: "ES2022", - module: "ESNext", - moduleResolution: "bundler", - strict: true, - noEmit: true, - skipLibCheck: true, - }, - include: ["consumer.ts"], - }, - null, - 2, - ), - ); - - writeFileSync( - join(consumerDir, "smoke.mjs"), - `import { Effect } from "effect"; -import { CanonicalJson } from "@triplex-build/triplex/content"; - -if (CanonicalJson.encodeOrThrow({ z: -0, a: [undefined, 1] }) !== JSON.stringify({ a: [null, 1], z: 0 })) { - throw new Error("Public canonical JSON encoding is unavailable or inconsistent"); -} -import { EntityId, Triples, string } from "@triplex-build/triplex"; -import { Runtime, DatabaseScope } from "@triplex-build/triplex/runtime"; -import * as Derivation from "@triplex-build/triplex/derivation"; -import { LiveQuery } from "@triplex-build/triplex/live-query"; -void LiveQuery; -import { Why } from "@triplex-build/triplex/why"; -import * as Database from "@triplex-build/triplex/database"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; - -if (typeof Database.layer !== "function" || typeof Database.Database !== "function") { - throw new Error("Public database entry point is unavailable"); -} - -if (typeof Runtime.define !== "function" || typeof DatabaseScope.test("packed") !== "string") { - throw new Error("Public runtime entry point is unavailable"); -} - -const result = await Effect.runPromise( - Effect.gen(function* () { - const triples = yield* Triples; - const alice = EntityId.make("person:alice"); - const bob = EntityId.make("person:bob"); - yield* triples.assert({ - entityId: alice, - attribute: ":person/name", - value: string("Alice"), - validFrom: 0, - validTo: 5, - }); - const query = { - find: ["?name"], - where: [["?person", ":person/name", "?name"]], - }; - const definition = yield* Derivation.make({ - name: "person.names", - query, - identity: ["?name"], - configSnapshot: "config:consumer-smoke", - }); - yield* Derivation.Materialization.materialize(triples, definition, { - basis: { validAt: 1 }, - }); - const preview = yield* Derivation.Overlay.evaluateOverlay(triples, definition, { - basis: { validAt: 1 }, - overlay: { - assertions: [{ - entityId: bob, - attribute: ":person/name", - value: string("Bob"), - }], - }, - }); - return { - query: yield* triples.query(query, { basis: { validAt: 1 } }), - preview, - materialization: yield* Derivation.Materialization.current(triples, definition, { - basis: { validAt: 1 }, - }), - }; - }).pipe(Effect.provide(SqliteTriples.layerMemory)), -); - -if ( - result.query.results.length !== 1 || - result.query.results[0]?.["?name"] !== "Alice" || - result.preview.candidates.length !== 2 || - result.preview.nextTemporalBoundary !== 5 || - !result.preview.candidates.some((candidate) => - candidate.sources.some((source) => source.hypothetical === true) - ) || - result.materialization.status !== "current" || - result.materialization.nextTemporalBoundary !== 5 || - result.materialization.candidates.length !== 1 -) { - throw new Error("Unexpected packaged SQLite/Datalog result: " + JSON.stringify(result)); + const dependencies = { "@triplex-build/triplex": `file:${tarball}`, effect: "4.1.1" }; + write("package.json", JSON.stringify({ private: true, type: "module", dependencies })); + install(); + write( + "isolation.mjs", + `import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +for (const name of ${JSON.stringify(optionalDrivers)}) assert.throws(() => require.resolve(name)); +for (const name of ${JSON.stringify(Object.keys(manifest.exports).filter((key) => !["./sqlite", "./postgres", "./foundationdb", "./testkit/postgres"].includes(key)))}) { + await import('@triplex-build/triplex' + (name === '.' ? '' : name.slice(1))); } `, ); - - // Skip FoundationDB's native addon install so the type-level check is portable. - // Effect 4 stable's SQLite driver uses node:sqlite for the runtime smoke test. + run("node", ["isolation.mjs"], consumer); + console.log("PASS: root and every driver-free subpath import with no drivers installed."); + // Browser-safe core entrypoints must never reach Node or backend imports. + const visited = new Set(); + const visit = (file) => { + if (visited.has(file)) return; + visited.add(file); + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(/(?:from\s*|import\s*\(|import\s*)["']([^"']+)["']/g)) { + const spec = match[1]; + assert.ok( + !spec.startsWith("node:") && + !spec.startsWith("@effect/platform-node") && + !spec.startsWith("@effect/sql-"), + `${file}: ${spec}`, + ); + if (spec.startsWith(".")) { + const target = resolve(file, "..", spec); + assert.ok(target.includes("/dist/core/"), target); + visit(target); + } + } + }; + for (const entry of Object.values(manifest.exports)) { + if (entry.import.startsWith("./dist/core/")) + visit(join(consumer, "node_modules/@triplex-build/triplex", entry.import)); + } + console.log("PASS: browser-safe core module graph contains no Node or backend imports."); + const cli = join(consumer, "node_modules/.bin/triplex"); + assert.ok(run(cli, ["--help"], consumer).includes("mcp")); + assert.equal(run(cli, ["--version"], consumer).trim(), manifest.version); + // Library consumers need only Effect and the selected backend driver. + dependencies["@effect/sql-sqlite-node"] = "4.1.1"; + write("package.json", JSON.stringify({ private: true, type: "module", dependencies })); + install(); + write("smoke.mjs", readFileSync(join(root, "scripts/fixtures/package-smoke.mjs"), "utf8")); + run("node", ["smoke.mjs"], consumer); + console.log("PASS: SQLite/Datalog/derivation behavior with only Effect and the SQLite driver."); + // Node services are optional peers used only for executable database commands. + dependencies["@effect/platform-node"] = "4.1.1"; + dependencies["@effect/platform-node-shared"] = "4.1.1"; + dependencies["@effect/sql-sqlite-node"] = "4.1.1"; + dependencies.typescript = "6.0.2"; + write("package.json", JSON.stringify({ private: true, type: "module", dependencies })); + install(); + assert.ok(run(cli, ["--help"], consumer).includes("mcp")); + assert.equal(run(cli, ["--version"], consumer).trim(), manifest.version); + write( + "consumer.ts", + readFileSync(join(root, "scripts/fixtures/package-consumer.ts"), "utf8") + + "\n" + + Object.keys(manifest.exports) + .map( + (key, i) => + `import * as m${i} from '@triplex-build/triplex${key === "." ? "" : key.slice(1)}'; void m${i};`, + ) + .join("\n"), + ); run( - "npm", - ["install", "--strict-peer-deps", "--ignore-scripts", "--no-audit", "--no-fund"], - consumerDir, + join(consumer, "node_modules/.bin/tsc"), + [ + "--noEmit", + "--strict", + "--skipLibCheck", + "--module", + "nodenext", + "--target", + "esnext", + "consumer.ts", + ], + consumer, ); - const effectInstallPaths = run("npm", ["ls", "effect", "--all", "--parseable"], consumerDir) - .trim() - .split("\n") - .filter(Boolean); - if (effectInstallPaths.length !== 1) { - throw new Error(`Expected one shared Effect installation, found ${effectInstallPaths.length}`); - } - run("npx", ["tsc", "--project", "tsconfig.json"], consumerDir); - run("node", ["smoke.mjs"], consumerDir); - - const cliManifest = packedManifests.find( - (manifest) => manifest.name === "@triplex-build/triplex-cli", + console.log("PASS: all subpath declarations compile in the clean consumer."); + const described = JSON.parse( + run(cli, ["--sqlite", join(consumer, "smoke.sqlite"), "describe"], consumer), ); - if (!cliManifest) throw new Error("Could not locate the packed CLI manifest"); - if (cliManifest.bin?.triplex !== "./dist/cli.js") { - throw new Error("The packed CLI must expose dist/cli.js as the triplex executable"); - } - const cli = join(consumerDir, "node_modules", ".bin", "triplex"); - run(cli, ["--help"], consumerDir); - const version = run(cli, ["--version"], consumerDir); - if (!version.includes(cliManifest.version)) { - throw new Error( - `Packed CLI version mismatch: expected ${cliManifest.version}, received ${version.trim()}`, + assert.equal(described.ok, true); + const mcp = start(cli, ["mcp", "--sqlite", join(consumer, "mcp.sqlite")]); + let stderr = ""; + mcp.stderr.on("data", (data) => { + stderr += data; + }); + const initialized = new Promise((done, reject) => { + let output = ""; + const timeout = setTimeout( + () => reject(new Error(`MCP initialize timed out: ${output}\n${stderr}`)), + 20000, ); - } - const described = JSON.parse( - run(cli, ["--sqlite", join(consumerDir, "agent.sqlite"), "describe"], consumerDir), + mcp.once("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`MCP exited ${code}: ${stderr}`)); + }); + mcp.stdout.on("data", (data) => { + output += data; + const lines = output.split("\n"); + output = lines.pop(); + try { + for (const line of lines) { + const message = JSON.parse(line); + if (message.id === 1) { + clearTimeout(timeout); + assert.ok(message.result?.serverInfo, JSON.stringify(message)); + done(); + } + } + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + }); + mcp.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "pack-check", version: "1" }, + }, + }) + "\n", ); - if (described.ok !== true || described.command !== "describe") { - throw new Error(`Unexpected packed CLI description: ${JSON.stringify(described)}`); + await initialized; + await stop(mcp); + console.log("PASS: triplex --help, --version, SQLite describe, and mcp initialize."); + const port = await new Promise((done) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + server.close(() => done(port)); + }); + }); + const dashboard = start(cli, [ + "dashboard", + "--sqlite", + join(consumer, "dashboard.sqlite"), + "--port", + String(port), + ]); + let dashboardError = ""; + dashboard.stderr.on("data", (data) => { + dashboardError += data; + }); + let response; + for (let i = 0; i < 100; i++) { + if (dashboard.exitCode !== null) throw new Error(`Dashboard exited: ${dashboardError}`); + try { + response = await fetch(`http://127.0.0.1:${port}/`); + break; + } catch { + await new Promise((done) => setTimeout(done, 100)); + } } - - process.stdout.write( - `Verified ${tarballs.length} package tarballs and a clean consumer install.\n`, + assert.equal(response?.status, 200, dashboardError); + const html = await response.text(); + const assets = [...html.matchAll(/(?:src|href)="([^"#]+\.(?:js|css))"/g)].map( + (match) => match[1], ); - rmSync(workDir, { recursive: true, force: true }); + assert.ok(assets.length > 0); + for (const asset of assets) + assert.equal((await fetch(new URL(asset, `http://127.0.0.1:${port}`))).status, 200); + assert.equal((await fetch(`http://127.0.0.1:${port}/api/health`)).status, 200); + await stop(dashboard); + console.log("PASS: triplex dashboard serves its HTML, JavaScript, CSS and health endpoint."); + console.log("Verified one package tarball and a clean consumer install."); + rmSync(work, { recursive: true, force: true }); } catch (error) { - process.stderr.write(`Package verification artifacts retained at ${workDir}\n`); + console.error(`Package verification artifacts retained at ${work}`); + if (error.stdout) console.error(error.stdout.toString()); + if (error.stderr) console.error(error.stderr.toString()); throw error; +} finally { + for (const child of children) await stop(child); } diff --git a/scripts/check-single-package.test.mjs b/scripts/check-single-package.test.mjs new file mode 100644 index 00000000..ac9a8af7 --- /dev/null +++ b/scripts/check-single-package.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { test } from "node:test"; + +test("only the unified Triplex package can publish", () => { + const manifests = readdirSync(new URL("../packages/", import.meta.url)).map((name) => + JSON.parse(readFileSync(new URL(`../packages/${name}/package.json`, import.meta.url))), + ); + assert.deepEqual( + manifests.filter((p) => !p.private).map((p) => p.name), + ["@triplex-build/triplex"], + ); + const manifest = manifests.find((p) => !p.private); + for (const path of [ + "sql", + "sqlite", + "postgres", + "cloudflare", + "foundationdb", + "host", + "http", + "testkit", + "mcp", + ]) { + assert.ok(manifest.exports[`./${path}`], `missing /${path}`); + } + for (const driver of [ + "@effect/sql-sqlite-node", + "@effect/sql-pg", + "foundationdb", + "@effect/platform-node", + "@effect/platform-node-shared", + ]) { + assert.equal(manifest.peerDependenciesMeta[driver].optional, true); + } +}); diff --git a/scripts/fixtures/package-consumer.ts b/scripts/fixtures/package-consumer.ts new file mode 100644 index 00000000..4114035a --- /dev/null +++ b/scripts/fixtures/package-consumer.ts @@ -0,0 +1,172 @@ +import { + DatabaseId, + DatalogValidationError, + EntityId, + KvTriples, + TransactionId, + TripleId, + type TripleInput, + type TriplesService, +} from "@triplex-build/triplex"; +import { validateDatalogQuery, type DatalogQuery } from "@triplex-build/triplex/datalog"; +import { + Attribute, + ConfigRuntime, + ConfigStore, + EntityType, + EntityValidation, + Evaluate, + GraphConstraint, + TypeExpr, +} from "@triplex-build/triplex/config"; +import { Runtime, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; +import * as Derivation from "@triplex-build/triplex/derivation"; +import { LiveQuery } from "@triplex-build/triplex/live-query"; +void LiveQuery; +import { Why } from "@triplex-build/triplex/why"; +import * as Database from "@triplex-build/triplex/database"; +import { Inbox, Outbox, OperationalEntryConflictError } from "@triplex-build/triplex/operational"; +import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; +import * as Cloudflare from "@triplex-build/triplex/cloudflare"; +import * as FoundationDb from "@triplex-build/triplex/foundationdb"; +import * as Host from "@triplex-build/triplex/host"; +import * as Postgres from "@triplex-build/triplex/postgres"; +import { PgTriples } from "@triplex-build/triplex/postgres"; +import * as Sql from "@triplex-build/triplex/sql"; +import { makeSqliteLayer } from "@triplex-build/triplex/sqlite"; +import * as Testkit from "@triplex-build/triplex/testkit"; +import { postgresSchemaLayer } from "@triplex-build/triplex/testkit/postgres"; +import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex/http"; + +const triple: TripleInput = { + entityId: EntityId.make("person:alice"), + attribute: ":person/name", + value: { type: "string", value: "Alice" }, +}; +const transactionId = TransactionId.make("_tx/00000000000000000000000000"); +const tripleId = TripleId.make("00000000000000000000000000"); +const transactionEntity: EntityId = transactionId; +declare const triplesService: TriplesService; +const filteredFeed = triplesService.transactions({ entityIdPrefix: "home:", after: 0, limit: 10 }); +const explainedLayer = Why.layer; +const retainedAssertion = triplesService.assertion(tripleId); +const timeline = triplesService.transactionsForEntity(triple.entityId, { limit: 20 }); +const historicalEntity = triplesService.entity(triple.entityId, { recordedPosition: 0 }); +const hostTransaction = PgTriples.withTransaction(triplesService.transact([])); +const intent = Outbox.append({ + scope: triple.entityId, + idempotencyKey: "command:1", + payload: { intent: true }, +}); +const incoming = Inbox.append({ + scope: triple.entityId, + idempotencyKey: "message:1", + payload: null, +}); +const entryTransaction = triplesService.transact([ + intent, + incoming, + Outbox.acknowledge(Outbox.id(triple.entityId, "command:1")), +]); +const pendingEntries = Outbox.pending(triplesService, { scope: triple.entityId, limit: 10 }); +const guardedWrite = triplesService.transact([], { + preconditions: [ + { + _tag: "AttributeValue", + entityId: triple.entityId, + attribute: triple.attribute, + value: triple.value, + }, + { _tag: "AttributeAbsent", entityId: triple.entityId, attribute: ":missing" }, + { _tag: "EntityAbsent", entityId: EntityId.make("new:entity") }, + ], +}); +const kvLiveBackfill = KvTriples.migrateLiveFacts({ batchSize: 500 }); +const entryHistory = Outbox.get(triplesService, Outbox.id(triple.entityId, "command:1")); +const operationalConflict = new OperationalEntryConflictError({ + entryId: Outbox.id(triple.entityId, "command:1"), + kind: "outbox", + scope: triple.entityId, + idempotencyKey: "command:1", + message: "conflicting payload", +}); + +const historicalQuery = triplesService.queryAll( + { find: ["?value"], where: [[triple.entityId, ":person/name", "?value"]] }, + { basis: { recordedPosition: 0, validAt: 1 } }, +); +const ambientPostgres = PgTriples.layerFromSqlClient({ scope: "host/database" }); +const postgresFixture = postgresSchemaLayer({ + url: "postgresql://localhost/test", + triples: PgTriples.layerFromSqlClient, +}); +const schemaError = new Postgres.SchemaVersionError({ expectedVersion: 3, actualVersion: 2 }); +const scopedPostgres = PgTriples.layerForDatabase( + { database: "application" }, + DatabaseId.make("tenant-a"), +); +const query: DatalogQuery = { + find: ["?name"], + where: [["?person", ":person/name", "?name"]], +}; +const EmployerName = Attribute.text(":employer/name"); +const Employer = EntityType.make("Employer", { + name: Attribute.use(EmployerName, { required: true }), +}); +const nameAssertion = Employer.attributes.name.assertion("Acme"); +const databaseLayer = Database.layer({ schema: { types: [Employer] } }); +void databaseLayer; +const sensitiveLayer = Sensitive.layer({ + database: "tenant-a", + attributes: [Attribute.sensitive(Attribute.text(":employee/ssn"))], +}); +void triple; +void sensitiveLayer; +void ValueCodec.aesGcm; +void KeyProvider.test; +void transactionId; +void tripleId; +void transactionEntity; +void timeline; +void historicalEntity; +void hostTransaction; +void historicalQuery; +void ambientPostgres; +void postgresFixture; +void schemaError; +void scopedPostgres; +void query; +void validateDatalogQuery; +void DatalogValidationError; +void nameAssertion; +void ConfigStore; +void ConfigRuntime; +void Runtime; +void DatabaseScope; +void Capabilities; +void EntityValidation; +void Evaluate; +void GraphConstraint; +void TypeExpr; +void Derivation; +void Derivation.Materialization; +void Derivation.Overlay; +void makeSqliteLayer; +void Cloudflare; +void FoundationDb; +void Host; +void Postgres; +void Sql; +void Testkit; +void EntityHttp; +void HttpAuthorizationAllowAll; + +void filteredFeed; +void explainedLayer; +void retainedAssertion; +void entryTransaction; +void pendingEntries; +void guardedWrite; +void kvLiveBackfill; +void entryHistory; +void operationalConflict; diff --git a/scripts/fixtures/package-smoke.mjs b/scripts/fixtures/package-smoke.mjs new file mode 100644 index 00000000..4d586716 --- /dev/null +++ b/scripts/fixtures/package-smoke.mjs @@ -0,0 +1,89 @@ +import { Effect } from "effect"; +import { CanonicalJson } from "@triplex-build/triplex/content"; + +if ( + CanonicalJson.encodeOrThrow({ z: -0, a: [undefined, 1] }) !== + JSON.stringify({ a: [null, 1], z: 0 }) +) { + throw new Error("Public canonical JSON encoding is unavailable or inconsistent"); +} +import { EntityId, Triples, string } from "@triplex-build/triplex"; +import { Runtime, DatabaseScope } from "@triplex-build/triplex/runtime"; +import * as Derivation from "@triplex-build/triplex/derivation"; +import { LiveQuery } from "@triplex-build/triplex/live-query"; +void LiveQuery; +import { Why } from "@triplex-build/triplex/why"; +import * as Database from "@triplex-build/triplex/database"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; + +if (typeof Database.layer !== "function" || typeof Database.Database !== "function") { + throw new Error("Public database entry point is unavailable"); +} + +if (typeof Runtime.define !== "function" || typeof DatabaseScope.test("packed") !== "string") { + throw new Error("Public runtime entry point is unavailable"); +} + +const result = await Effect.runPromise( + Effect.gen(function* () { + const triples = yield* Triples; + const alice = EntityId.make("person:alice"); + const bob = EntityId.make("person:bob"); + yield* triples.assert({ + entityId: alice, + attribute: ":person/name", + value: string("Alice"), + validFrom: 0, + validTo: 5, + }); + const query = { + find: ["?name"], + where: [["?person", ":person/name", "?name"]], + }; + const definition = yield* Derivation.make({ + name: "person.names", + query, + identity: ["?name"], + configSnapshot: "config:consumer-smoke", + }); + yield* Derivation.Materialization.materialize(triples, definition, { + basis: { validAt: 1 }, + }); + const preview = yield* Derivation.Overlay.evaluateOverlay(triples, definition, { + basis: { validAt: 1 }, + overlay: { + assertions: [ + { + entityId: bob, + attribute: ":person/name", + value: string("Bob"), + }, + ], + }, + }); + return { + query: yield* triples.query(query, { basis: { validAt: 1 } }), + preview, + materialization: yield* Derivation.Materialization.current(triples, definition, { + basis: { validAt: 1 }, + }), + }; + }).pipe(Effect.provide(SqliteTriples.layerMemory)), +); + +if ( + result.query.results.length !== 1 || + result.query.results[0]?.["?name"] !== "Alice" || + result.preview.candidates.length !== 2 || + result.preview.nextTemporalBoundary !== 5 || + !result.preview.candidates.some((candidate) => + candidate.sources.some((source) => source.hypothetical === true), + ) || + result.materialization.status !== "current" || + result.materialization.nextTemporalBoundary !== 5 || + result.materialization.candidates.length !== 1 +) { + throw new Error("Unexpected packaged SQLite/Datalog result: " + JSON.stringify(result)); +} + +void Why; diff --git a/test/fixtures/live-precondition-index.ts b/test/fixtures/live-precondition-index.ts index 2ba50cd6..0aa9f792 100644 --- a/test/fixtures/live-precondition-index.ts +++ b/test/fixtures/live-precondition-index.ts @@ -4,7 +4,7 @@ import { Effect } from "effect"; import { SqlClient } from "effect/sql"; import { expect } from "vitest"; import { EntityId, Triples, number } from "@triplex-build/triplex"; -import { makeSqlLivePreconditionLookup, SqliteDialect } from "@triplex-build/triplex-sql"; +import { makeSqlLivePreconditionLookup, SqliteDialect } from "@triplex-build/triplex/sql"; /** Same guarded value, growing retracted history and unrelated live attributes. */ export const livePreconditionIndexPlan = (backend: "sqlite" | "postgres") => diff --git a/test/fixtures/operational-entry-index.ts b/test/fixtures/operational-entry-index.ts index 54c6400b..6f7dde0e 100644 --- a/test/fixtures/operational-entry-index.ts +++ b/test/fixtures/operational-entry-index.ts @@ -6,7 +6,7 @@ import { SqlClient } from "effect/sql"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Outbox } from "@triplex-build/triplex/operational"; import type { ContentId } from "@triplex-build/triplex/content"; -import { makeSqlOperationalEntryStorage, SqliteDialect } from "@triplex-build/triplex-sql"; +import { makeSqlOperationalEntryStorage, SqliteDialect } from "@triplex-build/triplex/sql"; import { expect } from "vitest"; /** Synthetic acknowledged history; actual public append/read/ack around each size. */ diff --git a/test/fixtures/transaction-prefix-index.ts b/test/fixtures/transaction-prefix-index.ts index 67865652..4502164c 100644 --- a/test/fixtures/transaction-prefix-index.ts +++ b/test/fixtures/transaction-prefix-index.ts @@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs"; import { Effect } from "effect"; import { SqlClient } from "effect/sql"; import { EntityId, TransactionId, Triples, number } from "@triplex-build/triplex"; -import { makeSqlTransactionPrefixLookup, SqliteDialect } from "@triplex-build/triplex-sql"; +import { makeSqlTransactionPrefixLookup, SqliteDialect } from "@triplex-build/triplex/sql"; import { expect } from "vitest"; const fixtureId = (value: number): string => { diff --git a/test/integration/package.json b/test/integration/package.json index bd2432c3..43f89d92 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -10,11 +10,6 @@ "dependencies": { "@effect/sql-sqlite-node": "catalog:", "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-cloudflare": "workspace:^", - "@triplex-build/triplex-http": "workspace:^", - "@triplex-build/triplex-sql": "workspace:^", - "@triplex-build/triplex-sqlite": "workspace:^", - "@triplex-build/triplex-testkit": "workspace:^", "effect": "catalog:" }, "devDependencies": { diff --git a/test/integration/test/change.test.ts b/test/integration/test/change.test.ts index f706820d..a1c0cddf 100644 --- a/test/integration/test/change.test.ts +++ b/test/integration/test/change.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { Effect, Layer, Option } from "effect"; import { Change, EntityId, Triples, string } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; diff --git a/test/integration/test/commit-validator-adapter.test.ts b/test/integration/test/commit-validator-adapter.test.ts index e90c33f9..081848d3 100644 --- a/test/integration/test/commit-validator-adapter.test.ts +++ b/test/integration/test/commit-validator-adapter.test.ts @@ -11,8 +11,8 @@ import { TriplesLive, TripleStoreRuntimeLayer, } from "@triplex-build/triplex/internal"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; -import { SqliteAdapterLive } from "@triplex-build/triplex-sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; +import { SqliteAdapterLive } from "@triplex-build/triplex/sqlite"; it("preserves a validator rejection wrapped by a custom adapter", async () => { let wrapped = false; diff --git a/test/integration/test/database-preview.test.ts b/test/integration/test/database-preview.test.ts index f8fc7e8e..d776fcf9 100644 --- a/test/integration/test/database-preview.test.ts +++ b/test/integration/test/database-preview.test.ts @@ -3,7 +3,7 @@ import { expect, it } from "vitest"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; import * as Database from "@triplex-build/triplex/database"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; it("SQLite previews guarded temporal changes and commits the same semantic change set", async () => { const Person = EntityType.make("PreviewPerson", { diff --git a/test/integration/test/database.test.ts b/test/integration/test/database.test.ts index 0eeb4d6b..6fe5dc8f 100644 --- a/test/integration/test/database.test.ts +++ b/test/integration/test/database.test.ts @@ -1,6 +1,6 @@ import { describe } from "vitest"; import { KvTriples } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { databaseBehavior } from "./database-behavior.js"; for (const [name, backend] of [ diff --git a/test/integration/test/datalog-differential.test.ts b/test/integration/test/datalog-differential.test.ts index 7a2fbf30..889a5bcf 100644 --- a/test/integration/test/datalog-differential.test.ts +++ b/test/integration/test/datalog-differential.test.ts @@ -13,7 +13,7 @@ import { type QueryContext, } from "@triplex-build/triplex/internal"; import { EntityId } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { ConfigStore, EntityValidation, TypeExpr } from "@triplex-build/triplex/config"; const eid = EntityId.make; diff --git a/test/integration/test/entity-decode.test.ts b/test/integration/test/entity-decode.test.ts index 9ecb8554..4e0378bf 100644 --- a/test/integration/test/entity-decode.test.ts +++ b/test/integration/test/entity-decode.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { Effect } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; it("decodes SQLite entity reads and uses source facts for typed temporal replacement", async () => { const Worker = EntityType.make("Worker", { diff --git a/test/integration/test/fixtures/SqliteTestLayer.ts b/test/integration/test/fixtures/SqliteTestLayer.ts index 605c5281..86a72b2b 100644 --- a/test/integration/test/fixtures/SqliteTestLayer.ts +++ b/test/integration/test/fixtures/SqliteTestLayer.ts @@ -48,8 +48,8 @@ import { TriplesLive, TripleStoreRuntimeLayer, } from "@triplex-build/triplex/internal"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; -import { SqliteAdapterLive } from "@triplex-build/triplex-sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; +import { SqliteAdapterLive } from "@triplex-build/triplex/sqlite"; // ─── Pre-composed layers (in-memory, most common) ────────────────────────── diff --git a/test/integration/test/http-api.test.ts b/test/integration/test/http-api.test.ts index f9b63149..a87823f9 100644 --- a/test/integration/test/http-api.test.ts +++ b/test/integration/test/http-api.test.ts @@ -14,8 +14,8 @@ import { HttpAuthorization, HttpAuthorizationAllowAll, UnauthorizedError, -} from "@triplex-build/triplex-http"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +} from "@triplex-build/triplex/http"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { Effect, Layer } from "effect"; import { HttpRouter } from "effect/http"; import { describe, expect, it } from "vitest"; diff --git a/test/integration/test/kv-conformance.test.ts b/test/integration/test/kv-conformance.test.ts index ce5f6811..97ccd65d 100644 --- a/test/integration/test/kv-conformance.test.ts +++ b/test/integration/test/kv-conformance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Effect } from "effect"; import { KvTriples } from "@triplex-build/triplex"; -import { makeTriplesConformanceSuite } from "@triplex-build/triplex-testkit"; +import { makeTriplesConformanceSuite } from "@triplex-build/triplex/testkit"; describe("in-memory KV Triples conformance", () => { it( diff --git a/test/integration/test/live-query.test.ts b/test/integration/test/live-query.test.ts index 50934ab0..12f7378c 100644 --- a/test/integration/test/live-query.test.ts +++ b/test/integration/test/live-query.test.ts @@ -9,11 +9,11 @@ import { makeTestKvBackend, KvBackend, } from "@triplex-build/triplex/internal"; -import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex-sqlite"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; +import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex/sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; import { DatabaseScope } from "@triplex-build/triplex/runtime"; import { LiveQuery } from "@triplex-build/triplex/live-query"; -import { liveQueryCases } from "@triplex-build/triplex-testkit"; +import { liveQueryCases } from "@triplex-build/triplex/testkit"; for (const backend of ["kv", "sqlite"] as const) { for (const [name, program] of Object.entries(liveQueryCases)) { it(`${backend}: ${name}`, async () => { diff --git a/test/integration/test/recorded-reads.test.ts b/test/integration/test/recorded-reads.test.ts index 650784ab..038b2e7d 100644 --- a/test/integration/test/recorded-reads.test.ts +++ b/test/integration/test/recorded-reads.test.ts @@ -8,9 +8,9 @@ import { DeterministicTripleStoreRuntimeLive, } from "@triplex-build/triplex/internal"; import { InMemoryKvBackendLive } from "@triplex-build/triplex/internal"; -import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex-sqlite"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; -import { recordedReadsConformance } from "@triplex-build/triplex-testkit"; +import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex/sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; +import { recordedReadsConformance } from "@triplex-build/triplex/testkit"; // D9/D11 freeze Cloudflare; its new historical timestamp/wildcard reads return ReadError. // FoundationDB inherits these reads from KV; native coverage remains opt-in. diff --git a/test/integration/test/schema-bound.test.ts b/test/integration/test/schema-bound.test.ts index b30080df..cfce4d0a 100644 --- a/test/integration/test/schema-bound.test.ts +++ b/test/integration/test/schema-bound.test.ts @@ -1,7 +1,7 @@ import { expect, it, vi } from "vitest"; import { Effect, Layer } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { Attribute, ConfigStore, SchemaBound } from "@triplex-build/triplex/config"; const base = ConfigStore.layer.pipe(Layer.provideMerge(SqliteTriples.layerMemory)); diff --git a/test/integration/test/snapshot-integration.test.ts b/test/integration/test/snapshot-integration.test.ts index 40591897..48913d76 100644 --- a/test/integration/test/snapshot-integration.test.ts +++ b/test/integration/test/snapshot-integration.test.ts @@ -24,8 +24,8 @@ import { TripleStoreRuntimeLayer, } from "@triplex-build/triplex/internal"; import { EntityId, TransactionId, TripleId } from "@triplex-build/triplex"; -import { SqliteAdapterLive } from "@triplex-build/triplex-sqlite"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; +import { SqliteAdapterLive } from "@triplex-build/triplex/sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; const sqliteDialectLayer = Layer.succeed(CurrentDialect, SqliteDialect); const eid = EntityId.make; diff --git a/test/integration/test/triples-service.test.ts b/test/integration/test/triples-service.test.ts index 41309f72..daef3205 100644 --- a/test/integration/test/triples-service.test.ts +++ b/test/integration/test/triples-service.test.ts @@ -10,11 +10,11 @@ import { describe, it, expect } from "vitest"; import { Effect } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; -import { SqliteTriples } from "@triplex-build/triplex-sqlite"; +import { SqliteTriples } from "@triplex-build/triplex/sqlite"; import { triplesConformanceCases, makeTriplesConformanceSuite, -} from "@triplex-build/triplex-testkit"; +} from "@triplex-build/triplex/testkit"; const eid = EntityId.make; diff --git a/test/integration/test/triples-sql.test.ts b/test/integration/test/triples-sql.test.ts index 076ea3d4..36f5ab13 100644 --- a/test/integration/test/triples-sql.test.ts +++ b/test/integration/test/triples-sql.test.ts @@ -14,8 +14,8 @@ import { boolean, ref as makeRef, } from "@triplex-build/triplex/internal"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; -import { SqliteAdapterLive } from "@triplex-build/triplex-sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; +import { SqliteAdapterLive } from "@triplex-build/triplex/sqlite"; import { EntityId, TransactionId, TripleId } from "@triplex-build/triplex"; import { SqliteTestLayer } from "./fixtures/SqliteTestLayer.js"; diff --git a/test/integration/test/why.test.ts b/test/integration/test/why.test.ts index e99406ca..8b0e2009 100644 --- a/test/integration/test/why.test.ts +++ b/test/integration/test/why.test.ts @@ -8,14 +8,14 @@ import { DeterministicTripleStoreRuntimeLive, } from "@triplex-build/triplex/internal"; import { InMemoryKvBackendLive } from "@triplex-build/triplex/internal"; -import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex-sqlite"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; +import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex/sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; import { assertionConformance, whyReadBoundaryConformance, whyConformance, whyTestLayer, -} from "@triplex-build/triplex-testkit"; +} from "@triplex-build/triplex/testkit"; // D9/D11 freeze Cloudflare; its new historical timestamp/wildcard reads return ReadError. // FoundationDB inherits these reads from KV; native coverage remains opt-in. diff --git a/test/stress/package.json b/test/stress/package.json index 3b954092..659f3bac 100644 --- a/test/stress/package.json +++ b/test/stress/package.json @@ -21,10 +21,6 @@ "@effect/sql-pg": "catalog:", "@effect/sql-sqlite-node": "catalog:", "@triplex-build/triplex": "workspace:^", - "@triplex-build/triplex-foundationdb": "workspace:^", - "@triplex-build/triplex-postgres": "workspace:^", - "@triplex-build/triplex-sql": "workspace:^", - "@triplex-build/triplex-sqlite": "workspace:^", "effect": "catalog:" }, "devDependencies": { diff --git a/test/stress/scripts/commit-position-contention.ts b/test/stress/scripts/commit-position-contention.ts index 0ef241c5..32ba15fa 100644 --- a/test/stress/scripts/commit-position-contention.ts +++ b/test/stress/scripts/commit-position-contention.ts @@ -11,7 +11,7 @@ import { databaseToSchema, PgTriples, type PostgresqlConfig, -} from "@triplex-build/triplex-postgres"; +} from "@triplex-build/triplex/postgres"; import { GenericContainer, Wait } from "testcontainers"; const integer = (name: string, fallback: number, min: number, max: number) => { diff --git a/test/stress/scripts/datalog-pagination.ts b/test/stress/scripts/datalog-pagination.ts index b8c3a3ad..6c55c610 100644 --- a/test/stress/scripts/datalog-pagination.ts +++ b/test/stress/scripts/datalog-pagination.ts @@ -7,9 +7,9 @@ import { CurrentDialect, makeTripleStoreRuntimeLayer, } from "@triplex-build/triplex/internal"; -import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex-sqlite"; -import { SqlQueryExecutorLive } from "@triplex-build/triplex-sql"; -import { PgTriples, makePostgresqlLayerFromUrl } from "@triplex-build/triplex-postgres"; +import { SqliteAdapterLive, SqliteDialect, SqliteTestLayer } from "@triplex-build/triplex/sqlite"; +import { SqlQueryExecutorLive } from "@triplex-build/triplex/sql"; +import { PgTriples, makePostgresqlLayerFromUrl } from "@triplex-build/triplex/postgres"; const size = Number(process.env["DATALOG_BENCH_ENTITIES"] ?? 10_000); if (!Number.isSafeInteger(size) || size < 100 || size > 1_000_000) diff --git a/test/stress/src/backend.ts b/test/stress/src/backend.ts index d3d38dd4..e9a3c33e 100644 --- a/test/stress/src/backend.ts +++ b/test/stress/src/backend.ts @@ -24,19 +24,19 @@ import { SqliteClient } from "@effect/sql-sqlite-node"; import { NodeServices } from "@effect/platform-node"; import { DatabaseManager } from "@triplex-build/triplex"; import { Triples, type TriplesService } from "@triplex-build/triplex"; -import { DatabaseManagerLive, DatabaseRegistryLive } from "@triplex-build/triplex-sql"; -import { makeSqliteBackend } from "@triplex-build/triplex-sqlite"; +import { DatabaseManagerLive, DatabaseRegistryLive } from "@triplex-build/triplex/sql"; +import { makeSqliteBackend } from "@triplex-build/triplex/sqlite"; import { makePostgresqlBackendFromUrl, makePostgresqlLayerFromUrl, -} from "@triplex-build/triplex-postgres"; +} from "@triplex-build/triplex/postgres"; import { InMemoryKvBackendLive, KvTriplesLive, RuntimeServicesLive, TripleStoreRuntimeLayer, } from "@triplex-build/triplex/internal"; -import { makeFdbKvBackend, type FdbKvBackendConfig } from "@triplex-build/triplex-foundationdb"; +import { makeFdbKvBackend, type FdbKvBackendConfig } from "@triplex-build/triplex/foundationdb"; import { promises as fs } from "node:fs"; // ─── Types ───────────────────────────────────────────────────────────────── diff --git a/trials/README.md b/trials/README.md index 066a9706..93189d47 100644 --- a/trials/README.md +++ b/trials/README.md @@ -22,7 +22,7 @@ From the repository root, with Node.js >=22.19.0 and pinned pnpm 10.11.0: ```sh corepack enable pnpm install --frozen-lockfile -pnpm turbo run build --filter=@triplex-build/triplex-sqlite... --filter=@triplex-build/triplex-cli... +pnpm turbo run build --filter=@triplex-build/triplex mkdir -p trials/runs/vendor-risk-YYYY-MM-DD bash trials/setup.sh trials/runs/vendor-risk-YYYY-MM-DD cp trials/briefs/vendor-risk.md trials/runs/vendor-risk-YYYY-MM-DD/BRIEF.md diff --git a/trials/setup.sh b/trials/setup.sh index 725ec447..3fea2a64 100644 --- a/trials/setup.sh +++ b/trials/setup.sh @@ -14,15 +14,12 @@ for file in package.json tsconfig.json vitest.config.ts node_modules; do exit 1 fi done -for package in core sqlite; do - if [[ ! -f "$ROOT/packages/$package/dist/index.d.ts" ]]; then - echo "Build packages first: missing packages/$package/dist/index.d.ts" >&2 - exit 1 - fi -done +if [[ ! -f "$ROOT/packages/triplex/dist/core/index.d.ts" ]]; then + echo "Build the public package first: pnpm turbo run build --filter=@triplex-build/triplex" >&2 + exit 1 +fi mkdir -p "$TRIAL/node_modules/@triplex-build" "$TRIAL/node_modules/@types" "$TRIAL/src" "$TRIAL/test" -ln -s "$ROOT/packages/core" "$TRIAL/node_modules/@triplex-build/triplex" -ln -s "$ROOT/packages/sqlite" "$TRIAL/node_modules/@triplex-build/triplex-sqlite" +ln -s "$ROOT/packages/triplex" "$TRIAL/node_modules/@triplex-build/triplex" # One Effect copy, shared with core, to preserve service identity. ln -s "$ROOT/packages/core/node_modules/effect" "$TRIAL/node_modules/effect" ln -s "$ROOT/node_modules/vitest" "$TRIAL/node_modules/vitest" diff --git a/turbo.json b/turbo.json index 7aad6f1d..a8a234da 100644 --- a/turbo.json +++ b/turbo.json @@ -22,6 +22,36 @@ "dependsOn": ["build", "^build"], "inputs": ["src/**", "test/**", "package.json", "vitest.config.ts"], "outputs": [] + }, + "@triplex-build/triplex-dashboard#build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "vite.config.ts", "index.html", "public/**"], + "outputs": ["dist/**", "dist-server/**"] + }, + "@triplex-build/triplex#build": { + "dependsOn": ["^build"], + "inputs": [ + "package.json", + "README.md", + "LICENSE", + "$TURBO_ROOT$/scripts/assemble-package.mjs" + ], + "outputs": ["dist/**", "publish/**"] + }, + "@triplex-build/triplex-cli#build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/packages/triplex/package.json"], + "outputs": ["dist/**"] + }, + "@triplex-build/triplex#test": { + "dependsOn": ["^build"], + "inputs": ["package.json", "$TURBO_ROOT$/scripts/check-single-package.test.mjs"], + "outputs": [] + }, + "@triplex-build/triplex#typecheck": { + "dependsOn": ["^build"], + "inputs": ["package.json", "$TURBO_ROOT$/scripts/check-single-package.test.mjs"], + "outputs": [] } } } diff --git a/vitest.workspace-aliases.ts b/vitest.workspace-aliases.ts index 3fe27989..b1520f96 100644 --- a/vitest.workspace-aliases.ts +++ b/vitest.workspace-aliases.ts @@ -17,7 +17,14 @@ const sourceFor = (packageDir: string, target: ExportTarget): string | undefined .replace("./dist/", "./src/") .replace(/\.d\.ts$/, ".ts") .replace(/\.js$/, ".ts"); - const absolute = path.resolve(packageDir, source); + const absolute = + path.basename(packageDir) === "triplex" + ? path.resolve( + packageDir, + "..", + source.replace("./src/", "").replace(/^([^/]+)\//, "$1/src/"), + ) + : path.resolve(packageDir, source); return fs.existsSync(absolute) ? absolute : undefined; }; @@ -32,7 +39,11 @@ export function workspaceAliases() { if (!fs.existsSync(manifestPath)) continue; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PackageJson; - if (!manifest.name?.startsWith("@triplex-build/triplex")) continue; + if ( + !manifest.name?.startsWith("@triplex-build/triplex") && + manifest.name !== "@triplex-internal/core" + ) + continue; for (const [subpath, target] of Object.entries(manifest.exports ?? {})) { const replacement = sourceFor(packageDir, target);