sumak 0.0.8 → 0.0.10

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.
Files changed (42) hide show
  1. package/README.md +315 -5
  2. package/dist/builder/json-optics.d.mts +106 -0
  3. package/dist/builder/json-optics.mjs +110 -0
  4. package/dist/builder/typed-delete.mjs +4 -0
  5. package/dist/builder/typed-insert.d.mts +1 -1
  6. package/dist/builder/typed-insert.mjs +4 -0
  7. package/dist/builder/typed-select.d.mts +20 -1
  8. package/dist/builder/typed-select.mjs +70 -21
  9. package/dist/builder/typed-update.mjs +4 -0
  10. package/dist/index.d.mts +12 -0
  11. package/dist/index.mjs +13 -0
  12. package/dist/normalize/expression.d.mts +26 -0
  13. package/dist/normalize/expression.mjs +330 -0
  14. package/dist/normalize/index.d.mts +4 -0
  15. package/dist/normalize/index.mjs +3 -0
  16. package/dist/normalize/query.d.mts +14 -0
  17. package/dist/normalize/query.mjs +126 -0
  18. package/dist/normalize/types.d.mts +38 -0
  19. package/dist/normalize/types.mjs +7 -0
  20. package/dist/optimize/index.d.mts +3 -0
  21. package/dist/optimize/index.mjs +2 -0
  22. package/dist/optimize/optimizer.d.mts +28 -0
  23. package/dist/optimize/optimizer.mjs +37 -0
  24. package/dist/optimize/rules.d.mts +31 -0
  25. package/dist/optimize/rules.mjs +161 -0
  26. package/dist/optimize/types.d.mts +29 -0
  27. package/dist/optimize/types.mjs +1 -0
  28. package/dist/plugin/audit-timestamp.d.mts +31 -0
  29. package/dist/plugin/audit-timestamp.mjs +54 -0
  30. package/dist/plugin/data-masking.d.mts +31 -0
  31. package/dist/plugin/data-masking.mjs +49 -0
  32. package/dist/plugin/multi-tenant.d.mts +37 -0
  33. package/dist/plugin/multi-tenant.mjs +66 -0
  34. package/dist/plugin/optimistic-lock.d.mts +38 -0
  35. package/dist/plugin/optimistic-lock.mjs +35 -0
  36. package/dist/plugin/query-limit.d.mts +20 -0
  37. package/dist/plugin/query-limit.mjs +23 -0
  38. package/dist/plugin/soft-delete.d.mts +13 -4
  39. package/dist/plugin/soft-delete.mjs +21 -4
  40. package/dist/sumak.d.mts +27 -3
  41. package/dist/sumak.mjs +41 -3
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -41,6 +41,9 @@
41
41
  - [Schema Builder (DDL)](#schema-builder-ddl)
42
42
  - [Full-Text Search](#full-text-search)
43
43
  - [Temporal Tables](#temporal-tables-sql2011)
44
+ - [JSON Optics](#json-optics)
45
+ - [Compiled Queries](#compiled-queries)
46
+ - [Query Optimization](#query-optimization)
44
47
  - [Plugins](#plugins)
45
48
  - [Hooks](#hooks)
46
49
  - [Dialects](#dialects)
@@ -428,6 +431,8 @@ db.selectFrom("users")
428
431
  .toSQL()
429
432
  ```
430
433
 
434
+ > For composable, type-tracked JSON navigation, see [JSON Optics](#json-optics).
435
+
431
436
  ### PostgreSQL Array Operators
432
437
 
433
438
  ```ts
@@ -689,6 +694,38 @@ Available: `clearWhere()`, `clearOrderBy()`, `clearLimit()`, `clearOffset()`, `c
689
694
 
690
695
  ---
691
696
 
697
+ ## Cursor Pagination
698
+
699
+ ```ts
700
+ // Forward pagination (after cursor)
701
+ db.selectFrom("users")
702
+ .select("id", "name")
703
+ .cursorPaginate({ column: "id", after: 42, pageSize: 20 })
704
+ .toSQL()
705
+ // SELECT "id", "name" FROM "users" WHERE ("id" > $1) ORDER BY "id" ASC LIMIT 21
706
+ // params: [42] — pageSize + 1 for hasNextPage detection
707
+
708
+ // Backward pagination (before cursor)
709
+ db.selectFrom("users")
710
+ .select("id", "name")
711
+ .cursorPaginate({ column: "id", before: 100, pageSize: 20 })
712
+ .toSQL()
713
+ // WHERE ("id" < $1) ORDER BY "id" DESC LIMIT 21
714
+
715
+ // First page (no cursor)
716
+ db.selectFrom("users").select("id", "name").cursorPaginate({ column: "id", pageSize: 20 }).toSQL()
717
+ // LIMIT 21
718
+
719
+ // With existing WHERE — ANDs together
720
+ db.selectFrom("users")
721
+ .select("id", "name")
722
+ .where(({ active }) => active.eq(true))
723
+ .cursorPaginate({ column: "id", after: lastId, pageSize: 20 })
724
+ .toSQL()
725
+ ```
726
+
727
+ ---
728
+
692
729
  ## Raw SQL
693
730
 
694
731
  ### `sql` tagged template
@@ -980,16 +1017,277 @@ Modes: `as_of`, `from_to`, `between`, `contained_in`, `all`.
980
1017
 
981
1018
  ---
982
1019
 
1020
+ ## JSON Optics
1021
+
1022
+ Composable, type-tracked JSON column navigation. Each `.at()` step tracks the type at that level.
1023
+
1024
+ ```ts
1025
+ import { jsonCol } from "sumak"
1026
+
1027
+ // Navigate into JSON: -> (returns JSON)
1028
+ db.selectFrom("users")
1029
+ .selectExprs(jsonCol("data").at("address").at("city").asText().as("city"))
1030
+ .toSQL()
1031
+ // SELECT "data"->'address'->>'city' AS "city" FROM "users"
1032
+
1033
+ // Text extraction: ->> (returns text)
1034
+ db.selectFrom("users").selectExprs(jsonCol("meta").text("name").as("metaName")).toSQL()
1035
+ // SELECT "meta"->>'name' AS "metaName" FROM "users"
1036
+
1037
+ // PG path operators: #> and #>>
1038
+ jsonCol("data").atPath("address.city") // #> (returns JSON)
1039
+ jsonCol("data").textPath("address.city") // #>> (returns text)
1040
+
1041
+ // With table prefix
1042
+ jsonCol("data", "users").at("settings").asText()
1043
+ ```
1044
+
1045
+ Type-safe with generics:
1046
+
1047
+ ```ts
1048
+ interface UserProfile {
1049
+ address: { city: string; zip: string }
1050
+ preferences: { theme: string }
1051
+ }
1052
+
1053
+ // Type narrows at each level
1054
+ jsonCol<UserProfile>("profile")
1055
+ .at("address") // JsonOptic<{ city: string; zip: string }>
1056
+ .at("city") // JsonOptic<string>
1057
+ .asText() // JsonExpr<string>
1058
+ ```
1059
+
1060
+ ---
1061
+
1062
+ ## Compiled Queries
1063
+
1064
+ Pre-bake SQL at setup time. At runtime, only fill parameters — zero AST traversal.
1065
+
1066
+ ```ts
1067
+ import { placeholder, compileQuery } from "sumak"
1068
+
1069
+ // Define query with named placeholders
1070
+ const findUser = compileQuery<{ userId: number }>(
1071
+ db
1072
+ .selectFrom("users")
1073
+ .select("id", "name")
1074
+ .where(({ id }) => id.eq(placeholder("userId")))
1075
+ .build(),
1076
+ db.printer(),
1077
+ )
1078
+
1079
+ // Runtime — same SQL string, different params:
1080
+ findUser({ userId: 42 })
1081
+ // → { sql: 'SELECT "id", "name" FROM "users" WHERE "id" = $1', params: [42] }
1082
+
1083
+ findUser({ userId: 99 })
1084
+ // → { sql: 'SELECT "id", "name" FROM "users" WHERE "id" = $1', params: [99] }
1085
+
1086
+ // Inspect the pre-baked SQL
1087
+ findUser.sql // 'SELECT "id", "name" FROM "users" WHERE "id" = $1'
1088
+ ```
1089
+
1090
+ ---
1091
+
1092
+ ## Query Optimization
1093
+
1094
+ sumak automatically normalizes and optimizes queries through two new pipeline layers.
1095
+
1096
+ ### Normalization (NbE)
1097
+
1098
+ Enabled by default. Reduces expressions to canonical form:
1099
+
1100
+ - **Flatten AND/OR:** `(a AND (b AND c))` → `(a AND b AND c)`
1101
+ - **Deduplicate:** `a = 1 AND b = 2 AND a = 1` → `a = 1 AND b = 2`
1102
+ - **Simplify tautologies:** `x AND true` → `x`, `x OR false` → `x`
1103
+ - **Constant folding:** `1 + 2` → `3`
1104
+ - **Double negation:** `NOT NOT x` → `x`
1105
+ - **Comparison normalization:** `1 = x` → `x = 1`
1106
+
1107
+ ### Optimization (Rewrite Rules)
1108
+
1109
+ Built-in rules applied after normalization:
1110
+
1111
+ - **Predicate pushdown:** Moves WHERE conditions into JOIN ON when they reference a single table
1112
+ - **Subquery flattening:** `SELECT * FROM (SELECT * FROM t)` → `SELECT * FROM t`
1113
+ - **WHERE true removal:** Cleans up `WHERE true` left by plugins
1114
+
1115
+ ### Configuration
1116
+
1117
+ ```ts
1118
+ // Default: both enabled
1119
+ const db = sumak({ dialect: pgDialect(), tables: { ... } })
1120
+
1121
+ // Disable normalization
1122
+ const db = sumak({ dialect: pgDialect(), normalize: false, tables: { ... } })
1123
+
1124
+ // Disable optimization
1125
+ const db = sumak({ dialect: pgDialect(), optimizeQueries: false, tables: { ... } })
1126
+ ```
1127
+
1128
+ ### Custom Rewrite Rules
1129
+
1130
+ ```ts
1131
+ import { createRule } from "sumak"
1132
+
1133
+ const defaultLimit = createRule({
1134
+ name: "default-limit",
1135
+ match: (node) => node.type === "select" && !node.limit,
1136
+ apply: (node) => ({ ...node, limit: { type: "literal", value: 1000 } }),
1137
+ })
1138
+
1139
+ const db = sumak({
1140
+ dialect: pgDialect(),
1141
+ rules: [defaultLimit],
1142
+ tables: { ... },
1143
+ })
1144
+ ```
1145
+
1146
+ Rules are applied bottom-up until a fixpoint (no more changes). Max 10 iterations by default.
1147
+
1148
+ ---
1149
+
983
1150
  ## Plugins
984
1151
 
1152
+ ### WithSchemaPlugin
1153
+
1154
+ ```ts
1155
+ const db = sumak({
1156
+ plugins: [new WithSchemaPlugin("public")],
1157
+ ...
1158
+ })
1159
+ // SELECT * FROM "public"."users"
1160
+ ```
1161
+
1162
+ ### SoftDeletePlugin
1163
+
1164
+ ```ts
1165
+ // Mode "convert" (default) — DELETE becomes UPDATE SET deleted_at = NOW()
1166
+ const db = sumak({
1167
+ plugins: [new SoftDeletePlugin({ tables: ["users"], mode: "convert" })],
1168
+ ...
1169
+ })
1170
+
1171
+ db.deleteFrom("users").where(({ id }) => id.eq(1)).toSQL()
1172
+ // UPDATE "users" SET "deleted_at" = NOW() WHERE ("id" = $1) AND ("deleted_at" IS NULL)
1173
+
1174
+ // Mode "filter" — just adds WHERE deleted_at IS NULL (no DELETE conversion)
1175
+ new SoftDeletePlugin({ tables: ["users"], mode: "filter" })
1176
+ ```
1177
+
1178
+ ### AuditTimestampPlugin
1179
+
1180
+ ```ts
1181
+ // Auto-inject created_at/updated_at timestamps
1182
+ const db = sumak({
1183
+ plugins: [new AuditTimestampPlugin({ tables: ["users"] })],
1184
+ ...
1185
+ })
1186
+
1187
+ db.insertInto("users").values({ name: "Alice" }).toSQL()
1188
+ // INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ($1, NOW(), NOW())
1189
+
1190
+ db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
1191
+ // UPDATE "users" SET "name" = $1, "updated_at" = NOW() WHERE ...
1192
+ ```
1193
+
1194
+ ### MultiTenantPlugin
1195
+
1196
+ ```ts
1197
+ // Auto-inject tenant_id on all queries
1198
+ // Use a callback for per-request tenant resolution:
1199
+ const db = sumak({
1200
+ plugins: [
1201
+ new MultiTenantPlugin({
1202
+ tables: ["users", "posts"],
1203
+ tenantId: () => getCurrentTenantId(), // called per query
1204
+ }),
1205
+ ],
1206
+ ...
1207
+ })
1208
+
1209
+ db.selectFrom("users").select("id").toSQL()
1210
+ // SELECT "id" FROM "users" WHERE ("tenant_id" = $1)
1211
+
1212
+ db.insertInto("users").values({ name: "Alice" }).toSQL()
1213
+ // INSERT INTO "users" ("name", "tenant_id") VALUES ($1, $2)
1214
+ ```
1215
+
1216
+ ### QueryLimitPlugin
1217
+
1218
+ ```ts
1219
+ // Auto-inject LIMIT on unbounded SELECTs
1220
+ const db = sumak({
1221
+ plugins: [new QueryLimitPlugin({ maxRows: 1000 })],
1222
+ ...
1223
+ })
1224
+
1225
+ db.selectFrom("users").select("id").toSQL()
1226
+ // SELECT "id" FROM "users" LIMIT 1000
1227
+
1228
+ db.selectFrom("users").select("id").limit(5).toSQL()
1229
+ // SELECT "id" FROM "users" LIMIT 5 — explicit limit preserved
1230
+ ```
1231
+
1232
+ ### CamelCasePlugin
1233
+
985
1234
  ```ts
986
- import { WithSchemaPlugin, SoftDeletePlugin, CamelCasePlugin } from "sumak"
1235
+ // Transform snake_case result columns to camelCase
1236
+ const db = sumak({
1237
+ plugins: [new CamelCasePlugin()],
1238
+ ...
1239
+ })
1240
+ ```
1241
+
1242
+ ### OptimisticLockPlugin
987
1243
 
1244
+ ```ts
1245
+ // Auto-inject WHERE version = N and SET version = version + 1 on UPDATE
1246
+ // Use a callback for per-row version:
1247
+ let rowVersion = 3
1248
+ const db = sumak({
1249
+ plugins: [
1250
+ new OptimisticLockPlugin({
1251
+ tables: ["users"],
1252
+ currentVersion: () => rowVersion, // called per query
1253
+ }),
1254
+ ],
1255
+ ...
1256
+ })
1257
+
1258
+ rowVersion = fetchedRow.version // set before each update
1259
+ db.update("users").set({ name: "Bob" }).where(({ id }) => id.eq(1)).toSQL()
1260
+ // UPDATE "users" SET "name" = $1, "version" = ("version" + 1)
1261
+ // WHERE ("id" = $2) AND ("version" = $3)
1262
+ ```
1263
+
1264
+ ### DataMaskingPlugin
1265
+
1266
+ ```ts
1267
+ // Mask sensitive data in query results
1268
+ const plugin = new DataMaskingPlugin({
1269
+ rules: [
1270
+ { column: "email", mask: "email" }, // "alice@example.com" → "al***@example.com"
1271
+ { column: "phone", mask: "phone" }, // "+1234567890" → "***7890"
1272
+ { column: "name", mask: "partial" }, // "John Doe" → "Jo***"
1273
+ { column: "ssn", mask: (v) => `***-**-${String(v).slice(-4)}` }, // custom
1274
+ ],
1275
+ })
1276
+
1277
+ const db = sumak({ plugins: [plugin], ... })
1278
+ ```
1279
+
1280
+ ### Combining Plugins
1281
+
1282
+ ```ts
988
1283
  const db = sumak({
989
1284
  dialect: pgDialect(),
990
1285
  plugins: [
991
- new WithSchemaPlugin("public"), // auto "public"."users"
992
- new SoftDeletePlugin({ tables: ["users"] }), // auto WHERE deleted_at IS NULL
1286
+ new WithSchemaPlugin("public"),
1287
+ new SoftDeletePlugin({ tables: ["users"] }),
1288
+ new AuditTimestampPlugin({ tables: ["users", "posts"] }),
1289
+ new MultiTenantPlugin({ tables: ["users", "posts"], tenantId: () => currentTenantId }),
1290
+ new QueryLimitPlugin({ maxRows: 5000 }),
993
1291
  ],
994
1292
  tables: { ... },
995
1293
  })
@@ -1054,7 +1352,7 @@ import { serial, text } from "sumak/schema"
1054
1352
 
1055
1353
  ## Architecture
1056
1354
 
1057
- sumak uses a 5-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
1355
+ sumak uses a 7-layer pipeline. Your code never touches SQL strings — everything flows through an AST.
1058
1356
 
1059
1357
  ```
1060
1358
  ┌─────────────────────────────────────────────────────────────────┐
@@ -1074,7 +1372,15 @@ sumak uses a 5-layer pipeline. Your code never touches SQL strings — everythin
1074
1372
  │ Plugin.transformNode() → Hook "query:before" │
1075
1373
  │ → AST rewriting, tenant isolation, soft delete, logging │
1076
1374
  ├─────────────────────────────────────────────────────────────────┤
1077
- │ 5. PRINTER
1375
+ │ 5. NORMALIZE (NbE)
1376
+ │ Predicate simplification, constant folding, deduplication │
1377
+ │ → Canonical form via Normalization by Evaluation │
1378
+ ├─────────────────────────────────────────────────────────────────┤
1379
+ │ 6. OPTIMIZE (Rewrite Rules) │
1380
+ │ Predicate pushdown, subquery flattening, user rules │
1381
+ │ → Declarative rules applied to fixpoint │
1382
+ ├─────────────────────────────────────────────────────────────────┤
1383
+ │ 7. PRINTER │
1078
1384
  │ .toSQL() → { sql: "SELECT ...", params: [...] } │
1079
1385
  │ → Dialect-specific: PG ($1), MySQL (?), MSSQL (@p0) │
1080
1386
  └─────────────────────────────────────────────────────────────────┘
@@ -1086,6 +1392,8 @@ The query is never a string until the very last step. This means:
1086
1392
 
1087
1393
  - **Plugins can rewrite queries** — add WHERE clauses, prefix schemas, transform joins
1088
1394
  - **Hooks can inspect/modify** — logging, tracing, tenant isolation
1395
+ - **Normalize simplifies** — duplicate predicates, tautologies, constant expressions
1396
+ - **Optimize rewrites** — predicate pushdown, subquery flattening, custom rules
1089
1397
  - **Printers are swappable** — same AST, different SQL per dialect
1090
1398
  - **No SQL injection** — values are always parameterized
1091
1399
 
@@ -1095,6 +1403,8 @@ The query is never a string until the very last step. This means:
1095
1403
  - **Immutable builders** — every method returns a new instance
1096
1404
  - **Proxy-based column access** — `({ age }) => age.gt(18)` with full type safety
1097
1405
  - **Phantom types** — `Expression<T>` carries type info with zero runtime cost
1406
+ - **NbE normalization** — expressions reduced to canonical form before printing
1407
+ - **Compiled queries** — pre-bake SQL at setup, zero AST walk at runtime
1098
1408
 
1099
1409
  ---
1100
1410
 
@@ -0,0 +1,106 @@
1
+ import type { ExpressionNode } from "../ast/nodes.mjs";
2
+ import type { Expression } from "../ast/typed-expression.mjs";
3
+ /**
4
+ * JSON optics — composable, type-tracked JSON column navigation.
5
+ *
6
+ * Each `.at()` step creates a new optic that tracks the type at that level.
7
+ * The final expression is a chain of JSON access operators.
8
+ *
9
+ * ```ts
10
+ * // Type-tracked navigation:
11
+ * jsonCol<UserProfile>("profile")
12
+ * .at("address") // JsonOptic<Address>
13
+ * .at("city") // JsonOptic<string>
14
+ * .asText() // Expression<string>
15
+ *
16
+ * // Use in SELECT:
17
+ * db.selectFrom("users")
18
+ * .selectExprs(jsonCol("data").at("name").asText().as("name"))
19
+ * ```
20
+ *
21
+ * **Dialect-aware operators:**
22
+ * - `.at("key")` → `->` (returns JSON)
23
+ * - `.asText()` → `->>` (returns text)
24
+ * - `.atPath("a.b.c")` → `#>` (PG path operator)
25
+ * - `.asTextPath("a.b.c")` → `#>>` (PG text path operator)
26
+ */
27
+ export declare class JsonOptic<T = unknown> {
28
+ readonly _type: T;
29
+ constructor(node: ExpressionNode);
30
+ /**
31
+ * Navigate into a JSON object key. Returns JSON type.
32
+ *
33
+ * ```ts
34
+ * jsonCol("data").at("address") // → data->'address'
35
+ * ```
36
+ */
37
+ at<K extends string>(key: K): JsonOptic<T extends Record<K, infer V> ? V : unknown>;
38
+ /**
39
+ * Navigate into a JSON object key and extract as text.
40
+ *
41
+ * ```ts
42
+ * jsonCol("data").text("name") // → data->>'name' (returns string)
43
+ * ```
44
+ */
45
+ text<K extends string>(key: K): JsonExpr<string>;
46
+ /**
47
+ * Navigate by PG JSON path operator `#>`.
48
+ *
49
+ * ```ts
50
+ * jsonCol("data").atPath("address.city") // → data#>'{address,city}'
51
+ * ```
52
+ */
53
+ atPath(path: string): JsonOptic<unknown>;
54
+ /**
55
+ * Navigate by PG JSON text path operator `#>>`.
56
+ *
57
+ * ```ts
58
+ * jsonCol("data").textPath("address.city") // → data#>>'{address,city}'
59
+ * ```
60
+ */
61
+ textPath(path: string): JsonExpr<string>;
62
+ /**
63
+ * Cast current JSON value to text (`->>`).
64
+ */
65
+ asText(): JsonExpr<string>;
66
+ /**
67
+ * Get the underlying expression node.
68
+ */
69
+ toExpression(): Expression<T>;
70
+ }
71
+ /**
72
+ * A JSON expression that can be aliased and used in SELECT/WHERE.
73
+ * This is the "leaf" of the optics chain.
74
+ */
75
+ export declare class JsonExpr<T> {
76
+ readonly _type: T;
77
+ constructor(node: ExpressionNode);
78
+ /**
79
+ * Alias this expression for use in SELECT.
80
+ *
81
+ * ```ts
82
+ * jsonCol("data").text("name").as("userName")
83
+ * ```
84
+ */
85
+ as(alias: string): Expression<T>;
86
+ /**
87
+ * Get the underlying expression node.
88
+ */
89
+ toExpression(): Expression<T>;
90
+ }
91
+ /**
92
+ * Create a JSON optic from a column name.
93
+ *
94
+ * ```ts
95
+ * const profileCity = jsonCol<UserProfile>("profile").at("address").at("city").asText()
96
+ * ```
97
+ */
98
+ export declare function jsonCol<T = unknown>(column: string, table?: string): JsonOptic<T>;
99
+ /**
100
+ * Create a JSON optic from an existing expression.
101
+ *
102
+ * ```ts
103
+ * const optic = jsonExpr<Config>(someExpression).at("settings")
104
+ * ```
105
+ */
106
+ export declare function jsonExpr<T = unknown>(expr: Expression<any>): JsonOptic<T>;
@@ -0,0 +1,110 @@
1
+
2
+ export class JsonOptic {
3
+
4
+ _node;
5
+ constructor(node) {
6
+ this._node = node;
7
+ }
8
+
9
+ at(key) {
10
+ const node = {
11
+ type: "json_access",
12
+ expr: this._node,
13
+ path: key,
14
+ operator: "->"
15
+ };
16
+ return new JsonOptic(node);
17
+ }
18
+
19
+ text(key) {
20
+ const node = {
21
+ type: "json_access",
22
+ expr: this._node,
23
+ path: key,
24
+ operator: "->>"
25
+ };
26
+ return new JsonExpr(node);
27
+ }
28
+
29
+ atPath(path) {
30
+ const node = {
31
+ type: "json_access",
32
+ expr: this._node,
33
+ path,
34
+ operator: "#>"
35
+ };
36
+ return new JsonOptic(node);
37
+ }
38
+
39
+ textPath(path) {
40
+ const node = {
41
+ type: "json_access",
42
+ expr: this._node,
43
+ path,
44
+ operator: "#>>"
45
+ };
46
+ return new JsonExpr(node);
47
+ }
48
+
49
+ asText() {
50
+
51
+ if (this._node.type === "json_access") {
52
+ const ja = this._node;
53
+ if (ja.operator === "->") {
54
+ return new JsonExpr({
55
+ ...ja,
56
+ operator: "->>"
57
+ });
58
+ }
59
+ }
60
+
61
+ return new JsonExpr({
62
+ type: "cast",
63
+ expr: this._node,
64
+ dataType: "text"
65
+ });
66
+ }
67
+
68
+ toExpression() {
69
+ return this._node;
70
+ }
71
+ }
72
+
73
+ export class JsonExpr {
74
+
75
+ _node;
76
+ constructor(node) {
77
+ this._node = node;
78
+ }
79
+
80
+ as(alias) {
81
+ if (this._node.type === "json_access") {
82
+ return {
83
+ ...this._node,
84
+ alias
85
+ };
86
+ }
87
+ return {
88
+ type: "aliased_expr",
89
+ expr: this._node,
90
+ alias
91
+ };
92
+ }
93
+
94
+ toExpression() {
95
+ return this._node;
96
+ }
97
+ }
98
+
99
+ export function jsonCol(column, table) {
100
+ const node = {
101
+ type: "column_ref",
102
+ column,
103
+ table
104
+ };
105
+ return new JsonOptic(node);
106
+ }
107
+
108
+ export function jsonExpr(expr) {
109
+ return new JsonOptic(expr._node ?? expr);
110
+ }
@@ -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. */