@rotorsoft/act 1.19.0 → 1.21.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 +26 -1
- 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/act-builder.d.ts.map +1 -1
- package/dist/@types/builders/projection-builder.d.ts +41 -12
- package/dist/@types/builders/projection-builder.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-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/event-sourcing.d.ts +1 -1
- package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +1 -1
- 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 +302 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +240 -58
- 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;
|
|
@@ -2009,12 +2059,22 @@ function in_autoclose_window(window, now) {
|
|
|
2009
2059
|
|
|
2010
2060
|
// src/internal/autoclose-policy.ts
|
|
2011
2061
|
var import_zod6 = require("zod");
|
|
2012
|
-
var
|
|
2013
|
-
var
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2062
|
+
var DAY_MS = 864e5;
|
|
2063
|
+
var MIN_AFTER_DAYS = 1 / 1440;
|
|
2064
|
+
var MIN_KEEP_DAYS = 1;
|
|
2065
|
+
function days_after(date, days) {
|
|
2066
|
+
return new Date(date.getTime() + days * DAY_MS);
|
|
2067
|
+
}
|
|
2068
|
+
function days_before_now(days) {
|
|
2069
|
+
return new Date(Date.now() - days * DAY_MS);
|
|
2070
|
+
}
|
|
2071
|
+
var window_schema = (field, min_days, floor) => import_zod6.z.object({
|
|
2072
|
+
days: import_zod6.z.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
|
|
2073
|
+
}).refine((o) => o.days >= min_days, {
|
|
2074
|
+
message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
|
|
2017
2075
|
});
|
|
2076
|
+
var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
|
|
2077
|
+
var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
|
|
2018
2078
|
var IsSchema = import_zod6.z.union([
|
|
2019
2079
|
import_zod6.z.string().min(1, "autocloses: is must be a non-empty event name"),
|
|
2020
2080
|
import_zod6.z.array(
|
|
@@ -2036,16 +2096,16 @@ var AutoclosePolicySchema = import_zod6.z.object({
|
|
|
2036
2096
|
after: AfterSchema.optional(),
|
|
2037
2097
|
is: IsSchema.optional(),
|
|
2038
2098
|
reaches: ReachesSchema.optional(),
|
|
2039
|
-
or: OrBlockSchema.optional()
|
|
2099
|
+
or: OrBlockSchema.optional(),
|
|
2100
|
+
keep: KeepSchema.optional()
|
|
2040
2101
|
}).strict().refine(
|
|
2041
|
-
(o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
|
|
2102
|
+
(o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
|
|
2042
2103
|
{
|
|
2043
|
-
message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
|
|
2104
|
+
message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
|
|
2044
2105
|
}
|
|
2045
2106
|
);
|
|
2046
2107
|
function compile_after(after) {
|
|
2047
|
-
|
|
2048
|
-
return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
|
|
2108
|
+
return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
|
|
2049
2109
|
}
|
|
2050
2110
|
function compile_is(is) {
|
|
2051
2111
|
const set = new Set(typeof is === "string" ? [is] : is);
|
|
@@ -2054,13 +2114,17 @@ function compile_is(is) {
|
|
|
2054
2114
|
function compile_reaches(reaches) {
|
|
2055
2115
|
return (_stream, _head, count) => count >= reaches;
|
|
2056
2116
|
}
|
|
2057
|
-
function
|
|
2117
|
+
function policy_min_after_days(options) {
|
|
2058
2118
|
const parsed = AutoclosePolicySchema.parse(options);
|
|
2059
2119
|
const windows = [];
|
|
2060
|
-
if (parsed.after) windows.push(parsed.after.days
|
|
2061
|
-
if (parsed.or?.after) windows.push(parsed.or.after.days
|
|
2120
|
+
if (parsed.after) windows.push(parsed.after.days);
|
|
2121
|
+
if (parsed.or?.after) windows.push(parsed.or.after.days);
|
|
2062
2122
|
return windows.length ? Math.min(...windows) : void 0;
|
|
2063
2123
|
}
|
|
2124
|
+
function policy_keep_days(options) {
|
|
2125
|
+
const parsed = AutoclosePolicySchema.parse(options);
|
|
2126
|
+
return parsed.keep?.days;
|
|
2127
|
+
}
|
|
2064
2128
|
function compile_autoclose_policy(options) {
|
|
2065
2129
|
const parsed = AutoclosePolicySchema.parse(options);
|
|
2066
2130
|
const and_preds = [];
|
|
@@ -2103,12 +2167,20 @@ var CloseSignal = class extends Error {
|
|
|
2103
2167
|
* event id when omitted.
|
|
2104
2168
|
*/
|
|
2105
2169
|
at;
|
|
2170
|
+
/**
|
|
2171
|
+
* Windowed close (#1011): prune events older than this cutoff behind
|
|
2172
|
+
* the closest safe snapshot instead of retiring the stream. Thrown by
|
|
2173
|
+
* the autoclose reaction of a `.autocloses({ keep })` state; flows
|
|
2174
|
+
* into {@link CloseTarget.before}. Omitted → a full close.
|
|
2175
|
+
*/
|
|
2176
|
+
before;
|
|
2106
2177
|
constructor(opts) {
|
|
2107
2178
|
super("reaction requested close");
|
|
2108
2179
|
this.name = "CloseSignal";
|
|
2109
2180
|
this.stream = opts?.stream;
|
|
2110
2181
|
this.archive = opts?.archive;
|
|
2111
2182
|
this.at = opts?.at;
|
|
2183
|
+
this.before = opts?.before;
|
|
2112
2184
|
}
|
|
2113
2185
|
};
|
|
2114
2186
|
|
|
@@ -2132,7 +2204,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
|
|
|
2132
2204
|
for (const st of states.values()) {
|
|
2133
2205
|
const predicate = st.autoclose;
|
|
2134
2206
|
if (!predicate) continue;
|
|
2135
|
-
const
|
|
2207
|
+
const after_days = st.autoclose_after_days;
|
|
2208
|
+
const keep_days = st.autoclose_keep_days;
|
|
2136
2209
|
const archiver = st.archive;
|
|
2137
2210
|
const reaction = {
|
|
2138
2211
|
// Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
|
|
@@ -2156,7 +2229,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
|
|
|
2156
2229
|
});
|
|
2157
2230
|
const stats = await store2().query_stats([aggregate], {
|
|
2158
2231
|
count: true,
|
|
2159
|
-
|
|
2232
|
+
tail: keep_days !== void 0 ? true : void 0,
|
|
2233
|
+
exclude: keep_days !== void 0 ? [TOMBSTONE_EVENT, SNAP_EVENT] : [TOMBSTONE_EVENT]
|
|
2160
2234
|
});
|
|
2161
2235
|
const entry = stats.get(aggregate);
|
|
2162
2236
|
if (!entry) return;
|
|
@@ -2170,9 +2244,24 @@ function synthesize_autoclose_reactions(registry, states, config2) {
|
|
|
2170
2244
|
// aggregate) sees it caught up instead of blocking its own close.
|
|
2171
2245
|
at: head.id
|
|
2172
2246
|
});
|
|
2173
|
-
if (
|
|
2247
|
+
if (keep_days !== void 0) {
|
|
2248
|
+
const cutoff = days_before_now(keep_days);
|
|
2249
|
+
if (entry.tail.created < cutoff)
|
|
2250
|
+
throw new CloseSignal({
|
|
2251
|
+
stream: aggregate,
|
|
2252
|
+
before: cutoff,
|
|
2253
|
+
archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
|
|
2254
|
+
at: head.id
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
const dues = [];
|
|
2258
|
+
if (after_days !== void 0)
|
|
2259
|
+
dues.push(days_after(head.created, after_days));
|
|
2260
|
+
if (keep_days !== void 0)
|
|
2261
|
+
dues.push(days_after(entry.tail.created, keep_days));
|
|
2262
|
+
if (dues.length)
|
|
2174
2263
|
throw new DeferSignal({
|
|
2175
|
-
at: new Date(
|
|
2264
|
+
at: new Date(Math.min(...dues.map((d) => d.getTime())))
|
|
2176
2265
|
});
|
|
2177
2266
|
}
|
|
2178
2267
|
};
|
|
@@ -2559,8 +2648,20 @@ var CircuitBreaker = class {
|
|
|
2559
2648
|
var SAFETY_PROBE_PAGE_SIZE = 1e3;
|
|
2560
2649
|
async function run_close_cycle(targets, deps) {
|
|
2561
2650
|
const target_map = new Map(targets.map((t) => [t.stream, t]));
|
|
2562
|
-
const
|
|
2651
|
+
for (const t of target_map.values()) {
|
|
2652
|
+
if (t.before !== void 0 && t.restart)
|
|
2653
|
+
throw new Error(
|
|
2654
|
+
`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`
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
const windowed = [...target_map.values()].filter(
|
|
2658
|
+
(t) => t.before !== void 0
|
|
2659
|
+
);
|
|
2660
|
+
const full = [...target_map.values()].filter((t) => t.before === void 0);
|
|
2563
2661
|
const skipped = [];
|
|
2662
|
+
const windowed_result = windowed.length ? await run_windowed_closes(windowed, deps, skipped) : /* @__PURE__ */ new Map();
|
|
2663
|
+
if (!full.length) return { truncated: windowed_result, skipped };
|
|
2664
|
+
const streams = full.map((t) => t.stream);
|
|
2564
2665
|
const stream_info = await scan_stream_heads(streams);
|
|
2565
2666
|
const safe = await partition_by_safety(
|
|
2566
2667
|
stream_info,
|
|
@@ -2568,7 +2669,7 @@ async function run_close_cycle(targets, deps) {
|
|
|
2568
2669
|
skipped,
|
|
2569
2670
|
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
2570
2671
|
);
|
|
2571
|
-
if (!safe.length) return { truncated:
|
|
2672
|
+
if (!safe.length) return { truncated: windowed_result, skipped };
|
|
2572
2673
|
const { guarded, guard_events } = await guard_with_tombstones(
|
|
2573
2674
|
safe,
|
|
2574
2675
|
stream_info,
|
|
@@ -2576,7 +2677,7 @@ async function run_close_cycle(targets, deps) {
|
|
|
2576
2677
|
deps.tombstone,
|
|
2577
2678
|
skipped
|
|
2578
2679
|
);
|
|
2579
|
-
if (!guarded.length) return { truncated:
|
|
2680
|
+
if (!guarded.length) return { truncated: windowed_result, skipped };
|
|
2580
2681
|
const seed_states = await load_restart_seeds(
|
|
2581
2682
|
guarded,
|
|
2582
2683
|
target_map,
|
|
@@ -2592,8 +2693,62 @@ async function run_close_cycle(targets, deps) {
|
|
|
2592
2693
|
guard_events,
|
|
2593
2694
|
deps.correlation
|
|
2594
2695
|
);
|
|
2696
|
+
for (const [stream, entry] of windowed_result) truncated.set(stream, entry);
|
|
2595
2697
|
return { truncated, skipped };
|
|
2596
2698
|
}
|
|
2699
|
+
async function run_windowed_closes(windowed, deps, skipped) {
|
|
2700
|
+
const min_at = deps.reactive_events_size > 0 ? await probe_min_watermarks(
|
|
2701
|
+
windowed.map((t) => t.stream),
|
|
2702
|
+
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
2703
|
+
) : /* @__PURE__ */ new Map();
|
|
2704
|
+
for (const t of windowed) {
|
|
2705
|
+
if (t.archive) await t.archive();
|
|
2706
|
+
}
|
|
2707
|
+
const truncated = await store2().truncate(
|
|
2708
|
+
windowed.map((t) => ({
|
|
2709
|
+
stream: t.stream,
|
|
2710
|
+
before: t.before,
|
|
2711
|
+
max_id: min_at.get(t.stream)
|
|
2712
|
+
}))
|
|
2713
|
+
);
|
|
2714
|
+
for (const t of windowed) {
|
|
2715
|
+
if (!truncated.has(t.stream)) skipped.push(t.stream);
|
|
2716
|
+
}
|
|
2717
|
+
return truncated;
|
|
2718
|
+
}
|
|
2719
|
+
async function probe_min_watermarks(streams, page_size) {
|
|
2720
|
+
const min_at = /* @__PURE__ */ new Map();
|
|
2721
|
+
const source_regex = /* @__PURE__ */ new Map();
|
|
2722
|
+
const get_regex = (source) => {
|
|
2723
|
+
let re = source_regex.get(source);
|
|
2724
|
+
if (!re) {
|
|
2725
|
+
re = new RegExp(source);
|
|
2726
|
+
source_regex.set(source, re);
|
|
2727
|
+
}
|
|
2728
|
+
return re;
|
|
2729
|
+
};
|
|
2730
|
+
let after;
|
|
2731
|
+
for (; ; ) {
|
|
2732
|
+
let last;
|
|
2733
|
+
const { count } = await store2().query_streams(
|
|
2734
|
+
(position) => {
|
|
2735
|
+
last = position.stream;
|
|
2736
|
+
const source_re = position.source ? get_regex(position.source) : void 0;
|
|
2737
|
+
for (const stream of streams) {
|
|
2738
|
+
if (!source_re || source_re.test(stream)) {
|
|
2739
|
+
const prev = min_at.get(stream);
|
|
2740
|
+
if (prev === void 0 || position.at < prev)
|
|
2741
|
+
min_at.set(stream, position.at);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
},
|
|
2745
|
+
{ after, limit: page_size, source_matches: streams }
|
|
2746
|
+
);
|
|
2747
|
+
if (count < page_size) break;
|
|
2748
|
+
after = last;
|
|
2749
|
+
}
|
|
2750
|
+
return min_at;
|
|
2751
|
+
}
|
|
2597
2752
|
async function scan_stream_heads(streams) {
|
|
2598
2753
|
const stats = await store2().query_stats(streams, {
|
|
2599
2754
|
exclude: [SNAP_EVENT]
|
|
@@ -3024,7 +3179,7 @@ var DEFAULT_BATCH = 500;
|
|
|
3024
3179
|
async function snap(snapshot) {
|
|
3025
3180
|
const { id, stream, name, meta, version } = snapshot.event;
|
|
3026
3181
|
try {
|
|
3027
|
-
await store2().commit(
|
|
3182
|
+
const [committed] = await store2().commit(
|
|
3028
3183
|
stream,
|
|
3029
3184
|
[{ name: SNAP_EVENT, data: snapshot.state }],
|
|
3030
3185
|
{
|
|
@@ -3034,6 +3189,7 @@ async function snap(snapshot) {
|
|
|
3034
3189
|
version
|
|
3035
3190
|
// IMPORTANT! - state events are committed right after the snapshot event
|
|
3036
3191
|
);
|
|
3192
|
+
return committed;
|
|
3037
3193
|
} catch (error) {
|
|
3038
3194
|
const reason = error instanceof Error ? error.message : String(error);
|
|
3039
3195
|
log().warn(
|
|
@@ -3246,7 +3402,7 @@ async function action(me, action2, target, payload, options) {
|
|
|
3246
3402
|
const snapshot = await load(me, { stream, actor: target.actor });
|
|
3247
3403
|
if (snapshot.event?.name === TOMBSTONE_EVENT)
|
|
3248
3404
|
throw new StreamClosedError(stream);
|
|
3249
|
-
const expected = expectedVersion ?? snapshot.
|
|
3405
|
+
const expected = expectedVersion ?? (snapshot.version >= 0 ? snapshot.version : void 0);
|
|
3250
3406
|
if (me.given) {
|
|
3251
3407
|
const invariants = me.given[action2] || [];
|
|
3252
3408
|
invariants.forEach(({ valid: valid2, description }) => {
|
|
@@ -3328,18 +3484,22 @@ async function action(me, action2, target, payload, options) {
|
|
|
3328
3484
|
};
|
|
3329
3485
|
});
|
|
3330
3486
|
const last = snapshots.at(-1);
|
|
3331
|
-
const
|
|
3487
|
+
const contiguous = committed[0].version === snapshot.version + 1;
|
|
3488
|
+
const snapped = contiguous && me.snap?.(last);
|
|
3489
|
+
const snap_event = snapped ? await snap(last) : void 0;
|
|
3332
3490
|
if (!me.pii_aware) {
|
|
3333
|
-
|
|
3334
|
-
stream,
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3491
|
+
if (contiguous)
|
|
3492
|
+
cache2().set(stream, {
|
|
3493
|
+
stream,
|
|
3494
|
+
state: last.state,
|
|
3495
|
+
version: snap_event?.version ?? last.event.version,
|
|
3496
|
+
event_id: snap_event?.id ?? last.event.id,
|
|
3497
|
+
patches: snap_event ? 0 : last.patches,
|
|
3498
|
+
snaps: snap_event ? last.snaps + 1 : last.snaps
|
|
3499
|
+
}).catch((err) => log().error(err));
|
|
3500
|
+
else
|
|
3501
|
+
cache2().invalidate(stream).catch((err) => log().error(err));
|
|
3341
3502
|
}
|
|
3342
|
-
if (snapped) void snap(last);
|
|
3343
3503
|
return snapshots;
|
|
3344
3504
|
} catch (error) {
|
|
3345
3505
|
if (!(error instanceof ConcurrencyError)) throw error;
|
|
@@ -3789,8 +3949,13 @@ function build_handle(deps) {
|
|
|
3789
3949
|
acked_at: error.at ?? event.id,
|
|
3790
3950
|
// Close the signalled stream (the autoclose reaction's aggregate,
|
|
3791
3951
|
// which differs from its synthetic lease stream); a self-closing
|
|
3792
|
-
// user reaction omits it and closes its own lease stream.
|
|
3793
|
-
|
|
3952
|
+
// user reaction omits it and closes its own lease stream. A
|
|
3953
|
+
// carried `before` makes it a windowed close (prune, not retire).
|
|
3954
|
+
close: {
|
|
3955
|
+
stream: error.stream ?? stream,
|
|
3956
|
+
archive: error.archive,
|
|
3957
|
+
before: error.before
|
|
3958
|
+
}
|
|
3794
3959
|
};
|
|
3795
3960
|
return finalize(
|
|
3796
3961
|
lease,
|
|
@@ -5093,7 +5258,19 @@ var Act = class {
|
|
|
5093
5258
|
* 7. **Cache** — invalidate (tombstoned) or warm (restarted)
|
|
5094
5259
|
* 8. **Emit "closed"** — lifecycle event with results
|
|
5095
5260
|
*
|
|
5096
|
-
*
|
|
5261
|
+
* Targets carrying `before` take the **windowed** branch instead —
|
|
5262
|
+
* close the books on a rolling window: probe the min consumer
|
|
5263
|
+
* watermark (so the boundary never rises past a lagging reaction),
|
|
5264
|
+
* run the archive callback against the cutoff, then prune the prefix
|
|
5265
|
+
* below the closest safe `__snapshot__`. No tombstone guard (the
|
|
5266
|
+
* pre-cutoff prefix is immutable), no seed, cache untouched — the
|
|
5267
|
+
* stream stays live and keeps accepting actions. Requires the state
|
|
5268
|
+
* to snapshot via `.snap(...)`; no qualifying snapshot ⇒ the stream
|
|
5269
|
+
* lands in `skipped` (retry after the next snapshot). Windowed
|
|
5270
|
+
* entries in the result echo `before` and carry the surviving
|
|
5271
|
+
* boundary snapshot as `committed`.
|
|
5272
|
+
*
|
|
5273
|
+
* @param targets - Per-stream close options (stream, restart?, archive?, before?)
|
|
5097
5274
|
* @returns `{ truncated: TruncateResult, skipped: string[] }`
|
|
5098
5275
|
*
|
|
5099
5276
|
* @example Archive and close
|
|
@@ -5111,6 +5288,19 @@ var Act = class {
|
|
|
5111
5288
|
* { stream: "counter-2" }, // tombstoned
|
|
5112
5289
|
* ]);
|
|
5113
5290
|
* ```
|
|
5291
|
+
*
|
|
5292
|
+
* @example Windowed close — keep the last 180 days of real events
|
|
5293
|
+
* ```typescript
|
|
5294
|
+
* const cutoff = new Date();
|
|
5295
|
+
* cutoff.setDate(cutoff.getDate() - 180);
|
|
5296
|
+
* await app.close([
|
|
5297
|
+
* {
|
|
5298
|
+
* stream: "ledger-acme",
|
|
5299
|
+
* before: cutoff,
|
|
5300
|
+
* archive: async () => { await archiveToS3("ledger-acme", cutoff); },
|
|
5301
|
+
* },
|
|
5302
|
+
* ]);
|
|
5303
|
+
* ```
|
|
5114
5304
|
*/
|
|
5115
5305
|
async close(targets) {
|
|
5116
5306
|
if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
|
|
@@ -5208,6 +5398,7 @@ function act() {
|
|
|
5208
5398
|
autoclose_archiver: (state_name) => _aa.get(state_name) ?? null
|
|
5209
5399
|
};
|
|
5210
5400
|
const pending_projections = [];
|
|
5401
|
+
const fold_projections = [];
|
|
5211
5402
|
const batch_handlers = /* @__PURE__ */ new Map();
|
|
5212
5403
|
const lanes = [];
|
|
5213
5404
|
let _built = false;
|
|
@@ -5273,6 +5464,8 @@ function act() {
|
|
|
5273
5464
|
withProjection: (proj) => {
|
|
5274
5465
|
merge_projection(proj, registry.events);
|
|
5275
5466
|
register_batch_handler(proj, batch_handlers);
|
|
5467
|
+
if (proj.fold)
|
|
5468
|
+
fold_projections.push(proj);
|
|
5276
5469
|
return builder;
|
|
5277
5470
|
},
|
|
5278
5471
|
withActor: () => builder,
|
|
@@ -5286,6 +5479,26 @@ function act() {
|
|
|
5286
5479
|
for (const proj of pending_projections) {
|
|
5287
5480
|
merge_projection(proj, registry.events);
|
|
5288
5481
|
register_batch_handler(proj, batch_handlers);
|
|
5482
|
+
if (proj.fold) fold_projections.push(proj);
|
|
5483
|
+
}
|
|
5484
|
+
for (const proj of fold_projections) {
|
|
5485
|
+
const fold = proj.fold;
|
|
5486
|
+
const merged = states.get(fold.name);
|
|
5487
|
+
if (!merged)
|
|
5488
|
+
throw new Error(
|
|
5489
|
+
`State projection "${proj.target}" folds "${fold.name}", which is not registered \u2014 add it via withState/withSlice before build()`
|
|
5490
|
+
);
|
|
5491
|
+
const missing = Object.keys(merged.events).filter(
|
|
5492
|
+
(event_name) => !(event_name in proj.events)
|
|
5493
|
+
);
|
|
5494
|
+
if (missing.length > 0)
|
|
5495
|
+
throw new Error(
|
|
5496
|
+
`State projection "${proj.target}" of "${fold.name}" is missing events ${missing.join(", ")} \u2014 pass every partial of the state to .of()`
|
|
5497
|
+
);
|
|
5498
|
+
batch_handlers.set(
|
|
5499
|
+
proj.target,
|
|
5500
|
+
make_fold_handler(merged, fold.flush, fold.config)
|
|
5501
|
+
);
|
|
5289
5502
|
}
|
|
5290
5503
|
finalize_deprecations();
|
|
5291
5504
|
validate_lane_references(registry, lanes);
|
|
@@ -5428,39 +5641,52 @@ function _projection(target) {
|
|
|
5428
5641
|
batchHandler: handler
|
|
5429
5642
|
})
|
|
5430
5643
|
}),
|
|
5431
|
-
of: (
|
|
5644
|
+
of: (...args) => {
|
|
5432
5645
|
if (Object.keys(events).length > 0)
|
|
5433
5646
|
throw new Error(
|
|
5434
5647
|
`Projection "${target}" mixes .of() with .on() handlers \u2014 a projection either folds a state or declares handlers, never both`
|
|
5435
5648
|
);
|
|
5649
|
+
const is_state = (a) => !!a && typeof a.init === "function";
|
|
5650
|
+
const partials = args.filter(is_state);
|
|
5651
|
+
const options = args.find((a) => !is_state(a)) ?? {};
|
|
5652
|
+
if (partials.length === 0)
|
|
5653
|
+
throw new Error(`Projection "${target}" .of() requires a state`);
|
|
5654
|
+
const name = partials[0].name;
|
|
5655
|
+
for (const partial of partials)
|
|
5656
|
+
if (partial.name !== name)
|
|
5657
|
+
throw new Error(
|
|
5658
|
+
`Projection "${target}" .of() partials must share one state name \u2014 got "${name}" and "${partial.name}"`
|
|
5659
|
+
);
|
|
5436
5660
|
const config2 = resolveFoldConfig(options);
|
|
5437
5661
|
const fold_events = {};
|
|
5438
|
-
for (const
|
|
5439
|
-
const
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5662
|
+
for (const partial of partials)
|
|
5663
|
+
for (const [event_name, schema] of Object.entries(partial.events)) {
|
|
5664
|
+
if (event_name in fold_events) continue;
|
|
5665
|
+
const noop2 = {
|
|
5666
|
+
[`${target}_fold`]: async () => {
|
|
5667
|
+
}
|
|
5668
|
+
}[`${target}_fold`];
|
|
5669
|
+
fold_events[event_name] = {
|
|
5670
|
+
schema,
|
|
5671
|
+
reactions: /* @__PURE__ */ new Map([
|
|
5672
|
+
[
|
|
5673
|
+
`${target}_fold`,
|
|
5674
|
+
{
|
|
5675
|
+
handler: noop2,
|
|
5676
|
+
resolver: { target },
|
|
5677
|
+
options: { blockOnError: true, maxRetries: 3 }
|
|
5678
|
+
}
|
|
5679
|
+
]
|
|
5680
|
+
])
|
|
5681
|
+
};
|
|
5682
|
+
}
|
|
5457
5683
|
return {
|
|
5458
5684
|
flush: (handler) => ({
|
|
5459
5685
|
build: () => ({
|
|
5460
5686
|
_tag: "Projection",
|
|
5461
5687
|
events: fold_events,
|
|
5462
5688
|
target,
|
|
5463
|
-
|
|
5689
|
+
fold: { name, flush: handler, config: config2 }
|
|
5464
5690
|
})
|
|
5465
5691
|
})
|
|
5466
5692
|
};
|
|
@@ -5607,10 +5833,16 @@ function action_builder(state2) {
|
|
|
5607
5833
|
".autocloses(...) requires a policy object; got " + typeof policy
|
|
5608
5834
|
);
|
|
5609
5835
|
}
|
|
5836
|
+
if (policy.keep && !internal.snap) {
|
|
5837
|
+
throw new Error(
|
|
5838
|
+
".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."
|
|
5839
|
+
);
|
|
5840
|
+
}
|
|
5610
5841
|
internal.autoclose = compile_autoclose_policy(
|
|
5611
5842
|
policy
|
|
5612
5843
|
);
|
|
5613
|
-
internal.
|
|
5844
|
+
internal.autoclose_after_days = policy_min_after_days(policy);
|
|
5845
|
+
internal.autoclose_keep_days = policy_keep_days(policy);
|
|
5614
5846
|
return builder;
|
|
5615
5847
|
},
|
|
5616
5848
|
archives(archive) {
|