Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
209c64b
Add backup jobs infrastructure for hourly project data exports
jirevwe Mar 30, 2026
846276e
Add models for AzureBlobStorage and BackupJob to datastore
jirevwe Mar 30, 2026
37b2f77
Add BlobStore abstraction and support for Azure Blob Storage integration
jirevwe Mar 30, 2026
93928d6
Add StreamExport for gzip-compressed JSONL backups to BlobStore
jirevwe Mar 30, 2026
b357755
Switch ExportRecords to JSONL format with snapshot consistency using …
jirevwe Mar 30, 2026
bea397c
Refactor tests to use helper functions for parsing JSONL and decompre…
jirevwe Mar 30, 2026
1c8c0d6
Switch ExportRecords to JSONL format with snapshot consistency using …
jirevwe Mar 30, 2026
1cfe7e0
Add Azure Blob Storage configuration support to queries and migrations
jirevwe Mar 30, 2026
7225cff
Add Azure Blob Storage configuration support to storage policy
jirevwe Mar 30, 2026
bb4378e
Add E2E tests for Azure Blob Storage backup functionality
jirevwe Mar 30, 2026
6b79fd5
Refactor storage policy handling to support Azure Blob Storage and si…
jirevwe Mar 30, 2026
4f1eb9c
Add unit tests for BlobStore with support for OnPrem, S3, and Azure B…
jirevwe Mar 30, 2026
4f0d221
Add support for Azurite (Azure Blob Storage emulator) for testing env…
jirevwe Mar 30, 2026
28bdc28
Handle "ContainerAlreadyExists" error when creating default Azurite c…
jirevwe Mar 31, 2026
e5edf07
Simplify `FailBackupJob` method signature by combining parameters `jo…
jirevwe Mar 31, 2026
db0bb0b
Add indexes for filtering and sorting `backup_jobs` by status and pro…
jirevwe Mar 31, 2026
c396aa3
Remove unnecessary blank line in `closeWithError` function
jirevwe Mar 31, 2026
5b89ead
Add configurable backup interval support with dynamic cron scheduling…
jirevwe Mar 31, 2026
6d2e37e
Refactor SQL queries and Go methods for `backup_jobs` to improve argu…
jirevwe Mar 31, 2026
45fe85a
Add stress test script for backups with support for S3, Azure Blob, a…
jirevwe Mar 31, 2026
e2bff73
Refactor SQL queries and Go methods for `backup_jobs` to improve argu…
jirevwe Mar 31, 2026
03e6ecb
Update Postgres version to 18 in docker-compose and configuration
jirevwe Mar 31, 2026
c8ef81f
Integrate cached repositories across services to improve query perfor…
jirevwe Apr 1, 2026
b698073
Add cached endpoint repository with comprehensive tests to improve ca…
jirevwe Apr 1, 2026
458b63d
Add cached filter repository with tests to enhance caching and minimi…
jirevwe Apr 1, 2026
3994a9b
Add cached project repository with tests to optimize caching and redu…
jirevwe Apr 1, 2026
2f73f3b
Add cached subscription repository with tests to optimize caching and…
jirevwe Apr 1, 2026
69071ea
Add cached API key repository with methods to enhance caching and red…
jirevwe Apr 1, 2026
3a492dc
Add cached organisation repository to enhance caching and reduce data…
jirevwe Apr 1, 2026
2012ef0
Add cached portal link repository to enhance caching and reduce datab…
jirevwe Apr 1, 2026
f2eaa0d
Update PgBouncer configuration to adjust pool mode, connection limits…
jirevwe Apr 1, 2026
99cff0a
Add backup collector for CDC-based WAL streaming and periodic blob st…
jirevwe Apr 1, 2026
70f90f4
Add CDC backup collector to worker for WAL streaming and periodic blo…
jirevwe Apr 1, 2026
a16b87e
Add publication for convoy tables to support CDC-based backup options
jirevwe Apr 1, 2026
1038d54
Add CDC and replication configuration options to Convoy
jirevwe Apr 1, 2026
dc72280
Add publication for convoy tables to support CDC-based backup options
jirevwe Apr 1, 2026
55274e6
Update Docker Compose: add ports and WAL configuration for Postgres; …
jirevwe Apr 1, 2026
3adad41
Add cached repositories for API keys and portal links to enhance cach…
jirevwe Apr 1, 2026
9d39d54
Remove project-scoped filtering from export functions and queries
jirevwe Apr 1, 2026
daad8f0
Update query struct: replace basic types with pgtype equivalents for …
jirevwe Apr 1, 2026
c30a8f3
Refactor repository functions to use grouped parameter declarations f…
jirevwe Apr 1, 2026
df1f45d
Add tests for backup collector, covering start/stop behavior, insert …
jirevwe Apr 2, 2026
6ec97f3
Add end-to-end tests for CDC-based backup collector on on-prem, S3, a…
jirevwe Apr 2, 2026
5d37020
Add unit tests for backup collector buffer and flush functionality
jirevwe Apr 2, 2026
feaef9b
Enable logical WAL in PostgreSQL test container for CDC backup tests
jirevwe Apr 2, 2026
ff8e6a9
Relax strict equality checks in backup export tests to allow for mult…
jirevwe Apr 2, 2026
701005b
Simplify time filtering logic in backup helper tests by replacing cut…
jirevwe Apr 2, 2026
6f35302
Add helper function to check UID existence in JSONL results in backup…
jirevwe Apr 2, 2026
4c9ebf1
Remove project-scoped filtering test from `ExportRecords` and add `Ba…
jirevwe Apr 2, 2026
f0c2db3
Add path traversal protection to `OnPremClient.Upload` to prevent ins…
jirevwe Apr 4, 2026
8878d6a
Make `OnPremClient.Upload` context-aware to handle cancellation durin…
jirevwe Apr 4, 2026
1a9c92c
Make `flushedLSN` thread-safe using `atomic.Uint64` and improve shutd…
jirevwe Apr 4, 2026
9560251
Add nil check for `storage` in `NewBlobStoreClient` to prevent nil po…
jirevwe Apr 4, 2026
d49f528
Handle `pgx.ErrNoRows` in `ClaimBackupJob` to return nil instead of a…
jirevwe Apr 4, 2026
1e066da
Add conditional creation of `convoy_backup` publication to handle exi…
jirevwe Apr 4, 2026
5e90583
Rename `BackupProjectData` task to `ExportTableData` across the codebase
jirevwe Apr 7, 2026
a5d3b57
Add `ensureContainer` to AzureBlobClient for thread-safe container cr…
jirevwe Apr 7, 2026
fbba096
Replace `ExportTableData` task with direct call to `Exporter.StreamEx…
jirevwe Apr 7, 2026
bf38375
Rename `lookback` to `lookBackDur` for consistency in variable naming
jirevwe Apr 7, 2026
974058a
Merge branch 'main' of https://github.com/frain-dev/convoy into raymo…
jirevwe Apr 7, 2026
7b0b2bb
Remove `project_id` field from `BackupJob` and drop related database …
jirevwe Apr 8, 2026
41c801d
Remove `project_id` references from backup job handling and update re…
jirevwe Apr 8, 2026
74d8145
Remove `project_id` references from backup job queries and related st…
jirevwe Apr 8, 2026
ad91823
Update blob key and file path formatting for backups to use date and …
jirevwe Apr 8, 2026
bbbc528
Remove `project_id` from backup job handling and streamline backup logic
jirevwe Apr 8, 2026
e01614b
Offset backup job scheduling to ensure enqueue runs before processing
jirevwe Apr 8, 2026
14da20b
Add new backup job utilities and remove `project_id` from backup logic
jirevwe Apr 8, 2026
8affecf
Remove `projectRepo` and `project` from `Exporter` to simplify struct…
jirevwe Apr 8, 2026
3af8459
Add support for offset in cron generation via `DurationToCronOffset`
jirevwe Apr 8, 2026
1fc402b
Remove `projectRepo` and simplify `Exporter` constructor; update back…
jirevwe Apr 8, 2026
e9c6ca1
Simplify error variable scoping in `config.Override` handling.
jirevwe Apr 8, 2026
0bb0de6
Update `Exporter` to use `expStart` and `expEnd` instead of `expDate`…
jirevwe Apr 9, 2026
391bed9
Refactor date filtering in `Exporter` queries to use start and end ti…
jirevwe Apr 9, 2026
681f512
Update `Exporter` and record-handling methods to support start and en…
jirevwe Apr 9, 2026
3d87b74
Update mock repository methods to support start and end timestamps in…
jirevwe Apr 9, 2026
c30f808
Update tests to use start and end timestamps in `ExportRecords`, repl…
jirevwe Apr 9, 2026
6cd5bd6
Add `cachedrepo` package for cache-aside repository utilities with un…
jirevwe Apr 9, 2026
a62a8c1
Refactor `CachedEndpointRepository` and tests to use `cachedrepo` uti…
jirevwe Apr 9, 2026
84b5971
Remove `CachedFilterRepository` and associated tests; migrate to `cac…
jirevwe Apr 9, 2026
5eda4f6
Remove `CachedProjectRepository` and associated tests; migrate to `ca…
jirevwe Apr 9, 2026
43c4ccf
Remove `CachedSubscriptionRepository` and associated tests; migrate t…
jirevwe Apr 9, 2026
1d234fd
Remove `CachedAPIKeyRepository` and associated implementation; migrat…
jirevwe Apr 9, 2026
e00bb76
Remove `CachedOrganisationRepository` and associated implementation; …
jirevwe Apr 9, 2026
7490c92
Remove `CachedPortalLinkRepository` and associated implementation; mi…
jirevwe Apr 9, 2026
9a3594d
Increase `PGBOUNCER_DEFAULT_POOL_SIZE` to 80 in local `.env` configur…
jirevwe Apr 9, 2026
a1709af
Clean up redundant comments in cached repository implementations.
jirevwe Apr 10, 2026
f2ee71f
Update tests to use explicit epoch start time in `ExportRecords` call…
jirevwe Apr 10, 2026
89ba623
Update tests to use current timestamps for event seeding; revise expo…
jirevwe Apr 10, 2026
c9ce84e
Add unique bucket and container creation for test isolation in backup…
jirevwe Apr 10, 2026
8b1ab12
Enable RetentionPolicy-based task registration and backup scheduling …
jirevwe Apr 10, 2026
4f49c73
Remove unused task registrations for `MonitorTwitterSources` and `Tok…
jirevwe Apr 10, 2026
bb26063
Add `NewExporterWithWindow` constructor for time-bounded manual/expor…
jirevwe Apr 10, 2026
ef74b41
Add `ManualBackup` task for on-demand, time-bounded backup operations
jirevwe Apr 10, 2026
238175e
Add `backup` command for on-demand event and delivery backup operations
jirevwe Apr 10, 2026
71068df
Add `ManualBackupJob` task type for on-demand backup operations
jirevwe Apr 10, 2026
7b4978e
Add `TriggerBackup` handler and API route for on-demand backup job cr…
jirevwe Apr 10, 2026
fa21ded
Ensure UTC timestamps in `Exporter` initialization; validate export w…
jirevwe Apr 13, 2026
ce01073
Refactor `EnqueueBackupJobIfIdle` to support time-bounded backup sche…
jirevwe Apr 13, 2026
d7edfae
Add admin guard and retention policy checks to `TriggerBackup` handler
jirevwe Apr 13, 2026
1573161
Add `notFoundErr` handling to `FetchWithNotFound` for improved cache …
jirevwe Apr 13, 2026
6f22c50
Invalidate cache entries in `DeleteFilter` using subscriptionID and e…
jirevwe Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,10 @@ func (a *ApplicationHandler) mountControlPlaneRoutes(router chi.Router, handler
configRouter.Get("/auth", handler.GetAuthConfiguration)
})

