Skip to content

fix(dx): stamp updatedAt with the schema key in BaseRepository updates - #3923

Open
stalniy wants to merge 3 commits into
mainfrom
feat/spec
Open

fix(dx): stamp updatedAt with the schema key in BaseRepository updates#3923
stalniy wants to merge 3 commits into
mainfrom
feat/spec

Conversation

@stalniy

@stalniy stalniy commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Why

Fixes bug

BaseRepository.updateBy, updateById and updateManyById refreshed a row's modification
timestamp by putting the raw column key updated_at into Drizzle's .set(). Drizzle builds an
UPDATE's SET clause from schema property names (updatedAt), and silently ignores a key it does
not recognise — so the stamp never reached the database and no error was raised.

Every row updated through those methods kept its creation-time timestamp. That matters most for
data_keys: a KMS rotation re-wraps thousands of rows through updateById, and each one kept its
pre-rotation updated_at, leaving operators no way to audit when a rotation actually touched a row.

What

  • BaseRepository now routes both update paths through one toUpdateSet helper that uses the
    schema key updatedAt, so the stamp is emitted as "updated_at" = now().
  • A caller-supplied updatedAt still wins — the spread order makes the stamp a default, not an
    override.
  • Tables without an updatedAt column (e.g. userSetting) are unaffected: no clause, no crash.
  • DeploymentSettingRepository.toInput is deleted. It was a per-repository workaround that injected
    a JavaScript new Date() to paper over the gap; the base class now covers it, and the stamp comes
    from the database clock instead of the API process's.
  • New base.repository.spec.ts pins the emitted SQL for all four cases by stubbing the driver.
it("stamps updated_at on a table that declares the column", async () => {
  const { dataKeyRepository, executedQueries } = setup();

  await executeAgainstStubbedDriver(() => dataKeyRepository.updateBy({ userId: USER_ID }, { wrappedByKid: "kms-v2" }));

  expect(executedQueries).toEqual([
    {
      query: 'update "data_keys" set "wrapped_by_kid" = $1, "updated_at" = now() where "data_keys"."user_id" = $2',
      params: ["kms-v2", USER_ID]
    }
  ]);
});

No migration, no API contract change. apps/api is green on npm test, npm run lint -- --quiet
and npx tsc --noEmit.

Demo

BaseRepository stamps updated_at with the schema key

2026-09-11T14:45:47Z by Showboat 0.6.1

BaseRepository.updateBy and updateManyById refreshed a row's modification timestamp by putting
the raw column key updated_at into Drizzle's .set() call. Drizzle builds an UPDATE's SET clause
from schema property names, not from database column names, and it silently ignores a key it
does not recognise. The column is updated_at; the schema property is updatedAt. So the stamp was
dropped on the floor, and the column kept whatever value it was given when the row was created.

This branch routes both methods through one toUpdateSet helper that uses the schema key, and drops
the per-repository workaround DeploymentSettingRepository had grown to paper over the gap.

What follows compiles the SQL that the real repository classes emit — first on this branch, then on
main — so the difference is visible as a statement rather than as a test result.

git -C /home/agent/workspace --no-pager diff --no-color main...HEAD -- \
  apps/api/src/core/repositories/base.repository.ts \
  apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
diff --git a/apps/api/src/core/repositories/base.repository.ts b/apps/api/src/core/repositories/base.repository.ts
index 550569fe1..345e0db25 100644
--- a/apps/api/src/core/repositories/base.repository.ts
+++ b/apps/api/src/core/repositories/base.repository.ts
@@ -150,10 +150,7 @@ export abstract class BaseRepository<
   async updateManyById(ids: Output["id"][], payload: Partial<Input>): Promise<void> {
     await this.cursor
       .update(this.table)
-      .set({
-        ...this.toInput(payload),
-        updated_at: sql`now()`
-      })
+      .set(this.toUpdateSet(payload))
       .where(inArray(this.table.id, ids));
   }
 
