@rotorsoft/act 1.20.0 → 1.22.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/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts +55 -43
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/adapters/in-memory-store.d.ts +13 -4
- package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
- package/dist/@types/builders/state-builder.d.ts +35 -9
- package/dist/@types/builders/state-builder.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-config.d.ts +39 -15
- package/dist/@types/internal/autoclose-config.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-policy.d.ts +39 -13
- package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-reaction.d.ts.map +1 -1
- package/dist/@types/internal/close-cycle.d.ts +5 -0
- package/dist/@types/internal/close-cycle.d.ts.map +1 -1
- package/dist/@types/internal/close-signal.d.ts +8 -0
- package/dist/@types/internal/close-signal.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +2 -2
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/reactions.d.ts.map +1 -1
- package/dist/@types/types/action.d.ts +46 -13
- package/dist/@types/types/action.d.ts.map +1 -1
- package/dist/@types/types/ports.d.ts +29 -3
- package/dist/@types/types/ports.d.ts.map +1 -1
- package/dist/{chunk-TODGOWEZ.js → chunk-KBTC3NGQ.js} +64 -14
- package/dist/chunk-KBTC3NGQ.js.map +1 -0
- package/dist/index.cjs +237 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +175 -26
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +63 -13
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-TODGOWEZ.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -803,6 +803,10 @@ var InMemoryStream = class {
|
|
|
803
803
|
var InMemoryStore = class {
|
|
804
804
|
// stored events
|
|
805
805
|
_events = [];
|
|
806
|
+
// next event id — monotonic, never reused. Deletions (truncate, windowed
|
|
807
|
+
// prune) punch holes in the id sequence, so ids are NOT array indexes and
|
|
808
|
+
// NOT `_events.length`; they only stay sorted ascending in `_events`.
|
|
809
|
+
_next_id = 0;
|
|
806
810
|
// stored stream positions and other metadata
|
|
807
811
|
_streams = /* @__PURE__ */ new Map();
|
|
808
812
|
// last committed version per stream — O(1) replacement for filter-on-commit
|
|
@@ -820,11 +824,26 @@ var InMemoryStore = class {
|
|
|
820
824
|
_pii = /* @__PURE__ */ new Map();
|
|
821
825
|
_reset_indexes() {
|
|
822
826
|
this._events.length = 0;
|
|
827
|
+
this._next_id = 0;
|
|
823
828
|
this._stream_versions.clear();
|
|
824
829
|
this._max_event_id_by_stream.clear();
|
|
825
830
|
this._max_non_snap_event_id = -1;
|
|
826
831
|
this._pii.clear();
|
|
827
832
|
}
|
|
833
|
+
// First index whose event id is greater than `after`. `_events` stays
|
|
834
|
+
// sorted ascending by id (append-only commits; deletions preserve
|
|
835
|
+
// order), so id-bounded scans binary-search their start instead of
|
|
836
|
+
// assuming id === index — an invariant that truncation breaks.
|
|
837
|
+
_first_index_after(after) {
|
|
838
|
+
let lo = 0;
|
|
839
|
+
let hi = this._events.length;
|
|
840
|
+
while (lo < hi) {
|
|
841
|
+
const mid = lo + hi >>> 1;
|
|
842
|
+
if (this._events[mid].id > after) hi = mid;
|
|
843
|
+
else lo = mid + 1;
|
|
844
|
+
}
|
|
845
|
+
return lo;
|
|
846
|
+
}
|
|
828
847
|
// Attach the isolated PII payload (or null) to an event before handing it to
|
|
829
848
|
// a caller. Allocation-free for events without PII — by far the common case.
|
|
830
849
|
_with_pii(e) {
|
|
@@ -877,7 +896,7 @@ var InMemoryStore = class {
|
|
|
877
896
|
await sleep();
|
|
878
897
|
let count = 0;
|
|
879
898
|
if (query?.backward) {
|
|
880
|
-
let i = (query?.before
|
|
899
|
+
let i = (query?.before !== void 0 ? this._first_index_after(query.before - 1) : this._events.length) - 1;
|
|
881
900
|
while (i >= 0) {
|
|
882
901
|
const e = this._events[i--];
|
|
883
902
|
if (query && !this.in_query(query, e)) continue;
|
|
@@ -892,7 +911,7 @@ var InMemoryStore = class {
|
|
|
892
911
|
if (query?.limit && count >= query.limit) break;
|
|
893
912
|
}
|
|
894
913
|
} else {
|
|
895
|
-
let i = (query?.after ?? -1)
|
|
914
|
+
let i = this._first_index_after(query?.after ?? -1);
|
|
896
915
|
if (query?.with_snaps && query.stream_exact && query.stream !== void 0 && query.after === void 0) {
|
|
897
916
|
for (let j = this._events.length - 1; j >= 0; j--) {
|
|
898
917
|
const e = this._events[j];
|
|
@@ -941,7 +960,7 @@ var InMemoryStore = class {
|
|
|
941
960
|
let last_non_snap_id = -1;
|
|
942
961
|
const committed = msgs.map(({ name, data, pii }) => {
|
|
943
962
|
const c = {
|
|
944
|
-
id: this.
|
|
963
|
+
id: this._next_id++,
|
|
945
964
|
stream,
|
|
946
965
|
version,
|
|
947
966
|
created: /* @__PURE__ */ new Date(),
|
|
@@ -1282,7 +1301,7 @@ var InMemoryStore = class {
|
|
|
1282
1301
|
count++;
|
|
1283
1302
|
if (count >= limit) break;
|
|
1284
1303
|
}
|
|
1285
|
-
return { maxEventId: this._events.
|
|
1304
|
+
return { maxEventId: this._events.at(-1)?.id ?? -1, count };
|
|
1286
1305
|
}
|
|
1287
1306
|
/**
|
|
1288
1307
|
* Per-stream aggregated stats — see {@link Store.query_stats}.
|
|
@@ -1363,13 +1382,41 @@ var InMemoryStore = class {
|
|
|
1363
1382
|
}
|
|
1364
1383
|
/**
|
|
1365
1384
|
* Atomically truncates streams and seeds each with a snapshot or tombstone.
|
|
1366
|
-
*
|
|
1385
|
+
* Windowed targets (`before` set) prune the prefix below the closest safe
|
|
1386
|
+
* `__snapshot__` instead — no seed, subscriptions untouched, no-op when no
|
|
1387
|
+
* snapshot qualifies.
|
|
1388
|
+
* @param targets - Streams to truncate with optional snapshot state and meta,
|
|
1389
|
+
* or a `before`/`max_id` boundary for a windowed prefix delete.
|
|
1367
1390
|
* @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
|
|
1368
1391
|
*/
|
|
1369
1392
|
async truncate(targets) {
|
|
1370
1393
|
await sleep();
|
|
1394
|
+
const result = /* @__PURE__ */ new Map();
|
|
1395
|
+
const windowed = targets.filter((t) => t.before !== void 0);
|
|
1396
|
+
if (windowed.length) {
|
|
1397
|
+
const drop = /* @__PURE__ */ new Set();
|
|
1398
|
+
for (const { stream, before, max_id } of windowed) {
|
|
1399
|
+
let boundary;
|
|
1400
|
+
for (const e of this._events) {
|
|
1401
|
+
if (e.stream === stream && e.name === SNAP_EVENT && e.created < before && (max_id === void 0 || e.id <= max_id) && (!boundary || e.id > boundary.id))
|
|
1402
|
+
boundary = e;
|
|
1403
|
+
}
|
|
1404
|
+
if (!boundary) continue;
|
|
1405
|
+
let deleted = 0;
|
|
1406
|
+
for (const e of this._events) {
|
|
1407
|
+
if (e.stream === stream && e.id < boundary.id) {
|
|
1408
|
+
drop.add(e.id);
|
|
1409
|
+
this._pii.get(stream)?.delete(e.id);
|
|
1410
|
+
deleted++;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
result.set(stream, { deleted, committed: boundary, before });
|
|
1414
|
+
}
|
|
1415
|
+
if (drop.size) this._events = this._events.filter((e) => !drop.has(e.id));
|
|
1416
|
+
}
|
|
1417
|
+
const full = targets.filter((t) => t.before === void 0);
|
|
1371
1418
|
const deleted_counts = /* @__PURE__ */ new Map();
|
|
1372
|
-
const stream_set = new Set(
|
|
1419
|
+
const stream_set = new Set(full.map((t) => t.stream));
|
|
1373
1420
|
for (const e of this._events) {
|
|
1374
1421
|
if (stream_set.has(e.stream)) {
|
|
1375
1422
|
deleted_counts.set(e.stream, (deleted_counts.get(e.stream) ?? 0) + 1);
|
|
@@ -1380,11 +1427,11 @@ var InMemoryStore = class {
|
|
|
1380
1427
|
this._streams.delete(stream);
|
|
1381
1428
|
this._stream_versions.delete(stream);
|
|
1382
1429
|
this._max_event_id_by_stream.delete(stream);
|
|
1430
|
+
this._pii.delete(stream);
|
|
1383
1431
|
}
|
|
1384
|
-
const
|
|
1385
|
-
for (const { stream, snapshot, meta } of targets) {
|
|
1432
|
+
for (const { stream, snapshot, meta } of full) {
|
|
1386
1433
|
const event = {
|
|
1387
|
-
id: this.
|
|
1434
|
+
id: this._next_id++,
|
|
1388
1435
|
stream,
|
|
1389
1436
|
version: 0,
|
|
1390
1437
|
created: /* @__PURE__ */ new Date(),
|
|
@@ -1416,25 +1463,27 @@ var InMemoryStore = class {
|
|
|
1416
1463
|
* throw inside the driver restores the snapshot, leaving the store
|
|
1417
1464
|
* byte-for-byte unchanged from the operator's perspective.
|
|
1418
1465
|
*
|
|
1419
|
-
* `id`s are reassigned `0..N-1` as events arrive (
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
1466
|
+
* `id`s are reassigned `0..N-1` as events arrive (dense — the
|
|
1467
|
+
* monotonic id counter restarts at 0 for the rebuild). `created` is
|
|
1468
|
+
* preserved verbatim from the source.
|
|
1422
1469
|
*/
|
|
1423
1470
|
async restore(driver) {
|
|
1424
1471
|
await sleep();
|
|
1425
1472
|
const prev_events = this._events;
|
|
1473
|
+
const prev_next_id = this._next_id;
|
|
1426
1474
|
const prev_streams = this._streams;
|
|
1427
1475
|
const prev_stream_versions = this._stream_versions;
|
|
1428
1476
|
const prev_max_event_id_by_stream = this._max_event_id_by_stream;
|
|
1429
1477
|
const prev_max_non_snap_event_id = this._max_non_snap_event_id;
|
|
1430
1478
|
this._events = [];
|
|
1479
|
+
this._next_id = 0;
|
|
1431
1480
|
this._streams = /* @__PURE__ */ new Map();
|
|
1432
1481
|
this._stream_versions = /* @__PURE__ */ new Map();
|
|
1433
1482
|
this._max_event_id_by_stream = /* @__PURE__ */ new Map();
|
|
1434
1483
|
this._max_non_snap_event_id = -1;
|
|
1435
1484
|
try {
|
|
1436
1485
|
await driver(async (event) => {
|
|
1437
|
-
const id = this.
|
|
1486
|
+
const id = this._next_id++;
|
|
1438
1487
|
const committed = { ...event, id };
|
|
1439
1488
|
this._events.push(committed);
|
|
1440
1489
|
this._stream_versions.set(event.stream, event.version);
|
|
@@ -1446,6 +1495,7 @@ var InMemoryStore = class {
|
|
|
1446
1495
|
});
|
|
1447
1496
|
} catch (err) {
|
|
1448
1497
|
this._events = prev_events;
|
|
1498
|
+
this._next_id = prev_next_id;
|
|
1449
1499
|
this._streams = prev_streams;
|
|
1450
1500
|
this._stream_versions = prev_stream_versions;
|
|
1451
1501
|
this._max_event_id_by_stream = prev_max_event_id_by_stream;
|
|
@@ -2006,15 +2056,33 @@ function in_autoclose_window(window, now) {
|
|
|
2006
2056
|
const hour = hour_in_zone(now, window.timeZone);
|
|
2007
2057
|
return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
|
|
2008
2058
|
}
|
|
2059
|
+
function next_window_open(window, now) {
|
|
2060
|
+
for (let h = 0; h <= 48; h++) {
|
|
2061
|
+
const candidate = new Date(now.getTime() + h * 36e5);
|
|
2062
|
+
if (hour_in_zone(candidate, window.timeZone) === window.start)
|
|
2063
|
+
return candidate;
|
|
2064
|
+
}
|
|
2065
|
+
return new Date(now.getTime() + 864e5);
|
|
2066
|
+
}
|
|
2009
2067
|
|
|
2010
2068
|
// src/internal/autoclose-policy.ts
|
|
2011
2069
|
var import_zod6 = require("zod");
|
|
2012
|
-
var
|
|
2013
|
-
var
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2070
|
+
var DAY_MS = 864e5;
|
|
2071
|
+
var MIN_AFTER_DAYS = 1 / 1440;
|
|
2072
|
+
var MIN_KEEP_DAYS = 1;
|
|
2073
|
+
function days_after(date, days) {
|
|
2074
|
+
return new Date(date.getTime() + days * DAY_MS);
|
|
2075
|
+
}
|
|
2076
|
+
function days_before_now(days) {
|
|
2077
|
+
return new Date(Date.now() - days * DAY_MS);
|
|
2078
|
+
}
|
|
2079
|
+
var window_schema = (field, min_days, floor) => import_zod6.z.object({
|
|
2080
|
+
days: import_zod6.z.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
|
|
2081
|
+
}).refine((o) => o.days >= min_days, {
|
|
2082
|
+
message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
|
|
2017
2083
|
});
|
|
2084
|
+
var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
|
|
2085
|
+
var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
|
|
2018
2086
|
var IsSchema = import_zod6.z.union([
|
|
2019
2087
|
import_zod6.z.string().min(1, "autocloses: is must be a non-empty event name"),
|
|
2020
2088
|
import_zod6.z.array(
|
|
@@ -2036,16 +2104,16 @@ var AutoclosePolicySchema = import_zod6.z.object({
|
|
|
2036
2104
|
after: AfterSchema.optional(),
|
|
2037
2105
|
is: IsSchema.optional(),
|
|
2038
2106
|
reaches: ReachesSchema.optional(),
|
|
2039
|
-
or: OrBlockSchema.optional()
|
|
2107
|
+
or: OrBlockSchema.optional(),
|
|
2108
|
+
keep: KeepSchema.optional()
|
|
2040
2109
|
}).strict().refine(
|
|
2041
|
-
(o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
|
|
2110
|
+
(o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
|
|
2042
2111
|
{
|
|
2043
|
-
message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
|
|
2112
|
+
message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
|
|
2044
2113
|
}
|
|
2045
2114
|
);
|
|
2046
2115
|
function compile_after(after) {
|
|
2047
|
-
|
|
2048
|
-
return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
|
|
2116
|
+
return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
|
|
2049
2117
|
}
|
|
2050
2118
|
function compile_is(is) {
|
|
2051
2119
|
const set = new Set(typeof is === "string" ? [is] : is);
|
|
@@ -2054,13 +2122,17 @@ function compile_is(is) {
|
|
|
2054
2122
|
function compile_reaches(reaches) {
|
|
2055
2123
|
return (_stream, _head, count) => count >= reaches;
|
|
2056
2124
|
}
|
|
2057
|
-
function
|
|
2125
|
+
function policy_min_after_days(options) {
|
|
2058
2126
|
const parsed = AutoclosePolicySchema.parse(options);
|
|
2059
2127
|
const windows = [];
|
|
2060
|
-
if (parsed.after) windows.push(parsed.after.days
|
|
2061
|
-
if (parsed.or?.after) windows.push(parsed.or.after.days
|
|
2128
|
+
if (parsed.after) windows.push(parsed.after.days);
|
|
2129
|
+
if (parsed.or?.after) windows.push(parsed.or.after.days);
|
|
2062
2130
|
return windows.length ? Math.min(...windows) : void 0;
|
|
2063
2131
|
}
|
|
2132
|
+
function policy_keep_days(options) {
|
|
2133
|
+
const parsed = AutoclosePolicySchema.parse(options);
|
|
2134
|
+
return parsed.keep?.days;
|
|
2135
|
+
}
|
|
2064
2136
|
function compile_autoclose_policy(options) {
|
|
2065
2137
|
const parsed = AutoclosePolicySchema.parse(options);
|
|
2066
2138
|
const and_preds = [];
|
|
@@ -2103,12 +2175,20 @@ var CloseSignal = class extends Error {
|
|
|
2103
2175
|
* event id when omitted.
|
|
2104
2176
|
*/
|
|
2105
2177
|
at;
|
|
2178
|
+
/**
|
|
2179
|
+
* Windowed close (#1011): prune events older than this cutoff behind
|
|
2180
|
+
* the closest safe snapshot instead of retiring the stream. Thrown by
|
|
2181
|
+
* the autoclose reaction of a `.autocloses({ keep })` state; flows
|
|
2182
|
+
* into {@link CloseTarget.before}. Omitted → a full close.
|
|
2183
|
+
*/
|
|
2184
|
+
before;
|
|
2106
2185
|
constructor(opts) {
|
|
2107
2186
|
super("reaction requested close");
|
|
2108
2187
|
this.name = "CloseSignal";
|
|
2109
2188
|
this.stream = opts?.stream;
|
|
2110
2189
|
this.archive = opts?.archive;
|
|
2111
2190
|
this.at = opts?.at;
|
|
2191
|
+
this.before = opts?.before;
|
|
2112
2192
|
}
|
|
2113
2193
|
};
|
|
2114
2194
|
|
|
@@ -2132,7 +2212,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
|
|
|
2132
2212
|
for (const st of states.values()) {
|
|
2133
2213
|
const predicate = st.autoclose;
|
|
2134
2214
|
if (!predicate) continue;
|
|
2135
|
-
const
|
|
2215
|
+
const after_days = st.autoclose_after_days;
|
|
2216
|
+
const keep_days = st.autoclose_keep_days;
|
|
2136
2217
|
const archiver = st.archive;
|
|
2137
2218
|
const reaction = {
|
|
2138
2219
|
// Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
|
|
@@ -2152,11 +2233,12 @@ function synthesize_autoclose_reactions(registry, states, config2) {
|
|
|
2152
2233
|
const aggregate = event.stream;
|
|
2153
2234
|
if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
|
|
2154
2235
|
throw new DeferSignal({
|
|
2155
|
-
at:
|
|
2236
|
+
at: next_window_open(config2.autocloseWindow, /* @__PURE__ */ new Date())
|
|
2156
2237
|
});
|
|
2157
2238
|
const stats = await store2().query_stats([aggregate], {
|
|
2158
2239
|
count: true,
|
|
2159
|
-
|
|
2240
|
+
tail: keep_days !== void 0 ? true : void 0,
|
|
2241
|
+
exclude: keep_days !== void 0 ? [TOMBSTONE_EVENT, SNAP_EVENT] : [TOMBSTONE_EVENT]
|
|
2160
2242
|
});
|
|
2161
2243
|
const entry = stats.get(aggregate);
|
|
2162
2244
|
if (!entry) return;
|
|
@@ -2170,9 +2252,24 @@ function synthesize_autoclose_reactions(registry, states, config2) {
|
|
|
2170
2252
|
// aggregate) sees it caught up instead of blocking its own close.
|
|
2171
2253
|
at: head.id
|
|
2172
2254
|
});
|
|
2173
|
-
if (
|
|
2255
|
+
if (keep_days !== void 0) {
|
|
2256
|
+
const cutoff = days_before_now(keep_days);
|
|
2257
|
+
if (entry.tail.created < cutoff)
|
|
2258
|
+
throw new CloseSignal({
|
|
2259
|
+
stream: aggregate,
|
|
2260
|
+
before: cutoff,
|
|
2261
|
+
archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
|
|
2262
|
+
at: head.id
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
const dues = [];
|
|
2266
|
+
if (after_days !== void 0)
|
|
2267
|
+
dues.push(days_after(head.created, after_days));
|
|
2268
|
+
if (keep_days !== void 0)
|
|
2269
|
+
dues.push(days_after(entry.tail.created, keep_days));
|
|
2270
|
+
if (dues.length)
|
|
2174
2271
|
throw new DeferSignal({
|
|
2175
|
-
at: new Date(
|
|
2272
|
+
at: new Date(Math.min(...dues.map((d) => d.getTime())))
|
|
2176
2273
|
});
|
|
2177
2274
|
}
|
|
2178
2275
|
};
|
|
@@ -2559,8 +2656,20 @@ var CircuitBreaker = class {
|
|
|
2559
2656
|
var SAFETY_PROBE_PAGE_SIZE = 1e3;
|
|
2560
2657
|
async function run_close_cycle(targets, deps) {
|
|
2561
2658
|
const target_map = new Map(targets.map((t) => [t.stream, t]));
|
|
2562
|
-
const
|
|
2659
|
+
for (const t of target_map.values()) {
|
|
2660
|
+
if (t.before !== void 0 && t.restart)
|
|
2661
|
+
throw new Error(
|
|
2662
|
+
`close: \`before\` and \`restart\` are mutually exclusive (stream "${t.stream}") \u2014 a windowed close keeps the stream live behind a real snapshot; restart reseeds it`
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
const windowed = [...target_map.values()].filter(
|
|
2666
|
+
(t) => t.before !== void 0
|
|
2667
|
+
);
|
|
2668
|
+
const full = [...target_map.values()].filter((t) => t.before === void 0);
|
|
2563
2669
|
const skipped = [];
|
|
2670
|
+
const windowed_result = windowed.length ? await run_windowed_closes(windowed, deps, skipped) : /* @__PURE__ */ new Map();
|
|
2671
|
+
if (!full.length) return { truncated: windowed_result, skipped };
|
|
2672
|
+
const streams = full.map((t) => t.stream);
|
|
2564
2673
|
const stream_info = await scan_stream_heads(streams);
|
|
2565
2674
|
const safe = await partition_by_safety(
|
|
2566
2675
|
stream_info,
|
|
@@ -2568,7 +2677,7 @@ async function run_close_cycle(targets, deps) {
|
|
|
2568
2677
|
skipped,
|
|
2569
2678
|
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
2570
2679
|
);
|
|
2571
|
-
if (!safe.length) return { truncated:
|
|
2680
|
+
if (!safe.length) return { truncated: windowed_result, skipped };
|
|
2572
2681
|
const { guarded, guard_events } = await guard_with_tombstones(
|
|
2573
2682
|
safe,
|
|
2574
2683
|
stream_info,
|
|
@@ -2576,7 +2685,7 @@ async function run_close_cycle(targets, deps) {
|
|
|
2576
2685
|
deps.tombstone,
|
|
2577
2686
|
skipped
|
|
2578
2687
|
);
|
|
2579
|
-
if (!guarded.length) return { truncated:
|
|
2688
|
+
if (!guarded.length) return { truncated: windowed_result, skipped };
|
|
2580
2689
|
const seed_states = await load_restart_seeds(
|
|
2581
2690
|
guarded,
|
|
2582
2691
|
target_map,
|
|
@@ -2592,8 +2701,62 @@ async function run_close_cycle(targets, deps) {
|
|
|
2592
2701
|
guard_events,
|
|
2593
2702
|
deps.correlation
|
|
2594
2703
|
);
|
|
2704
|
+
for (const [stream, entry] of windowed_result) truncated.set(stream, entry);
|
|
2595
2705
|
return { truncated, skipped };
|
|
2596
2706
|
}
|
|
2707
|
+
async function run_windowed_closes(windowed, deps, skipped) {
|
|
2708
|
+
const min_at = deps.reactive_events_size > 0 ? await probe_min_watermarks(
|
|
2709
|
+
windowed.map((t) => t.stream),
|
|
2710
|
+
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
2711
|
+
) : /* @__PURE__ */ new Map();
|
|
2712
|
+
for (const t of windowed) {
|
|
2713
|
+
if (t.archive) await t.archive();
|
|
2714
|
+
}
|
|
2715
|
+
const truncated = await store2().truncate(
|
|
2716
|
+
windowed.map((t) => ({
|
|
2717
|
+
stream: t.stream,
|
|
2718
|
+
before: t.before,
|
|
2719
|
+
max_id: min_at.get(t.stream)
|
|
2720
|
+
}))
|
|
2721
|
+
);
|
|
2722
|
+
for (const t of windowed) {
|
|
2723
|
+
if (!truncated.has(t.stream)) skipped.push(t.stream);
|
|
2724
|
+
}
|
|
2725
|
+
return truncated;
|
|
2726
|
+
}
|
|
2727
|
+
async function probe_min_watermarks(streams, page_size) {
|
|
2728
|
+
const min_at = /* @__PURE__ */ new Map();
|
|
2729
|
+
const source_regex = /* @__PURE__ */ new Map();
|
|
2730
|
+
const get_regex = (source) => {
|
|
2731
|
+
let re = source_regex.get(source);
|
|
2732
|
+
if (!re) {
|
|
2733
|
+
re = new RegExp(source);
|
|
2734
|
+
source_regex.set(source, re);
|
|
2735
|
+
}
|
|
2736
|
+
return re;
|
|
2737
|
+
};
|
|
2738
|
+
let after;
|
|
2739
|
+
for (; ; ) {
|
|
2740
|
+
let last;
|
|
2741
|
+
const { count } = await store2().query_streams(
|
|
2742
|
+
(position) => {
|
|
2743
|
+
last = position.stream;
|
|
2744
|
+
const source_re = position.source ? get_regex(position.source) : void 0;
|
|
2745
|
+
for (const stream of streams) {
|
|
2746
|
+
if (!source_re || source_re.test(stream)) {
|
|
2747
|
+
const prev = min_at.get(stream);
|
|
2748
|
+
if (prev === void 0 || position.at < prev)
|
|
2749
|
+
min_at.set(stream, position.at);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
},
|
|
2753
|
+
{ after, limit: page_size, source_matches: streams }
|
|
2754
|
+
);
|
|
2755
|
+
if (count < page_size) break;
|
|
2756
|
+
after = last;
|
|
2757
|
+
}
|
|
2758
|
+
return min_at;
|
|
2759
|
+
}
|
|
2597
2760
|
async function scan_stream_heads(streams) {
|
|
2598
2761
|
const stats = await store2().query_stats(streams, {
|
|
2599
2762
|
exclude: [SNAP_EVENT]
|
|
@@ -3794,8 +3957,13 @@ function build_handle(deps) {
|
|
|
3794
3957
|
acked_at: error.at ?? event.id,
|
|
3795
3958
|
// Close the signalled stream (the autoclose reaction's aggregate,
|
|
3796
3959
|
// which differs from its synthetic lease stream); a self-closing
|
|
3797
|
-
// user reaction omits it and closes its own lease stream.
|
|
3798
|
-
|
|
3960
|
+
// user reaction omits it and closes its own lease stream. A
|
|
3961
|
+
// carried `before` makes it a windowed close (prune, not retire).
|
|
3962
|
+
close: {
|
|
3963
|
+
stream: error.stream ?? stream,
|
|
3964
|
+
archive: error.archive,
|
|
3965
|
+
before: error.before
|
|
3966
|
+
}
|
|
3799
3967
|
};
|
|
3800
3968
|
return finalize(
|
|
3801
3969
|
lease,
|
|
@@ -5098,7 +5266,19 @@ var Act = class {
|
|
|
5098
5266
|
* 7. **Cache** — invalidate (tombstoned) or warm (restarted)
|
|
5099
5267
|
* 8. **Emit "closed"** — lifecycle event with results
|
|
5100
5268
|
*
|
|
5101
|
-
*
|
|
5269
|
+
* Targets carrying `before` take the **windowed** branch instead —
|
|
5270
|
+
* close the books on a rolling window: probe the min consumer
|
|
5271
|
+
* watermark (so the boundary never rises past a lagging reaction),
|
|
5272
|
+
* run the archive callback against the cutoff, then prune the prefix
|
|
5273
|
+
* below the closest safe `__snapshot__`. No tombstone guard (the
|
|
5274
|
+
* pre-cutoff prefix is immutable), no seed, cache untouched — the
|
|
5275
|
+
* stream stays live and keeps accepting actions. Requires the state
|
|
5276
|
+
* to snapshot via `.snap(...)`; no qualifying snapshot ⇒ the stream
|
|
5277
|
+
* lands in `skipped` (retry after the next snapshot). Windowed
|
|
5278
|
+
* entries in the result echo `before` and carry the surviving
|
|
5279
|
+
* boundary snapshot as `committed`.
|
|
5280
|
+
*
|
|
5281
|
+
* @param targets - Per-stream close options (stream, restart?, archive?, before?)
|
|
5102
5282
|
* @returns `{ truncated: TruncateResult, skipped: string[] }`
|
|
5103
5283
|
*
|
|
5104
5284
|
* @example Archive and close
|
|
@@ -5116,6 +5296,19 @@ var Act = class {
|
|
|
5116
5296
|
* { stream: "counter-2" }, // tombstoned
|
|
5117
5297
|
* ]);
|
|
5118
5298
|
* ```
|
|
5299
|
+
*
|
|
5300
|
+
* @example Windowed close — keep the last 180 days of real events
|
|
5301
|
+
* ```typescript
|
|
5302
|
+
* const cutoff = new Date();
|
|
5303
|
+
* cutoff.setDate(cutoff.getDate() - 180);
|
|
5304
|
+
* await app.close([
|
|
5305
|
+
* {
|
|
5306
|
+
* stream: "ledger-acme",
|
|
5307
|
+
* before: cutoff,
|
|
5308
|
+
* archive: async () => { await archiveToS3("ledger-acme", cutoff); },
|
|
5309
|
+
* },
|
|
5310
|
+
* ]);
|
|
5311
|
+
* ```
|
|
5119
5312
|
*/
|
|
5120
5313
|
async close(targets) {
|
|
5121
5314
|
if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
|
|
@@ -5648,10 +5841,16 @@ function action_builder(state2) {
|
|
|
5648
5841
|
".autocloses(...) requires a policy object; got " + typeof policy
|
|
5649
5842
|
);
|
|
5650
5843
|
}
|
|
5844
|
+
if (policy.keep && !internal.snap) {
|
|
5845
|
+
throw new Error(
|
|
5846
|
+
".autocloses({ keep }) requires .snap(...) earlier in the chain \u2014 a rolling window prunes behind a real snapshot, so a state that never snapshots has nothing to prune behind."
|
|
5847
|
+
);
|
|
5848
|
+
}
|
|
5651
5849
|
internal.autoclose = compile_autoclose_policy(
|
|
5652
5850
|
policy
|
|
5653
5851
|
);
|
|
5654
|
-
internal.
|
|
5852
|
+
internal.autoclose_after_days = policy_min_after_days(policy);
|
|
5853
|
+
internal.autoclose_keep_days = policy_keep_days(policy);
|
|
5655
5854
|
return builder;
|
|
5656
5855
|
},
|
|
5657
5856
|
archives(archive) {
|