# `@x12i/activix` — Package Specification

> **Bundled copy:** This file was copied from `ai-activities-tracking/docs/specs/activix.spec.md` for use in a standalone repo. **§8** links to `ActivityTracker.ts` / `DatabaseManager.ts` still point at the old monorepo paths.

**Version:** 1.0.0  
**Registry:** `https://registry.npmjs.org` (npmjs; `@x12i` deps may be private)  
**Depends on:** `@x12i/xronox-store` (must be published first)

---

## 1. Purpose

`activix` is a **generic activity lifecycle layer** built on top of `@x12i/xronox-store`. It provides a two-phase `start → complete/fail` record lifecycle for any kind of operation that can be tracked over time.

It knows about:
- Records that have a **status** and a **lifecycle** (started → completed/failed/timeout)
- Records that have **timestamps** (start time, end time, duration)
- Stale record detection and batch status updates
- Post-hoc partial field updates

It knows **nothing** about:
- AI, LLMs, tokens, cost, providers, models
- Graphs, nodes, skills, agents
- Contracts, diagnostics, format extraction
- Any specific field names beyond what the consumer configures

**The consumer controls:** collection names, primary key field name, status field name, status value strings, timestamp field names, and what additional fields go into each record.

---

## 2. Package Structure

```
activix/
├── src/
│   ├── Activix.ts           # Main class
│   ├── types.ts               # All interfaces and types
│   └── index.ts               # Barrel export
├── dist/                      # ESM build output
├── dist-cjs/                  # CJS build output
├── scripts/
│   └── create-cjs-package.js  # Post-build CJS package.json injector
├── .tests/
│   ├── activix.test.ts      # Integration tests (live MongoDB)
│   ├── activix-patch.test.ts
│   ├── activix-stale.test.ts
│   └── tsconfig.json
├── package.json
├── tsconfig.json
├── tsconfig.cjs.json
├── .env.example
└── README.md
```

---

## 3. `package.json`

```json
{
  "name": "@x12i/activix",
  "version": "1.0.0",
  "description": "Generic two-phase activity lifecycle tracker built on @x12i/xronox-store",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist-cjs/index.js"
    }
  },
  "scripts": {
    "build": "tsc && tsc -p tsconfig.cjs.json && node scripts/create-cjs-package.js",
    "build:esm": "tsc",
    "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/create-cjs-package.js",
    "build:tests": "tsc -p .tests/tsconfig.json",
    "test": "npm run build && npm run build:tests && node dist/.tests/activix.test.js",
    "test:patch": "npm run build && npm run build:tests && node dist/.tests/activix-patch.test.js",
    "test:stale": "npm run build && npm run build:tests && node dist/.tests/activix-stale.test.js",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "@x12i/xronox-store": "^1.0.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@x12i/xronox": "^3.1.6"
  },
  "engines": { "node": ">=16.0.0" },
  "publishConfig": {
    "registry": "https://registry.npmjs.org",
    "access": "restricted"
  },
  "files": ["dist", "dist-cjs", "README.md", ".env.example"]
}
```

**Note:** `@x12i/xronox` appears only in `devDependencies` — it is needed in tests to pass a pre-built Xronox instance, but `activix` itself never imports Xronox directly. All Xronox interaction goes through `xronox-store`.

---

## 4. Core Concept

A **record** in activix is any document that:

1. Has a **primary key** — field name is configurable, auto-generated if not supplied
2. Has a **status field** — field name and value strings are configurable
3. Has a **start timestamp** — set automatically on `startRecord()`
4. Optionally has an **end timestamp** and **duration** — set automatically on `completeRecord()` / `failRecord()`

All field names and status string values are resolved from the `ActivixCollectionConfig` for the relevant collection. Sensible defaults are provided so the simplest config is just `{ name: 'my-collection' }`.

---

## 5. Types (`src/types.ts`)

### 5.1 `ActivixCollectionConfig`

