Skip to content

Unify repository adapters behind a backend-agnostic core#38

Merged
isuttell merged 7 commits into
mainfrom
worktree-agent-aab8c64f64ee8da40
May 24, 2026
Merged

Unify repository adapters behind a backend-agnostic core#38
isuttell merged 7 commits into
mainfrom
worktree-agent-aab8c64f64ee8da40

Conversation

@isuttell

@isuttell isuttell commented May 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Lift the duplicated domain orchestration out of PostgresRepository (~948 lines) and LocalRepository (~839 lines) into one backend-agnostic RepositoryCore that implements Repository. Each backend now supplies only two ports: a scope-bound Entities accessor and a UnitOfWork runner (read/command/peekReplay). Postgres binds them to RLS-scoped Drizzle queries plus runCommand; local binds them to Maps plus a key-to-result idempotency cache. The two repositories become thin subclasses, so a behavior change is written once.
  • Replace the per-app ApiDatabase / UploadDatabase structural types with the single Repository interface exported from @agent-paste/db. apps/api and apps/upload import it and drop the Required<Pick<...>> casts, db as ... & ... intersections, defensive unknown re-parsing, and per-method database_unavailable 503 guards (the single if (!db) return 503 binding check stays). apps/api also aligns to the db actor types and drops the now-dead adminActor helper and getAdminWhoami branch.
  • Three deliberate reconciliations are the only behavior changes: one recordUploadedFile shape (Postgres superset; local ignores objectKey/sizeBytes), one core cursor type { createdAt: Date; id } with the wire format unchanged, and peekIdempotentReplay on the interface (apps/upload switches off the direct @agent-paste/commands peek + createHyperdriveExecutor fallback; apps/api gains no peek). Everything else is behavior-preserving.

packages/commands and packages/db/src/postgres/executor.ts are untouched. Decision recorded in ADR 0069; deferred follow-ups (faithful local in-flight idempotency, a possible core.ts split) tracked in docs/ops/repository-todo.md.

Test plan

  • pnpm verify green (62/62 Turbo tasks: lint, typecheck, test, openapi:check, db:check, web build).
  • pnpm --filter @agent-paste/db test (24 passed) after the local unit-of-work scope-key hardening.
  • Pre-commit (gitleaks, biome, typecheck, prettier-docs) and pre-push (test) hooks green.
  • CI is the merge gate.

Notes

  • CodeRabbit (coderabbit review --plain) ran on the first pass and surfaced three findings: the two minor items on local-unit-of-work.ts are the documented deferred in-flight idempotency follow-up (no change needed) and a scope-key alignment suggestion that I applied so the local idempotency key derivation provably matches Postgres; the third was a trivial ADR-prose nitpick. A re-run hit the org credit limit ("Your organization has run out of usage credits"), so the remaining diff after the hardening edit was self-reviewed (em-dash scan clean, core/ports/adapters re-read).

Summary by CodeRabbit

  • New Features

    • Unified backend repository used by API and upload services for more consistent behavior.
  • Improvements

    • Better idempotency/replay handling for API requests and upload sessions.
    • Clearer authentication actor handling for admin and API-key flows.
  • Documentation

    • Added architecture ADR and ops notes describing the repository unification and follow-ups.
  • Tests

    • Updated test fixtures to exercise idempotency replay paths.

Review Change Stack

isuttell and others added 3 commits May 23, 2026 17:41
PostgresRepository and LocalRepository re-implemented the same ~26
methods with identical domain orchestration; only the storage calls and
the transaction/RLS/idempotency wrapper differed. Lift all orchestration
into one RepositoryCore that implements the new Repository interface, and
express each backend as two ports: a scope-bound Entities accessor and a
UnitOfWork runner (read/command/peekReplay). Postgres binds these to
RLS-scoped Drizzle queries plus runCommand; local binds them to Maps plus
a key-to-result idempotency cache. The two repositories become thin
subclasses, so a behavior change is now written once.

