From c1346f7fc5e572bb2add1f5b0f711bbc1f903c42 Mon Sep 17 00:00:00 2001 From: midagedev Date: Fri, 22 May 2026 17:07:19 +0900 Subject: [PATCH 1/2] Support isolated billing workspaces Allow one running server to hold several fully isolated billing datasets so parallel test suites no longer have to restart the server or reset shared state between runs. Requests select a workspace with the X-Billtap-Workspace header or the workspace query parameter; unselected requests keep using the backward-compatible default workspace backed by the configured database_url. Named workspaces open their own SQLite database lazily under a workspaces/ directory and get an independent API handler, so customers, invoices, webhooks, idempotency keys, and test clocks are all isolated. GET /workspaces lists the known workspaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + README.md | 31 ++++ cmd/billtap/main.go | 9 +- internal/server/server.go | 95 +++++++--- internal/server/workspace.go | 291 ++++++++++++++++++++++++++++++ internal/server/workspace_test.go | 183 +++++++++++++++++++ 6 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 internal/server/workspace.go create mode 100644 internal/server/workspace_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d5cdc..2292dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Added multi-workspace support so one running server can hold several fully + isolated billing datasets. Requests select a workspace with the + `X-Billtap-Workspace` header or `workspace` query parameter, unselected + requests keep using the backward-compatible `default` workspace, named + workspaces open their own SQLite database lazily under `workspaces/`, and + `GET /workspaces` lists the known workspaces. - Added a manual invoice-backed one-time payment flow for local SaaS usage charges, including `POST /v1/invoices`, `POST /v1/invoiceitems`, `POST /v1/invoices/{id}/finalize`, metadata preservation, expanded diff --git a/README.md b/README.md index b684d6c..deea31e 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,37 @@ calls are prefix-aware: The published GHCR image is runtime-prefix safe. You do not need to rebuild the frontend for each mount path. +## Workspaces + +Billtap can hold several fully isolated billing datasets in one running server, +so parallel test suites do not have to restart Billtap or reset shared state +between runs. + +- Requests with no workspace selector use the `default` workspace, backed by + the configured `database_url`. Existing integrations keep working unchanged. +- Name a workspace to get an independent dataset (its own customers, invoices, + webhooks, idempotency keys, and test clocks). It is created on first use. +- Select a workspace with the `X-Billtap-Workspace` request header or the + `workspace` query parameter. The resolved name is echoed on the + `X-Billtap-Workspace` response header. + +```bash +# default workspace (backward compatible) +curl http://localhost:8080/v1/customers + +# isolated dataset for one test suite +curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers +curl 'http://localhost:8080/v1/customers?workspace=suite-a' + +# list known workspaces +curl http://localhost:8080/workspaces +``` + +Workspace names accept letters, digits, `.`, `-`, and `_`, must start with a +letter or digit, and are case-insensitive. Each named workspace is stored next +to the default database under a `workspaces/` directory (for example +`.billtap/workspaces/suite-a.db`). + ## Fixture And Assertion APIs Billtap includes local integration-test helpers: diff --git a/cmd/billtap/main.go b/cmd/billtap/main.go index eb017d2..0e79f6b 100644 --- a/cmd/billtap/main.go +++ b/cmd/billtap/main.go @@ -59,9 +59,16 @@ func main() { } }() + appServer := server.New(server.Options{Config: cfg, Store: store}) + defer func() { + if err := appServer.Close(); err != nil { + slog.Warn("close workspaces", "error", err) + } + }() + srv := &http.Server{ Addr: cfg.Addr, - Handler: server.New(server.Options{Config: cfg, Store: store}), + Handler: appServer, ReadHeaderTimeout: 5 * time.Second, } diff --git a/internal/server/server.go b/internal/server/server.go index 3a45ce2..ef28b61 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "errors" "net/http" "os" "path/filepath" @@ -21,22 +22,33 @@ type Options struct { } type Server struct { - cfg config.Config - store storage.Store - mux *http.ServeMux + cfg config.Config + store storage.Store + mux *http.ServeMux + workspaces *workspaceManager } -func New(opts Options) http.Handler { +func New(opts Options) *Server { s := &Server{ cfg: opts.Config, store: opts.Store, mux: http.NewServeMux(), } s.cfg.PublicBasePath = config.NormalizePublicBasePath(s.cfg.PublicBasePath) + s.workspaces = newWorkspaceManager(s.cfg, s.store, s.buildAPIHandler) s.routes() return s } +// Close releases workspace storage opened on demand. The default store passed +// via Options is owned by the caller and is not closed here. +func (s *Server) Close() error { + if s.workspaces == nil { + return nil + } + return s.workspaces.Close() +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { basePath := s.requestBasePath(r) if basePath == "" { @@ -58,28 +70,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (s *Server) routes() { - if repo, ok := s.store.(billing.Repository); ok { - var webhookService *webhooks.Service - if webhookRepo, ok := s.store.(webhooks.Repository); ok { - webhookService = webhooks.NewServiceWithOptions(webhookRepo, webhooks.ServiceOptions{ - StoreRawPayloads: s.cfg.RawPayloadStorage != config.RawPayloadMetadataOnly, - RetentionDays: s.cfg.RetentionDays, - SignatureHeaderName: s.cfg.WebhookSignatureHeader, - APIVersion: s.cfg.WebhookAPIVersion, - }) - } - var diagnosticsService *diagnostics.Service - if diagnosticsRepo, ok := s.store.(diagnostics.Repository); ok { - diagnosticsService = diagnostics.NewService(diagnosticsRepo) - } - handler := api.New(api.Options{ - Billing: billing.NewService(repo), - Webhooks: webhookService, - Diagnostics: diagnosticsService, - PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath), - }) - s.mux.Handle("/v1/", handler) - s.mux.Handle("/api/", handler) + if s.workspaces.apiEnabled { + apiHandler := s.workspaces.handler() + s.mux.Handle("/v1/", apiHandler) + s.mux.Handle("/api/", apiHandler) + s.mux.HandleFunc("/workspaces", s.handleWorkspaces) } s.mux.HandleFunc("/", s.handleRoot) s.mux.HandleFunc("/health", s.handleHealth) @@ -93,6 +88,54 @@ func (s *Server) routes() { s.mux.HandleFunc("/assets/", s.handleAssets) } +// buildAPIHandler assembles the Stripe-like API handler for a single +// workspace store. It is invoked once per workspace by the workspace manager. +func (s *Server) buildAPIHandler(store storage.Store) (http.Handler, error) { + repo, ok := store.(billing.Repository) + if !ok { + return nil, errors.New("storage backend does not implement the billing repository") + } + var webhookService *webhooks.Service + if webhookRepo, ok := store.(webhooks.Repository); ok { + webhookService = webhooks.NewServiceWithOptions(webhookRepo, webhooks.ServiceOptions{ + StoreRawPayloads: s.cfg.RawPayloadStorage != config.RawPayloadMetadataOnly, + RetentionDays: s.cfg.RetentionDays, + SignatureHeaderName: s.cfg.WebhookSignatureHeader, + APIVersion: s.cfg.WebhookAPIVersion, + }) + } + var diagnosticsService *diagnostics.Service + if diagnosticsRepo, ok := store.(diagnostics.Repository); ok { + diagnosticsService = diagnostics.NewService(diagnosticsRepo) + } + return api.New(api.Options{ + Billing: billing.NewService(repo), + Webhooks: webhookService, + Diagnostics: diagnosticsService, + PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath), + }), nil +} + +func (s *Server) handleWorkspaces(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + methodNotAllowed(w) + return + } + names := s.workspaces.list() + data := make([]map[string]any, 0, len(names)) + for _, name := range names { + data = append(data, map[string]any{ + "object": "workspace", + "name": name, + "is_default": name == DefaultWorkspace, + }) + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "object": "list", + "data": data, + }) +} + func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/internal/server/workspace.go b/internal/server/workspace.go new file mode 100644 index 0000000..3e40e6d --- /dev/null +++ b/internal/server/workspace.go @@ -0,0 +1,291 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + + "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/storage" +) + +const ( + // DefaultWorkspace is the implicit workspace used when a request does not + // name one. It is backed by the configured DatabaseURL so existing + // integrations keep working unchanged. + DefaultWorkspace = "default" + + // WorkspaceHeader carries the target workspace name on a request and is + // echoed back on the response so callers can confirm the resolved value. + WorkspaceHeader = "X-Billtap-Workspace" + + // WorkspaceQueryParam is an alternative to WorkspaceHeader for callers + // that cannot easily set headers. + WorkspaceQueryParam = "workspace" + + maxWorkspaceNameLength = 63 +) + +// workspaceNamePattern keeps names safe to use as SQLite filenames: an +// alphanumeric lead character followed by alphanumerics, dot, dash, or +// underscore. The leading-character rule rejects "" and dotted paths (".."). +var workspaceNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// apiHandlerBuilder constructs the Stripe-like API handler for one store. +type apiHandlerBuilder func(storage.Store) (http.Handler, error) + +// workspaceManager owns one isolated billing store (and API handler) per +// workspace name. The default workspace reuses the externally-owned store; +// named workspaces open their own SQLite database lazily on first use. +type workspaceManager struct { + cfg config.Config + build apiHandlerBuilder + + mu sync.Mutex + handlers map[string]http.Handler // name -> API handler + stores map[string]storage.Store // name -> store (lazily opened only) + + // apiEnabled is false when the default store cannot back the API (for + // example a non-billing store). It preserves the previous behaviour of + // not mounting /v1/ at all in that case. + apiEnabled bool +} + +func newWorkspaceManager(cfg config.Config, defaultStore storage.Store, build apiHandlerBuilder) *workspaceManager { + m := &workspaceManager{ + cfg: cfg, + build: build, + handlers: make(map[string]http.Handler), + stores: make(map[string]storage.Store), + } + if defaultStore == nil { + return m + } + handler, err := build(defaultStore) + if err != nil { + return m + } + m.handlers[DefaultWorkspace] = handler + m.apiEnabled = true + return m +} + +// handler returns the dispatcher mounted on /v1/ and /api/. It resolves the +// workspace for each request, lazily provisioning isolated storage as needed. +func (m *workspaceManager) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name, err := resolveWorkspace(r) + if err != nil { + writeWorkspaceError(w, r, http.StatusBadRequest, err.Error()) + return + } + apiHandler, err := m.get(r.Context(), name) + if err != nil { + writeWorkspaceError(w, r, http.StatusInternalServerError, + fmt.Sprintf("could not open workspace %q: %v", name, err)) + return + } + w.Header().Set(WorkspaceHeader, name) + // The workspace selector is consumed here; strip it so the strict + // API parameter validation downstream never sees it. + apiHandler.ServeHTTP(w, stripWorkspaceQuery(r)) + }) +} + +// stripWorkspaceQuery returns a request with the workspace query parameter +// removed, leaving the original untouched when it carries no such parameter. +func stripWorkspaceQuery(r *http.Request) *http.Request { + query := r.URL.Query() + if _, ok := query[WorkspaceQueryParam]; !ok { + return r + } + query.Del(WorkspaceQueryParam) + clone := r.Clone(r.Context()) + cloned := *r.URL + cloned.RawQuery = query.Encode() + cloned.RawPath = "" + clone.URL = &cloned + return clone +} + +// get returns the API handler for name, opening its store on first use. +func (m *workspaceManager) get(ctx context.Context, name string) (http.Handler, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if handler, ok := m.handlers[name]; ok { + return handler, nil + } + if !m.apiEnabled { + return nil, fmt.Errorf("billing API is not available for this storage backend") + } + + // Decouple the store lifetime from the triggering request: the store is + // reused for every later request, so a cancelled first request must not + // tear it down. + store, err := storage.OpenSQLite(context.WithoutCancel(ctx), workspaceDSN(m.cfg.DatabaseURL, name)) + if err != nil { + return nil, err + } + handler, err := m.build(store) + if err != nil { + _ = store.Close() + return nil, err + } + m.handlers[name] = handler + m.stores[name] = store + return handler, nil +} + +// list reports the known workspaces: the default, any opened this session, +// and any whose database file already exists on disk. +func (m *workspaceManager) list() []string { + set := map[string]bool{DefaultWorkspace: true} + + m.mu.Lock() + for name := range m.handlers { + set[name] = true + } + m.mu.Unlock() + + if dir := workspacesDir(m.cfg.DatabaseURL); dir != "" { + ext := workspaceDBExt(m.cfg.DatabaseURL) + if entries, err := os.ReadDir(dir); err == nil { + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ext) { + continue + } + set[strings.TrimSuffix(entry.Name(), ext)] = true + } + } + } + + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Close releases every lazily-opened workspace store. The default store is +// owned by the caller and is left untouched. +func (m *workspaceManager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + var firstErr error + for name, store := range m.stores { + if err := store.Close(); err != nil && firstErr == nil { + firstErr = err + } + delete(m.stores, name) + delete(m.handlers, name) + } + return firstErr +} + +// resolveWorkspace extracts and validates the workspace name from a request, +// falling back to DefaultWorkspace when none is supplied. +func resolveWorkspace(r *http.Request) (string, error) { + raw := strings.TrimSpace(r.Header.Get(WorkspaceHeader)) + if raw == "" { + raw = strings.TrimSpace(r.URL.Query().Get(WorkspaceQueryParam)) + } + if raw == "" { + return DefaultWorkspace, nil + } + + // Filenames on macOS/Windows are case-insensitive; normalise so "Foo" + // and "foo" cannot resolve to two handlers over one file. + name := strings.ToLower(raw) + if name == DefaultWorkspace { + return DefaultWorkspace, nil + } + if len(name) > maxWorkspaceNameLength { + return "", fmt.Errorf("workspace name must be at most %d characters", maxWorkspaceNameLength) + } + if !workspaceNamePattern.MatchString(name) { + return "", fmt.Errorf("workspace name %q is invalid: use letters, digits, '.', '-', '_' and a leading alphanumeric", raw) + } + return name, nil +} + +// workspaceDSN derives the SQLite DSN for a named workspace from the base +// (default) DSN. The default workspace returns the base DSN unchanged. +func workspaceDSN(baseDSN, name string) string { + if name == "" || name == DefaultWorkspace { + return baseDSN + } + if isMemoryDSN(baseDSN) { + // Each in-memory workspace needs a distinct shared-cache name so it + // stays isolated yet survives across pooled connections. + return fmt.Sprintf("file:billtap_ws_%s?mode=memory&cache=shared", name) + } + + path, query := splitDSN(baseDSN) + ext := filepath.Ext(path) + if ext == "" { + ext = ".db" + } + wsPath := filepath.Join(filepath.Dir(path), "workspaces", name+ext) + if query == "" { + return wsPath + } + return "file:" + wsPath + query +} + +// workspacesDir returns the directory that holds named workspace databases, +// or "" when the base DSN is in-memory. +func workspacesDir(baseDSN string) string { + if isMemoryDSN(baseDSN) { + return "" + } + path, _ := splitDSN(baseDSN) + return filepath.Join(filepath.Dir(path), "workspaces") +} + +func workspaceDBExt(baseDSN string) string { + path, _ := splitDSN(baseDSN) + if ext := filepath.Ext(path); ext != "" { + return ext + } + return ".db" +} + +// splitDSN separates a SQLite DSN into its filesystem path and trailing +// query/fragment, dropping any leading "file:" scheme. +func splitDSN(dsn string) (path string, query string) { + path = strings.TrimPrefix(dsn, "file:") + if idx := strings.IndexAny(path, "?#"); idx >= 0 { + return path[:idx], path[idx:] + } + return path, "" +} + +func isMemoryDSN(dsn string) bool { + return dsn == ":memory:" || + strings.HasPrefix(dsn, "file::memory:") || + strings.Contains(dsn, "mode=memory") +} + +func writeWorkspaceError(w http.ResponseWriter, r *http.Request, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if r.Method == http.MethodHead { + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "type": "invalid_request_error", + "message": message, + }, + }) +} diff --git a/internal/server/workspace_test.go b/internal/server/workspace_test.go new file mode 100644 index 0000000..9e13ac3 --- /dev/null +++ b/internal/server/workspace_test.go @@ -0,0 +1,183 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/storage" +) + +// newWorkspaceServer builds a SQLite-backed server whose configured +// DatabaseURL matches the default store, so named workspaces resolve to +// sibling files under /workspaces. +func newWorkspaceServer(t *testing.T) (*Server, string) { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "billtap.db") + store, err := storage.OpenSQLite(context.Background(), dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + cfg := config.Config{ + Addr: ":0", + DatabaseURL: dbPath, + StaticDir: "web/dist", + Environment: "test", + } + srv := New(Options{Config: cfg, Store: store}) + t.Cleanup(func() { + _ = srv.Close() + _ = store.Close() + }) + return srv, dir +} + +func countCustomers(t *testing.T, handler http.Handler, workspace string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + if workspace != "" { + req.Header.Set(WorkspaceHeader, workspace) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list customers (workspace=%q) status = %d body = %s", workspace, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode customer list: %v body=%s", err, rec.Body.String()) + } + return len(out.Data) +} + +func TestWorkspacesIsolateBillingData(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + // Two customers in the default workspace, one in a named workspace. + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "default-1@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "default-2@example.test"}) + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "alt@example.test"}, + map[string]string{WorkspaceHeader: "test-a"}) + + if got := countCustomers(t, srv, ""); got != 2 { + t.Fatalf("default workspace customer count = %d, want 2", got) + } + if got := countCustomers(t, srv, "test-a"); got != 1 { + t.Fatalf("test-a workspace customer count = %d, want 1", got) + } + if got := countCustomers(t, srv, "default"); got != 2 { + t.Fatalf("explicit default workspace customer count = %d, want 2", got) + } + if got := countCustomers(t, srv, "test-b"); got != 0 { + t.Fatalf("fresh workspace customer count = %d, want 0", got) + } +} + +func TestWorkspaceResolvedFromQueryParam(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers?workspace=via-query", map[string]string{"email": "q@example.test"}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/customers?workspace=via-query", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(WorkspaceHeader); got != "via-query" { + t.Fatalf("response %s = %q, want %q", WorkspaceHeader, got, "via-query") + } + if got := countCustomers(t, srv, ""); got != 0 { + t.Fatalf("default workspace should stay empty, got %d", got) + } +} + +func TestWorkspaceHeaderEchoedAndInvalidRejected(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + req.Header.Set(WorkspaceHeader, "Mixed-Case") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(WorkspaceHeader); got != "mixed-case" { + t.Fatalf("resolved workspace = %q, want lowercased %q", got, "mixed-case") + } + + for _, bad := range []string{"bad/name", "../escape", ".hidden", "with space"} { + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + req.Header.Set(WorkspaceHeader, bad) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("workspace %q status = %d, want 400", bad, rec.Code) + } + } +} + +func TestWorkspacesListingEndpoint(t *testing.T) { + srv, _ := newWorkspaceServer(t) + + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "x@example.test"}, + map[string]string{WorkspaceHeader: "scenario-1"}) + + req := httptest.NewRequest(http.MethodGet, "/workspaces", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var out struct { + Data []struct { + Name string `json:"name"` + IsDefault bool `json:"is_default"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode workspace list: %v body=%s", err, rec.Body.String()) + } + seen := make(map[string]bool) + for _, ws := range out.Data { + seen[ws.Name] = true + } + if !seen[DefaultWorkspace] || !seen["scenario-1"] { + t.Fatalf("workspace list = %#v, want default and scenario-1", out.Data) + } +} + +func TestWorkspaceDSN(t *testing.T) { + cases := []struct { + base string + name string + want string + }{ + {".billtap/billtap.db", "default", ".billtap/billtap.db"}, + {".billtap/billtap.db", "test-a", filepath.Join(".billtap", "workspaces", "test-a.db")}, + {"/data/billtap.db", "ci", filepath.Join("/data", "workspaces", "ci.db")}, + {":memory:", "iso", "file:billtap_ws_iso?mode=memory&cache=shared"}, + } + for _, tc := range cases { + if got := workspaceDSN(tc.base, tc.name); got != tc.want { + t.Fatalf("workspaceDSN(%q, %q) = %q, want %q", tc.base, tc.name, got, tc.want) + } + } +} From 107dbfb1d5ecd4e0019b4aa856a7decf31877fc5 Mon Sep 17 00:00:00 2001 From: midagedev Date: Fri, 22 May 2026 17:13:56 +0900 Subject: [PATCH 2/2] Document billing workspaces in architecture and specs Reflect the new isolated-workspace feature across the design docs and the product spec: testing isolation guidance, the storage architecture note, functional requirement FR-010, non-functional requirement NFR-008, the API contract Workspaces section, the data-model scoping note, and tasks T147. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ARCHITECTURE.md | 9 ++++++++- docs/TESTING.md | 5 +++++ specs/000-product/contracts/api.md | 20 ++++++++++++++++++++ specs/000-product/data-model.md | 6 ++++++ specs/000-product/spec.md | 7 +++++++ specs/000-product/tasks.md | 1 + 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5444510..a0773fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,7 +67,14 @@ Initial default: - SQLite for local app state - in-memory option for unit tests -Tables: +One running server can host several isolated billing datasets. The implicit +`default` workspace is backed by the configured `database_url`; each named +workspace (selected per request via the `X-Billtap-Workspace` header or +`workspace` query parameter) opens its own SQLite database lazily under a +sibling `workspaces/` directory and gets an independent API handler, so its +billing state, webhooks, idempotency keys, and test clocks stay isolated. + +Tables (per workspace): - customers - products diff --git a/docs/TESTING.md b/docs/TESTING.md index 6f5def1..342a393 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -81,6 +81,11 @@ Fixture ergonomics for integration tests: - assert expected objects through `POST /api/fixtures/assert` - keep fixture IDs stable for customer, product, and price setup - use fixture `runId`, `namespace`, `tenantId`, and `ref` metadata to isolate repeated local/CI runs +- for stronger isolation, run parallel suites against separate Billtap + workspaces instead of restarting the server between sets: send + `X-Billtap-Workspace: ` (or `?workspace=`) so each suite gets an + independent dataset, while unselected requests keep using the `default` + workspace; `GET /workspaces` lists what exists Integration diagnostics for failed app runs: diff --git a/specs/000-product/contracts/api.md b/specs/000-product/contracts/api.md index 81eaf82..dfc8331 100644 --- a/specs/000-product/contracts/api.md +++ b/specs/000-product/contracts/api.md @@ -16,6 +16,26 @@ Process health. Storage and worker readiness. +## Workspaces + +One running server can host several isolated billing datasets. Every `/v1` +and `/api` request resolves a workspace before dispatch: + +- A request with no selector uses the `default` workspace, backed by the + configured `database_url`. This keeps existing integrations unchanged. +- A request may select a named workspace with the `X-Billtap-Workspace` + request header or the `workspace` query parameter. Named workspaces are + created on first use, have their own storage, and are isolated from each + other and from `default`. +- The resolved workspace name is returned on the `X-Billtap-Workspace` + response header. An invalid workspace name returns `400`. + +### `GET /workspaces` + +Lists known workspaces (the default, any opened this session, and any whose +database file already exists). Returns a `list` envelope of `workspace` +objects with `name` and `is_default`. + ## Stripe-like API ### Customers diff --git a/specs/000-product/data-model.md b/specs/000-product/data-model.md index d4a17c6..5c7002d 100644 --- a/specs/000-product/data-model.md +++ b/specs/000-product/data-model.md @@ -1,5 +1,11 @@ # Data Model +All entities below are scoped to a single workspace. A server hosts the +implicit `default` workspace plus any named workspaces; each workspace has its +own isolated store, so the same entity id may exist independently in different +workspaces. Workspaces are an instance-level partition and are not themselves +persisted rows — see `contracts/api.md` for selection and listing. + ## Customer - id diff --git a/specs/000-product/spec.md b/specs/000-product/spec.md index fd8a651..fb3cf09 100644 --- a/specs/000-product/spec.md +++ b/specs/000-product/spec.md @@ -117,6 +117,11 @@ Acceptance criteria: - FR-007: Create, retrieve, confirm, fail, and list payment intents. - FR-008: Create, retrieve, update, delete, and list webhook endpoints. - FR-009: Create, retrieve, and list events. +- FR-010: Serve isolated billing workspaces from one running server. Requests + with no workspace selector use the backward-compatible `default` workspace; + a request may select a named workspace via the `X-Billtap-Workspace` header + or `workspace` query parameter to get an independent dataset, and the known + workspaces are listable. ### Hosted UI @@ -177,6 +182,8 @@ Acceptance criteria: - NFR-005: No real card data is stored. - NFR-006: Contract behavior is fixture-backed. - NFR-007: Profile-specific behavior is fixture-backed and does not require production payment credentials. +- NFR-008: Named workspaces are isolated at the storage boundary so parallel + test suites do not need a server restart or shared-state reset between runs. ## Non-Goals diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index 41c4838..12ad17c 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -269,6 +269,7 @@ Gate: - [x] T144 Capture the public simulation capacity backlog for regression-driven fixture and scenario expansion - [x] T145 Expand customer history, subscription pause/resume, and payment-method attach/detach simulation routes - [x] T146 Add browser-facing public base path and forwarded-prefix support +- [x] T147 Add isolated billing workspaces selectable per request so parallel test suites share one server Suggested agents: