sumak 0.0.7 → 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.
@@ -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);
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,14 +46,34 @@ 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
- return new TypedInsertBuilder(table);
61
+ const b = new TypedInsertBuilder(table);
62
+ b._printer = this._dialect.createPrinter();
63
+ b._compile = (node) => this.compile(node);
64
+ return b;
51
65
  }
52
66
  update(table) {
53
- return new TypedUpdateBuilder(table);
67
+ const b = new TypedUpdateBuilder(table);
68
+ b._printer = this._dialect.createPrinter();
69
+ b._compile = (node) => this.compile(node);
70
+ return b;
54
71
  }
55
72
  deleteFrom(table) {
56
- return new TypedDeleteBuilder(table);
73
+ const b = new TypedDeleteBuilder(table);
74
+ b._printer = this._dialect.createPrinter();
75
+ b._compile = (node) => this.compile(node);
76
+ return b;
57
77
  }
58
78
 
59
79
  mergeInto(target, source, sourceAlias, on) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sumak",
3
- "version": "0.0.7",
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",