okengine 0.2.8 → 0.3.4

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 (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +59 -31
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +98 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +149 -0
  75. package/src/cli/openbao-bootstrap.ts +314 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +172 -0
  159. package/src/plugins/cors.ts +153 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +139 -0
  165. package/src/plugins/ip-allowlist.ts +129 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Drizzle condition compiler — parenthesized `or`/`and` groups, `like` /
3
+ * `ilike`, loud failures on unsupported operators, and `orderBy` terms.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ and,
9
+ asc,
10
+ between,
11
+ desc,
12
+ eq,
13
+ ilike,
14
+ inArray,
15
+ isNotNull,
16
+ isNull,
17
+ like,
18
+ lt,
19
+ not,
20
+ or,
21
+ sql,
22
+ } from "drizzle-orm";
23
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
24
+ import { compileOrderBy, compileWhere } from "./sql-condition.ts";
25
+
26
+ const notes = sqliteTable("notes", {
27
+ id: text("id").primaryKey(),
28
+ title: text("title").notNull(),
29
+ createdAt: integer("created_at").notNull(),
30
+ });
31
+
32
+ describe("compileWhere", () => {
33
+ test("bare comparison stays unparenthesized", () => {
34
+ const c = compileWhere(eq(notes.id, "n1"));
35
+ expect(c.clause).toBe(`"id" = ?`);
36
+ expect(c.params).toEqual(["n1"]);
37
+ });
38
+
39
+ test("like compiles to LIKE + bound pattern", () => {
40
+ const c = compileWhere(like(notes.title, "%hello%"));
41
+ expect(c.clause).toBe(`"title" like ?`);
42
+ expect(c.params).toEqual(["%hello%"]);
43
+ });
44
+
45
+ test("ilike compiles to ILIKE + bound pattern (postgres parity)", () => {
46
+ const c = compileWhere(ilike(notes.title, "%Hello%"));
47
+ expect(c.clause).toBe(`"title" ilike ?`);
48
+ expect(c.params).toEqual(["%Hello%"]);
49
+ });
50
+
51
+ test("and joins with AND", () => {
52
+ const c = compileWhere(and(eq(notes.id, "n1"), eq(notes.title, "t")));
53
+ expect(c.clause).toBe(`("id" = ?) AND ("title" = ?)`);
54
+ expect(c.params).toEqual(["n1", "t"]);
55
+ });
56
+
57
+ test("or produces a parenthesized OR group — never flattened into AND", () => {
58
+ const c = compileWhere(or(eq(notes.id, "n1"), lt(notes.createdAt, 10)));
59
+ expect(c.clause).toBe(`("id" = ?) OR ("created_at" < ?)`);
60
+ expect(c.params).toEqual(["n1", 10]);
61
+ });
62
+
63
+ test("composite cursor predicate keeps OR/AND precedence", () => {
64
+ const c = compileWhere(
65
+ and(
66
+ like(notes.title, "%x%"),
67
+ or(lt(notes.createdAt, 100), and(eq(notes.createdAt, 100), lt(notes.id, "n1"))),
68
+ ),
69
+ );
70
+ expect(c.clause).toBe(
71
+ `("title" like ?) AND (("created_at" < ?) OR (("created_at" = ?) AND ("id" < ?)))`,
72
+ );
73
+ expect(c.params).toEqual(["%x%", 100, 100, "n1"]);
74
+ expect(c.predicates.map((p) => p.op)).toEqual(["like", "<", "=", "<"]);
75
+ });
76
+
77
+ test("single-condition or() collapses to the leaf", () => {
78
+ const c = compileWhere(or(eq(notes.id, "n1")));
79
+ expect(c.clause).toBe(`"id" = ?`);
80
+ expect(c.params).toEqual(["n1"]);
81
+ });
82
+
83
+ test("plain equality maps are unchanged", () => {
84
+ const c = compileWhere({ id: "n1", title: "t" });
85
+ expect(c.clause).toBe(`"id" = ? AND "title" = ?`);
86
+ expect(c.params).toEqual(["n1", "t"]);
87
+ });
88
+
89
+ test("inArray expands one placeholder per value", () => {
90
+ const c = compileWhere(inArray(notes.id, ["a", "b", "c"]));
91
+ expect(c.clause).toBe(`"id" in (?, ?, ?)`);
92
+ expect(c.params).toEqual(["a", "b", "c"]);
93
+ expect(c.predicates.map((p) => p.op)).toEqual(["in"]);
94
+ });
95
+
96
+ test("isNull / isNotNull bind no value", () => {
97
+ const nil = compileWhere(isNull(notes.title));
98
+ expect(nil.clause).toBe(`"title" is null`);
99
+ expect(nil.params).toEqual([]);
100
+ const notNil = compileWhere(and(eq(notes.id, "n1"), isNotNull(notes.title)));
101
+ expect(notNil.clause).toBe(`("id" = ?) AND ("title" is not null)`);
102
+ expect(notNil.params).toEqual(["n1"]);
103
+ });
104
+
105
+ test("boolean equality binds the boolean", () => {
106
+ const c = compileWhere(eq(notes.id, true as never));
107
+ expect(c.clause).toBe(`"id" = ?`);
108
+ expect(c.params).toEqual([true]);
109
+ });
110
+
111
+ test("unsupported operators fail loudly instead of dropping predicates", () => {
112
+ expect(() => compileWhere(between(notes.createdAt, 1, 2))).toThrow(/unsupported operator/);
113
+ expect(() => compileWhere(not(eq(notes.id, "n1")))).toThrow(/unsupported fragment/);
114
+ expect(() => compileWhere(sql`title = 1`)).toThrow(/unsupported fragment/);
115
+ });
116
+ });
117
+
118
+ describe("compileOrderBy", () => {
119
+ test("asc/desc terms with bare-column default", () => {
120
+ const terms = compileOrderBy([desc(notes.createdAt), asc(notes.id), notes.title]);
121
+ expect(terms).toEqual([
122
+ { column: "created_at", direction: "DESC" },
123
+ { column: "id", direction: "ASC" },
124
+ { column: "title", direction: "ASC" },
125
+ ]);
126
+ });
127
+
128
+ test("garbage terms fail loudly", () => {
129
+ expect(() => compileOrderBy([sql`random()`])).toThrow(/orderBy/);
130
+ expect(() => compileOrderBy([42])).toThrow(/orderBy/);
131
+ });
132
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Compile Drizzle SQL operators (`eq`, `and`, `lt`, …) and plain equality
3
- * maps into `WHERE` SQL + bound params for {@link SqlConnection}.
2
+ * Compile Drizzle SQL operators (`eq`, `and`, `or`, `lt`, `like`, …) and plain
3
+ * equality maps into `WHERE` SQL + bound params for {@link SqlConnection}.
4
4
  *
5
5
  * Table-agnostic query-builder support — not domain helpers.
6
6
  */
@@ -11,7 +11,18 @@ export type WhereMap = Readonly<Record<string, unknown>>;
11
11
  /** One compiled predicate (`col op ?`). */
12
12
  export interface CompiledPredicate {
13
13
  readonly column: string;
14
- readonly op: "=" | "<" | ">" | "<=" | ">=" | "!=";
14
+ readonly op:
15
+ | "="
16
+ | "<"
17
+ | ">"
18
+ | "<="
19
+ | ">="
20
+ | "!="
21
+ | "like"
22
+ | "ilike"
23
+ | "in"
24
+ | "is null"
25
+ | "is not null";
15
26
  readonly value: unknown;
16
27
  }
17
28
 
@@ -22,6 +33,21 @@ export interface CompiledWhere {
22
33
  readonly predicates: readonly CompiledPredicate[];
23
34
  }
24
35
 
36
+ /** One compiled `ORDER BY` term. */
37
+ export interface CompiledOrder {
38
+ readonly column: string;
39
+ readonly direction: "ASC" | "DESC";
40
+ }
41
+
42
+ /** Intermediate compiled node — a leaf comparison or a joined group. */
43
+ interface CompiledNode {
44
+ readonly clause: string;
45
+ readonly params: readonly unknown[];
46
+ readonly predicates: readonly CompiledPredicate[];
47
+ }
48
+
49
+ const EMPTY_NODE: CompiledNode = { clause: "", params: [], predicates: [] };
50
+
25
51
  /**
26
52
  * True when `value` looks like a Drizzle `SQL` wrapper (`eq` / `and` / …).
27
53
  *
@@ -35,12 +61,37 @@ export function isDrizzleSql(value: unknown): boolean {
35
61
  );
36
62
  }
37
63
 
64
+ /** Marker for {@link andWhere} — avoids importing `drizzle-orm` here. */
65
+ export interface AndCondition {
66
+ readonly [andConditionBrand]: true;
67
+ readonly conditions: readonly unknown[];
68
+ }
69
+ const andConditionBrand: unique symbol = Symbol("oke.and");
70
+
71
+ /**
72
+ * Join conditions with `AND` without importing `drizzle-orm`.
73
+ * `undefined` entries are dropped; a single remaining condition compiles
74
+ * bare.
75
+ *
76
+ * @param conditions - Equality maps or Drizzle SQL
77
+ */
78
+ export function andWhere(...conditions: readonly unknown[]): unknown {
79
+ const kept = conditions.filter((c) => c !== undefined && c !== null);
80
+ if (kept.length === 1) return kept[0];
81
+ return { [andConditionBrand]: true, conditions: kept } satisfies AndCondition;
82
+ }
83
+
38
84
  /**
39
85
  * Compile a where input into SQL.
40
86
  *
41
87
  * Accepts:
42
88
  * - plain {@link WhereMap} equality maps
43
- * - Drizzle `SQL` from `eq` / `and` / `lt` / …
89
+ * - Drizzle `SQL` from `eq` / `and` / `or` / `lt` / `like` /
90
+ * - {@link AndCondition} from {@link andWhere}
91
+ *
92
+ * Nested `and(...)` / `or(...)` compile to parenthesized groups — `or` is
93
+ * never flattened into `AND`. Unsupported operators throw rather than
94
+ * silently dropping predicates.
44
95
  *
45
96
  * @param where - Condition
46
97
  */
@@ -50,8 +101,19 @@ export function compileWhere(where: unknown): CompiledWhere {
50
101
  }
51
102
 
52
103
  if (isDrizzleSql(where)) {
53
- const predicates = extractPredicates(where);
54
- return predicatesToWhere(predicates);
104
+ const node = compileSqlNode(where);
105
+ return { clause: node.clause, params: node.params, predicates: node.predicates };
106
+ }
107
+
108
+ if (isAndCondition(where)) {
109
+ const children = where.conditions.map(compileWhere).filter((c) => c.clause !== "");
110
+ if (children.length === 0) return { clause: "", params: [], predicates: [] };
111
+ if (children.length === 1) return children[0]!;
112
+ return {
113
+ clause: children.map((c) => `(${c.clause})`).join(" AND "),
114
+ params: children.flatMap((c) => c.params),
115
+ predicates: children.flatMap((c) => c.predicates),
116
+ };
55
117
  }
56
118
 
57
119
  if (typeof where === "object" && !Array.isArray(where)) {
@@ -68,8 +130,59 @@ export function compileWhere(where: unknown): CompiledWhere {
68
130
  throw new TypeError("sql where: expected equality map or Drizzle SQL condition");
69
131
  }
70
132
 
133
+ function isAndCondition(value: unknown): value is AndCondition {
134
+ return (
135
+ typeof value === "object" &&
136
+ value !== null &&
137
+ (value as AndCondition)[andConditionBrand] === true
138
+ );
139
+ }
140
+
71
141
  /**
72
- * Turn predicates into `col = ? AND …` form.
142
+ * Compile Drizzle `asc()` / `desc()` terms into `ORDER BY` entries.
143
+ * Bare column references default to `ASC`.
144
+ *
145
+ * @param orders - Order terms (`asc(col)` / `desc(col)` SQL or columns)
146
+ */
147
+ export function compileOrderBy(orders: readonly unknown[]): readonly CompiledOrder[] {
148
+ return orders.map((order) => {
149
+ if (isDrizzleSql(order)) {
150
+ let column: string | undefined;
151
+ let text = "";
152
+ for (const chunk of chunksOf(order)) {
153
+ if (isDrizzleSql(chunk)) {
154
+ throw new TypeError("orderBy(): nested SQL is not supported");
155
+ }
156
+ const chunkString = chunkText(chunk);
157
+ if (chunkString !== undefined) {
158
+ text += chunkString;
159
+ continue;
160
+ }
161
+ const col = asColumn(chunk);
162
+ if (col === undefined) {
163
+ throw new TypeError("orderBy(): expected drizzle asc()/desc() SQL");
164
+ }
165
+ if (column !== undefined) {
166
+ throw new TypeError("orderBy(): one column per term");
167
+ }
168
+ column = col;
169
+ }
170
+ if (column === undefined) {
171
+ throw new TypeError("orderBy(): expected drizzle asc()/desc() SQL");
172
+ }
173
+ const dir = text.trim().toLowerCase();
174
+ if (dir === "" || dir === "asc") return { column, direction: "ASC" } as const;
175
+ if (dir === "desc") return { column, direction: "DESC" } as const;
176
+ throw new TypeError(`orderBy(): unsupported direction ${JSON.stringify(text.trim())}`);
177
+ }
178
+ const col = asColumn(order);
179
+ if (col !== undefined) return { column: col, direction: "ASC" } as const;
180
+ throw new TypeError("orderBy(): expected drizzle asc()/desc() SQL or a column");
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Turn predicates into `col = ? AND …` form (plain equality maps only).
73
186
  *
74
187
  * @param predicates - Ordered predicates
75
188
  */
@@ -86,41 +199,145 @@ function predicatesToWhere(predicates: readonly CompiledPredicate[]): CompiledWh
86
199
  }
87
200
 
88
201
  /**
89
- * Walk Drizzle `queryChunks` and collect simple binary comparisons.
202
+ * Recursively compile one Drizzle `SQL` node. Nodes with nested `SQL`
203
+ * children are groups joined by `AND` / `OR` (detected from the separator
204
+ * chunks Drizzle emits); nodes without are leaf comparisons.
90
205
  *
91
- * @param node - Drizzle SQL or nested chunk
206
+ * @param node - Drizzle SQL wrapper
92
207
  */
93
- function extractPredicates(node: unknown): CompiledPredicate[] {
94
- const out: CompiledPredicate[] = [];
95
- walk(node, out);
96
- return out;
208
+ function compileSqlNode(node: unknown): CompiledNode {
209
+ const chunks = chunksOf(node);
210
+ const nested = chunks.filter(isDrizzleSql);
211
+
212
+ if (nested.length === 0) {
213
+ return compileLeaf(chunks);
214
+ }
215
+
216
+ // Group level: only structural parens and ` and ` / ` or ` separators may
217
+ // appear next to nested conditions — anything else is a loud failure.
218
+ let joiner: "AND" | "OR" | undefined;
219
+ for (const chunk of chunks) {
220
+ if (isDrizzleSql(chunk)) continue;
221
+ const text = chunkText(chunk);
222
+ if (text === undefined) {
223
+ throw new TypeError("sql where: unsupported mixed condition");
224
+ }
225
+ const token = text.trim().toLowerCase();
226
+ if (token === "" || token === "(" || token === ")") continue;
227
+ if (token === "and" || token === "or") {
228
+ const next = token.toUpperCase() as "AND" | "OR";
229
+ if (joiner !== undefined && joiner !== next) {
230
+ throw new TypeError("sql where: mixed AND/OR at one level — parenthesize explicitly");
231
+ }
232
+ joiner = next;
233
+ continue;
234
+ }
235
+ throw new TypeError(`sql where: unsupported fragment ${JSON.stringify(text)}`);
236
+ }
237
+
238
+ const children = nested.map(compileSqlNode).filter((c) => c.clause !== "");
239
+ if (children.length === 0) return EMPTY_NODE;
240
+ if (children.length === 1) return children[0] ?? EMPTY_NODE;
241
+ const sep = joiner ?? "AND";
242
+ return {
243
+ clause: children.map((c) => `(${c.clause})`).join(` ${sep} `),
244
+ params: children.flatMap((c) => c.params),
245
+ predicates: children.flatMap((c) => c.predicates),
246
+ };
97
247
  }
98
248
 
99
- function walk(node: unknown, out: CompiledPredicate[]): void {
100
- if (!node || typeof node !== "object") return;
101
- const chunks = (node as { queryChunks?: unknown[] }).queryChunks;
102
- if (!Array.isArray(chunks)) return;
249
+ /**
250
+ * Compile a leaf node (`col op ?`) from chunks with no nested `SQL`.
251
+ * Structural paren chunks are ignored; every other chunk must be part of the
252
+ * comparison or it throws. `is null` / `is not null` bind no value; `in`
253
+ * binds one placeholder per array element.
254
+ *
255
+ * @param chunks - Drizzle query chunks
256
+ */
257
+ function compileLeaf(chunks: readonly unknown[]): CompiledNode {
258
+ let column: string | undefined;
259
+ let op: CompiledPredicate["op"] | undefined;
260
+ let value: unknown;
261
+ let state: "start" | "column" | "op" | "done" = "start";
103
262
 
104
- // Flatten nested SQL wrappers first so we can scan for col / op / param.
105
- const flat: unknown[] = [];
106
- for (const c of chunks) {
107
- if (c && typeof c === "object" && "queryChunks" in (c as object)) {
108
- walk(c, out);
109
- } else {
110
- flat.push(c);
263
+ for (const chunk of chunks) {
264
+ if (Array.isArray(chunk)) {
265
+ if (state !== "op" || op !== "in") {
266
+ throw new TypeError("sql where: unsupported condition shape");
267
+ }
268
+ const values = chunk.map(asInArrayValue);
269
+ value = values;
270
+ state = "done";
271
+ continue;
272
+ }
273
+ const text = chunkText(chunk);
274
+ if (text !== undefined) {
275
+ const token = text.trim().replace(/\)+$/, "");
276
+ if (token === "" || token === "(" || token === ")") continue;
277
+ if (state === "column") {
278
+ op = asOperator(token);
279
+ if (op === undefined) {
280
+ throw new TypeError(`sql where: unsupported operator ${JSON.stringify(token)}`);
281
+ }
282
+ state = op === "is null" || op === "is not null" ? "done" : "op";
283
+ continue;
284
+ }
285
+ throw new TypeError(`sql where: unsupported fragment ${JSON.stringify(text)}`);
111
286
  }
287
+ const col = asColumn(chunk);
288
+ if (col !== undefined) {
289
+ if (state !== "start") {
290
+ throw new TypeError("sql where: unsupported condition shape");
291
+ }
292
+ column = col;
293
+ state = "column";
294
+ continue;
295
+ }
296
+ const param = asParamValue(chunk);
297
+ if (param.found) {
298
+ if (state !== "op") {
299
+ throw new TypeError("sql where: unsupported condition shape");
300
+ }
301
+ value = param.value;
302
+ state = "done";
303
+ continue;
304
+ }
305
+ throw new TypeError("sql where: unsupported condition chunk");
112
306
  }
113
307
 
114
- for (let i = 0; i < flat.length; i++) {
115
- const col = asColumn(flat[i]);
116
- if (!col) continue;
117
- const op = asOperator(flat[i + 1]);
118
- if (!op) continue;
119
- const param = asParam(flat[i + 2]);
120
- if (param === undefined) continue;
121
- out.push({ column: col, op, value: param });
122
- i += 2;
308
+ if (state === "start") return EMPTY_NODE;
309
+ if (state !== "done" || column === undefined || op === undefined) {
310
+ throw new TypeError("sql where: incomplete condition");
311
+ }
312
+ const predicate: CompiledPredicate = { column, op, value };
313
+ if (op === "is null" || op === "is not null") {
314
+ return { clause: `${quoteIdent(column)} ${op}`, params: [], predicates: [predicate] };
315
+ }
316
+ if (op === "in") {
317
+ const values = value as readonly unknown[];
318
+ const placeholders = values.map(() => "?").join(", ");
319
+ return {
320
+ clause: `${quoteIdent(column)} in (${placeholders})`,
321
+ params: [...values],
322
+ predicates: [predicate],
323
+ };
123
324
  }
325
+ return {
326
+ clause: `${quoteIdent(column)} ${op} ?`,
327
+ params: [value],
328
+ predicates: [predicate],
329
+ };
330
+ }
331
+
332
+ function chunksOf(node: unknown): readonly unknown[] {
333
+ return (node as { queryChunks: unknown[] }).queryChunks;
334
+ }
335
+
336
+ /** Joined text of a Drizzle `StringChunk` (`{ value: string[] }`). */
337
+ function chunkText(chunk: unknown): string | undefined {
338
+ if (!chunk || typeof chunk !== "object") return undefined;
339
+ const value = (chunk as { value?: unknown }).value;
340
+ return Array.isArray(value) ? value.join("") : undefined;
124
341
  }
125
342
 
126
343
  function asColumn(chunk: unknown): string | undefined {
@@ -129,12 +346,8 @@ function asColumn(chunk: unknown): string | undefined {
129
346
  return typeof name === "string" && name.length > 0 ? name : undefined;
130
347
  }
131
348
 
132
- function asOperator(chunk: unknown): CompiledPredicate["op"] | undefined {
133
- if (!chunk || typeof chunk !== "object") return undefined;
134
- const value = (chunk as { value?: unknown }).value;
135
- const text = Array.isArray(value) ? value.join("") : typeof value === "string" ? value : "";
136
- const trimmed = text.trim();
137
- switch (trimmed) {
349
+ function asOperator(token: string): CompiledPredicate["op"] | undefined {
350
+ switch (token.toLowerCase()) {
138
351
  case "=":
139
352
  return "=";
140
353
  case "<":
@@ -148,18 +361,43 @@ function asOperator(chunk: unknown): CompiledPredicate["op"] | undefined {
148
361
  case "!=":
149
362
  case "<>":
150
363
  return "!=";
364
+ case "like":
365
+ return "like";
366
+ case "ilike":
367
+ return "ilike";
368
+ case "in":
369
+ return "in";
370
+ case "is null":
371
+ return "is null";
372
+ case "is not null":
373
+ return "is not null";
151
374
  default:
152
375
  return undefined;
153
376
  }
154
377
  }
155
378
 
156
- function asParam(chunk: unknown): unknown {
157
- if (!chunk || typeof chunk !== "object") return undefined;
158
- if (!("value" in (chunk as object))) return undefined;
159
- // StringChunk also has `.value` (array of strings) — skip those.
160
- if (Array.isArray((chunk as { value: unknown }).value)) return undefined;
161
- if ((chunk as { name?: unknown }).name) return undefined;
162
- return (chunk as { value: unknown }).value;
379
+ /** Unwrap one element of a Drizzle `inArray` value array. */
380
+ function asInArrayValue(item: unknown): unknown {
381
+ const param = asParamValue(item);
382
+ if (!param.found) {
383
+ throw new TypeError("sql where: unsupported in-list value");
384
+ }
385
+ return param.value;
386
+ }
387
+
388
+ /**
389
+ * Param values arrive either as Drizzle `Param` instances (wrapped by
390
+ * `bindIfParam`) or as raw primitives interpolated by `sql` templates
391
+ * (`like`, `ilike`, …).
392
+ */
393
+ function asParamValue(chunk: unknown): { readonly found: boolean; readonly value?: unknown } {
394
+ if (chunk === undefined) return { found: false };
395
+ if (chunk === null || typeof chunk !== "object") return { found: true, value: chunk };
396
+ if (Array.isArray(chunk)) return { found: false };
397
+ if (!("value" in (chunk as object))) return { found: false };
398
+ if (Array.isArray((chunk as { value: unknown }).value)) return { found: false };
399
+ if ((chunk as { name?: unknown }).name) return { found: false };
400
+ return { found: true, value: (chunk as { value: unknown }).value };
163
401
  }
164
402
 
165
403
  /**
@@ -8,8 +8,31 @@
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
10
  import { Database } from "bun:sqlite";
11
+ import { and, desc, eq, like, lt, or } from "drizzle-orm";
12
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
11
13
  import { sqliteDriver } from "../../drivers/sqlite.ts";
12
- import { createSqlStoreHandle } from "./sql-session.ts";
14
+ import type { SqlConnection } from "../../drivers/types.ts";
15
+ import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
16
+
17
+ const posts = sqliteTable("posts", {
18
+ id: text("id").primaryKey(),
19
+ title: text("title").notNull(),
20
+ createdAt: integer("created_at").notNull(),
21
+ });
22
+
23
+ async function openHandle(): Promise<{ handle: SqlStoreHandle; conn: SqlConnection }> {
24
+ const conn = await sqliteDriver.connect({
25
+ client: new Database(":memory:"),
26
+ role: "primary",
27
+ });
28
+ const handle = createSqlStoreHandle("sql:app", {
29
+ connection: conn,
30
+ classifications: new Map(),
31
+ routedRole: "primary",
32
+ domainDdl: "ensure",
33
+ });
34
+ return { handle, conn };
35
+ }
13
36
 
14
37
  describe("SqlStoreHandle — no relational query surface (path b)", () => {
15
38
  test("created handle exposes exactly the single-table surface", async () => {
@@ -35,6 +58,8 @@ describe("SqlStoreHandle — no relational query surface (path b)", () => {
35
58
  "exists",
36
59
  "increment",
37
60
  "raw",
61
+ "count",
62
+ "page",
38
63
  "ensureTable",
39
64
  ].sort(),
40
65
  );
@@ -48,3 +73,63 @@ describe("SqlStoreHandle — no relational query surface (path b)", () => {
48
73
  await conn.close();
49
74
  });
50
75
  });
76
+
77
+ describe("SqlStoreHandle — orderBy / limit select chain", () => {
78
+ test("orderBy + limit without where", async () => {
79
+ const { handle, conn } = await openHandle();
80
+ await handle.insert(posts).values({ id: "p1", title: "one", createdAt: 100 });
81
+ await handle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
82
+ await handle.insert(posts).values({ id: "p3", title: "three", createdAt: 200 });
83
+
84
+ const rows = await handle.select().from(posts).orderBy(desc(posts.createdAt)).limit(2);
85
+ expect(rows.map((r) => r.id)).toEqual(["p2", "p3"]);
86
+ await conn.close();
87
+ });
88
+
89
+ test("limit directly on from() — no where required", async () => {
90
+ const { handle, conn } = await openHandle();
91
+ await handle.insert(posts).values({ id: "p1", title: "one", createdAt: 100 });
92
+ await handle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
93
+
94
+ const rows = await handle.select().from(posts).limit(1);
95
+ expect(rows).toHaveLength(1);
96
+ await conn.close();
97
+ });
98
+
99
+ test("composite cursor predicate: where → orderBy → limit", async () => {
100
+ const { handle, conn } = await openHandle();
101
+ await handle.insert(posts).values({ id: "p1", title: "one", createdAt: 100 });
102
+ await handle.insert(posts).values({ id: "p2", title: "two", createdAt: 300 });
103
+ await handle.insert(posts).values({ id: "p3", title: "three", createdAt: 200 });
104
+ // Tie on createdAt=200 with an id that sorts after the cursor id.
105
+ await handle.insert(posts).values({ id: "p0", title: "four", createdAt: 200 });
106
+
107
+ const order = [desc(posts.createdAt), desc(posts.id)] as const;
108
+ const page1 = await handle
109
+ .select()
110
+ .from(posts)
111
+ .orderBy(...order)
112
+ .limit(2);
113
+ expect(page1.map((r) => r.id)).toEqual(["p2", "p3"]);
114
+
115
+ // (createdAt, id) < (200, "p3") — the second page of a keyset cursor.
116
+ const page2 = await handle
117
+ .select()
118
+ .from(posts)
119
+ .where(or(lt(posts.createdAt, 200), and(eq(posts.createdAt, 200), lt(posts.id, "p3"))))
120
+ .orderBy(...order)
121
+ .limit(2);
122
+ expect(page2.map((r) => r.id)).toEqual(["p0", "p1"]);
123
+ await conn.close();
124
+ });
125
+
126
+ test("like filters rows through the session (sqlite ASCII-insensitive)", async () => {
127
+ const { handle, conn } = await openHandle();
128
+ await handle.insert(posts).values({ id: "p1", title: "alpha", createdAt: 100 });
129
+ await handle.insert(posts).values({ id: "p2", title: "bravo", createdAt: 200 });
130
+
131
+ const rows = await handle.select().from(posts).where(like(posts.title, "A%"));
132
+ expect(rows.map((r) => r.id)).toEqual(["p1"]);
133
+ await conn.close();
134
+ });
135
+ });