From 1fdee8435d2dfc043c9415ea8aa3cbbcf6e7e4eb Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 29 Sep 2025 22:12:23 +0200 Subject: [PATCH] feat: add timestamps plugin to manage created_at and updated_at fields Introduced a new timestamps plugin that allows the addition of `created_at` and `updated_at` fields to specified entities. Included tests to verify functionality, ensuring timestamps are correctly set on entity creation and updates. Updated the plugin index to export the new timestamps functionality. --- app/src/plugins/data/timestamp.plugin.spec.ts | 74 ++++++++++++++++ app/src/plugins/data/timestamps.plugin.ts | 86 +++++++++++++++++++ app/src/plugins/index.ts | 1 + 3 files changed, 161 insertions(+) create mode 100644 app/src/plugins/data/timestamp.plugin.spec.ts create mode 100644 app/src/plugins/data/timestamps.plugin.ts diff --git a/app/src/plugins/data/timestamp.plugin.spec.ts b/app/src/plugins/data/timestamp.plugin.spec.ts new file mode 100644 index 0000000..bdf8811 --- /dev/null +++ b/app/src/plugins/data/timestamp.plugin.spec.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { timestamps } from "./timestamps.plugin"; +import { em, entity, text } from "bknd"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +describe("timestamps plugin", () => { + test("should ignore if no or invalid entities are provided", async () => { + const app = createApp({ + options: { + plugins: [timestamps({ entities: [] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual([]); + + { + const app = createApp({ + options: { + plugins: [timestamps({ entities: ["posts"] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual([]); + } + }); + + test("should add timestamps to the specified entities", async () => { + const app = createApp({ + config: { + data: em({ + posts: entity("posts", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [timestamps({ entities: ["posts", "invalid"] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]); + expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([ + "id", + "title", + "created_at", + "updated_at", + ]); + + // insert + const mutator = app.em.mutator(app.em.entity("posts")); + const { data } = await mutator.insertOne({ title: "Hello" }); + expect(data.created_at).toBeDefined(); + expect(data.updated_at).toBeDefined(); + expect(data.created_at).toBeInstanceOf(Date); + expect(data.updated_at).toBeInstanceOf(Date); + const diff = data.created_at.getTime() - data.updated_at.getTime(); + expect(diff).toBeLessThan(10); + expect(diff).toBeGreaterThan(-1); + + // update (set updated_at to null, otherwise it's too fast to test) + await app.em.connection.kysely + .updateTable("posts") + .set({ updated_at: null }) + .where("id", "=", data.id) + .execute(); + const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" }); + expect(updatedData.updated_at).toBeDefined(); + expect(updatedData.updated_at).toBeInstanceOf(Date); + }); +}); diff --git a/app/src/plugins/data/timestamps.plugin.ts b/app/src/plugins/data/timestamps.plugin.ts new file mode 100644 index 0000000..0de5a94 --- /dev/null +++ b/app/src/plugins/data/timestamps.plugin.ts @@ -0,0 +1,86 @@ +import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd"; +import { $console } from "bknd/utils"; + +export type TimestampsPluginOptions = { + entities: string[]; + setUpdatedOnCreate?: boolean; +}; + +/** + * This plugin adds `created_at` and `updated_at` fields to the specified entities. + * Add it to your plugins in `bknd.config.ts` like this: + * + * ```ts + * export default { + * plugins: [timestamps({ entities: ["posts"] })], + * } + * ``` + */ +export function timestamps({ + entities = [], + setUpdatedOnCreate = true, +}: TimestampsPluginOptions): AppPlugin { + return (app: App) => ({ + name: "timestamps", + schema: () => { + if (entities.length === 0) { + $console.warn("No entities specified for timestamps plugin"); + return; + } + + const appEntities = app.em.entities.map((e) => e.name); + + return em( + Object.fromEntries( + entities + .filter((e) => appEntities.includes(e)) + .map((e) => [ + e, + entity(e, { + created_at: datetime(), + updated_at: datetime(), + }), + ]), + ), + ); + }, + onBuilt: async () => { + app.emgr.onEvent( + DatabaseEvents.MutatorInsertBefore, + (event) => { + const { entity, data } = event.params; + if (entities.includes(entity.name)) { + return { + ...data, + created_at: new Date(), + updated_at: setUpdatedOnCreate ? new Date() : null, + }; + } + return data; + }, + { + mode: "sync", + id: "bknd-timestamps", + }, + ); + + app.emgr.onEvent( + DatabaseEvents.MutatorUpdateBefore, + async (event) => { + const { entity, data } = event.params; + if (entities.includes(entity.name)) { + return { + ...data, + updated_at: new Date(), + }; + } + return data; + }, + { + mode: "sync", + id: "bknd-timestamps", + }, + ); + }, + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 45db2d5..b0090ff 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin"; export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; +export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";