@suluk/drizzle 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/drizzle",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/cas.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Once-only WRITE primitives — the concurrency-correctness skeleton a money/state-machine path needs, made
3
+ * driver-agnostic. `rowsChanged` normalizes the affected-row count across drivers (bun:sqlite `.changes`, D1
4
+ * `.meta.changes`, others `.rowsAffected`); `claimOnce` runs a CONDITIONAL update and reports whether THIS call
5
+ * won the transition (changed a row) — the compare-and-set that makes "pending→paid", "paid→refunded", an
6
+ * atomic latch flip, or a claim-then-notify sweep fire exactly once under concurrent delivery. The state machine
7
+ * (which transitions, which side-effects) stays in the app; this owns only the race-safe claim, behind a port.
8
+ */
9
+ import type { SQL } from "drizzle-orm";
10
+
11
+ /** A drizzle `.run()` result across drivers (bun:sqlite / D1 / better-sqlite3). */
12
+ export type WriteResult = { changes?: number; rowsAffected?: number; meta?: { changes?: number } } | unknown;
13
+
14
+ /** The number of rows a write affected, normalized across drivers (0 when unknown). */
15
+ export function rowsChanged(result: WriteResult): number {
16
+ const r = result as { changes?: number; rowsAffected?: number; meta?: { changes?: number } } | null | undefined;
17
+ return Number(r?.meta?.changes ?? r?.changes ?? r?.rowsAffected ?? 0) || 0;
18
+ }
19
+
20
+ /** Minimal drizzle handle for a conditional update (bun:sqlite sync or D1 async — both awaited). */
21
+ export interface ClaimDb { update: (table: unknown) => { set: (values: Record<string, unknown>) => { where: (cond: SQL) => { run: () => unknown | Promise<unknown>; returning: () => unknown | Promise<unknown> } } } }
22
+
23
+ /**
24
+ * Atomically CLAIM a transition: `UPDATE table SET set WHERE where`, returning true iff this call changed a row.
25
+ * The `where` MUST include the FROM-state guard (e.g. `and(eq(id, n), eq(status, "pending"))`) so a re-delivery /
26
+ * concurrent caller finds the row already transitioned and changes nothing → returns false. The single point that
27
+ * makes a once-only side-effect (charge, refund, decrement, email) safe to run when, and only when, the claim wins.
28
+ */
29
+ export async function claimOnce(db: ClaimDb, table: unknown, where: SQL, set: Record<string, unknown>): Promise<boolean> {
30
+ const res = await db.update(table).set(set).where(where).run();
31
+ return rowsChanged(res) > 0;
32
+ }
33
+
34
+ /**
35
+ * Atomically CLAIM a SET of rows and RETURN them: `UPDATE table SET set WHERE where RETURNING *`. The claim-then-act
36
+ * variant of {@link claimOnce} — for a batch sweep (mark a waitlist notified / a cart-recovery emailed) where each
37
+ * row must be handled exactly once even if the sweep overlaps: a concurrent run's UPDATE claims a DISJOINT set, so
38
+ * the side-effect (email, notify) fires once per row. Returns the rows THIS call won; act only on those.
39
+ */
40
+ export async function claimRows<T = Record<string, unknown>>(db: ClaimDb, table: unknown, where: SQL, set: Record<string, unknown>): Promise<T[]> {
41
+ return (await db.update(table).set(set).where(where).returning()) as T[];
42
+ }
package/src/index.ts CHANGED
@@ -44,3 +44,6 @@ export { tableDDL, schemaDDL, type DdlOptions } from "./ddl";
44
44
  // Driver-agnostic gated CRUD HANDLERS (the @suluk/hono gate engine over a drizzle table) — ONE impl for dev (bun:sqlite,
45
45
  // sync) + worker (D1, async); the db is injected as a resolver, so the two runtimes share one factory, no twin drift.
46
46
  export { crudHandlers, type CrudHandlers, type CrudHandlerOptions, type CrudDb } from "./handlers";
47
+ // once-only WRITE primitives — the race-safe compare-and-set skeleton for money/state-machine paths (normalize the
48
+ // affected-row count across drivers; claim a transition exactly once). The transitions/side-effects stay in the app.
49
+ export { rowsChanged, claimOnce, claimRows, type WriteResult, type ClaimDb } from "./cas";
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Once-only WRITE primitives. rowsChanged normalizes every driver's result shape; claimOnce makes a conditional
3
+ * transition fire exactly once — the second claim of the same transition wins nothing (the money-path guarantee).
4
+ */
5
+ import { test, expect, describe, beforeEach } from "bun:test";
6
+ import { Database } from "bun:sqlite";
7
+ import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
8
+ import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
9
+ import { and, eq } from "drizzle-orm";
10
+ import { rowsChanged, claimOnce, claimRows, schemaDDL, type ClaimDb } from "../src/index";
11
+
12
+ describe("rowsChanged", () => {
13
+ test("normalizes bun:sqlite .changes, D1 .meta.changes, others .rowsAffected; 0 when unknown", () => {
14
+ expect(rowsChanged({ changes: 1 })).toBe(1); // bun:sqlite / better-sqlite3
15
+ expect(rowsChanged({ meta: { changes: 2 } })).toBe(2); // D1
16
+ expect(rowsChanged({ rowsAffected: 3 })).toBe(3); // some drivers
17
+ expect(rowsChanged({})).toBe(0);
18
+ expect(rowsChanged(null)).toBe(0);
19
+ expect(rowsChanged(undefined)).toBe(0);
20
+ });
21
+ });
22
+
23
+ const order = sqliteTable("order", {
24
+ id: integer("id").primaryKey({ autoIncrement: true }),
25
+ status: text("status").notNull().default("pending"),
26
+ });
27
+
28
+ describe("claimOnce — atomic compare-and-set", () => {
29
+ let db: BunSQLiteDatabase;
30
+ beforeEach(() => { const s = new Database(":memory:"); s.exec(schemaDDL([order])); db = drizzle(s); db.insert(order).values({ status: "pending" }).run(); });
31
+
32
+ test("first claim of pending→paid wins; the second (re-delivery) changes nothing", async () => {
33
+ const won1 = await claimOnce(db as unknown as ClaimDb, order, and(eq(order.id, 1), eq(order.status, "pending"))!, { status: "paid" });
34
+ const won2 = await claimOnce(db as unknown as ClaimDb, order, and(eq(order.id, 1), eq(order.status, "pending"))!, { status: "paid" });
35
+ expect(won1).toBe(true);
36
+ expect(won2).toBe(false); // already paid — the FROM-state guard fails, no row changed
37
+ expect(db.select().from(order).where(eq(order.id, 1)).get()!.status).toBe("paid");
38
+ });
39
+
40
+ test("a transition whose FROM-state doesn't match claims nothing", async () => {
41
+ // row is pending; try to claim paid→cancelled → no match
42
+ expect(await claimOnce(db as unknown as ClaimDb, order, and(eq(order.id, 1), eq(order.status, "paid"))!, { status: "cancelled" })).toBe(false);
43
+ expect(db.select().from(order).where(eq(order.id, 1)).get()!.status).toBe("pending"); // untouched
44
+ });
45
+ });
46
+
47
+ describe("claimRows — claim a set + return exactly the rows this call won", () => {
48
+ let db: BunSQLiteDatabase;
49
+ beforeEach(() => { const s = new Database(":memory:"); s.exec(schemaDDL([order])); db = drizzle(s); for (let i = 0; i < 3; i++) db.insert(order).values({ status: "pending" }).run(); });
50
+
51
+ test("claims matching rows once; a re-run claims a disjoint (empty) set", async () => {
52
+ const first = await claimRows<{ id: number }>(db as unknown as ClaimDb, order, eq(order.status, "pending"), { status: "paid" });
53
+ expect(first.map((r) => r.id).sort()).toEqual([1, 2, 3]); // returned the rows it flipped
54
+ const second = await claimRows(db as unknown as ClaimDb, order, eq(order.status, "pending"), { status: "paid" });
55
+ expect(second.length).toBe(0); // already paid → nothing left to claim (no double-handling)
56
+ });
57
+ });