@rotorsoft/act 1.22.5 → 1.22.7

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/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  sleep,
21
21
  store,
22
22
  validate
23
- } from "./chunk-CWBI4NQD.js";
23
+ } from "./chunk-QVHLKVS7.js";
24
24
  import {
25
25
  ActorSchema,
26
26
  CausationEventSchema,
@@ -89,6 +89,15 @@ function deprecated_event_names(names) {
89
89
  const deprecated = /* @__PURE__ */ new Set();
90
90
  for (const list of groups.values()) {
91
91
  if (list.length < 2) continue;
92
+ const by_version = /* @__PURE__ */ new Map();
93
+ for (const { version, name } of list) {
94
+ const clash = by_version.get(version);
95
+ if (clash)
96
+ throw new Error(
97
+ `duplicate event version: ${clash} and ${name} both map to version ${version} \u2014 leading zeros in a \`_v<n>\` suffix are not allowed; use a single canonical spelling`
98
+ );
99
+ by_version.set(version, name);
100
+ }
92
101
  list.sort((a, b) => b.version - a.version);
93
102
  for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
94
103
  }
@@ -534,18 +543,33 @@ function hour_in_zone(now, timeZone) {
534
543
  }).formatToParts(now);
535
544
  return Number(parts.find((p) => p.type === "hour")?.value);
536
545
  }
546
+ function hour_in_range(hour, start, end) {
547
+ return start < end ? hour >= start && hour < end : hour >= start || hour < end;
548
+ }
549
+ function is_dst_gap_open(now, timeZone, start) {
550
+ const hour = hour_in_zone(now, timeZone);
551
+ if (hour <= start) return false;
552
+ const prev = hour_in_zone(new Date(now.getTime() - 36e5), timeZone);
553
+ return prev === start - 1 && hour === start + 1;
554
+ }
537
555
  function in_autoclose_window(window, now) {
538
556
  if (!window) return true;
539
557
  const hour = hour_in_zone(now, window.timeZone);
540
- return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
558
+ return hour_in_range(hour, window.start, window.end) || is_dst_gap_open(now, window.timeZone, window.start);
541
559
  }
542
560
  function next_window_open(window, now) {
561
+ const start_ms = now.getTime();
543
562
  for (let h = 0; h <= 48; h++) {
544
- const candidate = new Date(now.getTime() + h * 36e5);
563
+ const candidate = new Date(start_ms + h * 36e5);
545
564
  if (hour_in_zone(candidate, window.timeZone) === window.start)
546
565
  return candidate;
566
+ const boundary = new Date(
567
+ candidate.getTime() - candidate.getTime() % 36e5
568
+ );
569
+ if (is_dst_gap_open(boundary, window.timeZone, window.start))
570
+ return boundary;
547
571
  }
548
- return new Date(now.getTime() + 864e5);
572
+ return new Date(start_ms + 864e5);
549
573
  }
550
574
 
551
575
  // src/internal/autoclose-policy.ts