```typescript
export interface ActivixCollectionConfig {
  /** MongoDB collection name */
  name: string;

  /**
   * An alias/role used to reference this collection in method calls.
   * If not specified, the collection name is used as the role.
   * e.g. role: 'default' → store.collection('my-collection') is the default collection
   */
  role?: string;

  /** Primary key field name. Default: 'activityId' */
  primaryKey?: string;

  /** Prefix for auto-generated primary key values. Default: 'act-' */
  primaryKeyPrefix?: string;

  /**
   * Nested object on each record (`runContext` by default). `sessionId` is copied from nested `runContext` or top-level `sessionId` when provided; if both are missing, Activix warns and does not generate one.
   * Default field name: `runContext`. Top-level `sessionId` is merged into `runContext.sessionId` when absent there.
   */
  runContextField?: string;

  /** Status field name. Default: 'status' */
  statusField?: string;

  /** Start timestamp field name (Unix ms). Default: 'startTime' */
  startTimeField?: string;

  /** End timestamp field name (Unix ms). Default: 'endTime' */
  endTimeField?: string;

  /** Duration field name (ms). Default: 'duration' */
  durationField?: string;

  /** Status value strings. All have defaults — override any or all. */
  statusValues?: {
    /** Default: 'started' */
    started?: string;
    /** Default: 'completed' */
    completed?: string;
    /** Default: 'failed' */
    failed?: string;
    /** Default: 'timeout' — used by markStaleRecords() */
    timeout?: string;
  };

  /**
   * Indexes to create on init(), in addition to the auto-created unique index on primaryKey.
   * Format: same as xronox-store's IndexSpec.
   */
  indexes?: Array<{
    keys: Record<string, 1 | -1>;
    options?: { unique?: boolean; sparse?: boolean };
  }>;

  /** Tombstone field for `purgeOldRecords()`; wired to xronox-store `visibility` when Activix creates the store. Default: `purgedAt` */
  purgeAtField?: string;
  /** Visibility mode for that field (xronox-store). Default: `hiddenIfNonNull`. */
  purgeVisibilityMode?: 'hiddenIfNonNull' | 'hiddenIfTruthy';

  /**
   * Per-collection cache config for xronox-store.
   * When Activix creates the store, resolved with xronox-store `resolveCacheConfig` (defaults `maxSize` 10000, `ttlMs` 0).
   */
  cache?: {
    maxSize?: number;
    ttlMs?: number;
  };
}
```

### 5.2 Resolved collection config (internal)

```typescript
// All fields required after resolution — not exported
interface ResolvedCollectionConfig {
  name: string;
  role: string;               // informational; Activix keys collections by `name` in v2
  primaryKey: string;         // default: 'activityId'
  primaryKeyPrefix: string;   // default: 'act-'
  runContextField: string;      // default: 'runContext'
  statusField: string;        // default: 'status'
  startTimeField: string;     // default: 'startTime'
  endTimeField: string;       // default: 'endTime'
  durationField: string;      // default: 'duration'
  statusValues: {
    started: string;          // default: 'started'
    completed: string;        // default: 'completed'
    failed: string;           // default: 'failed'
    timeout: string;          // default: 'timeout'
  };
}
```

### 5.3 `ActivixOptions`

```typescript
export interface ActivixOptions {
  /**
   * Pre-built XronoxStore instance (preferred path).
   * If provided, activix will call store.init() during its own init() unless skipStoreInit is true.
   */
  store?: import('@x12i/xronox-store').XronoxStore;

  /**
   * If store is provided and has already been initialized externally, set this to true
   * to skip calling store.init() again.
   */
  skipStoreInit?: boolean;

  /**
   * Connection options — used only when `store` is NOT provided.
   * Activix creates its own XronoxStore internally.
   */
  mongoUri?: string;
  mongoDb?: never; // disallowed in constructor; DB is env-resolved (ACTIVIX_DB_NAME -> MONGO_AI_LOGS_DB -> MONGO_LOGS_DB -> MONGO_DB -> "activitix")
  mongoRole?: string;
  xronox?: import('@x12i/xronox').Xronox;  // pass raw xronox, store created internally

  /** Collection definitions */
  collections: ActivixCollectionConfig[];

  /**
   * Which collection name is used when no collection is specified in method calls.
   * Required when using `collections`; must match a configured `name`.
   */
  defaultCollection: string;

  /**
   * Default TTL for stale record detection (ms).
   * Used by markStaleRecords() when no ttlMs is passed per-call.
   * Default: 300000 (5 minutes)
   */
  staleRecordTTL?: number;

  /**
   * Error handling config, passed through to XronoxStore.
   * Used only when `store` is NOT provided.
   */
  errorHandling?: {
    onConnectionError?: 'throw' | 'queue' | 'silent';
    onPersistError?: 'throw' | 'queue' | 'silent';
    retry?: { maxRetries?: number; retryDelay?: number; exponentialBackoff?: boolean };
    queue?: { maxSize?: number; flushInterval?: number };
  };

  /**
   * Optional logger. Must implement { debug, info, warn, error }.
   * Defaults to console if not provided.
   */
  logger?: {
    debug(msg: string, meta?: Record<string, unknown>): void;
    info(msg: string, meta?: Record<string, unknown>): void;
    warn(msg: string, meta?: Record<string, unknown>): void;
    error(msg: string, meta?: Record<string, unknown>): void;
  };
}
```

### 5.4 `StartRecordResult<T>`

```typescript
export interface StartRecordResult<T extends Record<string, unknown> = Record<string, unknown>> {
  /** Primary key string (default field `activityId`; same as `record[primaryKey]`). */
  activityId: string;
  /** @deprecated Same as `activityId` (v1 migration). */
  recordId: string;
  /** Full record including `runContext`, status, startTime, primary key field. */
  record: T;
}
```

### 5.5 `MarkStaleOptions`

```typescript
export interface MarkStaleOptions {
  /** Which collection role to target. Defaults to defaultCollection. */
  collection?: string;
  /** TTL in ms. Records started more than this long ago are stale. Defaults to staleRecordTTL from options. */
  ttlMs?: number;
}
```

---

## 6. `Activix` Class (`src/Activix.ts`)

### 6.1 Class Fields

```typescript
export class Activix {
  private store: XronoxStore;
  private ownedStore: boolean;               // true if we created the store internally
  private collections: Map<string, ResolvedCollectionConfig>;
  private defaultCollectionRole: string;
  private staleRecordTTL: number;
  private logger: ResolvedLogger;
  private initialized: boolean;
  private initPromise: Promise<void> | null; // deduplicate concurrent init() calls
}
```

### 6.2 Constructor

```typescript
constructor(options: ActivixOptions)
```

**Steps:**
1. Resolve all `ActivixCollectionConfig` entries into `ResolvedCollectionConfig` (fill in all defaults)
2. Build `this.collections` map keyed by `role` (or `name` if role not set)
3. Resolve default collection name: for **`collections`** mode, **`options.defaultCollection`** is required and must match a configured collection; for single **`collection`** mode, the default is that collection's name
4. If `options.store` is provided: use it, set `ownedStore = false`
5. If NOT provided: create a `new XronoxStore(...)` internally from `mongoUri` + env-resolved DB + `mongoRole/xronox`, set `ownedStore = true`
   - Map each `ActivixCollectionConfig` to a `CollectionDefinition` (pass resolved `name`, `primaryKey`, `primaryKeyPrefix`, `indexes`, `cache: { ...activixDefaults, ...user }`)
6. Set `staleRecordTTL = options.staleRecordTTL ?? 300000`
7. Do NOT call `init()` from constructor — keep it explicit

### 6.3 `init(): Promise<void>`

```typescript
async init(): Promise<void>
```

- Deduplicate via `initPromise` (same pattern as `ActivityTracker.init()` in the current codebase, lines 1967-2030)
- If `options.skipStoreInit` is true: skip `store.init()`, set `initialized = true`
- Otherwise: call `await this.store.init()`
- Set `initialized = true`

### 6.4 `startRecord<T>(data?, options?): Promise<StartRecordResult<T>>`

```typescript
async startRecord<T extends Record<string, unknown> = Record<string, unknown>>(
  data?: Partial<T>,
  options?: { collection?: string }
): Promise<StartRecordResult<T>>
```

**Behavior:**
1. Resolve collection config from `options.collection` or default collection name
2. Copy `data`; strip integrator-controlled identity/lifecycle fields from that copy
3. Build `runContext`: plain object from `data[runContextField]` (or `{}`); set `sessionId` from nested field if non-empty, else from top-level `sessionId` if non-empty; if still missing, **warn** and leave `sessionId` unset (no generation)
4. Build the record:
   ```typescript
   const record = {
     ...sanitizedData,
     [config.runContextField]: runContext,
     [config.statusField]: config.statusValues.started,
     [config.startTimeField]: Date.now(),
   };
   ```