Three deliberate reconciliations are the only behavior changes: one
recordUploadedFile shape (Postgres superset; local ignores objectKey and
sizeBytes), one core cursor type { createdAt: Date; id } with the wire
format unchanged, and peekIdempotentReplay on the interface. Everything
else is behavior-preserving. The local unit of work keys idempotency by
the run scope's workspace to stay provably aligned with the Postgres key
derivation. packages/commands and the Postgres executor are untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
apps/api and apps/upload each declared their own structural database
type (ApiDatabase, UploadDatabase) that was never the real exported
shape, forcing Required<Pick<...>> casts, db as ... intersections,
defensive re-parsing through unknown, and per-method database_unavailable
guards. Import the single Repository interface from @agent-paste/db
instead and drop all of that; the one if (!db) return 503 binding check
stays.

apps/upload now calls db.peekIdempotentReplay(...) and the direct
@agent-paste/commands peekIdempotentReplay plus createHyperdriveExecutor
fallback are removed from its replay path. apps/api gains no peek (it
never had one). apps/api also aligns to the @agent-paste/db actor types:
authenticateApiKey returns ApiKeyActor, authenticateAdmin returns
AdminActor, and the now-redundant adminActor helper and dead
getAdminWhoami branch are removed. Test DB fakes gain peekIdempotentReplay.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add ADR 0069 documenting the ports-and-adapters repository unification and
the three reconciliations, index it in docs/adr/README.md, and add an ADR
Coverage row plus a Recently Completed entry to project-status.md. Add
docs/ops/repository-todo.md tracking the deferred follow-ups: faithful
in-flight idempotency serialization in the local unit of work, the matching
peekReplay in-flight distinction, and a possible RepositoryCore split if it
keeps growing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d460aa30-21a7-454f-a289-e9e0a67dce4f

📥 Commits

Reviewing files that changed from the base of the PR and between 13c7197 and aaeb7ae.

📒 Files selected for processing (6)
  • docs/ops/repository-todo.md
  • packages/db/src/repository/core.ts
  • packages/db/src/repository/interface.ts
  • packages/db/src/repository/local-entities.ts
  • packages/db/src/repository/ports.ts
  • packages/db/src/repository/postgres-entities.ts

Walkthrough

This PR consolidates domain orchestration into a new backend-agnostic RepositoryCore and exposes a single Repository interface. It adds port/adaptor types (UnitOfWork, Entities, RunScope), implements RepositoryCore with idempotent command/read flows, and provides Postgres and local adapters (PostgresUnitOfWork/postgresEntities, LocalUnitOfWork/localEntities). Apps (api/upload) are refactored to use Repository and split actor types (ApiKeyActor vs AdminActor). Tests were updated to stub peekIdempotentReplay, and documentation (ADR 0070, project-status, repository-todo) records the architecture and follow-ups.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API as apps/api | apps/upload
  participant RepositoryCore
  participant UnitOfWork
  participant Entities
  Client->>API: HTTP request (web callback / upload / admin action)
  API->>RepositoryCore: invoke Repository method (e.g. resolveWebMember, createUploadSession, deleteArtifact)
  RepositoryCore->>UnitOfWork: command/read(spec, handler) (includes idempotency key)
  UnitOfWork->>Entities: persistence calls (select/insert/update/delete)
  Entities-->>UnitOfWork: persistence result
  UnitOfWork-->>RepositoryCore: cached or fresh result (peekReplay used for idempotency)
  RepositoryCore-->>API: domain response (publish result, web payload, cleanup result)
  API-->>Client: JSON response
Loading

Possibly related PRs

  • zaks-io/agent-paste#34: Touches the same web/dashboard flows and listWebArtifacts/resolveWebMember call signatures now aligned to the unified Repository.
  • zaks-io/agent-paste#32: Also modifies the WorkOS web callback/member resolution paths which this PR refactors to use Repository idempotent commands.
  • zaks-io/agent-paste#9: Related to upload mutation idempotency replay behavior that this change centralizes under Repository.peekIdempotentReplay.