uiRouter.Route("/backups", func(backupRouter chi.Router) {
backupRouter.Post("/trigger", handler.TriggerBackup)
})

billingHandler := &handlers.BillingHandler{
Handler: handler,
BillingClient: a.A.BillingClient,
Expand Down
92 changes: 92 additions & 0 deletions api/handlers/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package handlers

import (
"encoding/json"
"net/http"
"time"

"github.com/go-chi/render"
"github.com/oklog/ulid/v2"

"github.com/frain-dev/convoy"
"github.com/frain-dev/convoy/config"
"github.com/frain-dev/convoy/internal/pkg/exporter"
"github.com/frain-dev/convoy/queue"
"github.com/frain-dev/convoy/util"
)

type triggerBackupRequest struct {
Start *time.Time `json:"start"`
End *time.Time `json:"end"`
}

type triggerBackupPayload struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}

// TriggerBackup enqueues an asynchronous manual backup job.
// POST /ui/backups/trigger
func (h *Handler) TriggerBackup(w http.ResponseWriter, r *http.Request) {
if !h.isInstanceAdmin(r) {
_ = render.Render(w, r, util.NewErrorResponse("Unauthorized: instance admin access required", http.StatusForbidden))
return
}

cfg, err := config.Get()
if err != nil {
_ = render.Render(w, r, util.NewErrorResponse("failed to load config", http.StatusInternalServerError))
return
}

if !cfg.RetentionPolicy.IsRetentionPolicyEnabled {
_ = render.Render(w, r, util.NewErrorResponse("backup is not enabled in configuration", http.StatusUnprocessableEntity))
return
}

// Default time window: last backup interval to now
end := time.Now()
start := end.Add(-exporter.ParseBackupInterval(cfg.RetentionPolicy.BackupInterval))

// Parse optional overrides from request body (empty body is fine)
var req triggerBackupRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
_ = render.Render(w, r, util.NewErrorResponse("invalid request body", http.StatusBadRequest))
return
}
if req.Start != nil {
start = *req.Start
}
if req.End != nil {
end = *req.End
}
}