5. **`validateActivityStructure(record)`** — requires root **`outer`** (`input`, `output`, `metadata`); optional **`inner`** (`request`, `response`, `metadata`)
6. Generate `keyValue = primaryKeyPrefix + uuid` (or plain uuid when prefix is empty), then set both:
   - `record[config.primaryKey] = keyValue`
   - `record.activityId = keyValue`
7. Call `await this.store.collection(config.name).insert(record)`
8. Let `id` = returned key string. Return `{ activityId: id, recordId: id, record: { ...record, [config.primaryKey]: id, activityId: id } as T }`

**Key point:** Activix owns activity identity generation at the persistence boundary. `xronox-store` remains generic and persists whatever configured primary key field it is given.

### 6.5 `completeRecord<T>(id, updates?, options?): Promise<T>`

```typescript
async completeRecord<T extends Record<string, unknown> = Record<string, unknown>>(
  id: string,
  updates?: Partial<T>,
  options?: { collection?: string }
): Promise<T>
```

**Behavior:**
1. Resolve collection config
2. Strip nullish **primary key** from `updates` if present
3. Fetch current record from store: `await this.store.collection(config.name).getByKey(id)` (cache-first)
4. If not found: throw `Error('Record not found: ' + id)`
5. Calculate timing from cleaned `updates` and `existing`
6. Build updated record (merge existing, cleaned updates, completed status, endTime, duration)
7. Call `await this.store.collection(config.name).update(id, updated)`
8. Return `updated as T`

### 6.6 `failRecord<T>(id, error, updates?, options?): Promise<T>`

```typescript
async failRecord<T extends Record<string, unknown> = Record<string, unknown>>(
  id: string,
  error: string | Error,
  updates?: Partial<T>,
  options?: { collection?: string; upsertIfMissing?: boolean }
): Promise<T>
```

**Behavior:**
1. Resolve collection config
2. Strip nullish primary key from `updates` when merging
3. Attempt to fetch current record: `await this.store.collection(config.name).getByKey(id)`
4. If not found AND `options.upsertIfMissing !== false` (default: allow upsert on fail):
   - Build record from cleaned `updates`, **`runContext`** (same rules as `startRecord`), primary key = `id`, status failed, timestamps, `error` message
   - **Insert** (not update)
5. If not found AND `upsertIfMissing === false`: throw
6. If found: merge like `completeRecord` but with failed status and `error` field
7. Call `update` or `insert` accordingly
8. Return result

**Note on `upsertIfMissing`:** This mirrors the current `logFailure()` behavior in `ActivityTracker.ts` (line 2460), which always passes `upsertIfMissing: true`. The rationale: if phase 1 was silently dropped (DB hiccup), the failure should still be recorded. Default behavior in activix is the same — allow upsert on fail.

### 6.7 `patchRecord<T>(id, fields, options?): Promise<void>`

```typescript
async patchRecord<T extends Record<string, unknown> = Record<string, unknown>>(
  id: string,
  fields: Partial<T>,
  options?: { collection?: string }
): Promise<void>
```

**Behavior:**
- Resolves collection config
- Strips nullish primary key and the configured tombstone field (`purgeAtField`) from `fields`; if nothing left, returns without calling the store
- Calls `await this.store.collection(config.name).patchByKey(id, fields)`
- On missing record, logs warning with meta `{ id, collection }` (does not throw)

**Covers the `updateContractOutput` use case** from the current codebase. In activix, the consumer calls:
```typescript
await activix.patchRecord(id, { contractOutput: {...}, contractStatus: 'ok' });
```
No knowledge of the field names is needed by activix itself.

### 6.8 `getRecord<T>(id, options?): Promise<T | null>`

```typescript
async getRecord<T extends Record<string, unknown> = Record<string, unknown>>(
  id: string,
  options?: { collection?: string }
): Promise<T | null>
```

- Resolves collection config
- Calls `await this.store.collection(config.name).getByKey(id)` (cache-first; tombstones hidden when the collection defines xronox-store `visibility`, which Activix sets when it builds the store)
- Returns `result as T | null`

### 6.9 `findRecords<T>(filter, options?): Promise<T[]>`

```typescript
async findRecords<T extends Record<string, unknown> = Record<string, unknown>>(
  filter: Record<string, unknown>,
  options?: {
    collection?: string;
    limit?: number;
    sort?: Record<string, 1 | -1>;
    mergeCache?: boolean;
    includeHidden?: boolean;
  }
): Promise<T[]>
```

- Resolves collection config
- Calls `readMany` with the same options (xronox-store ≥ 1.2). Tombstone filtering is applied by the store when `visibility` is configured.
- By default **`mergeCache`** is off — pass **`mergeCache: true`** to union matching in-memory PK entries with DB rows.
- Use **`getRecord(id)`** after a write if you only need one key’s latest in-process document without enabling **`mergeCache`** on a query.

### 6.10 `markStaleRecords(options?): Promise<number>`

```typescript
async markStaleRecords(options?: MarkStaleOptions): Promise<number>
```

**Behavior (covers `updateStaleActivities` use case from the current codebase):**

1. Resolve collection config from `options?.collection` or `defaultCollectionRole`
2. Calculate threshold:
   ```typescript
   const ttlMs = options?.ttlMs ?? this.staleRecordTTL;
   const threshold = Date.now() - ttlMs;
   ```
3. Build the filter using the collection's configured field names:
   ```typescript
   const filter = {
     [config.statusField]: config.statusValues.started,
     [config.startTimeField]: { $lt: threshold },
   };
   ```
4. Call `await this.store.collection(config.name).updateMany(filter, { [config.statusField]: config.statusValues.timeout })`
5. Return the count returned by `updateMany()`

**Why this is fully generic:** The filter is built from configured field names and configured status value strings. The store knows nothing about "stale" or "started" or "timeout" — it just sees a filter and a `$set` equivalent.

### 6.11 `generateRecordId(): string`

```typescript
generateRecordId(): string
```

Generates a UUID (same implementation as `ActivityTracker.generateActivityId()` in the current codebase). Used internally by `startRecord()` only when passing a pre-set primary key value.

In practice, `insert()` in `xronox-store` handles ID generation — this method exists as a convenience for consumers who want to generate an ID upfront (e.g., to correlate with an external system before calling `startRecord()`).

Implementation:
```typescript
generateRecordId(): string {
  const g = globalThis as any;
  if (g.crypto?.randomUUID && typeof g.crypto.randomUUID === 'function') {
    return g.crypto.randomUUID();
  }
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
```

### 6.12 `isConnected(): boolean`

```typescript
isConnected(): boolean
```

Delegates to `this.store.isConnected()`.

### 6.13 `close(): Promise<void>`

```typescript
async close(): Promise<void>
```

- If `ownedStore === true`: call `await this.store.close()`
- If `ownedStore === false`: do nothing (the caller owns the store lifecycle)

---

## 7. Collection Resolution

### `_resolveCollection(role?: string): ResolvedCollectionConfig`

Internal method used by all public methods:

1. If `role` is provided: look up in `this.collections` by `role` key → throw if not found
2. If not provided: use `this.defaultCollectionRole` → look up → throw if not configured

This is the generic equivalent of `getDatabaseManager()` in `ActivityTracker.ts` (lines 2121-2153), but simpler — no activityType routing, just an explicit collection name/role.

---

## 8. What to Copy From This Project

Reference files: [`src/ActivityTracker.ts`](../src/ActivityTracker.ts) and [`src/DatabaseManager.ts`](../src/DatabaseManager.ts)

### Copy and adapt