@@ -162,10 +159,7 @@ export abstract class BaseRepository<
   async updateBy(query: Partial<Output>, payload: Partial<Input>, options?: MutationOptions): Promise<void | Output> {
     const cursor = this.cursor
       .update(this.table)
-      .set({
-        ...this.toInput(payload),
-        updated_at: sql`now()`
-      })
+      .set(this.toUpdateSet(payload))
       .where(this.queryToWhere(query));
 
     if (options?.returning) {
@@ -219,6 +213,11 @@ export abstract class BaseRepository<
     return this.whereAccessibleBy(where);
   }
 
+  /** Drizzle builds the SET clause from schema property names, so a raw column key such as updated_at is dropped without an error. */
+  private toUpdateSet(payload: Partial<Input>) {
+    return { updatedAt: sql`now()`, ...this.toInput(payload) };
+  }
+
   protected toInput(payload: Partial<Input>): Partial<T["$inferInsert"]> {
     return payload as Partial<T["$inferSelect"]>;
   }
diff --git a/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts b/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
index ee22e78b7..cd0d103ab 100644
--- a/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
+++ b/apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
@@ -642,12 +642,4 @@ export class DeploymentSettingRepository extends BaseRepository<Table, Deploymen
         set: { closed: true, updatedAt: sql`now()` }
       });
   }
-
-  protected toInput(payload: Partial<DeploymentSettingsInput>): Partial<DeploymentSettingsInput> {
-    if (!payload.updatedAt) {
-      payload.updatedAt = new Date();
-    }
-
-    return payload;
-  }
 }

The probe

There is no Postgres in this sandbox, so instead of asserting on a stored row the demo hands the
real repository classes a Drizzle instance whose driver is replaced by a recorder: every statement
Drizzle hands to the driver is captured verbatim and the call is then aborted. What gets printed is
the exact SQL that would have reached the database.

It exercises three tables on purpose — data_keys (has an updatedAt column), userSetting (has
none), and deployment_settings (the table whose repository carried the workaround).

cd /home/agent/workspace/apps/api

cat > .demo-update-sql.ts <<'PROBE'
import "reflect-metadata";

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

import type { ApiPgDatabase, TxService } from "@src/core";
import { DeploymentSettings } from "@src/deployment/model-schemas";
import { DeploymentSettingRepository } from "@src/deployment/repositories/deployment-setting/deployment-setting.repository";
import { DataKeys } from "@src/secret/model-schemas";
import { DataKeyRepository } from "@src/secret/repositories/data-key/data-key.repository";
import { Users } from "@src/user/model-schemas";
import { UserRepository } from "@src/user/repositories";

const USER_ID = "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081";
const DATA_KEY_ID = "6e4c9a2c-0f1d-4a3b-9c5e-7d8f0a1b2c3d";
const SUPPLIED_UPDATED_AT = new Date("2026-03-04T05:06:07.000Z");

const capturedQueries: string[] = [];
const client = postgres("postgres://localhost:5432/unused");
Object.assign(client, {
  unsafe(query: string) {
    capturedQueries.push(query);
    throw new Error("the driver is replaced by a recorder in this demo");
  }
});

const driverlessDb = drizzle(client);
const pg = { update: driverlessDb.update.bind(driverlessDb) } as unknown as ApiPgDatabase;
const txManager = { getPgTx: () => undefined } as unknown as TxService;

const dataKeyRepository = new DataKeyRepository(pg, DataKeys, txManager);
const userRepository = new UserRepository(pg, Users, txManager);
const deploymentSettingRepository = new DeploymentSettingRepository(pg, DeploymentSettings, txManager);

async function record(label: string, run: () => Promise<unknown>) {
  capturedQueries.length = 0;
  await run().catch(() => undefined);
  console.log(label);
  console.log("  " + (capturedQueries.join("\n  ") || "<no statement reached the driver>"));
  console.log("");
}

