@classytic/primitives 0.4.0 → 0.6.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 +162 -0
- package/dist/address.d.mts +1 -1
- package/dist/address.mjs +1 -1
- package/dist/approval.d.mts +1 -1
- package/dist/approval.mjs +1 -1
- package/dist/bank-transaction.d.mts +167 -0
- package/dist/bank-transaction.mjs +1 -0
- package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
- package/dist/brand.d.mts +1 -1
- package/dist/cadence.d.mts +1 -1
- package/dist/cadence.mjs +1 -1
- package/dist/condition.d.mts +76 -0
- package/dist/condition.mjs +131 -0
- package/dist/context.d.mts +1 -1
- package/dist/currency-B4rSdN7D.d.mts +110 -0
- package/dist/currency.d.mts +1 -109
- package/dist/currency.mjs +1 -1
- package/dist/events.d.mts +1 -1
- package/dist/events.mjs +1 -1
- package/dist/hold.d.mts +1 -1
- package/dist/hold.mjs +1 -1
- package/dist/mixin.d.mts +73 -0
- package/dist/mixin.mjs +47 -0
- package/dist/money.d.mts +2 -2
- package/dist/money.mjs +1 -1
- package/dist/outbox.d.mts +1 -1
- package/dist/outbox.mjs +1 -1
- package/dist/payment-gateway.d.mts +168 -0
- package/dist/payment-gateway.mjs +1 -0
- package/dist/period.d.mts +1 -1
- package/dist/period.mjs +1 -1
- package/dist/person.d.mts +1 -1
- package/dist/person.mjs +1 -1
- package/dist/phone.d.mts +47 -0
- package/dist/phone.mjs +310 -0
- package/dist/reference.d.mts +1 -1
- package/dist/reference.mjs +1 -1
- package/dist/result.d.mts +1 -1
- package/dist/result.mjs +1 -1
- package/dist/sla-policy.d.mts +116 -0
- package/dist/sla-policy.mjs +120 -0
- package/dist/sla.d.mts +1 -1
- package/dist/sla.mjs +1 -1
- package/dist/split-allocation.d.mts +1 -1
- package/dist/split-allocation.mjs +1 -1
- package/dist/state-machine.d.mts +1 -1
- package/dist/state-machine.mjs +1 -1
- package/dist/status-history.d.mts +86 -0
- package/dist/status-history.mjs +48 -0
- package/package.json +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,168 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.6.0] - 2026-05-17
|
|
7
|
+
|
|
8
|
+
### Added — five new primitives for CRM / workflow domains
|
|
9
|
+
|
|
10
|
+
These were extracted after deep-diving Twenty, Huly Platform, Frappe CRM,
|
|
11
|
+
and NocoBase. Each fills a gap the existing primitives didn't cover, and
|
|
12
|
+
each ships under its own flat subpath import (no breaking changes).
|
|
13
|
+
|
|
14
|
+
- **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
|
|
15
|
+
normalizes free-form input to E.164, captures the country calling code
|
|
16
|
+
as a discrete field, and reports failures via `Result<PhoneNumber,
|
|
17
|
+
PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
|
|
18
|
+
that want region-of-issue / line-type detection layer on top with
|
|
19
|
+
libphonenumber-js.
|
|
20
|
+
|
|
21
|
+
- **`/status-history`** — Generic, immutable status-change log:
|
|
22
|
+
`StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
|
|
23
|
+
`lastTransitionTo()`. Captures `durationInPriorMs` at write time
|
|
24
|
+
(Frappe's "status change log" pattern), so funnel-velocity dashboards
|
|
25
|
+
("avg time in Qualification") work without re-walking timestamps.
|
|
26
|
+
Pairs naturally with `/state-machine`.
|
|
27
|
+
|
|
28
|
+
- **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
|
|
29
|
+
(`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
|
|
30
|
+
/ `NotCondition`, plus a total `evaluate(condition, target)` and a
|
|
31
|
+
`validateCondition()` for boot-time schema checks. Powers SLA scope
|
|
32
|
+
filters, drip "if opened/clicked" branches, automation triggers, and
|
|
33
|
+
permission row-scopes — anywhere a predicate needs to round-trip
|
|
34
|
+
through a database.
|
|
35
|
+
|
|
36
|
+
- **`/mixin`** — Additive type composition without subclassing:
|
|
37
|
+
`withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
|
|
38
|
+
Lets a `Contact` carry per-domain payloads (`mixins.customer`,
|
|
39
|
+
`mixins.lead-fit`) on a typed `mixins` namespace without bloating the
|
|
40
|
+
base type. Distilled from Huly's mixin idea minus the decorators,
|
|
41
|
+
global hierarchy, and proxies.
|
|
42
|
+
|
|
43
|
+
- **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
|
|
44
|
+
priority matrix (`urgent` / `high` / `normal` → durations), first-
|
|
45
|
+
response vs rolling-response semantics, and working-hours window
|
|
46
|
+
(weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
|
|
47
|
+
at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
|
|
48
|
+
remainingMs, breached }` from a policy + per-entity inputs. The
|
|
49
|
+
existing simple `/sla` interface is unchanged — `sla-policy` derives
|
|
50
|
+
concrete `SLA` instances from a policy + priority and delegates to it.
|
|
51
|
+
|
|
52
|
+
### Changed — source-tree reorganization (no public-surface impact)
|
|
53
|
+
|
|
54
|
+
The 24 primitive source files moved from a flat `src/*.ts` layout into
|
|
55
|
+
six category folders so the package is self-documenting at the file
|
|
56
|
+
system level:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
src/
|
|
60
|
+
money/ money, currency, split-allocation, bank-transaction, payment-gateway
|
|
61
|
+
identity/ person, address, phone
|
|
62
|
+
scheduling/ period, cadence, sla, sla-policy
|
|
63
|
+
workflow/ state-machine, status-history, approval, hold, condition
|
|
64
|
+
events/ events, outbox
|
|
65
|
+
composition/ mixin, reference, context, brand, result
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Public subpath imports are unchanged** — `@classytic/primitives/money`,
|
|
69
|
+
`@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
|
|
70
|
+
to `dist/<name>.mjs` regardless of source nesting. Existing consumers
|
|
71
|
+
upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
|
|
72
|
+
diff.
|
|
73
|
+
|
|
74
|
+
### Dependencies
|
|
75
|
+
|
|
76
|
+
Still zero runtime deps. Node 22+.
|
|
77
|
+
|
|
78
|
+
## [0.5.0] - 2026-05-05
|
|
79
|
+
|
|
80
|
+
### Added — `/bank-transaction` subpath
|
|
81
|
+
|
|
82
|
+
Canonical bank-feed shapes shared across `@classytic/fin-io` (parsers
|
|
83
|
+
produce these), `@classytic/revenue` (`transactionRepository.import()`
|
|
84
|
+
consumes them), and `@classytic/ledger` (`bankStatementMapper`
|
|
85
|
+
consumes them). Single source of truth for the cross-package
|
|
86
|
+
vocabulary; eliminates the structural mirrors fin-io and revenue
|
|
87
|
+
were carrying.
|
|
88
|
+
|
|
89
|
+
Exports: `BankTransaction`, `BankCounterparty`, `BankAccount`,
|
|
90
|
+
`BankStatement`, `BankStatementSource`, `BankImportReport`,
|
|
91
|
+
`BankImportRowError`. All anchored on `Money` from `/money` (integer
|
|
92
|
+
minor units, `number`).
|
|
93
|
+
|
|
94
|
+
### Added — `/payment-gateway` subpath
|
|
95
|
+
|
|
96
|
+
Canonical payment-gateway data shapes. **Lets any provider package
|
|
97
|
+
(Stripe, Razorpay, SSLCommerz, PayPal, bKash, Nagad, manual, …)
|
|
98
|
+
implement against primitives' types without depending on revenue's
|
|
99
|
+
heavyweight runtime.** Provider authors peer-dep on
|
|
100
|
+
`@classytic/primitives` only.
|
|
101
|
+
|
|
102
|
+
Exports: `CreateIntentParams`, `PaymentIntent`, `PaymentResult`,
|
|
103
|
+
`RefundResult`, `WebhookEvent`, `ProviderCapabilities`. All `amount`
|
|
104
|
+
fields are `Money` from `/money` (no flat `amount: number, currency:
|
|
105
|
+
string` pairs — currency travels inside Money).
|
|
106
|
+
|
|
107
|
+
The `PaymentProvider` abstract class — the *contract* every provider
|
|
108
|
+
must satisfy — stays in `@classytic/revenue`. Revenue is the data
|
|
109
|
+
engine that consumes providers; provider packages live elsewhere.
|
|
110
|
+
|
|
111
|
+
### Architectural intent
|
|
112
|
+
|
|
113
|
+
Revenue 3.0 + primitives 0.5 + fin-io 0.3 establish a clean
|
|
114
|
+
three-tier seam:
|
|
115
|
+
|
|
116
|
+
1. **primitives** — pure data shapes (no runtime, no deps). Owns
|
|
117
|
+
`Money`, `BankTransaction`, `PaymentIntent`, every cross-
|
|
118
|
+
package vocabulary type.
|
|
119
|
+
2. **revenue** — the engine. Owns the abstract contracts
|
|
120
|
+
(`PaymentProvider`, `BankFeedProvider`), state machines, repos.
|
|
121
|
+
Consumes primitives shapes.
|
|
122
|
+
3. **provider packages** (`revenue-stripe`, `revenue-razorpay`,
|
|
123
|
+
`@classytic/fin-io/adapters/revenue`, …) — implementations.
|
|
124
|
+
Peer-dep on primitives. Optional peer-dep on revenue for the
|
|
125
|
+
abstract base class.
|
|
126
|
+
|
|
127
|
+
This eliminates the cycle where every provider package transitively
|
|
128
|
+
pulled mongoose + mongokit just to know what a `PaymentIntent`
|
|
129
|
+
looked like. Tree-shaking, build times, and dep-graph honesty all
|
|
130
|
+
improve.
|
|
131
|
+
|
|
132
|
+
### Migration
|
|
133
|
+
|
|
134
|
+
Consumers MUST import from primitives subpaths. No re-exports from
|
|
135
|
+
revenue or fin-io (PACKAGE_RULES P2):
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Before
|
|
139
|
+
import type { PaymentIntent, CreateIntentParams } from '@classytic/revenue';
|
|
140
|
+
import type { CanonicalTransaction, Money } from '@classytic/fin-io';
|
|
141
|
+
|
|
142
|
+
// After
|
|
143
|
+
import type {
|
|
144
|
+
PaymentIntent,
|
|
145
|
+
CreateIntentParams,
|
|
146
|
+
} from '@classytic/primitives/payment-gateway';
|
|
147
|
+
import type { BankTransaction } from '@classytic/primitives/bank-transaction';
|
|
148
|
+
import type { Money } from '@classytic/primitives/money';
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Provider helper classes (`PaymentIntent`, `PaymentResult`,
|
|
152
|
+
`RefundResult`, `WebhookEvent`) are deleted from revenue — they were
|
|
153
|
+
trivial wrappers around plain objects. Provider implementations now
|
|
154
|
+
return plain object literals matching the interface.
|
|
155
|
+
|
|
156
|
+
`CreateIntentParams.amount` is now `Money` (`{ amount: number,
|
|
157
|
+
currency: string }`), not the flat `amount + currency` pair. Revenue's
|
|
158
|
+
repository wraps internally:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const intent = await provider.createIntent({
|
|
162
|
+
amount: { amount: params.amount, currency },
|
|
163
|
+
metadata: params.metadata,
|
|
164
|
+
...params.paymentData,
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
6
168
|
## [0.3.1] - 2026-05-02
|
|
7
169
|
|
|
8
170
|
### Added — state-machine ↔ mongokit/sqlitekit integration
|
package/dist/address.d.mts
CHANGED
package/dist/address.mjs
CHANGED
package/dist/approval.d.mts
CHANGED
package/dist/approval.mjs
CHANGED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Money } from "./money.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/money/bank-transaction.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The other side of a bank transaction. All fields optional — different
|
|
6
|
+
* formats populate different subsets:
|
|
7
|
+
* OFX usually carries `name` only.
|
|
8
|
+
* CAMT carries IBAN + BIC + name (SEPA structured data).
|
|
9
|
+
* Plaid carries `merchant_name` mapped to `name`.
|
|
10
|
+
* CSV varies; usually `name` + maybe `accountNumber`.
|
|
11
|
+
*/
|
|
12
|
+
interface BankCounterparty {
|
|
13
|
+
/** Free-text name as the bank emits it. */
|
|
14
|
+
name?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Vendor-stable identifier — bank's own customer ID, structured
|
|
17
|
+
* remittance reference (ISO 11649), etc. Distinct from the
|
|
18
|
+
* transaction-level `reference`.
|
|
19
|
+
*/
|
|
20
|
+
identifier?: string;
|
|
21
|
+
/** International Bank Account Number (SEPA / EU). */
|
|
22
|
+
iban?: string;
|
|
23
|
+
/** Domestic account number. */
|
|
24
|
+
accountNumber?: string;
|
|
25
|
+
/** Bank / SWIFT BIC. */
|
|
26
|
+
bic?: string;
|
|
27
|
+
/** US ABA routing / Canadian transit. */
|
|
28
|
+
routingNumber?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Identifies the account a statement / sync batch is for. Different
|
|
32
|
+
* formats populate different subsets — consumers should treat every
|
|
33
|
+
* field except `currency` as best-effort.
|
|
34
|
+
*/
|
|
35
|
+
interface BankAccount {
|
|
36
|
+
iban?: string;
|
|
37
|
+
accountNumber?: string;
|
|
38
|
+
/** US ABA / SWIFT BIC / domestic bank code. Generic — format-specific. */
|
|
39
|
+
bankCode?: string;
|
|
40
|
+
/** Human-readable bank name. */
|
|
41
|
+
bankName?: string;
|
|
42
|
+
/** Account holder name as the bank emits it. */
|
|
43
|
+
ownerName?: string;
|
|
44
|
+
/** Type as the bank classifies it: `'CHECKING'`, `'SAVINGS'`, `'CREDITLINE'`. */
|
|
45
|
+
accountType?: string;
|
|
46
|
+
/**
|
|
47
|
+
* ISO 4217 of the account itself. REQUIRED — synthesized from format
|
|
48
|
+
* defaults if absent (parsers MUST fill this in).
|
|
49
|
+
*/
|
|
50
|
+
currency: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One row from a bank statement / accounting feed sync.
|
|
54
|
+
*
|
|
55
|
+
* `externalId` is REQUIRED — without it idempotent re-import is broken.
|
|
56
|
+
* Synthesize one from `(date | amount | description hash)` if the
|
|
57
|
+
* upstream format lacks a vendor-stable id.
|
|
58
|
+
*/
|
|
59
|
+
interface BankTransaction {
|
|
60
|
+
/**
|
|
61
|
+
* Vendor-stable unique identifier. REQUIRED.
|
|
62
|
+
*
|
|
63
|
+
* OFX: `<FITID>` from the source file.
|
|
64
|
+
* CAMT: `<NtryRef>` or `<AcctSvcrRef>`.
|
|
65
|
+
* QBO: `Id` from the QBO API.
|
|
66
|
+
* Plaid: `transaction_id`.
|
|
67
|
+
* CSV: synthesized as `sha256(date|amount|description)` truncated
|
|
68
|
+
* to 16 hex chars; parser should emit a warning.
|
|
69
|
+
*
|
|
70
|
+
* Used by consumers to enforce idempotent re-import via
|
|
71
|
+
* `(orgId, bankAccountId, externalId)` partial-unique indexes.
|
|
72
|
+
* Distinct from any host-chosen request-scoped idempotency key.
|
|
73
|
+
*/
|
|
74
|
+
externalId: string;
|
|
75
|
+
/** When the bank booked the entry. */
|
|
76
|
+
postedDate: Date;
|
|
77
|
+
/** When funds become available (cleared). May equal `postedDate`. */
|
|
78
|
+
valueDate?: Date;
|
|
79
|
+
/**
|
|
80
|
+
* Signed amount in integer minor units. Positive = inflow to the
|
|
81
|
+
* account holder, negative = outflow. Currency carried inside `Money`.
|
|
82
|
+
*/
|
|
83
|
+
amount: Money;
|
|
84
|
+
/** Free-text description as the bank emitted it. */
|
|
85
|
+
description: string;
|
|
86
|
+
/** Counterparty (payer / payee) when the format provides one. */
|
|
87
|
+
counterparty?: BankCounterparty;
|
|
88
|
+
/** Bank-provided reference number, check number, end-to-end id. */
|
|
89
|
+
reference?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Bank-provided category if any. Rare in OFX / CAMT, common in
|
|
92
|
+
* Plaid and consumer-finance CSV (Mint / YNAB).
|
|
93
|
+
*/
|
|
94
|
+
category?: string;
|
|
95
|
+
/** Running balance on the account after this entry, if available. */
|
|
96
|
+
balanceAfter?: Money;
|
|
97
|
+
/** Format-specific entry-type code (`'DEBIT'`, `'CREDIT'`, `'CHECK'`, …). */
|
|
98
|
+
type?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Format-specific raw record. Pass-through for audit / debugging.
|
|
101
|
+
* Consumers MUST NOT rely on this — schema may change between fin-io
|
|
102
|
+
* minor versions. Discard before persistence if size is a concern.
|
|
103
|
+
*/
|
|
104
|
+
raw?: unknown;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Aggregate envelope produced by file-format parsers (OFX, CAMT.053,
|
|
108
|
+
* MT940, CSV, IIF). Sync providers (Plaid CDC, QBO CDC) emit
|
|
109
|
+
* `BankTransaction[]` directly without the statement wrapper because
|
|
110
|
+
* sync is continuous, not statement-scoped.
|
|
111
|
+
*/
|
|
112
|
+
interface BankStatement {
|
|
113
|
+
account: BankAccount;
|
|
114
|
+
/** Statement period the parser observed. */
|
|
115
|
+
period: {
|
|
116
|
+
from: Date;
|
|
117
|
+
to: Date;
|
|
118
|
+
};
|
|
119
|
+
/** Opening balance at `period.from`. */
|
|
120
|
+
openingBalance: Money;
|
|
121
|
+
/** Closing balance at `period.to`. */
|
|
122
|
+
closingBalance: Money;
|
|
123
|
+
/**
|
|
124
|
+
* Available balance at statement end (may differ from
|
|
125
|
+
* `closingBalance` for pending-but-not-cleared items).
|
|
126
|
+
*/
|
|
127
|
+
availableBalance?: Money;
|
|
128
|
+
transactions: BankTransaction[];
|
|
129
|
+
/** Provenance metadata — which format produced this statement. */
|
|
130
|
+
source: BankStatementSource;
|
|
131
|
+
}
|
|
132
|
+
interface BankStatementSource {
|
|
133
|
+
/**
|
|
134
|
+
* Format the statement was parsed from. The set is closed —
|
|
135
|
+
* consumers can switch on it exhaustively. Add new variants here
|
|
136
|
+
* (and bump the package minor version) when adding a new parser.
|
|
137
|
+
*/
|
|
138
|
+
format: 'ofx' | 'qfx' | 'camt053' | 'mt940' | 'csv' | 'iif' | 'xero-csv' | 'plaid' | 'qbo';
|
|
139
|
+
/** Format version — `'OFX 2.1.1'`, `'CAMT.053.001.08'`, etc. */
|
|
140
|
+
version?: string;
|
|
141
|
+
/** When the file was generated by the bank, if present. */
|
|
142
|
+
generatedAt?: Date;
|
|
143
|
+
/** Bank / vendor software fingerprint (e.g. OFX FI ORG). */
|
|
144
|
+
softwareId?: string;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Per-row failure during a bulk import. Consumers (revenue's `import()`)
|
|
148
|
+
* collect these without aborting the batch.
|
|
149
|
+
*/
|
|
150
|
+
interface BankImportRowError {
|
|
151
|
+
externalId: string;
|
|
152
|
+
reason: string;
|
|
153
|
+
row?: BankTransaction;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Outcome of a bulk import. Same shape ledger's `wireImport` reports
|
|
157
|
+
* for symmetry.
|
|
158
|
+
*/
|
|
159
|
+
interface BankImportReport {
|
|
160
|
+
inserted: number;
|
|
161
|
+
updated: number;
|
|
162
|
+
skipped: number;
|
|
163
|
+
errors: BankImportRowError[];
|
|
164
|
+
durationMs: number;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { BankAccount, BankCounterparty, BankImportReport, BankImportRowError, BankStatement, BankStatementSource, BankTransaction };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/brand.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-
|
|
1
|
+
import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-LLXqoh5J.mjs";
|
|
2
2
|
export { Brand, DeepPartial, DeepReadonly, KeysMatching, NonEmptyArray, Nullable, OptionalKeys, Prettify, RequireKeys };
|
package/dist/cadence.d.mts
CHANGED
package/dist/cadence.mjs
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/workflow/condition.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Tiny JSON-serializable predicate DSL + evaluator.
|
|
4
|
+
*
|
|
5
|
+
* Hosts need to express "when this is true, do that" rules in places that
|
|
6
|
+
* can't carry code — SLA scope filters, drip-sequence branches ("if opened
|
|
7
|
+
* within 24h"), automation triggers, permission row-scopes. A 60-line
|
|
8
|
+
* predicate spec covers every realistic case better than a hand-rolled
|
|
9
|
+
* SQL-like grammar.
|
|
10
|
+
*
|
|
11
|
+
* Two layers:
|
|
12
|
+
* 1. `Condition` — pure data. JSON-safe. Round-trips through DB.
|
|
13
|
+
* 2. `evaluate(condition, value)` — pure function. Walks a dotted-path
|
|
14
|
+
* expression on `value`, applies the comparator, returns boolean.
|
|
15
|
+
*
|
|
16
|
+
* No regex parsing, no AST tooling, no string interpolation — the grammar
|
|
17
|
+
* is structurally typed and the evaluator is a 30-line switch.
|
|
18
|
+
*
|
|
19
|
+
* @example Single comparison
|
|
20
|
+
* evaluate({ field: 'status', op: 'eq', value: 'won' }, opportunity);
|
|
21
|
+
*
|
|
22
|
+
* @example Composite — drip "if opened in last 7d AND not clicked"
|
|
23
|
+
* evaluate(
|
|
24
|
+
* {
|
|
25
|
+
* all: [
|
|
26
|
+
* { field: 'lastOpenedAt', op: 'gte', value: sevenDaysAgo },
|
|
27
|
+
* { field: 'lastClickedAt', op: 'isNull' },
|
|
28
|
+
* ],
|
|
29
|
+
* },
|
|
30
|
+
* contactEngagement,
|
|
31
|
+
* );
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Comparators. Kept small — every operator here has a single, unambiguous
|
|
35
|
+
* semantic across types. `in` accepts an array; `contains` accepts a single
|
|
36
|
+
* scalar against an array field; `startsWith`/`endsWith` are string-only;
|
|
37
|
+
* `isNull` / `isNotNull` have no `value`.
|
|
38
|
+
*/
|
|
39
|
+
type Comparator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'startsWith' | 'endsWith' | 'isNull' | 'isNotNull';
|
|
40
|
+
/** Atomic predicate: extract `field` from the target, compare to `value`. */
|
|
41
|
+
interface FieldCondition {
|
|
42
|
+
/** Dotted path: `'contact.email'`, `'metadata.utm.source'`, etc. */
|
|
43
|
+
readonly field: string;
|
|
44
|
+
readonly op: Comparator;
|
|
45
|
+
/** Comparand. Omitted for `isNull` / `isNotNull`. */
|
|
46
|
+
readonly value?: unknown;
|
|
47
|
+
}
|
|
48
|
+
/** All branches must be true. */
|
|
49
|
+
interface AllCondition {
|
|
50
|
+
readonly all: readonly Condition[];
|
|
51
|
+
}
|
|
52
|
+
/** Any branch true short-circuits true. */
|
|
53
|
+
interface AnyCondition {
|
|
54
|
+
readonly any: readonly Condition[];
|
|
55
|
+
}
|
|
56
|
+
/** Inverts the wrapped condition. */
|
|
57
|
+
interface NotCondition {
|
|
58
|
+
readonly not: Condition;
|
|
59
|
+
}
|
|
60
|
+
type Condition = FieldCondition | AllCondition | AnyCondition | NotCondition;
|
|
61
|
+
/** Evaluate a `Condition` against `target`. Total — never throws on shape. */
|
|
62
|
+
declare function evaluate(condition: Condition, target: unknown): boolean;
|
|
63
|
+
type ConditionErrorCode = 'EMPTY' | 'UNKNOWN_SHAPE' | 'INVALID_OP' | 'MISSING_VALUE' | 'EMPTY_GROUP';
|
|
64
|
+
declare class ConditionError extends Error {
|
|
65
|
+
readonly name = "ConditionError";
|
|
66
|
+
readonly code: ConditionErrorCode;
|
|
67
|
+
constructor(code: ConditionErrorCode, message: string);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate a condition shape before persisting. Recurses through composites.
|
|
71
|
+
* Throws `ConditionError` so hosts get a stable code + message in API
|
|
72
|
+
* responses (`{ code: 'INVALID_OP', message: '...' }`).
|
|
73
|
+
*/
|
|
74
|
+
declare function validateCondition(condition: Condition): void;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { AllCondition, AnyCondition, Comparator, Condition, ConditionError, ConditionErrorCode, FieldCondition, NotCondition, evaluate, validateCondition };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//#region src/workflow/condition.ts
|
|
2
|
+
function isFieldCondition(c) {
|
|
3
|
+
return typeof c.field === "string" && typeof c.op === "string";
|
|
4
|
+
}
|
|
5
|
+
function isAllCondition(c) {
|
|
6
|
+
return Array.isArray(c.all);
|
|
7
|
+
}
|
|
8
|
+
function isAnyCondition(c) {
|
|
9
|
+
return Array.isArray(c.any);
|
|
10
|
+
}
|
|
11
|
+
function isNotCondition(c) {
|
|
12
|
+
return typeof c.not === "object" && c.not !== null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Walk a dotted path on `target`. Returns `undefined` when any segment
|
|
16
|
+
* doesn't exist. Treats `null` as a terminal (doesn't try to recurse on it).
|
|
17
|
+
*/
|
|
18
|
+
function getPath(target, path) {
|
|
19
|
+
if (path === "" || target === null || target === void 0) return void 0;
|
|
20
|
+
let cursor = target;
|
|
21
|
+
for (const segment of path.split(".")) {
|
|
22
|
+
if (cursor === null || cursor === void 0) return void 0;
|
|
23
|
+
if (typeof cursor !== "object") return void 0;
|
|
24
|
+
cursor = cursor[segment];
|
|
25
|
+
}
|
|
26
|
+
return cursor;
|
|
27
|
+
}
|
|
28
|
+
/** Evaluate a `Condition` against `target`. Total — never throws on shape. */
|
|
29
|
+
function evaluate(condition, target) {
|
|
30
|
+
if (isAllCondition(condition)) return condition.all.every((c) => evaluate(c, target));
|
|
31
|
+
if (isAnyCondition(condition)) return condition.any.some((c) => evaluate(c, target));
|
|
32
|
+
if (isNotCondition(condition)) return !evaluate(condition.not, target);
|
|
33
|
+
if (isFieldCondition(condition)) return evaluateField(condition, target);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
function evaluateField(c, target) {
|
|
37
|
+
const lhs = getPath(target, c.field);
|
|
38
|
+
const rhs = c.value;
|
|
39
|
+
switch (c.op) {
|
|
40
|
+
case "eq": return strictEqual(lhs, rhs);
|
|
41
|
+
case "neq": return !strictEqual(lhs, rhs);
|
|
42
|
+
case "gt": return compare(lhs, rhs) > 0;
|
|
43
|
+
case "gte": return compare(lhs, rhs) >= 0;
|
|
44
|
+
case "lt": return compare(lhs, rhs) < 0;
|
|
45
|
+
case "lte": return compare(lhs, rhs) <= 0;
|
|
46
|
+
case "in": return Array.isArray(rhs) && rhs.some((v) => strictEqual(lhs, v));
|
|
47
|
+
case "nin": return Array.isArray(rhs) && !rhs.some((v) => strictEqual(lhs, v));
|
|
48
|
+
case "contains": return Array.isArray(lhs) && lhs.some((v) => strictEqual(v, rhs));
|
|
49
|
+
case "startsWith": return typeof lhs === "string" && typeof rhs === "string" && lhs.startsWith(rhs);
|
|
50
|
+
case "endsWith": return typeof lhs === "string" && typeof rhs === "string" && lhs.endsWith(rhs);
|
|
51
|
+
case "isNull": return lhs === null || lhs === void 0;
|
|
52
|
+
case "isNotNull": return lhs !== null && lhs !== void 0;
|
|
53
|
+
default: return ((_) => false)(c.op);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Equality with `Date` awareness. `===` on two `Date` objects with the same
|
|
58
|
+
* millis returns `false`; this normalizes them. Plain primitives use `===`.
|
|
59
|
+
*/
|
|
60
|
+
function strictEqual(a, b) {
|
|
61
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
62
|
+
if (a instanceof Date && typeof b === "string") return a.toISOString() === b;
|
|
63
|
+
if (typeof a === "string" && b instanceof Date) return a === b.toISOString();
|
|
64
|
+
return a === b;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Numeric / chronological comparison. Returns `NaN` (which `compare()`
|
|
68
|
+
* callers handle by returning false for gt/lt/gte/lte) when operands aren't
|
|
69
|
+
* comparable. Dates are compared by their unix timestamp.
|
|
70
|
+
*/
|
|
71
|
+
function compare(a, b) {
|
|
72
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
73
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
74
|
+
if (typeof av === "number" && typeof bv === "number") return av - bv;
|
|
75
|
+
if (typeof av === "string" && typeof bv === "string") return av < bv ? -1 : av > bv ? 1 : 0;
|
|
76
|
+
return NaN;
|
|
77
|
+
}
|
|
78
|
+
var ConditionError = class extends Error {
|
|
79
|
+
name = "ConditionError";
|
|
80
|
+
code;
|
|
81
|
+
constructor(code, message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.code = code;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const COMPARATORS = new Set([
|
|
87
|
+
"eq",
|
|
88
|
+
"neq",
|
|
89
|
+
"gt",
|
|
90
|
+
"gte",
|
|
91
|
+
"lt",
|
|
92
|
+
"lte",
|
|
93
|
+
"in",
|
|
94
|
+
"nin",
|
|
95
|
+
"contains",
|
|
96
|
+
"startsWith",
|
|
97
|
+
"endsWith",
|
|
98
|
+
"isNull",
|
|
99
|
+
"isNotNull"
|
|
100
|
+
]);
|
|
101
|
+
const UNARY_OPS = new Set(["isNull", "isNotNull"]);
|
|
102
|
+
/**
|
|
103
|
+
* Validate a condition shape before persisting. Recurses through composites.
|
|
104
|
+
* Throws `ConditionError` so hosts get a stable code + message in API
|
|
105
|
+
* responses (`{ code: 'INVALID_OP', message: '...' }`).
|
|
106
|
+
*/
|
|
107
|
+
function validateCondition(condition) {
|
|
108
|
+
if (condition === null || typeof condition !== "object") throw new ConditionError("EMPTY", "condition is null or not an object");
|
|
109
|
+
if (isAllCondition(condition)) {
|
|
110
|
+
if (condition.all.length === 0) throw new ConditionError("EMPTY_GROUP", "all-group must have at least one branch");
|
|
111
|
+
condition.all.forEach(validateCondition);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (isAnyCondition(condition)) {
|
|
115
|
+
if (condition.any.length === 0) throw new ConditionError("EMPTY_GROUP", "any-group must have at least one branch");
|
|
116
|
+
condition.any.forEach(validateCondition);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (isNotCondition(condition)) {
|
|
120
|
+
validateCondition(condition.not);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (isFieldCondition(condition)) {
|
|
124
|
+
if (!COMPARATORS.has(condition.op)) throw new ConditionError("INVALID_OP", `unknown comparator: ${JSON.stringify(condition.op)}`);
|
|
125
|
+
if (!UNARY_OPS.has(condition.op) && condition.value === void 0) throw new ConditionError("MISSING_VALUE", `comparator '${condition.op}' requires a value`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new ConditionError("UNKNOWN_SHAPE", `condition must be a field/all/any/not — got: ${JSON.stringify(condition)}`);
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { ConditionError, evaluate, validateCondition };
|