@ductape/sdk 0.1.129 → 0.1.131

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.
@@ -76,6 +76,33 @@ export declare class FeatureService {
76
76
  * Get or create a ProductBuilder instance for the given product tag
77
77
  */
78
78
  private getProductBuilder;
79
+ /** Per-process guard so the one-time "cold cache" bulk warm (fetchFeatures()) only ever runs
80
+ * once per product per process, even under concurrent define() calls for many features at
81
+ * boot -- without this, N concurrent cache-miss defines would each independently trigger
82
+ * their own redundant bulk fetch. */
83
+ private featureCacheWarmed;
84
+ private featureFingerprintCacheKey;
85
+ /**
86
+ * A stable, deterministic fingerprint of exactly the fields that define a feature's real
87
+ * content (the same field set IFeatureConfig already establishes as "what a feature
88
+ * definition consists of" -- see features.types.ts). Deliberately excludes `tag` (redundant
89
+ * with the cache key) and any server-added bookkeeping fields (`_id`, timestamps) a remote
90
+ * IProductFeature carries that a locally-compiled schema never has -- comparing those would
91
+ * make every fingerprint mismatch even when the real, meaningful content is unchanged.
92
+ */
93
+ private computeFeatureFingerprint;
94
+ private getCachedFeatureFingerprint;
95
+ private setCachedFeatureFingerprint;
96
+ /**
97
+ * Fetches every real feature currently persisted for `productTag` in ONE bulk remote call
98
+ * (`builder.fetchFeatures()`, already existing -- the same call Ductape's own Workbench uses)
99
+ * and warms the Redis fingerprint cache for all of them at once. Real answer to "if Redis is
100
+ * empty, fetch all existing features, then check if the defined feature is already there
101
+ * before recreating it" -- this runs at most ONCE per product per process (concurrent
102
+ * define() calls for different features share the same in-flight promise, never triggering
103
+ * their own separate bulk fetch).
104
+ */
105
+ private warmFeatureCache;
79
106
  /**
80
107
  * Initialize logging service
81
108
  */
@@ -162,7 +189,7 @@ export declare class FeatureService {
162
189
  * const validation = await ctx.step('validate', async () => {
163
190
  * return ctx.api.run({
164
191
  * app: 'inventory-service',
165
- * event: 'validate-order',
192
+ * action: 'validate-order',
166
193
  * input: { body: ctx.input },
167
194
  * });
168
195
  * });
@@ -66,6 +66,22 @@ const console = {
66
66
  warn: globalThis.console.warn.bind(globalThis.console),
67
67
  error: globalThis.console.error.bind(globalThis.console),
68
68
  };
69
+ /**
70
+ * Deterministic JSON stringify: object keys are sorted recursively so two structurally-equal
71
+ * values always produce byte-identical output regardless of real-world key insertion order
72
+ * (a remote-fetched feature and a freshly-compiled local schema can legitimately differ only
73
+ * in property order, never in meaning). Array ORDER is preserved -- unlike object keys, array
74
+ * order is semantically meaningful here (`steps` is an ordered execution sequence).
75
+ */
76
+ function stableStringify(value) {
77
+ if (value === null || typeof value !== 'object')
78
+ return JSON.stringify(value);
79
+ if (Array.isArray(value))
80
+ return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
81
+ const keys = Object.keys(value).sort();
82
+ const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
83
+ return `{${entries.join(',')}}`;
84
+ }
69
85
  /**
70
86
  * Error class for feature-related errors
71
87
  */
@@ -119,6 +135,21 @@ class FeatureService {
119
135
  this.cacheManager = null;
120
136
  /** Local cache for cache configurations to avoid repeated API calls */
121
137
  this.cacheConfigCache = new Map();
138
+ // ==================== FEATURE-DEFINITION REDIS CACHE ====================
139
+ // define() previously did fetchFeature -> [fetchFeature + fetchEnv*N + updateProduct] ->
140
+ // fetchFeature on EVERY call, unconditionally, even when the feature's compiled schema was
141
+ // byte-for-byte identical to what was already persisted -- 5-6 real remote round-trips per
142
+ // feature, on every process boot, for features that never actually changed. At real
143
+ // application scale (dozens of features registered at startup) this turned every boot into a
144
+ // multi-minute serial remote-call chain. This cache closes that gap using the SAME Redis
145
+ // client already threaded through this service's own config (`config.redis_client`, already
146
+ // used by CacheManager above) -- degrades safely to "cache disabled, always hit the remote
147
+ // path" when no redis_client is configured, matching this class's existing pattern.
148
+ /** Per-process guard so the one-time "cold cache" bulk warm (fetchFeatures()) only ever runs
149
+ * once per product per process, even under concurrent define() calls for many features at
150
+ * boot -- without this, N concurrent cache-miss defines would each independently trigger
151
+ * their own redundant bulk fetch. */
152
+ this.featureCacheWarmed = new Map();
122
153
  //console.log('[FeatureService] constructor', { hasConfig: !!config, env_type: config?.env_type, hasRedis: !!config?.redis_client });
123
154
  this.config = config || null;
124
155
  this._privateKey = (config === null || config === void 0 ? void 0 : config.private_key) || '';
@@ -224,6 +255,89 @@ class FeatureService {
224
255
  }
225
256
  return builder;
226
257
  }
258
+ featureFingerprintCacheKey(productTag, tag) {
259
+ return `ductape:feature-fingerprint:${productTag}:${tag}`;
260
+ }
261
+ /**
262
+ * A stable, deterministic fingerprint of exactly the fields that define a feature's real
263
+ * content (the same field set IFeatureConfig already establishes as "what a feature
264
+ * definition consists of" -- see features.types.ts). Deliberately excludes `tag` (redundant
265
+ * with the cache key) and any server-added bookkeeping fields (`_id`, timestamps) a remote
266
+ * IProductFeature carries that a locally-compiled schema never has -- comparing those would
267
+ * make every fingerprint mismatch even when the real, meaningful content is unchanged.
268
+ */
269
+ computeFeatureFingerprint(feature) {
270
+ var _a, _b, _c, _d, _e, _f, _g, _h;
271
+ const comparable = {
272
+ name: feature.name,
273
+ description: (_a = feature.description) !== null && _a !== void 0 ? _a : null,
274
+ input: (_b = feature.input) !== null && _b !== void 0 ? _b : null,
275
+ output: (_c = feature.output) !== null && _c !== void 0 ? _c : null,
276
+ steps: (_d = feature.steps) !== null && _d !== void 0 ? _d : [],
277
+ signals: (_e = feature.signals) !== null && _e !== void 0 ? _e : null,
278
+ queries: (_f = feature.queries) !== null && _f !== void 0 ? _f : null,
279
+ options: (_g = feature.options) !== null && _g !== void 0 ? _g : null,
280
+ envs: (_h = feature.envs) !== null && _h !== void 0 ? _h : null,
281
+ };
282
+ return (0, crypto_1.createHash)('sha256').update(stableStringify(comparable)).digest('hex');
283
+ }
284
+ async getCachedFeatureFingerprint(productTag, tag) {
285
+ var _a;
286
+ const redisClient = (_a = this.config) === null || _a === void 0 ? void 0 : _a.redis_client;
287
+ if (!redisClient)
288
+ return null;
289
+ try {
290
+ return await redisClient.get(this.featureFingerprintCacheKey(productTag, tag));
291
+ }
292
+ catch (error) {
293
+ console.log('[FeatureService] getCachedFeatureFingerprint failed (treating as cache miss)', { productTag, tag, error: error instanceof Error ? error.message : String(error) });
294
+ return null;
295
+ }
296
+ }
297
+ async setCachedFeatureFingerprint(productTag, tag, fingerprint) {
298
+ var _a;
299
+ const redisClient = (_a = this.config) === null || _a === void 0 ? void 0 : _a.redis_client;
300
+ if (!redisClient)
301
+ return;
302
+ try {
303
+ await redisClient.set(this.featureFingerprintCacheKey(productTag, tag), fingerprint);
304
+ }
305
+ catch (error) {
306
+ // A failed cache WRITE must never fail define() itself -- the remote write already
307
+ // succeeded (or was confirmed unchanged) by the time this runs; losing the cache entry
308
+ // only costs a future redundant remote round-trip, not correctness.
309
+ console.log('[FeatureService] setCachedFeatureFingerprint failed (non-fatal)', { productTag, tag, error: error instanceof Error ? error.message : String(error) });
310
+ }
311
+ }
312
+ /**
313
+ * Fetches every real feature currently persisted for `productTag` in ONE bulk remote call
314
+ * (`builder.fetchFeatures()`, already existing -- the same call Ductape's own Workbench uses)
315
+ * and warms the Redis fingerprint cache for all of them at once. Real answer to "if Redis is
316
+ * empty, fetch all existing features, then check if the defined feature is already there
317
+ * before recreating it" -- this runs at most ONCE per product per process (concurrent
318
+ * define() calls for different features share the same in-flight promise, never triggering
319
+ * their own separate bulk fetch).
320
+ */
321
+ async warmFeatureCache(productTag, builder) {
322
+ let warmPromise = this.featureCacheWarmed.get(productTag);
323
+ if (!warmPromise) {
324
+ warmPromise = (async () => {
325
+ console.log('[FeatureService] warmFeatureCache: cold cache, fetching all existing features', { productTag });
326
+ const all = await builder.fetchFeatures();
327
+ const byTag = new Map();
328
+ for (const feature of all) {
329
+ if (!feature.tag)
330
+ continue;
331
+ byTag.set(feature.tag, feature);
332
+ await this.setCachedFeatureFingerprint(productTag, feature.tag, this.computeFeatureFingerprint(feature));
333
+ }
334
+ console.log('[FeatureService] warmFeatureCache: cached fingerprints for existing features', { productTag, count: byTag.size });
335
+ return byTag;
336
+ })();
337
+ this.featureCacheWarmed.set(productTag, warmPromise);
338
+ }
339
+ return warmPromise;
340
+ }
227
341
  /**
228
342
  * Initialize logging service
229
343
  */
@@ -466,7 +580,7 @@ class FeatureService {
466
580
  * const validation = await ctx.step('validate', async () => {
467
581
  * return ctx.api.run({
468
582
  * app: 'inventory-service',
469
- * event: 'validate-order',
583
+ * action: 'validate-order',
470
584
  * input: { body: ctx.input },
471
585
  * });
472
586
  * });
@@ -481,7 +595,7 @@ class FeatureService {
481
595
  * ```
482
596
  */
483
597
  async define(options) {
484
- var _a, _b;
598
+ var _a, _b, _c;
485
599
  console.log('[FeatureService] define ENTRY', { tag: options.tag, name: options.name, product: options.product });
486
600
  // Validate required fields
487
601
  if (!options.tag || !options.name || !options.handler) {
@@ -494,18 +608,61 @@ class FeatureService {
494
608
  console.log('[FeatureService] define compiling handler (compileAsync)', { tag: options.tag });
495
609
  const schema = await compiler.compileAsync();
496
610
  console.log('[FeatureService] define compiled schema', { tag: schema.tag, stepsCount: (_b = (_a = schema.steps) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0 });
497
- // If product is specified, create the feature
611
+ // If product is specified, create the feature -- but first check the Redis fingerprint
612
+ // cache (see the "FEATURE-DEFINITION REDIS CACHE" section above) so an unaltered feature
613
+ // that is ALREADY known to be persisted correctly never touches the remote API at all.
498
614
  if (options.product) {
499
- console.log('[FeatureService] define creating feature on backend', { product: options.product, tag: schema.tag });
500
- const builder = await this.getProductBuilder(options.product);
501
- const existing = await builder.fetchFeature(schema.tag);
502
- if (existing)
503
- await builder.updateFeature(schema.tag, schema);
504
- else
505
- await builder.createFeature(schema, true);
506
- const persisted = await builder.fetchFeature(schema.tag);
507
- if (!persisted) {
508
- throw new FeatureError(`Feature ${schema.tag} was acknowledged but is absent from the catalogue read path`, 'FEATURE_CATALOGUE_INCONSISTENT');
615
+ const fingerprint = this.computeFeatureFingerprint(schema);
616
+ const cachedFingerprint = await this.getCachedFeatureFingerprint(options.product, schema.tag);
617
+ if (cachedFingerprint === fingerprint) {
618
+ console.log('[FeatureService] define SKIPPED remote calls (Redis cache hit, unaltered)', { product: options.product, tag: schema.tag });
619
+ }
620
+ else {
621
+ console.log('[FeatureService] define creating feature on backend', { product: options.product, tag: schema.tag, cacheState: cachedFingerprint === null ? 'miss' : 'stale' });
622
+ const builder = await this.getProductBuilder(options.product);
623
+ let existing = await builder.fetchFeature(schema.tag);
624
+ // Real answer to "if Redis is empty, fetch all existing features, then check if the
625
+ // defined feature is there before recreating it": a cache miss AND no remote record for
626
+ // THIS tag could simply mean the cache was never warmed (e.g. first boot after a Redis
627
+ // flush) while the feature genuinely already exists remotely under normal operation --
628
+ // fetchFeature() above already covers that single-tag case, but warm the full cache here
629
+ // too (once per product per process) so every OTHER feature defined later in this same
630
+ // boot benefits from cache hits instead of each paying its own fetchFeature() call.
631
+ if (!this.featureCacheWarmed.has(options.product)) {
632
+ const allExisting = await this.warmFeatureCache(options.product, builder);
633
+ if (!existing)
634
+ existing = (_c = allExisting.get(schema.tag)) !== null && _c !== void 0 ? _c : null;
635
+ }
636
+ // Only a genuine remote WRITE (create or update) needs the post-write re-fetch below to
637
+ // guard against "acknowledged but not actually persisted" backend inconsistencies -- when
638
+ // `existing` already proves the feature is there (whether from fetchFeature's own by-tag
639
+ // lookup or the bulk warm fallback) and its fingerprint already matches, re-fetching by
640
+ // tag is not just redundant, it can genuinely fail: `existing` reaching us via the bulk
641
+ // warm path exists PRECISELY BECAUSE the by-tag lookup lagged behind once already for
642
+ // this same feature -- re-running that same lookup immediately after is not a reliable
643
+ // verification, it is repeating the exact race the warm fallback exists to route around.
644
+ let wroteRemotely = false;
645
+ if (existing) {
646
+ const existingFingerprint = this.computeFeatureFingerprint(existing);
647
+ if (existingFingerprint === fingerprint) {
648
+ console.log('[FeatureService] define: remote already matches compiled schema, skipping updateFeature', { product: options.product, tag: schema.tag });
649
+ }
650
+ else {
651
+ await builder.updateFeature(schema.tag, schema);
652
+ wroteRemotely = true;
653
+ }
654
+ }
655
+ else {
656
+ await builder.createFeature(schema, true);
657
+ wroteRemotely = true;
658
+ }
659
+ if (wroteRemotely) {
660
+ const persisted = await builder.fetchFeature(schema.tag);
661
+ if (!persisted) {
662
+ throw new FeatureError(`Feature ${schema.tag} was acknowledged but is absent from the catalogue read path`, 'FEATURE_CATALOGUE_INCONSISTENT');
663
+ }
664
+ }
665
+ await this.setCachedFeatureFingerprint(options.product, schema.tag, fingerprint);
509
666
  }
510
667
  }
511
668
  // Store locally