From dc997f96297ea1bf2e8020077c2ace8d3fbb0f3e Mon Sep 17 00:00:00 2001 From: lorentz Date: Fri, 10 Jul 2026 19:10:34 -0400 Subject: [PATCH] docs(11): add pattern map, cite D-02/D-07/D-08 in plan truths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .planning/STATE.md | 12 +- .../11-02-PLAN.md | 3 + .../11-PATTERNS.md | 330 ++++++++++++++++++ 3 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/11-company-catalog-subscription-sync/11-PATTERNS.md diff --git a/.planning/STATE.md b/.planning/STATE.md index d4dda90..38775d6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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% diff --git a/.planning/phases/11-company-catalog-subscription-sync/11-02-PLAN.md b/.planning/phases/11-company-catalog-subscription-sync/11-02-PLAN.md index 4ed265a..e8d68d9 100644 --- a/.planning/phases/11-company-catalog-subscription-sync/11-02-PLAN.md +++ b/.planning/phases/11-company-catalog-subscription-sync/11-02-PLAN.md @@ -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: diff --git a/.planning/phases/11-company-catalog-subscription-sync/11-PATTERNS.md b/.planning/phases/11-company-catalog-subscription-sync/11-PATTERNS.md new file mode 100644 index 0000000..9add1aa --- /dev/null +++ b/.planning/phases/11-company-catalog-subscription-sync/11-PATTERNS.md @@ -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 { + return this.run('sessions', triggeredBy); + } + async dailySync(triggeredBy = 'system'): Promise { + 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 { + 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 { + 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()` (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> { + return this.fetchJson>(`/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