Poem

🐇
A rabbit hops through code and key,
One core now threads both land and sea,
Local maps and Postgres hum in tune,
Idempotent replays beneath the moon,
Hooray — one interface, one bright rune.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely summarizes the main change: unifying repository adapters behind a backend-agnostic core, which is the central focus of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-agent-aab8c64f64ee8da40

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/index.ts`:
- Around line 526-535: The idempotency drift comes from calling
purgeArtifactBytes(env, artifactId) outside the runIdempotent block so retries
can change deleted_r2_objects; move the purge computation into the runIdempotent
closure (or have db.deleteArtifact return/persist deleted_r2_objects) so the
value is computed once and stored with the idempotent result. Concretely, update
the code around runIdempotent and db.deleteArtifact: either call
purgeArtifactBytes(env, artifactId) inside the async passed to runIdempotent and
include the purged count in the object returned by db.deleteArtifact (or merge
into its returned result), or extend db.deleteArtifact to persist
deleted_r2_objects and then return that persisted value so replayed responses
are identical; keep denyArtifact where it belongs but ensure deleted_r2_objects
is produced/stored within the idempotent operation (runIdempotent /
db.deleteArtifact).

In `@packages/db/src/repository/interface.ts`:
- Line 107: The interface methods getWhoami and peekIdempotentReplay currently
accept ApiActor but their implementations only support api_key actors and throw
for member actors; update their signatures to accept ApiKeyActor instead
(replace ApiActor with ApiKeyActor for getWhoami and peekIdempotentReplay) and
update any corresponding overloads/tests/usages to pass ApiKeyActor so callers
and implementations match, ensuring compile-time safety and avoiding runtime
throws in the functions named getWhoami and peekIdempotentReplay.

In `@packages/db/src/repository/postgres-entities.ts`:
- Around line 56-65: The rows returned by the Postgres queries (e.g., in
listExpiring) expose snake_case DB columns like workspace_id; map each row to
the camelCase shape the Entities port expects before returning (e.g., transform
{ id, workspace_id } => { id, workspaceId: workspace_id }) and update the
function return typing accordingly; apply the same mapping in the other similar
method around lines 85-94 so the core/local adapter receives { id, workspaceId }
instead of raw DB column names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 17bb285a-2ecb-491f-9157-f6b3a9c97c46

📥 Commits

Reviewing files that changed from the base of the PR and between 2695bf0 and e8bcade.

📒 Files selected for processing (23)
  • apps/api/src/index.ts
  • apps/api/src/local-mvp.test.ts
  • apps/upload/src/error-envelope.test.ts
  • apps/upload/src/index.test.ts
  • apps/upload/src/index.ts
  • docs/adr/0069-repository-core-ports-and-adapters.md
  • docs/adr/README.md
  • docs/ops/project-status.md
  • docs/ops/repository-todo.md
  • packages/db/src/index.test.ts
  • packages/db/src/index.ts
  • packages/db/src/local-repository.ts
  • packages/db/src/postgres/repository.ts
  • packages/db/src/repository/core.ts
  • packages/db/src/repository/interface.ts
  • packages/db/src/repository/local-entities.ts
  • packages/db/src/repository/local-state.ts
  • packages/db/src/repository/local-unit-of-work.ts
  • packages/db/src/repository/ports.ts
  • packages/db/src/repository/postgres-entities.ts
  • packages/db/src/repository/postgres-unit-of-work.ts
  • packages/db/src/repository/shared.ts
  • packages/db/src/repository/web-transforms.ts

Comment thread apps/api/src/index.ts
Comment on lines 526 to 535
return runIdempotent(context, async () => {
const result = await dbWithDeleteArtifact.deleteArtifact({
actor: adminActor(actor),
const result = await db.deleteArtifact({
actor: actor,
idempotencyKey,
artifactId,
});
await denyArtifact(env, artifactId);
const purged = await purgeArtifactBytes(env, artifactId);
if (result && typeof result === "object") {
return { ...(result as Record<string, unknown>), deleted_r2_objects: purged };
}
return result;
return { ...result, deleted_r2_objects: purged };
});

@coderabbitai coderabbitai Bot May 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Idempotent delete responses can drift across retries.

Line 527 runs an idempotent DB command, but Line 534 always overwrites deleted_r2_objects with the current purge count. A retry with the same idempotency key can therefore return a different payload than the first successful call.

Please move deleted_r2_objects computation into the idempotent command result (or persist/reuse the first computed purge count) so replayed responses stay stable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/index.ts` around lines 526 - 535, The idempotency drift comes
from calling purgeArtifactBytes(env, artifactId) outside the runIdempotent block
so retries can change deleted_r2_objects; move the purge computation into the
runIdempotent closure (or have db.deleteArtifact return/persist
deleted_r2_objects) so the value is computed once and stored with the idempotent
result. Concretely, update the code around runIdempotent and db.deleteArtifact:
either call purgeArtifactBytes(env, artifactId) inside the async passed to
runIdempotent and include the purged count in the object returned by
db.deleteArtifact (or merge into its returned result), or extend
db.deleteArtifact to persist deleted_r2_objects and then return that persisted
value so replayed responses are identical; keep denyArtifact where it belongs
but ensure deleted_r2_objects is produced/stored within the idempotent operation
(runIdempotent / db.deleteArtifact).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, but deferring out of this PR. The drift predates the unification: the purge-then-merge shape came from #24 (commit 22c4b36); this refactor only swapped dbWithDeleteArtifact for db. R2 purge accounting deliberately lives in the Worker (cleanup likewise reports deleted_r2_objects: 0 and defers object deletion to the jobs path), so making the count replay-stable is a behavior change to delete semantics, out of scope for a behavior-preserving refactor. Tracked in docs/ops/repository-todo.md with two resolution options. Leaving this thread open for visibility.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@isuttell, understood — the context makes sense. The drift predates this refactor, R2 purge accounting is intentionally a Worker-layer concern, and stabilizing the replay would constitute a behavior change rather than a structural cleanup. Happy to leave the thread open for visibility.

Would you like me to open a follow-up GitHub issue to track the two resolution options recorded in docs/ops/repository-todo.md? That way it stays visible outside of the PR thread.

Comment thread packages/db/src/repository/interface.ts Outdated
Comment thread packages/db/src/repository/postgres-entities.ts
isuttell and others added 3 commits May 23, 2026 17:51
Avoids a number collision with the Live Updates ADR 0069 added on main
via #37. The repository-core decision keeps the next free number.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…4f64ee8da40

# Conflicts:
#	docs/ops/project-status.md
@isuttell isuttell temporarily deployed to pr-preview-38 May 24, 2026 00:54 — with GitHub Actions Inactive
- Narrow getWhoami/peekIdempotentReplay to ApiKeyActor; both are api-key-only
  paths (the impl throws for member actors) and both callers authenticate via
  api key, so the wider ApiActor signature only invited compile-valid runtime
  failures.
- Drop the unused workspace_id field from Entities.listExpiring on the port and
  both adapters. runCleanup consumes only row.id, so the field was dead weight
  that leaked snake_case SQL naming through the backend-agnostic seam.
- Record the pre-existing deleted_r2_objects idempotency gap in repository-todo
  rather than fold a delete-semantics change into the behavior-preserving PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@isuttell isuttell temporarily deployed to pr-preview-38 May 24, 2026 01:09 — with GitHub Actions Inactive
@isuttell

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@isuttell isuttell merged commit 53fe779 into main May 24, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown

agent-paste PR preview resources were cleaned up. The pr-preview-${context.issue.number} environment is left in place; remove it from the GitHub UI if desired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant