bazis 0.131.0 → 0.132.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.
Files changed (66) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  4. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  5. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  6. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
  7. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  8. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  9. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  10. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  11. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +26 -16
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  14. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  15. package/core/workflow/definitions_module/http/Processes.controller.ts +65 -23
  16. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  17. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  18. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +33 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
  21. package/core/workflow/generated/deps.ts +11 -3
  22. package/core/workflow/index.ts +4 -2
  23. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  24. package/core/workflow/internal/canonicalJson.ts +8 -0
  25. package/core/workflow/internal/http.ts +20 -1
  26. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +0 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
  32. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  33. package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
  34. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
  35. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
  36. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  37. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  38. package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
  39. package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
  40. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  41. package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
  42. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
  43. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
  44. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  45. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  46. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  47. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  48. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +3 -3
  49. package/library/openapi/codegen.ts +3 -0
  50. package/library/validation/types/ValidatorOptions.ts +2 -0
  51. package/library/workflow/ProcessDocument.ts +59 -23
  52. package/library/workflow/expressionSyntax.ts +243 -0
  53. package/library/workflow/expressions.ts +343 -0
  54. package/library/workflow/formFields.ts +118 -33
  55. package/library/workflow/index.ts +48 -9
  56. package/library/workflow/localized.ts +35 -0
  57. package/library/workflow/masking.ts +58 -0
  58. package/library/workflow/mermaid.ts +18 -8
  59. package/library/workflow/nodeKinds.ts +88 -17
  60. package/library/workflow/palette.ts +108 -0
  61. package/library/workflow/paths.ts +31 -6
  62. package/library/workflow/structure.ts +282 -0
  63. package/library/workflow/upgradeDocument.ts +84 -0
  64. package/library/workflow/validateProcess.ts +130 -48
  65. package/package.json +1 -1
  66. package/library/workflow/conditions.ts +0 -44
@@ -1,7 +1,10 @@
1
- import { checkFormSpec, formFieldCodes, type FormSpec } from "./formFields";
2
- import { nodeKindOf, type NodeRole } from "./nodeKinds";
3
- import { DATA_PATH } from "./paths";
4
- import { PROCESS_SCHEMA_VERSION, type ConditionOp } from "./ProcessDocument";
1
+ import { checkExpression, checkPath, isExpressionNode, type ExpressionScope } from "./expressions";
2
+ import { checkExtensions, variableProblems } from "./formFields";
3
+ import { checkLocalizedText } from "./localized";
4
+ import { ISO_DURATION, nodeKindOf, type NodeRole } from "./nodeKinds";
5
+ import { isPath } from "./paths";
6
+ import { PROCESS_SCHEMA_VERSION, type ProcessDocument } from "./ProcessDocument";
7
+ import { toStructure } from "./structure";
5
8
 
6
9
  /**
7
10
  * One finding of {@link validateProcess}. An `error` blocks publishing, a
@@ -14,12 +17,16 @@ export interface ProcessIssue {
14
17
  readonly message: string;
15
18
  readonly node?: string;
16
19
  readonly flow?: string;
20
+ readonly variable?: string;
17
21
  }
18
22
 
19
23
  export type ProcessIssueCode =
20
24
  | "schema"
21
25
  | "too_large"
22
- | "input_invalid"
26
+ | "variable_invalid"
27
+ | "settings_invalid"
28
+ | "extension_invalid"
29
+ | "extension_unknown"
23
30
  | "id_invalid"
24
31
  | "unknown_kind"
25
32
  | "title_invalid"
@@ -47,13 +54,14 @@ export type ProcessIssueCode =
47
54
  | "unreachable"
48
55
  | "dead_end"
49
56
  | "diagram_missing"
50
- | "diagram_orphan";
57
+ | "diagram_orphan"
58
+ | "not_structured";
51
59
 
52
- export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_000_000, conditionDepth: 8 });
60
+ export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_000_000 });
53
61
 
54
62
  const ID = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
55
- const OPS = new Set<ConditionOp>(["eq", "ne", "gt", "gte", "lt", "lte", "in", "contains", "empty", "notEmpty"]);
56
- const UNARY = new Set<ConditionOp>(["empty", "notEmpty"]);
63
+ /** Paths that come with later stages of WORKFLOW-002: until then they are an error, not a silent literal. */
64
+ const NOT_YET = /^\$(secrets|item|index)(\.|$)/;
57
65
 
