From 2fd8b9f9a04d42061922d20d166658766f6b1de9 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 26 Nov 2025 10:08:30 +0100 Subject: [PATCH] feat: add sort plugin - Introduced a new sort plugin that allows sorting of entities based on a specified field. - Added API endpoints for reordering items and recalculating sort order. - Implemented tests to ensure correct functionality of sorting and reordering features. --- app/src/plugins/index.ts | 1 + app/src/plugins/sort.plugin.spec.ts | 859 ++++++++++++++++++++++++++++ app/src/plugins/sort.plugin.ts | 423 ++++++++++++++ 3 files changed, 1283 insertions(+) create mode 100644 app/src/plugins/sort.plugin.spec.ts create mode 100644 app/src/plugins/sort.plugin.ts diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index fcb23c15..4a0b68a3 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -9,3 +9,4 @@ 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"; export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin"; +export { sort, type SortPluginOptions } from "./sort.plugin"; diff --git a/app/src/plugins/sort.plugin.spec.ts b/app/src/plugins/sort.plugin.spec.ts new file mode 100644 index 00000000..6f769d03 --- /dev/null +++ b/app/src/plugins/sort.plugin.spec.ts @@ -0,0 +1,859 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { sort } from "./sort.plugin"; +import { em, entity, text, number } from "bknd"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +describe("sort plugin", () => { + test("should add sort field to configured entities", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const taskEntity = app.em.entity("tasks"); + expect(taskEntity).toBeDefined(); + expect(taskEntity?.fields.map((f) => f.name)).toContain("position"); + expect(taskEntity?.field("position")?.type).toBe("number"); + }); + + test("should auto-assign sort values on insert (starting from 0)", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // insert first item + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + expect(task1.position).toBe(0); + + // insert second item + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + expect(task2.position).toBe(1); + + // insert third item + const { data: task3 } = await mutator.insertOne({ title: "Task 3" }); + expect(task3.position).toBe(2); + }); + + test("should preserve manually set sort values on insert", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // insert with explicit position + const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 }); + expect(task.position).toBe(10); + }); + + test("should reorder items via API endpoint", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks at positions 0, 1, 2 + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + const { data: task3 } = await mutator.insertOne({ title: "Task 3" }); + + // move task3 (position 2) to position 0 + const res = await app.server.request("/api/sort/tasks/reorder", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: task3.id, position: 0 }), + }); + + expect(res.status).toBe(200); + + // verify positions + const repo = app.em.repo("tasks"); + const { data: updatedTask1 } = await repo.findOne({ id: task1.id }); + const { data: updatedTask2 } = await repo.findOne({ id: task2.id }); + const { data: updatedTask3 } = await repo.findOne({ id: task3.id }); + + expect(updatedTask3.position).toBe(0); // moved to position 0 + expect(updatedTask1.position).toBe(1); // shifted down + expect(updatedTask2.position).toBe(2); // shifted down + }); + + test("should automatically reorder when updating sort field directly", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks at positions 0, 1, 2, 3 + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + const { data: task3 } = await mutator.insertOne({ title: "Task 3" }); + const { data: task4 } = await mutator.insertOne({ title: "Task 4" }); + + // move task4 (position 3) to position 1 by updating directly + await mutator.updateOne(task4.id, { position: 1 }); + + // verify positions + const repo = app.em.repo("tasks"); + const { data: updatedTask1 } = await repo.findOne({ id: task1.id }); + const { data: updatedTask2 } = await repo.findOne({ id: task2.id }); + const { data: updatedTask3 } = await repo.findOne({ id: task3.id }); + const { data: updatedTask4 } = await repo.findOne({ id: task4.id }); + + expect(updatedTask1.position).toBe(0); // unchanged + expect(updatedTask2.position).toBe(2); // shifted down + expect(updatedTask3.position).toBe(3); // shifted down + expect(updatedTask4.position).toBe(1); // moved to position 1 + }); + + test("should automatically reorder when moving items up", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks at positions 0, 1, 2, 3 + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + const { data: task3 } = await mutator.insertOne({ title: "Task 3" }); + const { data: task4 } = await mutator.insertOne({ title: "Task 4" }); + + // move task1 (position 0) to position 2 by updating directly + await mutator.updateOne(task1.id, { position: 2 }); + + // verify positions + const repo = app.em.repo("tasks"); + const { data: updatedTask1 } = await repo.findOne({ id: task1.id }); + const { data: updatedTask2 } = await repo.findOne({ id: task2.id }); + const { data: updatedTask3 } = await repo.findOne({ id: task3.id }); + const { data: updatedTask4 } = await repo.findOne({ id: task4.id }); + + expect(updatedTask1.position).toBe(2); // moved to position 2 + expect(updatedTask2.position).toBe(0); // shifted up + expect(updatedTask3.position).toBe(1); // shifted up + expect(updatedTask4.position).toBe(3); // unchanged + }); + + test("should support scoped sorting", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + project_id: number(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + scope: "project_id", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks in project 1 + const { data: p1t1 } = await mutator.insertOne({ + title: "P1 Task 1", + project_id: 1, + }); + const { data: p1t2 } = await mutator.insertOne({ + title: "P1 Task 2", + project_id: 1, + }); + + // create tasks in project 2 + const { data: p2t1 } = await mutator.insertOne({ + title: "P2 Task 1", + project_id: 2, + }); + const { data: p2t2 } = await mutator.insertOne({ + title: "P2 Task 2", + project_id: 2, + }); + + // positions should be scoped per project + expect(p1t1.position).toBe(0); + expect(p1t2.position).toBe(1); + expect(p2t1.position).toBe(0); // resets for new scope + expect(p2t2.position).toBe(1); + }); + + test("should reorder only within scope", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + project_id: number(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + scope: "project_id", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks in project 1 + const { data: p1t1 } = await mutator.insertOne({ + title: "P1 Task 1", + project_id: 1, + }); + const { data: p1t2 } = await mutator.insertOne({ + title: "P1 Task 2", + project_id: 1, + }); + const { data: p1t3 } = await mutator.insertOne({ + title: "P1 Task 3", + project_id: 1, + }); + + // create tasks in project 2 + const { data: p2t1 } = await mutator.insertOne({ + title: "P2 Task 1", + project_id: 2, + }); + const { data: p2t2 } = await mutator.insertOne({ + title: "P2 Task 2", + project_id: 2, + }); + + // move p1t3 to position 0 (should only affect project 1) + await app.server.request("/api/sort/tasks/reorder", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: p1t3.id, position: 0 }), + }); + + // verify project 1 tasks + const repo = app.em.repo("tasks"); + const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id }); + const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id }); + const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id }); + + expect(updatedP1t3.position).toBe(0); // moved to position 0 + expect(updatedP1t1.position).toBe(1); // shifted + expect(updatedP1t2.position).toBe(2); // shifted + + // verify project 2 tasks are unchanged + const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id }); + const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id }); + + expect(updatedP2t1.position).toBe(0); // unchanged + expect(updatedP2t2.position).toBe(1); // unchanged + }); + + test("should recalculate all positions", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks with irregular positions + await mutator.insertOne({ title: "Task 1", position: 5 }); + await mutator.insertOne({ title: "Task 2", position: 10 }); + await mutator.insertOne({ title: "Task 3", position: 15 }); + await mutator.insertOne({ title: "Task 4", position: 100 }); + + // recalculate + const res = await app.server.request("/api/sort/tasks/recalculate", { + method: "POST", + body: JSON.stringify({}), + headers: { + "Content-Type": "application/json", + }, + }); + + expect(res.status).toBe(200); + + // verify positions are now 0, 1, 2, 3 + const { data: tasks } = await app.em.repo("tasks").findMany({ + orderBy: [{ position: "asc" }], + }); + + expect(tasks.length).toBe(4); + expect(tasks[0].position).toBe(0); + expect(tasks[1].position).toBe(1); + expect(tasks[2].position).toBe(2); + expect(tasks[3].position).toBe(3); + }); + + test("should recalculate positions within scope only", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + project_id: number(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + scope: "project_id", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks in project 1 with irregular positions + await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 }); + await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 }); + + // create tasks in project 2 with irregular positions + await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 }); + await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 }); + + // recalculate only project 1 + const res = await app.server.request("/api/sort/tasks/recalculate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ scope: 1 }), + }); + + expect(res.status).toBe(200); + + // verify project 1 tasks are recalculated + const { data: p1Tasks } = await app.em.repo("tasks").findMany({ + where: { project_id: 1 }, + orderBy: [{ position: "asc" }], + }); + + expect(p1Tasks.length).toBe(2); + expect(p1Tasks[0].position).toBe(0); + expect(p1Tasks[1].position).toBe(1); + + // verify project 2 tasks are unchanged + const { data: p2Tasks } = await app.em.repo("tasks").findMany({ + where: { project_id: 2 }, + orderBy: [{ position: "asc" }], + }); + + expect(p2Tasks.length).toBe(2); + expect(p2Tasks[0].position).toBe(10); // unchanged + expect(p2Tasks[1].position).toBe(20); // unchanged + }); + + test("should handle moving items to the end", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks at positions 0, 1, 2, 3 + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + const { data: task3 } = await mutator.insertOne({ title: "Task 3" }); + const { data: task4 } = await mutator.insertOne({ title: "Task 4" }); + + // move task1 to the end (position 3) + await app.server.request("/api/sort/tasks/reorder", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: task1.id, position: 3 }), + }); + + // verify positions + const repo = app.em.repo("tasks"); + const { data: updatedTask1 } = await repo.findOne({ id: task1.id }); + const { data: updatedTask2 } = await repo.findOne({ id: task2.id }); + const { data: updatedTask3 } = await repo.findOne({ id: task3.id }); + const { data: updatedTask4 } = await repo.findOne({ id: task4.id }); + + expect(updatedTask1.position).toBe(3); // moved to end + expect(updatedTask2.position).toBe(0); // shifted up + expect(updatedTask3.position).toBe(1); // shifted up + expect(updatedTask4.position).toBe(2); // shifted up + }); + + test("should return 400 for invalid item id", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const res = await app.server.request("/api/sort/tasks/reorder", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: 999999, position: 0 }), + }); + + expect(res.status).toBe(400); + }); + + test("should handle multiple entities with different configurations", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + categories: entity("categories", { + name: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + categories: { + field: "order", + }, + }, + }), + ], + }, + }); + await app.build(); + + // verify both entities have their sort fields + const taskEntity = app.em.entity("tasks"); + const categoryEntity = app.em.entity("categories"); + + expect(taskEntity?.fields.map((f) => f.name)).toContain("position"); + expect(categoryEntity?.fields.map((f) => f.name)).toContain("order"); + + // create items in both entities + const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" }); + const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" }); + + expect(task.position).toBe(0); + expect(category.order).toBe(0); + + // verify both endpoints exist + const taskRes = await app.server.request("/api/sort/tasks/recalculate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + const catRes = await app.server.request("/api/sort/categories/recalculate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + expect(taskRes.status).toBe(200); + expect(catRes.status).toBe(200); + }); + + test("should not trigger reorder when updating other fields", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + const { data: task3 } = await mutator.insertOne({ title: "Task 3" }); + + // update title only (should not trigger reordering) + await mutator.updateOne(task2.id, { title: "Task 2 Updated" }); + + // verify positions are unchanged + const repo = app.em.repo("tasks"); + const { data: updatedTask1 } = await repo.findOne({ id: task1.id }); + const { data: updatedTask2 } = await repo.findOne({ id: task2.id }); + const { data: updatedTask3 } = await repo.findOne({ id: task3.id }); + + expect(updatedTask1.position).toBe(0); + expect(updatedTask2.position).toBe(1); + expect(updatedTask2.title).toBe("Task 2 Updated"); + expect(updatedTask3.position).toBe(2); + }); + + test("should handle null sort values", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create task with null position (bypass listener by using kysely directly) + await app.connection.kysely + .insertInto("tasks") + .values({ title: "Task with null", position: null }) + .execute(); + + // create normal task + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + + // update the null task to have a position + const nullTask = await app.connection.kysely + .selectFrom("tasks") + .selectAll() + .where("title", "=", "Task with null") + .executeTakeFirst(); + + await mutator.updateOne(nullTask!.id, { position: 0 }); + + // verify both tasks have proper positions + const { data: tasks } = await app.em.repo("tasks").findMany({ + sort: { by: "position", dir: "asc" }, + }); + + expect(tasks.length).toBe(2); + expect(tasks[0].position).toBe(0); + expect(tasks[1].position).toBe(1); + }); + + test.only("should not create duplicates when moving to an occupied position", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create two tasks at positions 0 and 1 + const { data: task1 } = await mutator.insertOne({ title: "Task 1" }); + const { data: task2 } = await mutator.insertOne({ title: "Task 2" }); + + expect(task1.position).toBe(0); + expect(task2.position).toBe(1); + + // move task2 (at position 1) to position 0 + await mutator.updateOne(task2.id, { position: 0 }); + + // verify no duplicates + const { data: tasks } = await app.em.repo("tasks").findMany({ + sort: { by: "position", dir: "asc" }, + }); + console.dir(tasks, { depth: null }); + + expect(tasks.length).toBe(2); + expect(tasks[0].id).toBe(task2.id); + expect(tasks[0].position).toBe(0); + expect(tasks[1].id).toBe(task1.id); + expect(tasks[1].position).toBe(1); + + // verify no tasks have the same position + const positions = tasks.map((t) => t.position); + const uniquePositions = new Set(positions); + expect(uniquePositions.size).toBe(positions.length); + }); + + test("should preserve order when recalculating", async () => { + const app = createApp({ + config: { + data: em({ + tasks: entity("tasks", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [ + sort({ + entities: { + tasks: { + field: "position", + }, + }, + }), + ], + }, + }); + await app.build(); + + const mutator = app.em.mutator("tasks"); + + // create tasks with specific positions + const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 }); + const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 }); + const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 }); + + // recalculate + await app.server.request("/api/sort/tasks/recalculate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + // verify order is preserved (B, A, C based on original positions) + const { data: tasks } = await app.em.repo("tasks").findMany({ + sort: { by: "position", dir: "asc" }, + }); + + expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0 + expect(tasks[0].position).toBe(0); + expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1 + expect(tasks[1].position).toBe(1); + expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2 + expect(tasks[2].position).toBe(2); + }); +}); diff --git a/app/src/plugins/sort.plugin.ts b/app/src/plugins/sort.plugin.ts new file mode 100644 index 00000000..848ed7ac --- /dev/null +++ b/app/src/plugins/sort.plugin.ts @@ -0,0 +1,423 @@ +import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd"; +import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils"; +import { Hono } from "hono"; +import { sql } from "kysely"; + +const DEFAULT_BATCH_SIZE = 1000; + +export type SortPluginOptions = { + /** + * The base path for the API endpoints. + * @default "/api/sort" + */ + apiBasePath?: string; + + /** + * Configuration for entities that should have sorting enabled. + * Key is the entity name, value is the configuration. + */ + entities: Record< + string, + { + /** + * The name of the sort property (must be a number field). + */ + field: string; + + /** + * Optional scope field name. If provided, sorting will only happen within the same scope. + * For example, if scope is "category_id", items will only be sorted within items + * that have the same category_id value. + */ + scope?: string; + } + >; + + /** + * The batch size for recalculating sort order. + * @default 1000 + */ + recalculateBatchSize?: number; +}; + +class SortError extends Exception { + override name = "SortError"; + override code = HttpStatus.BAD_REQUEST; +} + +export function sort({ + apiBasePath = "/api/sort", + entities, + recalculateBatchSize = DEFAULT_BATCH_SIZE, +}: SortPluginOptions): AppPlugin { + return (app: App) => { + return { + name: "sort", + schema: () => { + return em( + Object.fromEntries( + Object.entries(entities).map(([entityName, config]) => [ + entityName, + entity(entityName, { + [config.field]: number({ default_value: 0 }), + }), + ]), + ), + ({ index }, schema) => { + for (const [entityName, config] of Object.entries(entities)) { + const indexed = app.em.getIndexedFields(entityName); + if (!indexed.some((f) => f.name === config.field)) { + index(schema[entityName]!).on([config.field]); + } + } + }, + ); + }, + onBuilt: async () => { + invariant( + entities && Object.keys(entities).length > 0, + "At least one entity must be configured", + ); + + // validate entities exist and have the configured fields + for (const [entityName, config] of Object.entries(entities)) { + const entity = app.em.entity(entityName); + invariant(entity, `Entity "${entityName}" not found in schema`); + + const sortFieldSchema = entity.field(config.field)!; + invariant( + sortFieldSchema, + `Sort field "${config.field}" not found in entity "${entityName}"`, + ); + invariant( + sortFieldSchema.type === "number", + `Sort field "${config.field}" in entity "${entityName}" must be a number field`, + ); + + if (config.scope) { + const scopeFieldSchema = entity.field(config.scope); + invariant( + scopeFieldSchema, + `Scope field "${config.scope}" not found in entity "${entityName}"`, + ); + } + } + + const hono = new Hono(); + + // register recalculate endpoints for each entity + for (const [entityName, config] of Object.entries(entities)) { + hono.post( + `/${entityName}/recalculate`, + jsc( + "json", + s + .object({ + scope: s.any().optional(), + }) + .optional(), + ), + async (c) => { + const body = c.req.valid("json"); + const scope = body?.scope; + + await recalculateSortOrder( + app, + entityName, + config, + recalculateBatchSize, + scope, + ); + + return c.json({ success: true, message: "Sort order recalculated" }); + }, + ); + + hono.post( + `/${entityName}/reorder`, + jsc( + "json", + s.object({ + id: s.any(), + position: s.number(), + }), + ), + async (c) => { + const { id, position } = c.req.valid("json"); + + await reorderItem(app, entityName, config, id, position); + + return c.json({ success: true, message: "Item reordered" }); + }, + ); + } + + app.server.route(apiBasePath, hono); + + // register listeners for automatic reordering + registerListeners(app, entities); + }, + }; + }; +} + +async function reorderItem( + app: App, + entityName: string, + config: SortPluginOptions["entities"][string], + id: any, + newPosition: number, +) { + const { field: sortField, scope: scopeField } = config; + const em = app.em.fork(); + const kysely = em.connection.kysely; + + // get the item + const { data: item } = await em.repo(entityName).findOne({ id }); + if (!item) { + throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`); + } + + const oldPosition = item[sortField] as number | null | undefined; + const scopeValue = scopeField ? item[scopeField] : undefined; + + // update the item with new position (using forked em, so no listeners are triggered) + await em.mutator(entityName).updateOne(id, { [sortField]: newPosition }); + + // shift other items using kysely + if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) { + if (newPosition < oldPosition) { + // moving up: increment items between newPosition and oldPosition + let query = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any) + .where(sortField as any, ">=", newPosition) + .where(sortField as any, "<", oldPosition) + .where("id", "!=", id); + + if (scopeField && scopeValue !== undefined) { + query = query.where(scopeField as any, "=", scopeValue); + } + + await query.execute(); + } else { + // moving down: decrement items between oldPosition and newPosition + let query = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any) + .where(sortField as any, ">", oldPosition) + .where(sortField as any, "<=", newPosition) + .where("id", "!=", id); + + if (scopeField && scopeValue !== undefined) { + query = query.where(scopeField as any, "=", scopeValue); + } + + await query.execute(); + } + } else if (oldPosition === undefined || oldPosition === null) { + // new item, shift everything at or after this position + let query = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any) + .where(sortField as any, ">=", newPosition) + .where("id", "!=", id); + + if (scopeField && scopeValue !== undefined) { + query = query.where(scopeField as any, "=", scopeValue); + } + + await query.execute(); + } +} + +async function recalculateSortOrder( + app: App, + entityName: string, + config: SortPluginOptions["entities"][string], + batchSize: number, + scope?: any, +) { + const { field: sortField, scope: scopeField } = config; + const db = app.connection.kysely; + + const { count } = (await db + .selectFrom(entityName) + .select((eb) => eb.fn.count("id").as("count")) + .$if(Boolean(scopeField && scope !== undefined), (eb) => + eb.where(scopeField as any, "=", scope), + ) + .$castTo<{ count: number }>() + .executeTakeFirst()) ?? { count: 0 }; + + const batches = Math.ceil(count / batchSize); + for (let i = 0; i < batches; i++) { + // get all items in scope, ordered by current sort value + const items = await db + .selectFrom(entityName) + .select(["id", sortField]) + .$if(Boolean(scopeField && scope !== undefined), (eb) => + eb.where(scopeField as any, "=", scope), + ) + .orderBy(sortField, "asc") + .limit(batchSize) + .offset(i * batchSize) + .execute(); + + const newQbs = items.map((item, index) => + db + .updateTable(entityName) + .set({ [sortField]: index }) + .where("id", "=", item.id), + ); + + await app.connection.executeQueries(...newQbs); + $console.log( + `[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`, + ); + } +} + +function registerListeners(app: App, entities: SortPluginOptions["entities"]) { + const kysely = app.connection.kysely; + + // handle insert events + app.emgr.onEvent( + DatabaseEvents.MutatorInsertBefore, + async (e) => { + const entityName = e.params.entity.name; + const config = entities[entityName]; + if (!config) return e.params.data; + + const { field: sortField, scope: scopeField } = config; + const data = e.params.data; + const scopeValue = scopeField ? data[scopeField] : undefined; + + // if no position provided, set to max + 1 + if (data[sortField] === undefined || data[sortField] === null) { + const query = kysely + .selectFrom(entityName) + .select((eb) => [eb.fn.max(eb.ref(sortField)).as("max")]) + // add scope filter if needed + .$if(Boolean(scopeField && scopeValue), (eb) => + eb.where(scopeField as any, "=", scopeValue as any), + ); + + const result = await query.executeTakeFirst(); + const max = result?.max ?? -1; + + return { + ...data, + [sortField]: max + 1, + }; + } + + // if position is provided, shift other items at or after that position + const newPosition = data[sortField] as number; + + let shiftQuery = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any) + .where(sortField as any, ">=", newPosition); + + if (scopeField && scopeValue !== undefined) { + shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue); + } + + await shiftQuery.execute(); + + return data; + }, + { + mode: "sync", + id: "bknd-sort-insert", + }, + ); + + // handle update events + app.emgr.onEvent( + DatabaseEvents.MutatorUpdateBefore, + async (e) => { + const entityName = e.params.entity.name; + const config = entities[entityName]; + if (!config) return e.params.data; + + const { field: sortField, scope: scopeField } = config; + const data = e.params.data; + + // only handle if sort field is being updated + if (!(sortField in data)) return e.params.data; + + const newPosition = data[sortField] as number; + const id = e.params.entityId; + + // get the current item to know its old position and scope + const item = await kysely + .selectFrom(entityName) + .selectAll() + .where("id" as any, "=", id) + .executeTakeFirst(); + + if (!item) return data; + + const oldPosition = item[sortField] as number | null | undefined; + const scopeValue = scopeField ? item[scopeField] : undefined; + + // if oldPosition is null or undefined, treat as inserting at newPosition + if (oldPosition === null || oldPosition === undefined) { + // shift items at or after the new position + let query = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any) + .where(sortField as any, ">=", newPosition) + .where("id" as any, "!=", id); + + if (scopeField && scopeValue !== undefined) { + query = query.where(scopeField as any, "=", scopeValue); + } + + await query.execute(); + return data; + } + + // shift other items using kysely + if (oldPosition !== newPosition) { + if (newPosition < oldPosition) { + // moving up: increment items between newPosition and oldPosition + let query = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any) + .where(sortField as any, ">=", newPosition) + .where(sortField as any, "<", oldPosition) + .where("id" as any, "!=", id); + + if (scopeField && scopeValue !== undefined) { + query = query.where(scopeField as any, "=", scopeValue); + } + + await query.execute(); + } else { + // moving down: decrement items between oldPosition and newPosition + let query = kysely + .updateTable(entityName) + .set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any) + .where(sortField as any, ">", oldPosition) + .where(sortField as any, "<=", newPosition) + .where("id" as any, "!=", id); + + if (scopeField && scopeValue !== undefined) { + query = query.where(scopeField as any, "=", scopeValue); + } + + await query.execute(); + } + } + + return data; + }, + { + mode: "sync", + id: "bknd-sort-update", + }, + ); +}