async function main() {
  await record("data_keys / updateBy", () => dataKeyRepository.updateBy({ userId: USER_ID }, { wrappedByKid: "kms-v2" }));
  await record("data_keys / updateById", () => dataKeyRepository.updateById(DATA_KEY_ID, { wrappedByKid: "kms-v2" }));
  await record("data_keys / updateManyById", () => dataKeyRepository.updateManyById([DATA_KEY_ID], { wrappedByKid: "kms-v2" }));
  await record("data_keys / updateBy, caller supplies updatedAt", () =>
    dataKeyRepository.updateBy({ id: DATA_KEY_ID }, { wrappedByKid: "kms-v2", updatedAt: SUPPLIED_UPDATED_AT })
  );
  await record("userSetting / updateBy, table has no updatedAt column", () => userRepository.updateBy({ id: USER_ID }, { bio: "hello" }));
  await record("deployment_settings / updateBy", () => deploymentSettingRepository.updateBy({ userId: USER_ID }, { autoTopUpEnabled: true }));
}

main().then(() => process.exit(0));
PROBE

/home/agent/workspace/node_modules/.bin/tsx --tsconfig ./tsconfig.json .demo-update-sql.ts
data_keys / updateBy
  update "data_keys" set "wrapped_by_kid" = $1, "updated_at" = now() where "data_keys"."user_id" = $2

data_keys / updateById
  update "data_keys" set "wrapped_by_kid" = $1, "updated_at" = now() where "data_keys"."id" = $2

data_keys / updateManyById
  update "data_keys" set "wrapped_by_kid" = $1, "updated_at" = now() where "data_keys"."id" in ($2)

data_keys / updateBy, caller supplies updatedAt
  update "data_keys" set "wrapped_by_kid" = $1, "updated_at" = $2 where "data_keys"."id" = $3

userSetting / updateBy, table has no updatedAt column
  update "userSetting" set "bio" = $1 where "userSetting"."id" = $2

deployment_settings / updateBy
  update "deployment_settings" set "auto_top_up_enabled" = $1, "updated_at" = now() where "deployment_settings"."user_id" = $2

Every data_keys update carries "updated_at" = now(). A caller who passes an explicit updatedAt
still wins — the stamp is a default, not an override. userSetting, which has no such column, is
left alone rather than being handed a column it does not have.

The same probe against main

Now the two files are replaced with their main contents and the identical probe is run again. The
probe itself is untouched, so any difference in output comes from the repository code.

cd /home/agent/workspace/apps/api

git show main:apps/api/src/core/repositories/base.repository.ts > src/core/repositories/base.repository.ts
git show main:apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts > src/deployment/repositories/deployment-setting/deployment-setting.repository.ts

/home/agent/workspace/node_modules/.bin/tsx --tsconfig ./tsconfig.json .demo-update-sql.ts
status=$?

git checkout HEAD -- src/core/repositories/base.repository.ts src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
exit $status
data_keys / updateBy
  update "data_keys" set "wrapped_by_kid" = $1 where "data_keys"."user_id" = $2

data_keys / updateById
  update "data_keys" set "wrapped_by_kid" = $1 where "data_keys"."id" = $2

data_keys / updateManyById
  update "data_keys" set "wrapped_by_kid" = $1 where "data_keys"."id" in ($2)

data_keys / updateBy, caller supplies updatedAt
  update "data_keys" set "wrapped_by_kid" = $1, "updated_at" = $2 where "data_keys"."id" = $3

userSetting / updateBy, table has no updatedAt column
  update "userSetting" set "bio" = $1 where "userSetting"."id" = $2

deployment_settings / updateBy
  update "deployment_settings" set "auto_top_up_enabled" = $1, "updated_at" = $2 where "deployment_settings"."user_id" = $3

Three statements changed, and they are the bug:

  • data_keys / updateBy, updateById and updateManyById emit no updated_at at all on
    main. Drizzle accepted the updated_at key, found no schema property by that name, and dropped
    it. Every row updated through these methods kept a stale modification timestamp, with no error
    anywhere to say so.
  • deployment_settings / updateBy does carry a stamp on main, but as a bound parameter $2
    rather than now() — that is the toInput workaround inserting a JavaScript new Date(), so the
    value came from the API process's clock instead of the database's. On this branch it is now(),
    consistent with every other table, and the workaround is gone.
  • The caller-supplied updatedAt case is identical on both sides, which is the point: the fix adds
    a default without taking the override away.

Finally, the probe is removed and the working tree is shown to be back where it started, so nothing
in this demo leaves a trace.

cd /home/agent/workspace
rm -f apps/api/.demo-update-sql.ts
git status --porcelain -- apps/api packages && echo "apps/ and packages/ are clean"
git log --oneline -1 --format='%s'
apps/ and packages/ are clean
test(dx): stub the driver in the base repository spec

Summary by CodeRabbit

  • Bug Fixes

    • Update operations now reliably set modification timestamps, including single-record and multi-record updates.
    • Caller-provided timestamps are preserved when supplied.
    • Updates now behave consistently for empty payloads and records without timestamp fields.
  • Tests

    • Added coverage for timestamp handling, single- and multi-record updates, empty updates, and tables without modification timestamps.

stalniy and others added 3 commits September 11, 2026 13:34
updateBy and updateManyById built their SET clause with the raw column name
updated_at. Drizzle's buildUpdateSet keeps only keys that exist in the table's
JS column map, where the property is updatedAt, and mapUpdateSet forwards an SQL
value without checking the key exists, so the stamp was dropped with no error
and no update routed through the base class ever moved the column.

Both methods now go through one toUpdateSet helper that sets the schema-typed
key. The stamp is spread first so a caller that passes its own updatedAt still
wins, which is what the trial abuse enforcement writes rely on today and matches
how drizzle resolves a set value against an onUpdate function.

A payload that reduces to nothing used to build a SET clause with no assignments
at all, which Postgres rejects; it now emits a bare updated_at touch.

The spec drives the real repositories against a driverless drizzle instance and
asserts the statement that reaches the driver, since the defect lives in the SQL
drizzle emits rather than in the object handed to set().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… does

toInput mutated the caller's payload to set updatedAt on every update, which was
only ever needed because the base class's own stamp never reached the database.
With that fixed the timestamp comes from now() in the same statement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lint errors, both from this branch. TxService is only used as a type
argument to mock(), so the import has to be a type import.

The larger one: the spec built its database with drizzle.mock(), and the api's
no-restricted-syntax rule matches any call to a member named mock, since it is
there to force module mocks into the isolated project. Drizzle's factory is not
a module mock, but the selector cannot tell the difference, and renaming the
import to slip past it would hide the rule from the next reader.

A real postgres client works instead: it opens nothing until a query runs, and
its unsafe method is stubbed out before one can. The stub records the statement
and throws, which is also what lets it type check, as a body that ends in a
throw returns never and never satisfies PendingQuery. Capturing the driver call
itself is closer to the defect than the query logger was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: 527586fe-e950-4535-80e3-488f7acdd2c5

📥 Commits

Reviewing files that changed from the base of the PR and between ec579eb and badaee6.

📒 Files selected for processing (3)
  • apps/api/src/core/repositories/base.repository.spec.ts
  • apps/api/src/core/repositories/base.repository.ts
  • apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
💤 Files with no reviewable changes (1)
  • apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts

Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Repository update timestamp handling

Layer / File(s) Summary
Shared update-set construction
apps/api/src/core/repositories/base.repository.ts, apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts
BaseRepository uses toUpdateSet for single-record and multi-record updates. The helper writes timestamps through updatedAt. The deployment settings repository no longer overrides payload conversion.
Update operation coverage
apps/api/src/core/repositories/base.repository.spec.ts
Tests cover generated SQL, parameters, timestamp preservation, empty payloads, tables without updated_at, and single-ID and multi-ID updates.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: baktun14

Merge Risk: ⚪ Minimal · up to badae

