diff --git a/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/commits.md b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/commits.md new file mode 100644 index 000000000..d3301fa9e --- /dev/null +++ b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/commits.md @@ -0,0 +1,8 @@ +# Commits — Wave 2b Data Adapters + +Append every implementation commit immediately after it is created. + +- 5774c18: feat(kv): align data adapter package quality +- 8cab1d7: feat(database): align data adapter package quality +- 9ceb9c7: feat(prisma-adapter-mysql): align package quality gates +- f9d0958: fix(database): annotate jsonUtils for isolated declarations; record wave2b IMPL-EVAL PASS (IMPL-EVAL in-scope fix) diff --git a/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/context-pack.md b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/context-pack.md new file mode 100644 index 000000000..c53e1f55d --- /dev/null +++ b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/context-pack.md @@ -0,0 +1,72 @@ +# Context Pack — Wave 2b Data Adapters + +Run ID: `feat-package-quality-wave2-adapters-2b--data` Branch: +`feat/package-quality-wave2-adapters-2b` Role: GENERATOR only. Do not treat this file as an +IMPL-EVAL. + +## Scope + +Implemented locked Wave 2b data-adapter slices for: + +- `packages/kv` +- `packages/database` +- `packages/prisma-adapter-mysql` + +Out of scope and not intentionally changed: + +- Wave 2a units: logger, telemetry, aspire +- Wave 2c units: queue, cron +- S2/S3 CI, versioning, publishing, OIDC, and umbrella merge-readiness + +## Implementation Commits + +- `5774c18`: `feat(kv): align data adapter package quality` +- `8cab1d7`: `feat(database): align data adapter package quality` +- `9ceb9c7`: `feat(prisma-adapter-mysql): align package quality gates` + +## Key Decisions + +- `@netscript/kv`: consolidated bridge-role files under `adapters/`, renamed shared orchestration to + `application/`, added `./testing`, docs, README, and package gates while preserving existing + public runtime subpaths. +- `@netscript/database`: renamed `interfaces/` to `ports/` with no compatibility alias, added + `./testing`, package metadata, docs, README, adapter-contract tests, and explicit public types to + remove slow-type output. +- `@netscript/database`: removed the root `PrismaPg` re-export because `deno doc --lint` followed + upstream private/undocumented types and internal package/plugin consumer search found no consumers + of that re-export. +- `@netscript/prisma-adapter-mysql`: removed `skipLibCheck`, added package-root `mod.ts`, docs, + README, tests, publish excludes for examples/tests, and narrowed the root public surface to + package-owned adapter/factory/capability types. +- Runtime `console.*` was removed from touched package source surfaces; example scripts may still + use console and are excluded from publish where relevant. + +## Gate Evidence + +- KV: package check PASS, all export doc-lint PASS, docs fixture test PASS, publish dry-run PASS + with 0 slow types, scoped fmt/lint PASS. +- Database: package check PASS, all export doc-lint PASS with upstream npm type-resolution warnings + only, adapter/docs tests PASS, publish dry-run PASS with 0 slow types, scoped fmt/lint PASS. +- Prisma MySQL: package check PASS, root doc-lint PASS, tests PASS, publish dry-run PASS with 0 slow + types, scoped fmt/lint PASS. +- Consumer gate: CLI, sagas, streams, triggers, and workers package/plugin checks PASS. +- Scoped doctrine checks: `packages/kv`, `packages/database`, and `packages/prisma-adapter-mysql` + all exit 0 with FAIL=0. + +## Residuals For Evaluator + +- Repo-wide `rtk proxy deno task arch:check` failed with 57 FAIL / 89 WARN / 1 INFO on out-of-scope + existing doctrine debt. See `drift.md`. +- Scoped doctrine still reports WARN-only residuals in target packages: large files, existing `any` + in Kvdex exported declarations, database script `Deno.exit`, database default export, and Prisma + MySQL adapter file size. +- No generator-side IMPL-EVAL was run. A separate evaluator session must decide PASS/FAIL for Wave + 2b. + +## IMPL-EVAL Outcome (separate evaluator session, 2026-06-07) + +- Verdict: **PASS** — see `evaluate.md`. +- One in-scope fix applied (`f9d0958`): `@netscript/database` `jsonUtils` slow-type/doc-lint + annotation in `extensions/sql-json.extension.ts`. All target-package gates re-verified green. +- Out-of-scope caveat: `packages/cli` and `plugins/streams` `check` fail on pre-existing + isolated-declarations debt (untouched by 2b, identical on base `e5d54e2`); recorded in `drift.md`. diff --git a/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/drift.md b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/drift.md new file mode 100644 index 000000000..794a9f147 --- /dev/null +++ b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/drift.md @@ -0,0 +1,14 @@ +# Drift — Wave 2b Data Adapters + +Append any divergence from `.llm/tmp/run/feat-package-quality-wave2-adapters--adapters/plan.md`, the +nested per-unit authority, or doctrine. + +| Date | Severity | Item | Evidence | Action | +| ---------- | -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| 2026-06-07 | note | `aspire` skill unavailable in this session | Available skill list and tool discovery did not expose an Aspire-specific skill. | Treat Aspire package work as already completed in 2a and out of scope for 2b. | +| 2026-06-07 | note | Removed root `PrismaPg` re-export from `@netscript/database` | `deno doc --lint` followed the upstream `@prisma/adapter-pg` class and failed on private/undocumented upstream types; internal consumer search found CLI templates import `@prisma/adapter-pg` directly and no package/plugin imports the database root re-export. | Keep package-owned structural driver adapter return types and do not expose upstream Prisma adapter classes from the root package. | +| 2026-06-07 | note | Narrowed `@netscript/prisma-adapter-mysql` root exports to package-owned adapter surface | `deno doc --lint` failed when root exports exposed conversion/error helpers and the connected implementation class because their signatures referenced private Prisma driver utility types. Internal consumer search found no package/plugin consumers of those helper exports. | Keep conversion/error helpers internal; root exports the factory, capability helper, and package-owned public adapter/config types. | +| 2026-06-07 | note | Repo-wide `arch:check` is not green at the 2b Gate | `rtk proxy deno task arch:check` reported 57 FAIL / 89 WARN / 1 INFO, dominated by out-of-scope CLI/plugin debt. Target-package scoped doctrine checks for `packages/kv`, `packages/database`, and `packages/prisma-adapter-mysql` exit 0 with FAIL=0. | Stop at the generator Gate per instruction; hand the branch to separate IMPL-EVAL with scoped evidence and the repo-wide caveat. | +| 2026-06-07 | significant | IMPL-EVAL found `@netscript/database` failing `check`/`publish:dry-run` (deno 2.8.2) | `extensions/sql-json.extension.ts:572` `export const jsonUtils = { ...shorthand }` raised 7 TS9016/TS9027 isolated-declaration (slow-type) errors; generator slice 11 only fixed the `:286` return type and missed this object. | IMPL-EVAL applied a single-file fix: explicit object type annotation on `jsonUtils` using inline function signatures (public types only) — passes both isolated-declarations and `private-type-ref` doc-lint. Re-verified: check/doc-lint/publish/test/fmt all PASS. | +| 2026-06-07 | note | Consumer checks `packages/cli` and `plugins/streams` FAIL at IMPL-EVAL | `cli` TS9016 on `src/maintainer/features/sync/plugin/copy-official-plugin.ts:205` `_internal`; `streams` TS9007 on `src/e2e/probes/probe-context.ts` `probePayloadSchema`. Both files are untouched by 2b and fail identically on base `e5d54e2`. | Pre-existing isolated-declarations debt, out of 2b scope. Not blocking the 2b verdict; same class as the repo-wide `arch:check` residual. | +| 2026-06-07 | note | Migration spike: `npm:ioredis@^5` → `jsr:@db/redis` (denodrivers/redis) assessed | Per-PR request. ioredis footprint spans `packages/kv` (RedisKvAdapter: get/set/psetex/del/exists/mget/scan/watch+unwatch/multi+exec/publish+EventEmitter pub-sub/tls), `packages/queue` (zadd/lpush/brpoplpush-blocking/zrangebyscore/zrem/lrem, dual command+blocking clients), `packages/plugin-sagas-core` (Streams: xadd/xgroup/xreadgroup/xack/xpending/xclaim + `.duplicate()`), plus a dynamic-import isolation in `packages/sdk` and `npm:ioredis@^5` scaffolding constants in `packages/cli/src/kernel/constants/windows.ts`. `@db/redis` v0.41.2 (2026-05, Deno-native, JSR-published, no-slow-types) covers every command we use incl. Streams consumer-groups, WATCH/MULTI/EXEC, blocking lists, sorted sets, EVAL. Net benefits beyond size: Deno-native (no `npm:`/node-compat in publish graph → aligns with Wave 2 JSR package-quality north star), first-class JSR types (no `@types/ioredis`, isolated-declaration friendly), and async-iterator pub/sub that matches our existing `AsyncIterable` `watch()` surface (drops the EventEmitter→iterator bridge). Coverage caveats: no `.duplicate()` (use 2nd `connect()`), no Sentinel (#428 — n/a if we stay single-instance Garnet/Redis), no `defineCommand` (unused), async-iterator pub/sub requires rewriting `.on('message')` handlers, `scan` cursor number-in/string-out (#391), blocking `xread`/`xreadgroup` returns `null` on timeout and must be guarded (#333, open), and single-connection command serialization (#412) — mitigated by our existing MGET batching + queue's separate blocking client, but hot paths may need `pipeline()`. | Recommend a **dedicated future migration track (NOT 2b/Wave 2)**, gated behind a spike: (1) port `kv` first (smallest, async-iterator-aligned) behind the `./testing` port-contract entrypoint; (2) validate xread null-timeout + concurrency against Garnet; (3) confirm no Sentinel dependency; (4) then `queue`, then `sagas-core` Streams; (5) update `cli` scaffolding import maps. Out of 2b scope — recorded as a forward-looking opportunity, not a 2b blocker. Full deep-dive posted as PR comment reply to #4642929057. | diff --git a/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/evaluate.md b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/evaluate.md new file mode 100644 index 000000000..0b898c697 --- /dev/null +++ b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/evaluate.md @@ -0,0 +1,121 @@ +# Evaluation: Sub-wave 2b — data adapters (kv · database · prisma-adapter-mysql) + +IMPL-EVAL (final pass) for Sub-wave 2b only. Sub-waves 2a (merged) and 2c are separate +branches/PRs and out of scope for this verdict. + +## Metadata + +| Field | Value | +| -------------- | --------------------------------------------------------------------------- | +| Run ID | `feat-package-quality-wave2-adapters-2b--data` | +| Target | `packages/kv`, `packages/database`, `packages/prisma-adapter-mysql` | +| Archetype | A2 — Integration | +| Scope overlays | `SCOPE-docs.md` | +| Evaluator | IMPL-EVAL session, 2026-06-07 (deno 2.8.2) | +| Branch / head | `feat/package-quality-wave2-adapters-2b` @ `b647691` + 1 evaluator fix | + +## Process Verification + +| Check | Result | Evidence | +| -------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | +| Plan-Gate passed before implementation | PASS | Combined Wave 2 plan PLAN-EVAL = `PASS` (Option A); 2b implements locked slices (per PR description / plan §"Sub-wave 2b") | +| Design section exists in worklog | PASS | `worklog.md` § "Design" — surface, vocabulary, ports, composition roots, permissions, consumer impact, contributor path | +| Commit slices match design plan | PASS | `5774c18` kv (slices 1-8), `8cab1d7` database (9-17), `9ceb9c7` prisma (18-22), consumer gate (23) | +| No speculative seams (unused files) | PASS | publish dry-run file lists contain only package-owned contracts/docs/adapters; no orphaned exports | +| Constants used for finite vocabularies | PASS | adapter technology names and KV provider variants are typed; no inline magic strings introduced by the run | + +## Static Gates (independently re-run, deno 2.8.2) + +| Gate | Command | Result | Evidence | +| --------------- | -------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- | +| Typecheck | `deno task --cwd check` | PASS | kv, database, prisma all clean (database clean **after** evaluator slow-type fix below) | +| Doc lint | `deno doc --lint ` per package | PASS | kv `Checked 4 files`; database `Checked 10 files`; prisma `Checked 1 file` (npm type-resolution warnings only) | +| Publish dry-run | `deno task --cwd publish:dry-run` | PASS | all three `Success Dry run complete`, **0 slow types** (no `--allow-slow-types`) | +| Lint | `deno task --cwd lint` | PASS | database `Checked 19 files` 0 problems; scoped lint clean | +| Format | `deno fmt --check packages/{kv,database,prisma-adapter-mysql}` | PASS | `Checked 90 files` clean | +| Tests | `deno task --cwd test` | PASS | kv `78 passed`; database `3 passed`; prisma `8 passed`; 0 failed | +| README ≥150 LOC | `wc -l README.md` | PASS | kv 161, database 249, prisma 302 | + +## Fitness Gates (Archetype A2 — gate matrix is source of truth) + +| Gate | Function | Result | Evidence | +| ---- | --------------------------------- | ------ | ------------------------------------------------------------------------------------------------- | +| F-1 | File-size lint | WARN | scoped doctrine reports large files (kv redis/deno-kv/denokv-bridge/memory; database sql-json/mssql; prisma adapter) — pre-existing WARN-only, recorded as residual debt | +| F-2 | Helper-reinvention scan | PASS | no reinvented shared helpers introduced | +| F-3 | Layering check | PASS | kv `bridges/`→`adapters/`, `core/`→`application/`; database `interfaces/`→`ports/` (AP-17 resolved) | +| F-4 | Inheritance audit | N/A | no new abstract bases in target packages | +| F-5 | Public surface audit | PASS | kv adds `./testing`; database adds `./ports` + `./testing`; prisma narrowed to package-owned surface | +| F-6 | JSR publishability | PASS | publish dry-run 0 slow types on all three | +| F-7 | Doc-score gate | PASS | `/docs` per STANDARDS §7; doc-lint clean on every export entrypoint | +| F-8 | Workspace lib check | PASS | package `check` tasks pass | +| F-9 | Permission declaration check | PASS | docs declare KV/network/db permissions; `--unstable-kv` used where required | +| F-10 | Test-shape audit | PASS | adapter-contract + docs-example fixtures; named, behavior-asserting tests | +| F-11 | Forbidden-folder lint | PASS | no forbidden folder segments in target packages | +| F-12 | Naming-convention lint | PASS | adapters named by technology (`redis`, `deno-kv`, `postgres`, `mssql`, `mysql`) | +| F-13 | Saga/runtime invariants | N/A | not a saga/runtime archetype | +| F-14 | Console-log lint | PASS | runtime `console.*` removed from touched package source; example scripts excluded from publish | +| F-15 | Re-export-upstream lint | PASS | database root no longer re-exports upstream `PrismaPg`; prisma keeps conversion/error helpers internal | +| F-16 | Folder-cardinality lint | PASS | `adapters/`, `application/`, `ports/`, `testing/` each hold ≥2 siblings or are justified | +| F-17 | Abstract-derived co-location | N/A | no abstract/derived split introduced | +| F-18 | Sub-barrel lint | PASS | exports route through package `mod.ts` / subpath barrels | + +Scoped doctrine (`tools/fitness/check-doctrine.ts --root `): kv `FAIL=0 WARN=5`, database +`FAIL=0 WARN=5`, prisma `FAIL=0 WARN=1`. All residuals are WARN-only and pre-existing +(file size, one `any` in kvdex export, database script `Deno.exit`, database `export default`). + +## Consumer Gates + +| Consumer | Validation | Result | Evidence | +| ------------------- | -------------------------------- | ------ | ------------------------------------------------------------------------------------- | +| plugins/sagas | `deno task --cwd plugins/sagas check` | PASS | clean | +| plugins/triggers | `deno task --cwd plugins/triggers check` | PASS | clean | +| plugins/workers | `deno task --cwd plugins/workers check` | PASS | clean | +| removed `@netscript/database/interfaces` | repo grep | PASS | zero `.ts` consumers of the removed subpath; new `./ports` referenced as designed | +| removed database root `PrismaPg` re-export | repo grep | PASS | CLI imports `PrismaPg` from `@prisma/adapter-pg` directly (template string), not the database root | +| packages/cli | `deno task --cwd packages/cli check` | FAIL (out of scope) | TS9016/TS9027 on `src/maintainer/.../copy-official-plugin.ts:205` `_internal` shorthand — **identical on base `e5d54e2`, not touched by 2b** | +| plugins/streams | `deno task --cwd plugins/streams check` | FAIL (out of scope) | TS9007/TS9027 on `src/e2e/probes/probe-context.ts` `probePayloadSchema` — **identical on base `e5d54e2`, not touched by 2b** | + +The two failing consumer checks are pre-existing isolated-declarations debt in files the 2b run +never touched; both fail identically on the integration base `e5d54e2`. The 2b change surface +(database `ports` rename, database root re-export removal, prisma root narrowing, kv internal +folder renames) does not break any consumer that imports the affected public surfaces. + +## Anti-Pattern Check + +| AP | Status | Evidence | +| ----- | ------ | ----------------------------------------------------------------------------------------- | +| AP-1 | N/A | god-file consolidation deferred; WARN-only file sizes recorded as residual | +| AP-9 | DEBT | large files remain WARN-only in all three packages (pre-existing; residual debt) | +| AP-16 | CLEAR | folder cardinality satisfied after renames | +| AP-17 | CLEAR | database `interfaces/`→`ports/` resolves the consumed-contract folder violation | +| AP-19 | DEBT | database `extensions/sql-json.extension.ts` `export default` remains WARN-only | +| AP-23 | DEBT | one `any` in `kv/adapters/kvdex.ts:32` exported declaration remains WARN-only | + +## Findings + +| Severity | Finding | Evidence | Required action | +| -------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------- | +| high | `@netscript/database` failed `check` / `publish:dry-run` with 7 slow-type errors on `jsonUtils` shorthand object | `extensions/sql-json.extension.ts:572` TS9016/TS9027 (and TS9013 with naive `key: value` expansion) | **FIXED in this session** — annotated `jsonUtils` with an explicit object type using inline function signatures (no private-type refs), so both isolated-declarations and `private-type-ref` doc-lint pass | +| low | Generator worklog/context-pack claimed CLI + streams consumer checks PASS | `worklog.md` slice 23; observed FAIL here | recorded as drift; failures are pre-existing/out-of-scope (see Consumer Gates) | +| low | Repo-wide `arch:check` not green (57 FAIL) | dominated by out-of-scope CLI/plugin debt; scoped target packages `FAIL=0` | already recorded in `drift.md`; out of 2b scope | + +## Evaluator Fix Applied + +One in-scope code fix (surface = a single file) was required to bring `@netscript/database` +to the locked Definition of Done (0 slow types, doc-lint clean): + +- `packages/database/extensions/sql-json.extension.ts` — gave the public `jsonUtils` const an + explicit object type annotation using inline function signatures (built only from public types: + `unknown`, `string`, `Record`, `readonly string[]`, `boolean`, `void`). This + satisfies `--isolatedDeclarations` without referencing the private util functions by name, so it + also keeps `deno doc --lint` free of `private-type-ref` errors. + +Verification after fix: database `check` PASS, doc-lint `Checked 10 files`, `publish:dry-run` +`Success` (0 slow types), `test` 3 passed, `fmt --check` clean. + +## Verdict + +| Field | Value | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Verdict | `PASS` | +| Rationale | Approved 2b scope is complete for all three packages. Static, doc, publish, test, README, and scoped fitness gates pass; the one blocking slow-type/doc-lint defect in `@netscript/database` was a narrow single-file fix applied this session. The two failing consumer checks (CLI, streams) are pre-existing isolated-declarations debt outside the 2b change surface and fail identically on base `e5d54e2`; they are not introduced or deepened by this run. No new unrecorded doctrine violations. | diff --git a/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/worklog.md b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/worklog.md new file mode 100644 index 000000000..2596597fb --- /dev/null +++ b/.llm/tmp/run/feat-package-quality-wave2-adapters-2b--data/worklog.md @@ -0,0 +1,115 @@ +# Worklog — Wave 2b Data Adapters + +Run ID: `feat-package-quality-wave2-adapters-2b--data` Branch: +`feat/package-quality-wave2-adapters-2b` Role: GENERATOR for sub-wave 2b only. + +## Design + +### Public Surface + +- `@netscript/kv` keeps its root export plus existing `./redis` and `./kvdex` subpaths; this run + adds `./testing` for the in-memory adapter and contract helper. +- `@netscript/database` keeps the root, adapter, script, tracing, and extension surfaces; this run + renames `./interfaces` to `./ports` with no compatibility alias. +- `@netscript/database/testing` is added for contract helpers and mock/in-memory database ports. +- `@netscript/prisma-adapter-mysql` remains a single-adapter root package; this run keeps its root + export, adds a canonical package-root `mod.ts` only if needed by the current package shape, and + excludes examples from publish. + +### Domain Vocabulary + +- KV vocabulary: `KvStore`, `WatchableKvStore`, `KvKey`, `KvProvider`, Redis/Deno KV/Kvdex adapter + variants, and in-memory test adapter. +- Database vocabulary: database client port, transaction/client capability, query result, SQL JSON + extension options, adapter technology variants (`postgres`, `mssql`, `mysql`), tracing controls, + and schema generation scripts. +- Prisma MySQL vocabulary: Prisma driver adapter, MySQL connection options, capabilities, conversion + helpers, and driver error mapping. + +### Ports + +- `kv` already owns store contracts under `types/`; this run does not widen the port surface, but + exposes a `./testing` contract helper that exercises the public KV behavior. +- `database` renames the consumed contracts from `interfaces/` to `ports/`; adapters import the port + contracts from the same package. +- `prisma-adapter-mysql` is a one-adapter package and does not invent a new port unless an existing + source contract already requires it. + +### Composition Roots + +- `kv` composition roots remain the existing factories and provider auto-detection functions, moved + from `core/` to `application/` when role naming changes. +- `database` composition roots remain the exported adapter factories and tracing helpers; defaults + stay explicit in exported functions, with no module-load-time connection acquisition. +- `prisma-adapter-mysql` composition stays in its adapter factory and connection opening functions; + removing `skipLibCheck` must not add hidden process/env reads. + +### Permissions + +- `kv` docs must declare `--unstable-kv`, network access for Redis, and environment access only + where provider auto-detection reads configuration. +- `database` docs must declare network/database connectivity permissions, file/process permissions + for scripts, and no implicit permissions for pure types. +- `prisma-adapter-mysql` docs must declare network access to MySQL and any npm/driver runtime + requirements. + +### Consumer-Import Impact + +- `@netscript/database/interfaces` is removed and replaced by `@netscript/database/ports`; the + approved plan recorded zero external consumers. +- `kv` subpaths do not change, so consumer impact is internal import updates only. +- `prisma-adapter-mysql` root import remains stable; examples are excluded from publish but not + removed. +- Final consumer gate checks CLI and plugins per the locked 2b slice 23. + +### Contributor Path + +- Add a new KV backend by opening `packages/kv/adapters/`, implementing the existing KV contract, + then adding a `./testing` contract invocation. +- Add a new database engine by adding a sibling under `packages/database/adapters/`, wiring the same + `ports/` contracts, documenting required permissions, and extending the adapter contract test. +- Extend Prisma MySQL behavior by updating focused files under `packages/prisma-adapter-mysql/src/`, + keeping driver-specific conversion separate from adapter lifecycle. + +### Commit Slices + +1. `kv`: consolidate `bridges/` into `adapters/`, rename `core/` to `application/`. +2. `kv`: update internal imports after rename. +3. `kv`: scaffold `/docs`. +4. `kv`: move root architecture content into `docs/architecture.md`. +5. `kv`: add `./testing` entrypoint with in-memory KV adapter/contract helper. +6. `kv`: add package lint/fmt/publish dry-run tasks. +7. `kv`: add docs example fixture test. +8. `kv`: verify publish dry-run and doc lint. +9. `database`: add metadata and publish hygiene. +10. `database`: rename `interfaces/` to `ports/` and update export. +11. `database`: fix the known slow type in `extensions/sql-json.extension.ts`. +12. `database`: fix doc-lint errors and public JSDoc. +13. `database`: write README with at least 150 lines. +14. `database`: scaffold `/docs`. +15. `database`: scaffold basic adapter contract tests. +16. `database`: add package lint/fmt/publish dry-run tasks. +17. `database`: verify publish dry-run and doc lint. +18. `prisma-adapter-mysql`: remove `skipLibCheck: true`. +19. `prisma-adapter-mysql`: fix type errors surfaced by removal. +20. `prisma-adapter-mysql`: fix doc-lint errors. +21. `prisma-adapter-mysql`: expand README and scaffold `/docs`. +22. `prisma-adapter-mysql`: add tasks and exclude `examples/` from publish. +23. `2b`: run consumer gate on CLI and plugins. + +### Deferred Scope + +- Real backend runtime tests remain deferred to S2 per the locked plan. +- `kv/tests/bridge_test.ts` AP-1 god-file debt remains open unless a slice must touch it for a + failing gate. +- Queue and cron changes are out of scope for this run. + +## Activity Log + +| Date | Slice | Activity | Evidence | +| ---------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-06-07 | Design | Created nested run directory and Design checkpoint for Wave 2b. | This file, `commits.md`, `drift.md`. | +| 2026-06-07 | 1-8 | Completed KV folder role rename, docs, `./testing`, tasks, doctest fixture, and package gates. | `deno task --cwd packages/kv check` PASS; `deno doc --lint packages/kv/mod.ts packages/kv/redis.ts packages/kv/kvdex.ts packages/kv/src/testing/mod.ts` PASS; `deno publish --dry-run --allow-dirty` PASS with 0 slow types; scoped fmt/lint PASS; docs fixture PASS. | +| 2026-06-07 | 9-17 | Completed database package metadata, `interfaces/` -> `ports/`, slow-type cleanup, public docs/JSDoc cleanup, README/docs, `./testing`, adapter contract tests, and package gates. | README 249 LOC; `rtk proxy deno task --cwd packages/database check` PASS; `deno doc --lint packages/database/mod.ts packages/database/ports/mod.ts packages/database/adapters/mod.ts packages/database/adapters/postgres.adapter.ts packages/database/adapters/mssql.adapter.ts packages/database/adapters/mysql.adapter.ts packages/database/extensions/mod.ts packages/database/scripts/mod.ts packages/database/prisma-tracing.ts packages/database/testing/mod.ts` PASS with upstream npm type-resolution warnings only; `deno test --allow-all packages/database/tests/adapter-contract_test.ts packages/database/tests/_fixtures/docs-examples_test.ts` PASS; `deno publish --dry-run --allow-dirty` PASS with 0 slow types; scoped fmt/lint PASS. | +| 2026-06-07 | 18-22 | Completed Prisma MySQL adapter package-root export, removed `skipLibCheck`, narrowed root public surface, added README/docs, tests, publish hygiene, and package gates. | README 302 LOC; `rtk proxy deno task --cwd packages/prisma-adapter-mysql check` PASS; `deno doc --lint packages/prisma-adapter-mysql/mod.ts` PASS; `deno test --allow-net --allow-env packages/prisma-adapter-mysql/tests/` PASS; `deno publish --dry-run --allow-dirty` PASS with 0 slow types; scoped fmt/lint PASS; `packages/prisma-adapter-mysql/src` console scan clean. | +| 2026-06-07 | 23 | Completed consumer-import gate and Gate handoff evidence. | `rtk proxy deno task --cwd packages/cli check` PASS; `rtk proxy deno task --cwd plugins/sagas check` PASS; `rtk proxy deno task --cwd plugins/streams check` PASS; `rtk proxy deno task --cwd plugins/triggers check` PASS; `rtk proxy deno task --cwd plugins/workers check` PASS; scoped doctrine checks for `packages/kv`, `packages/database`, and `packages/prisma-adapter-mysql` all exit 0 with FAIL=0; repo-wide `rtk proxy deno task arch:check` failed on out-of-scope existing doctrine debt and is recorded in drift. | diff --git a/packages/database/README.md b/packages/database/README.md new file mode 100644 index 000000000..9dc77bc8a --- /dev/null +++ b/packages/database/README.md @@ -0,0 +1,249 @@ +# @netscript/database + +Database adapter contracts, Prisma driver helpers, tracing, and schema tooling for NetScript. + +[![JSR](https://jsr.io/badges/@netscript/database)](https://jsr.io/@netscript/database) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](../../LICENSE) + +## Overview + +`@netscript/database` is the database integration package for NetScript applications and plugins. It +publishes the adapter contracts that database backends implement, plus focused helpers for Prisma +driver adapters, tracing, schema generation, and SQL JSON handling. + +The package does not own a user schema. User schemas stay in the application or plugin that owns the +data model. This package owns the reusable integration layer that lets those applications wire +PostgreSQL, SQL Server, MySQL, and future backends without copying connection and tracing code. + +Use this package when a NetScript component needs a database port, a Prisma driver adapter factory, +or a shared contract test for an adapter. Do not use it as a global singleton; the caller reads +configuration, chooses the backend, and passes typed options to the adapter or Prisma client. + +## Quickstart + +Install the package: + +```powershell +deno add jsr:@netscript/database@^0.0.1-alpha.0 +``` + +Create a PostgreSQL adapter: + +```ts +import { createPostgresAdapter } from 'jsr:@netscript/database@^0.0.1-alpha.0/adapters/postgres'; + +const adapter = createPostgresAdapter({ + connectionString: 'postgresql://app:secret@localhost:5432/app', +}); +``` + +Use the adapter with a generated Prisma client: + +```ts +import { PrismaClient } from './generated/client/mod.ts'; + +const prisma = new PrismaClient({ + adapter: adapter.getDriverAdapter(), +}); + +await prisma.$connect(); +await prisma.$disconnect(); +``` + +## Mental Model + +```text +application config + | + v +database adapter options + | + +-- ports/ DatabaseAdapter contract + +-- adapters/ postgres, mssql, mysql adapter factories + +-- extensions/ SQL JSON serialization extension + +-- tracing Prisma tracing helpers + +-- testing adapter contract runner +``` + +`ports/` defines the behavior NetScript needs from a database adapter. The contract is small: +connect, disconnect, health check, status, client access, and raw execution for migration or +diagnostic flows. + +`adapters/` contains technology-specific implementations. Each adapter is named after the backend it +wraps and implements the same port contract. + +`testing/` provides the shared contract runner. Downstream packages use it to prove their custom +adapter behaves like the first-party implementations. + +## API At A Glance + +| Entrypoint | Use | +| --------------------------------------- | ------------------------------------------------------------------- | +| `@netscript/database` | Root helper exports, connection string utilities, and common types. | +| `@netscript/database/ports` | `DatabaseAdapter`, provider, connection, and transaction contracts. | +| `@netscript/database/adapters` | Common adapter factory exports. | +| `@netscript/database/adapters/postgres` | PostgreSQL Prisma driver adapter factory. | +| `@netscript/database/adapters/mssql` | SQL Server Prisma driver adapter factory. | +| `@netscript/database/adapters/mysql` | MySQL adapter factory surface. | +| `@netscript/database/extensions` | SQL JSON Prisma extension. | +| `@netscript/database/scripts` | Schema and migration helper entrypoints. | +| `@netscript/database/tracing` | Prisma tracing helpers. | +| `@netscript/database/testing` | Mock adapter and reusable contract tests. | + +See [reference](./docs/reference/README.md) for the alpha entrypoint map. + +## Common Recipes + +### Create A PostgreSQL Adapter + +```ts +import { createPostgresAdapter } from 'jsr:@netscript/database@^0.0.1-alpha.0/adapters/postgres'; + +const adapter = createPostgresAdapter({ + connectionString: 'postgresql://app:secret@localhost:5432/app', +}); +``` + +### Create A SQL Server Adapter + +```ts +import { createMssqlAdapter } from 'jsr:@netscript/database@^0.0.1-alpha.0/adapters/mssql'; + +const adapter = createMssqlAdapter({ + connectionString: 'sqlserver://localhost:1433;database=app;user=sa;password=secret', +}); +``` + +### Build A Connection String + +```ts +import { buildPostgresConnectionString } from 'jsr:@netscript/database@^0.0.1-alpha.0'; + +const url = buildPostgresConnectionString({ + host: 'localhost', + port: 5432, + database: 'app', + user: 'app', + password: 'secret', +}); +``` + +### Run Adapter Contract Tests + +```ts +import { + createMockDatabaseAdapter, + runDatabaseAdapterContract, +} from 'jsr:@netscript/database@^0.0.1-alpha.0/testing'; + +runDatabaseAdapterContract({ + name: 'mock', + make: () => createMockDatabaseAdapter(), +}); +``` + +### Use SQL JSON Serialization + +```ts +import { sqlJsonExtension } from 'jsr:@netscript/database@^0.0.1-alpha.0/extensions'; + +const extension = sqlJsonExtension(Prisma, { + databaseType: 'mssql', + jsonFields: { + SagaInstance: ['state'], + }, +}); +``` + +## Configuration + +| Option | Applies to | Description | +| ----------------------- | ------------------ | --------------------------------------------------------------- | +| `connectionString` | Adapters | Full provider-specific database URL. | +| `host` / `port` | Adapters | Host and port when constructing provider URLs from parts. | +| `database` | Adapters | Database or schema name. | +| `username` / `password` | Adapters | Credentials passed to the driver adapter. | +| `ssl` | Adapters | Provider-specific SSL mode. | +| `poolSize` | Adapters | Connection pool size when supported by the backend. | +| `timeout` | Adapters | Connection timeout in milliseconds. | +| `jsonFields` | SQL JSON extension | Map of Prisma model names to JSON string fields. | +| `enableInstrumentation` | Shared config | Whether Prisma tracing helpers should be enabled by the caller. | + +Configuration is a typed boundary. Read environment variables in the application, validate them, and +pass typed options to the adapter. + +## Testing + +Use `@netscript/database/testing` for adapter contract tests: + +```ts +import { runDatabaseAdapterContract } from 'jsr:@netscript/database@^0.0.1-alpha.0/testing'; +import { createMyAdapter } from './my-adapter.ts'; + +runDatabaseAdapterContract({ + name: 'custom adapter', + make: () => createMyAdapter(), +}); +``` + +The package ships a mock adapter for unit tests that need a database port but do not need a real +database connection. + +## Observability + +Database adapters should expose provider, host, database, query kind, duration, and error fields to +the caller's logger or telemetry layer. This package provides Prisma tracing helpers through the +`./tracing` subpath but does not create global tracing state at module load time. + +Recommended fields: + +| Field | Meaning | +| ------------ | ----------------------------------------------------------------------- | +| `provider` | `postgres`, `mssql`, `mysql`, or `sqlite`. | +| `database` | Logical database name. | +| `operation` | `connect`, `disconnect`, `healthCheck`, `executeRaw`, or Prisma action. | +| `durationMs` | Operation duration in milliseconds. | +| `error` | Normalized error message when the operation fails. | + +## Architecture + +This is an Archetype 2 integration package. The port contract lives in `ports/`, backend +implementations live in `adapters/`, and tests consume the public `./testing` subpath. + +Read [docs/architecture.md](./docs/architecture.md) for the layer map and anti-pattern notes. + +## Stability And Versioning + +This package is part of the `0.0.1-alpha.0` quality wave. During alpha, public subpaths can still be +renamed when doctrine requires it. The `./interfaces` subpath was removed in this wave and replaced +by `./ports` without a compatibility shim. + +## Compatibility Matrix + +| Runtime | Status | +| -------- | ------------------------------------------------------------------------------- | +| Deno 2.x | Supported target. | +| Node.js | Supported through JSR/npm consumers when Prisma driver dependencies support it. | +| Browser | Not supported; database drivers require server-side APIs. | + +## Required Permissions + +Adapter usage requires network access to the database endpoint. Script entrypoints may need file +read/write and process permissions when generating or patching Prisma artifacts. Pure ports and +types require no permissions. + +## Contributing + +Run package checks before opening a PR: + +```powershell +deno task --cwd packages/database check +deno test --allow-all packages/database/tests/ +deno publish --dry-run --allow-dirty +``` + +Keep new adapters small, named by technology, and covered by the contract runner from `./testing`. + +## License + +MIT. diff --git a/packages/database/adapters/mssql.adapter.ts b/packages/database/adapters/mssql.adapter.ts index 7c04956b6..21ce4c888 100644 --- a/packages/database/adapters/mssql.adapter.ts +++ b/packages/database/adapters/mssql.adapter.ts @@ -13,7 +13,7 @@ import type { DatabaseConnectionOptions, DatabaseConnectionStatus, IsolationLevel, -} from '../interfaces/database-client.ts'; +} from '../ports/database-client.ts'; // ============================================================================ // MSSQL ADAPTER CONFIG TYPE @@ -22,28 +22,45 @@ import type { /** * Configuration object for @prisma/adapter-mssql. * This is what the PrismaMssql adapter expects. - * + * * Note: Windows Authentication (trustedConnection) requires the `tedious` driver * to be configured with NTLM authentication type, which is only supported on Windows. * For cross-platform compatibility, use SQL Server authentication (user/password). */ export interface MssqlAdapterConfig { + /** SQL Server host name or address. */ server: string; + /** SQL Server port. */ port?: number; + /** Database name. */ database: string; + /** SQL Server login user. */ user?: string; + /** SQL Server login password. */ password?: string; /** Authentication configuration for tedious */ authentication?: { - type: 'default' | 'ntlm' | 'azure-active-directory-password' | 'azure-active-directory-access-token'; + /** Tedious authentication mode. */ + type: + | 'default' + | 'ntlm' + | 'azure-active-directory-password' + | 'azure-active-directory-access-token'; + /** Authentication credentials for the selected tedious mode. */ options?: { + /** Login user name. */ userName?: string; + /** Login password. */ password?: string; + /** Windows domain for NTLM authentication. */ domain?: string; }; }; + /** Driver-level TLS and authentication options. */ options?: { + /** Whether to encrypt the connection. */ encrypt?: boolean; + /** Whether to trust the server certificate. */ trustServerCertificate?: boolean; /** @deprecated Use authentication.type = 'ntlm' instead */ trustedConnection?: boolean; @@ -70,19 +87,29 @@ export interface MssqlConnectionOptions extends DatabaseConnectionOptions { requestTimeout?: number; } +/** + * Public structural type returned by SQL Server driver adapter factories. + */ +export interface MssqlDriverAdapter { + /** Database provider identity. */ + readonly provider?: string; + /** Open a Prisma driver connection. */ + connect(): Promise; +} + // ============================================================================ // ADO.NET CONNECTION STRING PARSER // ============================================================================ /** * Parses an ADO.NET connection string into a config object for @prisma/adapter-mssql. - * + * * ADO.NET format: Server=host,port;Database=name;Integrated Security=True;TrustServerCertificate=True * Config format: { server: 'host', port: 1433, database: 'name', options: { ... } } - * + * * @param connectionString - ADO.NET format connection string * @returns MssqlAdapterConfig object for PrismaMssql - * + * * @example * ```typescript * const config = parseAdoNetConnectionString( @@ -116,11 +143,9 @@ export function parseAdoNetConnectionString(connectionString: string): MssqlAdap } const database = params['database'] || params['initial catalog'] || 'master'; - const integratedSecurity = - params['integrated security']?.toLowerCase() === 'true' || + const integratedSecurity = params['integrated security']?.toLowerCase() === 'true' || params['trusted_connection']?.toLowerCase() === 'true'; - const trustServerCertificate = - params['trustservercertificate']?.toLowerCase() === 'true' || + const trustServerCertificate = params['trustservercertificate']?.toLowerCase() === 'true' || params['trust server certificate']?.toLowerCase() === 'true'; const config: MssqlAdapterConfig = { @@ -139,7 +164,6 @@ export function parseAdoNetConnectionString(connectionString: string): MssqlAdap if (integratedSecurity) { // Windows Authentication using NTLM // This requires the tedious driver to use NTLM authentication type - console.log('⚠️ Windows Authentication (NTLM) requested from connection string'); config.authentication = { type: 'ntlm', options: { @@ -175,7 +199,7 @@ export function parseAdoNetConnectionString(connectionString: string): MssqlAdap * - MSSQL_TRUST_SERVER_CERTIFICATE * - MSSQL_USER (optional) * - MSSQL_PASSWORD (optional) - * + * * @param resourceName - The resource name prefix (default: 'MSSQL') * @returns MssqlAdapterConfig or null if required vars not set */ @@ -198,8 +222,10 @@ export function getMssqlConfigFromEnv(resourceName = 'MSSQL'): MssqlAdapterConfi } } - const integratedSecurity = Deno.env.get(`${prefix}_INTEGRATED_SECURITY`)?.toLowerCase() === 'true'; - const trustServerCertificate = Deno.env.get(`${prefix}_TRUST_SERVER_CERTIFICATE`)?.toLowerCase() === 'true'; + const integratedSecurity = + Deno.env.get(`${prefix}_INTEGRATED_SECURITY`)?.toLowerCase() === 'true'; + const trustServerCertificate = + Deno.env.get(`${prefix}_TRUST_SERVER_CERTIFICATE`)?.toLowerCase() === 'true'; const user = Deno.env.get(`${prefix}_USER`); const password = Deno.env.get(`${prefix}_PASSWORD`); @@ -220,7 +246,6 @@ export function getMssqlConfigFromEnv(resourceName = 'MSSQL'): MssqlAdapterConfi // Windows Authentication using NTLM // This requires the tedious driver to use NTLM authentication type // Note: This only works on Windows - console.log('⚠️ Windows Authentication (NTLM) requested - this only works on Windows'); config.authentication = { type: 'ntlm', options: { @@ -244,17 +269,17 @@ export function getMssqlConfigFromEnv(resourceName = 'MSSQL'): MssqlAdapterConfi * Gets MSSQL adapter configuration from environment. * Prefers structured env vars (MSSQL_SERVER, etc.) but falls back to * parsing ADO.NET connection string from the specified env var. - * + * * @param connectionStringEnvVar - Name of env var containing ADO.NET connection string (default: 'MSSQLDB_URI') * @returns MssqlAdapterConfig * @throws Error if no configuration is found - * + * * @example * ```typescript * // With structured env vars: * // MSSQL_SERVER=localhost, MSSQL_PORT=1433, MSSQL_DATABASE=mydb * const config = getMssqlConfig(); - * + * * // Or with connection string: * // MSSQLDB_URI=Server=localhost,1433;Database=mydb;... * const config = getMssqlConfig('MSSQLDB_URI'); @@ -264,7 +289,6 @@ export function getMssqlConfig(connectionStringEnvVar = 'MSSQLDB_URI'): MssqlAda // Try structured env vars first const structuredConfig = getMssqlConfigFromEnv(); if (structuredConfig) { - console.log(`📡 MSSQL config from env: ${structuredConfig.server}:${structuredConfig.port ?? 1433}/${structuredConfig.database}`); return structuredConfig; } @@ -278,7 +302,6 @@ export function getMssqlConfig(connectionStringEnvVar = 'MSSQLDB_URI'): MssqlAda } const parsedConfig = parseAdoNetConnectionString(connectionString); - console.log(`📡 MSSQL config from ADO.NET: ${parsedConfig.server}:${parsedConfig.port ?? 1433}/${parsedConfig.database}`); return parsedConfig; } @@ -314,20 +337,28 @@ export type MssqlIsolationLevel = IsolationLevel | 'Snapshot'; * const client = new PrismaClient({ adapter: adapter.getDriverAdapter() }); * ``` */ -export class MssqlAdapter; - $disconnect(): Promise; - $queryRaw: unknown; - $executeRaw: unknown; - $executeRawUnsafe: unknown; -}> implements DatabaseAdapter { +export class MssqlAdapter< + TClient extends { + $connect(): Promise; + $disconnect(): Promise; + $queryRaw: unknown; + $executeRaw: unknown; + $executeRawUnsafe: unknown; + }, +> implements DatabaseAdapter { + /** Database provider identity for status reporting. */ readonly provider = 'mssql' as const; private client: TClient | null = null; - private driverAdapter: PrismaMssql | null = null; + private driverAdapter: MssqlDriverAdapter | null = null; private readonly options: MssqlConnectionOptions; private lastConnected?: Date; + /** + * Create a SQL Server adapter from connection options. + * + * @param options - SQL Server connection options. + */ constructor(options: MssqlConnectionOptions) { this.options = options; } @@ -336,10 +367,10 @@ export class MssqlAdapter { if (!this.client) { throw new Error('SQL Server client not set. Call setClient() first.'); @@ -416,12 +449,14 @@ export class MssqlAdapter { if (this.client) { await this.client.$disconnect(); } } + /** Probe the database connection with a lightweight query. */ async healthCheck(): Promise { try { const client = this.getClient(); @@ -437,6 +472,7 @@ export class MssqlAdapter { const healthy = await this.healthCheck(); return { @@ -449,6 +485,7 @@ export class MssqlAdapter(query: string, ...params: unknown[]): Promise { const client = this.getClient(); const $queryRaw = client.$queryRaw as ( @@ -458,6 +495,7 @@ export class MssqlAdapter(query: string, ...params: unknown[]): Promise { const client = this.getClient(); const $executeRawUnsafe = client.$executeRawUnsafe as ( @@ -471,12 +509,14 @@ export class MssqlAdapter; - $disconnect(): Promise; - $queryRaw: unknown; - $executeRaw: unknown; - $executeRawUnsafe: unknown; -}>(options: MssqlConnectionOptions): MssqlAdapter { +export function createMssqlAdapter< + TClient extends { + $connect(): Promise; + $disconnect(): Promise; + $queryRaw: unknown; + $executeRaw: unknown; + $executeRawUnsafe: unknown; + }, +>(options: MssqlConnectionOptions): MssqlAdapter { return new MssqlAdapter(options); } diff --git a/packages/database/adapters/mysql.adapter.ts b/packages/database/adapters/mysql.adapter.ts index 37d444b47..c217e33f7 100644 --- a/packages/database/adapters/mysql.adapter.ts +++ b/packages/database/adapters/mysql.adapter.ts @@ -19,8 +19,7 @@ import type { DatabaseAdapter, DatabaseConnectionOptions, DatabaseConnectionStatus, - IsolationLevel, -} from '../interfaces/database-client.ts'; +} from '../ports/database-client.ts'; // ============================================================================ // MYSQL ADAPTER CONFIG TYPE @@ -47,7 +46,9 @@ export interface MysqlAdapterConfig { timeout?: number; /** TLS configuration */ tls?: { - mode?: "disabled" | "verify_identity"; + /** TLS verification mode. */ + mode?: 'disabled' | 'verify_identity'; + /** CA certificates used for TLS verification. */ caCerts?: string[]; }; } @@ -66,6 +67,16 @@ export interface MysqlConnectionOptions extends DatabaseConnectionOptions { multipleStatements?: boolean; } +/** + * Public structural type returned by MySQL driver adapter factories. + */ +export interface MysqlDriverAdapter { + /** Database provider identity. */ + readonly provider?: string; + /** Open a Prisma driver connection. */ + connect(): Promise; +} + // ============================================================================ // CONNECTION STRING PARSER // ============================================================================ @@ -195,7 +206,6 @@ export function getMysqlConfig(connectionStringEnvVar = 'MYSQLDB_URI'): MysqlAda // Try structured env vars first const structuredConfig = getMysqlConfigFromEnv(); if (structuredConfig) { - console.log(`📡 MySQL config from env: ${structuredConfig.hostname}:${structuredConfig.port ?? 3306}/${structuredConfig.db}`); return structuredConfig; } @@ -209,7 +219,6 @@ export function getMysqlConfig(connectionStringEnvVar = 'MYSQLDB_URI'): MysqlAda } const parsedConfig = parseMysqlConnectionString(connectionString); - console.log(`📡 MySQL config from URI: ${parsedConfig.hostname}:${parsedConfig.port ?? 3306}/${parsedConfig.db}`); return parsedConfig; } @@ -230,12 +239,12 @@ export function buildMysqlConnectionString(parts: { const { hostname, port = 3306, db, username, password, ssl } = parts; const encodedPassword = password ? encodeURIComponent(password) : ''; const auth = password ? `${username}:${encodedPassword}@` : `${username}@`; - + const params: string[] = []; if (ssl) params.push('ssl=true'); - + const queryString = params.length > 0 ? `?${params.join('&')}` : ''; - + return `mysql://${auth}${hostname}:${port}/${db}${queryString}`; } @@ -269,20 +278,28 @@ export function buildMysqlConnectionString(parts: { * const client = new PrismaClient({ adapter: adapter.getDriverAdapter() }); * ``` */ -export class MysqlAdapter; - $disconnect(): Promise; - $queryRaw: unknown; - $executeRaw: unknown; - $executeRawUnsafe: unknown; -}> implements DatabaseAdapter { +export class MysqlAdapter< + TClient extends { + $connect(): Promise; + $disconnect(): Promise; + $queryRaw: unknown; + $executeRaw: unknown; + $executeRawUnsafe: unknown; + }, +> implements DatabaseAdapter { + /** Database provider identity for status reporting. */ readonly provider = 'mysql' as const; private client: TClient | null = null; - private driverAdapter: PrismaMySql | null = null; + private driverAdapter: MysqlDriverAdapter | null = null; private readonly options: MysqlConnectionOptions; private lastConnected?: Date; + /** + * Create a MySQL adapter from connection options. + * + * @param options - MySQL connection options. + */ constructor(options: MysqlConnectionOptions) { this.options = options; } @@ -290,10 +307,10 @@ export class MysqlAdapter { if (!this.client) { throw new Error('MySQL client not set. Call setClient() first.'); @@ -368,12 +387,14 @@ export class MysqlAdapter { if (this.client) { await this.client.$disconnect(); } } + /** Probe the database connection with a lightweight query. */ async healthCheck(): Promise { try { const client = this.getClient(); @@ -388,6 +409,7 @@ export class MysqlAdapter { const healthy = await this.healthCheck(); return { @@ -400,6 +422,7 @@ export class MysqlAdapter(query: string, ...params: unknown[]): Promise { const client = this.getClient(); const $queryRaw = client.$queryRaw as ( @@ -409,6 +432,7 @@ export class MysqlAdapter(query: string, ...params: unknown[]): Promise { const client = this.getClient(); const $executeRawUnsafe = client.$executeRawUnsafe as ( @@ -453,12 +477,14 @@ export class MysqlAdapter; - $disconnect(): Promise; - $queryRaw: unknown; - $executeRaw: unknown; - $executeRawUnsafe: unknown; -}>(options: MysqlConnectionOptions): MysqlAdapter { +export function createMysqlAdapter< + TClient extends { + $connect(): Promise; + $disconnect(): Promise; + $queryRaw: unknown; + $executeRaw: unknown; + $executeRawUnsafe: unknown; + }, +>(options: MysqlConnectionOptions): MysqlAdapter { return new MysqlAdapter(options); } diff --git a/packages/database/adapters/postgres.adapter.ts b/packages/database/adapters/postgres.adapter.ts index a3a76b019..1fd678f78 100644 --- a/packages/database/adapters/postgres.adapter.ts +++ b/packages/database/adapters/postgres.adapter.ts @@ -11,7 +11,7 @@ import type { DatabaseAdapter, DatabaseConnectionOptions, DatabaseConnectionStatus, -} from '../interfaces/database-client.ts'; +} from '../ports/database-client.ts'; /** * PostgreSQL-specific connection options @@ -23,6 +23,18 @@ export interface PostgresConnectionOptions extends DatabaseConnectionOptions { applicationName?: string; } +/** + * Public structural type returned by PostgreSQL driver adapter factories. + */ +export interface PostgresDriverAdapter { + /** Database provider identity. */ + readonly provider?: string; + /** Open a Prisma driver connection. */ + connect(): Promise; + /** Open a shadow database connection when supported by the driver. */ + connectToShadowDb?(): Promise; +} + /** * PostgreSQL Adapter * @@ -40,20 +52,28 @@ export interface PostgresConnectionOptions extends DatabaseConnectionOptions { * const client = new PrismaClient({ adapter: adapter.getDriverAdapter() }); * ``` */ -export class PostgresAdapter; - $disconnect(): Promise; - $queryRaw: unknown; - $executeRaw: unknown; - $executeRawUnsafe: unknown; -}> implements DatabaseAdapter { +export class PostgresAdapter< + TClient extends { + $connect(): Promise; + $disconnect(): Promise; + $queryRaw: unknown; + $executeRaw: unknown; + $executeRawUnsafe: unknown; + }, +> implements DatabaseAdapter { + /** Database provider identity for status reporting. */ readonly provider = 'postgres' as const; private client: TClient | null = null; - private driverAdapter: PrismaPg | null = null; + private driverAdapter: PostgresDriverAdapter | null = null; private readonly options: PostgresConnectionOptions; private lastConnected?: Date; + /** + * Create a PostgreSQL adapter from connection options. + * + * @param options - PostgreSQL connection options. + */ constructor(options: PostgresConnectionOptions) { this.options = options; } @@ -61,10 +81,10 @@ export class PostgresAdapter { if (!this.client) { throw new Error('PostgreSQL client not set. Call setClient() first.'); @@ -94,12 +116,14 @@ export class PostgresAdapter { if (this.client) { await this.client.$disconnect(); } } + /** Probe the database connection with a lightweight query. */ async healthCheck(): Promise { try { const client = this.getClient(); @@ -115,6 +139,7 @@ export class PostgresAdapter { const healthy = await this.healthCheck(); return { @@ -127,6 +152,7 @@ export class PostgresAdapter(query: string, ...params: unknown[]): Promise { const client = this.getClient(); const $queryRaw = client.$queryRaw as ( @@ -136,6 +162,7 @@ export class PostgresAdapter(query: string, ...params: unknown[]): Promise { const client = this.getClient(); const $executeRawUnsafe = client.$executeRawUnsafe as ( @@ -185,12 +212,14 @@ export class PostgresAdapter; - $disconnect(): Promise; - $queryRaw: unknown; - $executeRaw: unknown; - $executeRawUnsafe: unknown; -}>(options: PostgresConnectionOptions): PostgresAdapter { +export function createPostgresAdapter< + TClient extends { + $connect(): Promise; + $disconnect(): Promise; + $queryRaw: unknown; + $executeRaw: unknown; + $executeRawUnsafe: unknown; + }, +>(options: PostgresConnectionOptions): PostgresAdapter { return new PostgresAdapter(options); } diff --git a/packages/database/deno.json b/packages/database/deno.json index ecd537b9e..2d77fec1b 100644 --- a/packages/database/deno.json +++ b/packages/database/deno.json @@ -1,16 +1,26 @@ { "name": "@netscript/database", "version": "0.0.1-alpha.0", + "description": "Database adapter contracts, Prisma driver helpers, tracing, and schema tooling for NetScript packages.", + "license": "MIT", "exports": { ".": "./mod.ts", - "./interfaces": "./interfaces/mod.ts", + "./ports": "./ports/mod.ts", "./adapters": "./adapters/mod.ts", "./adapters/postgres": "./adapters/postgres.adapter.ts", "./adapters/mssql": "./adapters/mssql.adapter.ts", "./adapters/mysql": "./adapters/mysql.adapter.ts", "./extensions": "./extensions/mod.ts", "./scripts": "./scripts/mod.ts", - "./tracing": "./prisma-tracing.ts" + "./tracing": "./prisma-tracing.ts", + "./testing": "./testing/mod.ts" + }, + "tasks": { + "check": "deno check --unstable-kv ./mod.ts ./ports/mod.ts ./adapters/mod.ts ./adapters/postgres.adapter.ts ./adapters/mssql.adapter.ts ./adapters/mysql.adapter.ts ./extensions/mod.ts ./scripts/mod.ts ./prisma-tracing.ts ./testing/mod.ts", + "test": "deno test --allow-all ./tests/", + "lint": "deno lint", + "fmt": "deno fmt --check", + "publish:dry-run": "deno publish --dry-run --allow-dirty" }, "imports": { "@prisma/client": "npm:@prisma/client@^7.4.2", @@ -19,5 +29,25 @@ "@prisma/adapter-pg": "npm:@prisma/adapter-pg@^7.4.2", "@prisma/adapter-mssql": "npm:@prisma/adapter-mssql@^7.4.2", "pg": "npm:pg@^8.13.1" + }, + "publish": { + "include": [ + "README.md", + "docs/**/*.md", + "deno.json", + "mod.ts", + "ports/**/*.ts", + "adapters/**/*.ts", + "extensions/**/*.ts", + "scripts/**/*.ts", + "prisma-tracing.ts", + "testing/**/*.ts" + ], + "exclude": [ + "**/*_test.ts", + "**/*.test.ts", + "tests/**", + "examples/**" + ] } } diff --git a/packages/database/docs/README.md b/packages/database/docs/README.md new file mode 100644 index 000000000..b1be5354f --- /dev/null +++ b/packages/database/docs/README.md @@ -0,0 +1,21 @@ +--- +title: NetScript Database +description: Documentation hub for database ports, adapters, extensions, scripts, and tests. +package: '@netscript/database' +order: 0 +--- + +# NetScript Database + +`@netscript/database` provides the database adapter contracts and Prisma integration helpers used by +NetScript packages. + +## Contents + +| Page | Purpose | +| --------------------------------------- | ----------------------------------------------------------- | +| [Architecture](./architecture.md) | A2 layer map and public surface. | +| [Concepts](./concepts.md) | Provider, adapter, port, tracing, and extension vocabulary. | +| [Getting started](./getting-started.md) | First-run guide for adapter creation and testing. | +| [Recipes](./recipes/README.md) | Task recipes for adapters, JSON fields, and contract tests. | +| [Reference](./reference/README.md) | Entrypoint map for the alpha API surface. | diff --git a/packages/database/docs/architecture.md b/packages/database/docs/architecture.md new file mode 100644 index 000000000..8e65b162f --- /dev/null +++ b/packages/database/docs/architecture.md @@ -0,0 +1,51 @@ +--- +title: Database Architecture +description: A2 integration architecture for @netscript/database. +package: '@netscript/database' +order: 1 +--- + +# Database Architecture + +Archetype: 2 + +`@netscript/database` implements Archetype 2 (Integration). It owns database adapter contracts and +ships named adapters and helper entrypoints for Prisma-backed database integrations. + +## Layer Diagram + +```text +mod.ts / subpaths + | + +-- ports/ DatabaseAdapter and shared database contracts + +-- adapters/ postgres, mssql, mysql implementations + +-- extensions/ SQL JSON Prisma extension + +-- scripts/ schema and migration tooling entrypoints + +-- tracing Prisma tracing helpers + +-- testing/ mock adapter and adapter contract runner +``` + +## Public Surface Map + +| Entrypoint | Purpose | +| -------------------------------- | ----------------------------------------------- | +| `@netscript/database` | Root utility and common adapter exports. | +| `@netscript/database/ports` | Contracts consumed by adapters and tests. | +| `@netscript/database/adapters/*` | Technology-specific adapter implementations. | +| `@netscript/database/extensions` | SQL JSON extension and field registry helpers. | +| `@netscript/database/scripts` | Prisma schema generation and migration helpers. | +| `@netscript/database/tracing` | Prisma query tracing helpers. | +| `@netscript/database/testing` | Mock adapter and contract runner. | + +## Axioms In Play + +- A1: public contracts are explicit and exported through `ports/`. +- A7: adapters wrap Prisma and driver APIs directly instead of reimplementing drivers. +- A8: `interfaces/` was renamed to `ports/` so the folder name describes its role. +- A10: callers own configuration and resource lifecycle. + +## Anti-Patterns Avoided + +- AP-11: adapters do not create a global client at module load time. +- AP-17: the package no longer has an `interfaces/` folder. +- AP-19: README documents permissions for network, scripts, and pure type usage. diff --git a/packages/database/docs/concepts.md b/packages/database/docs/concepts.md new file mode 100644 index 000000000..dac4f3392 --- /dev/null +++ b/packages/database/docs/concepts.md @@ -0,0 +1,32 @@ +--- +title: Database Concepts +description: Glossary for @netscript/database contracts, adapters, tracing, and extensions. +package: '@netscript/database' +order: 2 +--- + +# Database Concepts + +## Adapter + +An adapter implements `DatabaseAdapter` for a concrete backend. It owns driver-specific wiring but +not the application schema. + +## Port + +The port is the small database behavior contract consumed by NetScript packages. It lives in +`ports/` and is tested through `./testing`. + +## Provider + +A provider names a backend family such as `postgres`, `mssql`, `mysql`, or `sqlite`. + +## SQL JSON Extension + +The SQL JSON extension serializes and deserializes fields stored as strings when a backend lacks a +native JSON type for the selected schema. + +## Tracing + +Tracing helpers wrap Prisma operations so callers can attach structured spans without creating +global telemetry state inside the package. diff --git a/packages/database/docs/getting-started.md b/packages/database/docs/getting-started.md new file mode 100644 index 000000000..a09a52652 --- /dev/null +++ b/packages/database/docs/getting-started.md @@ -0,0 +1,40 @@ +--- +title: Getting Started +description: First run guide for database adapters and contract tests. +package: '@netscript/database' +order: 3 +--- + +# Getting Started + +Install the package: + +```powershell +deno add jsr:@netscript/database@^0.0.1-alpha.0 +``` + +Create an adapter: + +```ts +import { createPostgresAdapter } from 'jsr:@netscript/database@^0.0.1-alpha.0/adapters/postgres'; + +const adapter = createPostgresAdapter({ + connectionString: 'postgresql://app:secret@localhost:5432/app', +}); +``` + +Run a contract test: + +```ts +import { + createMockDatabaseAdapter, + runDatabaseAdapterContract, +} from 'jsr:@netscript/database@^0.0.1-alpha.0/testing'; + +runDatabaseAdapterContract({ + name: 'mock', + make: () => createMockDatabaseAdapter(), +}); +``` + +Grant network access when connecting to a real database. diff --git a/packages/database/docs/recipes/README.md b/packages/database/docs/recipes/README.md new file mode 100644 index 000000000..901c90171 --- /dev/null +++ b/packages/database/docs/recipes/README.md @@ -0,0 +1,14 @@ +--- +title: Database Recipes +description: Recipes for database adapter creation, JSON serialization, and contract testing. +package: '@netscript/database' +order: 0 +--- + +# Database Recipes + +| Recipe | Goal | +| ------------------------- | -------------------------------------------------------- | +| [Adapters](./adapters.md) | Create provider-specific Prisma driver adapters. | +| [SQL JSON](./sql-json.md) | Configure JSON serialization for SQL text fields. | +| [Testing](./testing.md) | Verify adapter behavior with the shared contract runner. | diff --git a/packages/database/docs/recipes/adapters.md b/packages/database/docs/recipes/adapters.md new file mode 100644 index 000000000..b01074e59 --- /dev/null +++ b/packages/database/docs/recipes/adapters.md @@ -0,0 +1,25 @@ +--- +title: Database Adapters +description: Create provider-specific adapters for Prisma clients. +package: '@netscript/database' +order: 1 +--- + +# Database Adapters + +## Goal + +Create the adapter that matches the database provider selected by the application. + +```ts +import { createPostgresAdapter } from 'jsr:@netscript/database@^0.0.1-alpha.0/adapters/postgres'; + +const adapter = createPostgresAdapter({ + connectionString: 'postgresql://app:secret@localhost:5432/app', +}); +``` + +## Pitfalls + +Do not read environment variables inside reusable package code. Read and validate them in the +application composition root. diff --git a/packages/database/docs/recipes/sql-json.md b/packages/database/docs/recipes/sql-json.md new file mode 100644 index 000000000..c569f2352 --- /dev/null +++ b/packages/database/docs/recipes/sql-json.md @@ -0,0 +1,28 @@ +--- +title: SQL JSON Fields +description: Configure the SQL JSON extension for fields stored as text. +package: '@netscript/database' +order: 2 +--- + +# SQL JSON Fields + +## Goal + +Serialize object fields when the selected SQL backend stores JSON as text. + +```ts +import { sqlJsonExtension } from 'jsr:@netscript/database@^0.0.1-alpha.0/extensions'; + +const extension = sqlJsonExtension(Prisma, { + databaseType: 'mssql', + jsonFields: { + SagaInstance: ['state'], + }, +}); +``` + +## Pitfalls + +Keep the field registry close to the Prisma schema owner. This package provides the mechanism, not +the user schema. diff --git a/packages/database/docs/recipes/testing.md b/packages/database/docs/recipes/testing.md new file mode 100644 index 000000000..3b243880e --- /dev/null +++ b/packages/database/docs/recipes/testing.md @@ -0,0 +1,28 @@ +--- +title: Database Testing +description: Use @netscript/database/testing to verify adapter contract behavior. +package: '@netscript/database' +order: 3 +--- + +# Database Testing + +## Goal + +Run the same contract against every database adapter. + +```ts +import { + createMockDatabaseAdapter, + runDatabaseAdapterContract, +} from 'jsr:@netscript/database@^0.0.1-alpha.0/testing'; + +runDatabaseAdapterContract({ + name: 'mock', + make: () => createMockDatabaseAdapter(), +}); +``` + +## Pitfalls + +The contract creates a fresh adapter per scenario. Do not share live clients between tests. diff --git a/packages/database/docs/reference/README.md b/packages/database/docs/reference/README.md new file mode 100644 index 000000000..0b14c815f --- /dev/null +++ b/packages/database/docs/reference/README.md @@ -0,0 +1,22 @@ +--- +title: Database Reference +description: Alpha entrypoint map for @netscript/database. +package: '@netscript/database' +order: 0 +--- + +# Database Reference + +Reference pages are generated from `deno doc` in a later wave. During alpha, use this entrypoint map +and JSR API docs. + +| Entrypoint | Primary symbols | +| --------------------------------------- | ------------------------------------------------------------------------ | +| `@netscript/database` | connection string helpers, transactions, tracing convenience exports. | +| `@netscript/database/ports` | `DatabaseAdapter`, `DatabaseProvider`, connection and transaction types. | +| `@netscript/database/adapters/postgres` | `PostgresAdapter`, `createPostgresAdapter`. | +| `@netscript/database/adapters/mssql` | `MssqlAdapter`, `createMssqlAdapter`. | +| `@netscript/database/adapters/mysql` | `MysqlAdapter`, `createMysqlAdapter`. | +| `@netscript/database/extensions` | `sqlJsonExtension`, `registerJsonFields`, `jsonUtils`. | +| `@netscript/database/scripts` | schema and migration helper functions. | +| `@netscript/database/testing` | `MockDatabaseAdapter`, `createMockDatabaseAdapter`, contract runner. | diff --git a/packages/database/extensions/mod.ts b/packages/database/extensions/mod.ts index 388a011f5..e7a5da534 100644 --- a/packages/database/extensions/mod.ts +++ b/packages/database/extensions/mod.ts @@ -1,20 +1,23 @@ // SQL JSON Extension - handles JSON serialization for databases that store JSON as text // (MSSQL stores as NVARCHAR(MAX), MySQL can use TEXT/VARCHAR instead of native JSON) export { - // Main extension factory (generic) - sqlJsonExtension, + type JsonField, + type JsonFieldConfig, + jsonUtils, // Database-specific aliases mssqlJsonExtension, mysqlJsonExtension, + type PrismaExtensionConfig, + type PrismaQueryContext, + type PrismaQueryHandler, + // Utilities + registerJsonFields, // Configuration and types type SqlDatabaseType, + // Main extension factory (generic) + sqlJsonExtension, type SqlJsonExtensionOptions, - type JsonFieldConfig, - // Utilities - registerJsonFields, - jsonUtils, - type JsonField, } from './sql-json.extension.ts'; // Re-export default for convenience -export { default as sqlJsonExtension_default } from './sql-json.extension.ts'; \ No newline at end of file +export { default as sqlJsonExtension_default } from './sql-json.extension.ts'; diff --git a/packages/database/extensions/sql-json.extension.ts b/packages/database/extensions/sql-json.extension.ts index 281f55323..7c669d7eb 100644 --- a/packages/database/extensions/sql-json.extension.ts +++ b/packages/database/extensions/sql-json.extension.ts @@ -35,6 +35,22 @@ export type SqlDatabaseType = 'mssql' | 'mysql' | 'generic'; */ export type JsonFieldConfig = Record; +const debugLogEncoder = new TextEncoder(); + +function formatDebugLogValue(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function writeDebugLog(name: string, message: string, ...args: unknown[]): void { + const suffix = args.length > 0 ? ` ${args.map(formatDebugLogValue).join(' ')}` : ''; + Deno.stderr.writeSync(debugLogEncoder.encode(`[${name}] ${message}${suffix}\n`)); +} + /** * Options for creating the SQL JSON extension. */ @@ -73,13 +89,13 @@ export interface SqlJsonExtensionOptions { } // ============================================================================ -// DEFAULT CONFIGURATIONS +// DEFAULT CONFIGURATIONS // ============================================================================ /** * Empty default JSON fields configuration. * Consumers should provide their own configuration via options. - * + * * @example * ```typescript * // Define your project's JSON fields @@ -87,7 +103,7 @@ export interface SqlJsonExtensionOptions { * SagaInstance: ['state'], * JobDefinition: ['config', 'metadata'], * }; - * + * * // Pass to the extension * const extension = sqlJsonExtension(Prisma, { jsonFields: JSON_FIELDS }); * ``` @@ -198,7 +214,7 @@ function fromJsonString(value: unknown): T | null { */ function serializeData( model: string, - data: Record + data: Record, ): Record { const fields = getJsonFields(model); if (fields.length === 0) return data; @@ -218,7 +234,7 @@ function serializeData( */ function deserializeData( model: string, - data: Record + data: Record, ): Record { const fields = getJsonFields(model); if (fields.length === 0) return data; @@ -237,7 +253,7 @@ function deserializeData( */ function serializeNestedData( model: string, - data: Record + data: Record, ): Record { const result = serializeData(model, data); @@ -273,21 +289,42 @@ function serializeNestedData( /** * Query context passed by Prisma to extension handlers. */ -interface PrismaQueryContext { +export interface PrismaQueryContext { + /** Model name for the intercepted Prisma operation. */ model?: string; + /** Operation arguments passed to Prisma. */ args: Record; + /** Continue the intercepted Prisma operation. */ query: (args: Record) => Promise; } +/** + * Query handler used by Prisma extension configuration. + */ +export type PrismaQueryHandler = (context: PrismaQueryContext) => Promise; + +/** + * Prisma extension configuration generated by SQL JSON helpers. + */ +export interface PrismaExtensionConfig { + /** Extension name shown in Prisma diagnostics. */ + readonly name: string; + /** Query interception handlers keyed by Prisma model operation. */ + readonly query: { + /** Operation handlers applied to all Prisma models. */ + readonly $allModels: Record; + }; +} + /** * Creates the extension configuration object. * This function is called with options to create a customized config. */ -function createExtensionConfig(options: SqlJsonExtensionOptions) { +function createExtensionConfig(options: SqlJsonExtensionOptions): PrismaExtensionConfig { const { name = 'sql-json-extension', debug = false } = options; const log = debug - ? (msg: string, ...args: unknown[]) => console.log(`[${name}] ${msg}`, ...args) + ? (message: string, ...args: unknown[]) => writeDebugLog(name, message, ...args) : () => {}; return { @@ -318,7 +355,8 @@ function createExtensionConfig(options: SqlJsonExtensionOptions) { args.data = serializeData(model, args.data as Record); } } - return query(args); + const result = await query(args); + return result; }, async createManyAndReturn({ model, args, query }: PrismaQueryContext) { @@ -355,7 +393,8 @@ function createExtensionConfig(options: SqlJsonExtensionOptions) { log('updateMany', model, 'serializing data'); args.data = serializeData(model, args.data as Record); } - return query(args); + const result = await query(args); + return result; }, async updateManyAndReturn({ model, args, query }: PrismaQueryContext) { @@ -396,9 +435,7 @@ function createExtensionConfig(options: SqlJsonExtensionOptions) { async findUniqueOrThrow({ model, args, query }: PrismaQueryContext) { const result = await query(args); - return model - ? deserializeData(model, result as Record) - : result; + return model ? deserializeData(model, result as Record) : result; }, async findFirst({ model, args, query }: PrismaQueryContext) { @@ -410,9 +447,7 @@ function createExtensionConfig(options: SqlJsonExtensionOptions) { async findFirstOrThrow({ model, args, query }: PrismaQueryContext) { const result = await query(args); - return model - ? deserializeData(model, result as Record) - : result; + return model ? deserializeData(model, result as Record) : result; }, async findMany({ model, args, query }: PrismaQueryContext) { @@ -433,7 +468,8 @@ function createExtensionConfig(options: SqlJsonExtensionOptions) { // Pass through - no transformation needed async deleteMany({ args, query }: PrismaQueryContext) { - return query(args); + const result = await query(args); + return result; }, async deleteManyAndReturn({ model, args, query }: PrismaQueryContext) { @@ -490,11 +526,13 @@ function createExtensionConfig(options: SqlJsonExtensionOptions) { * }); * * const saga = await prisma.sagaInstance.findFirst(); - * console.log(saga.state.orderId); // Automatically parsed back to object + * saga.state.orderId; // Automatically parsed back to object * ``` */ export function sqlJsonExtension< - P extends { defineExtension: (config: ReturnType) => ReturnType } + P extends { + defineExtension: (config: PrismaExtensionConfig) => ReturnType; + }, >(Prisma: P, options: SqlJsonExtensionOptions = {}): ReturnType { // Initialize the registry with provided JSON fields if (options.jsonFields) { @@ -510,14 +548,19 @@ export function sqlJsonExtension< // ============================================================================ /** - * @deprecated Use `sqlJsonExtension` instead. This alias is kept for backward compatibility. - * * Creates a Prisma extension for MSSQL JSON serialization. * This is an alias for `sqlJsonExtension` with `databaseType: 'mssql'`. + * + * @deprecated Use `sqlJsonExtension` instead. */ export function mssqlJsonExtension< - P extends { defineExtension: (config: ReturnType) => ReturnType } ->(Prisma: P, options: Omit = {}): ReturnType { + P extends { + defineExtension: (config: PrismaExtensionConfig) => ReturnType; + }, +>( + Prisma: P, + options: Omit = {}, +): ReturnType { return sqlJsonExtension(Prisma, { ...options, databaseType: 'mssql' }); } @@ -526,8 +569,13 @@ export function mssqlJsonExtension< * This is an alias for `sqlJsonExtension` with `databaseType: 'mysql'`. */ export function mysqlJsonExtension< - P extends { defineExtension: (config: ReturnType) => ReturnType } ->(Prisma: P, options: Omit = {}): ReturnType { + P extends { + defineExtension: (config: PrismaExtensionConfig) => ReturnType; + }, +>( + Prisma: P, + options: Omit = {}, +): ReturnType { return sqlJsonExtension(Prisma, { ...options, databaseType: 'mysql' }); } @@ -539,21 +587,35 @@ export function mysqlJsonExtension< * Export serialization utilities for manual use if needed. * These are useful when working with raw queries or custom operations. */ -export const jsonUtils = { +export const jsonUtils: { + readonly toJsonString: (value: unknown) => string | null; + readonly fromJsonString: (value: unknown) => T | null; + readonly serializeData: ( + model: string, + data: Record, + ) => Record; + readonly deserializeData: ( + model: string, + data: Record, + ) => Record; + readonly getJsonFields: (model: string) => readonly string[]; + readonly hasJsonFields: (model: string) => boolean; + readonly resetRegistry: () => void; +} = { /** Convert value to JSON string for database storage */ - toJsonString, + toJsonString: toJsonString, /** Parse JSON string from database to object */ - fromJsonString, + fromJsonString: fromJsonString, /** Serialize JSON fields in a data object */ - serializeData, + serializeData: serializeData, /** Deserialize JSON fields in a result object */ - deserializeData, + deserializeData: deserializeData, /** Get configured JSON fields for a model */ - getJsonFields, + getJsonFields: getJsonFields, /** Check if a model has JSON fields configured */ - hasJsonFields, + hasJsonFields: hasJsonFields, /** Reset registry to defaults */ - resetRegistry, + resetRegistry: resetRegistry, } as const; /** diff --git a/packages/database/mod.ts b/packages/database/mod.ts index ea21d64c5..a882be990 100644 --- a/packages/database/mod.ts +++ b/packages/database/mod.ts @@ -32,7 +32,6 @@ * @module */ -import { PrismaPg } from '@prisma/adapter-pg'; import { enablePrismaTracing } from './prisma-tracing.ts'; // ============================================================================ @@ -54,11 +53,8 @@ export function enableInstrumentation(): boolean { if (Deno.env.get('OTEL_DENO') === 'true') { try { enablePrismaTracing(); - console.log('🔭 Prisma instrumentation enabled'); return true; - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - console.warn('⚠️ Prisma instrumentation failed:', message); + } catch { return false; } } @@ -78,7 +74,7 @@ export type { IsolationLevel, SharedDatabaseConfig, TransactionOptions, -} from './interfaces/mod.ts'; +} from './ports/mod.ts'; // ============================================================================ // ADAPTER EXPORTS @@ -107,7 +103,7 @@ export { jsonUtils, mssqlJsonExtension, registerJsonFields } from './extensions/ // TRANSACTION HELPERS // ============================================================================ -import type { TransactionOptions } from './interfaces/mod.ts'; +import type { TransactionOptions } from './ports/mod.ts'; /** * Execute operations within a transaction @@ -253,12 +249,9 @@ export function buildMssqlConnectionString(parts: { } /** + * Backward-compatible alias for PostgreSQL connection string construction. + * * @deprecated Use buildPostgresConnectionString instead */ -export const buildConnectionString = buildPostgresConnectionString; - -// ============================================================================ -// RE-EXPORTS -// ============================================================================ - -export { PrismaPg }; +export const buildConnectionString: typeof buildPostgresConnectionString = + buildPostgresConnectionString; diff --git a/packages/database/interfaces/database-client.ts b/packages/database/ports/database-client.ts similarity index 100% rename from packages/database/interfaces/database-client.ts rename to packages/database/ports/database-client.ts diff --git a/packages/database/interfaces/mod.ts b/packages/database/ports/mod.ts similarity index 100% rename from packages/database/interfaces/mod.ts rename to packages/database/ports/mod.ts diff --git a/packages/database/prisma-tracing.ts b/packages/database/prisma-tracing.ts index 04eeafb3b..773caffc4 100644 --- a/packages/database/prisma-tracing.ts +++ b/packages/database/prisma-tracing.ts @@ -17,30 +17,129 @@ */ import { - setGlobalTracingHelper, clearGlobalTracingHelper, getGlobalTracingHelper, - type TracingHelper, - type EngineSpan, - type ExtendedSpanOptions, - type SpanCallback, + setGlobalTracingHelper, + type TracingHelper as PrismaTracingHelperContract, } from '@prisma/instrumentation-contract'; import { + type Context, context as _context, - trace, SpanKind, - type Context, - type Span, type SpanOptions, - type TracerProvider, - type Tracer, + trace, } from '@opentelemetry/api'; +/** + * Span context fields used by Prisma tracing propagation. + */ +export interface PrismaTracingSpanContext { + /** Current span identifier. */ + readonly spanId: string; + /** Current trace identifier. */ + readonly traceId: string; + /** OpenTelemetry trace flags for the span. */ + readonly traceFlags: number; +} + +/** + * Span link shape consumed by Prisma tracing helpers. + */ +export interface PrismaTracingSpanLink { + /** Linked span context. */ + readonly context: PrismaTracingSpanContext; +} + +/** + * Minimum OpenTelemetry span shape used by Prisma tracing helpers. + */ +export interface PrismaTracingSpan { + /** Return the span context identifiers used for propagation and links. */ + spanContext(): PrismaTracingSpanContext; + /** Add links to related spans. */ + addLinks(links: readonly PrismaTracingSpanLink[]): void; + /** End the span, optionally at a specific timestamp. */ + end(endTime?: unknown): void; +} + +/** + * Minimum OpenTelemetry tracer shape used by Prisma tracing helpers. + */ +export interface PrismaTracingTracer { + /** Start a span without making it active. */ + startSpan(name: string, options?: unknown, context?: unknown): PrismaTracingSpan; + /** Start an active span and run a callback in that span. */ + startActiveSpan( + name: string, + options: unknown, + callback: (span: PrismaTracingSpan) => R, + ): R; + /** Start an active span with an explicit context and run a callback. */ + startActiveSpan( + name: string, + options: unknown, + context: unknown, + callback: (span: PrismaTracingSpan) => R, + ): R; +} + // --------------------------------------------------------------------------- // ActiveTracingHelper — ported from @prisma/instrumentation@7.3.0 // --------------------------------------------------------------------------- +type PrismaSpanTime = NonNullable; + +type PrismaEngineSpanKind = 'client' | 'internal'; + +interface PrismaEngineSpan { + /** Stable engine span identifier. */ + id: string; + /** Parent engine span identifier, or null for root spans. */ + parentId: string | null; + /** Engine span name. */ + name: string; + /** Span start timestamp. */ + startTime: PrismaSpanTime; + /** Span end timestamp. */ + endTime: PrismaSpanTime; + /** Engine span kind. */ + kind: PrismaEngineSpanKind; + /** Optional span attributes. */ + attributes?: Record; + /** Optional linked engine span identifiers. */ + links?: string[]; +} + +interface PrismaExtendedSpanOptions extends SpanOptions { + /** Child span name without the `prisma:client:` prefix. */ + name: string; + /** Whether the span is internal and hidden unless Prisma trace debugging is enabled. */ + internal?: boolean; + /** Whether the span should become the active OpenTelemetry span. */ + active?: boolean; + /** Context to attach the child span to. */ + context?: Context; +} + +type PrismaSpanCallback = (span?: PrismaTracingSpan, context?: Context) => R; + +interface PrismaTracingHelper { + /** Return whether tracing is enabled. */ + isEnabled(): boolean; + /** Return the current W3C traceparent header value. */ + getTraceParent(context?: Context): string; + /** Dispatch Prisma engine spans to OpenTelemetry. */ + dispatchEngineSpans(spans: PrismaEngineSpan[]): void; + /** Return the active OpenTelemetry context, if any. */ + getActiveContext(): Context | undefined; + /** Run work inside a Prisma child span. */ + runInChildSpan( + nameOrOptions: string | PrismaExtendedSpanOptions, + callback: PrismaSpanCallback, + ): R; +} + const maybeProcess = (globalThis as Record).process; const processEnv = maybeProcess && typeof maybeProcess === 'object' ? (maybeProcess as { env?: Record }).env @@ -68,7 +167,7 @@ function isPromiseLike(value: unknown): value is PromiseLike { return value != null && typeof (value as PromiseLike)['then'] === 'function'; } -function endSpan(span: Span, result: T): T { +function endSpan(span: PrismaTracingSpan, result: T): T { if (isPromiseLike(result)) { return result.then( (value) => { @@ -90,17 +189,14 @@ function shouldIgnoreSpan( ignoreSpanTypes: (string | RegExp)[], ): boolean { return ignoreSpanTypes.some( - (pattern) => - typeof pattern === 'string' - ? pattern === spanName - : pattern.test(spanName), + (pattern) => typeof pattern === 'string' ? pattern === spanName : pattern.test(spanName), ); } function dispatchEngineSpan( - tracer: Tracer, - engineSpan: EngineSpan, - allSpans: EngineSpan[], + tracer: PrismaTracingTracer, + engineSpan: PrismaEngineSpan, + allSpans: PrismaEngineSpan[], linkIds: Map, ignoreSpanTypes: (string | RegExp)[], ): void { @@ -112,7 +208,7 @@ function dispatchEngineSpan( startTime: engineSpan.startTime, }; - tracer.startActiveSpan(engineSpan.name, spanOptions, (span: Span) => { + tracer.startActiveSpan(engineSpan.name, spanOptions, (span: PrismaTracingSpan) => { linkIds.set(engineSpan.id, span.spanContext().spanId); if (engineSpan.links) { @@ -140,12 +236,12 @@ function dispatchEngineSpan( }); } -class ActiveTracingHelper implements TracingHelper { - private tracerProvider: TracerProvider; +class ActiveTracingHelper implements PrismaTracingHelper { + private tracerProvider: PrismaTracingProvider; private ignoreSpanTypes: (string | RegExp)[]; constructor(options: { - tracerProvider: TracerProvider; + tracerProvider: PrismaTracingProvider; ignoreSpanTypes: (string | RegExp)[]; }) { this.tracerProvider = options.tracerProvider; @@ -164,7 +260,7 @@ class ActiveTracingHelper implements TracingHelper { return nonSampledTraceParent; } - dispatchEngineSpans(spans: EngineSpan[]): void { + dispatchEngineSpans(spans: PrismaEngineSpan[]): void { const tracer = this.tracerProvider.getTracer('prisma'); const linkIds = new Map(); const roots = spans.filter((span) => span.parentId === null); @@ -178,8 +274,8 @@ class ActiveTracingHelper implements TracingHelper { } runInChildSpan( - options: string | ExtendedSpanOptions, - callback: SpanCallback, + options: string | PrismaExtendedSpanOptions, + callback: PrismaSpanCallback, ): R { if (typeof options === 'string') { options = { name: options }; @@ -201,8 +297,10 @@ class ActiveTracingHelper implements TracingHelper { return endSpan(span, callback(span, context)); } - return tracer.startActiveSpan(name, options, (span: Span) => - endSpan(span, callback(span, context)), + return tracer.startActiveSpan( + name, + options, + (span: PrismaTracingSpan) => endSpan(span, callback(span, context)), ); } } @@ -211,9 +309,20 @@ class ActiveTracingHelper implements TracingHelper { // Public API — drop-in replacement for PrismaInstrumentation // --------------------------------------------------------------------------- +/** + * Tracer provider shape accepted by {@linkcode enablePrismaTracing}. + */ +export interface PrismaTracingProvider { + /** Return an OpenTelemetry-compatible tracer. */ + getTracer(name: string, version?: string, options?: unknown): PrismaTracingTracer; +} + +/** + * Configuration for Prisma OpenTelemetry tracing. + */ export interface PrismaTracingConfig { - /** TracerProvider to use. Defaults to the globally registered provider. */ - tracerProvider?: TracerProvider; + /** Tracer provider to use. Defaults to the globally registered provider. */ + tracerProvider?: PrismaTracingProvider; /** Span name patterns to ignore. */ ignoreSpanTypes?: (string | RegExp)[]; } @@ -229,9 +338,9 @@ export interface PrismaTracingConfig { export function enablePrismaTracing(config: PrismaTracingConfig = {}): void { setGlobalTracingHelper( new ActiveTracingHelper({ - tracerProvider: config.tracerProvider ?? trace.getTracerProvider(), + tracerProvider: config.tracerProvider ?? (trace.getTracerProvider() as PrismaTracingProvider), ignoreSpanTypes: config.ignoreSpanTypes ?? [], - }), + }) as PrismaTracingHelperContract, ); } @@ -248,6 +357,3 @@ export function disablePrismaTracing(): void { export function isPrismaTracingEnabled(): boolean { return getGlobalTracingHelper() !== undefined; } - -// Re-export contract types for consumers -export type { TracingHelper, EngineSpan, ExtendedSpanOptions, SpanCallback }; diff --git a/packages/database/scripts/fix-zod-imports.ts b/packages/database/scripts/fix-zod-imports.ts index d93cd2c36..7289c4a75 100644 --- a/packages/database/scripts/fix-zod-imports.ts +++ b/packages/database/scripts/fix-zod-imports.ts @@ -39,15 +39,27 @@ const isDecimalLike = (v: unknown): boolean => { }; `.trim(); +/** + * Options for generated Zod schema post-processing. + */ export interface FixZodImportsOptions { + /** Whether to print progress messages. */ verbose?: boolean; + /** Whether to replace Prisma Decimal imports with duck-typed validators. */ fixDecimalImports?: boolean; } +/** + * Summary of generated Zod schema post-processing changes. + */ export interface FixZodImportsResult { + /** Number of files with relative imports fixed. */ filesFixed: number; + /** Number of relative import specifiers fixed. */ importsFixed: number; + /** Number of files with Decimal compatibility fixes. */ decimalFilesFixed: number; + /** Number of Decimal references fixed. */ decimalImportsFixed: number; } @@ -303,7 +315,7 @@ export async function fixZodImports( for await (const entry of walk(zodOutputDir, { exts: ['.ts'] })) { if (!entry.isFile) continue; - let content = await Deno.readTextFile(entry.path); + const content = await Deno.readTextFile(entry.path); if (TYPED_DECIMAL_INPUT_REGEX.test(content)) { TYPED_DECIMAL_INPUT_REGEX.lastIndex = 0; diff --git a/packages/database/scripts/generate-zod.ts b/packages/database/scripts/generate-zod.ts index 6acf81467..51ea19402 100644 --- a/packages/database/scripts/generate-zod.ts +++ b/packages/database/scripts/generate-zod.ts @@ -15,6 +15,9 @@ import { fixZodImports } from './fix-zod-imports.ts'; const PRISMA_ZOD_GENERATOR_ENTRYPOINT = 'npm:prisma-zod-generator@2.1.4/lib/generator.js'; +/** + * Options for Prisma Zod schema generation. + */ export interface GenerateZodOptions { /** Path to the generated Zod output directory */ zodOutputDir: string; diff --git a/packages/database/scripts/mod.ts b/packages/database/scripts/mod.ts index 6445d7f0c..e8d8a4a24 100644 --- a/packages/database/scripts/mod.ts +++ b/packages/database/scripts/mod.ts @@ -8,16 +8,19 @@ export { fixZodImports, - runFixZodImports, type FixZodImportsOptions, type FixZodImportsResult, + runFixZodImports, } from './fix-zod-imports.ts'; export { patchPrismaClient, - runPatchPrismaClient, type PatchPrismaClientOptions, type PatchPrismaClientResult, + runPatchPrismaClient, } from './patch-prisma-client.ts'; -export { generateZodSchemas, generateZodSchemasCli, type GenerateZodOptions } from './generate-zod.ts'; -export { runMigration, runMigrationCli, type MigrationOptions } from './migrate.ts'; - +export { + type GenerateZodOptions, + generateZodSchemas, + generateZodSchemasCli, +} from './generate-zod.ts'; +export { type MigrationOptions, runMigration, runMigrationCli } from './migrate.ts'; diff --git a/packages/database/scripts/patch-prisma-client.ts b/packages/database/scripts/patch-prisma-client.ts index 448e5f5a5..e0242df2a 100644 --- a/packages/database/scripts/patch-prisma-client.ts +++ b/packages/database/scripts/patch-prisma-client.ts @@ -27,11 +27,17 @@ import { exists } from 'jsr:@std/fs@1/exists'; // TYPES // ============================================================================ +/** + * Options for Prisma client patching. + */ export interface PatchPrismaClientOptions { /** Enable console logging (default: true) */ verbose?: boolean; } +/** + * Summary of Prisma client patching changes. + */ export interface PatchPrismaClientResult { /** Whether client.ts was patched to an isomorphic façade */ clientPatched: boolean; diff --git a/packages/database/testing/mock-database.ts b/packages/database/testing/mock-database.ts new file mode 100644 index 000000000..14cd8f253 --- /dev/null +++ b/packages/database/testing/mock-database.ts @@ -0,0 +1,120 @@ +/** + * Mock database adapter and contract tests for `@netscript/database`. + * + * @module + */ + +import { assert, assertEquals } from 'jsr:@std/assert@1'; +import type { DatabaseAdapter, DatabaseConnectionStatus, DatabaseProvider } from '../ports/mod.ts'; + +/** + * In-memory database adapter used by port contract tests. + */ +export class MockDatabaseAdapter implements DatabaseAdapter { + /** Database provider identity reported by this mock. */ + readonly provider: DatabaseProvider; + #connected = false; + #queries: string[] = []; + + /** + * Create a mock database adapter. + * + * @param provider - Provider identity reported by the mock adapter. + */ + constructor(provider: DatabaseProvider = 'sqlite') { + this.provider = provider; + } + + /** Return the mock itself as the client. */ + getClient(): MockDatabaseAdapter { + return this; + } + + /** Mark the mock as connected. */ + connect(): Promise { + this.#connected = true; + return Promise.resolve(); + } + + /** Mark the mock as disconnected. */ + disconnect(): Promise { + this.#connected = false; + return Promise.resolve(); + } + + /** Report whether the mock is connected. */ + healthCheck(): Promise { + return Promise.resolve(this.#connected); + } + + /** Return a connection status snapshot. */ + getStatus(): Promise { + return Promise.resolve({ + connected: this.#connected, + provider: this.provider, + }); + } + + /** Record a raw query and return a typed placeholder result. */ + executeRaw(query: string, ..._params: unknown[]): Promise { + this.#queries.push(query); + return Promise.resolve({ query, rows: [] } as T); + } + + /** Record an unsafe raw query and return a typed placeholder result. */ + executeRawUnsafe(query: string, ..._params: unknown[]): Promise { + this.#queries.push(query); + return Promise.resolve({ query, affectedRows: 0 } as T); + } + + /** Return the recorded query text. */ + get queries(): readonly string[] { + return this.#queries; + } +} + +/** + * Create a new mock database adapter. + * + * @param provider - Optional provider identity for the mock. + * @returns A fresh mock adapter. + */ +export function createMockDatabaseAdapter( + provider: DatabaseProvider = 'sqlite', +): MockDatabaseAdapter { + return new MockDatabaseAdapter(provider); +} + +/** + * Options for {@linkcode runDatabaseAdapterContract}. + */ +export interface DatabaseAdapterContractOptions { + /** Human-readable adapter name for Deno test titles. */ + readonly name: string; + /** Creates a fresh database adapter for each contract scenario. */ + readonly make: () => DatabaseAdapter | Promise; +} + +/** + * Register the shared database adapter contract tests. + * + * @param options - Adapter factory and display name. + */ +export function runDatabaseAdapterContract( + options: DatabaseAdapterContractOptions, +): void { + Deno.test(`${options.name}: connects, reports status, executes, and disconnects`, async () => { + const adapter = await options.make(); + + await adapter.connect(); + assert(await adapter.healthCheck()); + + const status = await adapter.getStatus(); + assertEquals(status.connected, true); + + await adapter.executeRaw('select 1'); + + await adapter.disconnect(); + assertEquals(await adapter.healthCheck(), false); + }); +} diff --git a/packages/database/testing/mod.ts b/packages/database/testing/mod.ts new file mode 100644 index 000000000..893fe8441 --- /dev/null +++ b/packages/database/testing/mod.ts @@ -0,0 +1,29 @@ +/** + * Testing utilities for `@netscript/database`. + * + * Use this subpath to run the shared database adapter contract against custom + * adapters without importing package internals. + * + * @example + * ```ts + * import { + * createMockDatabaseAdapter, + * runDatabaseAdapterContract, + * } from "@netscript/database/testing"; + * + * runDatabaseAdapterContract({ + * name: "mock", + * make: () => createMockDatabaseAdapter(), + * }); + * ``` + * + * @module + */ + +export { + createMockDatabaseAdapter, + type DatabaseAdapterContractOptions, + MockDatabaseAdapter, + runDatabaseAdapterContract, +} from './mock-database.ts'; +export type { DatabaseAdapter, DatabaseConnectionStatus, DatabaseProvider } from '../ports/mod.ts'; diff --git a/packages/database/tests/_fixtures/docs-examples_test.ts b/packages/database/tests/_fixtures/docs-examples_test.ts new file mode 100644 index 000000000..13c687fcc --- /dev/null +++ b/packages/database/tests/_fixtures/docs-examples_test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from 'jsr:@std/assert@1'; +import { buildPostgresConnectionString } from '../../mod.ts'; +import { createMockDatabaseAdapter } from '../../testing/mod.ts'; + +Deno.test('docs: builds a PostgreSQL connection string from parts', () => { + const url = buildPostgresConnectionString({ + host: 'localhost', + port: 5432, + database: 'app', + user: 'app', + password: 'secret', + }); + + assertEquals(url, 'postgresql://app:secret@localhost:5432/app'); +}); + +Deno.test('docs: mock adapter follows basic lifecycle', async () => { + const adapter = createMockDatabaseAdapter(); + + await adapter.connect(); + assertEquals(await adapter.healthCheck(), true); + + const result = await adapter.executeRaw<{ query: string }>('select 1'); + assertEquals(result.query, 'select 1'); + + await adapter.disconnect(); + assertEquals(await adapter.healthCheck(), false); +}); diff --git a/packages/database/tests/adapter-contract_test.ts b/packages/database/tests/adapter-contract_test.ts new file mode 100644 index 000000000..bfb4667ef --- /dev/null +++ b/packages/database/tests/adapter-contract_test.ts @@ -0,0 +1,6 @@ +import { createMockDatabaseAdapter, runDatabaseAdapterContract } from '../testing/mod.ts'; + +runDatabaseAdapterContract({ + name: 'mock database adapter', + make: () => createMockDatabaseAdapter(), +}); diff --git a/packages/kv/ARCHITECTURE.md b/packages/kv/ARCHITECTURE.md deleted file mode 100644 index 2d466f150..000000000 --- a/packages/kv/ARCHITECTURE.md +++ /dev/null @@ -1,886 +0,0 @@ -# @netscript/kv - Key-Value Store Package - -> Provider-agnostic KV store abstraction with reactive watch support for event-driven systems - -## Overview - -This package provides a unified interface for key-value storage operations across Deno KV, Redis, -and in-memory backends, with a crucial extension: **reactive watch capabilities** that enable -real-time event-driven architectures. - -## Relationship with @netscript/queue Package - -### The Key Distinction - -| Package | Purpose | Operations | -| -------------------- | ------------------------------------- | -------------------------------------------------- | -| **@netscript/kv** | Key-value storage + reactive watching | get, set, delete, list, **watch**, **watchPrefix** | -| **@netscript/queue** | Message queue delivery + retries | enqueue, listen, ack, nack | - -**They share the same underlying connections but serve different purposes:** - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ APPLICATION │ -│ │ -│ @netscript/kv usage: @netscript/queue usage: │ -│ ───────────── ───────────── │ -│ kv.set(['executions', id], data) queue.enqueue(jobMessage) │ -│ kv.watchPrefix(['executions']) queue.listen(handler) │ -│ kv.watch([['jobs', id]]) // react to changes │ -│ │ -└──────────────────────────────────┬──────────────────────────────────────────┘ - │ - │ SHARED CONNECTION - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Deno.Kv Instance │ -│ │ -│ ┌─────────────────────────────┐ ┌─────────────────────────────────┐ │ -│ │ Key-Value Storage │ │ Native Queue (Deno.Kv.enqueue) │ │ -│ │ ['executions', ...] │ │ Fedify's DenoKvMessageQueue │ │ -│ │ ['jobs', ...] │ │ │ │ -│ │ ['events', ...] │ │ │ │ -│ │ │ │ │ │ -│ │ kv.set() / kv.get() │ │ kv.enqueue() / kv.listenQueue()│ │ -│ │ kv.watch() │ │ │ │ -│ └─────────────────────────────┘ └─────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### How They Integrate - -**@netscript/queue keeps its Fedify-based adapters unchanged.** We don't replace them, we complement -them: - -```typescript -// @netscript/queue's DenoKvAdapter (EXISTING - wraps Fedify) -import { DenoKvMessageQueue } from '@fedify/denokv'; - -export class DenoKvAdapter implements MessageQueue { - constructor(queueName: string, options?: { useShared?: boolean }) { - // NEW: Use shared KV instance from @netscript/kv package - if (options?.useShared !== false) { - this.kv = await getRawKv(); // From @netscript/kv package - } - this.queue = new DenoKvMessageQueue(this.kv); - } -} -``` - -**@netscript/kv provides additional capabilities Fedify doesn't have:** - -- `watch()` - Observe key changes -- `watchPrefix()` - Observe all changes under a prefix -- Shared instance management - ---- - -## Why a Separate Package? - -### Problem Statement - -The current implementation has several issues: - -1. **Scattered KV Usage**: KV instances are created ad-hoc in queue adapters, workers, and frontends -2. **No Watch Abstraction**: `Deno.Kv.watch()` is used directly, making code non-portable -3. **Sentinel Anti-Pattern**: Using `['executions-updated']` keys as change markers instead of - proper subscriptions -4. **Duplicate Connections**: Multiple KV connections opened when one shared instance would suffice - -### Current Broken Pattern (Sentinel) - -```typescript -// ❌ ANTI-PATTERN: Watching a "marker" key -const watcher = kv.watch([ - ['executions-updated'], // Sentinel key - ['running-tasks-updated'], // Sentinel key -]); - -for await (const entries of watcher) { - // When sentinel changes, refetch ALL data - const allExecutions = await fetchRecentExecutions(20); - sendSSE('executions', allExecutions); -} -``` - -### New Pattern (Direct Watch) - -```typescript -// ✅ CORRECT: Watch actual data -import { getKv } from '@netscript/kv'; - -const kv = await getKv(); - -for await (const event of kv.watchPrefix(['executions'])) { - // Get granular per-key changes - sendSSE('execution-update', { - type: event.type, // 'set' or 'delete' - jobId: event.key[1], - executionId: event.key[2], - execution: event.value, // The actual data! - }); -} -``` - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ APPLICATION LAYER │ -│ │ -│ import { getKv, watchPrefix, subscribe, publish } from '@netscript/kv'; │ -│ │ -│ const kv = await getKv(); │ -│ for await (const change of kv.watchPrefix(['jobs'])) { ... } │ -│ await kv.publish('job-triggers', { jobId: 'export' }); │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ @netscript/kv PACKAGE │ -│ │ -│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────────┐ │ -│ │ KvStore │ │ WatchableKv │ │ SharedInstance │ │ -│ │ Interface │ │ Interface │ │ Manager │ │ -│ │ │ │ │ │ │ │ -│ │ • get(key) │ │ • watch(keys) │ │ • getKv() │ │ -│ │ • set(key, val) │ │ • watchPrefix() │ │ • getRawKv() │ │ -│ │ • delete(key) │ │ • subscribe() │ │ • closeKv() │ │ -│ │ • list(prefix) │ │ • publish() │ │ │ │ -│ └────────────────────┘ └────────────────────┘ └─────────────────────┘ │ -│ │ │ -│ ┌───────────────────────────────────┴───────────────────────────────────┐ │ -│ │ ADAPTERS │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ │ -│ │ │ DenoKv │ │ Redis │ │ PostgreSQL │ │ Memory │ │ │ -│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │ │ │ -│ │ │ │ │ │ │ │ │ │ │ │ -│ │ │ Deno.Kv │ │ ioredis │ │ postgres │ │ Map + │ │ │ -│ │ │ .watch() │ │ pub/sub │ │ LISTEN/ │ │ Events │ │ │ -│ │ │ │ │ keyspace │ │ NOTIFY │ │ │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────┘ └───────────┘ │ │ -│ └────────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - │ getRawKv() for @netscript/queue - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ @netscript/queue PACKAGE │ -│ │ -│ Uses Fedify adapters (DenoKvMessageQueue, RedisMessageQueue, etc.) │ -│ Gets raw KV instance from @netscript/kv.getRawKv() for connection sharing │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Core Interfaces - -### Types - -```typescript -// interfaces/types.ts - -/** - * KV key - array of strings (compatible with Deno.Kv and Fedify) - */ -export type KvKey = readonly string[]; - -/** - * KV entry returned from list/get operations - */ -export interface KvEntry { - key: KvKey; - value: T; - versionstamp?: string; -} - -/** - * Watch event emitted when a key changes - */ -export interface WatchEvent { - key: KvKey; - value: T | null; - previousValue?: T | null; - type: 'set' | 'delete'; - timestamp: Date; - versionstamp?: string; -} - -/** - * Subscription handle for pub/sub - */ -export interface Subscription { - unsubscribe(): Promise; - readonly channel: string; - readonly isActive: boolean; -} -``` - -### KvStore Interface (Base) - -```typescript -// interfaces/kv-store.ts - -/** - * Base KV store interface - compatible with Fedify's KvStore - */ -export interface KvStore { - /** - * Get a value by key - */ - get(key: KvKey): Promise; - - /** - * Set a value with optional TTL - */ - set(key: KvKey, value: unknown, options?: KvSetOptions): Promise; - - /** - * Delete a key - */ - delete(key: KvKey): Promise; - - /** - * List entries by prefix - */ - list(options: KvListOptions): AsyncIterable>; - - /** - * Atomic compare-and-swap operation (optional) - */ - cas?(key: KvKey, expectedValue: unknown, newValue: unknown): Promise; - - /** - * Close the KV store connection - */ - close(): Promise; -} - -export interface KvSetOptions { - /** Time-to-live in milliseconds */ - ttl?: number; -} - -export interface KvListOptions { - /** Key prefix to filter by */ - prefix: KvKey; - /** Maximum number of entries to return */ - limit?: number; - /** Cursor for pagination */ - cursor?: string; - /** Reverse order */ - reverse?: boolean; -} -``` - -### WatchableKv Interface (Extended) - -```typescript -// interfaces/watchable-kv.ts - -/** - * Extended KV interface with reactive watch capabilities - * This is what @netscript/kv provides beyond Fedify's KvStore - */ -export interface WatchableKv extends KvStore { - /** - * Watch specific keys for changes. - * Returns an async iterator that yields on every change. - */ - watch( - keys: KvKey[], - options?: WatchOptions, - ): AsyncIterable[]>; - - /** - * Watch all keys under a prefix. - * More powerful than watch() - captures new keys too. - */ - watchPrefix( - prefix: KvKey, - options?: WatchOptions, - ): AsyncIterable>; - - /** - * Subscribe to a channel (pub/sub pattern). - * For backends that support native pub/sub (Redis, PostgreSQL). - */ - subscribe( - channel: string, - handler: (message: T) => void | Promise, - ): Promise; - - /** - * Publish to a channel. - */ - publish(channel: string, message: T): Promise; - - /** - * Check if watch is supported by this backend. - */ - readonly supportsWatch: boolean; - - /** - * Check if native pub/sub is supported by this backend. - */ - readonly supportsPubSub: boolean; -} - -export interface WatchOptions { - /** Abort signal for cleanup */ - signal?: AbortSignal; - /** Debounce rapid changes (ms) */ - debounce?: number; -} -``` - ---- - -## Adapters - -### Deno KV Adapter - -Deno KV has native `watch()` support: - -```typescript -// adapters/deno-kv.adapter.ts - -export class DenoKvAdapter implements WatchableKv { - readonly supportsWatch = true; - readonly supportsPubSub = false; // Emulated via watch - - constructor(private kv: Deno.Kv) {} - - // Basic CRUD operations - async get(key: KvKey): Promise { - const result = await this.kv.get(key); - return result.value ?? undefined; - } - - async set(key: KvKey, value: unknown, options?: KvSetOptions): Promise { - await this.kv.set(key, value, { - expireIn: options?.ttl, - }); - } - - async delete(key: KvKey): Promise { - await this.kv.delete(key); - } - - async *list(options: KvListOptions): AsyncIterable> { - const entries = this.kv.list({ - prefix: options.prefix, - limit: options.limit, - reverse: options.reverse, - }); - - for await (const entry of entries) { - yield { - key: entry.key as KvKey, - value: entry.value, - versionstamp: entry.versionstamp, - }; - } - } - - // Watch capabilities (Deno.Kv native!) - async *watch(keys: KvKey[], options?: WatchOptions): AsyncIterable[]> { - const stream = this.kv.watch(keys); - - try { - for await (const entries of stream) { - if (options?.signal?.aborted) break; - - yield entries.map((entry, i) => ({ - key: keys[i], - value: entry.value as T | null, - type: entry.value === null ? 'delete' : 'set', - timestamp: new Date(), - versionstamp: entry.versionstamp ?? undefined, - })); - } - } finally { - // Cleanup if needed - } - } - - async *watchPrefix(prefix: KvKey, options?: WatchPrefixOptions): AsyncIterable> { - // Deno KV watch doesn't support prefix watching natively - // Implementation: poll for new keys + watch known keys - // - // Options: - // - pollInterval: how often to scan for new keys (default: 1000ms) - // - skipInitial: if true, don't emit existing keys on connection (default: false) - // This is critical for SSE connections to avoid flooding clients with - // all existing records when they connect. - - let knownKeys = new Map(); - const keyToString = (k: KvKey) => JSON.stringify(k); - - // Initial scan - for await (const entry of this.list({ prefix })) { - knownKeys.set(keyToString(entry.key), entry.key); - } - - // Emit initial state (unless skipInitial is true) - if (!options?.skipInitial) { - // ... emit initial entries - } - - // Start watching known keys and polling for new ones - const pollInterval = 1000; // 1 second - let lastPoll = Date.now(); - - const keysArray = () => Array.from(knownKeys.values()); - - for await (const events of this.watch(keysArray(), options)) { - for (const event of events) { - yield event; - } - - // Periodically check for new keys - if (Date.now() - lastPoll > pollInterval) { - for await (const entry of this.list({ prefix })) { - const keyStr = keyToString(entry.key); - if (!knownKeys.has(keyStr)) { - knownKeys.set(keyStr, entry.key); - yield { - key: entry.key, - value: entry.value, - type: 'set', - timestamp: new Date(), - }; - } - } - lastPoll = Date.now(); - } - } - } - - // Pub/Sub emulated via KV watch - async subscribe( - channel: string, - handler: (message: T) => void | Promise, - ): Promise { - const channelKey: KvKey = ['__pubsub', 'channels', channel]; - let active = true; - - const watchLoop = async () => { - for await (const [event] of this.watch<{ message: T; timestamp: number }>([channelKey])) { - if (!active) break; - if (event.value === null) continue; - await handler(event.value.message); - } - }; - - // Start watching in background - const loopPromise = watchLoop(); - - return { - channel, - get isActive() { - return active; - }, - async unsubscribe() { - active = false; - // Loop will exit on next iteration - }, - }; - } - - async publish(channel: string, message: T): Promise { - const channelKey: KvKey = ['__pubsub', 'channels', channel]; - await this.kv.set(channelKey, { - message, - timestamp: Date.now(), - }); - } - - async close(): Promise { - this.kv.close(); - } -} -``` - -### Redis Adapter - -```typescript -// adapters/redis.adapter.ts - -export class RedisKvAdapter implements WatchableKv { - readonly supportsWatch = true; // Via keyspace notifications - readonly supportsPubSub = true; // Native Redis pub/sub - - constructor( - private client: Redis, - private subscriber: Redis, // Separate connection for subscriptions - ) {} - - // ... CRUD operations using Redis commands ... - - async subscribe( - channel: string, - handler: (message: T) => void | Promise, - ): Promise { - // Native Redis SUBSCRIBE - await this.subscriber.subscribe(channel); - - const messageHandler = async (ch: string, message: string) => { - if (ch === channel) { - await handler(JSON.parse(message) as T); - } - }; - - this.subscriber.on('message', messageHandler); - - return { - channel, - isActive: true, - async unsubscribe() { - await this.subscriber.unsubscribe(channel); - this.subscriber.off('message', messageHandler); - }, - }; - } - - async publish(channel: string, message: T): Promise { - await this.client.publish(channel, JSON.stringify(message)); - } -} -``` - -### Adapter Capabilities Matrix - -| Adapter | watch() | watchPrefix() | subscribe() | publish() | Notes | -| --------------- | --------------- | --------------- | --------------- | --------------- | ------------- | -| DenoKvAdapter | ✅ Native | ✅ Poll+Watch | ✅ Via watch | ✅ Via KV | Best for Deno | -| RedisAdapter | ✅ Keyspace | ✅ Pattern | ✅ Native | ✅ Native | Best for prod | -| PostgresAdapter | ❌ | ❌ | ✅ LISTEN | ✅ NOTIFY | Pub/sub only | -| MemoryAdapter | ✅ EventEmitter | ✅ EventEmitter | ✅ EventEmitter | ✅ EventEmitter | Dev/test | - ---- - -## Shared Instance Management - -```typescript -// core/shared-kv.ts - -import { getKvConnection } from '@netscript/sdk'; -import { DenoKvAdapter } from '../adapters/deno-kv.adapter.ts'; -import type { WatchableKv } from '../interfaces/watchable-kv.ts'; - -let sharedInstance: WatchableKv | null = null; -let rawKvInstance: Deno.Kv | null = null; - -/** - * Get the shared WatchableKv instance. - * Creates one if it doesn't exist. - * Safe to call multiple times - returns same instance. - */ -export async function getKv(): Promise { - if (sharedInstance) { - return sharedInstance; - } - - const kvPath = getKvConnection(); - rawKvInstance = kvPath ? await Deno.openKv(kvPath) : await Deno.openKv(); - sharedInstance = new DenoKvAdapter(rawKvInstance); - - return sharedInstance; -} - -/** - * Get the raw Deno.Kv instance. - * Used by @netscript/queue package for Fedify's DenoKvMessageQueue. - * - * This is the KEY integration point with @netscript/queue! - */ -export async function getRawKv(): Promise { - if (!rawKvInstance) { - await getKv(); // Initialize if needed - } - return rawKvInstance!; -} - -/** - * Close the shared KV instance. - * Call this on application shutdown. - */ -export async function closeKv(): Promise { - if (sharedInstance) { - await sharedInstance.close(); - sharedInstance = null; - rawKvInstance = null; - } -} -``` - ---- - -## Integration with @netscript/queue Package - -The @netscript/queue package should be updated to use @netscript/kv's shared instance: - -```typescript -// packages/queue/adapters/deno-kv.adapter.ts (UPDATED) - -import { getRawKv } from '@netscript/kv'; // Import from @netscript/kv package -import { DenoKvMessageQueue } from '@fedify/denokv'; - -export interface DenoKvAdapterOptions { - /** Use shared KV instance from @netscript/kv package (default: true) */ - useShared?: boolean; - /** Provide your own Deno.Kv instance */ - instance?: Deno.Kv; - /** Path to KV database (creates new instance) */ - path?: string; -} - -export class DenoKvAdapter implements MessageQueue { - private queue!: DenoKvMessageQueue; - private kv!: Deno.Kv; - private initialized = false; - - readonly nativeRetrial = true; - - constructor( - private readonly queueName: string, - private readonly options: DenoKvAdapterOptions = {}, - ) {} - - private async ensureInitialized(): Promise { - if (this.initialized) return; - - if (this.options.instance) { - // Use provided instance - this.kv = this.options.instance; - } else if (this.options.useShared !== false) { - // Default: use shared instance from @netscript/kv - this.kv = await getRawKv(); - } else if (this.options.path) { - // Create new instance with path - this.kv = await Deno.openKv(this.options.path); - } else { - // Fallback: use shared - this.kv = await getRawKv(); - } - - // Wrap with Fedify's message queue (unchanged!) - this.queue = new DenoKvMessageQueue(this.kv); - this.initialized = true; - } - - // ... rest of implementation unchanged ... -} -``` - ---- - -## Usage Examples - -### Frontend: Real-time Job Monitoring - -```typescript -// apps/frontend/routes/api/jobs.ts -import { getKv } from '@netscript/kv'; - -export const handler = define.handlers({ - async GET(_ctx) { - const kv = await getKv(); - - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - - // Watch job executions directly - no sentinel needed! - for await (const event of kv.watchPrefix(['executions'])) { - const data = JSON.stringify({ - type: event.type, - jobId: event.key[1], - executionId: event.key[2], - execution: event.value, - timestamp: event.timestamp.toISOString(), - }); - - controller.enqueue(encoder.encode(`data: ${data}\n\n`)); - } - }, - }); - - return new Response(stream, { - headers: { 'Content-Type': 'text/event-stream' }, - }); - }, -}); -``` - -### Worker: Record Execution (Triggers Watchers) - -```typescript -// workers/worker/state.ts -import { getKv } from '@netscript/kv'; -import type { ExecutionRecord } from '@contracts'; - -export class ExecutionState { - private kv = getKv(); - - async recordStart(execution: ExecutionRecord): Promise { - const kv = await this.kv; - - // This single set() triggers all watchers on ['executions'] prefix! - await kv.set(['executions', execution.jobId, execution.id], execution); - - // Update current execution pointer - await kv.set(['jobs', execution.jobId, 'current'], execution); - } - - async recordComplete( - executionId: string, - jobId: string, - updates: Partial, - ): Promise { - const kv = await this.kv; - const key = ['executions', jobId, executionId]; - const existing = await kv.get(key); - - if (existing) { - const updated = { ...existing, ...updates }; - await kv.set(key, updated); // Triggers watchers! - await kv.set(['jobs', jobId, 'last'], updated); - await kv.delete(['jobs', jobId, 'current']); - } - } -} -``` - -### Frontend: Trigger Job via Pub/Sub - -```typescript -// apps/frontend/routes/api/jobs/[id]/trigger.ts -import { getKv } from '@netscript/kv'; -import type { JobTriggerEventV1 } from '@contracts'; - -export const handler = define.handlers({ - async POST(ctx) { - const jobId = ctx.params.id; - const body = await ctx.req.json(); - - const kv = await getKv(); - - // Publish trigger event - worker will pick it up - await kv.publish('job-triggers', { - jobId, - payload: body.payload, - requestedBy: 'manual', - requestedAt: new Date().toISOString(), - }); - - return Response.json({ - message: 'Job trigger event published', - jobId, - }); - }, -}); -``` - -### Worker: Subscribe to Triggers - -```typescript -// workers/events/bus.ts -import { getKv } from '@netscript/kv'; -import { createTypedQueue } from '@netscript/queue'; -import type { JobMessageV1, JobTriggerEventV1 } from '@contracts'; - -const jobQueue = createTypedQueue('jobs', JobMessageSchema); - -export async function startEventBus(): Promise { - const kv = await getKv(); - - await kv.subscribe('job-triggers', async (event) => { - console.log(`📬 Received job trigger: ${event.jobId}`); - - await jobQueue.enqueue({ - jobId: event.jobId, - triggeredBy: 'event', - triggeredAt: new Date().toISOString(), - payload: event.payload, - }); - }); -} -``` - ---- - -## Directory Structure - -``` -packages/kv/ -├── ARCHITECTURE.md # This document -├── README.md # Quick start guide -├── deno.json # Package configuration -├── mod.ts # Main exports -│ -├── interfaces/ -│ ├── mod.ts -│ ├── types.ts # KvKey, WatchEvent, etc. -│ ├── kv-store.ts # Base KvStore interface -│ └── watchable-kv.ts # WatchableKv interface -│ -├── adapters/ -│ ├── mod.ts -│ ├── deno-kv.adapter.ts # Deno.Kv implementation -│ ├── redis.adapter.ts # Redis implementation -│ ├── postgres.adapter.ts # PostgreSQL implementation -│ └── memory.adapter.ts # In-memory for testing -│ -├── core/ -│ ├── mod.ts -│ ├── shared-kv.ts # getKv(), getRawKv(), closeKv() -│ └── factory.ts # createKv() factory -│ -└── utils/ - ├── mod.ts - └── key-serialization.ts -``` - ---- - -## Key Benefits - -| Benefit | Description | -| ----------------------- | --------------------------------------------------------------------- | -| **Unified Interface** | Same API across Deno KV, Redis, PostgreSQL | -| **Reactive by Default** | Watch/subscribe built into the interface | -| **Shared Instance** | Single KV connection reused across @netscript/kv and @netscript/queue | -| **No Sentinels** | Direct key watching replaces sentinel pattern | -| **Queue Compatibility** | `getRawKv()` provides instance for @netscript/queue adapters | -| **Event-Driven** | Natural integration with worker/frontend events | -| **Backend Portable** | Switch backends without code changes | - ---- - -## Summary - -**@netscript/kv is complementary to @netscript/queue, not a replacement:** - -- @netscript/kv = Storage + Watch + Pub/Sub -- @netscript/queue = Message Queue + Delivery + Retries - -**They share the same connection** via `getRawKv()`, which @netscript/queue's DenoKvAdapter uses -internally. - -**The key new capabilities from @netscript/kv:** - -1. `watchPrefix()` - Real-time updates without sentinels -2. `subscribe()`/`publish()` - Pub/sub for job triggering -3. Shared instance management - One connection for everything diff --git a/packages/kv/README.md b/packages/kv/README.md index c7454dcc3..9e3accbcb 100644 --- a/packages/kv/README.md +++ b/packages/kv/README.md @@ -4,13 +4,16 @@ [![Deno](https://img.shields.io/badge/runtime-Deno-000000?logo=deno&logoColor=white)](https://deno.com/) [![License](https://img.shields.io/badge/license-MIT-0f172a)](https://opensource.org/licenses/MIT) -Reactive key-value storage with a unified API across Deno KV, Redis, and in-memory backends. Auto-detects the best available provider from Aspire environment variables or falls back to local Deno KV. +Reactive key-value storage with a unified API across Deno KV, Redis, and in-memory backends. +Auto-detects the best available provider from Aspire environment variables or falls back to local +Deno KV. ## Features - **Multi-backend** — Deno KV, Redis/Garnet, and in-memory adapters behind one consistent API - **Auto-detection** — Discovers the best available backend from environment variables at startup -- **Reactive watches** — Subscribe to individual key changes or entire key prefixes across all backends +- **Reactive watches** — Subscribe to individual key changes or entire key prefixes across all + backends - **Atomic operations** — Compare-and-swap with versionstamp checks for safe concurrent writes - **TTL support** — Per-key expiration via `expireIn` (milliseconds) on all adapters - **Type-safe** — Fully typed keys, values, and events via `WatchableKv` and `KvStore` interfaces @@ -27,22 +30,23 @@ Reactive key-value storage with a unified API across Deno KV, Redis, and in-memo } ``` -The Redis adapter has an optional peer dependency on `ioredis`. Import `@netscript/kv/redis` only when you need it — the root entrypoint keeps Redis out of your module graph. +The Redis adapter has an optional peer dependency on `ioredis`. Import `@netscript/kv/redis` only +when you need it — the root entrypoint keeps Redis out of your module graph. ## Quick Start ```ts -import { getKv } from "@netscript/kv"; +import { getKv } from '@netscript/kv'; const kv = await getKv(); // auto-detects backend from environment -await kv.set(["users", "alice"], { name: "Alice", role: "admin" }); +await kv.set(['users', 'alice'], { name: 'Alice', role: 'admin' }); -const entry = await kv.get<{ name: string; role: string }>(["users", "alice"]); +const entry = await kv.get<{ name: string; role: string }>(['users', 'alice']); console.log(entry?.value.name); // "Alice" // React to changes in real time -for await (const events of kv.watch([["users", "alice"]])) { +for await (const events of kv.watch([['users', 'alice']])) { for (const event of events) { console.log(event.type, event.key, event.value); } @@ -51,10 +55,10 @@ for await (const events of kv.watch([["users", "alice"]])) { ## Entry Points -| Import | Purpose | -|--------|---------| -| `@netscript/kv` | Lifecycle helpers, all public types, Deno KV and in-memory adapters | -| `@netscript/kv/redis` | Redis adapter — import only when using Redis directly | +| Import | Purpose | +| --------------------- | ------------------------------------------------------------------- | +| `@netscript/kv` | Lifecycle helpers, all public types, Deno KV and in-memory adapters | +| `@netscript/kv/redis` | Redis adapter — import only when using Redis directly | ## Usage @@ -63,24 +67,24 @@ for await (const events of kv.watch([["users", "alice"]])) { Stream every change under a prefix, including newly created keys: ```ts -import { getKv } from "@netscript/kv"; +import { getKv } from '@netscript/kv'; const kv = await getKv(); -for await (const event of kv.watchPrefix(["jobs", "order-processor"])) { - console.log(`${event.key.join("/")} → ${event.type}`, event.value); +for await (const event of kv.watchPrefix(['jobs', 'order-processor'])) { + console.log(`${event.key.join('/')} → ${event.type}`, event.value); } ``` ### Set a value with TTL ```ts -import { getKv } from "@netscript/kv"; +import { getKv } from '@netscript/kv'; const kv = await getKv(); // Expire after 1 hour -await kv.set(["sessions", "tok_abc"], { userId: "u1" }, { expireIn: 3_600_000 }); +await kv.set(['sessions', 'tok_abc'], { userId: 'u1' }, { expireIn: 3_600_000 }); ``` ### Atomic compare-and-swap @@ -88,14 +92,14 @@ await kv.set(["sessions", "tok_abc"], { userId: "u1" }, { expireIn: 3_600_000 }) Update a value only if no concurrent write has occurred since it was last read: ```ts -import { getKv } from "@netscript/kv"; +import { getKv } from '@netscript/kv'; const kv = await getKv(); -const entry = await kv.get(["counters", "visits"]); +const entry = await kv.get(['counters', 'visits']); const result = await kv.atomic( - [{ key: ["counters", "visits"], versionstamp: entry?.versionstamp ?? null }], - [{ type: "set", key: ["counters", "visits"], value: (entry?.value ?? 0) + 1 }], + [{ key: ['counters', 'visits'], versionstamp: entry?.versionstamp ?? null }], + [{ type: 'set', key: ['counters', 'visits'], value: (entry?.value ?? 0) + 1 }], ); if (!result.ok) { @@ -106,26 +110,26 @@ if (!result.ok) { ### Redis backend with explicit connection ```ts -import { RedisKvAdapter } from "@netscript/kv/redis"; +import { RedisKvAdapter } from '@netscript/kv/redis'; const kv = new RedisKvAdapter({ - url: "redis://localhost:6379", - namespace: "myapp", + url: 'redis://localhost:6379', + namespace: 'myapp', }); -await kv.set(["cache", "featured"], items, { expireIn: 60_000 }); -const cached = await kv.get(["cache", "featured"]); +await kv.set(['cache', 'featured'], items, { expireIn: 60_000 }); +const cached = await kv.get(['cache', 'featured']); ``` ### In-memory backend for tests ```ts -import { MemoryKvAdapter } from "@netscript/kv"; +import { MemoryKvAdapter } from '@netscript/kv'; const kv = new MemoryKvAdapter(); -await kv.set(["test", "key"], "value"); -const entry = await kv.get(["test", "key"]); +await kv.set(['test', 'key'], 'value'); +const entry = await kv.get(['test', 'key']); // Data is process-local and cleared when the adapter is closed ``` @@ -133,24 +137,25 @@ const entry = await kv.get(["test", "key"]); `getKv()` selects the backend in this priority order: -| Priority | Condition | Backend | -|----------|-----------|---------| -| 1 | `CACHE_PROVIDER=redis` or `CACHE_PROVIDER=garnet` | Redis | -| 2 | `CACHE_PROVIDER=denokv` or `CACHE_PROVIDER=deno-kv` | Deno KV | -| 3 | `REDIS_URI` or `GARNET_URI` present | Redis | -| 4 | `ConnectionStrings__redis` or `ConnectionStrings__garnet` present | Redis | -| 5 | Aspire `services__redis__*` or `services__garnet__*` present | Redis | -| 6 | Aspire `services__kv__*`, `KV_URL`, or `DENO_KV_URL` present | Deno KV | -| 7 | (fallback) | Local Deno KV | +| Priority | Condition | Backend | +| -------- | ----------------------------------------------------------------- | ------------- | +| 1 | `CACHE_PROVIDER=redis` or `CACHE_PROVIDER=garnet` | Redis | +| 2 | `CACHE_PROVIDER=denokv` or `CACHE_PROVIDER=deno-kv` | Deno KV | +| 3 | `REDIS_URI` or `GARNET_URI` present | Redis | +| 4 | `ConnectionStrings__redis` or `ConnectionStrings__garnet` present | Redis | +| 5 | Aspire `services__redis__*` or `services__garnet__*` present | Redis | +| 6 | Aspire `services__kv__*`, `KV_URL`, or `DENO_KV_URL` present | Deno KV | +| 7 | (fallback) | Local Deno KV | To override auto-detection, set `CACHE_PROVIDER` to `redis`, `garnet`, `denokv`, or `deno-kv`. ## Resources -- [`@netscript/sdk`](https://jsr.io/@netscript/sdk) — Service clients and cache-backed queries built on `@netscript/kv` +- [`@netscript/sdk`](https://jsr.io/@netscript/sdk) — Service clients and cache-backed queries built + on `@netscript/kv` - [Deno KV documentation](https://docs.deno.com/deploy/kv/manual/) - [ioredis](https://github.com/redis/ioredis) — Peer dependency for the Redis adapter ## License -MIT \ No newline at end of file +MIT diff --git a/packages/kv/adapters/deno-kv.adapter.ts b/packages/kv/adapters/deno-kv.adapter.ts index f4242cdcf..30ca72751 100644 --- a/packages/kv/adapters/deno-kv.adapter.ts +++ b/packages/kv/adapters/deno-kv.adapter.ts @@ -6,8 +6,8 @@ import { delay } from '@std/async'; import { chunk } from '@std/collections/chunk'; -import { KvClosedError } from '../core/errors.ts'; -import { keyToString } from '../core/keys.ts'; +import { KvClosedError } from '../application/errors.ts'; +import { keyToString } from '../application/keys.ts'; import type { AtomicMutation } from '../types/kv-store.ts'; import type { WatchableKv } from '../types/watchable-kv.ts'; import type { diff --git a/packages/kv/bridges/denokv-bridge.ts b/packages/kv/adapters/denokv-bridge.ts similarity index 99% rename from packages/kv/bridges/denokv-bridge.ts rename to packages/kv/adapters/denokv-bridge.ts index 9af5cc5ac..b1923a578 100644 --- a/packages/kv/bridges/denokv-bridge.ts +++ b/packages/kv/adapters/denokv-bridge.ts @@ -10,7 +10,7 @@ */ import { createPackageLogger } from '@netscript/logger'; -import { generateVersionstamp } from '../core/keys.ts'; +import { generateVersionstamp } from '../application/keys.ts'; import type { WatchableKv } from '../types/watchable-kv.ts'; import type { AtomicCheck, KvKey, KvListOptions } from '../types/common.ts'; import type { AtomicMutation } from '../types/kv-store.ts'; diff --git a/packages/kv/bridges/kvdex.ts b/packages/kv/adapters/kvdex.ts similarity index 96% rename from packages/kv/bridges/kvdex.ts rename to packages/kv/adapters/kvdex.ts index 71e56fe68..013058b6e 100644 --- a/packages/kv/bridges/kvdex.ts +++ b/packages/kv/adapters/kvdex.ts @@ -16,11 +16,11 @@ import { kvdex } from '@olli/kvdex'; import { MapKv } from '@olli/kvdex/kv/map'; import { createPackageLogger } from '@netscript/logger'; -import { getActiveProvider, getKv, getRawKv } from '../core/shared.ts'; +import { getActiveProvider, getKv, getRawKv } from '../application/shared.ts'; import { WatchableKvBridge } from './denokv-bridge.ts'; -import type { KvProvider } from '../core/shared.ts'; -export type { KvProvider } from '../core/shared.ts'; +import type { KvProvider } from '../application/shared.ts'; +export type { KvProvider } from '../application/shared.ts'; const logger = createPackageLogger('kv:kvdex'); diff --git a/packages/kv/adapters/memory.adapter.ts b/packages/kv/adapters/memory.adapter.ts index 14bd0fcc8..af0a1db31 100644 --- a/packages/kv/adapters/memory.adapter.ts +++ b/packages/kv/adapters/memory.adapter.ts @@ -6,8 +6,13 @@ import { delay } from '@std/async'; import { BinaryHeap } from '@std/data-structures'; -import { KvClosedError } from '../core/errors.ts'; -import { compareKeys, generateVersionstamp, keyHasPrefix, keyToString } from '../core/keys.ts'; +import { KvClosedError } from '../application/errors.ts'; +import { + compareKeys, + generateVersionstamp, + keyHasPrefix, + keyToString, +} from '../application/keys.ts'; import type { AtomicMutation } from '../types/kv-store.ts'; import type { WatchableKv } from '../types/watchable-kv.ts'; import type { diff --git a/packages/kv/adapters/redis.adapter.ts b/packages/kv/adapters/redis.adapter.ts index 352cb2ea0..22e6da5e2 100644 --- a/packages/kv/adapters/redis.adapter.ts +++ b/packages/kv/adapters/redis.adapter.ts @@ -16,7 +16,7 @@ import { delay } from '@std/async'; import { createPackageLogger } from '@netscript/logger'; -import { generateVersionstamp, keyHasPrefix, keyToString } from '../core/keys.ts'; +import { generateVersionstamp, keyHasPrefix, keyToString } from '../application/keys.ts'; import type { AtomicMutation } from '../types/kv-store.ts'; import type { WatchableKv } from '../types/watchable-kv.ts'; import type { diff --git a/packages/kv/adapters/redis/connection.ts b/packages/kv/adapters/redis/connection.ts index 65608ef2b..5df6812a1 100644 --- a/packages/kv/adapters/redis/connection.ts +++ b/packages/kv/adapters/redis/connection.ts @@ -11,14 +11,10 @@ import { retry } from '@std/async/retry'; import { createPackageLogger } from '@netscript/logger'; -import { maskRedisUrl } from '../../core/auto-detect.ts'; -import { KvClosedError, KvConnectionError } from '../../core/errors.ts'; +import { maskRedisUrl } from '../../application/auto-detect.ts'; +import { KvClosedError, KvConnectionError } from '../../application/errors.ts'; import { toErrorMessage } from './serialization.ts'; -import { - DEFAULT_REDIS_URL, - REDIS_CONNECT_TIMEOUT_MS, - REDIS_KEEPALIVE_MS, -} from './types.ts'; +import { REDIS_CONNECT_TIMEOUT_MS, REDIS_KEEPALIVE_MS } from './types.ts'; import { Redis, type RedisOptions } from 'ioredis'; const logger = createPackageLogger('kv'); diff --git a/packages/kv/core/auto-detect.ts b/packages/kv/application/auto-detect.ts similarity index 100% rename from packages/kv/core/auto-detect.ts rename to packages/kv/application/auto-detect.ts diff --git a/packages/kv/core/errors.ts b/packages/kv/application/errors.ts similarity index 100% rename from packages/kv/core/errors.ts rename to packages/kv/application/errors.ts diff --git a/packages/kv/core/keys.ts b/packages/kv/application/keys.ts similarity index 97% rename from packages/kv/core/keys.ts rename to packages/kv/application/keys.ts index 2ccff72ac..b6f48cda3 100644 --- a/packages/kv/core/keys.ts +++ b/packages/kv/application/keys.ts @@ -8,9 +8,9 @@ * @module */ -import { monotonicUlid } from "@std/ulid"; +import { monotonicUlid } from '@std/ulid'; -import type { KvKey } from "../types/common.ts"; +import type { KvKey } from '../types/common.ts'; /** * Serialize a {@linkcode KvKey} to a deterministic string for `Map` lookups diff --git a/packages/kv/core/mod.ts b/packages/kv/application/mod.ts similarity index 100% rename from packages/kv/core/mod.ts rename to packages/kv/application/mod.ts diff --git a/packages/kv/core/shared.ts b/packages/kv/application/shared.ts similarity index 100% rename from packages/kv/core/shared.ts rename to packages/kv/application/shared.ts diff --git a/packages/kv/bridges/mod.ts b/packages/kv/bridges/mod.ts deleted file mode 100644 index d9fbfe0c7..000000000 --- a/packages/kv/bridges/mod.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Bridge layer for kvdex integration with `@netscript/kv`. - * - * Re-exports the kvdex factory, the `WatchableKvBridge` adapter, and all - * kvdex-compatible types so that consumers can import everything from a single - * entrypoint. - * - * @module - */ - -export { - type DenoAtomicCheck, - type DenoAtomicOperation, - type DenoKvCommitError, - type DenoKvCommitResult, - type DenoKvEnqueueOptions, - type DenoKvEntryMaybe, - type DenoKvGetOptions, - type DenoKvKeyPart, - type DenoKvListIterator, - type DenoKvListOptions, - type DenoKvListSelector, - type DenoKvSetOptions, - type DenoKvStrictKey, - type DenoKvWatchOptions, - WatchableKvBridge, -} from './denokv-bridge.ts'; - -export { - createNetscriptDb, - type CreateNetscriptDbOptions, - type KvdexSchema, - type KvProvider, -} from './kvdex.ts'; diff --git a/packages/kv/deno.json b/packages/kv/deno.json index e60166144..eb8b95239 100644 --- a/packages/kv/deno.json +++ b/packages/kv/deno.json @@ -6,11 +6,15 @@ "exports": { ".": "./mod.ts", "./redis": "./redis.ts", - "./kvdex": "./kvdex.ts" + "./kvdex": "./kvdex.ts", + "./testing": "./src/testing/mod.ts" }, "tasks": { - "check": "deno check ./mod.ts ./redis.ts ./kvdex.ts", - "test": "deno test --allow-all ./tests/" + "check": "deno check --unstable-kv ./mod.ts ./redis.ts ./kvdex.ts ./src/testing/mod.ts", + "test": "deno test --allow-all ./tests/", + "lint": "deno lint", + "fmt": "deno fmt --check", + "publish:dry-run": "deno publish --dry-run --allow-dirty" }, "imports": { "@std/async": "jsr:@std/async@^1", @@ -25,6 +29,7 @@ "publish": { "include": [ "**/*.ts", + "docs/**/*.md", "deno.json", "README.md" ], diff --git a/packages/kv/docs/README.md b/packages/kv/docs/README.md new file mode 100644 index 000000000..202f57813 --- /dev/null +++ b/packages/kv/docs/README.md @@ -0,0 +1,27 @@ +--- +title: NetScript KV +description: Documentation hub for the reactive key-value adapters in @netscript/kv. +package: '@netscript/kv' +order: 0 +--- + +# NetScript KV + +`@netscript/kv` provides the shared key-value contract used by NetScript runtimes that need durable +state, prefix scans, and reactive key watching. + +## Contents + +| Page | Purpose | +| --------------------------------------- | ------------------------------------------------------------------------- | +| [Architecture](./architecture.md) | A2 integration layout, public surface map, and adapter boundaries. | +| [Concepts](./concepts.md) | Domain vocabulary for keys, entries, watches, providers, and adapters. | +| [Getting started](./getting-started.md) | First-run guide using the in-memory adapter and shared lifecycle helpers. | +| [Recipes](./recipes/README.md) | Task-oriented examples for storage, testing, and observability. | +| [Reference](./reference/README.md) | Entry point map for the current alpha surface. | + +## Required Permissions + +The in-memory adapter needs no Deno permissions. Deno KV usage requires `--unstable-kv`. Redis usage +requires network access to the Redis or Garnet endpoint and environment access only when the +application asks the package to discover `REDIS_URL`, `GARNET_URL`, or `KV_REDIS_URL`. diff --git a/packages/kv/docs/architecture.md b/packages/kv/docs/architecture.md new file mode 100644 index 000000000..d2333041b --- /dev/null +++ b/packages/kv/docs/architecture.md @@ -0,0 +1,53 @@ +--- +title: KV Architecture +description: A2 integration architecture for @netscript/kv adapters and testing contracts. +package: '@netscript/kv' +order: 1 +--- + +# KV Architecture + +Archetype: 2 + +`@netscript/kv` implements Archetype 2 (Integration): it owns a small key-value contract and ships +named adapters for Deno KV, Redis, Kvdex integration, and memory-backed tests. + +## Layer Diagram + +```text +mod.ts / subpaths + | + +-- application/ shared lifecycle, provider detection, key policies + +-- types/ KvStore, WatchableKv, keys, entries, watch events + +-- adapters/ DenoKvAdapter, RedisKvAdapter, WatchableKvBridge, MemoryKvAdapter + +-- src/testing/ public contract tests and in-memory fixtures +``` + +## Public Surface Map + +| Entrypoint | Exposes | +| ----------------------- | -------------------------------------------------------------------- | +| `@netscript/kv` | Shared lifecycle helpers, common types, Deno KV and memory adapters. | +| `@netscript/kv/redis` | Redis adapter and self-registration for Redis provider discovery. | +| `@netscript/kv/kvdex` | Kvdex bridge factory and Deno KV compatibility types. | +| `@netscript/kv/testing` | In-memory adapter factory and reusable KV contract tests. | + +## Axioms In Play + +- A1: public types are explicit through `types/` and root re-exports. +- A7: the Deno KV adapter wraps `Deno.Kv` directly instead of hiding it behind generic helpers. +- A8: folder names now describe roles: `application`, `adapters`, `types`, and `testing`. +- A10: shared defaults are wired in `application/shared.ts`, not through a container. + +## Anti-Patterns Avoided + +- AP-11: the package does not open Redis at module load time; Redis registration is explicit through + the `./redis` subpath. +- AP-16: the old generic `core/` role is now `application/`. +- AP-17: adapter-facing contracts are named as public types, not a generic `interfaces/` folder. +- AP-22: no private `adapters/mod.ts` sub-barrel is kept after bridge consolidation. + +## Migration Note + +The previous root `ARCHITECTURE.md` content has been folded into this page. Code examples now point +at the current role folders and the `./testing` contract subpath. diff --git a/packages/kv/docs/concepts.md b/packages/kv/docs/concepts.md new file mode 100644 index 000000000..a5dd4e84a --- /dev/null +++ b/packages/kv/docs/concepts.md @@ -0,0 +1,38 @@ +--- +title: KV Concepts +description: Glossary for @netscript/kv keys, stores, watches, providers, and testing contracts. +package: '@netscript/kv' +order: 2 +--- + +# KV Concepts + +## Adapter + +An adapter implements the KV behavior against a concrete backend. Current adapters cover Deno KV, +Redis, Kvdex bridging, and process-local memory. + +## Key + +A key is a readonly array of string-compatible parts. The array shape stays compatible with +`Deno.Kv` and with NetScript queue integrations that share the same storage boundary. + +## Provider + +A provider names the backend selected by the shared lifecycle helpers. Provider detection can use +explicit options or environment variables supplied by the application. + +## Store + +`KvStore` is the base contract for reading, writing, deleting, listing, and closing state. Callers +write against the contract so tests can replace the backend with memory. + +## Watch + +`WatchableKv` extends the store with `watch` and `watchPrefix`. Use watches for live state streams +instead of sentinel keys that force callers to reload an entire collection. + +## Testing Contract + +The `@netscript/kv/testing` subpath exposes the in-memory adapter and `runKvStoreContract`. Custom +adapters should run that contract before they are used by a package or plugin. diff --git a/packages/kv/docs/getting-started.md b/packages/kv/docs/getting-started.md new file mode 100644 index 000000000..f9c794da6 --- /dev/null +++ b/packages/kv/docs/getting-started.md @@ -0,0 +1,49 @@ +--- +title: Getting Started +description: First run guide for @netscript/kv with memory, Deno KV, and Redis-backed usage. +package: '@netscript/kv' +order: 3 +--- + +# Getting Started + +Install the package: + +```powershell +deno add jsr:@netscript/kv@^0.0.1-alpha.0 +``` + +## Use the Memory Adapter + +```ts +import { MemoryKvAdapter } from 'jsr:@netscript/kv@^0.0.1-alpha.0'; + +const kv = new MemoryKvAdapter(); +await kv.set(['jobs', 'export'], { status: 'queued' }); +const job = await kv.get<{ status: string }>(['jobs', 'export']); +await kv.close(); +``` + +## Use the Shared Deno KV Lifecycle + +```ts +import { closeKv, getKv } from 'jsr:@netscript/kv@^0.0.1-alpha.0'; + +const kv = await getKv({ provider: 'deno-kv' }); +await kv.set(['jobs', 'import'], { status: 'running' }); +await closeKv(); +``` + +Run with `--unstable-kv` when the Deno KV provider is selected. + +## Use Redis + +```ts +import 'jsr:@netscript/kv@^0.0.1-alpha.0/redis'; +import { getKv } from 'jsr:@netscript/kv@^0.0.1-alpha.0'; + +const kv = await getKv({ provider: 'redis', redisUrl: 'redis://localhost:6379' }); +await kv.set(['jobs', 'notify'], { status: 'queued' }); +``` + +Grant network access to the Redis endpoint. diff --git a/packages/kv/docs/recipes/README.md b/packages/kv/docs/recipes/README.md new file mode 100644 index 000000000..50153b954 --- /dev/null +++ b/packages/kv/docs/recipes/README.md @@ -0,0 +1,14 @@ +--- +title: KV Recipes +description: Task-oriented recipes for storing, watching, testing, and observing KV-backed state. +package: '@netscript/kv' +order: 0 +--- + +# KV Recipes + +| Recipe | Goal | +| ----------------------------------- | -------------------------------------------------------------- | +| [Basic usage](./basic-usage.md) | Store and list records with a memory adapter. | +| [Testing](./testing.md) | Run the shared KV contract against an adapter. | +| [Observability](./observability.md) | Attach structured state changes to logs and traces in callers. | diff --git a/packages/kv/docs/recipes/basic-usage.md b/packages/kv/docs/recipes/basic-usage.md new file mode 100644 index 000000000..84e0f6750 --- /dev/null +++ b/packages/kv/docs/recipes/basic-usage.md @@ -0,0 +1,32 @@ +--- +title: Basic KV Usage +description: Store, read, list, and delete records with @netscript/kv. +package: '@netscript/kv' +order: 1 +--- + +# Basic KV Usage + +## Goal + +Store job state under a stable prefix and list it later. + +## Steps + +```ts +import { MemoryKvAdapter } from 'jsr:@netscript/kv@^0.0.1-alpha.0'; + +const kv = new MemoryKvAdapter(); +await kv.set(['jobs', 'nightly'], { status: 'queued' }); + +for await (const entry of kv.list<{ status: string }>({ prefix: ['jobs'] })) { + entry.value.status; +} + +await kv.delete(['jobs', 'nightly']); +await kv.close(); +``` + +## Pitfalls + +Use array keys consistently. Mixing string serialization formats makes prefix scans unreliable. diff --git a/packages/kv/docs/recipes/observability.md b/packages/kv/docs/recipes/observability.md new file mode 100644 index 000000000..20919b87a --- /dev/null +++ b/packages/kv/docs/recipes/observability.md @@ -0,0 +1,33 @@ +--- +title: KV Observability +description: Record structured KV state changes from callers without embedding console output. +package: '@netscript/kv' +order: 3 +--- + +# KV Observability + +## Goal + +Record key changes in the caller's logger or telemetry layer. + +## Steps + +```ts +import { MemoryKvAdapter } from 'jsr:@netscript/kv@^0.0.1-alpha.0'; + +const kv = new MemoryKvAdapter(); +const iterator = kv.watchPrefix(['jobs'], { skipInitial: true })[Symbol.asyncIterator](); + +await kv.set(['jobs', 'one'], { status: 'queued' }); +const event = await iterator.next(); +event.value?.type; + +await iterator.return?.(); +await kv.close(); +``` + +## Pitfalls + +Published packages should not call `console.log` from adapter internals. Emit structured events in +the consuming runtime instead. diff --git a/packages/kv/docs/recipes/testing.md b/packages/kv/docs/recipes/testing.md new file mode 100644 index 000000000..76f8b8bc7 --- /dev/null +++ b/packages/kv/docs/recipes/testing.md @@ -0,0 +1,31 @@ +--- +title: Testing KV Adapters +description: Use @netscript/kv/testing to verify an adapter satisfies the shared KV contract. +package: '@netscript/kv' +order: 2 +--- + +# Testing KV Adapters + +## Goal + +Run the same behavior contract against every adapter implementation. + +## Steps + +```ts +import { + createMemoryKvAdapter, + runKvStoreContract, +} from 'jsr:@netscript/kv@^0.0.1-alpha.0/testing'; + +runKvStoreContract({ + name: 'memory', + make: () => createMemoryKvAdapter(), +}); +``` + +## Pitfalls + +The contract creates a fresh adapter per scenario. Do not reuse global adapter instances across +tests because they hide cleanup bugs. diff --git a/packages/kv/docs/reference/README.md b/packages/kv/docs/reference/README.md new file mode 100644 index 000000000..fac31421d --- /dev/null +++ b/packages/kv/docs/reference/README.md @@ -0,0 +1,18 @@ +--- +title: KV Reference +description: Entrypoint reference map for @netscript/kv during alpha. +package: '@netscript/kv' +order: 0 +--- + +# KV Reference + +Reference pages are generated from `deno doc` in a later wave. During alpha, use JSR API docs and +the entrypoint map below. + +| Entrypoint | Primary symbols | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| `@netscript/kv` | `getKv`, `getRawKv`, `closeKv`, `DenoKvAdapter`, `MemoryKvAdapter`, `KvStore`, `WatchableKv`. | +| `@netscript/kv/redis` | `RedisKvAdapter`, `RedisKvOptions`. | +| `@netscript/kv/kvdex` | `createNetscriptDb`, `WatchableKvBridge`, Kvdex compatibility types. | +| `@netscript/kv/testing` | `createMemoryKvAdapter`, `runKvStoreContract`, `KvStoreContractOptions`. | diff --git a/packages/kv/kvdex.ts b/packages/kv/kvdex.ts index e1f2f24c1..2fceb2eab 100644 --- a/packages/kv/kvdex.ts +++ b/packages/kv/kvdex.ts @@ -12,7 +12,7 @@ export { type CreateNetscriptDbOptions, type KvdexSchema, type KvProvider, -} from './bridges/kvdex.ts'; +} from './adapters/kvdex.ts'; export { type DenoAtomicCheck, @@ -30,7 +30,7 @@ export { type DenoKvStrictKey, type DenoKvWatchOptions, WatchableKvBridge, -} from './bridges/denokv-bridge.ts'; +} from './adapters/denokv-bridge.ts'; export { collection, kvdex, model } from '@olli/kvdex'; export type { KvObject } from '@olli/kvdex'; diff --git a/packages/kv/mod.ts b/packages/kv/mod.ts index cab48c0e0..629782044 100644 --- a/packages/kv/mod.ts +++ b/packages/kv/mod.ts @@ -19,7 +19,7 @@ export { type KvProvider, resetKv, type SharedKvConfig, -} from './core/mod.ts'; +} from './application/mod.ts'; export type { AtomicCheck, diff --git a/packages/kv/redis.ts b/packages/kv/redis.ts index 9122fb271..54ca06318 100644 --- a/packages/kv/redis.ts +++ b/packages/kv/redis.ts @@ -25,7 +25,7 @@ */ import { RedisKvAdapter } from './adapters/redis.adapter.ts'; -import { registerKvAdapter } from './core/shared.ts'; +import { registerKvAdapter } from './application/shared.ts'; // --------------------------------------------------------------------------- // Self-registration — runs once on first import of this module. diff --git a/packages/kv/src/testing/memory-kv.ts b/packages/kv/src/testing/memory-kv.ts new file mode 100644 index 000000000..6bdcd7862 --- /dev/null +++ b/packages/kv/src/testing/memory-kv.ts @@ -0,0 +1,58 @@ +/** + * Test contracts and in-memory fixtures for `@netscript/kv`. + * + * @module + */ + +import { assert, assertEquals } from '@std/assert'; +import { MemoryKvAdapter } from '../../adapters/memory.adapter.ts'; +import type { KvStore } from '../../types/kv-store.ts'; + +/** + * Factory used by downstream tests when they need a clean in-memory KV adapter. + * + * @returns A new volatile adapter that implements the public KV contract. + */ +export function createMemoryKvAdapter(): MemoryKvAdapter { + return new MemoryKvAdapter(); +} + +/** + * Options for {@linkcode runKvStoreContract}. + */ +export interface KvStoreContractOptions { + /** Human-readable adapter name used in Deno test titles. */ + readonly name: string; + /** Creates a fresh adapter for each contract scenario. */ + readonly make: () => KvStore | Promise; +} + +/** + * Registers the canonical KV store contract tests for an adapter. + * + * @param options - Adapter factory and display name. + */ +export function runKvStoreContract(options: KvStoreContractOptions): void { + Deno.test(`${options.name}: stores, reads, lists, and deletes entries`, async () => { + const store = await options.make(); + try { + await store.set(['contract', 'one'], { value: 1 }); + await store.set(['contract', 'two'], { value: 2 }); + + const first = await store.get<{ value: number }>(['contract', 'one']); + assert(first); + assertEquals(first.value.value, 1); + + const values: number[] = []; + for await (const entry of store.list<{ value: number }>({ prefix: ['contract'] })) { + values.push(entry.value.value); + } + assertEquals(values, [1, 2]); + + await store.delete(['contract', 'one']); + assertEquals(await store.get(['contract', 'one']), null); + } finally { + await store.close(); + } + }); +} diff --git a/packages/kv/src/testing/mod.ts b/packages/kv/src/testing/mod.ts new file mode 100644 index 000000000..c7921a9e0 --- /dev/null +++ b/packages/kv/src/testing/mod.ts @@ -0,0 +1,27 @@ +/** + * Testing utilities for `@netscript/kv`. + * + * Import this subpath from adapter tests to run the shared KV port contract + * against a custom backend. + * + * @example + * ```ts + * import { createMemoryKvAdapter, runKvStoreContract } from "@netscript/kv/testing"; + * + * runKvStoreContract({ + * name: "memory", + * make: () => createMemoryKvAdapter(), + * }); + * ``` + * + * @module + */ + +export { + createMemoryKvAdapter, + type KvStoreContractOptions, + runKvStoreContract, +} from './memory-kv.ts'; +export { MemoryKvAdapter } from '../../adapters/memory.adapter.ts'; +export type { KvStore } from '../../types/kv-store.ts'; +export type { WatchableKv } from '../../types/watchable-kv.ts'; diff --git a/packages/kv/tests/_fixtures/docs-examples_test.ts b/packages/kv/tests/_fixtures/docs-examples_test.ts new file mode 100644 index 000000000..2c739a8f4 --- /dev/null +++ b/packages/kv/tests/_fixtures/docs-examples_test.ts @@ -0,0 +1,46 @@ +import { assert, assertEquals } from '@std/assert'; +import { MemoryKvAdapter } from '../../mod.ts'; +import { createMemoryKvAdapter } from '../../src/testing/mod.ts'; + +Deno.test('docs: memory quickstart stores and reads job state', async () => { + const kv = new MemoryKvAdapter(); + try { + await kv.set(['jobs', 'export'], { status: 'queued' }); + const job = await kv.get<{ status: string }>(['jobs', 'export']); + assert(job); + assertEquals(job.value.status, 'queued'); + } finally { + await kv.close(); + } +}); + +Deno.test('docs: testing entrypoint creates a clean in-memory adapter', async () => { + const kv = createMemoryKvAdapter(); + try { + await kv.set(['contract', 'example'], 1); + assertEquals((await kv.get(['contract', 'example']))?.value, 1); + } finally { + await kv.close(); + } +}); + +Deno.test('docs: observability recipe receives a watchPrefix event', async () => { + const kv = new MemoryKvAdapter(); + try { + const iterator = kv.watchPrefix<{ status: string }>(['jobs'], { + skipInitial: true, + })[Symbol.asyncIterator](); + + const next = iterator.next(); + await kv.set(['jobs', 'one'], { status: 'queued' }); + const event = await next; + + assert(!event.done); + assertEquals(event.value.type, 'set'); + assertEquals(event.value.key, ['jobs', 'one']); + + await iterator.return?.(); + } finally { + await kv.close(); + } +}); diff --git a/packages/kv/tests/auto-detect_test.ts b/packages/kv/tests/auto-detect_test.ts index 58a9db48e..d46c8e1dc 100644 --- a/packages/kv/tests/auto-detect_test.ts +++ b/packages/kv/tests/auto-detect_test.ts @@ -4,7 +4,7 @@ import { getDenoKvConnectionFromEnv, getRedisConnectionFromEnv, maskRedisUrl, -} from '../core/auto-detect.ts'; +} from '../application/auto-detect.ts'; async function withEnv( values: Record, diff --git a/packages/kv/tests/bridge_test.ts b/packages/kv/tests/bridge_test.ts index 600f1fb3d..bba6912ba 100644 --- a/packages/kv/tests/bridge_test.ts +++ b/packages/kv/tests/bridge_test.ts @@ -15,8 +15,8 @@ import { assert, assertEquals, assertThrows } from '@std/assert'; import { MemoryKvAdapter } from '../adapters/memory.adapter.ts'; -import { WatchableKvBridge } from '../bridges/denokv-bridge.ts'; -import { createNetscriptDb } from '../bridges/kvdex.ts'; +import { WatchableKvBridge } from '../adapters/denokv-bridge.ts'; +import { createNetscriptDb } from '../adapters/kvdex.ts'; import { collection, model } from '@olli/kvdex'; // --------------------------------------------------------------------------- diff --git a/packages/kv/tests/keys_test.ts b/packages/kv/tests/keys_test.ts index ee4e7fd73..94c06bd3d 100644 --- a/packages/kv/tests/keys_test.ts +++ b/packages/kv/tests/keys_test.ts @@ -1,55 +1,55 @@ /** - * Unit tests for the shared key utilities in `core/keys.ts`. + * Unit tests for the shared key utilities in `application/keys.ts`. * * @module */ -import { assert, assertEquals, assertStrictEquals } from "@std/assert"; +import { assert, assertEquals, assertStrictEquals } from '@std/assert'; import { compareKeys, generateVersionstamp, keyHasPrefix, keyToString, -} from "../core/keys.ts"; +} from '../application/keys.ts'; // --------------------------------------------------------------------------- // keyToString // --------------------------------------------------------------------------- -Deno.test("keyToString — string-only key", () => { - assertEquals(keyToString(["users", "123"]), '["users","123"]'); +Deno.test('keyToString — string-only key', () => { + assertEquals(keyToString(['users', '123']), '["users","123"]'); }); -Deno.test("keyToString — mixed types (string, number, boolean)", () => { - assertEquals(keyToString(["ns", 42, true]), '["ns",42,true]'); +Deno.test('keyToString — mixed types (string, number, boolean)', () => { + assertEquals(keyToString(['ns', 42, true]), '["ns",42,true]'); }); -Deno.test("keyToString — single segment", () => { - assertEquals(keyToString(["root"]), '["root"]'); +Deno.test('keyToString — single segment', () => { + assertEquals(keyToString(['root']), '["root"]'); }); -Deno.test("keyToString — empty key", () => { - assertEquals(keyToString([]), "[]"); +Deno.test('keyToString — empty key', () => { + assertEquals(keyToString([]), '[]'); }); -Deno.test("keyToString — deterministic (same input produces identical output)", () => { - const key = ["users", "123"] as const; +Deno.test('keyToString — deterministic (same input produces identical output)', () => { + const key = ['users', '123'] as const; assertStrictEquals(keyToString(key), keyToString(key)); - assertStrictEquals(keyToString(["ns", 42, true]), keyToString(["ns", 42, true])); + assertStrictEquals(keyToString(['ns', 42, true]), keyToString(['ns', 42, true])); }); // --------------------------------------------------------------------------- // generateVersionstamp // --------------------------------------------------------------------------- -Deno.test("generateVersionstamp — returns a 26-character string", () => { +Deno.test('generateVersionstamp — returns a 26-character string', () => { const stamp = generateVersionstamp(); - assertStrictEquals(typeof stamp, "string"); + assertStrictEquals(typeof stamp, 'string'); assertStrictEquals(stamp.length, 26); }); -Deno.test("generateVersionstamp — matches Crockford base-32 alphabet", () => { +Deno.test('generateVersionstamp — matches Crockford base-32 alphabet', () => { const stamp = generateVersionstamp(); assert( /^[0-9A-HJKMNP-TV-Z]{26}$/.test(stamp), @@ -57,13 +57,13 @@ Deno.test("generateVersionstamp — matches Crockford base-32 alphabet", () => { ); }); -Deno.test("generateVersionstamp — sequential calls are strictly monotonic", () => { +Deno.test('generateVersionstamp — sequential calls are strictly monotonic', () => { const a = generateVersionstamp(); const b = generateVersionstamp(); assert(a < b, `Expected "${a}" < "${b}"`); }); -Deno.test("generateVersionstamp — rapid burst of 100 calls: unique and ascending", () => { +Deno.test('generateVersionstamp — rapid burst of 100 calls: unique and ascending', () => { const stamps: string[] = []; for (let i = 0; i < 100; i++) { stamps.push(generateVersionstamp()); @@ -71,7 +71,7 @@ Deno.test("generateVersionstamp — rapid burst of 100 calls: unique and ascendi // Every stamp should be unique. const unique = new Set(stamps); - assertStrictEquals(unique.size, stamps.length, "All 100 stamps must be unique"); + assertStrictEquals(unique.size, stamps.length, 'All 100 stamps must be unique'); // Every consecutive pair must be strictly ascending. for (let i = 1; i < stamps.length; i++) { @@ -86,65 +86,65 @@ Deno.test("generateVersionstamp — rapid burst of 100 calls: unique and ascendi // keyHasPrefix // --------------------------------------------------------------------------- -Deno.test("keyHasPrefix — exact match returns true", () => { - assertStrictEquals(keyHasPrefix(["a", "b"], ["a", "b"]), true); +Deno.test('keyHasPrefix — exact match returns true', () => { + assertStrictEquals(keyHasPrefix(['a', 'b'], ['a', 'b']), true); }); -Deno.test("keyHasPrefix — proper prefix returns true", () => { - assertStrictEquals(keyHasPrefix(["a", "b", "c"], ["a", "b"]), true); +Deno.test('keyHasPrefix — proper prefix returns true', () => { + assertStrictEquals(keyHasPrefix(['a', 'b', 'c'], ['a', 'b']), true); }); -Deno.test("keyHasPrefix — wrong prefix returns false", () => { - assertStrictEquals(keyHasPrefix(["x", "b"], ["a"]), false); +Deno.test('keyHasPrefix — wrong prefix returns false', () => { + assertStrictEquals(keyHasPrefix(['x', 'b'], ['a']), false); }); -Deno.test("keyHasPrefix — key shorter than prefix returns false", () => { - assertStrictEquals(keyHasPrefix(["a"], ["a", "b"]), false); +Deno.test('keyHasPrefix — key shorter than prefix returns false', () => { + assertStrictEquals(keyHasPrefix(['a'], ['a', 'b']), false); }); -Deno.test("keyHasPrefix — empty prefix always matches", () => { - assertStrictEquals(keyHasPrefix(["a"], []), true); +Deno.test('keyHasPrefix — empty prefix always matches', () => { + assertStrictEquals(keyHasPrefix(['a'], []), true); }); -Deno.test("keyHasPrefix — both empty returns true", () => { +Deno.test('keyHasPrefix — both empty returns true', () => { assertStrictEquals(keyHasPrefix([], []), true); }); -Deno.test("keyHasPrefix — numeric segments", () => { +Deno.test('keyHasPrefix — numeric segments', () => { assertStrictEquals(keyHasPrefix([1, 2, 3], [1, 2]), true); }); Deno.test("keyHasPrefix — type mismatch with strict equality (string '1' vs number 1)", () => { // The function uses `!==` so "1" and 1 are different. - assertStrictEquals(keyHasPrefix(["1"], [1]), false); + assertStrictEquals(keyHasPrefix(['1'], [1]), false); }); // --------------------------------------------------------------------------- // compareKeys // --------------------------------------------------------------------------- -Deno.test("compareKeys — equal keys return 0", () => { - assertStrictEquals(compareKeys(["a", "b"], ["a", "b"]), 0); +Deno.test('compareKeys — equal keys return 0', () => { + assertStrictEquals(compareKeys(['a', 'b'], ['a', 'b']), 0); }); -Deno.test("compareKeys — first key less than second returns negative", () => { - assert(compareKeys(["a"], ["b"]) < 0); +Deno.test('compareKeys — first key less than second returns negative', () => { + assert(compareKeys(['a'], ['b']) < 0); }); -Deno.test("compareKeys — first key greater than second returns positive", () => { - assert(compareKeys(["b"], ["a"]) > 0); +Deno.test('compareKeys — first key greater than second returns positive', () => { + assert(compareKeys(['b'], ['a']) > 0); }); -Deno.test("compareKeys — shorter key sorts before longer key when common prefix matches", () => { - assert(compareKeys(["a"], ["a", "b"]) < 0); +Deno.test('compareKeys — shorter key sorts before longer key when common prefix matches', () => { + assert(compareKeys(['a'], ['a', 'b']) < 0); }); -Deno.test("compareKeys — numeric segments compared via String() coercion", () => { +Deno.test('compareKeys — numeric segments compared via String() coercion', () => { // String(1) = "1", String(2) = "2"; "1" < "2" assert(compareKeys([1], [2]) < 0); }); -Deno.test("compareKeys — mixed types ordered via String() coercion", () => { +Deno.test('compareKeys — mixed types ordered via String() coercion', () => { // String("a") = "a" equal, then String(1) = "1" < String(2) = "2" - assert(compareKeys(["a", 1], ["a", 2]) < 0); + assert(compareKeys(['a', 1], ['a', 2]) < 0); }); diff --git a/packages/prisma-adapter-mysql/README.md b/packages/prisma-adapter-mysql/README.md index ae4bec5aa..001f0e0e6 100644 --- a/packages/prisma-adapter-mysql/README.md +++ b/packages/prisma-adapter-mysql/README.md @@ -1,123 +1,302 @@ # @netscript/prisma-adapter-mysql -A Prisma driver adapter for MySQL/MariaDB that uses Deno's native mysql driver instead of the npm `mariadb` package. +`@netscript/prisma-adapter-mysql` is the MySQL and MariaDB Prisma driver adapter for NetScript +packages running on Deno. -## Problem +It provides a Prisma v7 driver adapter factory backed by `mysql2`. -Prisma v7 introduced driver adapters allowing custom database drivers. The official `@prisma/adapter-mariadb` uses the npm `mariadb` package, which has a critical incompatibility with Deno: +The package exists because the official MariaDB adapter depends on a driver path that can touch Node +socket internals that are not available in Deno. +The package is intentionally small. + +It owns the Prisma adapter bridge, MySQL value conversion, capability detection, and driver error +mapping. + +It does not own generated Prisma clients. + +It does not create databases. + +It does not manage application migrations. + +Those concerns stay in consuming applications and plugins. + +## Installation + +Use the JSR package from Deno code: + +```typescript +import { PrismaMySql } from 'jsr:@netscript/prisma-adapter-mysql'; ``` -Error: Symbol(Deno.internal.rid) + +Use the workspace package during local development: + +```typescript +import { PrismaMySql } from '../packages/prisma-adapter-mysql/mod.ts'; ``` -The mariadb driver accesses internal Node.js socket properties that don't exist in Deno's Node compatibility layer. +The package targets `0.0.1-alpha.0` during the package-quality release line. -## Solution +Keep this version lockstep with the rest of the NetScript alpha packages. -This adapter uses `deno.land/x/mysql` (deno_mysql) instead of the npm mariadb package, providing native Deno compatibility without requiring any Node.js dependencies. +## Public Surface -## Installation +The root export provides the adapter factory: -Since this is a Deno package, you can import it directly: +```typescript +import { PrismaMySql } from '@netscript/prisma-adapter-mysql'; +``` + +The root export also exposes package-owned public types: ```typescript -import { PrismaMySql } from "jsr:@netscript/prisma-adapter-mysql"; -// or from your local packages -import { PrismaMySql } from "../packages/prisma-adapter-mysql/src/mod.ts"; +import type { + MySqlConnectionConfig, + PrismaMySqlConnectedAdapter, + PrismaMySqlOptions, +} from '@netscript/prisma-adapter-mysql'; ``` -## Usage +Implementation helpers for conversion and error mapping are internal to the package. + +They are tested directly inside the repository but are not part of the supported root API. + +This keeps private Prisma driver utility types out of generated JSR documentation. + +## Basic Usage + +Create the adapter factory and pass it to Prisma Client: ```typescript -import { PrismaClient } from "@prisma/client"; -import { PrismaMySql } from "@netscript/prisma-adapter-mysql"; +import { PrismaClient } from '@prisma/client'; +import { PrismaMySql } from '@netscript/prisma-adapter-mysql'; -// Create adapter with connection config const adapter = new PrismaMySql({ - hostname: "localhost", + hostname: 'localhost', port: 3306, - username: "root", - password: "password", - db: "mydb", + username: 'root', + password: 'password', + db: 'app', poolSize: 5, }); -// Create Prisma client with adapter const prisma = new PrismaClient({ adapter }); -// Use Prisma normally const users = await prisma.user.findMany(); -// Cleanup await prisma.$disconnect(); ``` -## Configuration Options +The adapter factory opens pooled MySQL connections when Prisma connects. + +Prisma owns the normal client lifecycle. + +Call `prisma.$disconnect()` during application shutdown. + +## Configuration + +`MySqlConnectionConfig` accepts standard MySQL connection settings: + +```typescript +const config = { + hostname: 'localhost', + port: 3306, + username: 'root', + password: 'password', + db: 'app', + poolSize: 5, + timeout: 30_000, +}; +``` + +`hostname` is the MySQL server host. + +`port` defaults to `3306` when the underlying driver applies its default. + +`username` is the database user. + +`password` is optional when the server allows passwordless access. + +`db` is the database/schema name. + +`poolSize` controls the connection pool limit. + +`timeout` controls the driver connection timeout in milliseconds. + +TLS can be enabled with certificate material: ```typescript -interface MySqlConnectionConfig { - /** MySQL server hostname */ - hostname?: string; - /** MySQL server port (default: 3306) */ - port?: number; - /** Database username */ - username?: string; - /** Database password */ - password?: string; - /** Database name */ - db?: string; - /** Connection pool size (default: 1) */ - poolSize?: number; - /** Connection timeout in milliseconds */ - timeout?: number; - /** TLS configuration */ - tls?: { - mode?: "disabled" | "verify_identity"; - caCerts?: string[]; - }; -} - -interface PrismaMySqlOptions { - /** Database schema name */ - database?: string; - /** Callback when connection errors occur */ - onConnectionError?: (err: Error) => void; -} +const config = { + hostname: 'mysql.example.net', + username: 'app', + password: Deno.env.get('MYSQL_PASSWORD'), + db: 'app', + tls: { + mode: 'verify_identity', + caCerts: [await Deno.readTextFile('ca.pem')], + }, +}; ``` -## Features +## Permissions + +The adapter needs network permission to reach MySQL: + +```powershell +deno run --allow-net app.ts +``` + +Applications that read secrets from environment variables need environment permission: + +```powershell +deno run --allow-net --allow-env=MYSQL_PASSWORD app.ts +``` + +Applications that read TLS certificates from disk need file read permission: + +```powershell +deno run --allow-net --allow-read=ca.pem app.ts +``` + +The package itself does not read environment variables. + +The package itself does not read files. + +The package itself does not spawn subprocesses. + +## Capabilities + +The adapter detects MySQL server capability information on first connection. + +It enables relation joins for supported MySQL versions. + +It disables relation joins for old MySQL versions. -- ✅ Native Deno support - no Node.js compatibility layer issues -- ✅ Full Prisma CRUD operations (findMany, create, update, delete) -- ✅ Transaction support with isolation levels -- ✅ Connection pooling -- ✅ Automatic MySQL/MariaDB capability detection -- ✅ Proper type mappings (BigInt, Date, JSON, Bytes, etc.) -- ✅ Comprehensive error handling with Prisma error types +It disables relation joins for MariaDB versions. -## Compatibility +Capability detection is conservative when the server version cannot be read. -- MySQL 5.7+ -- MySQL 8.0+ (with relation joins support) -- MariaDB 10.2+ -- Deno 1.x / 2.x -- Prisma 7.0+ +## Type Conversion -## Limitations +The adapter maps MySQL column metadata to Prisma column types. -- Some advanced features may have different behavior compared to the official adapter -- Field metadata inference is based on JavaScript types when full metadata isn't available +Integer columns map to Prisma integer types. -## Development +Decimal columns map to Prisma numeric values. -```bash -# Type check +Date and time columns are normalized for Prisma transport. + +JSON columns map to Prisma JSON. + +Binary values are transported as bytes. + +BigInt values are returned as strings where Prisma expects transport-safe values. + +## Error Mapping + +The adapter maps common MySQL errors to Prisma driver error kinds. + +Unique constraint violations map to `UniqueConstraintViolation`. + +Foreign key failures map to `ForeignKeyConstraintViolation`. + +Missing databases map to `DatabaseDoesNotExist`. + +Authentication failures map to `AuthenticationFailed`. + +Unknown MySQL errors are preserved with MySQL code, message, state, and cause data. + +## Transactions + +The adapter starts transactions by checking out a pooled connection. + +It applies the requested isolation level when Prisma provides one. + +It begins the transaction before returning the transaction adapter. + +It releases the connection after commit or rollback. + +The implementation guards against duplicate commit or rollback calls. + +## Examples + +The repository includes `examples/basic-usage.ts`. + +Examples are intentionally excluded from the JSR publish payload. + +They may reference generated Prisma clients or local runtime assumptions that do not belong in the +package artifact. + +Runnable documentation examples used for package gates live under `tests/`. + +## Testing + +Run type checking: + +```powershell deno task check +``` + +Run tests: -# Run tests (requires MySQL server) +```powershell deno task test ``` +Run lint: + +```powershell +deno task lint +``` + +Run format validation: + +```powershell +deno task fmt +``` + +Run publish dry-run: + +```powershell +deno task publish:dry-run +``` + +The focused tests cover pure conversion, capability, and error mapping behavior. + +Live database integration tests are deferred to higher-level runtime validation. + +## Package Layout + +`mod.ts` is the package root. + +`src/mod.ts` owns the internal export barrel. + +`src/adapter.ts` owns the Prisma driver adapter factory and connection lifecycle. + +`src/conversion.ts` owns MySQL-to-Prisma value conversion. + +`src/errors.ts` owns MySQL-to-Prisma error mapping. + +`src/types.ts` owns public configuration and driver-shape types. + +`docs/` contains package documentation for architecture, concepts, setup, recipes, and reference. + +`examples/` contains local examples and is excluded from publish. + +`tests/` contains package tests and is excluded from publish. + +## Stability + +This package is alpha software. + +Public root types are intended to be stable within the `0.0.1-alpha.0` release line. + +Implementation helpers may change without a compatibility alias. + +Avoid importing from `src/` in consuming packages. + +Use the package root import instead. + ## License -Apache-2.0 (same as Prisma) +Apache-2.0. diff --git a/packages/prisma-adapter-mysql/deno.json b/packages/prisma-adapter-mysql/deno.json index f25f4692e..ada563e96 100644 --- a/packages/prisma-adapter-mysql/deno.json +++ b/packages/prisma-adapter-mysql/deno.json @@ -1,16 +1,35 @@ { "name": "@netscript/prisma-adapter-mysql", "version": "0.0.1-alpha.0", - "exports": "./src/mod.ts", + "description": "Prisma driver adapter for MySQL and MariaDB on Deno.", + "license": "Apache-2.0", + "exports": { + ".": "./mod.ts" + }, "imports": { "@prisma/driver-adapter-utils": "npm:@prisma/driver-adapter-utils@^7.4.2", "mysql": "npm:mysql2@^3.22.3" }, "tasks": { - "check": "deno check src/mod.ts", - "test": "deno test --allow-net --allow-env" + "check": "deno check --unstable-kv ./mod.ts", + "test": "deno test --allow-net --allow-env ./tests/", + "lint": "deno lint", + "fmt": "deno fmt --check", + "publish:dry-run": "deno publish --dry-run --allow-dirty" }, - "compilerOptions": { - "skipLibCheck": true + "publish": { + "include": [ + "README.md", + "docs/**/*.md", + "deno.json", + "mod.ts", + "src/**/*.ts" + ], + "exclude": [ + "examples/**", + "tests/**", + "**/*_test.ts", + "**/*.test.ts" + ] } } diff --git a/packages/prisma-adapter-mysql/docs/README.md b/packages/prisma-adapter-mysql/docs/README.md new file mode 100644 index 000000000..1b0b5fb30 --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/README.md @@ -0,0 +1,26 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Prisma MySQL Adapter Documentation' +--- + +# Prisma MySQL Adapter Documentation + +This directory documents the package-owned MySQL adapter surface for Prisma on Deno. + +Use these pages when adding adapter behavior, reviewing permissions, or wiring the package into an +application. + +## Sections + +- [Architecture](./architecture.md) describes package boundaries and composition. +- [Concepts](./concepts.md) defines adapter vocabulary. +- [Getting started](./getting-started.md) shows the minimal setup path. +- [Recipes](./recipes/README.md) collects focused usage patterns. +- [Reference](./reference/README.md) summarizes exported symbols. + +## Scope + +The package owns the Prisma driver adapter bridge. + +Generated Prisma clients, schemas, migrations, and application database lifecycle stay outside this +package. diff --git a/packages/prisma-adapter-mysql/docs/architecture.md b/packages/prisma-adapter-mysql/docs/architecture.md new file mode 100644 index 000000000..9cef63ee3 --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/architecture.md @@ -0,0 +1,62 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Architecture' +--- + +# Architecture + +Archetype: 2 + +`@netscript/prisma-adapter-mysql` is an Archetype 2 integration package. + +It wraps a MySQL driver behind Prisma's driver adapter protocol. + +## Boundaries + +The package owns: + +- adapter factory construction +- MySQL pool configuration +- connected adapter lifecycle +- transaction connection checkout and release +- MySQL field and value conversion +- MySQL error mapping +- server capability detection + +The package does not own: + +- Prisma schema generation +- generated Prisma clients +- migrations +- application configuration loading +- secret management +- real database provisioning + +## Composition + +Consumers create `PrismaMySqlAdapterFactory` through the `PrismaMySql` export. + +Prisma calls `connect()` on the factory. + +The factory creates the underlying MySQL pool and returns an internal connected adapter. + +The connected adapter is represented publicly by `PrismaMySqlConnectedAdapter`. + +## Public Surface + +The package root exports stable, package-owned names. + +Implementation helpers are not exported from the root because they depend on Prisma internal driver +utility types. + +This keeps JSR documentation focused on the supported adapter surface. + +## Permissions + +The adapter requires network permission at runtime. + +The package does not read environment variables or files by itself. + +Consumers need `--allow-env` only when their application reads credentials. + +Consumers need `--allow-read` only when their application reads TLS certificate files. diff --git a/packages/prisma-adapter-mysql/docs/concepts.md b/packages/prisma-adapter-mysql/docs/concepts.md new file mode 100644 index 000000000..14c0a7a89 --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/concepts.md @@ -0,0 +1,36 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Concepts' +--- + +# Concepts + +## Adapter Factory + +`PrismaMySql` is the root factory export. + +It is passed to `PrismaClient` as the `adapter` option. + +## Connected Adapter + +The connected adapter is created by Prisma when it opens the database connection. + +Consumers normally do not call `connect()` directly. + +## Capability Detection + +The factory reads the server version through the driver and infers relation-join support. + +Unknown versions use conservative defaults. + +## Conversion + +Conversion maps MySQL field metadata and values to Prisma's transport format. + +Date, time, binary, decimal, JSON, and BigInt values receive explicit handling. + +## Error Mapping + +MySQL driver errors are converted to Prisma driver error kinds where possible. + +Unknown MySQL errors preserve their code and message. diff --git a/packages/prisma-adapter-mysql/docs/getting-started.md b/packages/prisma-adapter-mysql/docs/getting-started.md new file mode 100644 index 000000000..2ddf98b3f --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/getting-started.md @@ -0,0 +1,38 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Getting Started' +--- + +# Getting Started + +Import the factory from the package root: + +```typescript +import { PrismaMySql } from '@netscript/prisma-adapter-mysql'; +``` + +Create a factory with connection settings: + +```typescript +const adapter = new PrismaMySql({ + hostname: 'localhost', + port: 3306, + username: 'root', + password: 'password', + db: 'app', +}); +``` + +Pass it to Prisma Client: + +```typescript +const prisma = new PrismaClient({ adapter }); +``` + +Run with network permission: + +```powershell +deno run --allow-net app.ts +``` + +Add environment or read permissions only when your application reads secrets or certificate files. diff --git a/packages/prisma-adapter-mysql/docs/recipes/README.md b/packages/prisma-adapter-mysql/docs/recipes/README.md new file mode 100644 index 000000000..beeab041f --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/recipes/README.md @@ -0,0 +1,15 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Recipes' +--- + +# Recipes + +- [Local MySQL](./local-mysql.md) +- [TLS](./tls.md) +- [Testing](./testing.md) + +Recipes focus on application wiring. + +They avoid generated Prisma client details unless those details are necessary to explain adapter +setup. diff --git a/packages/prisma-adapter-mysql/docs/recipes/local-mysql.md b/packages/prisma-adapter-mysql/docs/recipes/local-mysql.md new file mode 100644 index 000000000..6ac9376ba --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/recipes/local-mysql.md @@ -0,0 +1,27 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Local MySQL' +--- + +# Local MySQL + +Use local connection settings during development: + +```typescript +const adapter = new PrismaMySql({ + hostname: 'localhost', + port: 3306, + username: 'root', + password: 'password', + db: 'app', + poolSize: 5, +}); +``` + +Run the application with network permission: + +```powershell +deno run --allow-net app.ts +``` + +The package does not start or provision MySQL. diff --git a/packages/prisma-adapter-mysql/docs/recipes/testing.md b/packages/prisma-adapter-mysql/docs/recipes/testing.md new file mode 100644 index 000000000..e55ba3fa9 --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/recipes/testing.md @@ -0,0 +1,23 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Testing' +--- + +# Testing + +The package test suite covers pure behavior that does not require a live database: + +- capability detection +- MySQL field and value conversion +- driver error mapping + +Run focused package tests: + +```powershell +deno task test +``` + +Live MySQL behavior should be tested by application or runtime integration suites that can provision +a database. + +Those suites should verify connection lifecycle, Prisma queries, transactions, and shutdown cleanup. diff --git a/packages/prisma-adapter-mysql/docs/recipes/tls.md b/packages/prisma-adapter-mysql/docs/recipes/tls.md new file mode 100644 index 000000000..f0ac726b4 --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/recipes/tls.md @@ -0,0 +1,27 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'TLS' +--- + +# TLS + +Use TLS certificate material when connecting to managed MySQL services: + +```typescript +const adapter = new PrismaMySql({ + hostname: 'mysql.example.net', + username: 'app', + password: Deno.env.get('MYSQL_PASSWORD'), + db: 'app', + tls: { + mode: 'verify_identity', + caCerts: [await Deno.readTextFile('ca.pem')], + }, +}); +``` + +The application needs the permissions it uses to read secrets and files: + +```powershell +deno run --allow-net --allow-env=MYSQL_PASSWORD --allow-read=ca.pem app.ts +``` diff --git a/packages/prisma-adapter-mysql/docs/reference/README.md b/packages/prisma-adapter-mysql/docs/reference/README.md new file mode 100644 index 000000000..2b1b39dee --- /dev/null +++ b/packages/prisma-adapter-mysql/docs/reference/README.md @@ -0,0 +1,33 @@ +--- +package: '@netscript/prisma-adapter-mysql' +title: 'Reference' +--- + +# Reference + +## Values + +- `PrismaMySql` +- `PrismaMySqlAdapterFactory` +- `inferCapabilities` + +## Types + +- `MySqlConnectionConfig` +- `PrismaMySqlOptions` +- `MySqlCapabilities` +- `PrismaMySqlConnectedAdapter` +- `PrismaMySqlConnectionInfo` +- `PrismaMySqlIsolationLevel` +- `PrismaMySqlQuery` +- `PrismaMySqlResultSet` +- `PrismaMySqlTransactionAdapter` +- `DenoMySqlClient` +- `DenoMySqlConnection` +- `ExecuteResult` + +## Notes + +The root does not export conversion and error helper functions. + +Those functions are package internals used by the adapter implementation. diff --git a/packages/prisma-adapter-mysql/examples/basic-usage.ts b/packages/prisma-adapter-mysql/examples/basic-usage.ts index ea775427c..b39e1c072 100644 --- a/packages/prisma-adapter-mysql/examples/basic-usage.ts +++ b/packages/prisma-adapter-mysql/examples/basic-usage.ts @@ -18,36 +18,36 @@ * ``` */ -import { PrismaMySql } from "../src/mod.ts"; +import { PrismaMySql } from '../src/mod.ts'; // Example configuration const config = { - hostname: Deno.env.get("MYSQL_HOST") ?? "localhost", - port: parseInt(Deno.env.get("MYSQL_PORT") ?? "3306"), - username: Deno.env.get("MYSQL_USER") ?? "root", - password: Deno.env.get("MYSQL_PASSWORD") ?? "", - db: Deno.env.get("MYSQL_DATABASE") ?? "test", + hostname: Deno.env.get('MYSQL_HOST') ?? 'localhost', + port: parseInt(Deno.env.get('MYSQL_PORT') ?? '3306'), + username: Deno.env.get('MYSQL_USER') ?? 'root', + password: Deno.env.get('MYSQL_PASSWORD') ?? '', + db: Deno.env.get('MYSQL_DATABASE') ?? 'test', poolSize: 5, }; async function main() { - console.log("Creating MySQL adapter..."); + console.log('Creating MySQL adapter...'); // Create the adapter factory const adapter = new PrismaMySql(config, { database: config.db, onConnectionError: (err) => { - console.error("Connection error:", err.message); + console.error('Connection error:', err.message); }, }); - console.log("Connecting to database..."); + console.log('Connecting to database...'); // Connect and create the adapter instance const connectedAdapter = await adapter.connect(); - console.log("Connected successfully!"); - console.log("Connection info:", connectedAdapter.getConnectionInfo()); + console.log('Connected successfully!'); + console.log('Connection info:', connectedAdapter.getConnectionInfo()); // At this point, you would create a PrismaClient with the adapter: // @@ -61,27 +61,27 @@ async function main() { // await prisma.$disconnect(); // For this example, we'll just test the raw query capability - console.log("\nTesting raw query..."); + console.log('\nTesting raw query...'); try { const result = await connectedAdapter.queryRaw({ - sql: "SELECT 1 + 1 AS result, NOW() AS current_time", + sql: 'SELECT 1 + 1 AS result, NOW() AS current_time', args: [], argTypes: [], }); - console.log("Query result:"); - console.log(" Columns:", result.columnNames); - console.log(" Column Types:", result.columnTypes); - console.log(" Rows:", result.rows); + console.log('Query result:'); + console.log(' Columns:', result.columnNames); + console.log(' Column Types:', result.columnTypes); + console.log(' Rows:', result.rows); } catch (error) { - console.error("Query failed:", error); + console.error('Query failed:', error); } // Cleanup - console.log("\nDisposing adapter..."); + console.log('\nDisposing adapter...'); await connectedAdapter.dispose(); - console.log("Done!"); + console.log('Done!'); } // Run if executed directly diff --git a/packages/prisma-adapter-mysql/mod.ts b/packages/prisma-adapter-mysql/mod.ts new file mode 100644 index 000000000..08c1ed7f3 --- /dev/null +++ b/packages/prisma-adapter-mysql/mod.ts @@ -0,0 +1,7 @@ +/** + * Prisma MySQL driver adapter for Deno. + * + * @module + */ + +export * from './src/mod.ts'; diff --git a/packages/prisma-adapter-mysql/src/adapter.ts b/packages/prisma-adapter-mysql/src/adapter.ts index 00497ff1b..f7a695fe4 100644 --- a/packages/prisma-adapter-mysql/src/adapter.ts +++ b/packages/prisma-adapter-mysql/src/adapter.ts @@ -13,7 +13,6 @@ import type { ConnectionInfo, IsolationLevel, SqlDriverAdapter, - SqlDriverAdapterFactory, SqlQuery, SqlQueryable, SqlResultSet, @@ -330,7 +329,7 @@ class MySqlTransaction extends MySqlQueryable implements Transaction { * const prisma = new PrismaClient({ adapter }); * ``` */ -export class PrismaMySqlAdapter extends MySqlQueryable implements SqlDriverAdapter { +class PrismaMySqlAdapter extends MySqlQueryable implements SqlDriverAdapter { constructor( client: AnyClient, private readonly capabilities: MySqlCapabilities, @@ -454,18 +453,120 @@ export class PrismaMySqlAdapter extends MySqlQueryable implements SqlDriverAdapt * const prisma = new PrismaClient({ adapter }); * ``` */ -export class PrismaMySqlAdapterFactory implements SqlDriverAdapterFactory { - readonly provider = 'mysql' as const; - readonly adapterName = PACKAGE_NAME; +export interface PrismaMySqlQuery { + /** SQL statement to execute. */ + sql: string; + /** Positional query arguments. */ + args: unknown[]; + /** Prisma argument metadata for each argument. */ + argTypes: Array<{ + /** Prisma scalar type name. */ + scalarType: string; + /** Database-specific type name. */ + dbType: string; + /** Whether the argument is a scalar or list. */ + arity?: 'scalar' | 'list'; + }>; +} + +/** + * Result set returned by Prisma MySQL raw queries. + */ +export interface PrismaMySqlResultSet { + /** Column names in result order. */ + columnNames: string[]; + /** Prisma column type numbers in result order. */ + columnTypes: number[]; + /** Result rows in column order. */ + rows: unknown[][]; + /** Last inserted ID when reported by MySQL. */ + lastInsertId?: string; +} + +/** + * Transaction isolation levels accepted by the MySQL adapter. + */ +export type PrismaMySqlIsolationLevel = + | 'READ UNCOMMITTED' + | 'READ COMMITTED' + | 'REPEATABLE READ' + | 'SNAPSHOT' + | 'SERIALIZABLE'; + +/** + * Connection details reported to Prisma. + */ +export interface PrismaMySqlConnectionInfo { + /** Database schema name. */ + schemaName?: string; + /** Whether the server supports relation joins. */ + supportsRelationJoins: boolean; +} + +/** + * Connected transaction adapter returned by `startTransaction`. + */ +export interface PrismaMySqlTransactionAdapter { + /** Database provider identity. */ + readonly provider: 'mysql'; + /** Adapter package name. */ + readonly adapterName: string; + /** Execute a raw SQL query. */ + queryRaw(query: PrismaMySqlQuery): Promise; + /** Execute a raw SQL statement and return affected rows. */ + executeRaw(query: PrismaMySqlQuery): Promise; + /** Commit the transaction. */ + commit(): Promise; + /** Roll back the transaction. */ + rollback(): Promise; +} + +/** + * Connected MySQL adapter returned by {@linkcode PrismaMySqlAdapterFactory.connect}. + */ +export interface PrismaMySqlConnectedAdapter { + /** Database provider identity. */ + readonly provider: 'mysql'; + /** Adapter package name. */ + readonly adapterName: string; + /** Execute a raw SQL query. */ + queryRaw(query: PrismaMySqlQuery): Promise; + /** Execute a raw SQL statement and return affected rows. */ + executeRaw(query: PrismaMySqlQuery): Promise; + /** Execute a SQL script. */ + executeScript(script: string): Promise; + /** Return connection details used by Prisma. */ + getConnectionInfo(): PrismaMySqlConnectionInfo; + /** Start a transaction. */ + startTransaction( + isolationLevel?: PrismaMySqlIsolationLevel, + ): Promise; + /** Close the underlying driver resources. */ + dispose(): Promise; + /** Return the underlying driver object. */ + underlyingDriver(): unknown; +} + +/** + * Factory for creating Prisma MySQL adapter instances. + */ +export class PrismaMySqlAdapterFactory { + /** Database provider identity. */ + readonly provider: 'mysql' = 'mysql'; + /** Adapter package name. */ + readonly adapterName: string = PACKAGE_NAME; #capabilities?: MySqlCapabilities; #config: MySqlConnectionConfig; #options?: PrismaMySqlOptions; - constructor( - config: MySqlConnectionConfig, - options?: PrismaMySqlOptions, - ) { + /** + * Create a MySQL adapter factory. + * + * @param config - MySQL connection configuration. + * @param options - Adapter options passed to Prisma. + */ + constructor(config: MySqlConnectionConfig, options?: PrismaMySqlOptions) { this.#config = config; this.#options = { ...options, @@ -476,7 +577,7 @@ export class PrismaMySqlAdapterFactory implements SqlDriverAdapterFactory { /** * Connect to the database and create an adapter instance. */ - async connect(): Promise { + async connect(): Promise { const { createPool } = await import('mysql') as unknown as Mysql2Module; let client: AnyClient; @@ -501,14 +602,14 @@ export class PrismaMySqlAdapterFactory implements SqlDriverAdapterFactory { client, this.#capabilities, this.#options, - ); + ) as PrismaMySqlConnectedAdapter; } } function createMysql2Client(pool: Mysql2Pool): AnyClient { return { - async connect(): Promise { - return this; + connect(): Promise { + return Promise.resolve(this); }, async query(sql: string, values?: readonly unknown[]): Promise[]> { const [rows] = await pool.query(sql, values); @@ -616,7 +717,8 @@ export function inferCapabilities(version: unknown): MySqlCapabilities { // No relation-joins support for mysql < 8.0.13 or mariadb const isMariaDB = suffix?.toLowerCase()?.includes('mariadb') ?? false; const supportsRelationJoins = !isMariaDB && - (major > 8 || (major === 8 && minor >= 0 && patch >= 13)); + (major > 8 || + (major === 8 && (minor > 0 || (minor === 0 && patch >= 13)))); return { supportsRelationJoins }; } diff --git a/packages/prisma-adapter-mysql/src/conversion.ts b/packages/prisma-adapter-mysql/src/conversion.ts index c401f731f..722161e81 100644 --- a/packages/prisma-adapter-mysql/src/conversion.ts +++ b/packages/prisma-adapter-mysql/src/conversion.ts @@ -5,12 +5,8 @@ * deno_mysql driver which returns data in a different format. */ -import type { - ArgType, - ColumnType, - ResultValue, -} from "@prisma/driver-adapter-utils"; -import { ColumnTypeEnum } from "@prisma/driver-adapter-utils"; +import type { ArgType, ColumnType, ResultValue } from '@prisma/driver-adapter-utils'; +import { ColumnTypeEnum } from '@prisma/driver-adapter-utils'; /** * MySQL column type constants. @@ -155,8 +151,6 @@ export function mapColumnType(field: MySqlFieldInfo): ColumnType { return ColumnTypeEnum.Int32; default: - // Log warning for unknown types but don't crash - console.warn(`Unknown MySQL column type: ${fieldType}, defaulting to Text`); return ColumnTypeEnum.Text; } } @@ -173,29 +167,29 @@ export function mapArg( } // Handle BigInt conversion from string - if (typeof arg === "string" && argType.scalarType === "bigint") { + if (typeof arg === 'string' && argType.scalarType === 'bigint') { return BigInt(arg); } // Handle datetime string conversion - if (typeof arg === "string" && argType.scalarType === "datetime") { + if (typeof arg === 'string' && argType.scalarType === 'datetime') { arg = new Date(arg) as unknown as A; } // Handle Date formatting based on database type if (arg instanceof Date) { const dbType = argType.dbType as string; - if (dbType === "TIME" || dbType === "TIME2") { + if (dbType === 'TIME' || dbType === 'TIME2') { return formatTime(arg); } - if (dbType === "DATE" || dbType === "NEWDATE") { + if (dbType === 'DATE' || dbType === 'NEWDATE') { return formatDate(arg); } return formatDateTime(arg); } // Handle bytes conversion from base64 string - if (typeof arg === "string" && argType.scalarType === "bytes") { + if (typeof arg === 'string' && argType.scalarType === 'bytes') { return base64ToUint8Array(arg); } @@ -235,11 +229,11 @@ export function mapRow( case MySqlColumnType.DATETIME: case MySqlColumnType.DATETIME2: if (value instanceof Date) { - return value.toISOString().replace(/(\.000)?Z$/, "+00:00"); + return value.toISOString().replace(/(\.000)?Z$/, '+00:00'); } - if (typeof value === "string") { + if (typeof value === 'string') { // Parse and reformat the date string - return new Date(`${value}Z`).toISOString().replace(/(\.000)?Z$/, "+00:00"); + return new Date(`${value}Z`).toISOString().replace(/(\.000)?Z$/, '+00:00'); } break; @@ -259,7 +253,7 @@ export function mapRow( } // Handle BigInt - if (typeof value === "bigint") { + if (typeof value === 'bigint') { return value.toString(); } @@ -285,45 +279,45 @@ export function rowToArray( // Date formatting utilities function formatDateTime(date: Date): string { - const pad = (n: number, z = 2) => String(n).padStart(z, "0"); + const pad = (n: number, z = 2) => String(n).padStart(z, '0'); const ms = date.getUTCMilliseconds(); return ( pad(date.getUTCFullYear(), 4) + - "-" + + '-' + pad(date.getUTCMonth() + 1) + - "-" + + '-' + pad(date.getUTCDate()) + - " " + + ' ' + pad(date.getUTCHours()) + - ":" + + ':' + pad(date.getUTCMinutes()) + - ":" + + ':' + pad(date.getUTCSeconds()) + - (ms ? "." + String(ms).padStart(3, "0") : "") + (ms ? '.' + String(ms).padStart(3, '0') : '') ); } function formatDate(date: Date): string { - const pad = (n: number, z = 2) => String(n).padStart(z, "0"); + const pad = (n: number, z = 2) => String(n).padStart(z, '0'); return ( pad(date.getUTCFullYear(), 4) + - "-" + + '-' + pad(date.getUTCMonth() + 1) + - "-" + + '-' + pad(date.getUTCDate()) ); } function formatTime(date: Date): string { - const pad = (n: number, z = 2) => String(n).padStart(z, "0"); + const pad = (n: number, z = 2) => String(n).padStart(z, '0'); const ms = date.getUTCMilliseconds(); return ( pad(date.getUTCHours()) + - ":" + + ':' + pad(date.getUTCMinutes()) + - ":" + + ':' + pad(date.getUTCSeconds()) + - (ms ? "." + String(ms).padStart(3, "0") : "") + (ms ? '.' + String(ms).padStart(3, '0') : '') ); } @@ -339,7 +333,7 @@ function base64ToUint8Array(base64: string): Uint8Array { } function uint8ArrayToBase64(bytes: Uint8Array): string { - let binary = ""; + let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } diff --git a/packages/prisma-adapter-mysql/src/errors.ts b/packages/prisma-adapter-mysql/src/errors.ts index eaab17e53..66174a40e 100644 --- a/packages/prisma-adapter-mysql/src/errors.ts +++ b/packages/prisma-adapter-mysql/src/errors.ts @@ -4,10 +4,7 @@ * Maps deno_mysql errors to Prisma driver adapter error format. */ -import type { - Error as DriverAdapterErrorObject, - MappedError, -} from "@prisma/driver-adapter-utils"; +import type { Error as DriverAdapterErrorObject, MappedError } from '@prisma/driver-adapter-utils'; /** * MySQL error structure from deno_mysql driver. @@ -28,7 +25,7 @@ export function convertDriverError(error: unknown): DriverAdapterErrorObject { if (isDriverError(error)) { return { originalCode: (error.errno ?? 0).toString(), - originalMessage: error.sqlMessage ?? error.message ?? "N/A", + originalMessage: error.sqlMessage ?? error.message ?? 'N/A', ...mapDriverError(error), }; } @@ -46,11 +43,11 @@ export function mapDriverError(error: MySqlError): MappedError { switch (errno) { // Unique constraint violation case 1062: { - const index = error.sqlMessage?.split(" ").pop()?.split("'").at(1)?.split( - ".", + const index = error.sqlMessage?.split(' ').pop()?.split("'").at(1)?.split( + '.', ).pop(); return { - kind: "UniqueConstraintViolation", + kind: 'UniqueConstraintViolation', constraint: index !== undefined ? { index } : undefined, }; } @@ -58,18 +55,18 @@ export function mapDriverError(error: MySqlError): MappedError { // Foreign key constraint violations case 1451: case 1452: { - const field = error.sqlMessage?.split(" ").at(17)?.split("`").at(1); + const field = error.sqlMessage?.split(' ').at(17)?.split('`').at(1); return { - kind: "ForeignKeyConstraintViolation", + kind: 'ForeignKeyConstraintViolation', constraint: field !== undefined ? { fields: [field] } : undefined, }; } // Null constraint violation (from index) case 1263: { - const index = error.sqlMessage?.split(" ").pop()?.split("'").at(1); + const index = error.sqlMessage?.split(' ').pop()?.split("'").at(1); return { - kind: "NullConstraintViolation", + kind: 'NullConstraintViolation', constraint: index !== undefined ? { index } : undefined, }; } @@ -77,74 +74,74 @@ export function mapDriverError(error: MySqlError): MappedError { // Value out of range case 1264: return { - kind: "ValueOutOfRange", - cause: error.sqlMessage ?? "N/A", + kind: 'ValueOutOfRange', + cause: error.sqlMessage ?? 'N/A', }; // Null constraint violation (from field) case 1364: case 1048: { - const field = error.sqlMessage?.split(" ").at(1)?.split("'").at(1); + const field = error.sqlMessage?.split(' ').at(1)?.split("'").at(1); return { - kind: "NullConstraintViolation", + kind: 'NullConstraintViolation', constraint: field !== undefined ? { fields: [field] } : undefined, }; } // Database does not exist case 1049: { - const db = error.sqlMessage?.split(" ").pop()?.split("'").at(1); + const db = error.sqlMessage?.split(' ').pop()?.split("'").at(1); return { - kind: "DatabaseDoesNotExist", + kind: 'DatabaseDoesNotExist', db, }; } // Database already exists case 1007: { - const db = error.sqlMessage?.split(" ").at(3)?.split("'").at(1); + const db = error.sqlMessage?.split(' ').at(3)?.split("'").at(1); return { - kind: "DatabaseAlreadyExists", + kind: 'DatabaseAlreadyExists', db, }; } // Database access denied case 1044: { - const db = error.sqlMessage?.split(" ").pop()?.split("'").at(1); + const db = error.sqlMessage?.split(' ').pop()?.split("'").at(1); return { - kind: "DatabaseAccessDenied", + kind: 'DatabaseAccessDenied', db, }; } // Authentication failed case 1045: { - const user = error.sqlMessage?.split(" ").at(4)?.split("@").at(0)?.split( + const user = error.sqlMessage?.split(' ').at(4)?.split('@').at(0)?.split( "'", ).at(1); return { - kind: "AuthenticationFailed", + kind: 'AuthenticationFailed', user, }; } // Table does not exist case 1146: { - const table = error.sqlMessage?.split(" ").at(1)?.split("'").at(1)?.split( - ".", + const table = error.sqlMessage?.split(' ').at(1)?.split("'").at(1)?.split( + '.', ).pop(); return { - kind: "TableDoesNotExist", + kind: 'TableDoesNotExist', table, }; } // Column not found case 1054: { - const column = error.sqlMessage?.split(" ").at(2)?.split("'").at(1); + const column = error.sqlMessage?.split(' ').at(2)?.split("'").at(1); return { - kind: "ColumnNotFound", + kind: 'ColumnNotFound', column, }; } @@ -152,11 +149,11 @@ export function mapDriverError(error: MySqlError): MappedError { // Length mismatch (data too long) case 1406: { const column = error.sqlMessage - ?.split(" ") + ?.split(' ') .flatMap((part) => part.split("'")) .at(6); return { - kind: "LengthMismatch", + kind: 'LengthMismatch', column, }; } @@ -164,30 +161,30 @@ export function mapDriverError(error: MySqlError): MappedError { // Missing full-text search index case 1191: return { - kind: "MissingFullTextSearchIndex", + kind: 'MissingFullTextSearchIndex', }; // Transaction write conflict (deadlock) case 1213: return { - kind: "TransactionWriteConflict", + kind: 'TransactionWriteConflict', }; // Too many connections case 1040: case 1203: return { - kind: "TooManyConnections", - cause: error.sqlMessage ?? "N/A", + kind: 'TooManyConnections', + cause: error.sqlMessage ?? 'N/A', }; // Default: return MySQL-specific error default: return { - kind: "mysql", + kind: 'mysql', code: errno, - message: error.sqlMessage ?? error.message ?? "N/A", - state: error.sqlState ?? "N/A", + message: error.sqlMessage ?? error.message ?? 'N/A', + state: error.sqlState ?? 'N/A', cause: error.cause?.message ?? undefined, }; } @@ -197,7 +194,7 @@ export function mapDriverError(error: MySqlError): MappedError { * Type guard to check if an error is a MySQL driver error. */ function isDriverError(error: unknown): error is MySqlError { - if (typeof error !== "object" || error === null) { + if (typeof error !== 'object' || error === null) { return false; } @@ -205,9 +202,9 @@ function isDriverError(error: unknown): error is MySqlError { // deno_mysql errors have various structures, check for common properties return ( - typeof err.errno === "number" || - typeof err.sqlMessage === "string" || - typeof err.code === "string" || - (typeof err.message === "string" && err.message.includes("MySQL")) + typeof err.errno === 'number' || + typeof err.sqlMessage === 'string' || + typeof err.code === 'string' || + (typeof err.message === 'string' && err.message.includes('MySQL')) ); } diff --git a/packages/prisma-adapter-mysql/src/mod.ts b/packages/prisma-adapter-mysql/src/mod.ts index cd44f119b..ba16df82b 100644 --- a/packages/prisma-adapter-mysql/src/mod.ts +++ b/packages/prisma-adapter-mysql/src/mod.ts @@ -37,12 +37,16 @@ * @module */ -export { - inferCapabilities, - PrismaMySql, - PrismaMySqlAdapter, - PrismaMySqlAdapterFactory, -} from "./adapter.ts"; +export { inferCapabilities, PrismaMySql, PrismaMySqlAdapterFactory } from './adapter.ts'; + +export type { + PrismaMySqlConnectedAdapter, + PrismaMySqlConnectionInfo, + PrismaMySqlIsolationLevel, + PrismaMySqlQuery, + PrismaMySqlResultSet, + PrismaMySqlTransactionAdapter, +} from './adapter.ts'; export type { DenoMySqlClient, @@ -51,8 +55,4 @@ export type { MySqlCapabilities, MySqlConnectionConfig, PrismaMySqlOptions, -} from "./types.ts"; - -export { mapArg, mapColumnType, mapRow } from "./conversion.ts"; - -export { convertDriverError, mapDriverError } from "./errors.ts"; \ No newline at end of file +} from './types.ts'; diff --git a/packages/prisma-adapter-mysql/src/types.ts b/packages/prisma-adapter-mysql/src/types.ts index 9d009978a..4a2d30e8e 100644 --- a/packages/prisma-adapter-mysql/src/types.ts +++ b/packages/prisma-adapter-mysql/src/types.ts @@ -22,7 +22,9 @@ export interface MySqlConnectionConfig { timeout?: number; /** TLS configuration */ tls?: { - mode?: "disabled" | "verify_identity"; + /** TLS verification mode. */ + mode?: 'disabled' | 'verify_identity'; + /** CA certificates used by TLS verification. */ caCerts?: string[]; }; } @@ -49,7 +51,9 @@ export interface MySqlCapabilities { * Result from execute() call - for INSERT/UPDATE/DELETE */ export interface ExecuteResult { + /** Number of rows affected by the statement. */ affectedRows?: number; + /** Last inserted auto-increment ID, when MySQL reports one. */ lastInsertId?: number | bigint; } @@ -83,21 +87,27 @@ export interface FieldInfo { * deno_mysql Client interface */ export interface DenoMySqlClient { + /** Connect using the provided MySQL configuration. */ connect(config: MySqlConnectionConfig): Promise; + /** Execute a query and return row objects. */ query>( sql: string, params?: unknown[], ): Promise; + /** Execute a statement and return execution metadata. */ execute( sql: string, params?: unknown[], ): Promise; + /** Run a callback inside a driver-managed transaction. */ transaction( fn: (conn: DenoMySqlConnection) => Promise, ): Promise; + /** Run a callback with a checked-out pooled connection. */ useConnection( fn: (conn: DenoMySqlConnection) => Promise, ): Promise; + /** Close the client and release resources. */ close(): Promise; } @@ -105,10 +115,12 @@ export interface DenoMySqlClient { * Connection interface for transactions */ export interface DenoMySqlConnection { + /** Execute a query on this connection and return row objects. */ query>( sql: string, params?: unknown[], ): Promise; + /** Execute a statement on this connection and return execution metadata. */ execute( sql: string, params?: unknown[], diff --git a/packages/prisma-adapter-mysql/tests/capabilities_test.ts b/packages/prisma-adapter-mysql/tests/capabilities_test.ts new file mode 100644 index 000000000..459a4b2ab --- /dev/null +++ b/packages/prisma-adapter-mysql/tests/capabilities_test.ts @@ -0,0 +1,13 @@ +import { assertEquals } from 'jsr:@std/assert@1'; +import { inferCapabilities } from '../src/adapter.ts'; + +Deno.test('inferCapabilities enables relation joins for supported MySQL versions', () => { + assertEquals(inferCapabilities('8.0.13'), { supportsRelationJoins: true }); + assertEquals(inferCapabilities('8.4.0'), { supportsRelationJoins: true }); +}); + +Deno.test('inferCapabilities disables relation joins for old MySQL and MariaDB', () => { + assertEquals(inferCapabilities('5.7.44'), { supportsRelationJoins: false }); + assertEquals(inferCapabilities('10.11.8-MariaDB'), { supportsRelationJoins: false }); + assertEquals(inferCapabilities(undefined), { supportsRelationJoins: false }); +}); diff --git a/packages/prisma-adapter-mysql/tests/conversion_test.ts b/packages/prisma-adapter-mysql/tests/conversion_test.ts new file mode 100644 index 000000000..3e667dcb8 --- /dev/null +++ b/packages/prisma-adapter-mysql/tests/conversion_test.ts @@ -0,0 +1,79 @@ +import { assertEquals } from 'jsr:@std/assert@1'; +import { ColumnTypeEnum } from '@prisma/driver-adapter-utils'; +import { + mapArg, + mapColumnType, + mapRow, + MySqlColumnType, + type MySqlFieldInfo, +} from '../src/conversion.ts'; + +function field( + name: string, + fieldType: MySqlColumnType, + fieldFlag = 0, +): MySqlFieldInfo { + return { + catalog: '', + schema: '', + table: '', + originTable: '', + name, + originName: name, + encoding: 0, + fieldLen: 0, + fieldType, + fieldFlag, + decimals: 0, + defaultVal: '', + }; +} + +Deno.test('mapColumnType maps common MySQL types to Prisma column types', () => { + assertEquals(mapColumnType(field('id', MySqlColumnType.LONG)), ColumnTypeEnum.Int32); + assertEquals(mapColumnType(field('total', MySqlColumnType.NEWDECIMAL)), ColumnTypeEnum.Numeric); + assertEquals(mapColumnType(field('payload', MySqlColumnType.JSON)), ColumnTypeEnum.Json); + assertEquals( + mapColumnType(field('created_at', MySqlColumnType.DATETIME)), + ColumnTypeEnum.DateTime, + ); +}); + +Deno.test('mapArg converts Prisma transport values for MySQL parameters', () => { + assertEquals( + mapArg('42', { scalarType: 'bigint', dbType: 'BIGINT', arity: 'scalar' }), + 42n, + ); + assertEquals( + mapArg(new Date('2026-06-07T12:34:56.789Z'), { + scalarType: 'datetime', + dbType: 'DATETIME', + arity: 'scalar', + }), + '2026-06-07 12:34:56.789', + ); + assertEquals( + mapArg('AQID', { scalarType: 'bytes', dbType: 'BLOB', arity: 'scalar' }), + new Uint8Array([1, 2, 3]), + ); +}); + +Deno.test('mapRow converts rows to Prisma result arrays', () => { + const fields = [ + field('id', MySqlColumnType.LONGLONG), + field('created_at', MySqlColumnType.DATETIME), + field('blob', MySqlColumnType.BLOB), + ]; + + assertEquals( + mapRow( + { + id: 12n, + created_at: new Date('2026-06-07T12:34:56.000Z'), + blob: new Uint8Array([1, 2, 3]), + }, + fields, + ), + ['12', '2026-06-07T12:34:56+00:00', 'AQID'], + ); +}); diff --git a/packages/prisma-adapter-mysql/tests/errors_test.ts b/packages/prisma-adapter-mysql/tests/errors_test.ts new file mode 100644 index 000000000..b61d21efe --- /dev/null +++ b/packages/prisma-adapter-mysql/tests/errors_test.ts @@ -0,0 +1,33 @@ +import { assertEquals, assertThrows } from 'jsr:@std/assert@1'; +import { convertDriverError, mapDriverError } from '../src/errors.ts'; + +Deno.test('mapDriverError maps unique constraint violations', () => { + assertEquals( + mapDriverError({ + errno: 1062, + sqlMessage: "Duplicate entry 'demo' for key 'User.email'", + }), + { + kind: 'UniqueConstraintViolation', + constraint: { index: 'email' }, + }, + ); +}); + +Deno.test('mapDriverError maps authentication failures', () => { + assertEquals( + mapDriverError({ + errno: 1045, + sqlMessage: "Access denied for user 'app'@'localhost'", + }), + { + kind: 'AuthenticationFailed', + user: 'app', + }, + ); +}); + +Deno.test('convertDriverError preserves non-driver errors by throwing them', () => { + const error = new Error('plain failure'); + assertThrows(() => convertDriverError(error), Error, 'plain failure'); +});