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.
- package/AGENTS.md +17 -15
- package/README.md +59 -31
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +98 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +149 -0
- package/src/cli/openbao-bootstrap.ts +314 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +172 -0
- package/src/plugins/cors.ts +153 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +139 -0
- package/src/plugins/ip-allowlist.ts +129 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /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
|
|
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
|
|
54
|
-
return
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
206
|
+
* @param node - Drizzle SQL wrapper
|
|
92
207
|
*/
|
|
93
|
-
function
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
133
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return
|
|
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 {
|
|
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
|
+
});
|