The timestamp update behavior is covered across repository update paths, including caller overrides and tables without timestamp columns. No merge-blocking risk remains.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/spec

Warning

Some tools did not complete. Review the errors below.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

apps/api/src/core/repositories/base.repository.spec.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

apps/api/src/core/repositories/base.repository.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).


Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.70%. Comparing base (ebb3e49) to head (badaee6).
⚠️ Report is 7 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3923      +/-   ##
==========================================
- Coverage   82.28%   81.70%   -0.58%     
==========================================
  Files        1276     1176     -100     
  Lines       35230    32617    -2613     
  Branches     8542     8019     -523     
==========================================
- Hits        28989    26651    -2338     
+ Misses       5516     5260     -256     
+ Partials      725      706      -19     
Flag Coverage Δ *Carryforward flag
api 92.70% <ø> (-0.01%) ⬇️ Carriedforward from ebb3e49
deploy-web 72.42% <ø> (ø) Carriedforward from ebb3e49
log-collector ?
notifications 94.35% <ø> (ø) Carriedforward from ebb3e49
provider-console 81.68% <ø> (ø) Carriedforward from ebb3e49
provider-inventory ?
provider-proxy 88.61% <ø> (ø) Carriedforward from ebb3e49
tx-signer ?

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
apps/api/src/core/repositories/base.repository.ts 77.66% <ø> (ø)
...eployment-setting/deployment-setting.repository.ts 97.95% <ø> (-0.09%) ⬇️

... and 100 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks good — a small, well-isolated fix with solid pinned-SQL test coverage.

What was reviewed: the toUpdateSet helper and its use in updateBy/updateManyById (spread order lets a caller-supplied updatedAt win over the now() default); removal of DeploymentSettingRepository.toInput — checked that every other write path in that repository already sets updatedAt: sql\now()`explicitly, so it's unaffected; checked the other two repositories withtoInput overrides (UserRepository, UserWalletRepository) and confirmed neither touches updatedAt, so they're unaffected too; new base.repository.spec.tsfollows thesetup()-function convention and pins the actual emitted SQL for tables with and without an updatedAt` column.

Extended reasoning...

Overview

The diff touches three files: apps/api/src/core/repositories/base.repository.ts (extracts a toUpdateSet helper used by updateBy/updateManyById to correctly stamp updatedAt using the Drizzle schema property name instead of the raw, silently-ignored updated_at column key), apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts (deletes a now-redundant toInput override that was papering over the bug with a JS-side new Date()), and a new base.repository.spec.ts unit test that pins the exact SQL emitted for four scenarios (stamp on a table with the column, caller-supplied override wins, empty payload still stamps, table without the column is unaffected).

Security risks

None. This is a data-integrity/audit-trail fix (ensuring updated_at timestamps are actually persisted); it does not touch authentication, authorization, or any user-controlled input in a way that introduces injection or exposure risk. All values are still parameterized through Drizzle's query builder.

Level of scrutiny

This warranted a careful read since BaseRepository is shared across many repositories in apps/api, so a mistake here could have wide blast radius. I traced every other consumer of the removed toInput override pattern and every call site in DeploymentSettingRepository that mutates updatedAt to confirm none of them depended on the deleted workaround. I also verified the spread order in the new toUpdateSet helper preserves the "caller override wins" semantics claimed in the PR description, and cross-checked the new test's expected table/column names against the actual DataKeys and Users/userSetting schemas and repositories.

Other factors

The new test file follows the repo's setup()-instead-of-beforeEach convention (no shared state, setup at the bottom, though it takes no parameters here — consistent with existing precedent elsewhere in the codebase where no parameterization is needed), uses mock<T>() from vitest-mock-extended rather than as unknown as, and is correctly named/colocated as *.spec.ts. The change is small, mechanical, and the test suite directly demonstrates the fix by pinning the actual generated SQL, which gives strong confidence. No outstanding review threads or CHANGES_REQUESTED reviews are visible in the timeline.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant