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.
- package/README.md +302 -126
- package/dist/builder/eb.d.mts +0 -1
- package/dist/builder/eb.mjs +1 -5
- package/dist/builder/typed-delete.d.mts +2 -0
- package/dist/builder/typed-delete.mjs +15 -2
- package/dist/builder/typed-insert.d.mts +3 -6
- package/dist/builder/typed-insert.mjs +34 -29
- package/dist/builder/typed-merge.d.mts +1 -2
- package/dist/builder/typed-merge.mjs +9 -22
- package/dist/builder/typed-select.d.mts +24 -2
- package/dist/builder/typed-select.mjs +107 -52
- package/dist/builder/typed-update.d.mts +3 -2
- package/dist/builder/typed-update.mjs +26 -18
- package/dist/index.d.mts +6 -1
- package/dist/index.mjs +6 -1
- package/dist/plugin/audit-timestamp.d.mts +31 -0
- package/dist/plugin/audit-timestamp.mjs +54 -0
- package/dist/plugin/data-masking.d.mts +31 -0
- package/dist/plugin/data-masking.mjs +49 -0
- package/dist/plugin/multi-tenant.d.mts +37 -0
- package/dist/plugin/multi-tenant.mjs +66 -0
- package/dist/plugin/optimistic-lock.d.mts +38 -0
- package/dist/plugin/optimistic-lock.mjs +35 -0
- package/dist/plugin/query-limit.d.mts +20 -0
- package/dist/plugin/query-limit.mjs +23 -0
- package/dist/plugin/soft-delete.d.mts +13 -4
- package/dist/plugin/soft-delete.mjs +21 -4
- package/dist/sumak.d.mts +6 -0
- package/dist/sumak.mjs +24 -4
- package/package.json +1 -1
|
@@ -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
|
|
5
|
-
*
|
|
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
|
-
* //
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|