Files
suaveplan/openspec/archive/2026-05-19-wire-surreal-export-store/specs/api-plugin-export/spec.md
2026-05-27 23:29:53 +01:00

8.9 KiB

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"