58
66
  type Json = Record<string, unknown>;
59
67
 
@@ -71,6 +79,8 @@ export interface ValidateProcessOptions {
71
79
  * `callers`. Without it Tool names are only checked for their form.
72
80
  */
73
81
  readonly tools?: (name: string) => boolean;
82
+ /** Whether an `extensions` namespace has a check. Without it namespaces are only checked for their form. */
83
+ readonly extensions?: (namespace: string) => boolean;
74
84
  }
75
85
 
76
86
  /**
@@ -80,8 +90,8 @@ export interface ValidateProcessOptions {
80
90
  */
81
91
  export function validateProcess(document: unknown, options: ValidateProcessOptions = {}): ProcessIssue[] {
82
92
  const out: ProcessIssue[] = [];
83
- const error = (code: ProcessIssueCode, message: string, at: { node?: string; flow?: string } = {}) => out.push({ severity: "error", code, message, ...at });
84
- const warning = (code: ProcessIssueCode, message: string, at: { node?: string; flow?: string } = {}) => out.push({ severity: "warning", code, message, ...at });
93
+ const error = (code: ProcessIssueCode, message: string, at: IssueTarget = {}) => out.push({ severity: "error", code, message, ...at });
94
+ const warning = (code: ProcessIssueCode, message: string, at: IssueTarget = {}) => out.push({ severity: "warning", code, message, ...at });
85
95
 
86
96
  if (!isObject(document)) return [{ severity: "error", code: "schema", message: "document must be an object" }];
87
97
  if (JSON.stringify(document).length > PROCESS_LIMITS.bytes) return [{ severity: "error", code: "too_large", message: `document is larger than ${PROCESS_LIMITS.bytes} bytes` }];
@@ -90,10 +100,15 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
90
100
  if (!isObject(document.flows)) error("schema", "flows must be an object");
91
101
  if (document.diagram !== undefined && !isDiagram(document.diagram)) error("schema", "diagram must have shapes and edges objects with numeric coordinates");
92
102
  if (out.length > 0) return out;
93
- if (document.input !== undefined) {
94
- const problem = checkFormSpec(document.input, "input");
95
- if (problem) error("input_invalid", problem);
103
+ const variables = document.variables === undefined ? [] : variableProblems(document.variables);
104
+ for (const { message, variable } of variables) error("variable_invalid", message, variable === undefined ? {} : { variable });
105
+ const declared = variables.length === 0 && Array.isArray(document.variables) ? document.variables as readonly Json[] : [];
106
+ if (document.settings !== undefined) {
107
+ const problem = checkSettings(document.settings);
108
+ if (problem) error("settings_invalid", problem);
96
109
  }
110
+ checkNamespaces(document.extensions, "extensions", {}, options, error);
111
+ for (const variable of declared) checkNamespaces(variable.extensions, "extensions", { variable: String(variable.name) }, options, error);
97
112
 
98
113
  const nodes = document.nodes as Json;
99
114
  const flows = document.flows as Json;
@@ -117,7 +132,9 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
117
132
  }
118
133
  roles.set(id, definition.role);
119
134
  kinds.set(id, definition.kind);
120
- if (typeof raw.title !== "string" || raw.title.trim().length === 0 || raw.title.length > 200) error("title_invalid", "title must be 1–200 characters", { node: id });
135
+ const titleProblem = checkLocalizedText(raw.title, 200);
136
+ if (titleProblem) error("title_invalid", `title ${titleProblem}`, { node: id });
137
+ checkNamespaces(raw.extensions, "extensions", { node: id }, options, error);
121
138
  if (raw.config !== undefined && !isObject(raw.config)) {
122
139
  error("config_invalid", "config must be an object", { node: id });
123
140
  } else {
@@ -140,9 +157,22 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
140
157
  else if (kinds.get(id) === "boundaryError" && kinds.get(host) !== "serviceTask") error("boundary_attach", "error boundary attaches only to a service task", { node: id });
141
158
  }
142
159
 
143
- // What conditions may read: declared input fields and existing nodes.
144
- const inputSpec = document.input !== undefined && checkFormSpec(document.input, "input") === null ? document.input as FormSpec : undefined;
145
- const scope: ConditionScope = { input: inputSpec === undefined ? null : new Set(formFieldCodes(inputSpec)), nodes: roles };
160
+ // What expressions may read: declared variables, what steps save and existing nodes.
161
+ const named = (direction: (value: unknown) => boolean) => new Set(declared.filter((variable) => direction(variable.direction)).map((variable) => String(variable.name)));
162
+ const writable = named((direction) => direction !== "in");
163
+ const saved = new Set(writable);
164
+ for (const raw of Object.values(nodes)) {
165
+ const saveTo = isObject(raw) && isObject(raw.config) ? raw.config.saveTo : undefined;
166
+ if (typeof saveTo === "string") saved.add(saveTo);
167
+ }
168
+ const known = variables.length === 0;
169
+ const scope: ExpressionScope = { ...(known ? { input: named((direction) => direction === "in"), vars: saved } : {}), nodes: new Set(Object.keys(nodes)) };
170
+
171
+ // What steps read: an assign writes declared variables, inputs and waits read what exists.
172
+ for (const [id, raw] of Object.entries(nodes)) {
173
+ if (!isObject(raw) || !isObject(raw.config) || definitionProblem(raw)) continue;
174
+ for (const problem of configExpressionProblems(raw.kind as string, raw.config, scope, known ? writable : undefined)) error("config_invalid", problem, { node: id });
175
+ }
146
176
 
147
177
  // Flows: ends, duplicates, conditions.
148
178
  const outgoing = new Map<string, string[]>();
@@ -168,13 +198,16 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
168
198
  pairs.add(pair);
169
199
  outgoing.set(source, [...outgoing.get(source) ?? [], id]);
170
200
  incoming.set(target, [...incoming.get(target) ?? [], id]);
171
- if (raw.title !== undefined && (typeof raw.title !== "string" || raw.title.length > 200)) error("title_invalid", "flow title must be up to 200 characters", { flow: id });
201
+ if (raw.title !== undefined) {
202
+ const problem = checkLocalizedText(raw.title, 200);
203
+ if (problem) error("title_invalid", `flow title ${problem}`, { flow: id });
204
+ }
172
205
  if (raw.isDefault !== undefined && typeof raw.isDefault !== "boolean") error("default_invalid", "isDefault must be a boolean", { flow: id });
173
206
  const fromChoice = kinds.get(source) === "exclusiveGateway";
174
207
  if (raw.condition !== undefined) {
175
208
  if (!fromChoice) error("condition_not_allowed", "only flows leaving an exclusive gateway have conditions", { flow: id });
176
209
  else {
177
- const problem = checkCondition(raw.condition, 0, scope);
210
+ const problem = checkExpression(raw.condition, scope);
178
211
  if (problem) error("condition_invalid", problem, { flow: id });
179
212
  }
180
213
  }
@@ -234,11 +267,14 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
234
267
  for (const id of Object.keys(diagram.shapes)) if (!Object.hasOwn(nodes, id)) warning("diagram_orphan", "shape without a node", { node: id });
235
268
  for (const id of Object.keys(diagram.edges)) if (!Object.hasOwn(flows, id)) warning("diagram_orphan", "edge without a flow", { flow: id });
236
269
  }