| Source location | What it does | How to adapt for activix |
|----------------|--------------|---------------------------|
| `ActivityTracker.ts` lines 2072-2087 | UUID generation (`defaultJobIdGenerator`) | Use as `generateRecordId()`. Remove the `jobId` naming, rename to `generateRecordId`. |
| `ActivityTracker.ts` lines 1967-2030 | `init()` deduplication via `initPromise` | Copy the pattern exactly for `Activix.init()`. Replace `this.dbManager.init()` with `this.store.init()`. Remove the multi-manager parallel init. |
| `ActivityTracker.ts` lines 2179-2260 | `logActivity()` — phase 1 | Becomes `startRecord()`. Strip all AI-specific normalization (`normalizeMetadata`, `metadataToDocument`). Keep: auto-generate ID if missing, set status + startTime, call insert, return result. |
| `ActivityTracker.ts` lines 2296-2390 | `updateActivity()` — phase 2 | Becomes `completeRecord()` / `failRecord()`. Strip all AI-specific field mapping. Keep: fetch from cache/store, calculate duration, set status + endTime + duration, call update. |
| `ActivityTracker.ts` lines 2460 | `logFailure()` upsertIfMissing logic | Copy the default-to-upsert behavior into `failRecord()`. |
| `DatabaseManager.ts` lines 1618-1669 | `findStaleActivities()` | Becomes the filter logic inside `markStaleRecords()`. Replace hardcoded `'started'` and `startTime` with config values. Replace `{ $lt: threshold }` filter structure — keep as-is. |
| `ActivityTracker.ts` lines 3036-3112 | `updateContractOutput()` | Becomes `patchRecord()`. Strip the contract-specific field names. The merge logic simplifies greatly because `patchByKey()` in xronox-store handles it all — `patchRecord()` is just a thin delegation. |
| `ActivityTracker.ts` lines 2121-2153 | `getDatabaseManager()` routing | Becomes `_resolveCollection()`. Remove activityType-based routing. Keep: lookup by name/role, throw if not found. |
| `DatabaseManager.ts` lines 405-475 | `_connect()` — Xronox config building | Move to xronox-store. activix does NOT deal with Xronox directly. |

### Do NOT copy

| File | What to skip | Why |
|------|-------------|-----|
| `ActivityTracker.ts` | `normalizeMetadata()` | AI-domain field mapping — not needed |
| `ActivityTracker.ts` | `logGraphStart/Complete/Fail`, `logNodeStart/Complete/Fail` | Graph/node-specific lifecycle — not in activix |
| `ActivityTracker.ts` | `logBadRequest()` | AI-specific bad request handling |
| `ActivityTracker.ts` | ERC auto-configuration in constructor (lines 1503-1557) | ERC is infrastructure of this project |
| `ActivityTracker.ts` | `startActivity()`, `logSuccess()`, `logFailure()` names | Rename: `startRecord()`, `completeRecord()`, `failRecord()` |
| `DatabaseManager.ts` | `metadataToDocument()` / `_documentToMetadata()` | Schema mapping — not needed (store is generic) |
| `DatabaseManager.ts` | `_ensureIndexes()` with hardcoded AI field indexes | Move to xronox-store; consumer provides index specs |
| `DatabaseManager.ts` | Retry queue / flusher | Moved to xronox-store entirely |
| `types.ts` | `ActivityMetadata`, `ActivityDocument`, all AI types | Domain-specific — not in activix |
| `src/esm-loader.ts` | Xronox and nx-config2 loaders | Moved to xronox-store |
| `src/taskOutput.ts` | MD5, `calculateTaskTypeId`, etc. | AI-domain utilities |
| `src/nx-config2-wrapper.ts` | Config wrapper | Moved to xronox-store |

---

## 9. Building `activix` Inside This Project

`activix` is developed inside this project's repo (temporarily) in a subfolder, then extracted.

### Directory layout while in this project

```
ai-activities-tracking/
├── src/                          ← existing, untouched
├── activix/                    ← NEW subfolder
│   ├── src/
│   │   ├── Activix.ts
│   │   ├── types.ts
│   │   └── index.ts
│   ├── .tests/
│   │   ├── activix.test.ts
│   │   ├── activix-patch.test.ts
│   │   ├── activix-stale.test.ts
│   │   └── tsconfig.json
│   ├── package.json              ← points to @x12i/xronox-store
│   ├── tsconfig.json
│   ├── tsconfig.cjs.json
│   └── .env.example
├── docs/
│   └── specs/
│       ├── xronox-store.spec.md  ← this is that file's sibling
│       └── activix.spec.md     ← this file
└── ...existing files...
```

### Installation step before building

After `@x12i/xronox-store` is published:

```bash
cd activix
npm install
# This installs @x12i/xronox-store from the registry
```

The `activix/package.json` lists `@x12i/xronox-store: "^1.0.0"` as its only production dependency.

---

## 10. Tests (`.tests/`)

Tests run against the same MongoDB instance as the parent project, using the same `.env` file (relative path lookup `../../.env` from the test directory).

### Test collection names — isolated from all other collections

