docs(11): add pattern map, cite D-02/D-07/D-08 in plan truths
Pattern mapper output for Phase 11 was missing from the initial plan commit. Decision coverage gate also flagged D-02/D-07/D-08 as implemented-but-uncited in 11-02-PLAN.md's must_haves.truths block — added explicit citations so the translation gate passes cleanly.
This commit is contained in:
parent
c8dd64f766
commit
dc997f9629
3 changed files with 339 additions and 6 deletions
|
|
@ -2,14 +2,14 @@
|
|||
gsd_state_version: 1.0
|
||||
milestone: v2.0
|
||||
milestone_name: PAX8 Integration
|
||||
status: planning
|
||||
status: executing
|
||||
stopped_at: Phase 11 context gathered
|
||||
last_updated: "2026-07-10T22:43:01.751Z"
|
||||
last_activity: 2026-07-10
|
||||
last_updated: "2026-07-10T23:10:13.476Z"
|
||||
last_activity: 2026-07-10 -- Phase 11 planning complete
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 1
|
||||
total_plans: 3
|
||||
total_plans: 6
|
||||
completed_plans: 3
|
||||
percent: 20
|
||||
---
|
||||
|
|
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10)
|
|||
|
||||
Phase: 11
|
||||
Plan: Not started
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-07-10
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-07-10 -- Phase 11 planning complete
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ must_haves:
|
|||
- "A full sync upserts every current PAX8 company into pax8_companies (PAX8-03)"
|
||||
- "A full sync upserts every current PAX8 subscription with product, quantity, billing term, customer price, and partner cost (PAX8-04)"
|
||||
- "The catalog table (pax8_products) holds a readable name + category for every product referenced by a subscription, populated from the full product list — not bare SKU IDs (PAX8-05, D-01)"
|
||||
- "Referenced-but-unknown products are resolved by filtering the full product list fetched once per sync run to subscription-referenced IDs, in the same sync pass — not a separate two-pass batch step; PAX8 has no per-SKU product-detail endpoint, so this is the single-pass mechanism that satisfies D-02's intent (D-02)"
|
||||
- "Companies/subscriptions/products no longer returned by PAX8 are soft-deleted (is_deleted=true, deleted_at set), never hard-removed (D-05/D-06)"
|
||||
- "fullSync is the only sync mode (no incrementalSync); PAX8 exposes no modified-since/delta filter for companies, subscriptions, or products, so every entity type falls back to full sync per D-07's per-entity-type fallback clause (D-07)"
|
||||
- "Every fullSync run performs the complete tombstone/reconciliation pass (diff current PAX8 IDs against non-deleted Postgres rows) as an unconditional step, satisfying D-08's periodic full-reconciliation requirement on every invocation (D-08)"
|
||||
- "No code path in the sync service issues a PAX8 API write; it calls only the client's read methods (PAX8-08)"
|
||||
- "POST /api/pax8/sync starts the sync fire-and-forget and returns immediately; a concurrent trigger returns 409"
|
||||
artifacts:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
# Phase 11: Company, Catalog & Subscription Sync - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-10
|
||||
**Files analyzed:** 3 (1 new service, 1 new route, 1 extended client) + 1 possible migration gap
|
||||
**Analogs found:** 3 / 3
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|--------------------|------|-----------|-----------------|---------------|
|
||||
| `lib/services/pax8-sync-service.ts` (new) | service | CRUD (batch upsert + soft-delete reconciliation) | `lib/services/appgate-sync-service.ts` | exact |
|
||||
| `app/api/pax8/sync/route.ts` (new) | route | request-response (fire-and-forget trigger) | `app/api/itglue/sync/route.ts` | exact |
|
||||
| `lib/services/pax8-client.ts` (extend) | service (API client) | request-response | itself (extend in place) | exact — extend existing file, don't replace |
|
||||
| `migrations/09X_pax8_sync_history.sql` (new, only if planner decides a dedicated history table is needed) | migration | CRUD | `migrations/089_appgate_tables.sql` (appgate_sync_history) | role-match |
|
||||
|
||||
Note: `pax8_companies`, `pax8_products`, `pax8_subscriptions` tables already exist from Phase 10 (`migrations/091_pax8_tables.sql`) — this phase does not need new schema migrations for those three tables unless research finds a genuine column gap (e.g., the D-03 dual-cost columns, discussed below).
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `lib/services/pax8-sync-service.ts` (service, CRUD)
|
||||
|
||||
**Analog:** `lib/services/appgate-sync-service.ts` (376 lines — most recently written sync service in the repo, and the only one whose tombstone logic already targets `UUID` primary keys, matching PAX8's `id UUID PRIMARY KEY` shape exactly). Secondary analogs: `lib/services/qbo-sync-service.ts` (tombstone pattern on `TEXT` ids + full-vs-incremental branching) and `lib/services/entity-sync.ts` (canonical incremental-timestamp pattern, referenced explicitly in CONTEXT.md).
|
||||
|
||||
**Imports pattern** (`lib/services/appgate-sync-service.ts` lines 19-28):
|
||||
```typescript
|
||||
import postgresClient from './postgres-client';
|
||||
import { AppgateClient } from './appgate-client';
|
||||
import { getAppgateClient } from './appgate-factory';
|
||||
import type {
|
||||
AppgateActiveSession,
|
||||
AppgateAppliance,
|
||||
AppgateHourlyLogins,
|
||||
AppgateOnBoardedDevice,
|
||||
AppgateSyncResult,
|
||||
} from '@/lib/types/appgate';
|
||||
```
|
||||
For PAX8, mirror this exactly:
|
||||
```typescript
|
||||
import postgresClient from './postgres-client';
|
||||
import { Pax8Client } from './pax8-client';
|
||||
import { getPax8Client } from './pax8-factory';
|
||||
import type { Pax8Company, Pax8Subscription, Pax8Product } from '@/lib/types/pax8';
|
||||
```
|
||||
|
||||
**Class shape / singleton + in-progress guard** (`appgate-sync-service.ts` lines 30-48, 348-352):
|
||||
```typescript
|
||||
export class AppgateSyncService {
|
||||
private client: AppgateClient;
|
||||
private syncing = false;
|
||||
|
||||
constructor(client?: AppgateClient) {
|
||||
this.client = client ?? getAppgateClient();
|
||||
}
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.syncing;
|
||||
}
|
||||
|
||||
async sessionsSync(triggeredBy = 'system'): Promise<AppgateSyncResult> {
|
||||
return this.run('sessions', triggeredBy);
|
||||
}
|
||||
async dailySync(triggeredBy = 'system'): Promise<AppgateSyncResult> {
|
||||
return this.run('daily', triggeredBy);
|
||||
}
|
||||
}
|
||||
...
|
||||
let _instance: AppgateSyncService | null = null;
|
||||
export function getAppgateSyncService(): AppgateSyncService {
|
||||
if (!_instance) _instance = new AppgateSyncService();
|
||||
return _instance;
|
||||
}
|
||||
```
|
||||
For PAX8, the constructor + `getPax8SyncService()` singleton should follow this exactly (`this.client = client ?? getPax8Client()`). Public entry points should be `fullSync(triggeredBy)` / `incrementalSync(triggeredBy)` to match the veeam/qbo naming convention (`isSyncInProgress()`, `fullSync`, `incrementalSync`) since D-07 is explicitly incremental-with-full-fallback per entity type, not a "sessions vs daily" split like AppGate's.
|
||||
|
||||
**Orchestration / run() pattern** (`appgate-sync-service.ts` lines 67-97):
|
||||
```typescript
|
||||
private async run(syncType: 'sessions' | 'daily', triggeredBy: string): Promise<AppgateSyncResult> {
|
||||
if (this.syncing) throw new Error('AppGate sync already in progress');
|
||||
this.syncing = true;
|
||||
const syncId = `appgate_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const startedAt = new Date();
|
||||
const result = this.emptyResult(syncId, syncType, startedAt);
|
||||
|
||||
console.log(`[AppgateSync] Starting ${syncType} sync (${syncId}) — triggered by ${triggeredBy}`);
|
||||
|
||||
try {
|
||||
result.sessions = await this.syncSessions();
|
||||
if (syncType === 'daily') {
|
||||
result.devices = await this.syncDevices();
|
||||
result.appliances = await this.syncAppliances();
|
||||
...
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
result.errors.push(msg);
|
||||
result.status = 'failed';
|
||||
console.error(`[AppgateSync] ${syncType} sync failed:`, msg);
|
||||
} finally {
|
||||
result.completedAt = new Date();
|
||||
result.durationMs = result.completedAt.getTime() - startedAt.getTime();
|
||||
await this.persistHistory(result, triggeredBy);
|
||||
this.syncing = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
For PAX8, adapt this shape so companies → products (lazy/referenced, D-01/D-02) → subscriptions run in dependency order within one `run()`, with the periodic full-reconciliation pass (D-08) as an additional step gated by a parameter or by comparing `syncType`.
|
||||
|
||||
**Per-entity upsert + tombstone pattern — this is the load-bearing excerpt for D-05/D-06/D-08** (`appgate-sync-service.ts` lines 185-244, appliances — chosen over devices because appliances use a `UUID` PK exactly like `pax8_companies`/`pax8_products`/`pax8_subscriptions`):
|
||||
```typescript
|
||||
private async syncAppliances(): Promise<{ upserted: number; tombstoned: number }> {
|
||||
const items = await this.client.getAppliances();
|
||||
let upserted = 0;
|
||||
const seen: string[] = [];
|
||||
for (const a of items) {
|
||||
if (!a.id) continue;
|
||||
seen.push(a.id);
|
||||
await postgresClient.query(
|
||||
`INSERT INTO appgate_appliances
|
||||
(id, name, hostname, notes, version, site, site_name,
|
||||
activated, pending_certificate_renewal, tags, roles, raw,
|
||||
created_at, updated_at, synced_at, is_deleted, deleted_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, NOW(), false, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
... (all mutable columns) ...,
|
||||
synced_at = NOW(),
|
||||
is_deleted = false,
|
||||
deleted_at = NULL`,
|
||||
[a.id, a.name, ... ],
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
const tomb = seen.length === 0
|
||||
? 0
|
||||
: (await postgresClient.query(
|
||||
`UPDATE appgate_appliances SET is_deleted=true, deleted_at=NOW()
|
||||
WHERE is_deleted=false AND id <> ALL($1::uuid[])`,
|
||||
[seen],
|
||||
)).rowCount ?? 0;
|
||||
if (tomb > 0) console.log(`[AppgateSync] Tombstoned ${tomb} appliance(s)`);
|
||||
return { upserted, tombstoned: tomb };
|
||||
}
|
||||
```
|
||||
**Directly copy this shape for `pax8_companies`, `pax8_products`, and `pax8_subscriptions`** — same `id UUID PRIMARY KEY` type, same `is_deleted`/`deleted_at`/`synced_at` columns already present in `migrations/091_pax8_tables.sql`. Note the `<> ALL($1::uuid[])` cast — PAX8 ids are UUID strings, so this is the correct cast (not `::text[]` as in the device/qbo variants, and not `::bigint[]` as in `lib/utils/db-helpers.ts`'s `softDeleteMissingRecords`, which is Autotask-specific and NOT reusable here). This satisfies D-05/D-06 directly: `is_deleted=false, deleted_at=NULL` reset on every successful re-upsert (in case a previously-tombstoned row reappears), plus the tombstone pass for anything not in `seen`.
|
||||
|
||||
**D-08 (periodic full reconciliation) implementation note:** AppGate's tombstone pass runs unconditionally inside `syncAppliances()`/`syncDevices()` every "daily" sync because AppGate's list endpoints always return the full set (no incremental filter exists for those entities). For PAX8, if a given entity type (companies/products/subscriptions) has no modified-since filter, follow this same unconditional-tombstone-on-full-list approach. If PAX8 does support delta/incremental for some entity, follow **`qbo-sync-service.ts`'s explicit branch** instead (`lib/services/qbo-sync-service.ts` lines 171-196): only run the tombstone diff when `syncType === 'full'`, since an incremental pull's `seenIds` would incorrectly tombstone everything not recently touched:
|
||||
```typescript
|
||||
// Tombstone pass — full syncs only.
|
||||
if (syncType === 'full' && seenIds.length > 0) {
|
||||
const tombstoneRes = await postgresClient.query<{ id: string }>(
|
||||
`UPDATE qbo_invoices
|
||||
SET is_deleted = true, deleted_at = NOW()
|
||||
WHERE realm_id = $1 AND is_deleted = false AND id <> ALL($2::text[])
|
||||
RETURNING id`,
|
||||
[realmId, seenIds],
|
||||
);
|
||||
tombstoned = tombstoneRes.rowCount ?? tombstoneRes.rows.length;
|
||||
}
|
||||
```
|
||||
Whichever cadence the planner picks (every sync run vs. a less-frequent full pass), this branch-on-`syncType` shape is the mechanism to expose that hook, per CONTEXT.md's Claude's Discretion note.
|
||||
|
||||
**Lazy/referenced-only catalog sync (D-01/D-02) — no direct analog exists in the codebase** (nothing else in Pulse does an "inline fetch missing related row during iteration" pattern at this granularity). Closest structural precedent is IT Glue's per-parent iteration in `lib/services/itglue-sync-service.ts` (`syncModels()`, lines 142-160 — iterate manufacturers, then call a child-relationship endpoint per manufacturer). Adapt that iteration shape for D-02: while iterating subscriptions, check an in-memory `Set` of already-upserted/known product IDs (seed it from `SELECT id FROM pax8_products` once per sync run, same pattern as AppGate/Veeam's "known FK set" helper below), and only call the client's product-detail endpoint for IDs not yet in that set.
|
||||
|
||||
**FK-safety "known ID set" pattern** (`lib/services/veeam-sync-service.ts` lines 219-230, repeated at 253-258, 297-299, 343-347, 381-383, 411-412, 451-452):
|
||||
```typescript
|
||||
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
|
||||
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
|
||||
...
|
||||
const orgUid = orgUids.has(s.organizationUid) ? s.organizationUid : null;
|
||||
```
|
||||
Useful for `pax8_subscriptions.pax8_company_id`/`product_id` soft-refs — though per migration 091's comment, these are intentionally NOT hard FKs, so this pattern is optional defensive cleanup rather than a hard requirement (unlike Veeam, where it prevents FK violations).
|
||||
|
||||
**Error handling pattern:** every entity-sync method wraps its own logic and returns a per-entity result rather than throwing up to the orchestrator (see `qbo-sync-service.ts` lines 205-209, `itglue-sync-service.ts`'s `run()` helper lines 49-60). Top-level `run()`/`executeSync()` only catches truly unexpected/fatal errors. Follow this: one try/catch per entity-type sync method, collecting `{ entity, success, recordsUpserted, duration, error? }`.
|
||||
|
||||
**History persistence pattern** (`appgate-sync-service.ts` lines 330-345):
|
||||
```typescript
|
||||
private async persistHistory(r: AppgateSyncResult, triggeredBy: string): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO appgate_sync_history (...) VALUES (...)`,
|
||||
[...],
|
||||
).catch((e) => console.error('[AppgateSync] history insert failed:', e));
|
||||
}
|
||||
```
|
||||
**Schema gap to flag for planner:** `migrations/091_pax8_tables.sql` does NOT create a `pax8_sync_history` table (unlike itglue's `itg_sync_history`, appgate's `appgate_sync_history`, s1's `s1_sync_history`). Two options, planner's call:
|
||||
1. Reuse the generic `sync_history` table (`migrations/001_initial_schema.sql` lines 542-554, columns: `entity_type, sync_type, status, started_at, completed_at, records_added/updated/deleted, error_message, triggered_by`) — this is what `veeam-sync-service.ts` (lines 79-89, 140-149) and `qbo-sync-service.ts` (lines 533-554) both do, avoiding a new migration entirely.
|
||||
2. Add a dedicated `pax8_sync_history` table via a new numbered migration mirroring `appgate_sync_history` (`migrations/089_appgate_tables.sql` lines 117-...) if per-entity JSONB detail is wanted.
|
||||
Given CONTEXT.md's canonical-refs note that Phase 10 already laid down the full schema and this phase should not alter it "unless a genuine gap is found" — and a sync-history table is a genuine, minor gap — leaning toward option 1 (reuse generic `sync_history`) keeps this phase's migration footprint at zero, consistent with D-05/D-06's framing that no new migration should be needed.
|
||||
|
||||
---
|
||||
|
||||
### `app/api/pax8/sync/route.ts` (route, request-response / fire-and-forget trigger)
|
||||
|
||||
**Analog:** `app/api/itglue/sync/route.ts` (55 lines — cleanest, most minimal fire-and-forget example; preferred over veeam's route which adds a `full`/`incremental` body-driven branch that PAX8 may also want, shown below as a secondary reference).
|
||||
|
||||
**Full pattern** (`app/api/itglue/sync/route.ts`, all 55 lines):
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const triggeredBy = body.triggeredBy || 'manual';
|
||||
|
||||
const svc = getITGlueSyncService();
|
||||
if (svc.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Fire and forget — return immediately, sync runs in background
|
||||
svc.fullSync(triggeredBy).catch(err =>
|
||||
console.error('[ITGlue] Background sync error:', err.message)
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'IT Glue sync started' });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const svc = getITGlueSyncService();
|
||||
const inProgress = svc.isSyncInProgress();
|
||||
const { rows } = await postgresClient.query(
|
||||
`SELECT ... FROM itg_sync_history ORDER BY started_at DESC LIMIT 10`
|
||||
);
|
||||
const counts = await postgresClient.query(`SELECT (SELECT COUNT(*) FROM itg_organizations) AS organizations, ...`);
|
||||
return NextResponse.json({ inProgress, counts: counts.rows[0], history: rows });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
**If the sync service exposes both `fullSync`/`incrementalSync` (per D-07), add veeam's body-driven `syncType` branch** (`app/api/veeam/sync/route.ts` lines 24-39):
|
||||
```typescript
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const syncType = body.syncType === 'full' ? 'full' : 'incremental';
|
||||
const resultPromise = syncType === 'full'
|
||||
? syncService.fullSync('manual')
|
||||
: syncService.incrementalSync('manual');
|
||||
resultPromise.catch((err) => console.error('[VEEAM-SYNC-API] Background sync failed:', err));
|
||||
return NextResponse.json({ message: `Veeam ${syncType} sync started`, syncType });
|
||||
```
|
||||
**409 guard pattern** (`app/api/veeam/sync/route.ts` lines 17-22):
|
||||
```typescript
|
||||
if (syncService.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'A Veeam sync is already in progress' }, { status: 409 });
|
||||
}
|
||||
```
|
||||
Apply this 409 check to the PAX8 route exactly as shown — matches CLAUDE.md's status-code convention (409 for the "already running" case is established here, not in the CLAUDE.md text itself, but consistently across all three sync routes inspected).
|
||||
|
||||
**Auth note:** none of the veeam/itglue sync routes call `requireAuth()`/`requireAdmin()` — they rely on `middleware.ts`'s session-cookie check only, since these are manual/admin-triggered internal endpoints, not public webhooks. Follow the same (no explicit route-level auth call) unless CONTEXT.md or a later phase's admin UI wiring specifies otherwise. Confirm `/api/pax8/sync` is NOT added to `middleware.ts`'s public-route allowlist (it should stay behind the session-cookie check, unlike webhooks).
|
||||
|
||||
---
|
||||
|
||||
### `lib/services/pax8-client.ts` (extend in place — service/API client, request-response)
|
||||
|
||||
**Current state** (full file already read, 79 lines): has `getToken()` (OAuth2 client-credentials with PAX8's JSON-body deviation), `fetchJson<T>()` (with 429/Retry-After backoff, max 4 retries), and one read method `listCompanies(page, size)`.
|
||||
|
||||
**Pattern to extend with** — add `listProducts`, `listSubscriptions`, `getProductById` following the exact same shape as `listCompanies` (`pax8-client.ts` lines 75-78):
|
||||
```typescript
|
||||
async listCompanies(page = 0, size = 10): Promise<Pax8PageEnvelope<Pax8Company>> {
|
||||
return this.fetchJson<Pax8PageEnvelope<Pax8Company>>(`/companies?page=${page}&size=${size}`);
|
||||
}
|
||||
```
|
||||
Pagination loop to fetch all pages — no existing PAX8 helper for this yet (single auth-proof call only fetched one page). Nearest paginate-until-exhausted analog is IT Glue's `client.getRawAllPages(path)` (used throughout `itglue-sync-service.ts`, e.g. line 110) — the sync service (not the client) should own the "loop until `page.number >= page.totalPages - 1`" logic, OR add a `listAllCompanies()`-style helper to `pax8-client.ts` itself, mirroring `getRawAllPages`. Planner's call which layer owns pagination; either is consistent with existing conventions (IT Glue's client owns it, Veeam/AppGate/QBO's clients return already-paginated full arrays from methods like `getOrganizations()`).
|
||||
|
||||
**Test pattern for any new client methods** — `lib/services/pax8-client.test.ts` (108 lines, all read) establishes the mocking convention: `vi.stubGlobal('fetch', fetchMock)` with a hand-rolled `Response`-shaped mock keyed by URL substring match (`url.includes('/token')` vs. other paths), `vi.unstubAllGlobals()` in `beforeEach`. Follow this exact style for any new client method tests — do not introduce `nock`, `msw`, or another HTTP mocking library.
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Soft-delete + tombstone reconciliation (D-05/D-06/D-08)
|
||||
**Source:** `lib/services/appgate-sync-service.ts` lines 185-244 (UUID-keyed, closest match), `lib/services/qbo-sync-service.ts` lines 171-196 (full-vs-incremental branch)
|
||||
**Apply to:** all three `syncCompanies()`/`syncProducts()`/`syncSubscriptions()` methods in the new `pax8-sync-service.ts`
|
||||
```sql
|
||||
-- upsert, resetting tombstone flags on every successful re-sync:
|
||||
INSERT INTO pax8_companies (id, name, ..., synced_at, is_deleted, deleted_at)
|
||||
VALUES ($1, $2, ..., NOW(), false, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name, ..., synced_at = NOW(), is_deleted = false, deleted_at = NULL;
|
||||
|
||||
-- tombstone anything not seen this pass (UUID cast, matches pax8_* PK type):
|
||||
UPDATE pax8_companies SET is_deleted = true, deleted_at = NOW()
|
||||
WHERE is_deleted = false AND id <> ALL($1::uuid[]);
|
||||
```
|
||||
|
||||
### Postgres bulk-write mechanism
|
||||
**Source:** `lib/services/postgres-client.ts` lines 223-262 (`bulkUpsert`), lines 267-295 (`softDelete`/`softDeleteMany`)
|
||||
**Apply to:** all new sync methods, IF a batch (multi-row) insert is preferred over per-row loops. Note `bulkUpsert`'s `ON CONFLICT` clause always appends `updated_at = CURRENT_TIMESTAMP` — it does NOT set `is_deleted`/`deleted_at`/`synced_at` automatically, so if used, those three columns must be included explicitly in each record object passed in. Also note `softDeleteMany()` casts `id = ANY($1::bigint[])` — **not usable as-is for PAX8's UUID ids**; either write a raw query (as `appgate-sync-service.ts` does) or extend `postgresClient` with a UUID-aware variant. Given every other recent PAX8-adjacent integration (AppGate, QBO) wrote the tombstone query directly rather than going through `postgresClient.softDeleteMany()`, prefer the raw-query approach for consistency.
|
||||
|
||||
### Fire-and-forget sync trigger + in-progress guard
|
||||
**Source:** `app/api/itglue/sync/route.ts` (full file), `app/api/veeam/sync/route.ts` lines 13-46
|
||||
**Apply to:** `app/api/pax8/sync/route.ts`
|
||||
```typescript
|
||||
if (svc.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
|
||||
}
|
||||
svc.fullSync(triggeredBy).catch(err => console.error('[Pax8Sync] Background sync error:', err.message));
|
||||
return NextResponse.json({ ok: true, message: 'PAX8 sync started' });
|
||||
```
|
||||
|
||||
### Factory / configuration-check pattern (already built, Phase 10 — no changes needed)
|
||||
**Source:** `lib/services/pax8-factory.ts` (full file, 25 lines)
|
||||
```typescript
|
||||
export function isPax8Configured(): boolean {
|
||||
return Boolean(process.env.PAX8_CLIENT_ID && process.env.PAX8_CLIENT_SECRET);
|
||||
}
|
||||
export function getPax8Client(): Pax8Client {
|
||||
if (_client) return _client;
|
||||
if (!isPax8Configured()) throw new Error('PAX8 is not configured — set PAX8_CLIENT_ID and PAX8_CLIENT_SECRET');
|
||||
_client = new Pax8Client({ clientId: process.env.PAX8_CLIENT_ID!, clientSecret: process.env.PAX8_CLIENT_SECRET! });
|
||||
return _client;
|
||||
}
|
||||
```
|
||||
**Apply to:** `pax8-sync-service.ts`'s constructor (`this.client = client ?? getPax8Client()`) — do not re-implement configuration checking inside the sync service; the factory already throws with a clear message if unconfigured, and the sync service's `run()` catch-block will surface that as an entity error.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| Lazy/referenced-only catalog fetch, inline single-SKU lookup during subscription iteration (D-01/D-02) | service (sub-pattern within sync service) | event-driven (fetch-on-miss during iteration) | No existing Pulse sync service does a "fetch missing related entity inline, mid-loop, keyed off an in-memory seen-set" pattern. Closest structural precedent is IT Glue's per-parent nested iteration (`syncModels()`, `syncFlexibleAssets()`) but those pre-fetch all children per known parent rather than fetching one missing child on demand. Planner/implementer should design this from scratch using the "known ID set" pattern (see Shared Patterns) as scaffolding, not copy a full existing method. |
|
||||
| Dual cost columns (list price vs. partner/reseller cost) on `pax8_subscriptions` (D-03/D-04) | migration (potential column addition) | CRUD | `migrations/091_pax8_tables.sql` (`pax8_subscriptions`) currently has no cost/price columns at all — this is new schema territory research must confirm against PAX8's live subscription API response shape before the planner decides between a single `cost` column vs. `list_price` + nullable `partner_cost` (explicitly flagged as Claude's Discretion in CONTEXT.md). No existing Pulse table already models a "list vs partner cost" pair to copy from; `pax8_order_items` has `unit_price`/`line_total` (single price point) as the nearest partial precedent. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `lib/services/*.ts` (sync services: appgate, veeam, qbo, itglue, entity-sync), `lib/services/pax8-*.ts`, `lib/utils/db-helpers.ts`, `app/api/{veeam,itglue,qbo}/sync/route.ts`, `migrations/{001,037,038,089,091}_*.sql`, `lib/types/pax8.ts`
|
||||
**Files scanned:** entity-sync.ts (1478 lines, grepped for structure only), itglue-sync-service.ts (663 lines, full read), veeam-sync-service.ts (503 lines, full read), qbo-sync-service.ts (full read), appgate-sync-service.ts (376 lines, full read — primary analog), postgres-client.ts (bulkUpsert/softDelete section read), pax8-client.ts (79 lines, full read), pax8-factory.ts (25 lines, full read), pax8-client.test.ts (108 lines, full read), db-helpers.ts (335 lines, full read), migrations/091_pax8_tables.sql (170 lines, full read), app/api/veeam/sync/route.ts, app/api/itglue/sync/route.ts (both full reads), lib/types/pax8.ts (full read)
|
||||
**Pattern extraction date:** 2026-07-10
|
||||
Loading…
Add table
Add a link
Reference in a new issue