runner-helper-audit-core-vs-claim

activetype/doctech/nestjstech/typeormdomain/anabatic

RunnerHelper audit — core-backend-new vs backend-claim-dev

Scope: Compare RunnerHelper in Project Manager backend against the production Claim backend reference.
Date: 2026-05-20
Repos / paths:

RolePath
Subject (PM)Project Manager/backend-pms-dev/src/helpers/runner.helper.ts
Reference (Claim)backend-claim-dev/src/helpers/runner.helper.ts

Verdict: PM runner.helper.ts is an older fork (~1595 lines). Same API surface and structure as Claim, but behind on three production fixes Claim already ships. PM barely uses RunnerHelper today (runnerUAM in auth.service.ts, no newSaveHelper / offsetClause usage yet) — gaps are latent until PM features land.


Table of contents


Executive summary

AreaPM coreClaim reference
Refactored WHERE (applyWhereClause)YesYes
offsetClauseBroken (limit instead of offset)Fixed
newSaveHelper graph savesNoYes
newSaveHelper safe deleteNoYes
newSaveHelper parse fallbackNoYes

Critical — fix before paginated lists

offsetClause calls limit() instead of offset()

PM (wrong): selectQueryBuilder and selectQueryBuilderRaw use model.limit(offsetClause.offsetNumber).

Claim (correct): model.offset(offsetClause.offsetNumber).

Effect: offsetClause: { offsetNumber: 20 } sets LIMIT 20, not OFFSET 20. Page 2+ lists are wrong or empty.

Fix: Port Claim’s model.offset() in both methods (two-line change per method).


High — port before multi-entity writes

newSaveHelper missing graph mode

Claim adds graph?: boolean and saves parent+relations via queryRunner.manager.save(dataInsert) in one transaction. Used in Claim for payment request create (parent + items + nominatif), chat (conversation + participants), and mixed update batches.

PM only does getRepository(entity).create(dataInsert) → batch save. Nested relations on entity instances are not handled the same way.

newSaveHelper delete path is brittle

Claim: repo.create(plainRow) then remove(entityInstances).

PM: remove(dataInsert) directly — plain { id: N } objects from services often fail or no-op.

newSaveHelper error handling weaker

Claim: try/catch around JSON.parse(error.message) with fallback to raw message.

PM: bare JSON.parse → secondary crash (SyntaxError) when error is not SQL-wrapped JSON.

Empty readyRepo save

Claim only calls manager.save(readyRepo) when readyRepo.length > 0. PM always saves (including empty array).


Shared legacy debt (both codebases)

IssueRisk
commit param ignored in insertHelper, updateHelpercommit=false still commits
Returns HttpException instances from helpersCallers may treat as data
insertHelper uses new Promise(async …)Rejection edge cases
secondLevelInsertTransaction child inserts not awaitedPartial writes
upsert* + convertString string WHERESQL injection if values user-controlled
upsertBulkFunction select+update in loopN+1
selectRawHelper raw SQL stringsInjection if not parameterized
retrunValue typoPermanent API quirk
selectQueryBuilderRaw inline WHERE (not applyWhereClause)Nested WHERE bugs

Also: PM function.helper.ts lacks Claim’s ilikeBit on setWhereClause (minor, port when adding search).


Parity matrix

CapabilityPM coreClaim
applyWhereClause / handleSubWhereYesYes
getCount, getQuery, lock, CUSTOM selectYesYes
offsetClauseBrokenFixed
newSaveHelper graphNoYes
newSaveHelper safe deleteNoYes
newSaveHelper parse fallbackNoYes

Recommended port order

  1. Offset fixselectQueryBuilder + selectQueryBuilderRaw.
  2. Replace newSaveHelper body with Claim’s version (graph + safe delete + empty-save guard + catch).
  3. Optional: ilikeBit in function.helper.ts.
  4. Do not blind-merge entire file — substantive diff is ~100 lines.

Longer term (both): stop returning HttpException from helpers; honor commit; replace convertString upserts with parameterized setWhereClause + batch updates.


Deep dive: newSaveHelper

newSaveHelper runs one transaction over an array of operations: insert/update via save, or delete via remove.

Mental model

Each entry in the data array:

{
  entity: SomeEntity,      // used in plain mode only
  dataInsert: payload,     // plain rows, entity instances, or nested graph
  delete: boolean,
  is_data_array: boolean,
  graph?: boolean,         // claim only
}

Claim routes each step:

  1. graph: truemanager.save(dataInsert) (TypeORM cascade)
  2. delete: truerepo.create(...) then remove(...)
  3. else → repo.create(...) → batch readyRepo → one save(readyRepo)

PM core only has (2) without create on delete, and (3) without graph.


1. Graph saves (graph: true)

What Claim does

When graph: true, Claim skips getRepository(dt.entity).create(dt.dataInsert) and calls:

await queryRunner.manager.save(dt.dataInsert);

dataInsert is a tree of entity instances with nested relations (parent.items = [...], etc.). TypeORM walks the graph per entity cascade options.

entity is documentation; the graph path does not use it for create().

Claim example: create payment request

Build instances, wire relations, one graph save:

const paymentRequest = paymentRequestRepo.create(paymentRequestData);
paymentRequest.items = itemsEntities;
paymentRequest.approval_steps = approvalStepsEntities;

await this.runner.newSaveHelper(
  [{
    entity: PaymentRequestEntity,
    dataInsert: paymentRequest,
    delete: false,
    is_data_array: false,
    graph: true,
  }],
  true,
  true,
);

Why graph: One parent + many children + nested nominatif. Plain mode create() on a POJO does not persist nested relation properties the same way.

