@saasifier/database
v0.1.1
Published
The `DatabaseAdapter` interface SaaSify's core runtime is built against, plus an in-memory reference implementation and a shared contract test suite.
Readme
@saasifier/database
The DatabaseAdapter interface SaaSify's core runtime is built against, plus an in-memory reference implementation and a shared contract test suite.
Installation
npm install @saasifier/databaseOverview
SaaSify never talks to a database directly — @saasifier/core operates entirely against the DatabaseAdapter interface defined here, so any storage backend (Postgres, MongoDB, a custom ORM) can be plugged in by implementing it. This package ships that interface, createMemoryAdapter() (a zero-dependency, non-persistent implementation used in tests and playgrounds), and runDatabaseAdapterContractTests(), a reusable Vitest suite that every real adapter (e.g. @saasifier/database-postgres) is expected to pass so it can't silently drift from the interface's guarantees — in particular, tenant scoping (every method takes an explicit organizationId) and atomic usage-limit increments.
Usage
import { createMemoryAdapter, type DatabaseAdapter } from "@saasifier/database";
import { createSaaS } from "@saasifier/core";
const database: DatabaseAdapter = createMemoryAdapter();
const saas = createSaaS({
database,
config: {
tenancy: { strategy: "shared" },
database: { provider: "memory" }
}
});
const org = await database.organizations.create({ name: "Acme", slug: "acme" });
await database.memberships.create({ organizationId: org.id, userId: "user_1", role: "owner" });Verifying a custom adapter against the shared contract:
// my-adapter/src/my-adapter.contract.test.ts
import { runDatabaseAdapterContractTests } from "@saasifier/database/contract-tests";
import { createMyAdapter } from "./my-adapter.js";
runDatabaseAdapterContractTests("my-adapter", () => createMyAdapter(), {
teardown: (db) => db /* close pool / reset state */ && undefined
});API Reference
createMemoryAdapter(): DatabaseAdapter
Returns a fully in-memory DatabaseAdapter backed by Maps. Not tenant-isolated at the storage layer (everything lives in one process' memory) — it demonstrates the interface shape (every method scoped by organizationId), not physical separation. Intended for unit tests and local playgrounds, not production.
runDatabaseAdapterContractTests(name, createAdapter, options?): void
Registers a Vitest describe block (DatabaseAdapter contract: <name>) that exercises organization CRUD, cross-tenant membership isolation, atomic usage increments (including a concurrent-increment race test), audit log scoping, subscription upserts, webhook idempotency, and API key lifecycle/IDOR guards.
name: string— label used in the generateddescribeblock.createAdapter: () => Promise<DatabaseAdapter> | DatabaseAdapter— factory invoked before each test to get a fresh (or reset) adapter instance.options.teardown?: (db: DatabaseAdapter) => Promise<void> | void— called after each test, e.g. to close a connection pool or truncate tables.
The DatabaseAdapter interface
DatabaseAdapter is a bag of scoped repositories:
interface DatabaseAdapter {
organizations: OrganizationRepository;
memberships: MembershipRepository;
planAssignments: PlanAssignmentRepository;
usage: UsageRepository;
auditLogs: AuditLogRepository;
subscriptions: SubscriptionRepository;
webhookEvents: WebhookEventRepository;
apiKeys: ApiKeyRepository;
}OrganizationRepository—create,get,getBySlug,update,delete,list. There is intentionally no "list everything across tenants" beyondlist()scoping conventions used elsewhere — every other repository takes an explicitorganizationId.MembershipRepository—list(organizationId),find(organizationId, userId),create,remove,updateRole.PlanAssignmentRepository—get(organizationId)/set(organizationId, planKey). Tracks which plan an org is on; kept in sync by@saasifier/core's webhook handler and read byentitlements/usagerather than each inspectingsubscriptionsdirectly.UsageRepository—get(organizationId, metric)andincrementIfWithinLimit(organizationId, metric, amount, limit), which atomically increments only if the result would stay withinlimit, returning the new total ornullon refusal — real adapters implement this as a singleUPDATE ... WHERE used + n <= limitrather than a racy read-then-write.AuditLogRepository—create(entry)/list(organizationId).SubscriptionRepository—get(organizationId)/upsert(subscription), insert-or-update keyed onorganizationId.WebhookEventRepository—hasProcessed(eventId)/markProcessed(event), the idempotency ledger checked before, and updated after, webhook side effects.ApiKeyRepository—create,list(organizationId),revoke(organizationId, apiKeyId),touchLastUsed(apiKeyId), andfindByHash(hashedSecret)— the one method that intentionally omitsorganizationId, since authenticating an inbound request starts from only the raw key; it receives a hash, never the raw secret, and the matched organization comes back as part of theApiKeyrow.
Implement all eight repositories to plug a new storage backend into @saasifier/core, and run runDatabaseAdapterContractTests against it to confirm it honors the same guarantees as the memory adapter.