@@ -1382,6 +1406,16 @@ var CorrelateCycle = class {
1382
1406
  _has_dynamic_resolvers;
1383
1407
  _cd;
1384
1408
  _on_init;
1409
+ /**
1410
+ * Async cold-start hook (#1221). Runs once, after the sync `on_init`,
1411
+ * inside the same `init()` await. The orchestrator uses it to re-seed the
1412
+ * process-local defer timers from the store's persisted `deferred_at` so
1413
+ * an idle deferred stream re-arms its drain across a restart. Kept
1414
+ * separate from `on_init` because seeding is an async store read; `init`
1415
+ * already awaits, so folding it in here preserves the "runs exactly once"
1416
+ * guarantee without a second gate on the Act side.
1417
+ */
1418
+ _on_init_async;
1385
1419
  /**
1386
1420
  * Scope runner (#1191). The periodic `start_polling` timer fires
1387
1421
  * outside any caller frame, so its `correlate()` must be re-wrapped in
@@ -1390,7 +1424,7 @@ var CorrelateCycle = class {
1390
1424
  * `_scoped` (identity for a non-scoped Act), so it's required.
1391
1425
  */
1392
1426
  _run_scoped;
1393
- constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped) {
1427
+ constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped, on_init_async) {
1394
1428
  this._subscribed = new LruSet(maxSubscribedStreams);
1395
1429
  this._registry = registry;
1396
1430
  this._static_targets = static_targets;
@@ -1398,6 +1432,7 @@ var CorrelateCycle = class {
1398
1432
  this._cd = cd;
1399
1433
  this._on_init = on_init;
1400
1434
  this._run_scoped = run_scoped;
1435
+ this._on_init_async = on_init_async;
1401
1436
  }
1402
1437
  /** Last correlated event id. */
1403
1438
  get checkpoint() {
@@ -1419,6 +1454,7 @@ var CorrelateCycle = class {
1419
1454
  for (const { stream } of this._static_targets) {
1420
1455
  this._subscribed.add(stream);
1421
1456
  }
1457
+ await this._on_init_async?.();
1422
1458
  }
1423
1459
  /**
1424
1460
  * Discover dynamic-resolver targets in the events past the checkpoint
@@ -2292,6 +2328,22 @@ var DrainController = class {
2292
2328
  arm() {
2293
2329
  this._armed = true;
2294
2330
  }
2331
+ /**
2332
+ * Re-seed a persisted defer schedule into the process-local timer at cold
2333
+ * start (#1221). The `_defer` map lives in worker memory and is empty
2334
+ * after a restart; a stream deferred to a future due-time (e.g. an idle
2335
+ * autoclose aggregate) is durable in the store's `deferred_at` but has
2336
+ * nothing in memory to re-arm the drain at the due-time. The orchestrator
2337
+ * reads the persisted `deferred_at` for this controller's lane and calls
2338
+ * this to park the stream + (re)schedule the shared wake — so the drain
2339
+ * re-arms at the due-time with no intervening commit. `schedule()`
2340
+ * collapses many seeds into one timer, so callers may seed in a loop and
2341
+ * let the earliest due-time win.
2342
+ */
2343
+ seed_defer(stream, at) {
2344
+ this._defer.set(stream, at);
2345
+ this._defer.schedule();
2346
+ }
2295
2347
  /** Read-only flag — true while a commit / reset is unprocessed. */
2296
2348
  get armed() {
2297
2349
  return this._armed;
@@ -2840,6 +2892,7 @@ var Act = class {
2840
2892
  this._drain = options.drain !== false;
2841
2893
  this._breaker = this._build_breaker(options);
2842
2894
  this._drain_controllers = this._build_drain_controllers(options, lanes);
2895
+ this._advise_orphaned_lanes(options, lanes);
2843
2896
  this._audit_deps = this._build_audit_deps();
2844
2897
  this._correlate = this._build_correlate(options, classification);
2845
2898
  this._settle = this._build_settle(options);
@@ -2932,6 +2985,31 @@ var Act = class {
2932
2985
  }
2933
2986
  return controllers;
2934
2987
  }
2988
+ /**
2989
+ * Orphaned-lane startup advisory (#1220). When `onlyLanes` is set, this
2990
+ * instance builds a controller only for its slice of the declared lane
2991
+ * universe — every OTHER declared lane's stream is persisted (correlate
2992
+ * subscribes all static targets regardless of `onlyLanes`) but never
2993
+ * claimed here. If no peer worker deploys with those lanes in ITS
2994
+ * `onlyLanes`, their reactions accumulate forever, silently. A single
2995
+ * process can't verify the cluster invariant `∪ onlyLanes ⊇ declared
2996
+ * lanes`, so we surface the per-instance signal — "these declared lanes
2997
+ * have no controller here" — the same way the deprecated-event advisory
2998
+ * surfaces legacy events. No advisory when `onlyLanes` is unset (every
2999
+ * lane gets a controller) or covers every declared lane.
3000
+ */
3001
+ _advise_orphaned_lanes(options, lanes) {
3002
+ if (!options.onlyLanes || options.onlyLanes.length === 0) return;
3003
+ const active = new Set(this._drain_controllers.keys());
3004
+ const orphaned = ["default", ...lanes.map((l) => l.name)].filter(
3005
+ (name) => !active.has(name)
3006
+ );
3007
+ if (orphaned.length === 0) return;
3008
+ const list = orphaned.map((name) => `"${name}"`).join(", ");
3009
+ this._logger.info(
3010
+ `Act declared ${orphaned.length} orphaned lane(s) on this instance: ${list}. onlyLanes excludes them, so no DrainController claims their streams here \u2014 their reactions accumulate un-drained unless a peer worker deploys with these lanes in its onlyLanes. Ensure the cluster invariant holds: the union of every worker's onlyLanes must cover every declared lane. See docs/docs/guides/production-checklist.md \xA7 Sizing lanes.`
3011
+ );
3012
+ }
2935
3013
  /**
2936
3014
  * Audit deps bag. Snapshotted after registry classification and
2937
3015
  * drain-controller build so the audit module sees the finalized lane
@@ -2945,7 +3023,14 @@ var Act = class {
2945
3023
  event_to_state: this._event_to_state,
2946
3024
  states: this._states,
2947
3025
  known_events: new Set(this._event_to_state.keys()),
2948
- declared_lanes: new Set(this._drain_controllers.keys()),
3026
+ // The DECLARED lane universe — the implicit "default" plus every
3027
+ // `.withLane(...)` name — NOT `_drain_controllers.keys()` (#1224).
3028
+ // An `onlyLanes`-filtered instance builds a controller only for its
3029
+ // slice of lanes, so keying off the active controller set would flag
3030
+ // a stream correctly assigned to an excluded-but-declared lane (one
3031
+ // another worker drains) as `unknown-lane`. The audit reports what's
3032
+ // structurally routable across the cluster, not what this process runs.
3033
+ declared_lanes: /* @__PURE__ */ new Set(["default", ...this._lanes.map((l) => l.name)]),
2949
3034
  routed_events: new Set(this._event_to_lanes.keys())
2950
3035
  };
2951
3036
  }
@@ -2966,9 +3051,36 @@ var Act = class {
2966
3051
  },
2967
3052
  // Re-scope the background `start_correlations` timer so its
2968
3053
  // correlate resolves the scoped ports, not the singleton (#1191).
2969
- this._scoped
3054
+ this._scoped,
3055
+ // Cold-start defer re-seed (#1221). Skipped on writer-only instances
3056
+ // (`drain: false`) — they run no local controllers to re-arm.
3057
+ this._drain ? () => this._seed_persisted_defers() : void 0
2970
3058
  );
