@packvium/engine 0.1.0 → 0.1.1
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/README.md +78 -2
- package/SECURITY.md +77 -0
- package/commerce-model.js +236 -0
- package/commerce.js +664 -0
- package/examples/basic.mjs +67 -0
- package/examples/commerce.mjs +163 -0
- package/fallback.js +103 -11
- package/index.d.ts +12 -0
- package/index.js +41 -1
- package/package.json +3 -3
- package/policy.js +226 -0
package/policy.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned eligibility rules, compiled into the checks the packer already runs.
|
|
3
|
+
*
|
|
4
|
+
* See docs/POLICY-RULES.md for the contract and the reasoning behind its shape. The
|
|
5
|
+
* short version: rules travel in the request as data because an engine is driven over
|
|
6
|
+
* JSON as a subprocess, so a rule registered inside one process has no wire
|
|
7
|
+
* representation and nothing can check that four engines agree about it.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately not a predicate language. `eligible_container_tags`, `incompatible_tags`
|
|
10
|
+
* and `tag_limits` already express the predicates in every engine, so each rule form
|
|
11
|
+
* here compiles to a check the packer already performs. What a rule adds is only what
|
|
12
|
+
* tags cannot carry: identity, effective dating, priority, and the shipment-scoped facts
|
|
13
|
+
* a request had nowhere to put.
|
|
14
|
+
*
|
|
15
|
+
* This module is package-internal: package.json exports only the root entry point.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// The shipment-scoped facts a rule may select on. Properties of the shipment rather
|
|
19
|
+
// than of any item or container, which is why the request had nowhere to put them.
|
|
20
|
+
const SHIPMENT_FACTS = ['facility', 'customer', 'carrier', 'service'];
|
|
21
|
+
|
|
22
|
+
// Wire name -> the keys the form requires, in the order they are read. Exactly one may
|
|
23
|
+
// appear on a rule: a rule naming two forms would have no single meaning for a citation.
|
|
24
|
+
const FORMS = {
|
|
25
|
+
separate_tags: ['tag', 'from_tag'],
|
|
26
|
+
require_container_tag: ['item_tag', 'container_tag'],
|
|
27
|
+
limit_tag_per_container: ['tag', 'max'],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
31
|
+
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
// The detail strings are part of the cross-language contract, and every other engine
|
|
33
|
+
// renders a tag the way its own language quotes a short string literal. Single quotes
|
|
34
|
+
// are what those agree on; a tag is a request-supplied string, so it is escaped here
|
|
35
|
+
// rather than interpolated raw.
|
|
36
|
+
const quoted = tag => `'${tag.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A rule set this engine cannot honour exactly as written.
|
|
40
|
+
*
|
|
41
|
+
* Structured rather than skipped: a rule silently dropped for being malformed would let
|
|
42
|
+
* a request pack in a way its own policy forbids, which is the failure the whole
|
|
43
|
+
* contract exists to prevent.
|
|
44
|
+
*/
|
|
45
|
+
export class PolicyError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'PolicyError';
|
|
49
|
+
this.code = 'policy_error';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function integer(value, where, minimum) {
|
|
54
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
55
|
+
throw new PolicyError(`${where} must be an integer >= ${minimum}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Declared facts, or the absence of one. A fact nobody declared is not a wildcard: a
|
|
62
|
+
* rule naming it simply never participates, so an unstated facility cannot silently
|
|
63
|
+
* match a rule written for a specific one.
|
|
64
|
+
*/
|
|
65
|
+
function shipmentContext(raw, where) {
|
|
66
|
+
const context = {};
|
|
67
|
+
for (const fact of SHIPMENT_FACTS) context[fact] = null;
|
|
68
|
+
if (raw == null) return context;
|
|
69
|
+
if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`);
|
|
70
|
+
const unknown = Object.keys(raw).filter(key => !SHIPMENT_FACTS.includes(key)).sort();
|
|
71
|
+
if (unknown.length) throw new PolicyError(`${where} names unknown shipment facts: ${unknown.join(', ')}`);
|
|
72
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
73
|
+
if (typeof value !== 'string' || !value) throw new PolicyError(`${where}.${name} must be a non-empty string`);
|
|
74
|
+
context[name] = value;
|
|
75
|
+
}
|
|
76
|
+
return context;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether every fact this selector names equals the shipment's own. */
|
|
80
|
+
const satisfiedBy = (selector, shipment) =>
|
|
81
|
+
SHIPMENT_FACTS.every(fact => selector[fact] === null || selector[fact] === shipment[fact]);
|
|
82
|
+
|
|
83
|
+
function parseForm(name, raw, where) {
|
|
84
|
+
if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`);
|
|
85
|
+
const keys = FORMS[name];
|
|
86
|
+
const unknown = Object.keys(raw).filter(key => !keys.includes(key)).sort();
|
|
87
|
+
if (unknown.length) throw new PolicyError(`${where} has unknown keys: ${unknown.join(', ')}`);
|
|
88
|
+
const missing = keys.filter(key => !hasOwn(raw, key));
|
|
89
|
+
if (missing.length) throw new PolicyError(`${where} is missing ${missing.join(', ')}`);
|
|
90
|
+
const form = {kind: name};
|
|
91
|
+
for (const key of keys) {
|
|
92
|
+
if (key === 'max') { form.max = integer(raw.max, `${where}.max`, 0); continue }
|
|
93
|
+
if (typeof raw[key] !== 'string' || !raw[key]) throw new PolicyError(`${where}.${key} must be a non-empty string`);
|
|
94
|
+
form[key] = raw[key];
|
|
95
|
+
}
|
|
96
|
+
return form;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseRule(raw, index) {
|
|
100
|
+
const where = `policy.rules[${index}]`;
|
|
101
|
+
if (!isPlainObject(raw)) throw new PolicyError(`${where} must be an object`);
|
|
102
|
+
const named = Object.keys(FORMS).filter(name => hasOwn(raw, name));
|
|
103
|
+
if (named.length !== 1) {
|
|
104
|
+
throw new PolicyError(
|
|
105
|
+
`${where} must name exactly one rule form (${Object.keys(FORMS).sort().join(', ')}), not ${named.length}`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof raw.id !== 'string' || !raw.id) throw new PolicyError(`${where}.id must be a non-empty string`);
|
|
108
|
+
const version = integer(raw.version, `${where}.version`, 1);
|
|
109
|
+
return {
|
|
110
|
+
id: raw.id,
|
|
111
|
+
version,
|
|
112
|
+
citation: `${raw.id}@${version}`,
|
|
113
|
+
effective_at: integer(raw.effective_at, `${where}.effective_at`, 0),
|
|
114
|
+
priority: integer(raw.priority, `${where}.priority`, 0),
|
|
115
|
+
applies_to: shipmentContext(raw.applies_to, `${where}.applies_to`),
|
|
116
|
+
form: parseForm(named[0], raw[named[0]], `${where}.${named[0]}`),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolution, fixed by the contract and identical in every engine — or the same request
|
|
122
|
+
* packs differently depending on which one answered it.
|
|
123
|
+
*/
|
|
124
|
+
function resolve(rules, asOf, shipment) {
|
|
125
|
+
const participating = rules.filter(
|
|
126
|
+
rule => rule.effective_at <= asOf && satisfiedBy(rule.applies_to, shipment));
|
|
127
|
+
// Append-only per id: among participating versions of one id the highest
|
|
128
|
+
// `effective_at` wins, ties broken by the highest `version`. The same resolution the
|
|
129
|
+
// catalog registry already uses for `as_of` lookups, deliberately, so a reader learns
|
|
130
|
+
// one rule and not two.
|
|
131
|
+
const latest = new Map();
|
|
132
|
+
for (const rule of participating) {
|
|
133
|
+
const current = latest.get(rule.id);
|
|
134
|
+
if (current === undefined || rule.effective_at > current.effective_at
|
|
135
|
+
|| (rule.effective_at === current.effective_at && rule.version > current.version)) {
|
|
136
|
+
latest.set(rule.id, rule);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Citation order, not evaluation order: the first rule that rejects a candidate is the
|
|
140
|
+
// one cited, so sorting here is what makes the citation deterministic. Ties go to the
|
|
141
|
+
// lexicographically smallest id -- never to insertion order, which would make the
|
|
142
|
+
// citation depend on the order the caller happened to write.
|
|
143
|
+
return [...latest.values()].sort((a, b) => b.priority - a.priority || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The rules that participate in one request, already resolved and ordered. */
|
|
147
|
+
export function parsePolicy(raw) {
|
|
148
|
+
if (raw == null) return [];
|
|
149
|
+
if (!isPlainObject(raw)) throw new PolicyError('policy must be an object');
|
|
150
|
+
const unknown = Object.keys(raw).filter(key => !['as_of', 'shipment', 'rules'].includes(key)).sort();
|
|
151
|
+
if (unknown.length) throw new PolicyError(`policy has unknown keys: ${unknown.join(', ')}`);
|
|
152
|
+
const declared = raw.rules ?? [];
|
|
153
|
+
if (!Array.isArray(declared)) throw new PolicyError('policy.rules must be an array');
|
|
154
|
+
if (!declared.length) return [];
|
|
155
|
+
// No default: a guessed instant silently activates or hides a restriction, and reading
|
|
156
|
+
// a clock here would make one request pack differently on different days.
|
|
157
|
+
if (!hasOwn(raw, 'as_of')) throw new PolicyError('policy.as_of is required whenever policy.rules is non-empty');
|
|
158
|
+
const asOf = integer(raw.as_of, 'policy.as_of', 0);
|
|
159
|
+
const shipment = shipmentContext(raw.shipment, 'policy.shipment');
|
|
160
|
+
return resolve(declared.map(parseRule), asOf, shipment);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether every rule permits `itemTags` in a container tagged `containerTags` that
|
|
165
|
+
* already holds `presentTags`, and the citation of the first that does not.
|
|
166
|
+
*
|
|
167
|
+
* `O(m + r)` for `m` placements already in the container and `r` resolved rules: one
|
|
168
|
+
* pass collecting the tags present, then one pass over the rules. The same bound class
|
|
169
|
+
* as the tag-count check it compiles onto, so the published complexity bounds are
|
|
170
|
+
* unchanged. Rules arrive in citation order, so the first rejection is already the one
|
|
171
|
+
* the contract says to cite.
|
|
172
|
+
*/
|
|
173
|
+
export function policyRejection(rules, itemTags, containerTags, presentTags) {
|
|
174
|
+
for (const rule of rules) {
|
|
175
|
+
const form = rule.form;
|
|
176
|
+
if (form.kind === 'require_container_tag') {
|
|
177
|
+
if (itemTags.includes(form.item_tag) && !containerTags.includes(form.container_tag)) {
|
|
178
|
+
return `${rule.citation}: requires a container tagged ${quoted(form.container_tag)}`;
|
|
179
|
+
}
|
|
180
|
+
} else if (form.kind === 'separate_tags') {
|
|
181
|
+
if (itemTags.includes(form.tag) && presentTags.get(form.from_tag)) {
|
|
182
|
+
return `${rule.citation}: ${quoted(form.tag)} may not share a container with ${quoted(form.from_tag)}`;
|
|
183
|
+
}
|
|
184
|
+
if (itemTags.includes(form.from_tag) && presentTags.get(form.tag)) {
|
|
185
|
+
return `${rule.citation}: ${quoted(form.from_tag)} may not share a container with ${quoted(form.tag)}`;
|
|
186
|
+
}
|
|
187
|
+
} else if (itemTags.includes(form.tag) && (presentTags.get(form.tag) ?? 0) >= form.max) {
|
|
188
|
+
return `${rule.citation}: at most ${form.max} item(s) tagged ${quoted(form.tag)} per container`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The rule that rules an item out of every offered container, if one does.
|
|
196
|
+
*
|
|
197
|
+
* Only `require_container_tag` can be answered here, and that is not a gap. It is a
|
|
198
|
+
* statement about the request alone -- this item carries the tag, no offered container
|
|
199
|
+
* carries the one it requires -- so it holds however the search goes. Segregation and
|
|
200
|
+
* per-container caps depend on what else was packed, so an item they leave behind was
|
|
201
|
+
* left behind by the search, and reporting that as proven would claim more than the
|
|
202
|
+
* engine knows.
|
|
203
|
+
*
|
|
204
|
+
* `O(r * c)` for `r` rules and `c` container templates, once per unpacked item rather
|
|
205
|
+
* than per candidate.
|
|
206
|
+
*/
|
|
207
|
+
export function provesUnplaceable(rules, itemTags, templates) {
|
|
208
|
+
for (const rule of rules) {
|
|
209
|
+
const form = rule.form;
|
|
210
|
+
if (form.kind !== 'require_container_tag' || !itemTags.includes(form.item_tag)) continue;
|
|
211
|
+
if (!templates.some(template => (template.tags ?? []).includes(form.container_tag))) {
|
|
212
|
+
return `${rule.citation}: requires a container tagged ${quoted(form.container_tag)}, `
|
|
213
|
+
+ 'which none of the containers offered carries';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Tag occurrence counts across placements, for the two forms that need them. */
|
|
220
|
+
export function tagOccurrences(placements) {
|
|
221
|
+
const counts = new Map();
|
|
222
|
+
for (const placement of placements) {
|
|
223
|
+
for (const tag of placement.item.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
224
|
+
}
|
|
225
|
+
return counts;
|
|
226
|
+
}
|