@telorun/templating 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,20 +54,13 @@ metadata:
54
54
  description: |
55
55
  A complete feedback collection REST API — no code, pure YAML.
56
56
  Persists entries to SQLite and serves them over HTTP.
57
+ imports:
58
+ Http: std/http-server@0.8.0
59
+ Sql: std/sql@0.5.1
57
60
  targets:
58
61
  - Migrations
59
62
  - Server
60
63
  ---
61
- kind: Telo.Import
62
- metadata:
63
- name: Http
64
- source: std/http-server@0.5.0
65
- ---
66
- kind: Telo.Import
67
- metadata:
68
- name: Sql
69
- source: std/sql@0.3.0
70
- ---
71
64
  # SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
72
65
  kind: Sql.Connection
73
66
  metadata:
@@ -127,7 +120,7 @@ routes:
127
120
  minLength: 1
128
121
  source:
129
122
  type: string
130
- required: [text]
123
+ required: [ text ]
131
124
  handler:
132
125
  kind: Sql.Exec
133
126
  connection:
@@ -157,7 +150,7 @@ routes:
157
150
  kind: Sql.Connection
158
151
  name: Db
159
152
  from: feedback
160
- columns: [id, text, source, score, created_at]
153
+ columns: [ id, text, source, score, created_at ]
161
154
  orderBy:
162
155
  - { column: created_at, direction: desc }
163
156
  response:
@@ -176,14 +169,14 @@ routes:
176
169
  properties:
177
170
  id:
178
171
  type: integer
179
- required: [id]
172
+ required: [ id ]
180
173
  handler:
181
174
  kind: Sql.Select
182
175
  connection:
183
176
  kind: Sql.Connection
184
177
  name: Db
185
178
  from: feedback
186
- columns: [id, text, source, score, created_at]
179
+ columns: [ id, text, source, score, created_at ]
187
180
  where:
188
181
  - { column: id, op: "=", value: "${{ request.params.id }}" }
189
182
  response:
