@classytic/primitives 0.1.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.
@@ -0,0 +1,32 @@
1
+ //#region src/result.d.ts
2
+ /**
3
+ * A discriminated-union result type — an alternative to throwing for expected
4
+ * domain failures. Zero-cost: use it where failure is a first-class outcome
5
+ * (validation, pre-conditions), keep `throw` for truly exceptional paths
6
+ * (infra, programmer error).
7
+ */
8
+ type Result<T, E = Error> = {
9
+ readonly ok: true;
10
+ readonly value: T;
11
+ } | {
12
+ readonly ok: false;
13
+ readonly error: E;
14
+ };
15
+ declare function ok<T>(value: T): Result<T, never>;
16
+ declare function err<E>(error: E): Result<never, E>;
17
+ declare function isOk<T, E>(r: Result<T, E>): r is {
18
+ ok: true;
19
+ value: T;
20
+ };
21
+ declare function isErr<T, E>(r: Result<T, E>): r is {
22
+ ok: false;
23
+ error: E;
24
+ };
25
+ /** Transform a successful value; pass-through on error. */
26
+ declare function mapResult<T, U, E>(r: Result<T, E>, fn: (v: T) => U): Result<U, E>;
27
+ /** Transform an error value; pass-through on success. */
28
+ declare function mapError<T, E, F>(r: Result<T, E>, fn: (e: E) => F): Result<T, F>;
29
+ /** Unwrap — returns the value or throws the error. Convenience only. */
30
+ declare function unwrap<T, E>(r: Result<T, E>): T;
31
+ //#endregion
32
+ export { Result, err, isErr, isOk, mapError, mapResult, ok, unwrap };
@@ -0,0 +1,34 @@
1
+ //#region src/result.ts
2
+ function ok(value) {
3
+ return {
4
+ ok: true,
5
+ value
6
+ };
7
+ }
8
+ function err(error) {
9
+ return {
10
+ ok: false,
11
+ error
12
+ };
13
+ }
14
+ function isOk(r) {
15
+ return r.ok;
16
+ }
17
+ function isErr(r) {
18
+ return !r.ok;
19
+ }
20
+ /** Transform a successful value; pass-through on error. */
21
+ function mapResult(r, fn) {
22
+ return r.ok ? ok(fn(r.value)) : r;
23
+ }
24
+ /** Transform an error value; pass-through on success. */
25
+ function mapError(r, fn) {
26
+ return r.ok ? r : err(fn(r.error));
27
+ }
28
+ /** Unwrap — returns the value or throws the error. Convenience only. */
29
+ function unwrap(r) {
30
+ if (r.ok) return r.value;
31
+ throw r.error;
32
+ }
33
+ //#endregion
34
+ export { err, isErr, isOk, mapError, mapResult, ok, unwrap };
package/dist/sla.d.mts ADDED
@@ -0,0 +1,71 @@
1
+ //#region src/sla.d.ts
2
+ /**
3
+ * Service-Level Agreement value object + breach-detection helpers.
4
+ *
5
+ * Describes a target duration a task must complete within, along with what
6
+ * to do when the deadline lapses. Consumers: `@classytic/flow` (wave picking,
7
+ * shipment), `@classytic/order` (fulfillment), `@classytic/commission`
8
+ * (statement close).
9
+ *
10
+ * Pure data + pure helpers. No scheduler, no events — hosts schedule the
11
+ * check (cron, Temporal activity, webhook) and call {@link isBreached} at
12
+ * evaluation time.
13
+ *
14
+ * @example
15
+ * import { type SLA, isBreached, remainingMs, breachedAt } from '@classytic/primitives/sla';
16
+ *
17
+ * const sla: SLA = { targetDurationMs: 2 * 60 * 60 * 1000, breachPolicy: 'escalate' };
18
+ * const startedAt = new Date('2026-04-17T10:00:00Z');
19
+ * isBreached(sla, startedAt, new Date('2026-04-17T11:00:00Z')); // false (1h elapsed of 2h)
20
+ * isBreached(sla, startedAt, new Date('2026-04-17T13:00:00Z')); // true
21
+ * breachedAt(sla, startedAt); // → Date('2026-04-17T12:00:00Z')
22
+ */
23
+ /**
24
+ * Policy describing what a host should do when the SLA is breached. The
25
+ * primitive does *not* execute the policy — the host reads this field and
26
+ * decides (log a warn, page oncall, block the workflow, etc.).
27
+ */
28
+ type BreachPolicy = 'warn' | 'escalate' | 'block';
29
+ interface SLA {
30
+ /** Target duration in milliseconds. Must be a positive integer. */
31
+ readonly targetDurationMs: number;
32
+ readonly breachPolicy: BreachPolicy;
33
+ /**
34
+ * Optional stable reference to a host-defined action (webhook URL, workflow
35
+ * name, alert route). The primitive just carries the string — host resolves.
36
+ */
37
+ readonly breachActionRef?: string;
38
+ /** Optional human label — useful for dashboards and audit logs. */
39
+ readonly label?: string;
40
+ }
41
+ type SLAErrorCode = 'INVALID_TARGET' | 'INVALID_START';
42
+ declare class SLAError extends Error {
43
+ readonly name = "SLAError";
44
+ readonly code: SLAErrorCode;
45
+ constructor(code: SLAErrorCode, message: string);
46
+ }
47
+ /** Validate an SLA spec. Call this before storing host-side. */
48
+ declare function validateSLA(sla: SLA): void;
49
+ /**
50
+ * The timestamp at which the SLA will be (or was) breached — `startedAt +
51
+ * targetDurationMs`.
52
+ */
53
+ declare function breachedAt(sla: SLA, startedAt: Date): Date;
54
+ /**
55
+ * Milliseconds remaining before breach. Negative if already breached.
56
+ * Clock source is passed explicitly so tests remain deterministic and
57
+ * hosts can evaluate against any reference time.
58
+ */
59
+ declare function remainingMs(sla: SLA, startedAt: Date, now?: Date): number;
60
+ /** Elapsed milliseconds since `startedAt`. */
61
+ declare function elapsedMs(startedAt: Date, now?: Date): number;
62
+ /** True if `now` is at or past the deadline. */
63
+ declare function isBreached(sla: SLA, startedAt: Date, now?: Date): boolean;
64
+ /**
65
+ * Elapsed-fraction of the SLA window (0 = just started, 1 = exactly at
66
+ * deadline, > 1 = breached). Useful for progress bars and early-warning
67
+ * thresholds (e.g., warn at 80% consumed).
68
+ */
69
+ declare function consumedFraction(sla: SLA, startedAt: Date, now?: Date): number;
70
+ //#endregion
71
+ export { BreachPolicy, SLA, SLAError, SLAErrorCode, breachedAt, consumedFraction, elapsedMs, isBreached, remainingMs, validateSLA };
package/dist/sla.mjs ADDED
@@ -0,0 +1,57 @@
1
+ //#region src/sla.ts
2
+ var SLAError = class extends Error {
3
+ name = "SLAError";
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ };
10
+ /** Validate an SLA spec. Call this before storing host-side. */
11
+ function validateSLA(sla) {
12
+ if (!Number.isInteger(sla.targetDurationMs) || sla.targetDurationMs <= 0 || !Number.isFinite(sla.targetDurationMs)) throw new SLAError("INVALID_TARGET", `targetDurationMs must be a positive integer, got ${sla.targetDurationMs}`);
13
+ }
14
+ /**
15
+ * The timestamp at which the SLA will be (or was) breached — `startedAt +
16
+ * targetDurationMs`.
17
+ */
18
+ function breachedAt(sla, startedAt) {
19
+ validateSLA(sla);
20
+ assertValidDate(startedAt);
21
+ return new Date(startedAt.getTime() + sla.targetDurationMs);
22
+ }
23
+ /**
24
+ * Milliseconds remaining before breach. Negative if already breached.
25
+ * Clock source is passed explicitly so tests remain deterministic and
26
+ * hosts can evaluate against any reference time.
27
+ */
28
+ function remainingMs(sla, startedAt, now = /* @__PURE__ */ new Date()) {
29
+ validateSLA(sla);
30
+ assertValidDate(startedAt);
31
+ assertValidDate(now);
32
+ return startedAt.getTime() + sla.targetDurationMs - now.getTime();
33
+ }
34
+ /** Elapsed milliseconds since `startedAt`. */
35
+ function elapsedMs(startedAt, now = /* @__PURE__ */ new Date()) {
36
+ assertValidDate(startedAt);
37
+ assertValidDate(now);
38
+ return now.getTime() - startedAt.getTime();
39
+ }
40
+ /** True if `now` is at or past the deadline. */
41
+ function isBreached(sla, startedAt, now = /* @__PURE__ */ new Date()) {
42
+ return remainingMs(sla, startedAt, now) <= 0;
43
+ }
44
+ /**
45
+ * Elapsed-fraction of the SLA window (0 = just started, 1 = exactly at
46
+ * deadline, > 1 = breached). Useful for progress bars and early-warning
47
+ * thresholds (e.g., warn at 80% consumed).
48
+ */
49
+ function consumedFraction(sla, startedAt, now = /* @__PURE__ */ new Date()) {
50
+ validateSLA(sla);
51
+ return elapsedMs(startedAt, now) / sla.targetDurationMs;
52
+ }
53
+ function assertValidDate(d) {
54
+ if (Number.isNaN(d.getTime())) throw new SLAError("INVALID_START", "date is not a valid Date");
55
+ }
56
+ //#endregion
57
+ export { SLAError, breachedAt, consumedFraction, elapsedMs, isBreached, remainingMs, validateSLA };
@@ -0,0 +1,84 @@
1
+ //#region src/split-allocation.d.ts
2
+ /**
3
+ * Deterministic integer allocation across N subjects.
4
+ *
5
+ * Given a `total` and a set of `subjects`, distribute the total across the
6
+ * subjects in integer minor units using a chosen `method`. The sum of the
7
+ * returned parts is *exactly* equal to `total` — no drift, no lost pennies —
8
+ * via the largest-remainder method.
9
+ *
10
+ * Designed for landed-cost allocation (flow), commission payout splits
11
+ * (commission), tax allocation (bd-tax), and payment splits (revenue).
12
+ *
13
+ * @example
14
+ * // Allocate 1000 cents of freight across 3 line items by weight.
15
+ * allocate(1000, [
16
+ * { id: 'L1', weight: 2 },
17
+ * { id: 'L2', weight: 3 },
18
+ * { id: 'L3', weight: 5 },
19
+ * ], 'by-weight');
20
+ * // → { total: 1000, method: 'by-weight', parts: [
21
+ * // { id: 'L1', amount: 200, weight: 2 },
22
+ * // { id: 'L2', amount: 300, weight: 3 },
23
+ * // { id: 'L3', amount: 500, weight: 5 },
24
+ * // ] }
25
+ *
26
+ * @example
27
+ * // Residue goes to the largest fractional remainder, deterministic.
28
+ * allocate(100, [
29
+ * { id: 'A', weight: 1 },
30
+ * { id: 'B', weight: 1 },
31
+ * { id: 'C', weight: 1 },
32
+ * ], 'by-weight');
33
+ * // → parts sum to 100: [34, 33, 33] (first-order tie-break on residue)
34
+ */
35
+ /**
36
+ * Allocation method. Proportional methods (`by-qty` | `by-weight` | `by-volume`
37
+ * | `by-value`) share the same algorithm — the label carries *semantic* meaning
38
+ * for downstream audit trails ("this cost was split by volume, not by value").
39
+ * `by-percent` validates the weights sum to 100. `equal` ignores weights.
40
+ */
41
+ type SplitMethod = 'equal' | 'by-qty' | 'by-weight' | 'by-volume' | 'by-value' | 'by-percent';
42
+ interface SplitSubject {
43
+ /** Caller-supplied identifier, preserved on the matching part. */
44
+ readonly id: string;
45
+ /** Non-negative numeric weight for proportional methods. Required unless `method === 'equal'` or `'by-percent'`. */
46
+ readonly weight?: number;
47
+ /** Percent in [0, 100] for `method === 'by-percent'`. Sum across subjects must equal 100 (± 0.001). */
48
+ readonly percent?: number;
49
+ }
50
+ interface SplitPart {
51
+ readonly id: string;
52
+ /** Integer in the same unit as `total`. */
53
+ readonly amount: number;
54
+ /** The effective weight used for this subject (for audit trails). */
55
+ readonly weight: number;
56
+ }
57
+ interface SplitResult {
58
+ /** Echoes the input total. Always equals `sum(parts[i].amount)` exactly. */
59
+ readonly total: number;
60
+ readonly method: SplitMethod;
61
+ readonly parts: readonly SplitPart[];
62
+ }
63
+ type SplitAllocationErrorCode = 'INVALID_TOTAL' | 'EMPTY_SUBJECTS' | 'INVALID_WEIGHT' | 'INVALID_PERCENT' | 'ZERO_WEIGHT_SUM' | 'DUPLICATE_SUBJECT_ID';
64
+ declare class SplitAllocationError extends Error {
65
+ readonly name = "SplitAllocationError";
66
+ readonly code: SplitAllocationErrorCode;
67
+ constructor(code: SplitAllocationErrorCode, message: string);
68
+ }
69
+ /**
70
+ * Allocate `total` across `subjects` using `method`. Returns one part per
71
+ * subject, in input order. `sum(parts[i].amount) === total` is guaranteed.
72
+ *
73
+ * Rounding residue is distributed via the largest-remainder method: the
74
+ * subjects with the largest fractional share get +1 (or -1 for negative
75
+ * totals) until the residue is exhausted. Ties are broken by input order —
76
+ * identical inputs always produce identical outputs.
77
+ *
78
+ * @throws SplitAllocationError — see {@link SplitAllocationErrorCode} for cases.
79
+ */
80
+ declare function allocate(total: number, subjects: readonly SplitSubject[], method: SplitMethod): SplitResult;
81
+ /** True if `sum(parts[i].amount) === result.total`. Useful as an assertion in host code. */
82
+ declare function isBalanced(result: SplitResult): boolean;
83
+ //#endregion
84
+ export { SplitAllocationError, SplitAllocationErrorCode, SplitMethod, SplitPart, SplitResult, SplitSubject, allocate, isBalanced };
@@ -0,0 +1,115 @@
1
+ //#region src/split-allocation.ts
2
+ var SplitAllocationError = class extends Error {
3
+ name = "SplitAllocationError";
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ };
10
+ /** Tolerance for `by-percent` sum validation — accommodates floating-point drift on typical inputs. */
11
+ const PERCENT_TOLERANCE = .001;
12
+ /**
13
+ * Allocate `total` across `subjects` using `method`. Returns one part per
14
+ * subject, in input order. `sum(parts[i].amount) === total` is guaranteed.
15
+ *
16
+ * Rounding residue is distributed via the largest-remainder method: the
17
+ * subjects with the largest fractional share get +1 (or -1 for negative
18
+ * totals) until the residue is exhausted. Ties are broken by input order —
19
+ * identical inputs always produce identical outputs.
20
+ *
21
+ * @throws SplitAllocationError — see {@link SplitAllocationErrorCode} for cases.
22
+ */
23
+ function allocate(total, subjects, method) {
24
+ if (!Number.isFinite(total) || !Number.isInteger(total)) throw new SplitAllocationError("INVALID_TOTAL", `total must be a finite integer, got ${total}`);
25
+ if (subjects.length === 0) {
26
+ if (total === 0) return {
27
+ total: 0,
28
+ method,
29
+ parts: []
30
+ };
31
+ throw new SplitAllocationError("EMPTY_SUBJECTS", `cannot allocate non-zero total (${total}) across zero subjects`);
32
+ }
33
+ const seenIds = /* @__PURE__ */ new Set();
34
+ for (const s of subjects) {
35
+ if (seenIds.has(s.id)) throw new SplitAllocationError("DUPLICATE_SUBJECT_ID", `duplicate subject id: ${s.id}`);
36
+ seenIds.add(s.id);
37
+ }
38
+ const weights = resolveWeights(subjects, method);
39
+ const sign = total < 0 ? -1 : 1;
40
+ return {
41
+ total,
42
+ method,
43
+ parts: distribute(Math.abs(total), weights).map((amount, i) => {
44
+ const subj = subjects[i];
45
+ const w = weights[i];
46
+ if (subj === void 0 || w === void 0) throw new Error("split-allocation invariant: parts, subjects, and weights out of sync");
47
+ return {
48
+ id: subj.id,
49
+ amount: amount * sign,
50
+ weight: w
51
+ };
52
+ })
53
+ };
54
+ }
55
+ /**
56
+ * Resolve the effective numeric weight per subject based on method.
57
+ * Validates inputs and throws on bad data.
58
+ */
59
+ function resolveWeights(subjects, method) {
60
+ if (method === "equal") return subjects.map(() => 1);
61
+ if (method === "by-percent") {
62
+ const percents = subjects.map((s, i) => {
63
+ const p = s.percent;
64
+ if (p === void 0) throw new SplitAllocationError("INVALID_PERCENT", `subject[${i}] (id=${s.id}) missing percent for method='by-percent'`);
65
+ if (!Number.isFinite(p) || p < 0 || p > 100) throw new SplitAllocationError("INVALID_PERCENT", `subject[${i}] (id=${s.id}) percent must be in [0, 100], got ${p}`);
66
+ return p;
67
+ });
68
+ const sum = percents.reduce((a, b) => a + b, 0);
69
+ if (Math.abs(sum - 100) > PERCENT_TOLERANCE) throw new SplitAllocationError("INVALID_PERCENT", `percents must sum to 100 (±${PERCENT_TOLERANCE}), got ${sum}`);
70
+ return percents;
71
+ }
72
+ const weights = subjects.map((s, i) => {
73
+ const w = s.weight;
74
+ if (w === void 0) throw new SplitAllocationError("INVALID_WEIGHT", `subject[${i}] (id=${s.id}) missing weight for method='${method}'`);
75
+ if (!Number.isFinite(w) || w < 0) throw new SplitAllocationError("INVALID_WEIGHT", `subject[${i}] (id=${s.id}) weight must be finite and non-negative, got ${w}`);
76
+ return w;
77
+ });
78
+ if (weights.reduce((a, b) => a + b, 0) === 0) throw new SplitAllocationError("ZERO_WEIGHT_SUM", `sum of weights is zero — cannot allocate proportionally`);
79
+ return weights;
80
+ }
81
+ /**
82
+ * Core largest-remainder distributor. Operates on non-negative `total` and
83
+ * non-negative `weights` with `sum(weights) > 0`. Guarantees:
84
+ * sum(result) === total
85
+ * result.length === weights.length
86
+ * determinism (ties broken by input order).
87
+ */
88
+ function distribute(total, weights) {
89
+ const weightSum = weights.reduce((a, b) => a + b, 0);
90
+ if (weightSum === 0) return weights.map(() => 0);
91
+ const raw = weights.map((w) => total * w / weightSum);
92
+ const floored = raw.map((r) => Math.floor(r));
93
+ const residue = total - floored.reduce((a, b) => a + b, 0);
94
+ if (residue === 0) return floored;
95
+ const order = raw.map((r, i) => ({
96
+ i,
97
+ frac: r - Math.floor(r)
98
+ })).sort((a, b) => {
99
+ if (b.frac !== a.frac) return b.frac - a.frac;
100
+ return a.i - b.i;
101
+ });
102
+ const result = floored.slice();
103
+ for (let k = 0; k < residue; k++) {
104
+ const entry = order[k];
105
+ if (entry === void 0) break;
106
+ result[entry.i] = (result[entry.i] ?? 0) + 1;
107
+ }
108
+ return result;
109
+ }
110
+ /** True if `sum(parts[i].amount) === result.total`. Useful as an assertion in host code. */
111
+ function isBalanced(result) {
112
+ return result.parts.reduce((acc, p) => acc + p.amount, 0) === result.total;
113
+ }
114
+ //#endregion
115
+ export { SplitAllocationError, allocate, isBalanced };
@@ -0,0 +1,122 @@
1
+ //#region src/tenant.d.ts
2
+ /**
3
+ * Tenant / scope configuration primitives.
4
+ *
5
+ * Every multi-tenant-capable package accepts a config block shaped like
6
+ * {@link TenantConfig}. It controls how the package stores and casts the
7
+ * tenant identifier on its models, and which key on
8
+ * `OperationContext` to read the tenant value from.
9
+ *
10
+ * Field names match `@classytic/mongokit`'s `MultiTenantOptions` so the
11
+ * resolved config can be forwarded straight into `multiTenantPlugin(...)`.
12
+ *
13
+ * Three strategies are supported:
14
+ * - `'field'` (default) — filter every query by a scalar field on documents.
15
+ * The common case; used by `multiTenantPlugin`.
16
+ * - `'none'` — disable scoping entirely (single-tenant app). Equivalent to
17
+ * `enabled: false`; `strategy: 'none'` is the explicit form.
18
+ * - `'custom'` — caller supplies a `resolve(ctx)` function that returns the
19
+ * filter shape to inject. Covers multi-field composite tenants, context-
20
+ * derived filters (region + partner id), or non-scalar scope keys.
21
+ *
22
+ * See PACKAGE_RULES.md §9 for the full pattern.
23
+ */
24
+ /**
25
+ * Mongo storage type for the tenant identifier.
26
+ *
27
+ * - `'objectId'` (recommended for new packages) — `Schema.Types.ObjectId` with
28
+ * `ref`. Enables `$lookup`, `.populate()`, QueryParser `?lookup=...`.
29
+ * - `'string'` — plain string. Use when the host auth system issues UUIDs or
30
+ * slugs rather than ObjectIds.
31
+ */
32
+ type TenantFieldType = 'objectId' | 'string';
33
+ /** Scope resolution strategy. */
34
+ type TenantStrategy = 'field' | 'none' | 'custom';
35
+ interface TenantConfig {
36
+ /**
37
+ * Scope strategy. Omit for the common `'field'` case — explicit `'none'`
38
+ * / `'custom'` lets packages collapse what used to live in a separate
39
+ * `ScopeConfig` type.
40
+ *
41
+ * @default 'field'
42
+ */
43
+ strategy?: TenantStrategy;
44
+ /**
45
+ * Whether tenant scoping is active. When `false`, the package runs in
46
+ * single-tenant mode — no filter injection, no tenant field on documents.
47
+ * Equivalent to `strategy: 'none'`.
48
+ *
49
+ * @default true
50
+ */
51
+ enabled?: boolean;
52
+ /**
53
+ * Document field name that stores the tenant id. Matches mongokit's
54
+ * `MultiTenantOptions.tenantField`. Used when `strategy === 'field'`.
55
+ *
56
+ * @default 'organizationId'
57
+ */
58
+ tenantField?: string;
59
+ /**
60
+ * How to store / cast the tenant id. Matches mongokit's `fieldType`.
61
+ *
62
+ * @default 'objectId'
63
+ */
64
+ fieldType?: TenantFieldType;
65
+ /**
66
+ * Mongoose ref for `'objectId'` types. Ignored when `fieldType === 'string'`.
67
+ *
68
+ * @default 'organization'
69
+ */
70
+ ref?: string;
71
+ /**
72
+ * Which key on `OperationContext` to read the tenant id from.
73
+ *
74
+ * @default 'organizationId'
75
+ */
76
+ contextKey?: string;
77
+ /**
78
+ * Whether the field is required. When `false`, the package permits
79
+ * unscoped / cross-tenant reads (typically only for admin paths).
80
+ *
81
+ * @default true
82
+ */
83
+ required?: boolean;
84
+ /**
85
+ * Custom resolver — called when `strategy === 'custom'` to produce the
86
+ * filter object injected into queries. Packages pass the request context;
87
+ * the resolver returns the filter shape.
88
+ *
89
+ * Example:
90
+ * ```ts
91
+ * {
92
+ * strategy: 'custom',
93
+ * resolve: (ctx) => ({
94
+ * organizationId: ctx.organizationId,
95
+ * region: ctx.region,
96
+ * }),
97
+ * }
98
+ * ```
99
+ */
100
+ resolve?: (ctx: Record<string, unknown>) => Record<string, unknown>;
101
+ }
102
+ /** Sensible defaults for a freshly-built package (field strategy). */
103
+ declare const DEFAULT_TENANT_CONFIG: Required<Pick<TenantConfig, 'strategy' | 'enabled' | 'tenantField' | 'fieldType' | 'ref' | 'contextKey' | 'required'>>;
104
+ /**
105
+ * Resolved shape returned by {@link resolveTenantConfig}. Always includes the
106
+ * field defaults (so packages can inspect field names even when `enabled:
107
+ * false`) and threads `resolve` when `strategy === 'custom'`.
108
+ */
109
+ type ResolvedTenantConfig = typeof DEFAULT_TENANT_CONFIG & {
110
+ resolve?: TenantConfig['resolve'];
111
+ };
112
+ /**
113
+ * Resolve a possibly-partial {@link TenantConfig} against the defaults.
114
+ *
115
+ * - `false` → `enabled: false`, `strategy: 'none'`.
116
+ * - `true` / `undefined` → default field strategy.
117
+ * - Object with `strategy: 'custom'` → `resolve` is required; throws otherwise.
118
+ * - Object with `strategy: 'none'` → `enabled: false`.
119
+ */
120
+ declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedTenantConfig;
121
+ //#endregion
122
+ export { DEFAULT_TENANT_CONFIG, ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy, resolveTenantConfig };
@@ -0,0 +1,54 @@
1
+ //#region src/tenant.ts
2
+ /** Sensible defaults for a freshly-built package (field strategy). */
3
+ const DEFAULT_TENANT_CONFIG = {
4
+ strategy: "field",
5
+ enabled: true,
6
+ tenantField: "organizationId",
7
+ fieldType: "objectId",
8
+ ref: "organization",
9
+ contextKey: "organizationId",
10
+ required: true
11
+ };
12
+ /**
13
+ * Resolve a possibly-partial {@link TenantConfig} against the defaults.
14
+ *
15
+ * - `false` → `enabled: false`, `strategy: 'none'`.
16
+ * - `true` / `undefined` → default field strategy.
17
+ * - Object with `strategy: 'custom'` → `resolve` is required; throws otherwise.
18
+ * - Object with `strategy: 'none'` → `enabled: false`.
19
+ */
20
+ function resolveTenantConfig(config) {
21
+ if (config === false) return {
22
+ ...DEFAULT_TENANT_CONFIG,
23
+ strategy: "none",
24
+ enabled: false,
25
+ required: false
26
+ };
27
+ if (config === true || config === void 0) return { ...DEFAULT_TENANT_CONFIG };
28
+ const strategy = config.strategy ?? (config.enabled === false ? "none" : "field");
29
+ if (strategy === "none") return {
30
+ ...DEFAULT_TENANT_CONFIG,
31
+ ...config,
32
+ strategy: "none",
33
+ enabled: false,
34
+ required: false
35
+ };
36
+ if (strategy === "custom") {
37
+ if (typeof config.resolve !== "function") throw new Error("[primitives] TenantConfig.strategy 'custom' requires a 'resolve' function");
38
+ return {
39
+ ...DEFAULT_TENANT_CONFIG,
40
+ ...config,
41
+ strategy: "custom",
42
+ enabled: config.enabled ?? true,
43
+ resolve: config.resolve
44
+ };
45
+ }
46
+ return {
47
+ ...DEFAULT_TENANT_CONFIG,
48
+ ...config,
49
+ strategy: "field",
50
+ enabled: config.enabled ?? true
51
+ };
52
+ }
53
+ //#endregion
54
+ export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };