sumak 0.0.8 → 0.0.9

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/README.md CHANGED
@@ -689,6 +689,38 @@ Available: `clearWhere()`, `clearOrderBy()`, `clearLimit()`, `clearOffset()`, `c
689
689
 
690
690
  ---
691
691
 
692
+ ## Cursor Pagination
693
+
694
+ ```ts
695
+ // Forward pagination (after cursor)
696
+ db.selectFrom("users")
697
+ .select("id", "name")
698
+ .cursorPaginate({ column: "id", after: 42, pageSize: 20 })
699
+ .toSQL()
700
+ // SELECT "id", "name" FROM "users" WHERE ("id" > $1) ORDER BY "id" ASC LIMIT 21
701
+ // params: [42] — pageSize + 1 for hasNextPage detection
702
+
703
+ // Backward pagination (before cursor)
704
+ db.selectFrom("users")
705
+ .select("id", "name")
706
+ .cursorPaginate({ column: "id", before: 100, pageSize: 20 })
707
+ .toSQL()
708
+ // WHERE ("id" < $1) ORDER BY "id" DESC LIMIT 21
709
+
710
+ // First page (no cursor)
711
+ db.selectFrom("users").select("id", "name").cursorPaginate({ column: "id", pageSize: 20 }).toSQL()
712
+ // LIMIT 21
713
+
714
+ // With existing WHERE — ANDs together
715
+ db.selectFrom("users")
716
+ .select("id", "name")
717
+ .where(({ active }) => active.eq(true))
718
+ .cursorPaginate({ column: "id", after: lastId, pageSize: 20 })
719
+ .toSQL()
720
+ ```
721
+
722
+ ---
723
+
692
724
  ## Raw SQL
693
725
 
694
726
  ### `sql` tagged template
@@ -982,14 +1014,145 @@ Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
982
1014
 
983
1015
  ## Plugins
984
1016
 
1017
+ ### WithSchemaPlugin
1018
+
985
1019
  ```ts
986
- import { WithSchemaPlugin, SoftDeletePlugin, CamelCasePlugin } from "sumak"
1020
+ const db = sumak({
1021
+ plugins: [new WithSchemaPlugin("public")],
1022
+ ...
1023
+ })
1024
+ // SELECT * FROM "public"."users"
1025
+ ```
1026
+
1027
+ ### SoftDeletePlugin
1028
+
1029
+ ```ts
1030
+ // Mode "convert" (default) — DELETE becomes UPDATE SET deleted_at = NOW()
1031
+ const db = sumak({
1032
+ plugins: [new SoftDeletePlugin({ tables: ["users"], mode: "convert" })],
1033
+ ...
1034
+ })
1035
+
1036
+ db.deleteFrom("users").where(({ id }) => id.eq(1)).toSQL()
1037
+ // UPDATE "users" SET "deleted_at" = NOW() WHERE ("id" = $1) AND ("deleted_at" IS NULL)
1038
+
1039
+ // Mode "filter" — just adds WHERE deleted_at IS NULL (no DELETE conversion)
1040
+ new SoftDeletePlugin({ tables: ["users"], mode: "filter" })
1041
+ ```
987
1042
 
1043
+ ### AuditTimestampPlugin
1044
+
1045
+ ```ts
1046
+ // Auto-inject created_at/updated_at timestamps
1047
+ const db = sumak({
1048
+ plugins: [new AuditTimestampPlugin({ tables: ["users"] })],
1049
+ ...
1050
+ })
1051
+
1052
+ db.insertInto("users").values({ name: "Alice" }).toSQL()
1053
+ // INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ($1, NOW(), NOW())
1054
+
1055
+ db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
1056
+ // UPDATE "users" SET "name" = $1, "updated_at" = NOW() WHERE ...
1057
+ ```
1058
+
1059
+ ### MultiTenantPlugin
1060
+
1061
+ ```ts
1062
+ // Auto-inject tenant_id on all queries
1063
+ // Use a callback for per-request tenant resolution:
1064
+ const db = sumak({
1065
+ plugins: [
1066
+ new MultiTenantPlugin({
1067
+ tables: ["users", "posts"],
1068
+ tenantId: () => getCurrentTenantId(), // called per query
1069
+ }),
1070
+ ],
1071
+ ...
1072
+ })
1073
+
1074
+ db.selectFrom("users").select("id").toSQL()
1075
+ // SELECT "id" FROM "users" WHERE ("tenant_id" = $1)
1076
+
1077
+ db.insertInto("users").values({ name: "Alice" }).toSQL()
1078
+ // INSERT INTO "users" ("name", "tenant_id") VALUES ($1, $2)
1079
+ ```
1080
+
1081
+ ### QueryLimitPlugin
1082
+
1083
+ ```ts
1084
+ // Auto-inject LIMIT on unbounded SELECTs
1085
+ const db = sumak({
1086
+ plugins: [new QueryLimitPlugin({ maxRows: 1000 })],
1087
+ ...
1088
+ })
1089
+
1090
+ db.selectFrom("users").select("id").toSQL()
1091
+ // SELECT "id" FROM "users" LIMIT 1000
1092
+
1093
+ db.selectFrom("users").select("id").limit(5).toSQL()
1094
+ // SELECT "id" FROM "users" LIMIT 5 — explicit limit preserved
1095
+ ```
1096
+
1097
+ ### CamelCasePlugin
1098
+
1099
+ ```ts
1100
+ // Transform snake_case result columns to camelCase
1101
+ const db = sumak({
1102
+ plugins: [new CamelCasePlugin()],
1103
+ ...
1104
+ })
1105
+ ```
1106
+
1107
+ ### OptimisticLockPlugin
1108
+
1109
+ ```ts
1110
+ // Auto-inject WHERE version = N and SET version = version + 1 on UPDATE
1111
+ // Use a callback for per-row version:
1112
+ let rowVersion = 3
1113
+ const db = sumak({
1114
+ plugins: [
1115
+ new OptimisticLockPlugin({
1116
+ tables: ["users"],
1117
+ currentVersion: () => rowVersion, // called per query
1118
+ }),
1119
+ ],
1120
+ ...
1121
+ })
1122
+
1123
+ rowVersion = fetchedRow.version // set before each update
1124
+ db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
1125
+ // UPDATE "users" SET "name" = $1, "version" = ("version" + 1)
1126
+ // WHERE ("id" = $2) AND ("version" = $3)
1127
+ ```
1128
+
1129
+ ### DataMaskingPlugin
1130
+
1131
+ ```ts
1132
+ // Mask sensitive data in query results
1133
+ const plugin = new DataMaskingPlugin({
1134
+ rules: [
1135
+ { column: "email", mask: "email" }, // "alice@example.com" → "al***@example.com"
1136
+ { column: "phone", mask: "phone" }, // "+1234567890" → "***7890"
1137
+ { column: "name", mask: "partial" }, // "John Doe" → "Jo***"
1138
+ { column: "ssn", mask: (v) => `***-**-${String(v).slice(-4)}` }, // custom
1139
+ ],
1140
+ })
1141
+
1142
+ const db = sumak({ plugins: [plugin], ... })
1143
+ ```
1144
+
1145
+ ### Combining Plugins
1146
+
1147
+ ```ts
988
1148
  const db = sumak({
989
1149
  dialect: pgDialect(),
990
1150
  plugins: [
991
- new WithSchemaPlugin("public"), // auto "public"."users"
992
- new SoftDeletePlugin({ tables: ["users"] }), // auto WHERE deleted_at IS NULL
1151
+ new WithSchemaPlugin("public"),
1152
+ new SoftDeletePlugin({ tables: ["users"] }),
1153
+ new AuditTimestampPlugin({ tables: ["users", "posts"] }),
1154
+ new MultiTenantPlugin({ tables: ["users", "posts"], tenantId: () => currentTenantId }),
1155
+ new QueryLimitPlugin({ maxRows: 5000 }),
993
1156
  ],
994
1157
  tables: { ... },
995
1158
  })
@@ -8,6 +8,8 @@ export class TypedDeleteBuilder {
8
8
  _builder;
9
9
 
10
10
  _printer;
11
+
12
+ _compile;
11
13
  constructor(table) {
12
14
  this._builder = new DeleteBuilder().from(table);
13
15
  }
@@ -16,6 +18,7 @@ export class TypedDeleteBuilder {
16
18
  const t = new TypedDeleteBuilder("");
17
19
  t._builder = builder;
18
20
  t._printer = this._printer;
21
+ t._compile = this._compile;
19
22
  return t;
20
23
  }
21
24
 
@@ -77,6 +80,7 @@ export class TypedDeleteBuilder {
77
80
  }
78
81
 
79
82
  toSQL() {
83
+ if (this._compile) return this._compile(this.build());
80
84
  if (!this._printer) {
81
85
  throw new Error("toSQL() requires a printer. Use db.deleteFrom() or pass a printer to compile().");
82
86
  }
@@ -73,7 +73,7 @@ export declare class TypedInsertBuilder<
73
73
  $if(condition: boolean, fn: (qb: TypedInsertBuilder<DB, TB>) => TypedInsertBuilder<DB, TB>): TypedInsertBuilder<DB, TB>;
74
74
  build(): InsertNode;
75
75
  compile(printer: Printer): CompiledQuery;
76
- /** Compile to SQL using the dialect's printer. */
76
+ /** Compile to SQL using the full pipeline. */
77
77
  toSQL(): CompiledQuery;
78
78
  /** EXPLAIN this query. */
79
79
  explain(options?: {
@@ -8,6 +8,8 @@ export class TypedInsertBuilder {
8
8
  _builder;
9
9
 
10
10
  _printer;
11
+
12
+ _compile;
11
13
  constructor(table) {
12
14
  this._builder = new InsertBuilder().into(table);
13
15
  }
@@ -16,6 +18,7 @@ export class TypedInsertBuilder {
16
18
  const t = new TypedInsertBuilder("");
17
19
  t._builder = builder;
18
20
  t._printer = this._printer;
21
+ t._compile = this._compile;
19
22
  return t;
20
23
  }
21
24
 
@@ -153,6 +156,7 @@ export class TypedInsertBuilder {
153
156
  }
154
157
 
155
158
  toSQL() {
159
+ if (this._compile) return this._compile(this.build());
156
160
  if (!this._printer) {
157
161
  throw new Error("toSQL() requires a printer. Use db.insertInto() or pass a printer to compile().");
158
162
  }
@@ -19,7 +19,7 @@ export declare class TypedSelectBuilder<
19
19
  > {
20
20
  private _table;
21
21
  private _printer?;
22
- constructor(builder: SelectBuilder, table?: string, printer?: Printer);
22
+ constructor(builder: SelectBuilder, table?: string, printer?: Printer, compile?: (node: import("../ast/nodes.ts").ASTNode) => CompiledQuery);
23
23
  /** Select specific columns. Narrows O. */
24
24
  select<K extends keyof O & string>(...cols: K[]): TypedSelectBuilder<DB, TB, Pick<O, K>>;
25
25
  /** Select all columns. */
@@ -165,6 +165,25 @@ export declare class TypedSelectBuilder<
165
165
  $call<R>(fn: (qb: TypedSelectBuilder<DB, TB, O>) => R): R;
166
166
  /** Conditionally apply a transformation. */
167
167
  $if<O2>(condition: boolean, fn: (qb: TypedSelectBuilder<DB, TB, O>) => TypedSelectBuilder<DB, TB, O2>): TypedSelectBuilder<DB, TB, O | O2>;
168
+ /**
169
+ * Cursor-based (keyset) pagination.
170
+ *
171
+ * Adds WHERE column > cursor (ASC) or column < cursor (DESC),
172
+ * ORDER BY column, and LIMIT pageSize + 1 (for hasNextPage detection).
173
+ *
174
+ * ```ts
175
+ * db.selectFrom("users")
176
+ * .select("id", "name")
177
+ * .cursorPaginate({ column: "id", after: 42, pageSize: 20 })
178
+ * .toSQL()
179
+ * ```
180
+ */
181
+ cursorPaginate(options: {
182
+ column: keyof O & string;
183
+ after?: unknown;
184
+ before?: unknown;
185
+ pageSize: number;
186
+ }): TypedSelectBuilder<DB, TB, O>;
168
187
  /** Build the AST node. */
169
188
  build(): SelectNode;
170
189
  /** Compile to SQL with explicit printer. */
@@ -7,24 +7,27 @@ export class TypedSelectBuilder {
7
7
  _builder;
8
8
  _table;
9
9
  _printer;
10
- constructor(builder, table, printer) {
10
+
11
+ _compile;
12
+ constructor(builder, table, printer, compile) {
11
13
  this._builder = builder;
12
14
  this._table = table ?? "";
13
15
  this._printer = printer;
16
+ this._compile = compile;
14
17
  }
15
18
 
16
19
  select(...cols) {
17
- return new TypedSelectBuilder(this._builder.columns(...cols), this._table, this._printer);
20
+ return new TypedSelectBuilder(this._builder.columns(...cols), this._table, this._printer, this._compile);
18
21
  }
19
22
 
20
23
  selectAll() {
21
- return new TypedSelectBuilder(this._builder.allColumns(), this._table, this._printer);
24
+ return new TypedSelectBuilder(this._builder.allColumns(), this._table, this._printer, this._compile);
22
25
  }
23
26
 
24
27
  selectExpr(expr, alias) {
25
28
  const node = unwrap(expr);
26
29
  const aliased = aliasExpr(node, alias);
27
- return new TypedSelectBuilder(this._builder.columns(aliased), this._table, this._printer);
30
+ return new TypedSelectBuilder(this._builder.columns(aliased), this._table, this._printer, this._compile);
28
31
  }
29
32
 
30
33
  selectExprs(exprs) {
@@ -34,22 +37,22 @@ export class TypedSelectBuilder {
34
37
  const aliased = aliasExpr(node, alias);
35
38
  builder = builder.columns(aliased);
36
39
  }
37
- return new TypedSelectBuilder(builder, this._table, this._printer);
40
+ return new TypedSelectBuilder(builder, this._table, this._printer, this._compile);
38
41
  }
39
42
 
40
43
  distinct() {
41
- return new TypedSelectBuilder(this._builder.distinct(), this._table, this._printer);
44
+ return new TypedSelectBuilder(this._builder.distinct(), this._table, this._printer, this._compile);
42
45
  }
43
46
 
44
47
  distinctOn(...cols) {
45
- return new TypedSelectBuilder(this._builder.distinctOn(...cols), this._table, this._printer);
48
+ return new TypedSelectBuilder(this._builder.distinctOn(...cols), this._table, this._printer, this._compile);
46
49
  }
47
50
 
48
51
  where(exprOrCallback) {
49
52
  if (typeof exprOrCallback === "function") {
50
53
  const cols = createColumnProxies(this._table);
51
54
  const result = exprOrCallback(cols);
52
- return new TypedSelectBuilder(this._builder.where(unwrap(result)), this._table, this._printer);
55
+ return new TypedSelectBuilder(this._builder.where(unwrap(result)), this._table, this._printer, this._compile);
53
56
  }
54
57
  return new TypedSelectBuilder(this._builder.where(unwrap(exprOrCallback)), this._table, this._printer);
55
58
  }
@@ -80,7 +83,7 @@ export class TypedSelectBuilder {
80
83
 
81
84
  groupBy(...cols) {
82
85
  const resolved = cols.map((c) => typeof c === "string" ? c : unwrap(c));
83
- return new TypedSelectBuilder(this._builder.groupBy(...resolved), this._table, this._printer);
86
+ return new TypedSelectBuilder(this._builder.groupBy(...resolved), this._table, this._printer, this._compile);
84
87
  }
85
88
 
86
89
  having(exprOrCallback) {
@@ -112,31 +115,31 @@ export class TypedSelectBuilder {
112
115
  }
113
116
 
114
117
  forSystemTime(clause) {
115
- return new TypedSelectBuilder(this._builder.forSystemTime(clause), this._table, this._printer);
118
+ return new TypedSelectBuilder(this._builder.forSystemTime(clause), this._table, this._printer, this._compile);
116
119
  }
117
120
 
118
121
  forUpdate() {
119
- return new TypedSelectBuilder(this._builder.forUpdate(), this._table, this._printer);
122
+ return new TypedSelectBuilder(this._builder.forUpdate(), this._table, this._printer, this._compile);
120
123
  }
121
124
 
122
125
  forShare() {
123
- return new TypedSelectBuilder(this._builder.forShare(), this._table, this._printer);
126
+ return new TypedSelectBuilder(this._builder.forShare(), this._table, this._printer, this._compile);
124
127
  }
125
128
 
126
129
  forNoKeyUpdate() {
127
- return new TypedSelectBuilder(this._builder.forNoKeyUpdate(), this._table, this._printer);
130
+ return new TypedSelectBuilder(this._builder.forNoKeyUpdate(), this._table, this._printer, this._compile);
128
131
  }
129
132
 
130
133
  forKeyShare() {
131
- return new TypedSelectBuilder(this._builder.forKeyShare(), this._table, this._printer);
134
+ return new TypedSelectBuilder(this._builder.forKeyShare(), this._table, this._printer, this._compile);
132
135
  }
133
136
 
134
137
  skipLocked() {
135
- return new TypedSelectBuilder(this._builder.skipLocked(), this._table, this._printer);
138
+ return new TypedSelectBuilder(this._builder.skipLocked(), this._table, this._printer, this._compile);
136
139
  }
137
140
 
138
141
  noWait() {
139
- return new TypedSelectBuilder(this._builder.noWait(), this._table, this._printer);
142
+ return new TypedSelectBuilder(this._builder.noWait(), this._table, this._printer, this._compile);
140
143
  }
141
144
 
142
145
  with(name, query, recursive = false) {
@@ -144,11 +147,11 @@ export class TypedSelectBuilder {
144
147
  }
145
148
 
146
149
  union(query) {
147
- return new TypedSelectBuilder(this._builder.union(query.build()), this._table, this._printer);
150
+ return new TypedSelectBuilder(this._builder.union(query.build()), this._table, this._printer, this._compile);
148
151
  }
149
152
 
150
153
  unionAll(query) {
151
- return new TypedSelectBuilder(this._builder.unionAll(query.build()), this._table, this._printer);
154
+ return new TypedSelectBuilder(this._builder.unionAll(query.build()), this._table, this._printer, this._compile);
152
155
  }
153
156
 
154
157
  intersect(query) {
@@ -160,7 +163,7 @@ export class TypedSelectBuilder {
160
163
  }
161
164
 
162
165
  except(query) {
163
- return new TypedSelectBuilder(this._builder.except(query.build()), this._table, this._printer);
166
+ return new TypedSelectBuilder(this._builder.except(query.build()), this._table, this._printer, this._compile);
164
167
  }
165
168
 
166
169
  exceptAll(query) {
@@ -219,7 +222,7 @@ export class TypedSelectBuilder {
219
222
  }
220
223
 
221
224
  crossJoin(table) {
222
- return new TypedSelectBuilder(this._builder.join("CROSS", table), this._table, this._printer);
225
+ return new TypedSelectBuilder(this._builder.join("CROSS", table), this._table, this._printer, this._compile);
223
226
  }
224
227
 
225
228
  crossJoinLateral(subquery, alias) {
@@ -228,7 +231,7 @@ export class TypedSelectBuilder {
228
231
  query: subquery.build(),
229
232
  alias
230
233
  };
231
- return new TypedSelectBuilder(this._builder.crossJoinLateral(sub), this._table, this._printer);
234
+ return new TypedSelectBuilder(this._builder.crossJoinLateral(sub), this._table, this._printer, this._compile);
232
235
  }
233
236
 
234
237
  clearWhere() {
@@ -291,6 +294,49 @@ export class TypedSelectBuilder {
291
294
  return this;
292
295
  }
293
296
 
297
+ cursorPaginate(options) {
298
+ const { column, after, before, pageSize } = options;
299
+ let builder = this._builder;
300
+ if (after !== undefined) {
301
+ const condition = {
302
+ type: "binary_op",
303
+ op: ">",
304
+ left: {
305
+ type: "column_ref",
306
+ column
307
+ },
308
+ right: {
309
+ type: "param",
310
+ index: 0,
311
+ value: after
312
+ }
313
+ };
314
+ builder = builder.where(condition);
315
+ builder = builder.orderBy(column, "ASC");
316
+ } else if (before !== undefined) {
317
+ const condition = {
318
+ type: "binary_op",
319
+ op: "<",
320
+ left: {
321
+ type: "column_ref",
322
+ column
323
+ },
324
+ right: {
325
+ type: "param",
326
+ index: 0,
327
+ value: before
328
+ }
329
+ };
330
+ builder = builder.where(condition);
331
+ builder = builder.orderBy(column, "DESC");
332
+ }
333
+ builder = builder.limit({
334
+ type: "literal",
335
+ value: pageSize + 1
336
+ });
337
+ return new TypedSelectBuilder(builder, this._table, this._printer, this._compile);
338
+ }
339
+
294
340
  build() {
295
341
  return this._builder.build();
296
342
  }
@@ -300,6 +346,9 @@ export class TypedSelectBuilder {
300
346
  }
301
347
 
302
348
  toSQL() {
349
+ if (this._compile) {
350
+ return this._compile(this.build());
351
+ }
303
352
  if (!this._printer) {
304
353
  throw new Error("toSQL() requires a printer. Use db.selectFrom() or pass a printer to compile().");
305
354
  }
@@ -8,6 +8,8 @@ export class TypedUpdateBuilder {
8
8
  _builder;
9
9
 
10
10
  _printer;
11
+
12
+ _compile;
11
13
  constructor(table) {
12
14
  this._builder = new UpdateBuilder().table(table);
13
15
  }
@@ -16,6 +18,7 @@ export class TypedUpdateBuilder {
16
18
  const t = new TypedUpdateBuilder("");
17
19
  t._builder = builder;
18
20
  t._printer = this._printer;
21
+ t._compile = this._compile;
19
22
  return t;
20
23
  }
21
24
 
@@ -93,6 +96,7 @@ export class TypedUpdateBuilder {
93
96
  }
94
97
 
95
98
  toSQL() {
99
+ if (this._compile) return this._compile(this.build());
96
100
  if (!this._printer) {
97
101
  throw new Error("toSQL() requires a printer. Use db.update() or pass a printer to compile().");
98
102
  }
package/dist/index.d.mts CHANGED
@@ -45,6 +45,11 @@ export { PluginManager } from "./plugin/plugin-manager.mjs";
45
45
  export { WithSchemaPlugin } from "./plugin/with-schema.mjs";
46
46
  export { SoftDeletePlugin } from "./plugin/soft-delete.mjs";
47
47
  export { CamelCasePlugin } from "./plugin/camel-case.mjs";
48
+ export { AuditTimestampPlugin } from "./plugin/audit-timestamp.mjs";
49
+ export { OptimisticLockPlugin } from "./plugin/optimistic-lock.mjs";
50
+ export { DataMaskingPlugin } from "./plugin/data-masking.mjs";
51
+ export { MultiTenantPlugin } from "./plugin/multi-tenant.mjs";
52
+ export { QueryLimitPlugin } from "./plugin/query-limit.mjs";
48
53
  export { Hookable } from "./plugin/hooks.mjs";
49
54
  export type { HookContext, HookName, SumakHooks } from "./plugin/hooks.mjs";
50
55
  export { CreateTableBuilder, ColumnDefBuilder, ForeignKeyBuilder } from "./builder/ddl/create-table.mjs";
package/dist/index.mjs CHANGED
@@ -42,6 +42,11 @@ export { PluginManager } from "./plugin/plugin-manager.mjs";
42
42
  export { WithSchemaPlugin } from "./plugin/with-schema.mjs";
43
43
  export { SoftDeletePlugin } from "./plugin/soft-delete.mjs";
44
44
  export { CamelCasePlugin } from "./plugin/camel-case.mjs";
45
+ export { AuditTimestampPlugin } from "./plugin/audit-timestamp.mjs";
46
+ export { OptimisticLockPlugin } from "./plugin/optimistic-lock.mjs";
47
+ export { DataMaskingPlugin } from "./plugin/data-masking.mjs";
48
+ export { MultiTenantPlugin } from "./plugin/multi-tenant.mjs";
49
+ export { QueryLimitPlugin } from "./plugin/query-limit.mjs";
45
50
 
46
51
  export { Hookable } from "./plugin/hooks.mjs";
47
52
 
@@ -0,0 +1,31 @@
1
+ import type { ASTNode } from "../ast/nodes.mjs";
2
+ import type { SumakPlugin } from "./types.mjs";
3
+ interface AuditTimestampConfig {
4
+ tables: string[];
5
+ createdAt?: string;
6
+ updatedAt?: string;
7
+ }
8
+ /**
9
+ * Plugin that auto-injects created_at/updated_at timestamps.
10
+ *
11
+ * - INSERT: adds created_at and updated_at columns with NOW()
12
+ * - UPDATE: adds updated_at = NOW() to the SET clause
13
+ *
14
+ * ```ts
15
+ * const plugin = new AuditTimestampPlugin({ tables: ["users", "posts"] })
16
+ * // INSERT INTO "users" ("name") VALUES ('Ada')
17
+ * // → INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ('Ada', NOW(), NOW())
18
+ * ```
19
+ */
20
+ export declare class AuditTimestampPlugin implements SumakPlugin {
21
+ readonly name = "audit-timestamp";
22
+ private tables;
23
+ private createdAt;
24
+ private updatedAt;
25
+ constructor(config: AuditTimestampConfig);
26
+ transformNode(node: ASTNode): ASTNode;
27
+ private isTargetTable;
28
+ private transformInsert;
29
+ private transformUpdate;
30
+ }
31
+ export {};
@@ -0,0 +1,54 @@
1
+ import { fn } from "../ast/expression.mjs";
2
+
3
+ export class AuditTimestampPlugin {
4
+ name = "audit-timestamp";
5
+ tables;
6
+ createdAt;
7
+ updatedAt;
8
+ constructor(config) {
9
+ this.tables = new Set(config.tables);
10
+ this.createdAt = config.createdAt ?? "created_at";
11
+ this.updatedAt = config.updatedAt ?? "updated_at";
12
+ }
13
+ transformNode(node) {
14
+ switch (node.type) {
15
+ case "insert": return this.transformInsert(node);
16
+ case "update": return this.transformUpdate(node);
17
+ default: return node;
18
+ }
19
+ }
20
+ isTargetTable(tableName) {
21
+ return this.tables.has(tableName);
22
+ }
23
+ transformInsert(node) {
24
+ if (!this.isTargetTable(node.table.name)) return node;
25
+ const now = fn("NOW", []);
26
+ const columns = [
27
+ ...node.columns,
28
+ this.createdAt,
29
+ this.updatedAt
30
+ ];
31
+ const values = node.values.map((row) => [
32
+ ...row,
33
+ now,
34
+ now
35
+ ]);
36
+ return {
37
+ ...node,
38
+ columns,
39
+ values
40
+ };
41
+ }
42
+ transformUpdate(node) {
43
+ if (!this.isTargetTable(node.table.name)) return node;
44
+ const now = fn("NOW", []);
45
+ const set = [...node.set, {
46
+ column: this.updatedAt,
47
+ value: now
48
+ }];
49
+ return {
50
+ ...node,
51
+ set
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,31 @@
1
+ import type { SumakPlugin } from "./types.mjs";
2
+ type MaskFunction = (value: unknown) => unknown;
3
+ interface DataMaskingConfig {
4
+ rules: {
5
+ column: string;
6
+ mask: MaskFunction | "email" | "phone" | "partial";
7
+ }[];
8
+ }
9
+ /**
10
+ * Result-transform plugin that masks sensitive data in query results.
11
+ *
12
+ * Supports built-in mask types (`"email"`, `"phone"`, `"partial"`) and
13
+ * custom mask functions.
14
+ *
15
+ * ```ts
16
+ * const plugin = new DataMaskingPlugin({
17
+ * rules: [
18
+ * { column: "email", mask: "email" },
19
+ * { column: "phone", mask: "phone" },
20
+ * { column: "name", mask: "partial" },
21
+ * ],
22
+ * })
23
+ * ```
24
+ */
25
+ export declare class DataMaskingPlugin implements SumakPlugin {
26
+ readonly name = "data-masking";
27
+ private rules;
28
+ constructor(config: DataMaskingConfig);
29
+ transformResult(rows: Record<string, unknown>[]): Record<string, unknown>[];
30
+ }
31
+ export {};
@@ -0,0 +1,49 @@
1
+ function maskEmail(value) {
2
+ if (typeof value !== "string") return value;
3
+ const atIndex = value.indexOf("@");
4
+ if (atIndex < 0) return value;
5
+ const local = value.slice(0, atIndex);
6
+ const domain = value.slice(atIndex);
7
+ const keep = local.slice(0, 2);
8
+ return `${keep}***${domain}`;
9
+ }
10
+ function maskPhone(value) {
11
+ if (typeof value !== "string") return value;
12
+ const last4 = value.slice(-4);
13
+ return `***${last4}`;
14
+ }
15
+ function maskPartial(value) {
16
+ if (typeof value !== "string") return value;
17
+ const keep = value.slice(0, 2);
18
+ return `${keep}***`;
19
+ }
20
+ const builtinMasks = {
21
+ email: maskEmail,
22
+ phone: maskPhone,
23
+ partial: maskPartial
24
+ };
25
+
26
+ export class DataMaskingPlugin {
27
+ name = "data-masking";
28
+ rules;
29
+ constructor(config) {
30
+ this.rules = new Map();
31
+ for (const rule of config.rules) {
32
+ const fn = typeof rule.mask === "string" ? builtinMasks[rule.mask] : rule.mask;
33
+ if (fn) {
34
+ this.rules.set(rule.column, fn);
35
+ }
36
+ }
37
+ }
38
+ transformResult(rows) {
39
+ return rows.map((row) => {
40
+ const masked = { ...row };
41
+ for (const [column, fn] of this.rules) {
42
+ if (column in masked) {
43
+ masked[column] = fn(masked[column]);
44
+ }
45
+ }
46
+ return masked;
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,37 @@
1
+ import type { ASTNode } from "../ast/nodes.mjs";
2
+ import type { SumakPlugin } from "./types.mjs";
3
+ /**
4
+ * Plugin that auto-injects tenant_id filtering on all queries for configured tables.
5
+ *
6
+ * - SELECT/UPDATE/DELETE: adds `WHERE tenant_id = ?` (ANDed with existing WHERE)
7
+ * - INSERT: adds tenant_id column and value to each row
8
+ *
9
+ * `tenantId` accepts a value OR a function that returns the value per-query.
10
+ * Use a function for per-request tenant resolution (JWT, session, etc.):
11
+ *
12
+ * ```ts
13
+ * new MultiTenantPlugin({
14
+ * tables: ["users", "posts"],
15
+ * tenantId: () => getCurrentTenantId(),
16
+ * })
17
+ * ```
18
+ */
19
+ export declare class MultiTenantPlugin implements SumakPlugin {
20
+ readonly name = "multi-tenant";
21
+ private tables;
22
+ private column;
23
+ private getTenantId;
24
+ constructor(config: {
25
+ tables: string[];
26
+ column?: string;
27
+ tenantId: unknown | (() => unknown);
28
+ });
29
+ transformNode(node: ASTNode): ASTNode;
30
+ private isTargetTable;
31
+ private tenantCondition;
32
+ private addCondition;
33
+ private transformSelect;
34
+ private transformUpdate;
35
+ private transformDelete;
36
+ private transformInsert;
37
+ }
@@ -0,0 +1,66 @@
1
+ import { and, col, eq, param } from "../ast/expression.mjs";
2
+
3
+ export class MultiTenantPlugin {
4
+ name = "multi-tenant";
5
+ tables;
6
+ column;
7
+ getTenantId;
8
+ constructor(config) {
9
+ this.tables = new Set(config.tables);
10
+ this.column = config.column ?? "tenant_id";
11
+ this.getTenantId = typeof config.tenantId === "function" ? config.tenantId : () => config.tenantId;
12
+ }
13
+ transformNode(node) {
14
+ switch (node.type) {
15
+ case "select": return this.transformSelect(node);
16
+ case "update": return this.transformUpdate(node);
17
+ case "delete": return this.transformDelete(node);
18
+ case "insert": return this.transformInsert(node);
19
+ default: return node;
20
+ }
21
+ }
22
+ isTargetTable(tableName) {
23
+ return this.tables.has(tableName);
24
+ }
25
+ tenantCondition() {
26
+ return eq(col(this.column), param(0, this.getTenantId()));
27
+ }
28
+ addCondition(existing) {
29
+ const condition = this.tenantCondition();
30
+ return existing ? and(existing, condition) : condition;
31
+ }
32
+ transformSelect(node) {
33
+ if (!node.from || node.from.type !== "table_ref" || !this.isTargetTable(node.from.name)) {
34
+ return node;
35
+ }
36
+ return {
37
+ ...node,
38
+ where: this.addCondition(node.where)
39
+ };
40
+ }
41
+ transformUpdate(node) {
42
+ if (!this.isTargetTable(node.table.name)) return node;
43
+ return {
44
+ ...node,
45
+ where: this.addCondition(node.where)
46
+ };
47
+ }
48
+ transformDelete(node) {
49
+ if (!this.isTargetTable(node.table.name)) return node;
50
+ return {
51
+ ...node,
52
+ where: this.addCondition(node.where)
53
+ };
54
+ }
55
+ transformInsert(node) {
56
+ if (!this.isTargetTable(node.table.name)) return node;
57
+ const tenantId = this.getTenantId();
58
+ const columns = [...node.columns, this.column];
59
+ const values = node.values.map((row) => [...row, param(0, tenantId)]);
60
+ return {
61
+ ...node,
62
+ columns,
63
+ values
64
+ };
65
+ }
66
+ }
@@ -0,0 +1,38 @@
1
+ import type { ASTNode } from "../ast/nodes.mjs";
2
+ import type { SumakPlugin } from "./types.mjs";
3
+ /**
4
+ * Plugin that implements optimistic locking for configured tables.
5
+ *
6
+ * On UPDATE for configured tables:
7
+ * 1. Adds `WHERE version = :currentVersion` (ANDed with existing WHERE)
8
+ * 2. Adds `SET version = version + 1` to the SET clause
9
+ *
10
+ * `currentVersion` accepts a value OR a function that returns the value per-query.
11
+ * Use a function when the version varies per row/request:
12
+ *
13
+ * ```ts
14
+ * let rowVersion = 3
15
+ * const plugin = new OptimisticLockPlugin({
16
+ * tables: ["users"],
17
+ * currentVersion: () => rowVersion,
18
+ * })
19
+ *
20
+ * // Before each update, set rowVersion to the row's current version
21
+ * rowVersion = fetchedRow.version
22
+ * db.update("users").set({ name: "Bob" }).where(...).toSQL()
23
+ * // UPDATE ... SET "version" = "version" + 1 WHERE ... AND "version" = $N
24
+ * ```
25
+ */
26
+ export declare class OptimisticLockPlugin implements SumakPlugin {
27
+ readonly name = "optimistic-lock";
28
+ private tables;
29
+ private column;
30
+ private getVersion;
31
+ constructor(config: {
32
+ tables: string[];
33
+ column?: string;
34
+ currentVersion: number | (() => number);
35
+ });
36
+ transformNode(node: ASTNode): ASTNode;
37
+ private transformUpdate;
38
+ }
@@ -0,0 +1,35 @@
1
+ import { and, binOp, col, eq, param } from "../ast/expression.mjs";
2
+
3
+ export class OptimisticLockPlugin {
4
+ name = "optimistic-lock";
5
+ tables;
6
+ column;
7
+ getVersion;
8
+ constructor(config) {
9
+ this.tables = new Set(config.tables);
10
+ this.column = config.column ?? "version";
11
+ this.getVersion = typeof config.currentVersion === "function" ? config.currentVersion : () => config.currentVersion;
12
+ }
13
+ transformNode(node) {
14
+ if (node.type !== "update") return node;
15
+ return this.transformUpdate(node);
16
+ }
17
+ transformUpdate(node) {
18
+ if (!this.tables.has(node.table.name)) return node;
19
+ const versionCondition = eq(col(this.column), param(0, this.getVersion()));
20
+ const where = node.where ? and(node.where, versionCondition) : versionCondition;
21
+ const versionIncrement = binOp("+", col(this.column), {
22
+ type: "literal",
23
+ value: 1
24
+ });
25
+ const set = [...node.set, {
26
+ column: this.column,
27
+ value: versionIncrement
28
+ }];
29
+ return {
30
+ ...node,
31
+ set,
32
+ where
33
+ };
34
+ }
35
+ }
@@ -0,0 +1,20 @@
1
+ import type { ASTNode } from "../ast/nodes.mjs";
2
+ import type { SumakPlugin } from "./types.mjs";
3
+ /**
4
+ * Plugin that auto-injects a LIMIT on SELECT queries that don't already have one.
5
+ *
6
+ * ```ts
7
+ * const plugin = new QueryLimitPlugin({ maxRows: 500 })
8
+ * // SELECT * FROM "users"
9
+ * // → SELECT * FROM "users" LIMIT 500
10
+ * ```
11
+ */
12
+ export declare class QueryLimitPlugin implements SumakPlugin {
13
+ readonly name = "query-limit";
14
+ private maxRows;
15
+ constructor(config?: {
16
+ maxRows?: number;
17
+ });
18
+ transformNode(node: ASTNode): ASTNode;
19
+ private transformSelect;
20
+ }
@@ -0,0 +1,23 @@
1
+
2
+ export class QueryLimitPlugin {
3
+ name = "query-limit";
4
+ maxRows;
5
+ constructor(config) {
6
+ this.maxRows = config?.maxRows ?? 1e3;
7
+ }
8
+ transformNode(node) {
9
+ if (node.type !== "select") return node;
10
+ return this.transformSelect(node);
11
+ }
12
+ transformSelect(node) {
13
+ if (node.limit) return node;
14
+ const limit = {
15
+ type: "literal",
16
+ value: this.maxRows
17
+ };
18
+ return {
19
+ ...node,
20
+ limit
21
+ };
22
+ }
23
+ }
@@ -1,21 +1,30 @@
1
1
  import type { ASTNode } from "../ast/nodes.mjs";
2
2
  import type { SumakPlugin } from "./types.mjs";
3
3
  /**
4
- * Plugin that automatically adds `WHERE deleted_at IS NULL` to
5
- * SELECT, UPDATE, and DELETE queries for configured tables.
4
+ * Plugin that automatically handles soft deletes for configured tables.
5
+ *
6
+ * In "convert" mode (default):
7
+ * - SELECT/UPDATE: adds `WHERE deleted_at IS NULL`
8
+ * - DELETE: converts to `UPDATE SET deleted_at = NOW() WHERE ... AND deleted_at IS NULL`
9
+ *
10
+ * In "filter" mode:
11
+ * - SELECT/UPDATE/DELETE: adds `WHERE deleted_at IS NULL`
6
12
  *
7
13
  * ```ts
8
- * const plugin = new SoftDeletePlugin({ tables: ["users", "posts"] });
9
- * // SELECT * FROM "users" → SELECT * FROM "users" WHERE "deleted_at" IS NULL
14
+ * const plugin = new SoftDeletePlugin({ tables: ["users", "posts"] })
15
+ * // DELETE FROM "users" WHERE id = 1
16
+ * // → UPDATE "users" SET "deleted_at" = NOW() WHERE id = 1 AND "deleted_at" IS NULL
10
17
  * ```
11
18
  */
12
19
  export declare class SoftDeletePlugin implements SumakPlugin {
13
20
  readonly name = "soft-delete";
14
21
  private tables;
15
22
  private column;
23
+ private mode;
16
24
  constructor(config: {
17
25
  tables: string[];
18
26
  column?: string;
27
+ mode?: "filter" | "convert";
19
28
  });
20
29
  transformNode(node: ASTNode): ASTNode;
21
30
  private isTargetTable;
@@ -1,12 +1,14 @@
1
- import { and, col, isNull } from "../ast/expression.mjs";
1
+ import { and, col, fn, isNull } from "../ast/expression.mjs";
2
2
 
3
3
  export class SoftDeletePlugin {
4
4
  name = "soft-delete";
5
5
  tables;
6
6
  column;
7
+ mode;
7
8
  constructor(config) {
8
9
  this.tables = new Set(config.tables);
9
10
  this.column = config.column ?? "deleted_at";
11
+ this.mode = config.mode ?? "convert";
10
12
  }
11
13
  transformNode(node) {
12
14
  switch (node.type) {
@@ -44,9 +46,24 @@ export class SoftDeletePlugin {
44
46
  }
45
47
  transformDelete(node) {
46
48
  if (!this.isTargetTable(node.table.name)) return node;
47
- return {
48
- ...node,
49
- where: this.addCondition(node.where)
49
+ if (this.mode === "filter") {
50
+ return {
51
+ ...node,
52
+ where: this.addCondition(node.where)
53
+ };
54
+ }
55
+ const updateNode = {
56
+ type: "update",
57
+ table: node.table,
58
+ set: [{
59
+ column: this.column,
60
+ value: fn("NOW", [])
61
+ }],
62
+ where: this.addCondition(node.where),
63
+ returning: node.returning,
64
+ joins: node.joins,
65
+ ctes: node.ctes
50
66
  };
67
+ return updateNode;
51
68
  }
52
69
  }
package/dist/sumak.d.mts CHANGED
@@ -78,6 +78,12 @@ export declare class Sumak<DB> {
78
78
  selectFromSubquery<Alias extends string>(subquery: {
79
79
  build(): import("./ast/nodes.ts").SelectNode;
80
80
  }, alias: Alias): TypedSelectBuilder<DB, keyof DB & string, Record<string, unknown>>;
81
+ /**
82
+ * SELECT COUNT(*) FROM table — convenience shorthand.
83
+ */
84
+ selectCount<T extends keyof DB & string>(table: T): TypedSelectBuilder<DB, T, {
85
+ count: number;
86
+ }>;
81
87
  insertInto<T extends keyof DB & string>(table: T): TypedInsertBuilder<DB, T>;
82
88
  update<T extends keyof DB & string>(table: T): TypedUpdateBuilder<DB, T>;
83
89
  deleteFrom<T extends keyof DB & string>(table: T): TypedDeleteBuilder<DB, T>;
package/dist/sumak.mjs CHANGED
@@ -35,7 +35,7 @@ export class Sumak {
35
35
  return this._hooks.hook(name, handler);
36
36
  }
37
37
  selectFrom(table, alias) {
38
- return new TypedSelectBuilder(new SelectBuilder().from(table, alias), table, this._dialect.createPrinter());
38
+ return new TypedSelectBuilder(new SelectBuilder().from(table, alias), table, this._dialect.createPrinter(), (node) => this.compile(node));
39
39
  }
40
40
 
41
41
  selectFromSubquery(subquery, alias) {
@@ -46,19 +46,33 @@ export class Sumak {
46
46
  };
47
47
  return new TypedSelectBuilder(new SelectBuilder().from(sub), alias);
48
48
  }
49
+
50
+ selectCount(table) {
51
+ const star = { type: "star" };
52
+ const countFn = {
53
+ type: "function_call",
54
+ name: "COUNT",
55
+ args: [star],
56
+ alias: "count"
57
+ };
58
+ return new TypedSelectBuilder(new SelectBuilder().columns(countFn).from(table), table, this._dialect.createPrinter(), (node) => this.compile(node));
59
+ }
49
60
  insertInto(table) {
50
61
  const b = new TypedInsertBuilder(table);
51
62
  b._printer = this._dialect.createPrinter();
63
+ b._compile = (node) => this.compile(node);
52
64
  return b;
53
65
  }
54
66
  update(table) {
55
67
  const b = new TypedUpdateBuilder(table);
56
68
  b._printer = this._dialect.createPrinter();
69
+ b._compile = (node) => this.compile(node);
57
70
  return b;
58
71
  }
59
72
  deleteFrom(table) {
60
73
  const b = new TypedDeleteBuilder(table);
61
74
  b._printer = this._dialect.createPrinter();
75
+ b._compile = (node) => this.compile(node);
62
76
  return b;
63
77
  }
64
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sumak",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Type-safe SQL query builder with powerful SQL printers. Zero dependencies, tree-shakeable. Pure TypeScript.",
5
5
  "keywords": [
6
6
  "database",