if !start.Before(end) {
_ = render.Render(w, r, util.NewErrorResponse("start must be before end", http.StatusBadRequest))
return
}

payload, err := json.Marshal(triggerBackupPayload{Start: start, End: end})
if err != nil {
_ = render.Render(w, r, util.NewErrorResponse("failed to marshal payload", http.StatusInternalServerError))
return
}

job := &queue.Job{
ID: ulid.Make().String(),
Payload: payload,
}

if err := h.A.Queue.Write(convoy.ManualBackupJob, convoy.DefaultQueue, job); err != nil {
_ = render.Render(w, r, util.NewErrorResponse("failed to enqueue backup job", http.StatusInternalServerError))
return
}

_ = render.Render(w, r, util.NewServerResponse("backup job enqueued", map[string]interface{}{
"job_id": job.ID,
"start": start.Format(time.RFC3339),
"end": end.Format(time.RFC3339),
}, http.StatusAccepted))
}
28 changes: 5 additions & 23 deletions api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/frain-dev/convoy/auth"
"github.com/frain-dev/convoy/config"
"github.com/frain-dev/convoy/datastore"
"github.com/frain-dev/convoy/datastore/cached"
"github.com/frain-dev/convoy/internal/organisation_members"
"github.com/frain-dev/convoy/internal/organisations"
"github.com/frain-dev/convoy/internal/pkg/middleware"
Expand Down Expand Up @@ -56,7 +57,7 @@ func (h *Handler) retrieveProject(r *http.Request) (*datastore.Project, error) {
var project *datastore.Project
var err error

projectRepo := projects.New(h.A.Logger, h.A.DB)
projectRepo := cached.NewCachedProjectRepository(projects.New(h.A.Logger, h.A.DB), h.A.Cache, 5*time.Minute, h.A.Logger)

switch {
case h.IsReqWithJWT(authUser), h.IsReqWithPersonalAccessToken(authUser):
Expand All @@ -81,29 +82,10 @@ func (h *Handler) retrieveProject(r *http.Request) (*datastore.Project, error) {

projectID := apiKey.Role.Project

var p datastore.Project

cacheKey := convoy.ProjectCacheKey.Get(projectID)
cacheErr := h.A.Cache.Get(r.Context(), cacheKey.String(), &p)

// If cache hit with valid data, return it
if cacheErr == nil && p.UID != "" {
h.A.Logger.Info("found item in cache")
return &p, nil
}

// Cache miss - fetch from database
pp, fetchErr := projectRepo.FetchProjectByID(r.Context(), projectID)
if fetchErr != nil {
return nil, fetchErr
}

cacheErr = h.A.Cache.Set(r.Context(), cacheKey.String(), &pp, time.Hour)
if cacheErr != nil {
h.A.Logger.Error("failed to cache item", "error", cacheErr)
project, err = projectRepo.FetchProjectByID(r.Context(), projectID)
if err != nil {
return nil, err
}

return pp, nil
case h.IsReqWithPortalLinkToken(authUser):
if len(authUser.Credential.Token) > 0 { // this is the legacy static token type
svc := portal_links.New(h.A.Logger, h.A.DB)
Expand Down
4 changes: 3 additions & 1 deletion api/handlers/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import (
"context"
"errors"
"net/http"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/render"

"github.com/frain-dev/convoy"
"github.com/frain-dev/convoy/datastore"
"github.com/frain-dev/convoy/datastore/cached"
"github.com/frain-dev/convoy/internal/organisations"
"github.com/frain-dev/convoy/util"
)
Expand Down Expand Up @@ -61,7 +63,7 @@ func (h *Handler) RequireEnabledOrganisation() func(next http.Handler) http.Hand
if cachedOrg := r.Context().Value(convoy.OrganisationCtx); cachedOrg != nil {
org = cachedOrg.(*datastore.Organisation)
} else {
orgRepo := organisations.New(h.A.Logger, h.A.DB)
orgRepo := cached.NewCachedOrganisationRepository(organisations.New(h.A.Logger, h.A.DB), h.A.Cache, 5*time.Minute, h.A.Logger)
org, err = orgRepo.FetchOrganisationByID(r.Context(), project.OrganisationID)
if err != nil {
h.A.Logger.Error("Failed to fetch organisation for disabled check", "error", err)
Expand Down
34 changes: 30 additions & 4 deletions api/models/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,17 @@ type ConfigurationResponse struct {
}

type StoragePolicyConfiguration struct {
// Storage policy type e.g on_prem or s3
// Storage policy type e.g on_prem, s3, or azure_blob
Type datastore.StorageType `json:"type,omitempty" valid:"supported_storage~please provide a valid storage type,required"`

// S3 Bucket creds
S3 *S3Storage `json:"s3"`

// On_Prem directory
OnPrem *OnPremStorage `json:"on_prem"`

// Azure Blob Storage creds
AzureBlob *AzureBlobStorage `json:"azure_blob"`
}

func (sc *StoragePolicyConfiguration) Transform() *datastore.StoragePolicyConfiguration {
Expand All @@ -63,9 +66,10 @@ func (sc *StoragePolicyConfiguration) Transform() *datastore.StoragePolicyConfig
}

return &datastore.StoragePolicyConfiguration{
Type: sc.Type,
S3: sc.S3.transform(),
OnPrem: sc.OnPrem.transform(),
Type: sc.Type,
S3: sc.S3.transform(),
OnPrem: sc.OnPrem.transform(),
AzureBlob: sc.AzureBlob.transform(),
}
}

Expand Down Expand Up @@ -119,3 +123,25 @@ func (os *OnPremStorage) transform() *datastore.OnPremStorage {

return &datastore.OnPremStorage{Path: os.Path}
}

type AzureBlobStorage struct {
AccountName null.String `json:"account_name"`
AccountKey null.String `json:"account_key,omitempty"`
ContainerName null.String `json:"container_name"`
Endpoint null.String `json:"endpoint,omitempty"`
Prefix null.String `json:"prefix,omitempty"`
}

func (az *AzureBlobStorage) transform() *datastore.AzureBlobStorage {
if az == nil {
return nil
}

return &datastore.AzureBlobStorage{
AccountName: az.AccountName,
AccountKey: az.AccountKey,
ContainerName: az.ContainerName,
Endpoint: az.Endpoint,
Prefix: az.Prefix,
}
}
11 changes: 6 additions & 5 deletions cmd/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agent

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
Expand Down Expand Up @@ -48,8 +49,8 @@ func AddAgentCommand(a *cli.App) *cobra.Command {
return err
}

if err := config.Override(cliConfig); err != nil {
return err
if oErr := config.Override(cliConfig); oErr != nil {
return oErr
}

cfg, err := config.Get()
Expand All @@ -71,9 +72,9 @@ func AddAgentCommand(a *cli.App) *cobra.Command {
case <-quit:
cancel()
return nil
case err := <-runtimeErr:
if err != nil && err != context.Canceled {
return err
case eRrr := <-runtimeErr:
if eRrr != nil && !errors.Is(eRrr, context.Canceled) {
return eRrr
}
return nil
case <-ctx.Done():
Expand Down
108 changes: 108 additions & 0 deletions cmd/backup/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package backup

import (
"context"
"fmt"
"os"
"time"

"github.com/spf13/cobra"

"github.com/frain-dev/convoy/config"
"github.com/frain-dev/convoy/internal/configuration"
"github.com/frain-dev/convoy/internal/delivery_attempts"
"github.com/frain-dev/convoy/internal/event_deliveries"
"github.com/frain-dev/convoy/internal/events"
blobstore "github.com/frain-dev/convoy/internal/pkg/blob-store"
"github.com/frain-dev/convoy/internal/pkg/cli"
"github.com/frain-dev/convoy/internal/pkg/exporter"
)

func AddBackupCommand(a *cli.App) *cobra.Command {
var startFlag, endFlag string

cmd := &cobra.Command{
Use: "backup",
Short: "Run a one-time backup of events, deliveries, and delivery attempts",
Annotations: map[string]string{
"ShouldBootstrap": "false",
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()

cfg, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

// Default time window: last backup interval to now
end := time.Now()
start := end.Add(-exporter.ParseBackupInterval(cfg.RetentionPolicy.BackupInterval))

// Override with flags if provided
if endFlag != "" {
end, err = time.Parse(time.RFC3339, endFlag)
if err != nil {
return fmt.Errorf("invalid --end value (expected RFC3339): %w", err)
}
}
if startFlag != "" {
start, err = time.Parse(time.RFC3339, startFlag)
if err != nil {
return fmt.Errorf("invalid --start value (expected RFC3339): %w", err)
}
}

if !start.Before(end) {
return fmt.Errorf("--start (%s) must be before --end (%s)", start.Format(time.RFC3339), end.Format(time.RFC3339))
}

fmt.Fprintf(os.Stdout, "Backup window: [%s, %s)\n", start.Format(time.RFC3339), end.Format(time.RFC3339))

// Create repos
configRepo := configuration.New(a.Logger, a.DB)
eventRepo := events.New(a.Logger, a.DB)
eventDeliveryRepo := event_deliveries.New(a.Logger, a.DB)
attemptsRepo := delivery_attempts.New(a.Logger, a.DB)

// Load DB config for storage policy
dbConfig, err := configRepo.LoadConfiguration(ctx)
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}

// Create blob store
store, err := blobstore.NewBlobStoreClient(dbConfig.StoragePolicy, a.Logger)
if err != nil {
return fmt.Errorf("failed to create blob store: %w", err)
}

// Create exporter with explicit window
exp, err := exporter.NewExporterWithWindow(
eventRepo, eventDeliveryRepo, dbConfig, attemptsRepo,
start, end, a.Logger,
)
if err != nil {
return fmt.Errorf("failed to create exporter: %w", err)
}

// Run export
result, err := exp.StreamExport(ctx, store)
if err != nil {
return fmt.Errorf("backup failed: %w", err)
}

// Print results
for table, r := range result {
fmt.Fprintf(os.Stdout, "%s: %d records exported → %s\n", table, r.NumDocs, r.ExportFile)
}

fmt.Fprintln(os.Stdout, "Backup complete.")
return nil
},
}

cmd.Flags().StringVar(&startFlag, "start", "", "Export window start (RFC3339, e.g. 2026-04-01T00:00:00Z)")
cmd.Flags().StringVar(&endFlag, "end", "", "Export window end (RFC3339, e.g. 2026-04-02T00:00:00Z)")
return cmd
}
Loading
Loading