@synode/core 1.0.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/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ Synode Proprietary License
2
+
3
+ Copyright (c) 2026 Digitl Cloud GmbH. All rights reserved.
4
+
5
+ Permission is hereby granted, free of charge, to any person or organization
6
+ obtaining a copy of this software and associated documentation files (the
7
+ "Software"), to use the Software for personal, internal, and commercial
8
+ purposes, subject to the following conditions:
9
+
10
+ 1. PERMITTED USE. You may use, copy, and modify the Software for your own
11
+ personal, internal, or commercial purposes.
12
+
13
+ 2. NO REDISTRIBUTION. You may not distribute, publish, sublicense, or
14
+ otherwise make the Software or any derivative works available to third
15
+ parties, whether in source code or compiled form, free of charge or for
16
+ a fee.
17
+
18
+ 3. NO RESALE. You may not sell, rent, lease, or otherwise commercially
19
+ exploit the Software itself as a standalone product or as part of a
20
+ software distribution.
21
+
22
+ 4. NO HOSTING AS A SERVICE. You may not offer the Software to third parties
23
+ as a hosted, managed, or software-as-a-service product where the primary
24
+ value derives from the Software.
25
+
26
+ 5. ATTRIBUTION. You must retain this license notice and copyright notice in
27
+ all copies or substantial portions of the Software.
28
+
29
+ 6. NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
30
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
31
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
32
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES
33
+ OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
34
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
35
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,593 @@
1
+ const require_index = require('./index.cjs');
2
+ let __faker_js_faker = require("@faker-js/faker");
3
+
4
+ //#region src/errors.ts
5
+ const PATH_LABELS = [
6
+ "Journey",
7
+ "Adventure",
8
+ "Action",
9
+ "Step"
10
+ ];
11
+ /**
12
+ * Formats an error message with a hierarchical path prefix and optional suggestion.
13
+ *
14
+ * @param message - The base error message
15
+ * @param path - Hierarchical path segments labeled Journey/Adventure/Action/Step
16
+ * @param suggestion - Optional suggestion appended on a new line
17
+ * @returns The formatted message string
18
+ */
19
+ function formatErrorMessage(message, path, suggestion) {
20
+ let formatted = message;
21
+ if (path.length > 0) formatted = `[${path.map((segment, i) => `${PATH_LABELS[Math.min(i, PATH_LABELS.length - 1)]} "${segment}"`).join(" > ")}] ${message}`;
22
+ if (suggestion) formatted += `\n${suggestion}`;
23
+ return formatted;
24
+ }
25
+ /**
26
+ * Structured error class for all Synode validation and runtime errors.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * throw new SynodeError({
31
+ * code: 'INVALID_BOUNCE_CHANCE',
32
+ * message: 'Bounce chance must be between 0 and 1',
33
+ * path: ['Purchase Flow', 'Checkout', 'Submit Order'],
34
+ * expected: 'number between 0 and 1',
35
+ * received: '1.5',
36
+ * });
37
+ * ```
38
+ */
39
+ var SynodeError = class extends Error {
40
+ /** Structured error code identifying the error type. */
41
+ code;
42
+ /** Hierarchical path where the error occurred. */
43
+ path;
44
+ /** Optional suggestion for fixing the error. */
45
+ suggestion;
46
+ /** The original unformatted error message. */
47
+ rawMessage;
48
+ /** Optional description of the expected value. */
49
+ expected;
50
+ /** Optional description of the actual value received. */
51
+ received;
52
+ constructor(options) {
53
+ const formatted = formatErrorMessage(options.message, options.path, options.suggestion);
54
+ super(formatted, { cause: options.cause });
55
+ this.name = "SynodeError";
56
+ this.code = options.code;
57
+ this.path = options.path;
58
+ this.suggestion = options.suggestion;
59
+ this.rawMessage = options.message;
60
+ this.expected = options.expected;
61
+ this.received = options.received;
62
+ }
63
+ /**
64
+ * Produces a structured multi-line representation of the error for human-readable output.
65
+ *
66
+ * Format:
67
+ * ```
68
+ * [ERROR_CODE] rawMessage
69
+ * Path: Journey 'x' > Adventure 'y' > Action 'z'
70
+ * Expected: what was expected
71
+ * Received: what was provided
72
+ * Fix: suggestion
73
+ * ```
74
+ *
75
+ * Path is omitted when path is empty. Expected/Received are omitted when not provided.
76
+ * Fix is omitted when there is no suggestion.
77
+ *
78
+ * @returns A formatted string representation of the error.
79
+ */
80
+ format() {
81
+ const lines = [`[${this.code}] ${this.rawMessage}`];
82
+ if (this.path.length > 0) {
83
+ const segments = this.path.map((segment, i) => `${PATH_LABELS[Math.min(i, PATH_LABELS.length - 1)]} '${segment}'`);
84
+ lines.push(` Path: ${segments.join(" > ")}`);
85
+ }
86
+ if (this.expected !== void 0) lines.push(` Expected: ${this.expected}`);
87
+ if (this.received !== void 0) lines.push(` Received: ${this.received}`);
88
+ if (this.suggestion) lines.push(` Fix: ${this.suggestion}`);
89
+ return lines.join("\n");
90
+ }
91
+ };
92
+ /**
93
+ * Computes the Levenshtein edit distance between two strings using a space-efficient
94
+ * two-row approach.
95
+ *
96
+ * @param a - First string
97
+ * @param b - Second string
98
+ * @returns The minimum number of single-character edits (insertions, deletions, substitutions)
99
+ */
100
+ function levenshtein(a, b) {
101
+ if (a === b) return 0;
102
+ if (a.length === 0) return b.length;
103
+ if (b.length === 0) return a.length;
104
+ let prev = new Array(b.length + 1);
105
+ let curr = new Array(b.length + 1);
106
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
107
+ for (let i = 1; i <= a.length; i++) {
108
+ curr[0] = i;
109
+ for (let j = 1; j <= b.length; j++) {
110
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
111
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
112
+ }
113
+ [prev, curr] = [curr, prev];
114
+ }
115
+ return prev[b.length];
116
+ }
117
+ /**
118
+ * Finds the closest matching string from a list of candidates using Levenshtein distance.
119
+ * Returns undefined if no candidate is within the dynamic threshold.
120
+ *
121
+ * The threshold is `max(3, floor(maxLen * 0.4))` where maxLen is the longer of the
122
+ * input and candidate strings.
123
+ *
124
+ * @param input - The string to match against candidates
125
+ * @param candidates - List of valid strings to compare against
126
+ * @returns The closest matching candidate, or undefined if none is close enough
127
+ */
128
+ function suggestClosest(input, candidates) {
129
+ if (candidates.length === 0) return void 0;
130
+ let bestMatch;
131
+ let bestDistance = Infinity;
132
+ for (const candidate of candidates) {
133
+ const distance = levenshtein(input, candidate);
134
+ const maxLen = Math.max(input.length, candidate.length);
135
+ if (distance <= Math.max(3, Math.floor(maxLen * .4)) && distance < bestDistance) {
136
+ bestDistance = distance;
137
+ bestMatch = candidate;
138
+ }
139
+ }
140
+ return bestMatch;
141
+ }
142
+ /**
143
+ * Builds a human-readable suggestion string for a not-found error, including
144
+ * the closest match (if any) and the full list of available options.
145
+ *
146
+ * @param kind - The kind of entity (e.g., "dataset", "journey")
147
+ * @param requested - The name that was requested but not found
148
+ * @param available - List of valid names
149
+ * @returns A suggestion string with "Did you mean..." and/or available options
150
+ */
151
+ function buildNotFoundSuggestion(kind, requested, available) {
152
+ const closest = suggestClosest(requested, available);
153
+ const parts = [];
154
+ if (closest) parts.push(`Did you mean '${closest}'?`);
155
+ parts.push(`Available ${kind}s: [${available.join(", ")}]`);
156
+ return parts.join(" ");
157
+ }
158
+
159
+ //#endregion
160
+ //#region src/state/ids.ts
161
+ /**
162
+ * Default ID generator using crypto.randomUUID.
163
+ */
164
+ const defaultIdGenerator = { generate(prefix) {
165
+ const uuid = crypto.randomUUID();
166
+ return prefix ? `${prefix}_${uuid}` : uuid;
167
+ } };
168
+
169
+ //#endregion
170
+ //#region src/state/context.ts
171
+ var SynodeContext = class {
172
+ state = /* @__PURE__ */ new Map();
173
+ fieldMetadata = /* @__PURE__ */ new Map();
174
+ currentTime;
175
+ _userId;
176
+ _sessionId;
177
+ _faker;
178
+ completedJourneys = /* @__PURE__ */ new Set();
179
+ datasets = /* @__PURE__ */ new Map();
180
+ constructor(startTime = /* @__PURE__ */ new Date(), idGenerator = defaultIdGenerator, locale = "en") {
181
+ this.idGenerator = idGenerator;
182
+ this.locale = locale;
183
+ this.currentTime = new Date(startTime);
184
+ this._userId = idGenerator.generate("user");
185
+ this._sessionId = idGenerator.generate("session");
186
+ const localeDef = __faker_js_faker.allLocales[locale];
187
+ this._faker = new __faker_js_faker.Faker({ locale: localeDef });
188
+ }
189
+ get faker() {
190
+ return this._faker;
191
+ }
192
+ get userId() {
193
+ return this._userId;
194
+ }
195
+ get sessionId() {
196
+ return this._sessionId;
197
+ }
198
+ get(key) {
199
+ return this.state.get(key);
200
+ }
201
+ /**
202
+ * Set a context field value with optional scope for automatic cleanup.
203
+ *
204
+ * @param key - The field name
205
+ * @param value - The field value
206
+ * @param options - Optional settings including lifecycle scope
207
+ *
208
+ * @remarks
209
+ * When calling `set()` multiple times on the same field with different scopes,
210
+ * the most recent scope setting will be used. For example, if a field is first
211
+ * set with `scope: 'adventure'` and later set again with `scope: 'action'`,
212
+ * it will be cleared at the end of the action rather than the adventure.
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * ctx.set('cartItems', [], { scope: 'adventure' });
217
+ * // Later in the same adventure...
218
+ * ctx.set('cartItems', ['item1'], { scope: 'action' }); // Now scoped to action
219
+ * ```
220
+ */
221
+ set(key, value, options) {
222
+ this.state.set(key, value);
223
+ if (options?.scope) this.fieldMetadata.set(key, { scope: options.scope });
224
+ else this.fieldMetadata.delete(key);
225
+ }
226
+ now() {
227
+ return new Date(this.currentTime);
228
+ }
229
+ generateId(prefix) {
230
+ return this.idGenerator.generate(prefix);
231
+ }
232
+ hasCompletedJourney(journeyId) {
233
+ return this.completedJourneys.has(journeyId);
234
+ }
235
+ markJourneyComplete(journeyId) {
236
+ this.completedJourneys.add(journeyId);
237
+ }
238
+ dataset(id) {
239
+ const ds = this.datasets.get(id);
240
+ if (!ds) {
241
+ const available = [...this.datasets.keys()];
242
+ const suggestion = available.length > 0 ? buildNotFoundSuggestion("dataset", id, available) : void 0;
243
+ throw new SynodeError({
244
+ code: "DATASET_NOT_FOUND",
245
+ message: `Dataset '${id}' not found in context`,
246
+ path: [],
247
+ suggestion
248
+ });
249
+ }
250
+ return new DatasetHandleImpl(ds);
251
+ }
252
+ typedDataset(id) {
253
+ const ds = this.datasets.get(id);
254
+ if (!ds) {
255
+ const available = [...this.datasets.keys()];
256
+ const suggestion = available.length > 0 ? buildNotFoundSuggestion("dataset", id, available) : void 0;
257
+ throw new SynodeError({
258
+ code: "DATASET_NOT_FOUND",
259
+ message: `Dataset '${id}' not found in context`,
260
+ path: [],
261
+ suggestion
262
+ });
263
+ }
264
+ return new DatasetHandleImpl(ds);
265
+ }
266
+ /**
267
+ * Internal: Register a dataset for use in this context.
268
+ */
269
+ registerDataset(dataset) {
270
+ this.datasets.set(dataset.id, dataset);
271
+ }
272
+ /**
273
+ * Internal: Advance the simulation time.
274
+ */
275
+ advanceTime(ms) {
276
+ this.currentTime = new Date(this.currentTime.getTime() + ms);
277
+ }
278
+ /**
279
+ * Internal: Rotate the session ID.
280
+ */
281
+ rotateSession() {
282
+ this._sessionId = this.idGenerator.generate("session");
283
+ }
284
+ /**
285
+ * Internal: Clear all fields with the specified scope.
286
+ * Called by the engine when a scope ends.
287
+ */
288
+ clearScope(scope) {
289
+ const keysToDelete = [];
290
+ for (const [key, metadata] of this.fieldMetadata.entries()) if (metadata.scope === scope) keysToDelete.push(key);
291
+ for (const key of keysToDelete) {
292
+ this.state.delete(key);
293
+ this.fieldMetadata.delete(key);
294
+ }
295
+ }
296
+ };
297
+ /**
298
+ * Internal implementation of DatasetHandle.
299
+ */
300
+ var DatasetHandleImpl = class {
301
+ constructor(dataset) {
302
+ this.dataset = dataset;
303
+ }
304
+ randomRow() {
305
+ if (this.dataset.rows.length === 0) throw new Error(`Dataset '${this.dataset.id}' is empty.`);
306
+ const index = Math.floor(Math.random() * this.dataset.rows.length);
307
+ return this.dataset.rows[index];
308
+ }
309
+ getRowById(id) {
310
+ return this.dataset.rows.find((row) => {
311
+ for (const key of Object.keys(row)) if (row[key] === id) return true;
312
+ return false;
313
+ });
314
+ }
315
+ getRowByIndex(index) {
316
+ return this.dataset.rows[index];
317
+ }
318
+ getAllRows() {
319
+ return [...this.dataset.rows];
320
+ }
321
+ size() {
322
+ return this.dataset.rows.length;
323
+ }
324
+ };
325
+
326
+ //#endregion
327
+ //#region src/generators/persona.ts
328
+ /**
329
+ * Defines a new persona.
330
+ */
331
+ function definePersona(config) {
332
+ return config;
333
+ }
334
+ /**
335
+ * Generates a concrete persona instance from a definition.
336
+ * This resolves all dynamic fields and weighted distributions.
337
+ */
338
+ async function generatePersona(definition, baseContext) {
339
+ const attributes = {};
340
+ for (const [key, generator] of Object.entries(definition.attributes)) if (typeof generator === "function") attributes[key] = await generator(baseContext, attributes);
341
+ else attributes[key] = generator;
342
+ return {
343
+ id: definition.id,
344
+ name: definition.name,
345
+ attributes
346
+ };
347
+ }
348
+
349
+ //#endregion
350
+ //#region src/generators/dataset.ts
351
+ /**
352
+ * Defines a new dataset with type inference support.
353
+ *
354
+ * @example
355
+ * ```typescript
356
+ * const products = defineDataset({
357
+ * id: 'products',
358
+ * name: 'Products',
359
+ * count: 100,
360
+ * fields: {
361
+ * id: (ctx, row) => `prod-${row.index}`,
362
+ * name: (ctx) => ctx.faker.commerce.productName(),
363
+ * price: (ctx) => ctx.faker.number.float({ min: 10, max: 500 }),
364
+ * },
365
+ * });
366
+ *
367
+ * // Type inference works automatically
368
+ * type Product = InferDatasetRow<typeof products>;
369
+ * ```
370
+ */
371
+ function defineDataset(config) {
372
+ return config;
373
+ }
374
+ /**
375
+ * Generates a concrete dataset from a definition.
376
+ * @param definition The dataset definition.
377
+ * @param context Context providing access to faker, other datasets, and user data.
378
+ */
379
+ async function generateDataset(definition, context) {
380
+ if (!Number.isFinite(definition.count) || definition.count < 0 || definition.count > 1e7) throw new Error(`Dataset '${definition.id}' count must be a finite number between 0 and 10,000,000, got ${String(definition.count)}`);
381
+ const rows = [];
382
+ for (let index = 0; index < definition.count; index++) {
383
+ const row = {};
384
+ for (const [key, generator] of Object.entries(definition.fields)) if (typeof generator === "function") row[key] = await generator(context, {
385
+ index,
386
+ data: row
387
+ });
388
+ else row[key] = generator;
389
+ rows.push(row);
390
+ }
391
+ return {
392
+ id: definition.id,
393
+ name: definition.name,
394
+ rows
395
+ };
396
+ }
397
+
398
+ //#endregion
399
+ //#region src/monitoring/timing.ts
400
+ /**
401
+ * Generates a delay in milliseconds based on a time span configuration.
402
+ */
403
+ function generateDelay(timeSpan) {
404
+ const { min, max, distribution = "uniform" } = timeSpan;
405
+ switch (distribution) {
406
+ case "uniform": return min + Math.random() * (max - min);
407
+ case "gaussian": {
408
+ const mean = (min + max) / 2;
409
+ const stdDev = (max - min) / 6;
410
+ const u1 = Math.random();
411
+ const u2 = Math.random();
412
+ const value = mean + Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) * stdDev;
413
+ return Math.max(min, Math.min(max, value));
414
+ }
415
+ case "exponential": {
416
+ const lambda = 1 / (max - min);
417
+ const value = min - Math.log(1 - Math.random()) / lambda;
418
+ return Math.min(max, value);
419
+ }
420
+ default: return min + Math.random() * (max - min);
421
+ }
422
+ }
423
+ /**
424
+ * Checks if a bounce should occur based on probability.
425
+ */
426
+ function shouldBounce(bounceChance) {
427
+ if (!bounceChance) return false;
428
+ return Math.random() < bounceChance;
429
+ }
430
+
431
+ //#endregion
432
+ //#region src/execution/engine.ts
433
+ var Engine = class {
434
+ constructor(journey) {
435
+ this.journey = journey;
436
+ }
437
+ /**
438
+ * Executes the journey and yields events.
439
+ * @param context Optional context to use. If not provided, a new one is created.
440
+ */
441
+ async *run(context) {
442
+ const ctx = context ?? new SynodeContext();
443
+ ctx.rotateSession();
444
+ if (this.journey.requires) {
445
+ for (const requiredJourneyId of this.journey.requires) if (!ctx.hasCompletedJourney(requiredJourneyId)) return;
446
+ }
447
+ if (shouldBounce(this.journey.bounceChance)) {
448
+ if (this.journey.suppressionPeriod) {
449
+ const suppressionDelay = generateDelay(this.journey.suppressionPeriod);
450
+ ctx.advanceTime(suppressionDelay);
451
+ }
452
+ return;
453
+ }
454
+ for (const adventure of this.journey.adventures) {
455
+ if (shouldBounce(adventure.bounceChance)) if (adventure.onBounce === "skip") {
456
+ ctx.clearScope("adventure");
457
+ continue;
458
+ } else {
459
+ ctx.clearScope("adventure");
460
+ break;
461
+ }
462
+ for (const action of adventure.actions) {
463
+ if (shouldBounce(action.bounceChance)) {
464
+ ctx.clearScope("action");
465
+ break;
466
+ }
467
+ if (adventure.timeSpan) {
468
+ const delay = generateDelay(adventure.timeSpan);
469
+ ctx.advanceTime(delay);
470
+ }
471
+ const actionPath = [
472
+ this.journey.id,
473
+ adventure.id,
474
+ action.id
475
+ ];
476
+ let events;
477
+ try {
478
+ events = await action.handler(ctx);
479
+ } catch (err) {
480
+ if (err instanceof SynodeError) throw err;
481
+ throw new SynodeError({
482
+ code: "HANDLER_ERROR",
483
+ message: `${err instanceof Error ? err.constructor.name : "UnknownError"}: ${err instanceof Error ? err.message : String(err)} (userId: ${ctx.userId}, sessionId: ${ctx.sessionId})`,
484
+ path: actionPath,
485
+ cause: err
486
+ });
487
+ }
488
+ if (!Array.isArray(events)) throw new SynodeError({
489
+ code: "INVALID_HANDLER_RETURN",
490
+ message: `Action handler must return an array of Events, got ${typeof events}`,
491
+ path: actionPath
492
+ });
493
+ for (let i = 0; i < events.length; i++) {
494
+ if (i > 0 && action.timeSpan) {
495
+ const delay = generateDelay(action.timeSpan);
496
+ ctx.advanceTime(delay);
497
+ events[i].timestamp = ctx.now();
498
+ }
499
+ yield events[i];
500
+ }
501
+ ctx.clearScope("action");
502
+ }
503
+ ctx.clearScope("adventure");
504
+ }
505
+ ctx.markJourneyComplete(this.journey.id);
506
+ ctx.clearScope("journey");
507
+ if (this.journey.suppressionPeriod) {
508
+ const suppressionDelay = generateDelay(this.journey.suppressionPeriod);
509
+ ctx.advanceTime(suppressionDelay);
510
+ }
511
+ }
512
+ };
513
+
514
+ //#endregion
515
+ Object.defineProperty(exports, 'Engine', {
516
+ enumerable: true,
517
+ get: function () {
518
+ return Engine;
519
+ }
520
+ });
521
+ Object.defineProperty(exports, 'SynodeContext', {
522
+ enumerable: true,
523
+ get: function () {
524
+ return SynodeContext;
525
+ }
526
+ });
527
+ Object.defineProperty(exports, 'SynodeError', {
528
+ enumerable: true,
529
+ get: function () {
530
+ return SynodeError;
531
+ }
532
+ });
533
+ Object.defineProperty(exports, 'buildNotFoundSuggestion', {
534
+ enumerable: true,
535
+ get: function () {
536
+ return buildNotFoundSuggestion;
537
+ }
538
+ });
539
+ Object.defineProperty(exports, 'defineDataset', {
540
+ enumerable: true,
541
+ get: function () {
542
+ return defineDataset;
543
+ }
544
+ });
545
+ Object.defineProperty(exports, 'definePersona', {
546
+ enumerable: true,
547
+ get: function () {
548
+ return definePersona;
549
+ }
550
+ });
551
+ Object.defineProperty(exports, 'formatErrorMessage', {
552
+ enumerable: true,
553
+ get: function () {
554
+ return formatErrorMessage;
555
+ }
556
+ });
557
+ Object.defineProperty(exports, 'generateDataset', {
558
+ enumerable: true,
559
+ get: function () {
560
+ return generateDataset;
561
+ }
562
+ });
563
+ Object.defineProperty(exports, 'generateDelay', {
564
+ enumerable: true,
565
+ get: function () {
566
+ return generateDelay;
567
+ }
568
+ });
569
+ Object.defineProperty(exports, 'generatePersona', {
570
+ enumerable: true,
571
+ get: function () {
572
+ return generatePersona;
573
+ }
574
+ });
575
+ Object.defineProperty(exports, 'levenshtein', {
576
+ enumerable: true,
577
+ get: function () {
578
+ return levenshtein;
579
+ }
580
+ });
581
+ Object.defineProperty(exports, 'shouldBounce', {
582
+ enumerable: true,
583
+ get: function () {
584
+ return shouldBounce;
585
+ }
586
+ });
587
+ Object.defineProperty(exports, 'suggestClosest', {
588
+ enumerable: true,
589
+ get: function () {
590
+ return suggestClosest;
591
+ }
592
+ });
593
+ //# sourceMappingURL=engine-CgLY6SKJ.cjs.map