Skip to content

Commit 8ecceaf

Browse files
SSheppDevSeth Sheppardclaude
authored
feat: auto-indexing, custom indexes, CA cert injection, and auth token fix (#4)
* fix: restore execute bit on 004_readonly_user.sh init script macOS Git strips execute permissions on checkout, causing Postgres container init to fail with "bad interpreter: Permission denied" on first boot. Records the bit in the index so clones on Mac work correctly. Bumps patch version to 1.1.1. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: inject US Signal corporate CA cert into Docker image The corporate SSL inspection proxy re-signs outbound HTTPS traffic with the internal US Signal CA chain, causing Node.js inside the container to reject Salesforce API calls with UNABLE_TO_GET_ISSUER_CERT. Bakes the full three-cert chain (edge trust → issue CA → root CA) into the image via update-ca-certificates and points NODE_EXTRA_CA_CERTS at it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(db): auto-index reference fields and add operator-defined custom indexes Two indexing features: 1. Any field with sfType 'reference' is automatically indexed at table creation and when a column is added. Index names are derived from object+field names and hashed if they exceed Postgres's 63-char identifier limit. 2. sfdb.custom_indexes table stores operator-defined indexes (composite, partial, or any arbitrary expression) that are applied at startup and whenever a table is created or recreated. CRUD exposed at /api/indexes. All index creation uses CREATE INDEX CONCURRENTLY IF NOT EXISTS — no table locks, idempotent, safe to run against a live database. Co-Authored-By: Claude <noreply@anthropic.com> * docs: document reference-field auto-indexing and custom indexes feature - README: add Indexing section with curl examples for the /api/indexes API - docs/scope.md: update DDL table, add sfdb.custom_indexes to schema reference, add Indexing section with API reference and behavior description - CLAUDE.md: add both indexing behaviors to Key Design Decisions Co-Authored-By: Claude <noreply@anthropic.com> * feat(ui): add custom indexes card to Settings page Read-only table showing all registered custom indexes for the active org with a per-row delete button. Fetches on mount and on org switch. Also fixes GET /api/indexes to return camelCase keys consistent with other API routes. Co-Authored-By: Claude <noreply@anthropic.com> * chore(build): sync package-lock.json version to 1.1.1 Co-Authored-By: Claude <noreply@anthropic.com> * fix(auth): add --verbose flag to sf org display to get real access tokens sf org display --json redacts the accessToken unless --verbose is passed. Without it, tokens.json was storing the literal "[REDACTED]..." string, causing Bulk API 401 INVALID_AUTH_HEADER errors. Co-Authored-By: Claude <noreply@anthropic.com> * fix(auth): use sf org auth show-access-token to get real access tokens sf org display --json now permanently redacts accessToken regardless of flags, pointing to the new dedicated command. Switch to a two-call approach: sf org display for instanceUrl/username metadata, then sf org auth show-access-token for the actual token value. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Seth Sheppard <Seth.Sheppard@setshe-mbp-1.rvp.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 87a4429 commit 8ecceaf

12 files changed

Lines changed: 521 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ sf-db/
107107
### Postgres
108108
- Internal app tables → `sfdb` schema
109109
- 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`
110+
- All `sfdb.*` per-object/per-field tables (`sync_config`, `field_config`, `field_metadata`, `sync_log`, `sync_lock`, `custom_indexes`) are keyed by `(org_id, ...)` with `ON DELETE CASCADE` from `sfdb.orgs`
111111
- 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
112112
- Every synced table must have: `id`, `sf_created_at`, `sf_updated_at`, `sf_deleted_at`, `synced_at`
113113
- Field names are lowercase snake_case versions of SF API names
@@ -162,3 +162,5 @@ All runtime config (active org alias, sync intervals, enabled objects/fields) li
162162
- **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`.
163163
- **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)`.
164164
- **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.
165+
- **Reference fields are auto-indexed.** `createObjectTable` and `addColumn` automatically issue `CREATE INDEX CONCURRENTLY IF NOT EXISTS` for every `sfType === 'reference'` column. Index names are `idx_ref_<object>_<field>`, md5-hashed if > 63 chars.
166+
- **Custom indexes live in `sfdb.custom_indexes`.** Operator-defined indexes (composite, partial, etc.) are registered via `POST /api/indexes` and re-applied at startup and on table creation. Deleting a registration does not drop the underlying PG index.

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,47 @@ Queries `SELECT Id FROM <Object>` for the full live ID set, diffs against local
110110

111111
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.
112112

113+
## Indexing
114+
115+
sfetch maintains two layers of indexes on every synced table.
116+
117+
### Automatic — reference fields
118+
119+
Any Salesforce field of type `reference` (lookup / master-detail) is indexed automatically when the column is created. No configuration needed — sfetch infers this from the field type.
120+
121+
### Custom indexes
122+
123+
For queries with composite filters, date ranges, or partial index predicates, register an index via the API:
124+
125+
```bash
126+
curl -s -X POST http://localhost:7743/api/indexes \
127+
-H "Content-Type: application/json" \
128+
-H "X-Org-Id: 00d0a0000025groeam" \
129+
-d '{
130+
"object_api_name": "fferpcore__billingdocument__c",
131+
"index_name": "idx_bd_date_status",
132+
"columns": ["fferpcore__documentdate__c", "fferpcore__documentstatus__c"],
133+
"where_clause": "sf_deleted_at IS NULL"
134+
}'
135+
```
136+
137+
| Field | Required | Notes |
138+
|---|---|---|
139+
| `object_api_name` | yes | Salesforce API name of the object |
140+
| `index_name` | yes | Postgres index name — max 63 characters |
141+
| `columns` | yes | Array of column names to index |
142+
| `where_clause` | no | Raw SQL predicate for a partial index |
143+
144+
The index is applied immediately on POST and re-applied every time the API starts or the table is recreated. All index creation uses `CREATE INDEX CONCURRENTLY IF NOT EXISTS` — no table locks.
145+
146+
```bash
147+
# List registered custom indexes for the active org
148+
curl http://localhost:7743/api/indexes
149+
150+
# Remove a registration (does not drop the underlying PG index)
151+
curl -X DELETE http://localhost:7743/api/indexes/1
152+
```
153+
113154
## Database schema
114155

115156
**One schema per registered org** — named `org_<lowercased orgid>`, one table per enabled Salesforce object (e.g. `org_00d5g000001abcdeaa.account`)

docs/scope.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,13 @@ The sync engine manages schema changes directly with raw SQL — no migration fr
280280
| Event | DDL executed |
281281
|---|---|
282282
| Object enabled | `CREATE TABLE IF NOT EXISTS org_<orgid>.<object> (id text PRIMARY KEY, ...)` |
283+
| Object enabled | Auto-index every `reference`-type field; apply any registered custom indexes |
283284
| Object disabled + drop | `DROP TABLE org_<orgid>.<object>` |
284285
| Field re-enabled | `ALTER TABLE org_<orgid>.<object> ADD COLUMN <field> <type>` |
286+
| Field re-enabled (reference) | Auto-index the new column |
285287
| Field disabled | `ALTER TABLE org_<orgid>.<object> DROP COLUMN <field>` |
286288
287-
DDL is idempotent where possible (`IF NOT EXISTS`, `IF EXISTS`).
289+
DDL is idempotent where possible (`IF NOT EXISTS`, `IF EXISTS`). All index creation uses `CREATE INDEX CONCURRENTLY IF NOT EXISTS` — no table locks.
288290
289291
---
290292
@@ -363,6 +365,55 @@ All per-object/per-field tables include `org_id` and have `ON DELETE CASCADE` fr
363365
- `org_id` text PRIMARY KEY
364366
- `locked` boolean, `locked_at` timestamptz, `job_type` text
365367
368+
**`sfdb.custom_indexes`** — operator-defined indexes applied to org object tables
369+
- `id` serial PRIMARY KEY
370+
- `org_id` text (CASCADE from sfdb.orgs)
371+
- `object_api_name` text
372+
- `index_name` text (≤ 63 chars, unique per org)
373+
- `columns` text[] — columns to index
374+
- `where_clause` text NULL — optional partial index predicate (raw SQL)
375+
- Applied at startup and whenever the target table is created or recreated
376+
- CRUD via `GET/POST/DELETE /api/indexes`
377+
378+
---
379+
380+
## Indexing
381+
382+
sfetch maintains two layers of indexes on org object tables.
383+
384+
### Auto-indexes (reference fields)
385+
386+
Every Salesforce field of type `reference` (lookup / master-detail) is automatically indexed when its column is created. Reference fields are JOIN columns by definition — indexing them requires no configuration.
387+
388+
Index names follow the pattern `idx_ref_<object>_<field>`. If the combined name exceeds Postgres's 63-character identifier limit, the name is replaced with `idx_ref_<8-char md5 hash>`.
389+
390+
These indexes are created with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`, so they impose no table lock.
391+
392+
### Custom indexes (`sfdb.custom_indexes`)
393+
394+
For application-specific query patterns (composite filters, date ranges, status columns) that the auto-rule cannot derive, operators register indexes via the API or directly in the table:
395+
396+
```http
397+
POST /api/indexes
398+
Content-Type: application/json
399+
X-Org-Id: 00d0a0000025groeam
400+
401+
{
402+
"object_api_name": "fferpcore__billingdocument__c",
403+
"index_name": "idx_bd_date_status",
404+
"columns": ["fferpcore__documentdate__c", "fferpcore__documentstatus__c"],
405+
"where_clause": "sf_deleted_at IS NULL"
406+
}
407+
```
408+
409+
Custom indexes are applied immediately on POST and re-applied at every API startup and whenever the target table is created or recreated. They survive container restarts. Removing an entry via `DELETE /api/indexes/:id` removes the registration but does not drop the underlying Postgres index.
410+
411+
| Endpoint | Purpose |
412+
|---|---|
413+
| `GET /api/indexes` | List all custom indexes for the active org |
414+
| `POST /api/indexes` | Register and immediately apply a new index |
415+
| `DELETE /api/indexes/:id` | Remove a registration |
416+
366417
---
367418

