@suluk/drizzle 0.1.4 → 0.1.5
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 +1 -1
- package/src/cas.ts +32 -0
- package/src/index.ts +3 -0
- package/test/cas.test.ts +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/drizzle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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,32 @@
|
|
|
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> } } } }
|
|
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
|
+
}
|
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, type WriteResult, type ClaimDb } from "./cas";
|
package/test/cas.test.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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, 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
|
+
});
|