270
+ if (!hasErrors(out) && toStructure(document as unknown as ProcessDocument) === undefined) {
271
+ warning("not_structured", "the graph cannot be drawn as a list of steps: a designer shows it read only");
272
+ }
237
273
  return out;
238
274
  }
239
275
 
240
276
  /** An exclusive gateway: every outgoing flow has a condition, except at most one default flow. */
241
- function checkChoice(id: string, outs: readonly string[], flows: Json, error: (code: ProcessIssueCode, message: string, at?: { node?: string; flow?: string }) => void): void {
277
+ function checkChoice(id: string, outs: readonly string[], flows: Json, error: (code: ProcessIssueCode, message: string, at?: IssueTarget) => void): void {
242
278
  const defaults = outs.filter((flow) => (flows[flow] as Json).isDefault === true);
243
279
  if (defaults.length > 1) error("default_invalid", "exclusive gateway can have only one default flow", { node: id });
244
280
  for (const flow of outs) {
@@ -251,37 +287,83 @@ function checkChoice(id: string, outs: readonly string[], flows: Json, error: (c
251
287
  }
252
288
  }
253
289
 
254
- interface ConditionScope {
255
- /** Input field codes; `null` when no input is declared and `$input` is not checked. */
256
- readonly input: ReadonlySet<string> | null;
257
- readonly nodes: ReadonlyMap<string, unknown>;
290
+ /** The settings this version of the framework runs; a setting that would be stored and ignored is an error. */
291
+ function checkSettings(value: unknown): string | null {
292
+ if (!isObject(value)) return "settings must be an object";
293
+ for (const [key, item] of Object.entries(value)) {
294
+ if (key === "stepTimeout") {
295
+ if (typeof item !== "string" || !ISO_DURATION.test(item)) return "settings.stepTimeout must be an ISO duration without months and years";
296
+ } else if (key === "log") {
297
+ if (!isObject(item)) return "settings.log must be an object";
298
+ for (const [flag, on] of Object.entries(item)) {
299
+ if (flag !== "variables" && flag !== "toolPayloads" && flag !== "maskPersonal") return `settings.log.${flag} is unknown`;
300
+ if (typeof on !== "boolean") return `settings.log.${flag} must be a boolean`;
301
+ }
302
+ } else {
303
+ return `settings.${key} is not supported`;
304
+ }
305
+ }
306
+ return null;
258
307
  }
259
308
 
260
- function checkCondition(condition: unknown, depth: number, scope: ConditionScope): string | null {
261
- if (depth > PROCESS_LIMITS.conditionDepth) return `condition is nested deeper than ${PROCESS_LIMITS.conditionDepth}`;
262
- if (!isObject(condition)) return "condition must be an object";
263
- const keys = Object.keys(condition);
264
- if (keys.length === 1 && (keys[0] === "all" || keys[0] === "any")) {
265
- const list = condition[keys[0]];
266
- if (!Array.isArray(list) || list.length === 0) return `${keys[0]} must be a non-empty list`;
267
- for (const item of list) {
268
- const problem = checkCondition(item, depth + 1, scope);
269
- if (problem) return problem;
309
+ type IssueTarget = { node?: string; flow?: string; variable?: string };
310
+
311
+ /** `extensions` of the root or a node: its form, then whether its namespaces have a check. */
312
+ function checkNamespaces(value: unknown, at: string, target: IssueTarget, options: ValidateProcessOptions, error: (code: ProcessIssueCode, message: string, at?: IssueTarget) => void): void {
313
+ if (value === undefined) return;
314
+ const problem = checkExtensions(value, at);
315
+ if (problem) {
316
+ error("extension_invalid", problem, target);
317
+ return;
318
+ }
319
+ if (options.extensions === undefined) return;
320
+ for (const namespace of Object.keys(value as Json)) {
321
+ if (!options.extensions(namespace)) error("extension_unknown", `no check is registered for the extensions namespace ${namespace}`, target);
322
+ }
323
+ }
324
+
325
+ function definitionProblem(raw: Json): boolean {
326
+ const definition = nodeKindOf(raw.kind);
327
+ return definition === undefined || definition.check(raw.config as Json) !== null;
328
+ }
329
+
330
+ /** The expressions and paths in a step's `config`, against what the document declares. */
331
+ function configExpressionProblems(kind: string, config: Json, scope: ExpressionScope, writable: ReadonlySet<string> | undefined): string[] {
332
+ const out: string[] = [];
333
+ if (kind === "assign") {
334
+ for (const [index, item] of (config.assignments as readonly Json[]).entries()) {
335
+ const name = item.var as string;
336
+ if (writable && !writable.has(name)) out.push(`config.assignments[${index}].var: ${name} is not a declared local or out variable`);
337
+ const problem = checkExpression(item.value, scope);
338
+ if (problem) out.push(`config.assignments[${index}].value: ${problem}`);
270
339
  }
271
- return null;
272
340
  }
273
- if (keys.length === 1 && keys[0] === "not") return checkCondition(condition.not, depth + 1, scope);
274
- if (typeof condition.path !== "string" || !DATA_PATH.test(condition.path)) return "condition.path must look like $input.field, $vars.name or $nodes.Node.field";
275
- const [root, first] = condition.path.split(".");
276
- if (root === "$input" && scope.input && !scope.input.has(first!)) return `condition.path ${condition.path}: input has no field ${first}`;
277
- if (root === "$nodes" && !scope.nodes.has(first!)) return `condition.path ${condition.path}: no node ${first}`;
278
- if (typeof condition.op !== "string" || !OPS.has(condition.op as ConditionOp)) return `condition.op must be one of ${[...OPS].join(", ")}`;
279
- const op = condition.op as ConditionOp;
280
- if (keys.some((key) => key !== "path" && key !== "op" && key !== "value")) return "condition has unknown keys";
281
- if (UNARY.has(op)) return condition.value === undefined ? null : `${op} takes no value`;
282
- if (condition.value === undefined) return `${op} needs a value`;
283
- if (op === "in" && !Array.isArray(condition.value)) return "in needs a list value";
284
- return null;
341
+ if (kind === "serviceTask" && config.input !== undefined) walkInput(config.input, "config.input", scope, out);
342
+ for (const key of ["at", "correlation"]) {
343
+ if (typeof config[key] === "string") {
344
+ const problem = checkPath(config[key], scope);
345
+ if (problem) out.push(`config.${key}: ${problem}`);
346
+ }
347
+ }
348
+ return out;
349
+ }
350
+
351
+ function walkInput(value: unknown, at: string, scope: ExpressionScope, out: string[]): void {
352
+ if (typeof value === "string") {
353
+ if (NOT_YET.test(value)) out.push(`${at}: ${value} is not supported`);
354
+ else if (isPath(value)) {
355
+ const problem = checkPath(value, scope);
356
+ if (problem) out.push(`${at}: ${problem}`);
357
+ }
358
+ return;
359
+ }
360
+ if (isExpressionNode(value)) {
361
+ const problem = checkExpression(value, scope);
362
+ if (problem) out.push(`${at}: ${problem}`);
363
+ return;
364
+ }
365
+ if (Array.isArray(value)) value.forEach((item, index) => walkInput(item, `${at}[${index}]`, scope, out));
366
+ else if (isObject(value)) for (const [key, item] of Object.entries(value)) walkInput(item, `${at}.${key}`, scope, out);
285
367
  }
286
368
 
287
369
  function isDiagram(value: unknown): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.131.0",
3
+ "version": "0.132.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",
@@ -1,44 +0,0 @@
1
- import type { Condition } from "./ProcessDocument";
2
- import { isPath, readPath, type InstanceData } from "./paths";
3
-
4
- /** Evaluates a flow condition. Pure: no calls, no side effects. */
5
- export function evaluateCondition(condition: Condition, data: InstanceData): boolean {
6
- if ("all" in condition) return condition.all.every((item) => evaluateCondition(item, data));
7
- if ("any" in condition) return condition.any.some((item) => evaluateCondition(item, data));
8
- if ("not" in condition) return !evaluateCondition(condition.not, data);
9
- const actual = readPath(data, condition.path);
10
- const expected = isPath(condition.value) ? readPath(data, condition.value) : condition.value;
11
- switch (condition.op) {
12
- case "eq": return same(actual, expected);
13
- case "ne": return !same(actual, expected);
14
- case "gt": return compare(actual, expected, (a, b) => a > b);
15
- case "gte": return compare(actual, expected, (a, b) => a >= b);
16
- case "lt": return compare(actual, expected, (a, b) => a < b);
17
- case "lte": return compare(actual, expected, (a, b) => a <= b);
18
- case "in": return Array.isArray(expected) && expected.some((item) => same(actual, item));
19
- case "contains":
20
- if (typeof actual === "string") return typeof expected === "string" && actual.includes(expected);
21
- return Array.isArray(actual) && actual.some((item) => same(item, expected));
22
- case "empty": return isEmpty(actual);
23
- case "notEmpty": return !isEmpty(actual);
24
- }
25
- }
26
-
27
- function same(a: unknown, b: unknown): boolean {
28
- if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return Number(a) === Number(b);
29
- return JSON.stringify(a) === JSON.stringify(b);
30
- }
31
-
32
- /** Numbers compare as numbers (a decimal arrives as a string), other strings as strings (ISO dates compare right). */
33
- function compare(a: unknown, b: unknown, test: (a: number | string, b: number | string) => boolean): boolean {
34
- if (a === null || a === undefined || b === null || b === undefined) return false;
35
- const na = Number(a), nb = Number(b);
36
- if (typeof a !== "boolean" && typeof b !== "boolean" && a !== "" && b !== "" && Number.isFinite(na) && Number.isFinite(nb)) return test(na, nb);
37
- return typeof a === "string" && typeof b === "string" ? test(a, b) : false;
38
- }
39
-
40
- function isEmpty(value: unknown): boolean {
41
- if (value === null || value === undefined || value === "") return true;
42
- if (Array.isArray(value)) return value.length === 0;
43
- return typeof value === "object" && Object.keys(value).length === 0;
44
- }