Document Store Plan
Implementation plan for shared, versioned, Supabase-backed invoice and proposal persistence.
Document Store Plan
This is the implementation handoff for replacing browser-local documents with a shared Supabase document store. It is intentionally specific enough to resume from another development machine without reconstructing the architecture from chat history.
Outcome
The completed system should provide:
- One shared A Grade document workspace for Sebastien, Gina, and Gary.
- Automatic continuity between browsers, devices, and authenticated sessions.
- Database-owned invoice and proposal numbering.
- Safe concurrent editing through optimistic versions.
- Authenticated API access without a runtime service-role credential.
- Searchable document summaries without normalizing every document field.
- Recoverable browser caching without treating local storage as canonical.
Existing Architecture To Preserve
The plan follows the current client-dashboard and kof-repo Supabase architecture:
platform.clients
→ platform.projects
→ platform.project_memberships
→ platform.schema_modules
→ project-specific schema and module dataImportant established conventions:
platformremains private and is never exposed through PostgREST.- Project discovery is exposed through narrow functions in the
apischema. - Project data lives in a dedicated schema such as
koforbags_by_m. - Reads use the public Supabase key plus the signed-in user's JWT and remain protected by RLS.
- Writes use narrow
SECURITY DEFINERRPCs with explicit membership and role checks. - Runtime applications do not receive the service-role credential.
- Mutable records carry an optimistic
versionand reject stale writes. - Creation RPCs accept client-generated UUID request IDs so retries are idempotent.
- Shared TypeScript request and response contracts are consumed by both API and frontend apps.
Reference snapshots reviewed through Local Code Harness:
| Repository | Branch | Reviewed commit |
|---|---|---|
sebastien3k/client-dashboard | master | 8cc416507862fa3e45f1070314032f63657e9e00 |
sebastien3k/kof-repo | master | c197e4ba05fe14fa1176f6cef8d2fb0e44f6f709 |
sebastien3k/bags-by-m | main | ff16d26020581651a70faba7a5ca98f99fe2e9fd |
bags-by-m does not currently contain tracked database migrations for its product store; its newer
project discovery and inventory configuration appear in client-dashboard. The KOF migrations are
the stronger baseline for transactional document persistence.
Product Assumptions
This plan assumes:
- All three approved A Grade users work in the same project and see the same documents.
- All three may create and edit documents initially.
- Documents are edited as aggregates; line items are not shared catalog entities.
- JSON import/export is a backup/admin capability, not the normal workflow.
- Remote Supabase state becomes canonical before the first production deployment with real data.
If documents must instead remain private per user, the membership and RLS model must be changed before implementing the migration.
Platform Registration
Register the sales app in the existing control plane instead of introducing another organization or workspace abstraction.
platform.clients
slug: a-grade-contracting
name: A Grade Contracting
platform.projects
slug: a-grade-sales
name: A Grade Sales
schema_name: agrade
environment: production
platform.schema_modules
module_name: documents
module_version: 1Provision the three existing auth.users records through platform.project_memberships:
| User | Initial role |
|---|---|
| Sebastien | owner |
| Gina | client_admin |
| Gary | client_admin |
Database membership becomes the real authorization boundary. The browser email allowlist may remain as a fast UX rejection, but API and database authorization must not depend on it.
Proposed Database Shape
platform.project_document_settings
Module-specific project settings follow the existing project_inventory_settings convention.
| Column | Type | Purpose |
|---|---|---|
project_id | uuid primary key | References platform.projects |
invoice_prefix | text | Default AG-INV |
proposal_prefix | text | Default AG |
currency_code | text | Default USD, validated as three uppercase characters |
timezone | text | Default America/Chicago |
contract_version | integer | API/database document contract version |
created_at | timestamptz | Audit timestamp |
updated_at | timestamptz | Trigger-maintained timestamp |
Keep this table private and grant access only to service_role. Expose the authorized settings
through an api.get_my_document_project(project_id) discovery function.
agrade.document_sequences
Document numbers must be allocated in the database because multiple users can create documents at the same time.
| Column | Type |
|---|---|
project_id | uuid |
document_type | text constrained to invoice or proposal |
document_year | integer |
last_value | bigint |
Primary key:
(project_id, document_type, document_year)The create RPC atomically increments the appropriate row and returns a formatted number such as
AG-INV-2026-003 or AG-2026-002. The current client-side nextDocumentNumber() helper must stop
being authoritative.
agrade.documents
Use a relational envelope around a JSONB document aggregate.
| Column | Type | Notes |
|---|---|---|
id | uuid primary key | Client-generated UUIDv4 request/document ID |
project_id | uuid not null | References the A Grade project |
document_type | text not null | invoice or proposal |
document_number | text not null | Database-assigned, unique within the project |
status | text not null | draft, issued, accepted, paid, void, or archived |
document_date | date not null | Searchable/sortable envelope field |
customer_name | text not null | Extracted from billTo.company |
project_title | text | Dashboard/search field |
total_cents | bigint not null | Server-validated summary amount |
content | jsonb not null | Parties, lines, add-ons, terms, notes, schedule, and presentation settings |
content_schema_version | integer not null | Supports future payload migrations |
version | bigint not null | Optimistic concurrency version, starting at 1 |
created_by | uuid | References auth.users, on delete set null |
updated_by | uuid | References auth.users, on delete set null |
created_at | timestamptz | Creation timestamp |
updated_at | timestamptz | Trigger-maintained timestamp |
archived_at | timestamptz | Soft-delete timestamp |
archived_by | uuid | Actor who archived the document |
Recommended constraints and indexes:
- Unique
(project_id, document_number). - Check
version > 0andcontent_schema_version > 0. - Check
total_cents >= 0. - Check that
contentis a JSON object. - Index
(project_id, updated_at desc, id)for the dashboard. - Index
(project_id, document_type, document_date desc)for filtering. - Index
(project_id, lower(customer_name))or add a targeted text-search index when search ships. - Partial index for active documents where
archived_at is null.
Do not normalize line items, parties, add-ons, or acceptance fields into separate tables in the first version. A document is saved and printed as one aggregate, and those nested values are not reused by other records. The relational envelope provides the fields needed for listing, filtering, numbering, authorization, concurrency, and reporting.
RLS And Grants
Enable RLS on both agrade.documents and agrade.document_sequences.
Document reads should allow active members with one of:
owner
developer
client_admin
client_viewerWrites should allow:
owner
developer
client_adminImplementation rules:
- Use
platform.has_schema_role('agrade', allowed_roles)for the first project-specific policy. - Also validate the supplied
project_id, active client/project state, anddocumentsmodule inside every write RPC. - Grant authenticated users
SELECTunder RLS. - Revoke direct authenticated
INSERT,UPDATE, andDELETEafter the RPC paths are deployed. - Grant RPC execution only to the roles that need it; do not grant anonymous execution.
- Set
search_path = ''on every security-definer function and fully qualify referenced objects.
Database RPCs
agrade.create_document(...)
Responsibilities:
- Require
auth.uid(). - Validate active project membership and write role.
- Validate that the project has the
documentsmodule andschema_name = 'agrade'. - Accept a UUIDv4
request_idand use it as the document ID. - Serialize retries with an advisory transaction lock.
- Return the existing matching document when an identical request is retried.
- Allocate the next invoice/proposal number transactionally.
- Derive server-owned fields including actor IDs, status, timestamps, and initial version.
- Validate document type, date, customer, total, and JSON content limits.
agrade.update_document(...)
Responsibilities:
- Require
document_idandexpected_version. - Lock or conditionally update only the matching active document.
- Validate the document envelope and JSON content.
- Preserve immutable ID, type, and number unless a future explicit renumber operation is designed.
- Set
updated_by = auth.uid(). - Increment
versionand updateupdated_atthrough the shared platform trigger. - Raise SQLSTATE
40001when the expected version is stale so the API can return HTTP409.
agrade.archive_document(...)
Responsibilities:
- Require
expected_version. - Soft-delete by setting
status,archived_at, andarchived_by. - Remain idempotent for a repeated successful request.
- Reject stale versions rather than silently archiving a newer edit.
A later restore RPC can clear the archive fields. Avoid hard deletion for business documents.
Shared Contracts
Expand packages/shared so the API and sales client consume the same compile-time and runtime
contracts.
Recommended exports:
DocumentStatus
DocumentSummary
StoredDocument
CreateDocumentRequest
CreateDocumentResponse
UpdateDocumentRequest
UpdateDocumentResponse
DocumentListResponse
DocumentProjectAccess
DocumentConflictErrorAdd runtime schemas, preferably Zod, for external request bodies and persisted JSON content. The API validates before calling Supabase, while the RPC independently enforces authorization, concurrency, size limits, and critical invariants.
The persisted contract should distinguish:
- Database-owned envelope fields: ID, type, number, status, dates, actors, version, and summaries.
- Editable JSON content: parties, line items, add-ons, terms, notes, schedule, and presentation settings.
Before persistence ships, replace non-UUID document IDs with UUIDv4 values and decide whether the
editable price contract should move fully to integer cents. At minimum, the API must derive
total_cents deterministically and reject non-finite or negative amounts.
Bun API
Add an independently deployable Bun API workspace at apps/api, targeting
api.agradecontracting.com. Hono is a natural fit and matches the existing API style.
Suggested routes:
| Method | Route | Purpose |
|---|---|---|
GET | /v1/projects | List projects visible to the authenticated user |
GET | /v1/projects/:projectId/documents | Paginated document summaries |
POST | /v1/projects/:projectId/documents | Idempotently create a numbered draft |
GET | /v1/projects/:projectId/documents/:documentId | Load the complete document |
PUT | /v1/projects/:projectId/documents/:documentId | Versioned document save |
DELETE | /v1/projects/:projectId/documents/:documentId | Versioned archive operation |
Runtime request flow:
sales.agradecontracting.com
→ Authorization: Bearer <Supabase access token>
→ api.agradecontracting.com
→ Supabase PostgREST using the public key and the same user JWT
→ membership-aware RLS or a narrow write RPCThe API should:
- Validate the bearer token against Supabase Auth.
- Forward the real user JWT rather than replacing it with a service-role identity.
- Resolve the project through
api.get_my_document_project(). - Set
accept-profile/content-profileto the required custom schema. - Map PostgreSQL authorization, validation, conflict, and missing-row errors into stable HTTP errors.
- Apply request timeouts and narrow CORS to the local and production sales origins.
- Never receive or expose the Supabase service-role key during normal runtime.
Client Data Flow
Initial load
- Restore the Supabase Auth session.
- Fetch the user's accessible projects.
- Select the A Grade documents project.
- Fetch remote document summaries.
- Cache the successful response locally by project and authenticated user.
- Render remote state as canonical.
Creation
- Generate a UUIDv4 request ID.
- Call the create endpoint when a template is selected.
- Let the database reserve the official document number.
- Hydrate the selected template with the returned ID, number, version, and timestamps.
- Open the editor only after the remote draft exists, with a retry-safe pending state.
Saving
- Validate the editable document locally.
- Debounce edits and serialize saves so one browser cannot race itself.
- Send the last known
versionasexpectedVersion. - Replace local envelope metadata with the successful response.
- Update the recovery cache only after a successful remote save.
- Display clear states:
Saving,Saved,Offline—changes retained, orUpdated elsewhere.
Do not silently use last-write-wins. On HTTP 409, preserve the local unsaved content and ask the
user to reload or compare it with the newer remote document.
Supabase Realtime is not required for the first release. Version conflicts provide the necessary safety without adding subscription lifecycle complexity.
Legacy Browser Migration
The current browser store contains string IDs and seeded example records, so migration must be explicit and idempotent.
Recommended behavior:
- Fetch remote documents first.
- Detect the existing user-scoped local-storage collection.
- Convert every local record to the current shared schema and generate a UUIDv4 request ID.
- Submit through the normal create API rather than writing directly to Supabase.
- Treat an existing project/document-number conflict as already migrated and refresh remote state.
- Mark the local migration complete only after every record succeeds or is positively deduplicated.
- Retain a timestamped recovery copy locally until the user has verified the remote documents.
Avoid allowing all three users to upload identical seed documents. Prefer seeding the two shared demo documents once through the migration or a guarded administrative seed, then have every client load the same remote records.
Supabase Deployment Requirements
Expose the new application schema through PostgREST, but do not expose platform:
PGRST_DB_SCHEMAS=public,storage,graphql_public,api,kof,bags_by_m,agrade
PGRST_DB_EXTRA_SEARCH_PATH=public,extensionsPreserve any existing schema list rather than replacing it blindly. After changing the environment, recreate the relevant services:
docker compose up -d --force-recreate rest studioApply the canonical A Grade migration from this repository through the self-hosted Studio SQL editor or the host's PostgreSQL migration workflow. The application must never apply production migrations automatically at startup.
Implementation Sequence
Phase 1 — Contracts and migration
- Add document runtime schemas and API DTOs to
packages/shared. - Convert persisted document IDs to UUIDv4.
- Decide and document the integer-cents boundary.
- Add the canonical A Grade Supabase migration under
apps/api/supabase/migrations. - Register the A Grade client, project, module, settings, and three memberships.
- Add sequences, documents, triggers, RLS, grants, and RPCs.
- Add authenticated SQL verification queries for every role.
Phase 2 — API
- Scaffold the Bun/Hono
apps/apiworkspace. - Add Supabase bearer-token authentication.
- Add project discovery and document routes.
- Forward the user JWT to PostgREST.
- Validate requests using
packages/sharedschemas. - Map stale versions to HTTP
409. - Add unit tests for authorization, validation, idempotency, and conflict behavior.
Phase 3 — Sales client
- Add the authenticated API client.
- Replace local document initialization with remote project/document loading.
- Create numbered drafts through the API.
- Add serialized debounced saving with visible state.
- Implement conflict and offline-recovery behavior.
- Replace delete with archive semantics.
- Implement one-time browser-data migration.
- Move JSON import/export under an advanced backup surface.
Phase 4 — Deployment and verification
- Expose the
apiandagradePostgREST schemas without exposingplatform. - Apply the migration before deploying API code that calls its RPCs.
- Deploy
apps/apitoapi.agradecontracting.com. - Deploy
apps/salestosales.agradecontracting.com. - Verify CORS, Auth redirects, and password recovery on both localhost and production.
- Verify all three users see the same document collection.
- Verify a
client_viewercan read but cannot write. - Simulate two concurrent editors and confirm the stale save returns
409. - Create simultaneous invoices and confirm document numbers remain unique.
- Verify no runtime deployment contains a Supabase service-role credential.
Release Gate
The remote document store is ready for the first production sales deployment when:
- Memberships, RLS, and RPC grants pass authenticated database checks.
- Creating the same request twice returns one document.
- Concurrent creation cannot duplicate a document number.
- A stale save cannot overwrite a newer version.
- Reloading or signing in from another browser shows the latest saved documents.
- Local migration is idempotent and retains a recovery copy.
- The API and sales production builds and repository-wide typecheck pass.
- Browser-facing environments contain only the public Supabase key.
Deliberately Deferred
These capabilities should not block the first remote-persistence release:
- Supabase Realtime subscriptions.
- Fine-grained field-level collaborative editing.
- Normalized line-item tables.
- Customer/contact master records.
- Immutable issued-document revision history or generated PDF storage.
- Payment tracking beyond a basic lifecycle status.
- Hard deletion of business documents.
Immutable issued snapshots are a strong follow-up once the issue/send workflow is designed, but autosaving every keystroke into a revision table would add cost and noise without improving the first release.