@classytic/primitives 0.22.0 → 0.24.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/CHANGELOG.md +48 -0
- package/dist/address.d.mts +270 -1
- package/dist/address.mjs +190 -1
- package/dist/approval.d.mts +59 -1
- package/dist/approval.mjs +60 -1
- package/dist/calendar-facts.d.mts +176 -0
- package/dist/calendar-facts.mjs +187 -0
- package/dist/fault-attribution.d.mts +98 -0
- package/dist/fault-attribution.mjs +51 -0
- package/dist/memory-outbox.d.mts +4 -1
- package/dist/memory-outbox.mjs +9 -1
- package/dist/outbox.d.mts +28 -1
- package/dist/unit-cost-rate.d.mts +18 -1
- package/dist/unit-cost-rate.mjs +30 -1
- package/package.json +9 -1
package/dist/approval.mjs
CHANGED
|
@@ -39,6 +39,49 @@ function createChain(input) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
+
* The chain for a subject that needs NO approval — zero steps, already
|
|
43
|
+
* resolved. The explicit, greppable answer to "the policy engine ran and
|
|
44
|
+
* decided this document does not require approval."
|
|
45
|
+
*
|
|
46
|
+
* ## Why this is not a fabricated approval
|
|
47
|
+
*
|
|
48
|
+
* {@link computeChainStatus} already folds an empty step list to `'approved'`
|
|
49
|
+
* — "nothing is outstanding" is what approved MEANS to this primitive, and
|
|
50
|
+
* that fold predates this function. What {@link createChain} refuses
|
|
51
|
+
* (`EMPTY_STEPS`) is building a ROUTING chain that routes nowhere, which is a
|
|
52
|
+
* different mistake and stays refused. So this constructor states the same
|
|
53
|
+
* conclusion the fold reaches, under a name a reviewer can search for, rather
|
|
54
|
+
* than letting a caller reach it by handing `createChain` an empty array.
|
|
55
|
+
*
|
|
56
|
+
* ## Why a chain at all, instead of leaving `approvals` absent
|
|
57
|
+
*
|
|
58
|
+
* Because absence is the one input that occurs by accident. A schema that
|
|
59
|
+
* never declared the field strips it, a failed write leaves it unset — and
|
|
60
|
+
* {@link assertApproved} must keep treating that as CHAIN_MISSING. A present,
|
|
61
|
+
* zero-step chain is distinguishable from both a lost chain (absent) and a
|
|
62
|
+
* granted one (has steps and decisions), so every existing gate keeps working
|
|
63
|
+
* unchanged and no caller needs a second predicate beside `isApproved`.
|
|
64
|
+
*
|
|
65
|
+
* ## The hazard, stated plainly
|
|
66
|
+
*
|
|
67
|
+
* This makes {@link isApproved} true without a human deciding anything. It is
|
|
68
|
+
* only correct where a policy engine ASKED and got "no policy applies" — never
|
|
69
|
+
* as a fallback for a lookup that failed, errored, or was not configured. A
|
|
70
|
+
* resolver that cannot reach its policy store must throw; answering "not
|
|
71
|
+
* required" there would auto-approve every document in the deployment, which
|
|
72
|
+
* is precisely the permissive-default failure this codebase designs against.
|
|
73
|
+
*
|
|
74
|
+
* @param reason Why approval was not required, for the audit trail.
|
|
75
|
+
*/
|
|
76
|
+
function notRequiredChain(reason) {
|
|
77
|
+
return {
|
|
78
|
+
order: "sequential",
|
|
79
|
+
steps: [],
|
|
80
|
+
status: computeChainStatus([]),
|
|
81
|
+
notRequiredReason: reason
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
42
85
|
* Returns the next step awaiting a decision — or `null` if none.
|
|
43
86
|
*
|
|
44
87
|
* For `sequential` chains: the first `pending` step in order; later steps
|
|
@@ -116,6 +159,22 @@ function isApproved(chain) {
|
|
|
116
159
|
return chain.status === "approved";
|
|
117
160
|
}
|
|
118
161
|
/**
|
|
162
|
+
* Was this chain built by {@link notRequiredChain} — i.e. approval did not
|
|
163
|
+
* apply, as opposed to having been granted?
|
|
164
|
+
*
|
|
165
|
+
* Both are `isApproved`, and for GATING that is correct: neither blocks the
|
|
166
|
+
* operation. Use this only where the difference is worth showing — an audit
|
|
167
|
+
* trail or an approvals UI, where rendering "Approved" for a document nobody
|
|
168
|
+
* approved misleads the reader.
|
|
169
|
+
*
|
|
170
|
+
* Tests the STRUCTURE (no steps ⇒ nobody was ever asked), not
|
|
171
|
+
* `notRequiredReason`, which a subject schema that never declared the field
|
|
172
|
+
* silently drops on write.
|
|
173
|
+
*/
|
|
174
|
+
function isNotRequired(chain) {
|
|
175
|
+
return chain.steps.length === 0;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
119
178
|
* The canonical finalize-time gate: assert a chain EXISTS and has reached
|
|
120
179
|
* `approved`, else throw. Use it at every commit/post/finalize boundary that
|
|
121
180
|
* must not proceed unapproved (journal-entry post, PO approve, transfer
|
|
@@ -189,4 +248,4 @@ function computeChainStatus(steps) {
|
|
|
189
248
|
return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
|
|
190
249
|
}
|
|
191
250
|
//#endregion
|
|
192
|
-
export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
|
251
|
+
export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { t as CivilDate } from "./timezone-CxUjxmRU.mjs";
|
|
2
|
+
//#region src/scheduling/calendar-facts.d.ts
|
|
3
|
+
/** Inclusive civil-date bounds a dataset is asserted COMPLETE for. */
|
|
4
|
+
interface DateCoverage {
|
|
5
|
+
readonly from: CivilDate;
|
|
6
|
+
readonly through: CivilDate;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A set of dated facts plus the window it is complete for.
|
|
10
|
+
*
|
|
11
|
+
* ## The key type documents intent and CANNOT enforce it
|
|
12
|
+
*
|
|
13
|
+
* `Record<CivilDate, T>` is a mapped type over a branded string, which TypeScript collapses to a plain
|
|
14
|
+
* string index signature — so the brand does not survive in key position and any string is accepted at
|
|
15
|
+
* the type level. The brand is kept because it states what the keys MEAN; every key is validated at
|
|
16
|
+
* construction instead. Do not assume a value of this type has pre-validated keys unless it came from
|
|
17
|
+
* {@link defineDateEdition} or a schema that performs the same checks.
|
|
18
|
+
*/
|
|
19
|
+
interface CoveredDateEdition<TEntry extends object> {
|
|
20
|
+
readonly coverage: DateCoverage;
|
|
21
|
+
readonly dates: Readonly<Record<CivilDate, TEntry>>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Why a lookup could not answer. Each is a DIFFERENT operator remedy, which is why this is not a
|
|
25
|
+
* boolean:
|
|
26
|
+
*
|
|
27
|
+
* - `missing-edition` — nobody has entered this year. Enter it.
|
|
28
|
+
* - `outside-coverage` — the year exists but does not claim to cover this date. Extend the coverage.
|
|
29
|
+
* - `unattested` — data exists but no authorized party has vouched for it. Approve it.
|
|
30
|
+
*
|
|
31
|
+
* `unattested` is never produced by this module: attestation is a property of how a dataset was STORED
|
|
32
|
+
* and by whom, which a pure function cannot observe. It lives in the union so a config layer can return
|
|
33
|
+
* it through the same shape rather than inventing a fourth state.
|
|
34
|
+
*/
|
|
35
|
+
type DateFactUnknownReason = 'missing-edition' | 'outside-coverage' | 'unattested';
|
|
36
|
+
/**
|
|
37
|
+
* The three-state answer.
|
|
38
|
+
*
|
|
39
|
+
* **`not-listed` does not mean "working day".** It means this attested dataset does not list the date.
|
|
40
|
+
* Converting that into a working-day, a pay rate or a leave deduction is the consuming domain's job,
|
|
41
|
+
* because the same fact supports different conclusions in each.
|
|
42
|
+
*/
|
|
43
|
+
type DateFactLookup<TEntry> = {
|
|
44
|
+
readonly kind: 'listed';
|
|
45
|
+
readonly entry: TEntry;
|
|
46
|
+
} | {
|
|
47
|
+
readonly kind: 'not-listed';
|
|
48
|
+
} | {
|
|
49
|
+
readonly kind: 'unknown';
|
|
50
|
+
readonly reason: DateFactUnknownReason;
|
|
51
|
+
};
|
|
52
|
+
/** Who published a dataset. Required, and required to be non-empty — see {@link defineDateEdition}. */
|
|
53
|
+
interface DateAuthority {
|
|
54
|
+
/** The publishing body, e.g. a ministry. Never an aggregator API that merely relayed it. */
|
|
55
|
+
readonly issuer: string;
|
|
56
|
+
/** The citable instrument — a gazette or circular number. */
|
|
57
|
+
readonly reference: string;
|
|
58
|
+
readonly publishedOn?: CivilDate | undefined;
|
|
59
|
+
}
|
|
60
|
+
/** An edition that names its own authority. The unit of completeness for a jurisdiction fact. */
|
|
61
|
+
interface AttributedDateEdition<TEntry extends object> extends CoveredDateEdition<TEntry> {
|
|
62
|
+
readonly authority: DateAuthority;
|
|
63
|
+
}
|
|
64
|
+
/** One listed date, as an importer supplies it. */
|
|
65
|
+
interface DateEntryInput<TEntry extends object> {
|
|
66
|
+
readonly date: string;
|
|
67
|
+
readonly entry: TEntry;
|
|
68
|
+
}
|
|
69
|
+
interface DefineDateEditionInput<TEntry extends object> {
|
|
70
|
+
/**
|
|
71
|
+
* The edition key, `YYYY`. Checked AGAINST the coverage rather than derived from it — an edition
|
|
72
|
+
* filed under the wrong year is a data-entry error that would otherwise make every lookup in that
|
|
73
|
+
* year report `missing-edition` while the data sat one key away.
|
|
74
|
+
*/
|
|
75
|
+
readonly year: string;
|
|
76
|
+
readonly coverage: {
|
|
77
|
+
readonly from: string;
|
|
78
|
+
readonly through: string;
|
|
79
|
+
};
|
|
80
|
+
readonly authority: DateAuthority;
|
|
81
|
+
readonly dates: readonly DateEntryInput<TEntry>[];
|
|
82
|
+
/**
|
|
83
|
+
* Permit an edition covering less than the whole year.
|
|
84
|
+
*
|
|
85
|
+
* OFF by default. An annual public-holiday gazette covers a full year, so partial coverage is
|
|
86
|
+
* usually a half-finished import — and a half-finished import that presents as authoritative is
|
|
87
|
+
* precisely the failure this module exists to prevent. Opt in only where a consumer genuinely needs
|
|
88
|
+
* a partial window.
|
|
89
|
+
*/
|
|
90
|
+
readonly allowPartialYear?: boolean;
|
|
91
|
+
}
|
|
92
|
+
/** Thrown by {@link defineDateEdition}. Carries every problem, not the first. */
|
|
93
|
+
declare class InvalidDateEditionError extends Error {
|
|
94
|
+
readonly problems: readonly string[];
|
|
95
|
+
constructor(year: string, problems: readonly string[]);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build a validated edition, or throw naming every problem.
|
|
99
|
+
*
|
|
100
|
+
* REPORTS ALL problems rather than the first: an operator fixing a year's holiday list wants the whole
|
|
101
|
+
* list of bad rows, and returning one at a time turns one correction into twenty round trips.
|
|
102
|
+
*/
|
|
103
|
+
declare function defineDateEdition<TEntry extends object>(input: DefineDateEditionInput<TEntry>): AttributedDateEdition<TEntry>;
|
|
104
|
+
/**
|
|
105
|
+
* A jurisdiction's (or an employer's) dated facts, keyed by year.
|
|
106
|
+
*
|
|
107
|
+
* Year-keyed so an amendment replaces one edition rather than the whole history — and so a lookup for
|
|
108
|
+
* an unentered year is distinguishable from a date nobody listed.
|
|
109
|
+
*/
|
|
110
|
+
interface DateFactCalendar<TEntry extends object> {
|
|
111
|
+
/** The zone the civil dates are civil IN. A date has no meaning without it. */
|
|
112
|
+
readonly timeZone: string;
|
|
113
|
+
/** Key is `YYYY`. */
|
|
114
|
+
readonly editions: Readonly<Record<string, AttributedDateEdition<TEntry>>>;
|
|
115
|
+
}
|
|
116
|
+
/** Thrown by {@link defineDateFactCalendar}. */
|
|
117
|
+
declare class InvalidDateFactCalendarError extends Error {
|
|
118
|
+
readonly problems: readonly string[];
|
|
119
|
+
constructor(problems: readonly string[]);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build a validated calendar. **The timezone is checked here, not trusted.**
|
|
123
|
+
*
|
|
124
|
+
* A civil date has no meaning without a zone, and a WRONG zone is worse than a missing one: it shifts
|
|
125
|
+
* every boundary by the offset while every value still looks like a date. Asia/Dhaka is UTC+6, so a
|
|
126
|
+
* calendar read in UTC disagrees with the business day for six hours at both ends — the same failure
|
|
127
|
+
* that once reported August revenue as July for the last six hours of every month.
|
|
128
|
+
*
|
|
129
|
+
* Also re-checks each edition's key against its own coverage. `defineDateEdition` already enforces that,
|
|
130
|
+
* but a calendar can be assembled from editions that were built elsewhere or filed under the wrong key,
|
|
131
|
+
* and a misfiled edition makes every lookup in its year report `missing-edition` while the data sits one
|
|
132
|
+
* key away.
|
|
133
|
+
*/
|
|
134
|
+
declare function defineDateFactCalendar<TEntry extends object>(input: {
|
|
135
|
+
readonly timeZone: string;
|
|
136
|
+
readonly editions: Readonly<Record<string, AttributedDateEdition<TEntry>>>;
|
|
137
|
+
}): DateFactCalendar<TEntry>;
|
|
138
|
+
/**
|
|
139
|
+
* Look up one civil date. NEVER falls back to "not listed" for data that is absent rather than empty.
|
|
140
|
+
*
|
|
141
|
+
* Order matters: the edition is resolved first, then coverage, then membership. Checking membership
|
|
142
|
+
* first would answer `not-listed` for a year nobody has entered — which is the whole bug.
|
|
143
|
+
*/
|
|
144
|
+
declare function lookupDateFact<TEntry extends object>(calendar: DateFactCalendar<TEntry>, date: CivilDate): DateFactLookup<TEntry>;
|
|
145
|
+
/**
|
|
146
|
+
* Look up the civil date an INSTANT falls on, resolved in the CALENDAR'S zone.
|
|
147
|
+
*
|
|
148
|
+
* Prefer this over converting yourself. `lookupDateFact` takes a `CivilDate`, and producing one from an
|
|
149
|
+
* instant requires a zone — if the caller picks its own (the server's, or UTC) it will disagree with the
|
|
150
|
+
* calendar near midnight and be wrong by a whole day at the boundary. The calendar already knows the
|
|
151
|
+
* only correct zone, so the conversion belongs here rather than at every call site.
|
|
152
|
+
*/
|
|
153
|
+
declare function lookupDateFactAt<TEntry extends object>(calendar: DateFactCalendar<TEntry>, instant: Date): DateFactLookup<TEntry>;
|
|
154
|
+
/** A public holiday, as published. `label` is the occasion's name, for display and audit. */
|
|
155
|
+
interface PublicHolidayEntry {
|
|
156
|
+
readonly label: string;
|
|
157
|
+
}
|
|
158
|
+
/** A jurisdiction's public-holiday calendar. The company-scoped, statutory one. */
|
|
159
|
+
type PublicHolidayCalendar = DateFactCalendar<PublicHolidayEntry>;
|
|
160
|
+
/**
|
|
161
|
+
* An employer closure, which unlike a public holiday can also declare a date OPEN.
|
|
162
|
+
*
|
|
163
|
+
* The `open` arm exists because a branch override must be able to CONTRADICT a company-wide closure —
|
|
164
|
+
* the branch works that day. Modelled as a discriminated union rather than a boolean so a merge
|
|
165
|
+
* replaces the whole value per date; recursively merging `{state:'closed',label}` onto
|
|
166
|
+
* `{state:'open',reason}` would leave both fields and satisfy neither arm.
|
|
167
|
+
*/
|
|
168
|
+
type EmployerClosureEntry = {
|
|
169
|
+
readonly state: 'closed';
|
|
170
|
+
readonly label: string;
|
|
171
|
+
} | {
|
|
172
|
+
readonly state: 'open';
|
|
173
|
+
readonly reason: string;
|
|
174
|
+
};
|
|
175
|
+
//#endregion
|
|
176
|
+
export { AttributedDateEdition, CoveredDateEdition, DateAuthority, DateCoverage, DateEntryInput, DateFactCalendar, DateFactLookup, DateFactUnknownReason, DefineDateEditionInput, EmployerClosureEntry, InvalidDateEditionError, InvalidDateFactCalendarError, PublicHolidayCalendar, PublicHolidayEntry, defineDateEdition, defineDateFactCalendar, lookupDateFact, lookupDateFactAt };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { civilDateOf, civilDaysBetween, isCivilDate, isValidTimeZone } from "./timezone.mjs";
|
|
2
|
+
//#region src/scheduling/calendar-facts.ts
|
|
3
|
+
/**
|
|
4
|
+
* CALENDAR FACTS — attested sets of civil dates, and a lookup that can say "I do not know".
|
|
5
|
+
*
|
|
6
|
+
* A calendar fact is "this date is listed in a dataset somebody vouches for". It is deliberately NOT
|
|
7
|
+
* "this is a working day": that conclusion also depends on weekly schedules, shifts, substitutions and
|
|
8
|
+
* whichever law is asking. Consumers convert facts into their own verdicts — a tax pack into
|
|
9
|
+
* `holiday | working-day | unknown`, payroll into a premium rate, leave into whether balance is
|
|
10
|
+
* consumed. See {@link DateFactLookup}.
|
|
11
|
+
*
|
|
12
|
+
* ## Why this is a sibling of `sla-policy`, not a field on it
|
|
13
|
+
*
|
|
14
|
+
* `WorkingHours` in `./sla-policy` is an SLA SCHEDULE — weekdays plus start/end minutes, with an
|
|
15
|
+
* optional list of skipped dates. Gazette authority and completeness are not schedule properties, and
|
|
16
|
+
* bolting them on would couple statutory tax evidence to support hours while leaving the meaning of the
|
|
17
|
+
* shared field ambiguous per consumer. So these are separate types over the same `CivilDate`.
|
|
18
|
+
*
|
|
19
|
+
* ## The property everything else rests on: COVERAGE
|
|
20
|
+
*
|
|
21
|
+
* A bare `string[]` of holidays cannot express *unknown*. A date absent from the list is
|
|
22
|
+
* indistinguishable from a date nobody has entered yet, so a 2029 deadline checked against a 2026 list
|
|
23
|
+
* reads as a working day — a confident wrong answer, from data that was simply not there.
|
|
24
|
+
*
|
|
25
|
+
* An {@link CoveredDateEdition} therefore carries the bounds it is asserted COMPLETE for. Inside them,
|
|
26
|
+
* absent means not-listed. Outside them, absent means `unknown`. A dataset cannot prove its own
|
|
27
|
+
* completeness, so someone has to declare it — the same reasoning as a tax pack's gazette-coverage
|
|
28
|
+
* claim, and the reason a missing edition can never silently become an empty-but-complete one.
|
|
29
|
+
*
|
|
30
|
+
* ## Input is a LIST, storage is date-KEYED — and that is not incidental
|
|
31
|
+
*
|
|
32
|
+
* The constructors take an array (what an importer or a spreadsheet produces) and return a record keyed
|
|
33
|
+
* by date. Two reasons:
|
|
34
|
+
*
|
|
35
|
+
* 1. **duplicates are caught at construction.** A list can carry the same date twice with different
|
|
36
|
+
* labels; a record cannot, and building one silently would drop whichever came first.
|
|
37
|
+
* 2. **records overlay safely, arrays do not.** A config layer that merges a branch override onto a
|
|
38
|
+
* company default REPLACES arrays wholesale — so a branch list would erase the company list with
|
|
39
|
+
* no error at all. Keyed entries merge per date.
|
|
40
|
+
*/
|
|
41
|
+
/** Thrown by {@link defineDateEdition}. Carries every problem, not the first. */
|
|
42
|
+
var InvalidDateEditionError = class extends Error {
|
|
43
|
+
problems;
|
|
44
|
+
constructor(year, problems) {
|
|
45
|
+
super(`[primitives] Cannot define a date edition for "${year}" — ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join("\n"));
|
|
46
|
+
this.name = "InvalidDateEditionError";
|
|
47
|
+
this.problems = problems;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const YEAR_PATTERN = /^\d{4}$/;
|
|
51
|
+
/**
|
|
52
|
+
* Build a validated edition, or throw naming every problem.
|
|
53
|
+
*
|
|
54
|
+
* REPORTS ALL problems rather than the first: an operator fixing a year's holiday list wants the whole
|
|
55
|
+
* list of bad rows, and returning one at a time turns one correction into twenty round trips.
|
|
56
|
+
*/
|
|
57
|
+
function defineDateEdition(input) {
|
|
58
|
+
const problems = [];
|
|
59
|
+
const { year, coverage, authority, dates, allowPartialYear = false } = input;
|
|
60
|
+
if (!YEAR_PATTERN.test(year)) problems.push(`year must be YYYY; got "${year}"`);
|
|
61
|
+
if (!authority || !authority.issuer?.trim()) problems.push("authority.issuer is required and must be non-empty — an unattributed dataset cannot support a statutory claim");
|
|
62
|
+
if (!authority || !authority.reference?.trim()) problems.push("authority.reference is required and must be non-empty — cite the instrument, not the tool that relayed it");
|
|
63
|
+
if (authority?.publishedOn !== void 0 && !isCivilDate(authority.publishedOn)) problems.push(`authority.publishedOn must be a civil date (YYYY-MM-DD); got "${String(authority.publishedOn)}"`);
|
|
64
|
+
const from = coverage?.from;
|
|
65
|
+
const through = coverage?.through;
|
|
66
|
+
if (!isCivilDate(from)) problems.push(`coverage.from must be a civil date (YYYY-MM-DD); got "${String(from)}"`);
|
|
67
|
+
if (!isCivilDate(through)) problems.push(`coverage.through must be a civil date (YYYY-MM-DD); got "${String(through)}"`);
|
|
68
|
+
if (isCivilDate(from) && isCivilDate(through)) {
|
|
69
|
+
if (civilDaysBetween(from, through) < 0) problems.push(`coverage.from (${from}) is after coverage.through (${through})`);
|
|
70
|
+
if (YEAR_PATTERN.test(year)) {
|
|
71
|
+
if (from.slice(0, 4) !== year || through.slice(0, 4) !== year) problems.push(`edition key "${year}" disagrees with its coverage (${from} … ${through}) — an edition spans exactly one year`);
|
|
72
|
+
else if (!allowPartialYear && (from !== `${year}-01-01` || through !== `${year}-12-31`)) problems.push(`coverage (${from} … ${through}) is narrower than the full year "${year}". An annual gazette covers the whole year, so this is usually an unfinished import; pass allowPartialYear if it is deliberate`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const built = {};
|
|
76
|
+
const seen = /* @__PURE__ */ new Set();
|
|
77
|
+
for (const [index, row] of dates.entries()) {
|
|
78
|
+
if (!isCivilDate(row?.date)) {
|
|
79
|
+
problems.push(`dates[${index}].date must be a civil date (YYYY-MM-DD); got "${String(row?.date)}"`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (seen.has(row.date)) {
|
|
83
|
+
/**
|
|
84
|
+
* A duplicate is REFUSED, never last-write-wins. Two rows for one date disagree about something,
|
|
85
|
+
* and quietly keeping one drops a fact an operator entered on purpose.
|
|
86
|
+
*/
|
|
87
|
+
problems.push(`dates[${index}] repeats ${row.date} — a date may be listed once per edition`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (isCivilDate(from) && isCivilDate(through)) {
|
|
91
|
+
if (civilDaysBetween(from, row.date) < 0 || civilDaysBetween(row.date, through) < 0) {
|
|
92
|
+
problems.push(`dates[${index}] (${row.date}) falls outside the declared coverage ${from} … ${through}`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (row.entry === null || typeof row.entry !== "object") {
|
|
97
|
+
problems.push(`dates[${index}].entry must be an object; got ${String(row.entry)}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
seen.add(row.date);
|
|
101
|
+
built[row.date] = row.entry;
|
|
102
|
+
}
|
|
103
|
+
if (problems.length > 0) throw new InvalidDateEditionError(year, problems);
|
|
104
|
+
return {
|
|
105
|
+
coverage: {
|
|
106
|
+
from,
|
|
107
|
+
through
|
|
108
|
+
},
|
|
109
|
+
authority,
|
|
110
|
+
dates: built
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Thrown by {@link defineDateFactCalendar}. */
|
|
114
|
+
var InvalidDateFactCalendarError = class extends Error {
|
|
115
|
+
problems;
|
|
116
|
+
constructor(problems) {
|
|
117
|
+
super(`[primitives] Cannot define a date-fact calendar — ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join("\n"));
|
|
118
|
+
this.name = "InvalidDateFactCalendarError";
|
|
119
|
+
this.problems = problems;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Build a validated calendar. **The timezone is checked here, not trusted.**
|
|
124
|
+
*
|
|
125
|
+
* A civil date has no meaning without a zone, and a WRONG zone is worse than a missing one: it shifts
|
|
126
|
+
* every boundary by the offset while every value still looks like a date. Asia/Dhaka is UTC+6, so a
|
|
127
|
+
* calendar read in UTC disagrees with the business day for six hours at both ends — the same failure
|
|
128
|
+
* that once reported August revenue as July for the last six hours of every month.
|
|
129
|
+
*
|
|
130
|
+
* Also re-checks each edition's key against its own coverage. `defineDateEdition` already enforces that,
|
|
131
|
+
* but a calendar can be assembled from editions that were built elsewhere or filed under the wrong key,
|
|
132
|
+
* and a misfiled edition makes every lookup in its year report `missing-edition` while the data sits one
|
|
133
|
+
* key away.
|
|
134
|
+
*/
|
|
135
|
+
function defineDateFactCalendar(input) {
|
|
136
|
+
const problems = [];
|
|
137
|
+
if (typeof input.timeZone !== "string" || input.timeZone.length === 0) problems.push("timeZone is required — a civil date has no meaning without the zone it is civil in");
|
|
138
|
+
else if (!isValidTimeZone(input.timeZone)) problems.push(`timeZone "${input.timeZone}" is not a recognised IANA zone. A wrong zone shifts every boundary by its offset while every value still looks like a valid date`);
|
|
139
|
+
for (const [key, edition] of Object.entries(input.editions)) {
|
|
140
|
+
if (!YEAR_PATTERN.test(key)) {
|
|
141
|
+
problems.push(`edition key "${key}" must be YYYY`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (edition.coverage.from.slice(0, 4) !== key || edition.coverage.through.slice(0, 4) !== key) problems.push(`edition "${key}" is filed under a year its coverage (${edition.coverage.from} … ${edition.coverage.through}) does not match — every lookup in ${key} would report missing-edition`);
|
|
145
|
+
}
|
|
146
|
+
if (problems.length > 0) throw new InvalidDateFactCalendarError(problems);
|
|
147
|
+
return {
|
|
148
|
+
timeZone: input.timeZone,
|
|
149
|
+
editions: input.editions
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Look up one civil date. NEVER falls back to "not listed" for data that is absent rather than empty.
|
|
154
|
+
*
|
|
155
|
+
* Order matters: the edition is resolved first, then coverage, then membership. Checking membership
|
|
156
|
+
* first would answer `not-listed` for a year nobody has entered — which is the whole bug.
|
|
157
|
+
*/
|
|
158
|
+
function lookupDateFact(calendar, date) {
|
|
159
|
+
const edition = calendar.editions[date.slice(0, 4)];
|
|
160
|
+
if (edition === void 0) return {
|
|
161
|
+
kind: "unknown",
|
|
162
|
+
reason: "missing-edition"
|
|
163
|
+
};
|
|
164
|
+
const { from, through } = edition.coverage;
|
|
165
|
+
if (civilDaysBetween(from, date) < 0 || civilDaysBetween(date, through) < 0) return {
|
|
166
|
+
kind: "unknown",
|
|
167
|
+
reason: "outside-coverage"
|
|
168
|
+
};
|
|
169
|
+
const entry = edition.dates[date];
|
|
170
|
+
return entry === void 0 ? { kind: "not-listed" } : {
|
|
171
|
+
kind: "listed",
|
|
172
|
+
entry
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Look up the civil date an INSTANT falls on, resolved in the CALENDAR'S zone.
|
|
177
|
+
*
|
|
178
|
+
* Prefer this over converting yourself. `lookupDateFact` takes a `CivilDate`, and producing one from an
|
|
179
|
+
* instant requires a zone — if the caller picks its own (the server's, or UTC) it will disagree with the
|
|
180
|
+
* calendar near midnight and be wrong by a whole day at the boundary. The calendar already knows the
|
|
181
|
+
* only correct zone, so the conversion belongs here rather than at every call site.
|
|
182
|
+
*/
|
|
183
|
+
function lookupDateFactAt(calendar, instant) {
|
|
184
|
+
return lookupDateFact(calendar, civilDateOf(instant, calendar.timeZone));
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
export { InvalidDateEditionError, InvalidDateFactCalendarError, defineDateEdition, defineDateFactCalendar, lookupDateFact, lookupDateFactAt };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//#region src/composition/fault-attribution.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fault attribution value object + pure helpers.
|
|
4
|
+
*
|
|
5
|
+
* Answers ONE question every domain eventually asks: **when something goes
|
|
6
|
+
* wrong, who bears the cost?** A returned item's shipping, a redelivery after a
|
|
7
|
+
* failed drop, a re-issued card, an SLA credit, a scrapped inbound transfer —
|
|
8
|
+
* all the same shape: a reason code implies a responsible party, and whoever is
|
|
9
|
+
* responsible pays.
|
|
10
|
+
*
|
|
11
|
+
* The primitive is *pure data + pure helpers* — no persistence, no events, and
|
|
12
|
+
* deliberately NO vocabulary of its own. Each domain owns its reason codes and
|
|
13
|
+
* supplies a `FaultPolicy` mapping them to parties; the primitive owns the
|
|
14
|
+
* attribution RULES, which are what actually drift between implementations.
|
|
15
|
+
*
|
|
16
|
+
* Consumers: `@classytic/order` (RMA return-shipping liability),
|
|
17
|
+
* `@classytic/flow` (damaged inbound), `@classytic/carrier` (failed delivery
|
|
18
|
+
* attempts), and any SLA credit built on `primitives/sla-policy`.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this exists as a primitive
|
|
21
|
+
*
|
|
22
|
+
* `@classytic/order` documented "`merchantPaysReturnShipping` defaults from the
|
|
23
|
+
* line-level reasons" and then shipped `input.merchantPaysReturnShipping ??
|
|
24
|
+
* false`. The lookup never ran, so a `damaged_in_transit` return — squarely the
|
|
25
|
+
* merchant's fault — billed the CUSTOMER for return shipping. Nothing threw;
|
|
26
|
+
* the field held a plausible boolean.
|
|
27
|
+
*
|
|
28
|
+
* A boolean is the wrong type for this. `false` cannot distinguish "the
|
|
29
|
+
* customer is responsible" from "nobody has decided yet", so the unset case
|
|
30
|
+
* silently becomes a verdict against whoever `false` happens to mean. Modelling
|
|
31
|
+
* the PARTY, with `unknown` as a real member, makes that impossible to express
|
|
32
|
+
* by accident.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* import { attributeFault, bearsCost } from '@classytic/primitives/fault-attribution';
|
|
36
|
+
*
|
|
37
|
+
* const RETURN_FAULT = {
|
|
38
|
+
* defective: 'merchant',
|
|
39
|
+
* damaged_in_transit: 'merchant',
|
|
40
|
+
* changed_mind: 'customer',
|
|
41
|
+
* } as const;
|
|
42
|
+
*
|
|
43
|
+
* attributeFault(RETURN_FAULT, ['damaged_in_transit']); // 'merchant'
|
|
44
|
+
* attributeFault(RETURN_FAULT, ['changed_mind'], 'merchant'); // 'merchant' — explicit wins
|
|
45
|
+
* attributeFault(RETURN_FAULT, ['act_of_god']); // 'unknown' — NOT 'customer'
|
|
46
|
+
* bearsCost(attributeFault(RETURN_FAULT, ['defective']), 'merchant'); // true
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Who is responsible for the cost.
|
|
50
|
+
*
|
|
51
|
+
* `unknown` is a first-class member, not a gap. An unrecognised reason is an
|
|
52
|
+
* unanswered question, and answering it by default is how a system quietly
|
|
53
|
+
* bills the wrong party — the same asymmetry that makes a payment timeout
|
|
54
|
+
* `unknown` rather than `declined`.
|
|
55
|
+
*
|
|
56
|
+
* `shared` exists because real disputes settle that way (split shipping,
|
|
57
|
+
* goodwill on a customer-fault return). It is a DECISION, so it is only ever
|
|
58
|
+
* reachable through an explicit override — never inferred from a reason.
|
|
59
|
+
*/
|
|
60
|
+
type FaultParty = 'merchant' | 'customer' | 'carrier' | 'shared' | 'unknown';
|
|
61
|
+
/** Reason code → responsible party. The domain owns the keys. */
|
|
62
|
+
type FaultPolicy<TReason extends string = string> = Readonly<Partial<Record<TReason, FaultParty>>>;
|
|
63
|
+
/**
|
|
64
|
+
* Attribute fault across one or more reasons.
|
|
65
|
+
*
|
|
66
|
+
* Rules, in order:
|
|
67
|
+
*
|
|
68
|
+
* 1. **An explicit decision always wins.** A human or an upstream policy that
|
|
69
|
+
* said "the merchant covers this" outranks any reason lookup — a general
|
|
70
|
+
* default must never override a specific instruction.
|
|
71
|
+
* 2. **Merchant fault is sticky.** If ANY reason is the merchant's fault, the
|
|
72
|
+
* whole claim is: a customer returning three items, one of them defective,
|
|
73
|
+
* is not asked to part-pay the shipping. This is the single rule most likely
|
|
74
|
+
* to be re-implemented differently per call site, which is why it lives here.
|
|
75
|
+
* 3. **Carrier before customer.** A carrier-fault reason is not the customer's
|
|
76
|
+
* problem even though the merchant may recover it separately.
|
|
77
|
+
* 4. **Otherwise the reasons must AGREE.** Mixed or unmapped reasons yield
|
|
78
|
+
* `unknown` rather than a guess.
|
|
79
|
+
* 5. **No reasons at all is `unknown`**, never a party.
|
|
80
|
+
*/
|
|
81
|
+
declare function attributeFault<TReason extends string>(policy: FaultPolicy<TReason>, reasons: readonly TReason[], explicit?: FaultParty | undefined): FaultParty;
|
|
82
|
+
/**
|
|
83
|
+
* Does `party` bear the cost?
|
|
84
|
+
*
|
|
85
|
+
* `shared` bears it for every named party — a split is not an escape from
|
|
86
|
+
* paying. `unknown` bears nothing: an undecided claim must not auto-charge
|
|
87
|
+
* anyone, which is the whole reason `unknown` is modelled.
|
|
88
|
+
*/
|
|
89
|
+
declare function bearsCost(attributed: FaultParty, party: Exclude<FaultParty, 'unknown'>): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* True when attribution is still open and a human (or a later signal) must
|
|
92
|
+
* decide. Use it to route a claim to review instead of letting it settle at a
|
|
93
|
+
* default — an `unknown` that silently behaves like `customer` is the defect
|
|
94
|
+
* this primitive exists to prevent.
|
|
95
|
+
*/
|
|
96
|
+
declare function needsFaultReview(attributed: FaultParty): boolean;
|
|
97
|
+
//#endregion
|
|
98
|
+
export { FaultParty, FaultPolicy, attributeFault, bearsCost, needsFaultReview };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/composition/fault-attribution.ts
|
|
2
|
+
/**
|
|
3
|
+
* Attribute fault across one or more reasons.
|
|
4
|
+
*
|
|
5
|
+
* Rules, in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. **An explicit decision always wins.** A human or an upstream policy that
|
|
8
|
+
* said "the merchant covers this" outranks any reason lookup — a general
|
|
9
|
+
* default must never override a specific instruction.
|
|
10
|
+
* 2. **Merchant fault is sticky.** If ANY reason is the merchant's fault, the
|
|
11
|
+
* whole claim is: a customer returning three items, one of them defective,
|
|
12
|
+
* is not asked to part-pay the shipping. This is the single rule most likely
|
|
13
|
+
* to be re-implemented differently per call site, which is why it lives here.
|
|
14
|
+
* 3. **Carrier before customer.** A carrier-fault reason is not the customer's
|
|
15
|
+
* problem even though the merchant may recover it separately.
|
|
16
|
+
* 4. **Otherwise the reasons must AGREE.** Mixed or unmapped reasons yield
|
|
17
|
+
* `unknown` rather than a guess.
|
|
18
|
+
* 5. **No reasons at all is `unknown`**, never a party.
|
|
19
|
+
*/
|
|
20
|
+
function attributeFault(policy, reasons, explicit) {
|
|
21
|
+
if (explicit !== void 0) return explicit;
|
|
22
|
+
if (reasons.length === 0) return "unknown";
|
|
23
|
+
const parties = reasons.map((r) => policy[r] ?? "unknown");
|
|
24
|
+
if (parties.includes("merchant")) return "merchant";
|
|
25
|
+
if (parties.includes("carrier")) return "carrier";
|
|
26
|
+
const first = parties[0];
|
|
27
|
+
return parties.every((p) => p === first) ? first : "unknown";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Does `party` bear the cost?
|
|
31
|
+
*
|
|
32
|
+
* `shared` bears it for every named party — a split is not an escape from
|
|
33
|
+
* paying. `unknown` bears nothing: an undecided claim must not auto-charge
|
|
34
|
+
* anyone, which is the whole reason `unknown` is modelled.
|
|
35
|
+
*/
|
|
36
|
+
function bearsCost(attributed, party) {
|
|
37
|
+
if (attributed === "unknown") return false;
|
|
38
|
+
if (attributed === "shared") return true;
|
|
39
|
+
return attributed === party;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True when attribution is still open and a human (or a later signal) must
|
|
43
|
+
* decide. Use it to route a claim to review instead of letting it settle at a
|
|
44
|
+
* default — an `unknown` that silently behaves like `customer` is the defect
|
|
45
|
+
* this primitive exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
function needsFaultReview(attributed) {
|
|
48
|
+
return attributed === "unknown";
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { attributeFault, bearsCost, needsFaultReview };
|
package/dist/memory-outbox.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
|
|
2
|
-
import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
|
|
2
|
+
import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
|
|
3
3
|
//#region src/events/memory-outbox.d.ts
|
|
4
4
|
interface MemoryEntry {
|
|
5
5
|
event: DomainEvent;
|
|
@@ -7,6 +7,8 @@ interface MemoryEntry {
|
|
|
7
7
|
attempts: number;
|
|
8
8
|
visibleAt: number;
|
|
9
9
|
leaseOwner: string | null;
|
|
10
|
+
/** Fencing token — one per claim epoch of this event. */
|
|
11
|
+
fenceToken?: number;
|
|
10
12
|
leaseExpiresAt: number;
|
|
11
13
|
deliveredAt: number | null;
|
|
12
14
|
firstFailedAt: number | null;
|
|
@@ -44,6 +46,7 @@ declare class MemoryOutboxStore implements OutboxStore {
|
|
|
44
46
|
save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
|
|
45
47
|
getPending(limit: number): Promise<DomainEvent[]>;
|
|
46
48
|
claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
|
|
49
|
+
claimPendingFenced(options?: OutboxClaimOptions): Promise<OutboxClaimedEvent[]>;
|
|
47
50
|
acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
|
|
48
51
|
fail(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
|
|
49
52
|
getDeadLettered(limit: number): Promise<DeadLetteredEvent[]>;
|
package/dist/memory-outbox.mjs
CHANGED
|
@@ -53,6 +53,9 @@ var MemoryOutboxStore = class {
|
|
|
53
53
|
return this.entries.filter((e) => e.status === "pending" && e.visibleAt <= now && (e.leaseOwner === null || e.leaseExpiresAt <= now)).slice(0, limit).map((e) => e.event);
|
|
54
54
|
}
|
|
55
55
|
async claimPending(options) {
|
|
56
|
+
return (await this.claimPendingFenced(options)).map((c) => c.event);
|
|
57
|
+
}
|
|
58
|
+
async claimPendingFenced(options) {
|
|
56
59
|
const now = Date.now();
|
|
57
60
|
const limit = options?.limit ?? 100;
|
|
58
61
|
const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS;
|
|
@@ -68,7 +71,11 @@ var MemoryOutboxStore = class {
|
|
|
68
71
|
entry.leaseOwner = consumerId;
|
|
69
72
|
entry.leaseExpiresAt = now + leaseMs;
|
|
70
73
|
entry.attempts++;
|
|
71
|
-
|
|
74
|
+
entry.fenceToken = (entry.fenceToken ?? 0) + 1;
|
|
75
|
+
claimed.push({
|
|
76
|
+
event: entry.event,
|
|
77
|
+
fencingToken: entry.fenceToken
|
|
78
|
+
});
|
|
72
79
|
}
|
|
73
80
|
return claimed;
|
|
74
81
|
}
|
|
@@ -77,6 +84,7 @@ var MemoryOutboxStore = class {
|
|
|
77
84
|
if (!entry) return;
|
|
78
85
|
if (entry.status === "delivered") return;
|
|
79
86
|
if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
|
|
87
|
+
if (options?.fencingToken !== void 0 && options.fencingToken !== entry.fenceToken) throw new OutboxOwnershipError(eventId, `token ${options.fencingToken}`, `token ${entry.fenceToken}`);
|
|
80
88
|
entry.status = "delivered";
|
|
81
89
|
entry.deliveredAt = Date.now();
|
|
82
90
|
entry.leaseOwner = null;
|