```
activix-test-records
activix-test-events
activix-test-jobs
```

These names must not be used by anything else in the system during development.

### Test file structure

**`activix.test.ts` — Core lifecycle tests**

```typescript
// Setup: create Activix with two test collections
// Use MONGO_URI from .env. Database name is env-resolved by Activix
// (ACTIVIX_DB_NAME, then legacy fallbacks, else "activitix").

const ax = new Activix({
  mongoUri: process.env.MONGO_URI,
  collections: [
    {
      name: 'activix-test-records',
      statusField: 'status',
      startTimeField: 'startTime',
      endTimeField: 'endTime',
      durationField: 'duration',
      statusValues: { started: 'started', completed: 'completed', failed: 'failed', timeout: 'timeout' },
      indexes: [{ keys: { 'runContext.jobId': 1 } }, { keys: { status: 1 } }],
    },
  ],
  defaultCollection: 'activix-test-records',
});
await ax.init();
```

| Test case | What it verifies |
|-----------|-----------------|
| `startRecord()` returns `{ activityId, recordId, record }` | `activityId === recordId`, record has `runContext.sessionId`, status='started', startTime |
| `startRecord()` then `getRecord(activityId)` | Returns record from cache (no DB hit) |
| `completeRecord()` updates status, sets endTime and duration | All three fields correct in DB |
| `failRecord()` updates status to 'failed', includes error field | Error message present |
| `failRecord()` with upsertIfMissing on unknown id | New doc created with status='failed' and `runContext` |
| Phase 2 uses cached phase 1 data (no DB read) | Verify by tracking DB reads (spy/mock) |
| `getRecord()` on cache miss | Fetches from DB correctly |
| `findRecords()` by filter | Returns correct subset |
| Two collections are isolated | Insert to collection A not visible from collection B |
| Custom status values | Config `statusValues.completed = 'done'` → DB stores `'done'` |
| Custom field names | Config `statusField = 'state'` → DB stores `'state'` field not `'status'` |
| `isConnected()` after init | Returns true |
| `close()` drains in-flight | All started records are in DB after close |

**`activix-patch.test.ts` — Patch / post-hoc update tests**

| Test case | What it verifies |
|-----------|-----------------|
| `patchRecord()` on existing record | Only specified fields changed, rest preserved |
| `patchRecord()` on cached record | No DB read performed (verify via spy) |
| `patchRecord()` on uncached record (cache miss) | Fetches from DB, merges, writes back correctly |
| `patchRecord()` on record that doesn't exist | Does not throw, logs warning |
| Multiple patches on same record | Each patch accumulates — final state has all patched fields |

**`activix-stale.test.ts` — Stale record detection tests**

| Test case | What it verifies |
|-----------|-----------------|
| `markStaleRecords()` updates stale records | Records with status='started' and startTime < threshold → status='timeout' |
| `markStaleRecords()` does not touch completed records | status='completed' records untouched |
| `markStaleRecords()` returns correct count | Count matches number of stale records |
| `markStaleRecords()` with custom ttlMs | Threshold calculated correctly |
| `markStaleRecords()` with custom status values | Reads correct status field and writes correct timeout value |

---

## 11. Extraction to Standalone Repo

Once all tests pass in this project, extract `activix/` to its own repository:

1. Create a new repo: `xronoces/activix`
2. Move the `activix/` subfolder contents to the repo root
3. Update relative `.env` path in tests to just `.env` (copy `.env` to the new repo or use env vars)
4. Set up GitHub Actions CI to run tests on push
5. Publish to `https://registry.npmjs.org` as `@x12i/activix`

The parent project (`ai-activities-tracking`) is **not modified** during this process.

---

## 12. Migration Path — Replacing `ai-activities-tracking`

### Phase A — Independent new use cases (zero risk)

Deploy `activix` on features that don't exist in `ai-activities-tracking`:
- New event types
- New services that were not previously tracked
- `activix` writes to `activix-events-v1` etc. — completely separate collections
- `ai-activities-tracking` continues running unchanged

### Phase B — Parallel run on same data, different collections

For the same operations that `ai-activities-tracking` currently tracks:
- Consumer code calls BOTH packages (or a thin adapter calls both)
- `ai-activities-tracking` writes to `cognitive-activities`
- `activix` writes to `cognitive-activities-v2`
- Compare both collections: field parity check, status accuracy, timing accuracy
- Run this for a verification period (1-2 weeks of production traffic)