368419
## Deletion Tracking

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/export-tokens.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,32 @@ for (const org of orgs) {
7575
const target = org.alias || org.username
7676
process.stdout.write(` ${target}... `)
7777

78-
const raw = run('sf', ['org', 'display', '--target-org', target, '--json'])
78+
const displayRaw = run('sf', ['org', 'display', '--target-org', target, '--json'])
7979
let result
8080
try {
81-
result = JSON.parse(raw)?.result
81+
result = JSON.parse(displayRaw)?.result
8282
} catch {
8383
console.log('parse error')
8484
fail++
8585
continue
8686
}
8787

88-
if (!result?.accessToken || !result?.instanceUrl) {
88+
if (!result?.instanceUrl) {
89+
console.log('no instanceUrl')
90+
fail++
91+
continue
92+
}
93+
94+
// sf CLI v2 redacts accessToken from org display; use dedicated command instead
95+
const tokenRaw = run('sf', ['org', 'auth', 'show-access-token', '--target-org', target, '--json'])
96+
let accessToken
97+
try {
98+
accessToken = JSON.parse(tokenRaw)?.result?.accessToken
99+
} catch {
100+
// ignore parse errors
101+
}
102+
103+
if (!accessToken || accessToken.startsWith('[REDACTED]')) {
89104
console.log('no token')
90105
fail++
91106
continue
@@ -95,7 +110,7 @@ for (const org of orgs) {
95110
const entry = {
96111
alias: org.alias ?? null,
97112
username: result.username ?? org.username,
98-
accessToken: result.accessToken,
113+
accessToken,
99114
instanceUrl: result.instanceUrl,
100115
exportedAt: new Date().toISOString(),
101116
}

src/api/src/app.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
migrateToMultiOrg,
99
ensureOrgObjectPrimaryKeys,
1010
ensureSyncLockRows,
11+
ensureCustomIndexesTable,
1112
} from './db/migrate'
13+
import { applyAllCustomIndexes } from './sync/ddlManager'
1214
import { startScheduler } from './sync/scheduler'
1315

1416
// ---------------------------------------------------------------------------
@@ -21,6 +23,7 @@ import logsRouter from './routes/logs'
2123
import schedulesRouter from './routes/schedules'
2224
import settingsRouter from './routes/settings'
2325
import syncOrderRouter from './routes/syncOrder'
26+
import indexesRouter from './routes/indexes'
2427

2528
// ---------------------------------------------------------------------------
2629
// CORS helper — allow localhost origins only
@@ -97,6 +100,7 @@ export function createApp() {
97100
app.use('/api/schedules', schedulesRouter)
98101
app.use('/api/settings', settingsRouter)
99102
app.use('/api/sync-order', syncOrderRouter)
103+
app.use('/api/indexes', indexesRouter)
100104

101105
// ---------------------------------------------------------------------------
102106
// SPA static file serving — must come after all /api routes
@@ -155,10 +159,18 @@ export async function initApp(): Promise<void> {
155159
await migrateToMultiOrg()
156160
await ensureSyncLockRows()
157161
await ensureOrgObjectPrimaryKeys()
162+
await ensureCustomIndexesTable()
158163
} catch (err) {
159164
console.error('[startup] Migration failed:', (err as Error).message)
160165
}
161166

167+
try {
168+
await applyAllCustomIndexes()
169+
console.log('[startup] Custom indexes applied')
170+
} catch (err) {
171+
console.warn('[startup] Could not apply custom indexes:', (err as Error).message)
172+
}
173+
162174
try {
163175
// Release any stale locks left by a process killed mid-sync (works on
164176
// both legacy single-row and new per-org sync_lock shapes).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- 004_custom_indexes.sql
2+
-- Operator-defined indexes applied to org object tables at startup and on table creation.
3+
-- Runs once on first Postgres container start via docker-entrypoint-initdb.d.
4+
5+
CREATE TABLE IF NOT EXISTS sfdb.custom_indexes (
6+
id serial PRIMARY KEY,
7+
org_id text NOT NULL REFERENCES sfdb.orgs(org_id) ON DELETE CASCADE,
8+
object_api_name text NOT NULL,
9+
index_name text NOT NULL CHECK (char_length(index_name) <= 63),
10+
columns text[] NOT NULL,
11+
where_clause text,
12+
created_at timestamptz NOT NULL DEFAULT NOW(),
13+
UNIQUE (org_id, index_name)
14+
);

src/api/src/db/migrate.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,26 @@ export async function migrateToMultiOrg(): Promise<void> {
323323
}
324324
}
325325

326+
// ---------------------------------------------------------------------------
327+
// Ensure sfdb.custom_indexes exists — added after initial release so existing
328+
// installs won't have it from the init scripts.
329+
// ---------------------------------------------------------------------------
330+
331+
export async function ensureCustomIndexesTable(): Promise<void> {
332+
await pool.query(`
333+
CREATE TABLE IF NOT EXISTS sfdb.custom_indexes (
334+
id serial PRIMARY KEY,
335+
org_id text NOT NULL REFERENCES sfdb.orgs(org_id) ON DELETE CASCADE,
336+
object_api_name text NOT NULL,
337+
index_name text NOT NULL CHECK (char_length(index_name) <= 63),
338+
columns text[] NOT NULL,
339+
where_clause text,
340+
created_at timestamptz NOT NULL DEFAULT NOW(),
341+
UNIQUE (org_id, index_name)
342+
)
343+
`)
344+
}
345+
326346
// ---------------------------------------------------------------------------
327347
// Convenience: ensure a sync_lock row exists for every registered org.
328348
// Called on every startup so a row is present even if registration predates this code.

0 commit comments

Comments
 (0)