You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CLAUDE.md
+12-7Lines changed: 12 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -105,18 +105,22 @@ sf-db/
105
105
- All database queries go through the pg pool — never create ad-hoc connections
106
106
107
107
### Postgres
108
-
- Synced Salesforce data → `salesforce` schema
109
108
- Internal app tables → `sfdb` schema
109
+
- Synced Salesforce data → one schema per registered org named `org_<lowercased orgid>` (e.g. `org_00d5g000001abcdeaa`)
110
+
- All `sfdb.*` per-object/per-field tables (`sync_config`, `field_config`, `field_metadata`, `sync_log`, `sync_lock`) are keyed by `(org_id, ...)` with `ON DELETE CASCADE` from `sfdb.orgs`
111
+
- The active UI/sync context is stored in `sfdb.active_org` (single row); the API resolves it from `X-Org-Id` request header first, falling back to that pointer
110
112
- Every synced table must have: `id`, `sf_created_at`, `sf_updated_at`, `sf_deleted_at`, `synced_at`
111
113
- Field names are lowercase snake_case versions of SF API names
112
-
- DDL is always idempotent (`IF NOT EXISTS` / `IF EXISTS`)
114
+
- DDL is always idempotent (`IF NOT EXISTS` / `IF EXISTS`); identifiers are always quoted (objects like `Order` / `User` collide with PG reserved words)
113
115
114
116
### Sync engine
115
-
- Always acquire `sfdb.sync_lock` before running any sync
116
-
- Always release the lock in a `finally` block — never leave it held on error
117
+
- Every sync entry point takes `orgId` as its primary key; alias is only used to look up an `~/.sfdx` token via `sfdb.orgs`
118
+
-`sfdb.sync_lock` is per-org (one row per registered org). Acquire before any sync; always release in a `finally` block
119
+
- Different orgs sync in parallel; one sync per org is serialized via that org's lock
117
120
- If `last_delta_sync` is NULL → initial full load (no SystemModstamp WHERE clause)
118
121
- Stale lock threshold: 30 minutes
119
122
- Log purge runs at the start of every sync (delete rows older than `LOG_RETENTION_DAYS`)
123
+
- The cron scheduler runs as one process with two ticks (delta per minute, full daily 02:00) that iterate every registered org
120
124
121
125
### API
122
126
- All routes under `/api/` prefix
@@ -151,9 +155,10 @@ All runtime config (active org alias, sync intervals, enabled objects/fields) li
151
155
152
156
## Key Design Decisions (do not revisit without good reason)
153
157
154
-
-**sf CLI binary is NOT in the Docker image.** Auth tokens are read directly from the `~/.sf/` JSON files mounted into the container. No `sf org display` command.
158
+
-**sf CLI binary is NOT in the Docker image.** Auth tokens are read directly from the `~/.sfdx/` JSON files mounted into the container. No `sf org display` command.
155
159
-**The API is not a data API.** It serves the UI and orchestrates syncs only. External tools connect directly to Postgres.
156
160
-**Deletions are soft.**`sf_deleted_at` is set — records are never hard-deleted from the local DB.
157
161
-**Bulk API 2.0 by default.** REST query fallback only for objects under 2,000 records.
158
-
-**Config in DB, not `.env`.**`.env` is infrastructure only. Org alias, object selection, field selection, and schedule config all live in `sfdb.app_config` / `sfdb.sync_config` / `sfdb.field_config`.
159
-
-**One active org at a time.** Multi-org simultaneous sync is out of scope for v1.
162
+
-**Config in DB, not `.env`.**`.env` is infrastructure only. Org registry, object selection, field selection, and schedule config all live in `sfdb.orgs` / `sfdb.sync_config` / `sfdb.field_config` / `sfdb.app_config`.
163
+
-**Multi-org by schema.** Every registered org gets its own `org_<orgid>` schema. Removing an org drops the schema and cascades through `sfdb.*` via the FKs on `sfdb.orgs(org_id)`.
164
+
-**Schema name is derived from the immutable Salesforce org id**, not the user-editable alias — aliases can be renamed without affecting where the data lives.
Copy file name to clipboardExpand all lines: README.md
+16-11Lines changed: 16 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -31,21 +31,26 @@ A self-hosted Salesforce-to-PostgreSQL sync pipeline. Run it with `docker compos
31
31
# 1. Authenticate a Salesforce org (skip if already done)
32
32
sf org login web --alias my-org
33
33
34
-
# 2. Configure environment
34
+
# 2. Export decrypted Salesforce tokens for Docker to use
35
+
npm run export-tokens
36
+
37
+
# 3. Configure environment
35
38
cp .env.example .env
36
39
# Edit .env — set POSTGRES_PASSWORD at minimum
37
40
38
-
#3. Start
41
+
#4. Start
39
42
docker compose up -d
40
43
41
-
#4. Open the UI
44
+
#5. Open the UI
42
45
open http://localhost:7743
43
46
```
44
47
45
48
First start takes ~30 seconds while Postgres initializes and the API container builds.
46
49
47
50
The onboarding screen will detect your authenticated orgs and ask you to pick one. After that, go to the Objects page and enable the Salesforce objects you want to sync.
48
51
52
+
`npm run export-tokens` writes plaintext access tokens to `data/tokens.json` so the Docker container can authenticate to Salesforce. This file is local-only secret material, is git-ignored, and should never be committed or shared.
53
+
49
54
## Connect a BI tool or SQL client
50
55
51
56
Once data is syncing, connect any Postgres-compatible tool directly:
@@ -54,12 +59,12 @@ Once data is syncing, connect any Postgres-compatible tool directly:
54
59
|----------|-----------------------|
55
60
| Host |`localhost`|
56
61
| Port |`7745`|
57
-
| Database |`sfdb`|
58
-
| Schema |`salesforce`|
59
-
| User |`sfdb`|
62
+
| Database |`sfdb`|
63
+
| Schema |`org_<orgid>`|
64
+
| User |`sfdb`|
60
65
| Password |*(your `.env` value)*|
61
66
62
-
The Settings page in the UI shows a copyable connection string.
67
+
Each registered Salesforce org gets its own schema named `org_<lowercased 18-char Salesforce org id>`. The Settings page in the UI shows the schema name for every registered org and a copyable connection string.
63
68
64
69
A read-only role is also available — set `READONLY_PASSWORD` in `.env` and connect as user `sfdb_readonly`.
65
70
@@ -103,11 +108,11 @@ Queries `SELECT Id FROM <Object>` for the full live ID set, diffs against local
103
108
104
109
### Concurrency
105
110
106
-
Only one sync runs at a time. A single-row lock table (`sfdb.sync_lock`) prevents overlap. Stale locks (> 30 min) are automatically reclaimed on startup.
111
+
Sync is serialized per org via `sfdb.sync_lock`, with one lock row per registered org. Different orgs can sync in parallel; overlapping syncs for the same org are blocked. Stale locks (> 30 min) are automatically reclaimed on startup.
107
112
108
113
## Database schema
109
114
110
-
**`salesforce` schema** — one table per enabled Salesforce object, e.g. `salesforce.account`
115
+
**One schema per registered org** — named `org_<lowercased orgid>`, one table per enabled Salesforce object (e.g. `org_00d5g000001abcdeaa.account`)
111
116
112
117
| Column | Type | Notes |
113
118
|---|---|---|
@@ -118,7 +123,7 @@ Only one sync runs at a time. A single-row lock table (`sfdb.sync_lock`) prevent
118
123
|`sf_deleted_at`|`timestamptz NULL`| NULL = live; set when deletion detected |
119
124
|`synced_at`|`timestamptz`| Last written by this tool |
| Salesforce auth |`~/.sfdx` files read directly via Node `fs` (no `sf` binary in container) |
135
+
| Salesforce auth |`~/.sfdx` files read directly via Node `fs` (no `sf` binary in container) — multiple orgs supported, each gets its own Postgres schema |
0 commit comments