Mixed batch (Claim update)

saveOperations = [
  { entity: PaymentRequestItem, dataInsert: itemWithNominatif, graph: true, delete: false, ... },
  { entity: ApprovalStep, dataInsert: steps[], graph: false, is_data_array: true, delete: false },
  { entity: PaymentRequestItem, dataInsert: toDelete[], delete: true, is_data_array: true },
];
await this.runner.newSaveHelper(saveOperations, true, true);

Return when retrunValue: true: [...graphResults, ...savedPlain].

PM core — same intent without graph

const project = projectRepo.create({ name: 'Alpha' });
project.tasks = [taskRepo.create({ title: 'A' }), taskRepo.create({ title: 'B' })];

await runner.newSaveHelper([{
  entity: Project,
  dataInsert: project,
  delete: false,
  is_data_array: false,
  // graph: true  // claim only
}], true, true);
ClaimPM core
.tasks already entity instancesgraph: true → parent + tasks + FKsOften parent only
3-level nestOne graph save (if cascades set)Manual insert order or multiple helpers
Return valueFull saved graphOften incomplete tree

PM workaround until port: Multiple insertHelper calls in FK order, or port Claim’s graph branch.

Other Claim usages: chat.service.ts — conversation + participants with graph: true.


2. Safe delete (delete: true)

Claim

const repo = queryRunner.manager.getRepository(dt.entity);
const toRemove = dt.is_data_array
  ? (Array.isArray(dt.dataInsert) ? dt.dataInsert : [dt.dataInsert])
      .map((d) => repo.create(d))
  : repo.create(dt.dataInsert);
await queryRunner.manager.remove(Array.isArray(toRemove) ? toRemove : [toRemove]);

remove() needs entity metadata; repo.create({ id }) attaches it without inserting.

PM core

await queryRunner.manager.remove(dt.dataInsert);

Example: delete attachment by id

await this.runner.newSaveHelper([{
  entity: PaymentRequestAttachment,
  dataInsert: { attachment_id: attachmentId },
  delete: true,
  is_data_array: false,
}], true, true);
ClaimPM core
{ attachment_id: 123 }Reliable removeFragile
Plain rows from querycreate normalizesMay no-op / throw
Batch toDelete[] (10 rows)map(repo.create) + remove([...])Same risk × N

Example: update PR — remove line items

{
  entity: PaymentRequestItemEntity,
  dataInsert: itemsCtx.toDelete,
  delete: true,
  is_data_array: true,
}

If toDelete is minimal { payment_request_item_id } stubs, PM core is the risky path.

PM until port: Use deleteHelper(entity, { id: In(ids) }) or load full entities before remove.


3. Parse fallback (error handling in catch)

Normal path (both)

DB errors in .catch are rethrown as:

throw new Error(JSON.stringify({
  sql: err.query,
  sqlMessage: err.driverError.message,
  parameters: err.parameters,
  message: err.message,
}));

Outer catch should parse and call httpExceptionHelper.

Claim

} catch (error) {
  await queryRunner.rollbackTransaction();
  let parsed;
  try {
    parsed = JSON.parse(error?.message ?? '');
  } catch (_e) {
    this.httpExceptionService.httpExceptionHelper({
      sql: '',
      sqlMessage: error?.message,
      parameters: '',
      message: error?.message,
      status: error?.status,
    });
    return;
  }
  this.httpExceptionService.httpExceptionHelper({ /* parsed sql fields */ });
}

Non-JSON errors still become a proper HttpException with the real message.

PM core

} catch (error) {
  await queryRunner.rollbackTransaction();
  let err = JSON.parse(error.message);
  this.httpExceptionService.httpExceptionHelper({ ... });
}

JSON.parse throws → masks original error with SyntaxError: Unexpected token....

When each path fires

Error sourceerror.messageClaimPM core
Postgres FK violation in saveJSON from .catchParsed → sqlMessage in APISame
throw new Error('Parent project not found') inside tryPlain EnglishReadable API errorParse crash
graph: true but dataInsert undefinedTypeErrorReadableParse crash
Validation throw inside helper loopPlainReadableParse crash

Combined transactional example

Scenario: Update project, add 2 tasks, remove 1 task, one transaction.

const saveOps = [
  { entity: Task, dataInsert: removedTaskRows, delete: true, is_data_array: true },
  { entity: Task, dataInsert: newTaskEntities, delete: false, is_data_array: true, graph: false },
  { entity: Project, dataInsert: projectEntity, delete: false, is_data_array: false, graph: false },
];
await runner.newSaveHelper(saveOps, true, true);
StepClaimPM core
Delete old taskrepo.createremoveremove(plain) — risky
Insert new tasksBatch saveSame
Update projectBatch saveSame
CHECK constraint on nameSQL JSON → nice errorSQL JSON → nice error
Malformed removedTaskRowsReadable non-JSON errorOften parse error

Usage guidance for PM

When wiring PM services (aligned with Claim backend rules):

  • Reads: selectQueryBuilder + helper.setWhereClause(), returnValue: true, getOne / getCount as needed.
  • Multi-mutation: single newSaveHelper with graph: true for parent+children after port.
  • Never call this.runner.* inside loops; batch IDs then one helper call.
  • Soft delete: filter is_active in whereClause and join clauses.

Related

  • Claim production patterns: .cursor/rules/backend.mdc, .cursor/skills/runner-helper*
  • PM ERD: Project Manager/ERD.md (repo checkout, not vault)

Source: Cursor audit session 2026-05-20 (PM core-backend-new vs backend-claim-dev).