### Phase C — Cut over

- Consumer code switches to `activix` only
- `activix` writes to the original collection names (`cognitive-activities` etc.)
- `ai-activities-tracking` package is removed from the consumer's dependencies
- The `ai-activities-tracking` repo is tagged as frozen (`v4.x-final`) and archived

### Rollback at any phase

- Phase A → remove activix, no impact on existing system
- Phase B → stop the parallel write, no impact on existing collection
- Phase C → keep `ai-activities-tracking` in the dependency graph until confidence is established; cut over is just a config change in the consumer

---

## 13. Usage Example

### Simple usage with all defaults

```typescript
import { Activix } from '@x12i/activix';

const ax = new Activix({
  mongoUri: process.env.MONGO_URI,
  collections: [
    { name: 'my-jobs' }, // defaults: activityId, act-, runContext, status, startTime, endTime, duration
  ],
  defaultCollection: 'my-jobs',
});

await ax.init();

// Phase 1
const { activityId, record, recordId } = await ax.startRecord({
  jobType: 'image-resize',
  inputFile: 's3://bucket/img.png',
});
// recordId === activityId (deprecated alias)
// record = { activityId: 'act-...', runContext: { sessionId: '...' }, status: 'started', startTime: ..., ... }

// ... do the work ...

// Phase 2 — success
const completed = await ax.completeRecord(activityId, { outputFile: 's3://bucket/img-small.png' });
// completed = { ..., status: 'completed', endTime: 1710000003500, duration: 3500, outputFile: '...' }

// Phase 2 — failure
const failed = await ax.failRecord(activityId, new Error('S3 permission denied'));
// failed = { ..., status: 'failed', error: 'S3 permission denied', endTime: ..., duration: ... }

// Post-hoc patch
await ax.patchRecord(activityId, { reviewedBy: 'admin', reviewNote: 'looks good' });

// Look up
const job = await ax.getRecord(activityId);

// Find recent failures
const failures = await ax.findRecords(
  { status: 'failed' },
  { limit: 50, sort: { startTime: -1 } }
);

// Mark stale
const count = await ax.markStaleRecords({ ttlMs: 600000 }); // 10 minute TTL
console.log(`Marked ${count} timed-out records`);

await ax.close();
```

### With pre-built XronoxStore (for sharing a connection)

```typescript
import { XronoxStore } from '@x12i/xronox-store';
import { Activix } from '@x12i/activix';

const store = new XronoxStore({
  mongoUri: process.env.MONGO_URI,
  mongoDb: process.env.MONGO_DB,
  collections: [
    { name: 'my-jobs', indexes: [{ keys: { status: 1 } }] },
    { name: 'my-errors', primaryKey: 'errorId', primaryKeyPrefix: 'err-', indexes: [{ keys: { 'runContext.jobId': 1 } }] },
  ],
});
await store.init();

const ax = new Activix({
  store,
  skipStoreInit: true,
  collections: [
    { name: 'my-jobs' },
    { name: 'my-errors' },
  ],
  defaultCollection: 'my-jobs',
});

// ax.init() is still called but won't re-init the store
await ax.init();

const { activityId } = await ax.startRecord({ type: 'import' });
await ax.startRecord({ type: 'error-log' }, { collection: 'my-errors' });
```

### With custom field names and status values

```typescript
const ax = new Activix({
  mongoUri: process.env.MONGO_URI,
  collections: [{
    name: 'pipeline-runs',
    primaryKey: 'runId',
    primaryKeyPrefix: 'run-',
    statusField: 'state',
    startTimeField: 'startedAt',
    endTimeField: 'finishedAt',
    durationField: 'elapsedMs',
    statusValues: {
      started: 'running',
      completed: 'success',
      failed: 'error',
      timeout: 'timed-out',
    },
  }],
  defaultCollection: 'pipeline-runs',
});

await ax.init();

// DB doc will have: { runId: 'run-abc', runContext: { sessionId: '...' }, state: 'running', startedAt: 1710000000000, ... }
const { activityId } = await ax.startRecord({ pipelineName: 'etl-daily' });
// activityId holds the run id string (same as record.runId)
```
