@ekodb/ekodb-client 0.16.0 → 0.18.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/dist/functions.d.ts +457 -4
- package/dist/functions.js +312 -0
- package/dist/functions.test.d.ts +9 -0
- package/dist/functions.test.js +542 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/package.json +1 -1
- package/src/functions.test.ts +726 -0
- package/src/functions.ts +766 -4
- package/src/index.ts +2 -1
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the stored-function builder helpers (Stage + parameterRef).
|
|
3
|
+
*
|
|
4
|
+
* These tests cover the pure-data construction helpers and the structural
|
|
5
|
+
* parameter placeholder. They don't hit a running ekoDB — server-side
|
|
6
|
+
* behavior is covered by the Rust integration tests in
|
|
7
|
+
* `ekodb/ekodb_server/tests/function_parameters_tests.rs`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { Stage, parameterRef, type FunctionStageConfig } from "./functions";
|
|
12
|
+
|
|
13
|
+
describe("parameterRef", () => {
|
|
14
|
+
it("produces the structural placeholder shape ekoDB's resolver expects", () => {
|
|
15
|
+
expect(parameterRef("record")).toEqual({
|
|
16
|
+
type: "Parameter",
|
|
17
|
+
name: "record",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("preserves an arbitrary parameter name verbatim", () => {
|
|
22
|
+
expect(parameterRef("user_id").name).toBe("user_id");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("Stage.param", () => {
|
|
27
|
+
it("is an alias for parameterRef(name)", () => {
|
|
28
|
+
expect(Stage.param("x")).toEqual(parameterRef("x"));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("Stage.insert with a structural parameter placeholder", () => {
|
|
33
|
+
it("embeds the whole-record placeholder into Insert.record", () => {
|
|
34
|
+
const stage = Stage.insert("users", Stage.param("record")) as Extract<
|
|
35
|
+
FunctionStageConfig,
|
|
36
|
+
{ type: "Insert" }
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
expect(stage.type).toBe("Insert");
|
|
40
|
+
expect(stage.collection).toBe("users");
|
|
41
|
+
expect(stage.record).toEqual({ type: "Parameter", name: "record" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("also accepts per-field placeholders mixed with literal values", () => {
|
|
45
|
+
const stage = Stage.insert("items", {
|
|
46
|
+
label: "{{label}}",
|
|
47
|
+
parent_id: Stage.param("parent_id"),
|
|
48
|
+
kind: "item",
|
|
49
|
+
tags: Stage.param("tags"),
|
|
50
|
+
}) as Extract<FunctionStageConfig, { type: "Insert" }>;
|
|
51
|
+
|
|
52
|
+
expect(stage.record.label).toBe("{{label}}");
|
|
53
|
+
expect(stage.record.parent_id).toEqual({
|
|
54
|
+
type: "Parameter",
|
|
55
|
+
name: "parent_id",
|
|
56
|
+
});
|
|
57
|
+
expect(stage.record.kind).toBe("item");
|
|
58
|
+
expect(stage.record.tags).toEqual({ type: "Parameter", name: "tags" });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("Stage.updateById with a structural parameter placeholder", () => {
|
|
63
|
+
it("embeds the whole-updates placeholder into UpdateById.updates", () => {
|
|
64
|
+
const stage = Stage.updateById(
|
|
65
|
+
"items",
|
|
66
|
+
"{{id}}",
|
|
67
|
+
Stage.param("updates"),
|
|
68
|
+
) as Extract<FunctionStageConfig, { type: "UpdateById" }>;
|
|
69
|
+
|
|
70
|
+
expect(stage.type).toBe("UpdateById");
|
|
71
|
+
expect(stage.collection).toBe("items");
|
|
72
|
+
expect(stage.record_id).toBe("{{id}}");
|
|
73
|
+
expect(stage.updates).toEqual({ type: "Parameter", name: "updates" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts per-field placeholders for partial fine-grained updates", () => {
|
|
77
|
+
const stage = Stage.updateById("items", "{{id}}", {
|
|
78
|
+
label: "{{label}}",
|
|
79
|
+
updated_at: "{{updated_at}}",
|
|
80
|
+
}) as Extract<FunctionStageConfig, { type: "UpdateById" }>;
|
|
81
|
+
|
|
82
|
+
expect(stage.updates.label).toBe("{{label}}");
|
|
83
|
+
expect(stage.updates.updated_at).toBe("{{updated_at}}");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("Stage.update (filter-based) with structural updates", () => {
|
|
88
|
+
it("accepts a Parameter placeholder in both filter values and the updates body", () => {
|
|
89
|
+
const stage = Stage.update(
|
|
90
|
+
"items",
|
|
91
|
+
{
|
|
92
|
+
type: "Condition",
|
|
93
|
+
content: {
|
|
94
|
+
field: "id",
|
|
95
|
+
operator: "Eq",
|
|
96
|
+
value: Stage.param("id"),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
Stage.param("updates"),
|
|
100
|
+
) as Extract<FunctionStageConfig, { type: "Update" }>;
|
|
101
|
+
|
|
102
|
+
expect(stage.type).toBe("Update");
|
|
103
|
+
expect(stage.filter).toEqual({
|
|
104
|
+
type: "Condition",
|
|
105
|
+
content: {
|
|
106
|
+
field: "id",
|
|
107
|
+
operator: "Eq",
|
|
108
|
+
value: { type: "Parameter", name: "id" },
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(stage.updates).toEqual({ type: "Parameter", name: "updates" });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("Stage.batchInsert with structural placeholders inside each record", () => {
|
|
116
|
+
it("lets callers template each record's record-body from params", () => {
|
|
117
|
+
const stage = Stage.batchInsert("audit_log", [
|
|
118
|
+
{ actor: Stage.param("user_id"), at: "{{now}}", message: "created" },
|
|
119
|
+
{ actor: Stage.param("user_id"), at: "{{now}}", message: "initialized" },
|
|
120
|
+
]) as Extract<FunctionStageConfig, { type: "BatchInsert" }>;
|
|
121
|
+
|
|
122
|
+
expect(stage.type).toBe("BatchInsert");
|
|
123
|
+
expect(stage.records).toHaveLength(2);
|
|
124
|
+
expect(stage.records[0].actor).toEqual({
|
|
125
|
+
type: "Parameter",
|
|
126
|
+
name: "user_id",
|
|
127
|
+
});
|
|
128
|
+
expect(stage.records[0].at).toBe("{{now}}");
|
|
129
|
+
expect(stage.records[1].message).toBe("initialized");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("JSON serialization round-trip (what actually goes on the wire)", () => {
|
|
134
|
+
it("serializes a structural placeholder exactly as ekoDB expects", () => {
|
|
135
|
+
const stage = Stage.insert("users", Stage.param("record"));
|
|
136
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
137
|
+
expect((stage as any).ttl).toBeUndefined();
|
|
138
|
+
expect("ttl" in wire).toBe(false);
|
|
139
|
+
expect(wire).toEqual({
|
|
140
|
+
type: "Insert",
|
|
141
|
+
collection: "users",
|
|
142
|
+
record: { type: "Parameter", name: "record" },
|
|
143
|
+
bypass_ripple: false,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Crypto primitives: BcryptHash, BcryptVerify, RandomToken (ekoDB >= 0.41.0)
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
describe("Stage.bcryptHash", () => {
|
|
153
|
+
it("produces a BcryptHash stage with text placeholder + explicit cost", () => {
|
|
154
|
+
const stage = Stage.bcryptHash(
|
|
155
|
+
"{{password}}",
|
|
156
|
+
"password_hash",
|
|
157
|
+
12,
|
|
158
|
+
) as Extract<FunctionStageConfig, { type: "BcryptHash" }>;
|
|
159
|
+
expect(stage.type).toBe("BcryptHash");
|
|
160
|
+
expect(stage.plain).toBe("{{password}}");
|
|
161
|
+
expect(stage.cost).toBe(12);
|
|
162
|
+
expect(stage.output_field).toBe("password_hash");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("leaves cost undefined when the caller omits it", () => {
|
|
166
|
+
const stage = Stage.bcryptHash("{{password}}", "pw_hash") as Extract<
|
|
167
|
+
FunctionStageConfig,
|
|
168
|
+
{ type: "BcryptHash" }
|
|
169
|
+
>;
|
|
170
|
+
expect(stage.cost).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("Stage.bcryptVerify", () => {
|
|
175
|
+
it("produces a BcryptVerify stage wiring hash_field and output_field", () => {
|
|
176
|
+
const stage = Stage.bcryptVerify(
|
|
177
|
+
"{{password}}",
|
|
178
|
+
"password_hash",
|
|
179
|
+
"valid",
|
|
180
|
+
) as Extract<FunctionStageConfig, { type: "BcryptVerify" }>;
|
|
181
|
+
expect(stage.type).toBe("BcryptVerify");
|
|
182
|
+
expect(stage.plain).toBe("{{password}}");
|
|
183
|
+
expect(stage.hash_field).toBe("password_hash");
|
|
184
|
+
expect(stage.output_field).toBe("valid");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("Stage.randomToken", () => {
|
|
189
|
+
it("produces a RandomToken stage with explicit encoding", () => {
|
|
190
|
+
const stage = Stage.randomToken(32, "session_token", "hex") as Extract<
|
|
191
|
+
FunctionStageConfig,
|
|
192
|
+
{ type: "RandomToken" }
|
|
193
|
+
>;
|
|
194
|
+
expect(stage.type).toBe("RandomToken");
|
|
195
|
+
expect(stage.bytes).toBe(32);
|
|
196
|
+
expect(stage.encoding).toBe("hex");
|
|
197
|
+
expect(stage.output_field).toBe("session_token");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("leaves encoding undefined by default (server treats as hex)", () => {
|
|
201
|
+
const stage = Stage.randomToken(16, "token") as Extract<
|
|
202
|
+
FunctionStageConfig,
|
|
203
|
+
{ type: "RandomToken" }
|
|
204
|
+
>;
|
|
205
|
+
expect(stage.encoding).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("accepts base64 and base64url encodings", () => {
|
|
209
|
+
const a = Stage.randomToken(16, "t", "base64") as Extract<
|
|
210
|
+
FunctionStageConfig,
|
|
211
|
+
{ type: "RandomToken" }
|
|
212
|
+
>;
|
|
213
|
+
const b = Stage.randomToken(16, "t", "base64url") as Extract<
|
|
214
|
+
FunctionStageConfig,
|
|
215
|
+
{ type: "RandomToken" }
|
|
216
|
+
>;
|
|
217
|
+
expect(a.encoding).toBe("base64");
|
|
218
|
+
expect(b.encoding).toBe("base64url");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("Crypto stages JSON wire format", () => {
|
|
223
|
+
it("BcryptHash round-trips through JSON unchanged", () => {
|
|
224
|
+
const stage = Stage.bcryptHash("{{password}}", "password_hash", 12);
|
|
225
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
226
|
+
expect(wire).toEqual({
|
|
227
|
+
type: "BcryptHash",
|
|
228
|
+
plain: "{{password}}",
|
|
229
|
+
cost: 12,
|
|
230
|
+
output_field: "password_hash",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("BcryptVerify round-trips through JSON unchanged", () => {
|
|
235
|
+
const stage = Stage.bcryptVerify("{{password}}", "password_hash", "valid");
|
|
236
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
237
|
+
expect(wire).toEqual({
|
|
238
|
+
type: "BcryptVerify",
|
|
239
|
+
plain: "{{password}}",
|
|
240
|
+
hash_field: "password_hash",
|
|
241
|
+
output_field: "valid",
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("RandomToken round-trips through JSON unchanged", () => {
|
|
246
|
+
const stage = Stage.randomToken(32, "token", "hex");
|
|
247
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
248
|
+
expect(wire).toEqual({
|
|
249
|
+
type: "RandomToken",
|
|
250
|
+
bytes: 32,
|
|
251
|
+
encoding: "hex",
|
|
252
|
+
output_field: "token",
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// JWT primitives: JwtSign, JwtVerify (ekoDB >= 0.42.0)
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
describe("Stage.jwtSign", () => {
|
|
262
|
+
it("produces a JwtSign stage with claims, expiry, and algorithm", () => {
|
|
263
|
+
const stage = Stage.jwtSign(
|
|
264
|
+
{ sub: "{{user_id}}", role: "admin" },
|
|
265
|
+
"{{env.JWT_SECRET}}",
|
|
266
|
+
"token",
|
|
267
|
+
3600,
|
|
268
|
+
"HS256",
|
|
269
|
+
) as Extract<FunctionStageConfig, { type: "JwtSign" }>;
|
|
270
|
+
expect(stage.type).toBe("JwtSign");
|
|
271
|
+
expect(stage.claims).toEqual({ sub: "{{user_id}}", role: "admin" });
|
|
272
|
+
expect(stage.secret).toBe("{{env.JWT_SECRET}}");
|
|
273
|
+
expect(stage.expires_in_secs).toBe(3600);
|
|
274
|
+
expect(stage.algorithm).toBe("HS256");
|
|
275
|
+
expect(stage.output_field).toBe("token");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("leaves algorithm and expires_in_secs undefined when omitted", () => {
|
|
279
|
+
const stage = Stage.jwtSign(
|
|
280
|
+
{ sub: "u" },
|
|
281
|
+
"{{env.JWT_SECRET}}",
|
|
282
|
+
"t",
|
|
283
|
+
) as Extract<FunctionStageConfig, { type: "JwtSign" }>;
|
|
284
|
+
expect(stage.algorithm).toBeUndefined();
|
|
285
|
+
expect(stage.expires_in_secs).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("Stage.jwtVerify", () => {
|
|
290
|
+
it("produces a JwtVerify stage wiring token_field and output_field", () => {
|
|
291
|
+
const stage = Stage.jwtVerify(
|
|
292
|
+
"auth_token",
|
|
293
|
+
"{{env.JWT_SECRET}}",
|
|
294
|
+
"claims",
|
|
295
|
+
"HS512",
|
|
296
|
+
) as Extract<FunctionStageConfig, { type: "JwtVerify" }>;
|
|
297
|
+
expect(stage.type).toBe("JwtVerify");
|
|
298
|
+
expect(stage.token_field).toBe("auth_token");
|
|
299
|
+
expect(stage.secret).toBe("{{env.JWT_SECRET}}");
|
|
300
|
+
expect(stage.algorithm).toBe("HS512");
|
|
301
|
+
expect(stage.output_field).toBe("claims");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("JWT stages JSON wire format", () => {
|
|
306
|
+
it("JwtSign round-trips through JSON unchanged", () => {
|
|
307
|
+
const stage = Stage.jwtSign(
|
|
308
|
+
{ sub: "user-1" },
|
|
309
|
+
"{{env.JWT_SECRET}}",
|
|
310
|
+
"token",
|
|
311
|
+
3600,
|
|
312
|
+
"HS256",
|
|
313
|
+
);
|
|
314
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
315
|
+
expect(wire).toEqual({
|
|
316
|
+
type: "JwtSign",
|
|
317
|
+
claims: { sub: "user-1" },
|
|
318
|
+
secret: "{{env.JWT_SECRET}}",
|
|
319
|
+
algorithm: "HS256",
|
|
320
|
+
expires_in_secs: 3600,
|
|
321
|
+
output_field: "token",
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("JwtVerify round-trips through JSON unchanged", () => {
|
|
326
|
+
const stage = Stage.jwtVerify(
|
|
327
|
+
"token",
|
|
328
|
+
"{{env.JWT_SECRET}}",
|
|
329
|
+
"claims",
|
|
330
|
+
"HS256",
|
|
331
|
+
);
|
|
332
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
333
|
+
expect(wire).toEqual({
|
|
334
|
+
type: "JwtVerify",
|
|
335
|
+
token_field: "token",
|
|
336
|
+
secret: "{{env.JWT_SECRET}}",
|
|
337
|
+
algorithm: "HS256",
|
|
338
|
+
output_field: "claims",
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ============================================================================
|
|
344
|
+
// EmailSend (ekoDB >= 0.42.0)
|
|
345
|
+
// ============================================================================
|
|
346
|
+
|
|
347
|
+
describe("Stage.emailSend", () => {
|
|
348
|
+
it("produces a SendGrid EmailSend stage with full payload", () => {
|
|
349
|
+
const stage = Stage.emailSend(
|
|
350
|
+
"alice@example.com",
|
|
351
|
+
"Welcome",
|
|
352
|
+
"<p>Hi Alice</p>",
|
|
353
|
+
"bot@example.com",
|
|
354
|
+
"{{env.SENDGRID_API_KEY}}",
|
|
355
|
+
{
|
|
356
|
+
reply_to: "support@example.com",
|
|
357
|
+
provider: "sendgrid",
|
|
358
|
+
html: true,
|
|
359
|
+
output_field: "send_result",
|
|
360
|
+
},
|
|
361
|
+
) as Extract<FunctionStageConfig, { type: "EmailSend" }>;
|
|
362
|
+
expect(stage.type).toBe("EmailSend");
|
|
363
|
+
expect(stage.to).toBe("alice@example.com");
|
|
364
|
+
expect(stage.subject).toBe("Welcome");
|
|
365
|
+
expect(stage.body).toBe("<p>Hi Alice</p>");
|
|
366
|
+
expect(stage.from).toBe("bot@example.com");
|
|
367
|
+
expect(stage.reply_to).toBe("support@example.com");
|
|
368
|
+
expect(stage.api_key).toBe("{{env.SENDGRID_API_KEY}}");
|
|
369
|
+
expect(stage.provider).toBe("sendgrid");
|
|
370
|
+
expect(stage.html).toBe(true);
|
|
371
|
+
expect(stage.output_field).toBe("send_result");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("leaves optional fields undefined when omitted", () => {
|
|
375
|
+
const stage = Stage.emailSend(
|
|
376
|
+
"x@example.com",
|
|
377
|
+
"s",
|
|
378
|
+
"b",
|
|
379
|
+
"f@example.com",
|
|
380
|
+
"k",
|
|
381
|
+
) as Extract<FunctionStageConfig, { type: "EmailSend" }>;
|
|
382
|
+
expect(stage.reply_to).toBeUndefined();
|
|
383
|
+
expect(stage.provider).toBeUndefined();
|
|
384
|
+
expect(stage.html).toBeUndefined();
|
|
385
|
+
expect(stage.output_field).toBeUndefined();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ============================================================================
|
|
390
|
+
// Error Handling & Control Flow: TryCatch, Parallel, Sleep (ekoDB >= 0.42.0)
|
|
391
|
+
// ============================================================================
|
|
392
|
+
|
|
393
|
+
describe("Stage.tryCatch", () => {
|
|
394
|
+
it("produces a TryCatch stage with try/catch function lists", () => {
|
|
395
|
+
const stage = Stage.tryCatch(
|
|
396
|
+
[Stage.httpRequest("https://api.example.com/data")],
|
|
397
|
+
[Stage.insert("fallback_log", { error: "{{error}}" })],
|
|
398
|
+
"api_error",
|
|
399
|
+
) as Extract<FunctionStageConfig, { type: "TryCatch" }>;
|
|
400
|
+
expect(stage.type).toBe("TryCatch");
|
|
401
|
+
expect(stage.try_functions).toHaveLength(1);
|
|
402
|
+
expect(stage.catch_functions).toHaveLength(1);
|
|
403
|
+
expect(stage.output_error_field).toBe("api_error");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("leaves output_error_field undefined when omitted", () => {
|
|
407
|
+
const stage = Stage.tryCatch(
|
|
408
|
+
[Stage.findAll("users")],
|
|
409
|
+
[Stage.insert("errors", { msg: "failed" })],
|
|
410
|
+
) as Extract<FunctionStageConfig, { type: "TryCatch" }>;
|
|
411
|
+
expect(stage.output_error_field).toBeUndefined();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("Stage.parallel", () => {
|
|
416
|
+
it("produces a Parallel stage with functions and wait_for_all", () => {
|
|
417
|
+
const stage = Stage.parallel(
|
|
418
|
+
[Stage.findAll("users"), Stage.findAll("orders")],
|
|
419
|
+
true,
|
|
420
|
+
) as Extract<FunctionStageConfig, { type: "Parallel" }>;
|
|
421
|
+
expect(stage.type).toBe("Parallel");
|
|
422
|
+
expect(stage.functions).toHaveLength(2);
|
|
423
|
+
expect(stage.wait_for_all).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("defaults wait_for_all to true", () => {
|
|
427
|
+
const stage = Stage.parallel([Stage.findAll("users")]) as Extract<
|
|
428
|
+
FunctionStageConfig,
|
|
429
|
+
{ type: "Parallel" }
|
|
430
|
+
>;
|
|
431
|
+
expect(stage.wait_for_all).toBe(true);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("accepts wait_for_all = false for race semantics", () => {
|
|
435
|
+
const stage = Stage.parallel(
|
|
436
|
+
[Stage.findAll("users"), Stage.findAll("cache")],
|
|
437
|
+
false,
|
|
438
|
+
) as Extract<FunctionStageConfig, { type: "Parallel" }>;
|
|
439
|
+
expect(stage.wait_for_all).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("Stage.sleep", () => {
|
|
444
|
+
it("produces a Sleep stage with numeric duration", () => {
|
|
445
|
+
const stage = Stage.sleep(1000) as Extract<
|
|
446
|
+
FunctionStageConfig,
|
|
447
|
+
{ type: "Sleep" }
|
|
448
|
+
>;
|
|
449
|
+
expect(stage.type).toBe("Sleep");
|
|
450
|
+
expect(stage.duration_ms).toBe(1000);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("accepts a text placeholder for parameter substitution", () => {
|
|
454
|
+
const stage = Stage.sleep("{{delay}}") as Extract<
|
|
455
|
+
FunctionStageConfig,
|
|
456
|
+
{ type: "Sleep" }
|
|
457
|
+
>;
|
|
458
|
+
expect(stage.duration_ms).toBe("{{delay}}");
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// Response Formatting: Return (ekoDB >= 0.42.0)
|
|
464
|
+
// ============================================================================
|
|
465
|
+
|
|
466
|
+
describe("Stage.returnResponse", () => {
|
|
467
|
+
it("produces a Return stage with fields and status_code", () => {
|
|
468
|
+
const stage = Stage.returnResponse(
|
|
469
|
+
{ message: "ok", user_id: "{{id}}" },
|
|
470
|
+
201,
|
|
471
|
+
) as Extract<FunctionStageConfig, { type: "Return" }>;
|
|
472
|
+
expect(stage.type).toBe("Return");
|
|
473
|
+
expect(stage.fields).toEqual({ message: "ok", user_id: "{{id}}" });
|
|
474
|
+
expect(stage.status_code).toBe(201);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("leaves status_code undefined when omitted (server defaults to 200)", () => {
|
|
478
|
+
const stage = Stage.returnResponse({ success: true }) as Extract<
|
|
479
|
+
FunctionStageConfig,
|
|
480
|
+
{ type: "Return" }
|
|
481
|
+
>;
|
|
482
|
+
expect(stage.status_code).toBeUndefined();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ============================================================================
|
|
487
|
+
// Data Validation: Validate (ekoDB >= 0.42.0)
|
|
488
|
+
// ============================================================================
|
|
489
|
+
|
|
490
|
+
describe("Stage.validate", () => {
|
|
491
|
+
it("produces a Validate stage with schema, data_field, and on_error", () => {
|
|
492
|
+
const schema = {
|
|
493
|
+
type: "object",
|
|
494
|
+
required: ["name", "email"],
|
|
495
|
+
properties: {
|
|
496
|
+
name: { type: "string" },
|
|
497
|
+
email: { type: "string", format: "email" },
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
const stage = Stage.validate(schema, "{{input}}", [
|
|
501
|
+
Stage.returnResponse({ error: "validation failed" }, 400),
|
|
502
|
+
]) as Extract<FunctionStageConfig, { type: "Validate" }>;
|
|
503
|
+
expect(stage.type).toBe("Validate");
|
|
504
|
+
expect(stage.schema).toEqual(schema);
|
|
505
|
+
expect(stage.data_field).toBe("{{input}}");
|
|
506
|
+
expect(stage.on_error).toHaveLength(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("leaves on_error undefined when omitted", () => {
|
|
510
|
+
const stage = Stage.validate({ type: "object" }, "record") as Extract<
|
|
511
|
+
FunctionStageConfig,
|
|
512
|
+
{ type: "Validate" }
|
|
513
|
+
>;
|
|
514
|
+
expect(stage.on_error).toBeUndefined();
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ============================================================================
|
|
519
|
+
// New stages JSON wire format
|
|
520
|
+
// ============================================================================
|
|
521
|
+
|
|
522
|
+
describe("New stages JSON wire format", () => {
|
|
523
|
+
it("TryCatch round-trips through JSON unchanged", () => {
|
|
524
|
+
const stage = Stage.tryCatch(
|
|
525
|
+
[Stage.findAll("users")],
|
|
526
|
+
[Stage.insert("errors", { msg: "failed" })],
|
|
527
|
+
"err",
|
|
528
|
+
);
|
|
529
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
530
|
+
expect(wire.type).toBe("TryCatch");
|
|
531
|
+
expect(wire.try_functions).toHaveLength(1);
|
|
532
|
+
expect(wire.catch_functions).toHaveLength(1);
|
|
533
|
+
expect(wire.output_error_field).toBe("err");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("Parallel round-trips through JSON unchanged", () => {
|
|
537
|
+
const stage = Stage.parallel(
|
|
538
|
+
[Stage.findAll("a"), Stage.findAll("b")],
|
|
539
|
+
false,
|
|
540
|
+
);
|
|
541
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
542
|
+
expect(wire).toEqual({
|
|
543
|
+
type: "Parallel",
|
|
544
|
+
functions: [
|
|
545
|
+
{ type: "FindAll", collection: "a" },
|
|
546
|
+
{ type: "FindAll", collection: "b" },
|
|
547
|
+
],
|
|
548
|
+
wait_for_all: false,
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("Sleep round-trips through JSON unchanged", () => {
|
|
553
|
+
const wire = JSON.parse(JSON.stringify(Stage.sleep(500)));
|
|
554
|
+
expect(wire).toEqual({ type: "Sleep", duration_ms: 500 });
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("Return round-trips through JSON unchanged", () => {
|
|
558
|
+
const stage = Stage.returnResponse({ ok: true }, 201);
|
|
559
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
560
|
+
expect(wire).toEqual({
|
|
561
|
+
type: "Return",
|
|
562
|
+
fields: { ok: true },
|
|
563
|
+
status_code: 201,
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("Validate round-trips through JSON unchanged", () => {
|
|
568
|
+
const stage = Stage.validate({ type: "object" }, "data");
|
|
569
|
+
const wire = JSON.parse(JSON.stringify(stage));
|
|
570
|
+
expect(wire).toEqual({
|
|
571
|
+
type: "Validate",
|
|
572
|
+
schema: { type: "object" },
|
|
573
|
+
data_field: "data",
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe("Crypto and concurrency stages", () => {
|
|
579
|
+
it("hmacSign builds a stage with explicit algorithm and encoding", () => {
|
|
580
|
+
const s = Stage.hmacSign("{{payload}}", "{{env.KEY}}", "mac", {
|
|
581
|
+
algorithm: "sha256",
|
|
582
|
+
encoding: "hex",
|
|
583
|
+
});
|
|
584
|
+
expect(s).toEqual({
|
|
585
|
+
type: "HmacSign",
|
|
586
|
+
input: "{{payload}}",
|
|
587
|
+
secret: "{{env.KEY}}",
|
|
588
|
+
algorithm: "sha256",
|
|
589
|
+
output_field: "mac",
|
|
590
|
+
encoding: "hex",
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("hmacVerify wires all fields", () => {
|
|
595
|
+
const s = Stage.hmacVerify("{{p}}", "{{m}}", "{{env.K}}", "ok");
|
|
596
|
+
expect(s).toMatchObject({
|
|
597
|
+
type: "HmacVerify",
|
|
598
|
+
input: "{{p}}",
|
|
599
|
+
provided_mac: "{{m}}",
|
|
600
|
+
secret: "{{env.K}}",
|
|
601
|
+
output_field: "ok",
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("aesEncrypt and aesDecrypt build matching envelope contracts", () => {
|
|
606
|
+
const enc = Stage.aesEncrypt(
|
|
607
|
+
"{{plain}}",
|
|
608
|
+
"{{env.DATA_KEY}}",
|
|
609
|
+
"envelope",
|
|
610
|
+
"hex",
|
|
611
|
+
);
|
|
612
|
+
expect(enc).toMatchObject({
|
|
613
|
+
type: "AesEncrypt",
|
|
614
|
+
plaintext: "{{plain}}",
|
|
615
|
+
key: "{{env.DATA_KEY}}",
|
|
616
|
+
key_encoding: "hex",
|
|
617
|
+
output_field: "envelope",
|
|
618
|
+
});
|
|
619
|
+
const dec = Stage.aesDecrypt(
|
|
620
|
+
"envelope",
|
|
621
|
+
"{{env.DATA_KEY}}",
|
|
622
|
+
"plain",
|
|
623
|
+
"hex",
|
|
624
|
+
);
|
|
625
|
+
expect(dec).toMatchObject({
|
|
626
|
+
type: "AesDecrypt",
|
|
627
|
+
ciphertext_field: "envelope",
|
|
628
|
+
output_field: "plain",
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("uuidGenerate is a single-field stage", () => {
|
|
633
|
+
expect(Stage.uuidGenerate("id")).toEqual({
|
|
634
|
+
type: "UuidGenerate",
|
|
635
|
+
output_field: "id",
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("totpGenerate and totpVerify build with all options", () => {
|
|
640
|
+
const gen = Stage.totpGenerate("{{env.TOTP}}", "code", {
|
|
641
|
+
digits: 6,
|
|
642
|
+
period: 30,
|
|
643
|
+
algorithm: "sha1",
|
|
644
|
+
});
|
|
645
|
+
expect(gen.type).toBe("TotpGenerate");
|
|
646
|
+
const ver = Stage.totpVerify("{{user_code}}", "{{env.TOTP}}", "ok", {
|
|
647
|
+
skew: 1,
|
|
648
|
+
});
|
|
649
|
+
expect(ver.type).toBe("TotpVerify");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("base64Encode/Decode and hexEncode/Decode build correctly", () => {
|
|
653
|
+
expect(Stage.base64Encode("{{x}}", "b", true)).toMatchObject({
|
|
654
|
+
type: "Base64Encode",
|
|
655
|
+
url_safe: true,
|
|
656
|
+
});
|
|
657
|
+
expect(Stage.base64Decode("{{b}}", "x")).toMatchObject({
|
|
658
|
+
type: "Base64Decode",
|
|
659
|
+
});
|
|
660
|
+
expect(Stage.hexEncode("{{x}}", "h")).toEqual({
|
|
661
|
+
type: "HexEncode",
|
|
662
|
+
input: "{{x}}",
|
|
663
|
+
output_field: "h",
|
|
664
|
+
});
|
|
665
|
+
expect(Stage.hexDecode("{{h}}", "x")).toEqual({
|
|
666
|
+
type: "HexDecode",
|
|
667
|
+
input: "{{h}}",
|
|
668
|
+
output_field: "x",
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("slugify builds a stage", () => {
|
|
673
|
+
expect(Stage.slugify("{{title}}", "slug")).toEqual({
|
|
674
|
+
type: "Slugify",
|
|
675
|
+
input: "{{title}}",
|
|
676
|
+
output_field: "slug",
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("idempotencyClaim, rateLimit, lockAcquire, lockRelease build correctly", () => {
|
|
681
|
+
expect(Stage.idempotencyClaim("{{ikey}}", 3600, "claim")).toEqual({
|
|
682
|
+
type: "IdempotencyClaim",
|
|
683
|
+
key: "{{ikey}}",
|
|
684
|
+
ttl_secs: 3600,
|
|
685
|
+
output_field: "claim",
|
|
686
|
+
});
|
|
687
|
+
expect(Stage.rateLimit("{{user}}", 100, 60, "rl", "skip")).toMatchObject({
|
|
688
|
+
type: "RateLimit",
|
|
689
|
+
limit: 100,
|
|
690
|
+
window_secs: 60,
|
|
691
|
+
on_exceed: "skip",
|
|
692
|
+
});
|
|
693
|
+
expect(Stage.lockAcquire("{{r}}", 30, "lock")).toEqual({
|
|
694
|
+
type: "LockAcquire",
|
|
695
|
+
key: "{{r}}",
|
|
696
|
+
ttl_secs: 30,
|
|
697
|
+
output_field: "lock",
|
|
698
|
+
});
|
|
699
|
+
expect(Stage.lockRelease("{{r}}", "{{lock.token}}", "rel")).toEqual({
|
|
700
|
+
type: "LockRelease",
|
|
701
|
+
key: "{{r}}",
|
|
702
|
+
token: "{{lock.token}}",
|
|
703
|
+
output_field: "rel",
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("crypto + concurrency stages round-trip through JSON unchanged", () => {
|
|
708
|
+
const stages = [
|
|
709
|
+
Stage.hmacSign("a", "k", "m", { algorithm: "sha256", encoding: "hex" }),
|
|
710
|
+
Stage.aesEncrypt("p", "k", "e", "hex"),
|
|
711
|
+
Stage.uuidGenerate("id"),
|
|
712
|
+
Stage.totpGenerate("s", "c", {
|
|
713
|
+
digits: 6,
|
|
714
|
+
period: 30,
|
|
715
|
+
algorithm: "sha1",
|
|
716
|
+
}),
|
|
717
|
+
Stage.idempotencyClaim("k", 60, "f"),
|
|
718
|
+
Stage.rateLimit("k", 5, 60, "rl"),
|
|
719
|
+
Stage.lockAcquire("r", 60, "l"),
|
|
720
|
+
];
|
|
721
|
+
for (const s of stages) {
|
|
722
|
+
const wire = JSON.parse(JSON.stringify(s));
|
|
723
|
+
expect(wire.type).toBe(s.type);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
});
|