core-backend-new vs backend-claim-devScope: Compare RunnerHelper in Project Manager backend against the production Claim backend reference.
Date: 2026-05-20
Repos / paths:
| Role | Path |
|---|---|
| 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.
newSaveHelper
| Area | PM core | Claim reference |
|---|---|---|
Refactored WHERE (applyWhereClause) | Yes | Yes |
offsetClause | Broken (limit instead of offset) | Fixed |
newSaveHelper graph saves | No | Yes |
newSaveHelper safe delete | No | Yes |
newSaveHelper parse fallback | No | Yes |
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).
newSaveHelper missing graph modeClaim 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 brittleClaim: repo.create(plainRow) then remove(entityInstances).
PM: remove(dataInsert) directly — plain { id: N } objects from services often fail or no-op.
newSaveHelper error handling weakerClaim: 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.
readyRepo saveClaim only calls manager.save(readyRepo) when readyRepo.length > 0. PM always saves (including empty array).
| Issue | Risk |
|---|---|
commit param ignored in insertHelper, updateHelper | commit=false still commits |
Returns HttpException instances from helpers | Callers may treat as data |
insertHelper uses new Promise(async …) | Rejection edge cases |
secondLevelInsertTransaction child inserts not awaited | Partial writes |
upsert* + convertString string WHERE | SQL injection if values user-controlled |
upsertBulkFunction select+update in loop | N+1 |
selectRawHelper raw SQL strings | Injection if not parameterized |
retrunValue typo | Permanent 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).
| Capability | PM core | Claim |
|---|---|---|
applyWhereClause / handleSubWhere | Yes | Yes |
getCount, getQuery, lock, CUSTOM select | Yes | Yes |
offsetClause | Broken | Fixed |
newSaveHelper graph | No | Yes |
newSaveHelper safe delete | No | Yes |
newSaveHelper parse fallback | No | Yes |
selectQueryBuilder + selectQueryBuilderRaw.newSaveHelper body with Claim’s version (graph + safe delete + empty-save guard + catch).ilikeBit in function.helper.ts.Longer term (both): stop returning HttpException from helpers; honor commit; replace convertString upserts with parameterized setWhereClause + batch updates.
newSaveHelpernewSaveHelper runs one transaction over an array of operations: insert/update via save, or delete via remove.
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:
graph: true → manager.save(dataInsert) (TypeORM cascade)delete: true → repo.create(...) then remove(...)repo.create(...) → batch readyRepo → one save(readyRepo)PM core only has (2) without create on delete, and (3) without graph.
graph: true)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().
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.
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].
graphconst 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);
| Claim | PM core | |
|---|---|---|
.tasks already entity instances | graph: true → parent + tasks + FKs | Often parent only |
| 3-level nest | One graph save (if cascades set) | Manual insert order or multiple helpers |
| Return value | Full saved graph | Often 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.
delete: true)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.
await queryRunner.manager.remove(dt.dataInsert);
await this.runner.newSaveHelper([{
entity: PaymentRequestAttachment,
dataInsert: { attachment_id: attachmentId },
delete: true,
is_data_array: false,
}], true, true);
| Claim | PM core | |
|---|---|---|
{ attachment_id: 123 } | Reliable remove | Fragile |
| Plain rows from query | create normalizes | May no-op / throw |
Batch toDelete[] (10 rows) | map(repo.create) + remove([...]) | Same risk × N |
{
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.
catch)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.
} 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.
} catch (error) {
await queryRunner.rollbackTransaction();
let err = JSON.parse(error.message);
this.httpExceptionService.httpExceptionHelper({ ... });
}
JSON.parse throws → masks original error with SyntaxError: Unexpected token....
| Error source | error.message | Claim | PM core |
|---|---|---|---|
Postgres FK violation in save | JSON from .catch | Parsed → sqlMessage in API | Same |
throw new Error('Parent project not found') inside try | Plain English | Readable API error | Parse crash |
graph: true but dataInsert undefined | TypeError | Readable | Parse crash |
| Validation throw inside helper loop | Plain | Readable | Parse crash |
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);
| Step | Claim | PM core |
|---|---|---|
| Delete old task | repo.create → remove | remove(plain) — risky |
| Insert new tasks | Batch save | Same |
| Update project | Batch save | Same |
| CHECK constraint on name | SQL JSON → nice error | SQL JSON → nice error |
Malformed removedTaskRows | Readable non-JSON error | Often parse error |
When wiring PM services (aligned with Claim backend rules):
selectQueryBuilder + helper.setWhereClause(), returnValue: true, getOne / getCount as needed.newSaveHelper with graph: true for parent+children after port.this.runner.* inside loops; batch IDs then one helper call.is_active in whereClause and join clauses..cursor/rules/backend.mdc, .cursor/skills/runner-helper*Project Manager/ERD.md (repo checkout, not vault)Source: Cursor audit session 2026-05-20 (PM core-backend-new vs backend-claim-dev).