# 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