2971
3059
  }
3060
+ /**
3061
+ * Re-seed every active lane controller's process-local defer timer from
3062
+ * the store's persisted `deferred_at` (#1221). Runs once at cold start,
3063
+ * inside `CorrelateCycle.init`, after static targets are subscribed.
3064
+ *
3065
+ * The defer timer is worker memory: empty after a restart. A stream
3066
+ * deferred to a future due-time (the classic case: an idle autoclose
3067
+ * aggregate that deferred its terminal close) is durable in the store but
3068
+ * has nothing in memory to re-arm the drain — the controller disarms on
3069
+ * the first empty claim and, since the aggregate is idle, no commit ever
3070
+ * re-arms it. Reading the persisted schedule and seeding the owning lane's
3071
+ * timer restores the wake, so the close fires at the due-time.
3072
+ *
3073
+ * Streams whose lane has no controller on this instance (excluded by
3074
+ * `onlyLanes`) are skipped — a peer worker owns that lane's timer.
3075
+ */
3076
+ async _seed_persisted_defers() {
3077
+ const now = Date.now();
3078
+ await store().query_streams((pos) => {
3079
+ if (pos.deferred_at === void 0 || pos.deferred_at <= now) return;
3080
+ const controller = this._drain_controllers.get(pos.lane ?? "default");
3081
+ controller?.seed_defer(pos.stream, pos.deferred_at);
3082
+ });
3083
+ }
2972
3084
  /** Settle loop driving correlate + drain to quiescence. */
2973
3085
  _build_settle(options) {
2974
3086
  return new SettleLoop(