@@ -1 +1 @@
1
- {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD;;;;;gEAKgE;AAChE,eAAO,MAAM,cAAc,EAAE,SAAS,gBAAgB,EAA0C,CAAC;AAEjG,wBAAgB,qBAAqB,IAAI,wBAAwB,CAMhE;AAID;;;oBAGoB;AACpB,wBAAgB,eAAe,IAAI,wBAAwB,CAK1D"}
1
+ {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD;;;;;gEAKgE;AAChE,eAAO,MAAM,cAAc,EAAE,SAAS,gBAAgB,EAKrD,CAAC;AAEF,wBAAgB,qBAAqB,IAAI,wBAAwB,CAMhE;AAID;;;oBAGoB;AACpB,wBAAgB,eAAe,IAAI,wBAAwB,CAK1D"}
package/dist/builtins.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { celEngine } from "./engines/cel.js";
2
2
  import { literalEngine } from "./engines/literal.js";
3
3
  import { refEngine } from "./engines/ref.js";
4
+ import { sqlEngine } from "./engines/sql.js";
4
5
  import { TemplatingEngineRegistry } from "./registry.js";
5
6
  /** Single source of truth for the built-in templating engines. Every host
6
7
  * (kernel, analyzer, editor, vscode extension) calls `createDefaultRegistry`
@@ -8,7 +9,12 @@ import { TemplatingEngineRegistry } from "./registry.js";
8
9
  * agree on which engines exist. Per-host à-la-carte registration would let
9
10
  * a manifest validate clean in one host (e.g. `cel` only) and crash in
10
11
  * another (e.g. `cel + literal`); always ship the same set. */
11
- export const builtinEngines = [celEngine, literalEngine, refEngine];
12
+ export const builtinEngines = [
13
+ celEngine,
14
+ literalEngine,
15
+ refEngine,
16
+ sqlEngine,
17
+ ];
12
18
  export function createDefaultRegistry() {
13
19
  const registry = new TemplatingEngineRegistry();
14
20
  for (const engine of builtinEngines) {
@@ -1,4 +1,4 @@
1
- import type { CompiledValue } from "@telorun/sdk";
1
+ import { type CompiledValue } from "@telorun/sdk";
2
2
  import type { Environment } from "@marcbachmann/cel-js";
3
3
  export declare const TEMPLATE_REGEX: RegExp;
4
4
  export declare const EXACT_TEMPLATE_REGEX: RegExp;
@@ -12,4 +12,18 @@ export declare function compileExpression(expr: string, env: Environment): Compi
12
12
  * with stringified expression results. If no expressions are present, returns
13
13
  * the input string unchanged. Throws on CEL syntax errors. */
14
14
  export declare function compileString(s: string, env: Environment): unknown;
15
+ /** Split an interpolated value into literal fragments and the evaluated values
16
+ * of its embedded expressions, instead of joining them into one string. Lets a
17
+ * consumer emit its own placeholders between fragments and bind the values
18
+ * separately (e.g. parameterized SQL). The invariant
19
+ * `fragments.length === values.length + 1` always holds.
20
+ *
21
+ * - plain string (no `${{ }}`) → `{ fragments: [s], values: [] }`
22
+ * - bare single expression `${{ x }}` → `{ fragments: ["", ""], values: [x] }`
23
+ * - interpolated `"a ${{ x }} b"` → `{ fragments: ["a ", " b"], values: [x] }`
24
+ */
25
+ export declare function toParameterized(value: unknown, ctx: Record<string, unknown>): {
26
+ fragments: string[];
27
+ values: unknown[];
28
+ };
15
29
  //# sourceMappingURL=compile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/cel/compile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,eAAO,MAAM,cAAc,QAA8B,CAAC;AAC1D,eAAO,MAAM,oBAAoB,QAAqC,CAAC;AAEvE;;2CAE2C;AAC3C,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,aAAa,CAO/E;AAED;;;;+DAI+D;AAC/D,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAuBlE"}
1
+ {"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/cel/compile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,eAAO,MAAM,cAAc,QAA8B,CAAC;AAC1D,eAAO,MAAM,oBAAoB,QAAqC,CAAC;AAEvE;;2CAE2C;AAC3C,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,aAAa,CAO/E;AAED;;;;+DAI+D;AAC/D,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAwBlE;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B;IAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,OAAO,EAAE,CAAA;CAAE,CAsB5C"}
@@ -1,3 +1,4 @@
1
+ import { isCompiledValue } from "@telorun/sdk";
1
2
  export const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
2
3
  export const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
3
4
  /** Compile a single CEL expression (no `${{ }}` wrapping) into a CompiledValue.
@@ -36,6 +37,42 @@ export function compileString(s, env) {
36
37
  return {
37
38
  __compiled: true,
38
39
  source: s,
40
+ parts,
39
41
  call: (ctx) => parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
40
42
  };
41
43
  }
44
+ /** Split an interpolated value into literal fragments and the evaluated values
45
+ * of its embedded expressions, instead of joining them into one string. Lets a
46
+ * consumer emit its own placeholders between fragments and bind the values
47
+ * separately (e.g. parameterized SQL). The invariant
48
+ * `fragments.length === values.length + 1` always holds.
49
+ *
50
+ * - plain string (no `${{ }}`) → `{ fragments: [s], values: [] }`
51
+ * - bare single expression `${{ x }}` → `{ fragments: ["", ""], values: [x] }`
52
+ * - interpolated `"a ${{ x }} b"` → `{ fragments: ["a ", " b"], values: [x] }`
53
+ */
54
+ export function toParameterized(value, ctx) {
55
+ if (typeof value === "string")
56
+ return { fragments: [value], values: [] };
57
+ if (!isCompiledValue(value)) {
58
+ throw new Error("toParameterized expects a string or CompiledValue");
59
+ }
60
+ if (!value.parts) {
61
+ return { fragments: ["", ""], values: [value.call(ctx)] };
62
+ }
63
+ const fragments = [];
64
+ const values = [];
65
+ let current = "";
66
+ for (const p of value.parts) {
67
+ if (typeof p === "string") {
68
+ current += p;
69
+ }
70
+ else {
71
+ fragments.push(current);
72
+ current = "";
73
+ values.push(p.call(ctx));
74
+ }
75
+ }
76
+ fragments.push(current);
77
+ return { fragments, values };
78
+ }
@@ -1,4 +1,10 @@
1
- import type { TemplatingEngine } from "../engine.js";
1
+ import type { AnalyzeEnv, EngineDiagnostic, TemplatingEngine } from "../engine.js";
2
+ /** Statically analyze one CEL expression against the effective context schema:
3
+ * parse → extract member-access chains → validate each chain → flag nullable
4
+ * access. Single source of truth shared by the `!cel` engine (one expression)
5
+ * and the `!sql` engine (one per `${{ }}` interpolation), so diagnostic wording
6
+ * can't drift between them. */
7
+ export declare function analyzeCelExpression(source: string, env: AnalyzeEnv): EngineDiagnostic[];
2
8
  /** The `!cel` engine. Treats the entire tagged scalar as a single CEL
3
9
  * expression — no `${{ }}` wrapping. Analysis runs the same chain validator
4
10
  * as the untagged path: parse → extract member-access chains → validate each
@@ -1 +1 @@
1
- {"version":3,"file":"cel.d.ts","sourceRoot":"","sources":["../../src/engines/cel.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAoB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEvE;;;kDAGkD;AAClD,eAAO,MAAM,SAAS,EAAE,gBA4CvB,CAAC"}
1
+ {"version":3,"file":"cel.d.ts","sourceRoot":"","sources":["../../src/engines/cel.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEnF;;;;gCAIgC;AAChC,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,gBAAgB,EAAE,CAmCxF;AAED;;;kDAGkD;AAClD,eAAO,MAAM,SAAS,EAAE,gBAWvB,CAAC"}
@@ -1,5 +1,42 @@
1
1
  import { extractAccessChains, findNullableAccessIssues, validateChainAgainstSchema, } from "../cel/analyze.js";
2
2
  import { compileExpression } from "../cel/compile.js";
3
+ /** Statically analyze one CEL expression against the effective context schema:
4
+ * parse → extract member-access chains → validate each chain → flag nullable
5
+ * access. Single source of truth shared by the `!cel` engine (one expression)
6
+ * and the `!sql` engine (one per `${{ }}` interpolation), so diagnostic wording
7
+ * can't drift between them. */
8
+ export function analyzeCelExpression(source, env) {
9
+ const out = [];
10
+ let parsed;
11
+ try {
12
+ parsed = env.celEnv.parse(source);
13
+ }
14
+ catch (e) {
15
+ out.push({
16
+ code: "CEL_SYNTAX_ERROR",
17
+ message: e instanceof Error ? e.message : String(e),
18
+ });
19
+ return out;
20
+ }
21
+ if (!env.contextSchema)
22
+ return out;
23
+ const chains = extractAccessChains(parsed.ast);
24
+ for (const chain of chains) {
25
+ const err = validateChainAgainstSchema(chain, env.contextSchema);
26
+ if (err)
27
+ out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
28
+ }
29
+ for (const issue of findNullableAccessIssues(parsed.ast, env.contextSchema)) {
30
+ // Index access (member "[index]") attaches without a dot; a named field
31
+ // attaches with one — so the suggested CEL stays valid either way.
32
+ const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
33
+ out.push({
34
+ code: "CEL_NULLABLE_ACCESS",
35
+ message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
36
+ });
37
+ }
38
+ return out;
39
+ }
3
40
  /** The `!cel` engine. Treats the entire tagged scalar as a single CEL
4
41
  * expression — no `${{ }}` wrapping. Analysis runs the same chain validator
5
42
  * as the untagged path: parse → extract member-access chains → validate each
@@ -11,35 +48,6 @@ export const celEngine = {
11
48
  return compileExpression(source, env.celEnv);
12
49
  },
13
50
  analyze(source, env) {
14
- const out = [];
15
- let parsed;
16
- try {
17
- parsed = env.celEnv.parse(source);
18
- }
19
- catch (e) {
20
- out.push({
21
- code: "CEL_SYNTAX_ERROR",
22
- message: e instanceof Error ? e.message : String(e),
23
- });
24
- return out;
25
- }
26
- if (!env.contextSchema)
27
- return out;
28
- const chains = extractAccessChains(parsed.ast);
29
- for (const chain of chains) {
30
- const err = validateChainAgainstSchema(chain, env.contextSchema);
31
- if (err)
32
- out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
33
- }
34
- for (const issue of findNullableAccessIssues(parsed.ast, env.contextSchema)) {
35
- // Index access (member "[index]") attaches without a dot; a named field
36
- // attaches with one — so the suggested CEL stays valid either way.
37
- const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
38
- out.push({
39
- code: "CEL_NULLABLE_ACCESS",
40
- message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
41
- });
42
- }
43
- return out;
51
+ return analyzeCelExpression(source, env);
44
52
  },
45
53
  };
@@ -0,0 +1,11 @@
1
+ import { isParameterizedSql, type ParameterizedSql } from "@telorun/sdk";
2
+ import type { TemplatingEngine } from "../engine.js";
3
+ export { isParameterizedSql, type ParameterizedSql };
4
+ /** The `!sql` engine. Treats the tagged scalar as a SQL string with `${{ }}`
5
+ * interpolations whose values are *bound*, not spliced. Unlike `!cel` (one bare
6
+ * expression) it keeps the literal text and each interpolation separate: at
7
+ * runtime `call()` returns a {@link ParameterizedSql} the consumer turns into a
8
+ * parameterized query. Generic expansion passes that object through untouched
9
+ * (it is the `call()` result), so it survives the step-level input expansion. */
10
+ export declare const sqlEngine: TemplatingEngine;
11
+ //# sourceMappingURL=sql.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../src/engines/sql.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAsB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG7F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAAE,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,CAAC;AAErD;;;;;kFAKkF;AAClF,eAAO,MAAM,SAAS,EAAE,gBAqBvB,CAAC"}
@@ -0,0 +1,38 @@
1
+ import { isParameterizedSql } from "@telorun/sdk";
2
+ import { analyzeCelExpression } from "./cel.js";
3
+ import { compileString, toParameterized, TEMPLATE_REGEX } from "../cel/compile.js";
4
+ export { isParameterizedSql };
5
+ /** The `!sql` engine. Treats the tagged scalar as a SQL string with `${{ }}`
6
+ * interpolations whose values are *bound*, not spliced. Unlike `!cel` (one bare
7
+ * expression) it keeps the literal text and each interpolation separate: at
8
+ * runtime `call()` returns a {@link ParameterizedSql} the consumer turns into a
9
+ * parameterized query. Generic expansion passes that object through untouched
10
+ * (it is the `call()` result), so it survives the step-level input expansion. */
11
+ export const sqlEngine = {
12
+ name: "sql",
13
+ language: "sql",
14
+ compile(source, env) {
15
+ const inner = compileString(source, env.celEnv);
16
+ return {
17
+ __compiled: true,
18
+ source,
19
+ call: (ctx) => {
20
+ const { fragments, values } = toParameterized(inner, ctx);
21
+ return { __teloParameterized: true, fragments, values };
22
+ },
23
+ };
24
+ },
25
+ analyze(source, env) {
26
+ // Each `${{ }}` interpolation is its own CEL expression; reuse the shared
27
+ // per-expression analyzer so diagnostics match the `!cel` engine exactly.
28
+ return expressionsOf(source).flatMap((expr) => analyzeCelExpression(expr, env));
29
+ },
30
+ };
31
+ /** Extract each `${{ expr }}` body from a `!sql` template source. */
32
+ function expressionsOf(source) {
33
+ const exprs = [];
34
+ for (const m of source.matchAll(TEMPLATE_REGEX)) {
35
+ exprs.push(m[1].trim());
36
+ }
37
+ return exprs;
38
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { buildCelEnvironment, type CelHandlers } from "./cel/environment.js";
2
- export { compileExpression, compileString, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
2
+ export { compileExpression, compileString, toParameterized, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
3
3
  export { extractAccessChains, findNullableAccessIssues, INDEX_SEGMENT, validateChainAgainstSchema, } from "./cel/analyze.js";
4
4
  export { walkCelExpressions } from "./cel/walk.js";
5
5
  export { celEngine } from "./engines/cel.js";
6
6
  export { literalEngine } from "./engines/literal.js";
7
7
  export { refEngine } from "./engines/ref.js";
8
+ export { sqlEngine, isParameterizedSql, type ParameterizedSql } from "./engines/sql.js";
8
9
  export { TemplatingEngineRegistry } from "./registry.js";
9
10
  export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
10
11
  export type { AnalyzeEnv, CompileEnv, EngineDiagnostic, TemplatingEngine, } from "./engine.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,aAAa,EACb,0BAA0B,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE7C,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACvF,YAAY,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AACzG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,aAAa,EACb,0BAA0B,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAExF,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACvF,YAAY,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AACzG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  export { buildCelEnvironment } from "./cel/environment.js";
2
- export { compileExpression, compileString, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
2
+ export { compileExpression, compileString, toParameterized, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
3
3
  export { extractAccessChains, findNullableAccessIssues, INDEX_SEGMENT, validateChainAgainstSchema, } from "./cel/analyze.js";
4
4
  export { walkCelExpressions } from "./cel/walk.js";
5
5
  export { celEngine } from "./engines/cel.js";
6
6
  export { literalEngine } from "./engines/literal.js";
7
7
  export { refEngine } from "./engines/ref.js";
8
+ export { sqlEngine, isParameterizedSql } from "./engines/sql.js";
8
9
  export { TemplatingEngineRegistry } from "./registry.js";
9
10
  export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
10
11
  export { isRefSentinel, isTaggedSentinel, makeTaggedSentinel } from "./sentinel.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/templating",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Telo Templating - Engine registry and shared CEL core for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,7 +42,7 @@
42
42
  "@types/node": "^20.0.0",
43
43
  "typescript": "^5.0.0",
44
44
  "vitest": "^2.1.8",
45
- "@telorun/sdk": "0.16.0"
45
+ "@telorun/sdk": "0.21.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@telorun/sdk": "*"
package/src/builtins.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { celEngine } from "./engines/cel.js";
2
2
  import { literalEngine } from "./engines/literal.js";
3
3
  import { refEngine } from "./engines/ref.js";
4
+ import { sqlEngine } from "./engines/sql.js";
4
5
  import { TemplatingEngineRegistry } from "./registry.js";
5
6
  import type { TemplatingEngine } from "./engine.js";
6
7
 
@@ -10,7 +11,12 @@ import type { TemplatingEngine } from "./engine.js";
10
11
  * agree on which engines exist. Per-host à-la-carte registration would let
11
12
  * a manifest validate clean in one host (e.g. `cel` only) and crash in
12
13
  * another (e.g. `cel + literal`); always ship the same set. */
13
- export const builtinEngines: readonly TemplatingEngine[] = [celEngine, literalEngine, refEngine];
14
+ export const builtinEngines: readonly TemplatingEngine[] = [
15
+ celEngine,
16
+ literalEngine,
17
+ refEngine,
18
+ sqlEngine,
19
+ ];
14
20
 
15
21
  export function createDefaultRegistry(): TemplatingEngineRegistry {
16
22
  const registry = new TemplatingEngineRegistry();
@@ -1,4 +1,4 @@
1
- import type { CompiledValue } from "@telorun/sdk";
1
+ import { isCompiledValue, type CompiledValue } from "@telorun/sdk";
2
2
  import type { Environment } from "@marcbachmann/cel-js";
3
3
 
4
4
  export const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
@@ -41,7 +41,45 @@ export function compileString(s: string, env: Environment): unknown {
41
41
  return {
42
42
  __compiled: true,
43
43
  source: s,
44
+ parts,
44
45
  call: (ctx: Record<string, unknown>) =>
45
46
  parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
46
47
  } satisfies CompiledValue;
47
48
  }
49
+
50
+ /** Split an interpolated value into literal fragments and the evaluated values
51
+ * of its embedded expressions, instead of joining them into one string. Lets a
52
+ * consumer emit its own placeholders between fragments and bind the values
53
+ * separately (e.g. parameterized SQL). The invariant
54
+ * `fragments.length === values.length + 1` always holds.
55
+ *
56
+ * - plain string (no `${{ }}`) → `{ fragments: [s], values: [] }`
57
+ * - bare single expression `${{ x }}` → `{ fragments: ["", ""], values: [x] }`
58
+ * - interpolated `"a ${{ x }} b"` → `{ fragments: ["a ", " b"], values: [x] }`
59
+ */
60
+ export function toParameterized(
61
+ value: unknown,
62
+ ctx: Record<string, unknown>,
63
+ ): { fragments: string[]; values: unknown[] } {
64
+ if (typeof value === "string") return { fragments: [value], values: [] };
65
+ if (!isCompiledValue(value)) {
66
+ throw new Error("toParameterized expects a string or CompiledValue");
67
+ }
68
+ if (!value.parts) {
69
+ return { fragments: ["", ""], values: [value.call(ctx)] };
70
+ }
71
+ const fragments: string[] = [];
72
+ const values: unknown[] = [];
73
+ let current = "";
74
+ for (const p of value.parts) {
75
+ if (typeof p === "string") {
76
+ current += p;
77
+ } else {
78
+ fragments.push(current);
79
+ current = "";
80
+ values.push(p.call(ctx));
81
+ }
82
+ }
83
+ fragments.push(current);
84
+ return { fragments, values };
85
+ }
@@ -4,7 +4,49 @@ import {
4
4
  validateChainAgainstSchema,
5
5
  } from "../cel/analyze.js";
6
6
  import { compileExpression } from "../cel/compile.js";
7
- import type { EngineDiagnostic, TemplatingEngine } from "../engine.js";
7
+ import type { AnalyzeEnv, EngineDiagnostic, TemplatingEngine } from "../engine.js";
8
+
9
+ /** Statically analyze one CEL expression against the effective context schema:
10
+ * parse → extract member-access chains → validate each chain → flag nullable
11
+ * access. Single source of truth shared by the `!cel` engine (one expression)
12
+ * and the `!sql` engine (one per `${{ }}` interpolation), so diagnostic wording
13
+ * can't drift between them. */
14
+ export function analyzeCelExpression(source: string, env: AnalyzeEnv): EngineDiagnostic[] {
15
+ const out: EngineDiagnostic[] = [];
16
+
17
+ let parsed: ReturnType<typeof env.celEnv.parse>;
18
+ try {
19
+ parsed = env.celEnv.parse(source);
20
+ } catch (e) {
21
+ out.push({
22
+ code: "CEL_SYNTAX_ERROR",
23
+ message: e instanceof Error ? e.message : String(e),
24
+ });
25
+ return out;
26
+ }
27
+
28
+ if (!env.contextSchema) return out;
29
+
30
+ const chains = extractAccessChains(parsed.ast);
31
+ for (const chain of chains) {
32
+ const err = validateChainAgainstSchema(chain, env.contextSchema as Record<string, any>);
33
+ if (err) out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
34
+ }
35
+
36
+ for (const issue of findNullableAccessIssues(
37
+ parsed.ast,
38
+ env.contextSchema as Record<string, any>,
39
+ )) {
40
+ // Index access (member "[index]") attaches without a dot; a named field
41
+ // attaches with one — so the suggested CEL stays valid either way.
42
+ const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
43
+ out.push({
44
+ code: "CEL_NULLABLE_ACCESS",
45
+ message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
46
+ });
47
+ }
48
+ return out;
49
+ }
8
50
 
9
51
  /** The `!cel` engine. Treats the entire tagged scalar as a single CEL
10
52
  * expression — no `${{ }}` wrapping. Analysis runs the same chain validator
@@ -19,39 +61,6 @@ export const celEngine: TemplatingEngine = {
19
61
  },
20
62
 
21
63
  analyze(source, env) {
22
- const out: EngineDiagnostic[] = [];
23
-
24
- let parsed: ReturnType<typeof env.celEnv.parse>;
25
- try {
26
- parsed = env.celEnv.parse(source);
27
- } catch (e) {
28
- out.push({
29
- code: "CEL_SYNTAX_ERROR",
30
- message: e instanceof Error ? e.message : String(e),
31
- });
32
- return out;
33
- }
34
-
35
- if (!env.contextSchema) return out;
36
-
37
- const chains = extractAccessChains(parsed.ast);
38
- for (const chain of chains) {
39
- const err = validateChainAgainstSchema(chain, env.contextSchema as Record<string, any>);
40
- if (err) out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
41
- }
42
-
43
- for (const issue of findNullableAccessIssues(
44
- parsed.ast,
45
- env.contextSchema as Record<string, any>,
46
- )) {
47
- // Index access (member "[index]") attaches without a dot; a named field
48
- // attaches with one — so the suggested CEL stays valid either way.
49
- const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
50
- out.push({
51
- code: "CEL_NULLABLE_ACCESS",
52
- message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
53
- });
54
- }
55
- return out;
64
+ return analyzeCelExpression(source, env);
56
65
  },
57
66
  };
@@ -0,0 +1,44 @@
1
+ import { isParameterizedSql, type CompiledValue, type ParameterizedSql } from "@telorun/sdk";
2
+ import { analyzeCelExpression } from "./cel.js";
3
+ import { compileString, toParameterized, TEMPLATE_REGEX } from "../cel/compile.js";
4
+ import type { TemplatingEngine } from "../engine.js";
5
+
6
+ export { isParameterizedSql, type ParameterizedSql };
7
+
8
+ /** The `!sql` engine. Treats the tagged scalar as a SQL string with `${{ }}`
9
+ * interpolations whose values are *bound*, not spliced. Unlike `!cel` (one bare
10
+ * expression) it keeps the literal text and each interpolation separate: at
11
+ * runtime `call()` returns a {@link ParameterizedSql} the consumer turns into a
12
+ * parameterized query. Generic expansion passes that object through untouched
13
+ * (it is the `call()` result), so it survives the step-level input expansion. */
14
+ export const sqlEngine: TemplatingEngine = {
15
+ name: "sql",
16
+ language: "sql",
17
+
18
+ compile(source, env) {
19
+ const inner = compileString(source, env.celEnv);
20
+ return {
21
+ __compiled: true,
22
+ source,
23
+ call: (ctx: Record<string, unknown>): ParameterizedSql => {
24
+ const { fragments, values } = toParameterized(inner, ctx);
25
+ return { __teloParameterized: true, fragments, values };
26
+ },
27
+ } satisfies CompiledValue;
28
+ },
29
+
30
+ analyze(source, env) {
31
+ // Each `${{ }}` interpolation is its own CEL expression; reuse the shared
32
+ // per-expression analyzer so diagnostics match the `!cel` engine exactly.
33
+ return expressionsOf(source).flatMap((expr) => analyzeCelExpression(expr, env));
34
+ },
35
+ };
36
+
37
+ /** Extract each `${{ expr }}` body from a `!sql` template source. */
38
+ function expressionsOf(source: string): string[] {
39
+ const exprs: string[] = [];
40
+ for (const m of source.matchAll(TEMPLATE_REGEX)) {
41
+ exprs.push(m[1]!.trim());
42
+ }
43
+ return exprs;
44
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { buildCelEnvironment, type CelHandlers } from "./cel/environment.js";
2
2
  export {
3
3
  compileExpression,
4
4
  compileString,
5
+ toParameterized,
5
6
  TEMPLATE_REGEX,
6
7
  EXACT_TEMPLATE_REGEX,
7
8
  } from "./cel/compile.js";
@@ -16,6 +17,7 @@ export { walkCelExpressions } from "./cel/walk.js";
16
17
  export { celEngine } from "./engines/cel.js";
17
18
  export { literalEngine } from "./engines/literal.js";
18
19
  export { refEngine } from "./engines/ref.js";
20
+ export { sqlEngine, isParameterizedSql, type ParameterizedSql } from "./engines/sql.js";
19
21
 
20
22
  export { TemplatingEngineRegistry } from "./registry.js";
21
23
  export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";