Initial Commit
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Wire SurrealDB job-persistence store into `@suaveplan/api-plugin-export`
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-export`
|
||||
**Status**: PROPOSED
|
||||
**Type**: follow-up (MODIFIED-Requirements)
|
||||
**Spec delta**: ./specs/api-plugin-export/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/api-plugin-export` shipped at `741e1b176` with the upstream §2 "Current-state assessment" surface only. The proposal (`openspec/archive/2026-05-19-add-api-plugin-export/proposal.md`) explicitly carved out `§3 "Job persistence" via SurrealDB`:
|
||||
|
||||
> Upstream §2 ships the in-memory `Map<string, ExportJob>`. SurrealDB persistence is §3 work and requires `surql/` migrations + integration tests against `@suaveplan/api-plugin-database`. Deferred.
|
||||
|
||||
The dependencies that were missing at ship time — `@suaveplan/api-plugin-database`, `@suaveplan/surrealdb`, `@suaveplan/api-plugin-events` (canonical positional-SurQL pattern) — have now landed on genesis. This change wires the previously-excluded SurrealDB job-persistence store in.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `ExportJobStore` — formal async store contract (`get`, `set`, `delete`, `list`, `has`, `clear`) extracted from the implicit `Map<string, ExportJob>` surface so two backends can co-exist behind the same name.
|
||||
- ADDED: `MemoryExportJobStore` — `Map<string, ExportJob>` wrapper implementing `ExportJobStore`. Equivalent to the existing in-memory job store; keeps the in-memory path on the new typed contract.
|
||||
- ADDED: `SurrealExportStore` — SurrealDB-backed `ExportJobStore` implementation. CRUD over the `export_job` table using positional `$p0…$pN` placeholders (genesis canonical, matching `@suaveplan/api-plugin-events` repositories). Wraps every failure with `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)`.
|
||||
- ADDED: `EXPORT_CODES.JOB_PERSISTENCE_FAILED` — surfaces SurrealDB driver / network / parse failures from the `SurrealExportStore`. Cause chain preserves the underlying error.
|
||||
- ADDED: `surql/tables/export_job.surql` migration — `DEFINE TABLE OVERWRITE export_job SCHEMAFULL` + every field on `ExportJobSchema` + indexes on `(status)`, `(format)`, `(resource)`, `(created_at)`.
|
||||
- ADDED: `ServiceRegistry` slot `exportJobStore: ExportJobStore` via module augmentation. Independent of the pre-existing `exportJobs: Map<string, ExportJob>` slot, which remains for back-compat — callers wire whichever they want.
|
||||
- MODIFIED: `Public surface and side-effect imports` requirement — the package barrel now also re-exports `ExportJobStore`, `MemoryExportJobStore`, `SurrealExportStore`, and the `JOB_PERSISTENCE_FAILED` code; new subpath exports `./store/types`, `./store/memory/memory-store`, `./store/surreal/surreal-store`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Hard-bound to "wire SurrealDB persistence". Specifically excluded from this change (per the calling instructions):
|
||||
|
||||
- Re-plumbing `ExportRoutesDeps.jobs` from `Map<string, ExportJob>` to `ExportJobStore`. The routes, the format writers (CSV / JSON / XLSX), and the streaming pipeline are untouched. Callers wire the new store directly until a future route-refactor change lands.
|
||||
- `GET /:id/download` handler — separate excluded item from the original migration; remains deferred.
|
||||
- Streaming exporters, S3 sink, OOXML extras, auth / rate-limit / idempotency / audit middleware composition, signed URLs, Parquet, scheduled exports — all unchanged from the original carve-out.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Requirement | Source | Notes |
|
||||
|---|---|---|
|
||||
| `ExportJobStore` async contract | `enterprise/api-plugin-export.md §3` + `events.EventRepository` shape | Mirrors the `IEventRepository` async-CRUD convention. |
|
||||
| `SurrealExportStore` positional SurQL | `packages/api/api-plugin-events/src/repositories/event-repository/event-repository.ts` | Genesis-canonical `$p0`/`$p1` pattern. |
|
||||
| `export_job.surql` migration | Upstream `../suaveplan/packages/server/api-plugin-export/surql/` parity + `ExportJobSchema` shape | All `ExportJobSchema` fields persisted. |
|
||||
| `JOB_PERSISTENCE_FAILED` code | Genesis-wide `defineCodes` registry + `ExportError` hierarchy | New code; wired in `SurrealExportStore` only. |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`@suaveplan/api-plugin-export` ships at `0.1.0` with no genesis consumers yet:
|
||||
|
||||
- `bunx turbo ls --filter=...@suaveplan/api-plugin-export` returns the package itself only — `[x] no action needed (no downstream consumers on genesis at this date)`.
|
||||
|
||||
### Migration note
|
||||
|
||||
No consumer-visible behavior change. The pre-existing `Map`-based job slot (`exportJobs: Map<string, ExportJob>`) is preserved unchanged. The new `ExportJobStore` interface and the two implementations (`MemoryExportJobStore`, `SurrealExportStore`) are additive surface; no existing call site is renamed, removed, or rerouted.
|
||||
@@ -0,0 +1,138 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-export (wire-surreal-export-store)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-export`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Export job store contract (`ExportJobStore`)
|
||||
|
||||
The package SHALL expose an `ExportJobStore` interface defining the persistence contract for export jobs. The interface SHALL declare async methods `get(id)`, `set(id, job)`, `delete(id)`, `list(query?)`, `has(id)`, and `clear()`. `list(query?)` SHALL accept an `ExportJobStoreListQuery` carrying optional `status`, `format`, `resource` filters and required `limit`, `offset` pagination, returning `{ items: ExportJob[]; total: number }`. The interface SHALL be runtime-free (a type-only export) so consumers can implement it without pulling in any backend dependency.
|
||||
|
||||
#### Scenario: interface members
|
||||
|
||||
- **WHEN** `ExportJobStore` is consumed as a TypeScript type
|
||||
- **THEN** it declares the six async methods above with the documented signatures
|
||||
|
||||
**Verified by:** `src/store/memory/memory-store.test.ts::"MemoryExportJobStore satisfies the ExportJobStore contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: In-memory backend (`MemoryExportJobStore`)
|
||||
|
||||
`MemoryExportJobStore` SHALL implement `ExportJobStore` over an internal `Map<string, ExportJob>`. Every method SHALL resolve synchronously-derived results through a `Promise`. `list({ status, format, resource, limit, offset })` SHALL apply each provided filter as an AND-conjunction over the in-memory map values, then window with `offset`/`limit`, returning `total` as the pre-window match count.
|
||||
|
||||
#### Scenario: round-trip a job
|
||||
|
||||
- **WHEN** `set("a", job)` is called and then `get("a")`
|
||||
- **THEN** the resolved job deep-equals the input
|
||||
|
||||
#### Scenario: list filter conjunction
|
||||
|
||||
- **WHEN** three jobs differ on `status` / `format` and the query is `{ status: "completed", format: "csv", limit: 10, offset: 0 }`
|
||||
- **THEN** only the matching job appears in `items` and `total === 1`
|
||||
|
||||
#### Scenario: pagination
|
||||
|
||||
- **WHEN** five jobs are stored and the query is `{ limit: 2, offset: 2 }`
|
||||
- **THEN** `items.length === 2` and `total === 5`
|
||||
|
||||
#### Scenario: clear drains the store
|
||||
|
||||
- **WHEN** `clear()` is called after jobs are stored
|
||||
- **THEN** `list()` resolves with `{ items: [], total: 0 }`
|
||||
|
||||
**Verified by:** `src/store/memory/memory-store.test.ts::"set + get round-trips a job"` AND `src/store/memory/memory-store.test.ts::"list applies AND-conjunction filters"` AND `src/store/memory/memory-store.test.ts::"list paginates with offset and limit"` AND `src/store/memory/memory-store.test.ts::"clear empties the store"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SurrealDB backend (`SurrealExportStore`)
|
||||
|
||||
`SurrealExportStore` SHALL implement `ExportJobStore` over a SurrealDB `export_job` table reached through `@suaveplan/surrealdb` `SurrealAdapter`. All SurQL SHALL use positional `$p0…$pN` placeholders (genesis-canonical pattern; the adapter rewrites positional arrays into named bindings). The constructor SHALL accept a Zod-validated `SurrealExportStoreConfig` carrying `db: SurrealAdapter` and an optional `tableName: string` defaulting to `"export_job"`. Every method SHALL wrap underlying driver / network / parse failures in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` with the original error chained as `cause`. `set(id, job)` SHALL upsert the row (`UPDATE <tableName>:<id> CONTENT $p0 RETURN AFTER`). `delete(id)` SHALL be idempotent — deleting a missing row SHALL resolve without throwing. `list({ status, format, resource, limit, offset })` SHALL emit a `SELECT * FROM <tableName>` with an AND-conjunction `WHERE` clause and a sibling `SELECT count() FROM <tableName>` (also filtered) to derive `total` in a single round trip via `db.query(...)` returning two result sets.
|
||||
|
||||
#### Scenario: round-trip a job through SurrealDB
|
||||
|
||||
- **WHEN** `set("abc", job)` resolves and then `get("abc")` resolves
|
||||
- **THEN** the resolved job carries the same `id`, `format`, `resource`, `status`, `createdAt` as the input
|
||||
|
||||
#### Scenario: list with filters
|
||||
|
||||
- **WHEN** three rows are seeded (two `completed`, one `failed`) and `list({ status: "completed", limit: 10, offset: 0 })` is called
|
||||
- **THEN** `items.length === 2` and `total === 2`
|
||||
|
||||
#### Scenario: delete idempotency
|
||||
|
||||
- **WHEN** `delete("missing-id")` is called against an empty table
|
||||
- **THEN** the call resolves with no error
|
||||
|
||||
#### Scenario: driver failure surfaces JOB_PERSISTENCE_FAILED
|
||||
|
||||
- **WHEN** the underlying adapter `query(...)` rejects (e.g. connection drop, schema mismatch)
|
||||
- **THEN** the call rejects with `ExportError` whose `code === "EXPORT.JOB_PERSISTENCE_FAILED"` and whose `cause` is the underlying error
|
||||
|
||||
#### Scenario: invalid config rejects at construction time
|
||||
|
||||
- **WHEN** `new SurrealExportStore({})` is called without a `db` adapter
|
||||
- **THEN** `ExportError` is thrown with `code === "EXPORT.INVALID_CONFIG"` and the `ZodError` is attached as `cause`
|
||||
|
||||
**Verified by:** `tests/integration/surreal-store.test.ts::"set + get round-trips a job through SurrealDB"` AND `tests/integration/surreal-store.test.ts::"list applies status filter and pagination through SurrealDB"` AND `tests/integration/surreal-store.test.ts::"delete on a missing row is idempotent"` AND `src/store/surreal/surreal-store.test.ts::"driver failure surfaces JOB_PERSISTENCE_FAILED"` AND `src/store/surreal/surreal-store.test.ts::"invalid config rejects with INVALID_CONFIG"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `JOB_PERSISTENCE_FAILED` error code
|
||||
|
||||
`EXPORT_CODES` SHALL include the additional code `JOB_PERSISTENCE_FAILED`. The code SHALL be thrown from at least one production path — the `SurrealExportStore` driver-failure branch — and SHALL be pinned by `api-plugin-export.spec-conformance.test.ts`.
|
||||
|
||||
#### Scenario: registry membership
|
||||
|
||||
- **WHEN** `Object.values(EXPORT_CODES)` is enumerated
|
||||
- **THEN** the result contains `"EXPORT.JOB_PERSISTENCE_FAILED"`
|
||||
|
||||
#### Scenario: conformance pin
|
||||
|
||||
- **WHEN** the spec-conformance suite walks the registry against `src/**/*.ts` non-test files
|
||||
- **THEN** `JOB_PERSISTENCE_FAILED` is present in `src/store/surreal/surreal-store.ts`
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"JOB_PERSISTENCE_FAILED narrows via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SurrealDB migration (`export_job.surql`)
|
||||
|
||||
The package SHALL ship `surql/tables/export_job.surql` defining the `export_job` table as `SCHEMAFULL` with one `DEFINE FIELD OVERWRITE` per `ExportJobSchema` property (`format`, `resource`, `status`, `filters`, `filename`, `total_rows`, `exported_rows`, `file_size`, `error`, `created_at`, `completed_at`) and indexes on `(status)`, `(format)`, `(resource)`, `(created_at)`. The migration SHALL be idempotent (`DEFINE TABLE OVERWRITE`, `DEFINE FIELD OVERWRITE`, `DEFINE INDEX OVERWRITE`) so consecutive boots succeed.
|
||||
|
||||
#### Scenario: migration applies cleanly
|
||||
|
||||
- **WHEN** the migration is loaded twice against a fresh SurrealDB testcontainer
|
||||
- **THEN** both applies succeed without error
|
||||
|
||||
**Verified by:** `tests/integration/surreal-store.test.ts::"export_job migration is idempotent"`
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public surface and side-effect imports
|
||||
|
||||
The package root barrel SHALL import `./error-codes/error-codes.js` and `./services/registry-augmentation.js` for their side effects, and SHALL re-export every public symbol by name (no `export *` in any feature-module barrel). The barrel SHALL additionally re-export `ExportJobStore` (type), `MemoryExportJobStore`, `SurrealExportStore`, `SurrealExportStoreConfig` (type), and the `JOB_PERSISTENCE_FAILED` member of `EXPORT_CODES`. `package.json` SHALL declare `sideEffects: false` and ship subpath exports for `./plugin/plugin`, `./services/export/export-service`, `./routes/exports/exports`, `./exporters/csv/csv`, `./exporters/json/json`, `./exporters/xlsx/xlsx`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`, `./store/types/types`, `./store/memory/memory-store`, `./store/surreal/surreal-store`. The `files` array SHALL include `"surql"` so the migration ships to npm.
|
||||
|
||||
#### Scenario: side-effect imports present
|
||||
|
||||
- **WHEN** `src/index.ts` is read
|
||||
- **THEN** the file contains both `./error-codes/error-codes.js` and `./services/registry-augmentation.js` import statements
|
||||
|
||||
#### Scenario: no `export *` in feature barrels
|
||||
|
||||
- **WHEN** `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" src/` is run against the package
|
||||
- **THEN** no matches are found
|
||||
|
||||
#### Scenario: store subpath exports resolve
|
||||
|
||||
- **WHEN** a consumer imports from `@suaveplan/api-plugin-export/store/surreal/surreal-store`
|
||||
- **THEN** the import resolves to a module exporting `SurrealExportStore`
|
||||
|
||||
**Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"side-effect imports of error-codes and registry-augmentation are present in index.ts"` AND `src/api-plugin-export.spec-conformance.test.ts::"store subpath exports resolve from package.json"`
|
||||
@@ -0,0 +1,68 @@
|
||||
# Tasks: wire-surreal-export-store
|
||||
|
||||
Ordered checklist. Each task corresponds to a discrete `### Requirement` / `#### Scenario` in `specs/api-plugin-export/spec.md`.
|
||||
|
||||
## 1. Store contract
|
||||
|
||||
- [ ] 1.1 Author `src/store/types/types.ts` — `ExportJobStore` async interface (`get`, `set`, `delete`, `list`, `has`, `clear`) + `ExportJobStoreListQuery` (mirrors `ExportJobListQuery` without pagination defaults required by SurrealDB).
|
||||
- [ ] 1.2 Author sibling `src/store/types/types.md` (≥ 200 words).
|
||||
|
||||
## 2. In-memory backend
|
||||
|
||||
- [ ] 2.1 Author `src/store/memory/memory-store.ts` — `MemoryExportJobStore` class wrapping `Map<string, ExportJob>` with `ExportJobStore`-conformant async methods.
|
||||
- [ ] 2.2 Author `src/store/memory/memory-store.test.ts` — 100% coverage of every branch (get / set / delete / list filters + pagination / has / clear).
|
||||
- [ ] 2.3 Author sibling `src/store/memory/memory-store.md` (≥ 200 words).
|
||||
- [ ] 2.4 Author `src/store/memory/index.ts` barrel (explicit named re-exports — §6a).
|
||||
|
||||
## 3. SurrealDB backend
|
||||
|
||||
- [ ] 3.1 Author `src/store/surreal/surreal-store.ts` — `SurrealExportStore` class implementing `ExportJobStore`. Positional `$p0…$pN` SurQL. Every failure wrapped in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` with `cause` chain.
|
||||
- [ ] 3.2 Author `src/store/surreal/surreal-store.types.ts` — `SurrealExportStoreConfig` (Zod-validated, `db: SurrealAdapter`, `tableName` optional default `"export_job"`).
|
||||
- [ ] 3.3 Author sibling `src/store/surreal/surreal-store.md` (≥ 200 words).
|
||||
- [ ] 3.4 Author `src/store/surreal/index.ts` barrel.
|
||||
- [ ] 3.5 Author `src/store/index.ts` barrel.
|
||||
|
||||
## 4. Error code registry
|
||||
|
||||
- [ ] 4.1 Extend `src/error-codes/error-codes.ts` — add `JOB_PERSISTENCE_FAILED` to `EXPORT_CODES`.
|
||||
- [ ] 4.2 Update `src/error-codes/error-codes.md` to describe the new code.
|
||||
- [ ] 4.3 Extend `src/error-codes/error-codes.test.ts` with a narrowing test for the new code.
|
||||
|
||||
## 5. SurrealDB schema migration
|
||||
|
||||
- [ ] 5.1 Author `surql/tables/export_job.surql` — `DEFINE TABLE OVERWRITE export_job SCHEMAFULL` with one field per `ExportJobSchema` property plus indexes on `(status)`, `(format)`, `(resource)`, `(created_at)`.
|
||||
|
||||
## 6. Service-registry augmentation
|
||||
|
||||
- [ ] 6.1 Extend `src/services/registry-augmentation.ts` — add `exportJobStore: ExportJobStore` slot.
|
||||
|
||||
## 7. Barrel + subpath exports
|
||||
|
||||
- [ ] 7.1 Extend `src/index.ts` — re-export `ExportJobStore` (type), `MemoryExportJobStore`, `SurrealExportStore`, `SurrealExportStoreConfig` (type).
|
||||
- [ ] 7.2 Extend `package.json` `exports` map — add `./store/types/types`, `./store/memory/memory-store`, `./store/surreal/surreal-store`.
|
||||
- [ ] 7.3 Add `@suaveplan/surrealdb` to `dependencies` (workspace:^).
|
||||
- [ ] 7.4 Add `@suaveplan/testing-integration-surrealdb` to `devDependencies`.
|
||||
- [ ] 7.5 Update `files` array in `package.json` to include `"surql"`.
|
||||
|
||||
## 8. Integration test (real SurrealDB testcontainer — no mocks)
|
||||
|
||||
- [ ] 8.1 Author `tests/setup/polyfills.ts` mirroring the `api-plugin-events` setup (in-memory OTel `MeterProvider`).
|
||||
- [ ] 8.2 Author `tests/integration/surreal-store.test.ts` — boots `SurrealDBFixture`, applies the migration, exercises every `ExportJobStore` method round-trip.
|
||||
- [ ] 8.3 Extend `vitest.config.ts` — add `setupFiles: ["./tests/setup/polyfills.ts"]`, include `tests/**/*.test.ts`, register the new `surreal-store.ts` file in coverage excludes only if integration test is the verifier (it is — integration test counts towards coverage).
|
||||
|
||||
## 9. Spec-conformance pin
|
||||
|
||||
- [ ] 9.1 Extend `src/api-plugin-export.spec-conformance.test.ts` to assert `JOB_PERSISTENCE_FAILED` is thrown from a production source file.
|
||||
|
||||
## 10. Gates
|
||||
|
||||
- [ ] 10.1 `bunx biome check src/` — zero diagnostics.
|
||||
- [ ] 10.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-export` — green.
|
||||
- [ ] 10.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-export` — 100 / 100 / 100 / 100.
|
||||
- [ ] 10.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-export` — 0 errors.
|
||||
- [ ] 10.5 `bunx turbo build --filter=@suaveplan/api-plugin-export` — green.
|
||||
|
||||
## 11. Archive
|
||||
|
||||
- [ ] 11.1 Fold `specs/api-plugin-export/spec.md` into `openspec/specs/api-plugin-export/spec.md` (`## ADDED` block appended, `## MODIFIED` applied to existing requirement).
|
||||
- [ ] 11.2 `mv openspec/changes/wire-surreal-export-store openspec/archive/2026-05-19-wire-surreal-export-store`.
|
||||
Reference in New Issue
Block a user