From ac15acaf95f7a92808a3f37a172ee89757220943 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 00:01:02 -0500 Subject: [PATCH] feat(schema): createdAt/updatedAt on every domain entity (P4-K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect audit P4-K. Add `createdAt`/`updatedAt` TIMESTAMPTZ columns to 18 tables and flip every model from `timestamps: false` to `timestamps: true` so Sequelize auto-populates them on every write. Why - Auditability: every row carries creation + last-modification time without each controller maintaining it by hand. - Sync clients: third-party integrations get a reliable "what's changed since T" boundary for delta-pull workflows. - Observability: ad-hoc analytics on "new customers per day", etc., trivially work off `createdAt` rather than a row counter. Coverage - 18 tables touched: ApiKey, ApiMaster, BillingType, Company, Customer, CustomerPayment, InventoryItem, InventoryTransactions, Invoice, InvoiceJob, Job, ProductEntry, PurchaseOrderHeaders, PurchaseOrderLines, PurchaseOrderVendors, TimeEntry, VersionInfo, Worker. - IdempotencyKey is INTENTIONALLY excluded — it already manages its own `ikCreatedAt` / `ikExpiresAt` pair and a parallel createdAt/updatedAt would be redundant and confusing. Backfill - Existing rows have no recorded history; the migration backfills both columns to `now()` via the column DEFAULT. Operators carrying legacy Atbash SQL Server timestamps can patch real values post-migration via a one-off UPDATE. Down migration - Drops the two columns from every table. No FKs reference them, so the rollback is straight-forward. Tests - New `tests/unit/timestamps.test.js` (36 cases) — every model asserts `options.timestamps === true` AND `rawAttributes.{createdAt,updatedAt}` exist. - Full suite: 401 pass / 4 skip (was 365/4). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 +++ app/migrations/20260520000000-timestamps.js | 76 +++++++++++++++++++++ app/models/apikey.model.js | 2 +- app/models/apimaster.model.js | 2 +- app/models/billingtype.model.js | 2 +- app/models/company.model.js | 2 +- app/models/customer.model.js | 2 +- app/models/customerpayment.model.js | 2 +- app/models/inventoryitem.model.js | 2 +- app/models/inventorytransaction.model.js | 2 +- app/models/invoice.model.js | 2 +- app/models/invoicejob.model.js | 2 +- app/models/job.model.js | 2 +- app/models/productentry.model.js | 2 +- app/models/purchaseorderheader.model.js | 2 +- app/models/purchaseorderline.model.js | 2 +- app/models/purchaseordervendor.model.js | 2 +- app/models/timeentry.model.js | 2 +- app/models/versioninfo.model.js | 2 +- app/models/worker.model.js | 2 +- tests/unit/timestamps.test.js | 58 ++++++++++++++++ 21 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 app/migrations/20260520000000-timestamps.js create mode 100644 tests/unit/timestamps.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f848ca..4f9a226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`createdAt` / `updatedAt` on every domain entity** (P4-K). New + migration adds two `TIMESTAMPTZ NOT NULL DEFAULT now()` columns to + 18 tables (everything except `IdempotencyKey`, which already + tracks its own time fields). All 18 models flip + `timestamps: false` → `true` so Sequelize auto-populates the + columns on every `.create()` / `.update()`. Existing rows are + backfilled to `now()` at apply time; operators with the original + SQL Server timestamps from the Atbash legacy can patch real values + post-migration via a one-off UPDATE. - **Prometheus `/metrics` endpoint** (P4-J). Exposes prom-client's default Node.js metrics (event-loop lag, heap, GC, etc.) plus per-request `http_requests_total{method,route,status}` and diff --git a/app/migrations/20260520000000-timestamps.js b/app/migrations/20260520000000-timestamps.js new file mode 100644 index 0000000..4a2a166 --- /dev/null +++ b/app/migrations/20260520000000-timestamps.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Add `createdAt` / `updatedAt` (TIMESTAMPTZ NOT NULL DEFAULT now()) +// to every domain table so Sequelize models can flip +// `timestamps: false` → `timestamps: true`. +// +// Why P4-K: +// - Auditability: every row carries its creation + last-modification +// time without each controller having to maintain it by hand. +// - Sync clients: third-party integrations get a reliable +// "what's changed since T" boundary for delta-pull workflows. +// - Observability: SQL ad-hoc analysis on "new customers per day" +// etc. trivially works off `createdAt` rather than a row-counter. +// +// IdempotencyKey is intentionally NOT in this list — it already +// manages its own ikCreatedAt/ikExpiresAt and a parallel +// createdAt/updatedAt pair would be redundant + confusing. +// +// Backfill strategy: +// Existing rows have no recorded history, so we backfill both +// columns to now() at migration-apply time. Operators with the +// original SQL Server timestamps (Atbash legacy) can patch +// real values post-migration via a one-off UPDATE. +// +// Down: simply DROP the two columns from each table. Safe — no +// FKs reference these columns. + +'use strict'; + +const TABLES = [ + 'ApiKey', + 'ApiMaster', + 'BillingType', + 'Company', + 'Customer', + 'CustomerPayment', + 'InventoryItem', + 'InventoryTransactions', + 'Invoice', + 'InvoiceJob', + 'Job', + 'ProductEntry', + 'PurchaseOrderHeaders', + 'PurchaseOrderLines', + 'PurchaseOrderVendors', + 'TimeEntry', + 'VersionInfo', + 'Worker', +]; + +module.exports = { + async up(queryInterface, Sequelize) { + const SCHEMA = 'dbo'; + const sequelize = queryInterface.sequelize; + for (const table of TABLES) { + await sequelize.query(` + ALTER TABLE "${SCHEMA}"."${table}" + ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now() + `); + } + }, + + async down(queryInterface, Sequelize) { + const SCHEMA = 'dbo'; + const sequelize = queryInterface.sequelize; + for (const table of TABLES) { + await sequelize.query(` + ALTER TABLE "${SCHEMA}"."${table}" + DROP COLUMN IF EXISTS "updatedAt", + DROP COLUMN IF EXISTS "createdAt" + `); + } + }, +}; diff --git a/app/models/apikey.model.js b/app/models/apikey.model.js index c27d1d1..6111239 100644 --- a/app/models/apikey.model.js +++ b/app/models/apikey.model.js @@ -30,7 +30,7 @@ module.exports = (sequelize, Sequelize) => { }, { tableName: 'ApiKey', - timestamps: false, + timestamps: true, defaultScope: { where: { akArchive: false } } } ); diff --git a/app/models/apimaster.model.js b/app/models/apimaster.model.js index 1c69d27..9db8f37 100644 --- a/app/models/apimaster.model.js +++ b/app/models/apimaster.model.js @@ -25,7 +25,7 @@ module.exports = (sequelize, Sequelize) => { }, { tableName: 'ApiMaster', - timestamps: false, + timestamps: true, defaultScope: { where: { amArchive: false } } } ); diff --git a/app/models/billingtype.model.js b/app/models/billingtype.model.js index 6a045f9..3ed85fb 100644 --- a/app/models/billingtype.model.js +++ b/app/models/billingtype.model.js @@ -37,7 +37,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'BillingType', - timestamps: false, + timestamps: true, defaultScope: { where: { btArch: false } } }); diff --git a/app/models/company.model.js b/app/models/company.model.js index 2cf5600..373c23e 100644 --- a/app/models/company.model.js +++ b/app/models/company.model.js @@ -37,7 +37,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'Company', - timestamps: false, + timestamps: true, defaultScope: { where: { compArch: false } } }); diff --git a/app/models/customer.model.js b/app/models/customer.model.js index d209c04..38332dc 100644 --- a/app/models/customer.model.js +++ b/app/models/customer.model.js @@ -66,7 +66,7 @@ module.exports = (sequelize, Sequelize) => { }, { tableName: 'Customer', - timestamps: false, + timestamps: true, defaultScope: { where: { custArch: false } } } ); diff --git a/app/models/customerpayment.model.js b/app/models/customerpayment.model.js index b16fb45..3d33934 100644 --- a/app/models/customerpayment.model.js +++ b/app/models/customerpayment.model.js @@ -41,7 +41,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'CustomerPayment', - timestamps: false, + timestamps: true, defaultScope: { where: { cpayArch: false } } }); diff --git a/app/models/inventoryitem.model.js b/app/models/inventoryitem.model.js index 14c5d22..212211d 100644 --- a/app/models/inventoryitem.model.js +++ b/app/models/inventoryitem.model.js @@ -39,7 +39,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'InventoryItem', - timestamps: false, + timestamps: true, defaultScope: { where: { invitArch: false } } }); diff --git a/app/models/inventorytransaction.model.js b/app/models/inventorytransaction.model.js index fe1795a..82b0f7d 100644 --- a/app/models/inventorytransaction.model.js +++ b/app/models/inventorytransaction.model.js @@ -26,7 +26,7 @@ module.exports = (sequelize, Sequelize) => { invtInitId: { field: 'invtInitId', type: Sequelize.INTEGER, allowNull: false }, }, { tableName: 'InventoryTransactions', - timestamps: false, + timestamps: true, defaultScope: { where: { invtArch: false } } }); diff --git a/app/models/invoice.model.js b/app/models/invoice.model.js index fa681c3..3a076e5 100644 --- a/app/models/invoice.model.js +++ b/app/models/invoice.model.js @@ -45,7 +45,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'Invoice', - timestamps: false, + timestamps: true, defaultScope: { where: { invArch: false } } }); diff --git a/app/models/invoicejob.model.js b/app/models/invoicejob.model.js index d711be0..4635904 100644 --- a/app/models/invoicejob.model.js +++ b/app/models/invoicejob.model.js @@ -41,7 +41,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'InvoiceJob', - timestamps: false, + timestamps: true, defaultScope: { where: { injbArch: false } } }); diff --git a/app/models/job.model.js b/app/models/job.model.js index f2e38d0..0deac9d 100644 --- a/app/models/job.model.js +++ b/app/models/job.model.js @@ -39,7 +39,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'Job', - timestamps: false, + timestamps: true, defaultScope: { where: { jobArch: false } } }); diff --git a/app/models/productentry.model.js b/app/models/productentry.model.js index 4f4e141..b73caae 100644 --- a/app/models/productentry.model.js +++ b/app/models/productentry.model.js @@ -45,7 +45,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'ProductEntry', - timestamps: false, + timestamps: true, defaultScope: { where: { penArch: false } } }); diff --git a/app/models/purchaseorderheader.model.js b/app/models/purchaseorderheader.model.js index 92e564d..8bff3a3 100644 --- a/app/models/purchaseorderheader.model.js +++ b/app/models/purchaseorderheader.model.js @@ -22,7 +22,7 @@ module.exports = (sequelize, Sequelize) => { pohArch: { field: 'pohArch', type: Sequelize.BOOLEAN, defaultValue: false }, }, { tableName: 'PurchaseOrderHeaders', - timestamps: false, + timestamps: true, defaultScope: { where: { pohArch: false } } }); diff --git a/app/models/purchaseorderline.model.js b/app/models/purchaseorderline.model.js index 040d78f..919060b 100644 --- a/app/models/purchaseorderline.model.js +++ b/app/models/purchaseorderline.model.js @@ -28,7 +28,7 @@ module.exports = (sequelize, Sequelize) => { polArch: { field: 'polArch', type: Sequelize.BOOLEAN, defaultValue: false }, }, { tableName: 'PurchaseOrderLines', - timestamps: false, + timestamps: true, defaultScope: { where: { polArch: false } } }); diff --git a/app/models/purchaseordervendor.model.js b/app/models/purchaseordervendor.model.js index c2f4d9c..f32bc18 100644 --- a/app/models/purchaseordervendor.model.js +++ b/app/models/purchaseordervendor.model.js @@ -35,7 +35,7 @@ module.exports = (sequelize, Sequelize) => { povArch: { field: 'povArch', type: Sequelize.BOOLEAN, defaultValue: false }, }, { tableName: 'PurchaseOrderVendors', - timestamps: false, + timestamps: true, defaultScope: { where: { povArch: false } } }); diff --git a/app/models/timeentry.model.js b/app/models/timeentry.model.js index f420f66..487e665 100644 --- a/app/models/timeentry.model.js +++ b/app/models/timeentry.model.js @@ -65,7 +65,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'TimeEntry', - timestamps: false, + timestamps: true, defaultScope: { where: { teArch: false } } }); diff --git a/app/models/versioninfo.model.js b/app/models/versioninfo.model.js index 998dc70..eec2f31 100644 --- a/app/models/versioninfo.model.js +++ b/app/models/versioninfo.model.js @@ -27,7 +27,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'VersionInfo', - timestamps: false, + timestamps: true, }); return VersionInfo; diff --git a/app/models/worker.model.js b/app/models/worker.model.js index a7f2603..ca8e393 100644 --- a/app/models/worker.model.js +++ b/app/models/worker.model.js @@ -54,7 +54,7 @@ module.exports = (sequelize, Sequelize) => { }, }, { tableName: 'Worker', - timestamps: false, + timestamps: true, defaultScope: { where: { workerArch: false } } }); diff --git a/tests/unit/timestamps.test.js b/tests/unit/timestamps.test.js new file mode 100644 index 0000000..6da9ce3 --- /dev/null +++ b/tests/unit/timestamps.test.js @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Verifies every domain model has `timestamps: true` so Sequelize +// auto-populates `createdAt`/`updatedAt` on every write. Paired +// with migration `20260520000000-timestamps.js` which adds the +// underlying columns. + +import { describe, test, expect } from 'vitest'; + +const db = require('../../app/config/db.config.js'); + +// IdempotencyKey manages its own ikCreatedAt/ikExpiresAt columns +// and is intentionally excluded from the auto-timestamp set. +const MODELS_WITH_TIMESTAMPS = [ + 'ApiKey', + 'ApiMaster', + 'BillingType', + 'Company', + 'Customer', + 'CustomerPayment', + 'InventoryItem', + 'InventoryTransaction', + 'Invoice', + 'InvoiceJob', + 'Job', + 'ProductEntry', + 'PurchaseOrderHeader', + 'PurchaseOrderLine', + 'PurchaseOrderVendor', + 'TimeEntry', + 'VersionInfo', + 'Worker', +]; + +describe('every domain model has timestamps enabled', () => { + test.each(MODELS_WITH_TIMESTAMPS)( + '%s carries timestamps:true on its options', + (modelName) => { + const model = db[modelName]; + expect(model, `${modelName} should be defined on db`).toBeDefined(); + expect(model.options.timestamps).toBe(true); + }, + ); + + test.each(MODELS_WITH_TIMESTAMPS)( + '%s exposes createdAt / updatedAt attributes on the model', + (modelName) => { + const attrs = db[modelName].rawAttributes; + expect(attrs).toBeDefined(); + // Sequelize names them according to the model's defaults + // (camelCase here since none of the models override + // createdAt/updatedAt keys). + expect(attrs.createdAt).toBeDefined(); + expect(attrs.updatedAt).toBeDefined(); + }, + ); +});