Skip to content

fix(appsync): avoid N+1 DynamoDB queries in campaign total resolvers - #173

Merged
dmeiser merged 1 commit into
mainfrom
fix/144-campaign-totals-count
Aug 24, 2026
Merged

fix(appsync): avoid N+1 DynamoDB queries in campaign total resolvers#173
dmeiser merged 1 commit into
mainfrom
fix/144-campaign-totals-count

Conversation

@dmeiser

@dmeiser dmeiser commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Intent

Resolve kernelworx issue #144: Campaign.totalOrders and Campaign.totalRevenue AppSync field resolvers issued N+1 DynamoDB queries that loaded all order items per campaign (20 campaigns could trigger 40 large queries). Fix: totalOrders uses Select: COUNT; totalRevenue projects only totalAmount. Smallest correct fix per the issue's recommendation; denormalizing totals onto the campaign item was explicitly considered and ruled out as too broad a refactor for this PR. Closes #144.

What Changed

  • Changed Campaign.totalOrders AppSync resolver to request Select: COUNT and return $ctx.result.count, eliminating the need to load every order item just to count them.
  • Changed Campaign.totalRevenue AppSync resolver to project only totalAmount, so DynamoDB returns the minimal attributes needed to sum revenue instead of full order records.
  • Updated docs/SCHEMA.md to document Campaign.totalOrders and Campaign.totalRevenue as computed on-demand from the ORDER table rather than denormalized fields.

Risk Assessment

✅ Low: Minimal, behavior-preserving fix that correctly uses DynamoDB COUNT and projection with no new defects introduced.

Testing

Validated the fix by rendering the real AppSync VTL templates and confirming totalOrders uses Select: COUNT + ctx.result.count and totalRevenue projects totalAmount + sums it, with a baseline comparison showing the pre-fix templates did neither. Existing JS resolver unit tests pass. Could not run the live AppSync integration test because AWS credentials are expired.

Evidence: Focused VTL verification script
/**
 * Focused verification of the AppSync VTL mapping templates for
 * Campaign.totalOrders and Campaign.totalRevenue.
 *
 * These templates are the actual production code paths being fixed:
 *   - totalOrders must issue a DynamoDB Query with Select: COUNT and
 *     return ctx.result.count (not the size of loaded items).
 *   - totalRevenue must issue a DynamoDB Query that projects only
 *     totalAmount and sum the projected values.
 *
 * We execute the real VTL files through a Velocity engine and assert the
 * rendered request/response shapes, which is the public interface the
 * templates expose to AppSync / DynamoDB.
 */

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { strict as assert } from 'node:assert';
import Velocity from 'velocityjs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const WORKTREE = '/home/dm/.no-mistakes/worktrees/d353adefa548/01M0RHMVFJR56J0KV6D59A5577';
const BASE_COMMIT = '776cb41911e3a7ae449f2c9ef35824cdab1e4fe5';
const TEMPLATE_DIR = path.join(
  WORKTREE,
  'tofu/application/appsync/mapping-templates'
);

const templates = {
  totalOrdersRequest: 'campaign_total_orders_request.vtl',
  totalOrdersResponse: 'campaign_total_orders_response.vtl',
  totalRevenueRequest: 'campaign_total_revenue_request.vtl',
  totalRevenueResponse: 'campaign_total_revenue_response.vtl',
};

function readFile(filePath) {
  return fs.readFileSync(filePath, 'utf8');
}

function readTemplate(name) {
  return readFile(path.join(TEMPLATE_DIR, templates[name]));
}

function readBaseTemplate(name) {
  const repoPath = `tofu/application/appsync/mapping-templates/${templates[name]}`;
  return execSync(`git show ${BASE_COMMIT}:${repoPath}`, {
    cwd: WORKTREE,
    encoding: 'utf8',
  });
}

function makeUtil() {
  return {
    dynamodb: {
      toDynamoDBJson: (value) => {
        // AppSync returns a JSON-encoded DynamoDB typed value.
        if (typeof value === 'string') return JSON.stringify({ S: value });
        if (typeof value === 'number') return JSON.stringify({ N: String(value) });
        if (typeof value === 'boolean') return JSON.stringify({ BOOL: value });
        return JSON.stringify({ S: String(value) });
      },
    },
    error: (message, type) => {
      throw new Error(`${type}: ${message}`);
    },
  };
}

function render(template, ctx) {
  const context = {
    ctx,
    util: makeUtil(),
  };
  return Velocity.render(template, context).trim();
}

function parseJson(text) {
  try {
    return JSON.parse(text);
  } catch (err) {
    throw new Error(`Rendered template was not valid JSON:\n${text}\n${err.message}`);
  }
}

function section(title) {
  console.log(`\n=== ${title} ===`);
}

function check(name, condition, details) {
  if (condition) {
    console.log(`  ✓ ${name}`);
  } else {
    console.error(`  ✗ ${name}`);
    if (details) console.error(details);
    process.exitCode = 1;
  }
}

// ---------------------------------------------------------------------------
section('Current totalOrders request template');
{
  const rendered = render(
    readTemplate('totalOrdersRequest'),
    { source: { campaignId: 'CAMP#123' } }
  );
  const request = parseJson(rendered);
  check('operation is Query', request.operation === 'Query', request);
  check(
    'query expression references campaignId',
    request.query?.expression === 'campaignId = :campaignId',
    request
  );
  check(
    'expressionValues contain typed campaignId',
    request.query?.expressionValues?.[':campaignId']?.S === 'CAMP#123',
    request
  );
  check('select is COUNT', request.select === 'COUNT', request);
}

section('Current totalOrders response template');
{
  const template = readTemplate('totalOrdersResponse');
  check(
    'returns ctx.result.count when count is provided',
    render(template, { result: { count: 7, items: [{}, {}] } }) === '7'
  );
  check(
    'ignores items and returns 0 when count is 0',
    render(template, { result: { count: 0, items: [{ totalAmount: 10 }] } }) === '0'
  );
  check(
    'propagates errors',
    (() => {
      try {
        render(template, { error: { message: 'boom', type: 'DynamoDB' } });
        return false;
      } catch (e) {
        return e.message.includes('DynamoDB: boom');
      }
    })()
  );
}

section('Current totalRevenue request template');
{
  const rendered = render(
    readTemplate('totalRevenueRequest'),
    { source: { campaignId: 'CAMP#456' } }
  );
  const request = parseJson(rendered);
  check('operation is Query', request.operation === 'Query', request);
  check(
    'query expression references campaignId',
    request.query?.expression === 'campaignId = :campaignId',
    request
  );
  check(
    'expressionValues contain typed campaignId',
    request.query?.expressionValues?.[':campaignId']?.S === 'CAMP#456',
    request
  );
  check(
    'projection expression is totalAmount only',
    request.projection?.expression === 'totalAmount',
    request
  );
}

section('Current totalRevenue response template');
{
  const template = readTemplate('totalRevenueResponse');
  check(
    'sums totalAmount from projected items',
    render(template, {
      result: {
        items: [
          { totalAmount: 20.0 },
          { totalAmount: 10.0 },
          { totalAmount: 15.0 },
        ],
      },
    }) === '45'
  );
  check(
    'returns 0 for no items',
    render(template, { result: { items: [] } }) === '0'
  );
}

// ---------------------------------------------------------------------------
section('Baseline (pre-fix) templates from base commit');
{
  const baseOrdersRequest = render(
    readBaseTemplate('totalOrdersRequest'),
    { source: { campaignId: 'CAMP#old' } }
  );
  const baseOrdersReq = parseJson(baseOrdersRequest);
  check(
    'baseline totalOrders request did NOT use COUNT',
    baseOrdersReq.select !== 'COUNT',
    baseOrdersReq
  );

  const baseOrdersResponse = render(
    readBaseTemplate('totalOrdersResponse'),
    { result: { items: [{}, {}] } }
  );
  check(
    'baseline totalOrders response used items.size() (2)',
    baseOrdersResponse === '2',
    baseOrdersResponse
  );

  const baseRevenueRequest = render(
    readBaseTemplate('totalRevenueRequest'),
    { source: { campaignId: 'CAMP#old' } }
  );
  const baseRevReq = parseJson(baseRevenueRequest);
  check(
    'baseline totalRevenue request did NOT project totalAmount',
    baseRevReq.projection?.expression !== 'totalAmount',
    baseRevReq
  );
}

// ---------------------------------------------------------------------------
if (process.exitCode) {
  console.error('\nOne or more focused VTL checks failed.');
} else {
  console.log('\nAll focused VTL checks passed.');
}
Evidence: Focused VTL verification log

=== Current totalOrders request template === ✓ operation is Query ✓ query expression references campaignId ✓ expressionValues contain typed campaignId ✓ select is COUNT === Current totalOrders response template === ✓ returns ctx.result.count when count is provided ✓ ignores items and returns 0 when count is 0 ✓ propagates errors === Current totalRevenue request template === ✓ operation is Query ✓ query expression references campaignId ✓ expressionValues contain typed campaignId ✓ projection expression is totalAmount only === Current totalRevenue response template === ✓ sums totalAmount from projected items ✓ returns 0 for no items === Baseline (pre-fix) templates from base commit === ✓ baseline totalOrders request did NOT use COUNT ✓ baseline totalOrders response used items.size() (2) ✓ baseline totalRevenue request did NOT project totalAmount All focused VTL checks passed.


=== Current totalOrders request template ===
  ✓ operation is Query
  ✓ query expression references campaignId
  ✓ expressionValues contain typed campaignId
  ✓ select is COUNT

=== Current totalOrders response template ===
  ✓ returns ctx.result.count when count is provided
  ✓ ignores items and returns 0 when count is 0
  ✓ propagates errors

=== Current totalRevenue request template ===
  ✓ operation is Query
  ✓ query expression references campaignId
  ✓ expressionValues contain typed campaignId
  ✓ projection expression is totalAmount only

=== Current totalRevenue response template ===
  ✓ sums totalAmount from projected items
  ✓ returns 0 for no items

=== Baseline (pre-fix) templates from base commit ===
  ✓ baseline totalOrders request did NOT use COUNT
  ✓ baseline totalOrders response used items.size() (2)
  ✓ baseline totalRevenue request did NOT project totalAmount

All focused VTL checks passed.
- Outcome: ⚠️ 1 warning across 1 run (5m43s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • ⚠️ tofu/application/appsync/mapping-templates/campaign_total_orders_response.vtl:4 - campaign_total_orders_response.vtl uses $ctx.result.scannedCount instead of $ctx.result.count. The sibling JS resolver count_user_shared_campaigns_fn.js uses ctx.result.count for the same select: 'COUNT' pattern. DynamoDB's scannedCount counts items evaluated before any filter expression, while count counts items matching the query condition. With no filter expression they are equal today, so behavior is currently correct, but scannedCount is semantically wrong and will return inflated totals if a filter expression is added later. Change line 4 to $ctx.result.count.
  • ℹ️ tofu/application/appsync/mapping-templates/campaign_total_orders_request.vtl:1 - Both totalOrders and totalRevenue resolvers still evaluate only the first page of DynamoDB Query results (no nextToken handling). Campaigns with more than 1 MB of orders will continue to return truncated totals. This is the pre-existing pagination gap the issue explicitly accepts by ruling out denormalization onto the campaign item, so it is authorized containment rather than a new defect.

🔧 Fix: Use ctx.result.count for totalOrders COUNT query
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 warning
  • ⚠️ tests/integration/resolvers/campaignQueries.integration.test.ts:1092 - The live AppSync/DynamoDB integration test that exercises Campaign.totalOrders and Campaign.totalRevenue end-to-end (tests/integration/resolvers/campaignQueries.integration.test.ts) could not be run because the AWS session has expired (aws sts get-caller-identity failed). The focused VTL verification below validates the actual resolver templates, but it does not exercise the deployed AppSync API or real DynamoDB behavior. If you want full end-to-end confirmation, re-authenticate AWS and run the integration test.
  • node /tmp/no-mistakes-evidence/01M0RHMVFJR56J0KV6D59A5577/test_campaign_totals_vtl.mjs (focused VTL verification of the changed templates and baseline comparison)
  • npm run test:js-resolvers (baseline JS resolver unit suite)
  • aws sts get-caller-identity (environment check; failed due to expired session)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Copilot AI balanced review requested due to automatic review settings August 24, 2026 00:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Campaign.totalOrders now uses a DynamoDB Query with Select=COUNT instead
of loading every order item just to count them, and Campaign.totalRevenue
projects only totalAmount instead of fetching full order items before
summing in VTL. This removes the large per-campaign item payloads behind
the N+1 field resolvers. Denormalizing totals onto the campaign item was
considered and deliberately ruled out as too broad for this fix.

Closes #144
Copilot AI review requested due to automatic review settings August 24, 2026 09:44
@dmeiser
dmeiser force-pushed the fix/144-campaign-totals-count branch from 286afd1 to 333b2cf Compare August 24, 2026 09:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dmeiser
dmeiser merged commit 41eab29 into main Aug 24, 2026
11 checks passed
@dmeiser
dmeiser deleted the fix/144-campaign-totals-count branch August 24, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Campaign.totalOrders / totalRevenue field resolvers issue N+1 queries

2 participants