@blamejs/core 0.13.3 → 0.13.5
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 +4 -0
- package/README.md +2 -0
- package/index.js +3 -0
- package/lib/ai-aedt-bias-audit.js +180 -0
- package/lib/crdt.js +453 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.5 (2026-05-26) — **`b.ai.aedtBiasAudit` — NYC Local Law 144 bias audit.** b.ai.aedtBiasAudit computes the bias-audit figures New York City Local Law 144 requires before an Automated Employment Decision Tool may screen candidates (NYC Admin. Code §20-870 et seq.; DCWP rules 6 RCNY §5-300). Given the per-category counts an independent auditor collected — selected/total for a pass-fail tool, or scored-above-the-overall-median/total for a continuous-score tool — it returns the selection (or scoring) rate, the impact ratio (each group's rate divided by the most-selected group's rate), and an adverse-impact flag (impact ratio below the EEOC four-fifths threshold of 0.8) for every group, across the sex, race/ethnicity, and intersectional dimensions, plus the most-selected group per dimension and an overall flag. Categories under 2% of the audited data are marked excluded per DCWP discretion. It is a pure calculation that produces exactly the figures the annual published summary must contain — the law mandates the calculation, not any particular remediation. The relevant compliance postures (nyc-ll144, and ca-tfaia for California SB 53) were already in the catalog. **Added:** *`b.ai.aedtBiasAudit` — Local Law 144 selection/scoring rates and four-fifths impact ratios* — `b.ai.aedtBiasAudit({ type, metadata, categories, minCategoryShare? })` where `type` is `"selection"` (group entries `{ selected, total }`) or `"scoring"` (`{ scoredAboveMedian, total }`). Returns per-group rate, impact ratio, and `adverseImpact` flag across the `sex`, `raceEthnicity`, and `intersectional` dimensions, plus the most-selected group per dimension and an `anyAdverseImpact` summary. Categories below `minCategoryShare` (2% default) are excluded from the impact-ratio basis. Throws `AedtBiasAuditError` on malformed input.
|
|
12
|
+
|
|
13
|
+
- v0.13.4 (2026-05-26) — **`b.crdt` — conflict-free replicated data types.** b.crdt adds state-based Conflict-free Replicated Data Types: data structures that independent replicas update without coordination and still converge to the same value once they have exchanged state. Each type's merge is a join over a semilattice — commutative, associative, and idempotent — so replicas can merge in any order, any number of times, and agree, which makes these the substrate for active/active cluster state, offline-first clients that reconcile on reconnect, and eventually-consistent counters, sets, and maps. The release ships the full state-based family: grow-only and positive-negative counters (gCounter / pnCounter), grow-only, two-phase, and observed-remove sets (gSet / twoPSet / orSet), a last-write-wins register (lwwRegister), and an observed-remove map (orMap). Every type exposes the same contract — local mutators, merge(other) that returns a converged instance without mutating either operand, value() for the materialized value, and state() / fromState() for a JSON-serializable form to snapshot via b.archive or b.backup or ship to a peer — and carries a replicaId so per-replica contributions stay distinct. **Added:** *`b.crdt` — state-based CvRDT counters, sets, register, and map* — `b.crdt.gCounter` / `pnCounter` (grow-only and increment/decrement counters), `b.crdt.gSet` / `twoPSet` / `orSet` (grow-only, two-phase, and observed-remove sets — `orSet` supports re-add and resolves a concurrent add-vs-remove as add-wins), `b.crdt.lwwRegister` (last-write-wins with a deterministic replicaId tie-break), and `b.crdt.orMap` (observed-remove keys with last-write-wins values). Each exposes `merge` / `value` / `state` / `fromState` and converges by the CvRDT laws. `orSet` and `orMap` accept `tombstoneRetention` to bound tombstone memory against a remove flood.
|
|
14
|
+
|
|
11
15
|
- v0.13.3 (2026-05-26) — **`b.crypto.xwing` — X-Wing hybrid post-quantum KEM.** b.crypto.xwing adds the X-Wing hybrid key-encapsulation mechanism (draft-connolly-cfrg-xwing-kem): it runs ML-KEM-768 and X25519 side by side and binds their shared secrets with SHA3-256, so an encapsulated key stays secure as long as either ML-KEM-768 or X25519 holds. That is the conservative shape for moving off classical ECDH today — a harvest-now-decrypt-later attacker must break the lattice KEM, and a hypothetical ML-KEM break still leaves X25519 standing. keygen() produces a 32-byte decapsulation seed and a 1216-byte public key; encapsulate(publicKey) returns a 1120-byte ciphertext and a 32-byte shared secret; decapsulate(secretKey, ciphertext) recovers it. The X-Wing combiner is frozen, but its specification is still an IETF Internet-Draft, so this primitive is marked experimental and sits beside the existing pre-RFC post-quantum HPKE drafts; it composes the framework's vendored ML-KEM-768 and X25519 with SHA3 and adds no new cryptographic core. The combiner is known-answer-tested byte-for-byte against the draft's definition. **Added:** *`b.crypto.xwing` — X-Wing hybrid PQ/T KEM (experimental)* — `keygen(seed?)` → `{ publicKey (1216 B), secretKey (32-byte seed) }`; `encapsulate(publicKey, eseed?)` → `{ ciphertext (1120 B), sharedSecret (32 B) }`; `decapsulate(secretKey, ciphertext)` → the 32-byte shared secret. Both `keygen` and `encapsulate` accept an optional seed for deterministic operation. The combiner — `SHA3-256(ssMLKEM ‖ ssX25519 ‖ ctX25519 ‖ pkX25519 ‖ label)` — is exposed as `combiner` for advanced use. Marked `experimental` while draft-connolly-cfrg-xwing-kem remains an Internet-Draft; the algorithm itself is frozen.
|
|
12
16
|
|
|
13
17
|
- v0.13.2 (2026-05-26) — **`b.iabTcf.encode` — write TCF consent strings, and a TC-string timestamp fix.** b.iabTcf gains the encode half of its consent-string codec: b.iabTcf.encode(obj) serialises a parsed object back into an IAB TCF v2 TC string, and b.iabTcf.isValid(tcString) is a total never-throwing validity check. Vendor and purpose collections may be Sets, id arrays, or the parsed sections parseString returns; vendor sections are written with whichever of the bit-field and range forms is smaller, matching the reference CMP encoders, so a parsed string round-trips to an equivalent signal. parseString now fully decodes the Core publisher-restrictions list and the PublisherTC segment's publisher and custom purposes, where it previously reported only the segment's presence. The encoder is verified against the worked-example string in the IAB Tech Lab consent-string specification: it re-encodes that string's Core segment byte-for-byte. This release also fixes a TC-string parsing bug — the bit reader accumulated values with a 32-bit shift, so the 36-bit Created and LastUpdated timestamp fields were silently truncated for any real date; they now decode and round-trip exactly. **Added:** *`b.iabTcf.encode` / `b.iabTcf.isValid`* — `encode(obj)` serialises a TCF object (the shape `parseString` returns) into a TC string — Core plus optional DisclosedVendors, AllowedVendors, and PublisherTC segments — choosing the smaller of the bit-field and range vendor encodings. `isValid(tcString)` returns whether a string parses as a well-formed Core segment without throwing. `parseString` now fully decodes Core publisher restrictions and the PublisherTC purposes that were previously reported only as present. **Fixed:** *TC-string 36-bit timestamps were truncated on parse* — `b.iabTcf.parseString` read multi-bit fields with a 32-bit left-shift accumulation. The 36-bit Created and LastUpdated fields hold deciseconds-since-epoch, which exceeds 2^31 for any date after 1976, so those timestamps were silently corrupted. The reader now accumulates without the 32-bit truncation; timestamps decode correctly and round-trip through `encode`.
|
package/README.md
CHANGED
|
@@ -189,6 +189,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
189
189
|
- **Content provenance** — C2PA 2.1 + California SB-942 / AB-853 manifest builder for AI-generated media (provider, model id + version, timestamp, content ID, signed) (`b.contentCredentials`)
|
|
190
190
|
- **AI usage quotas** — per-tenant / per-model budgets metered by tokens / requests / cost-usd / compute-hours over calendar-aligned windows, with an atomic conditional reserve (no charge-then-refund race) + hard/soft/warn enforcement and an optional cross-node store; defends OWASP LLM10:2025 unbounded consumption / denial-of-wallet (`b.ai.quota`)
|
|
191
191
|
- **AI capability routing** — model-capability registry (context window / modalities / tool use / reasoning tier / cost rates) + a router that picks the cheapest model satisfying a request's requirements, refusing capability mismatches before the inference call (NIST AI RMF MAP + Model Cards); composes with `b.ai.quota` cost budgets (`b.ai.capability`)
|
|
192
|
+
- **AEDT bias audit** — NYC Local Law 144 bias-audit figures (`b.ai.aedtBiasAudit`): selection / scoring rates and EEOC four-fifths-rule impact ratios across sex, race/ethnicity, and their intersection, with the most-selected group and adverse-impact flags (impact ratio < 0.8) for the annual published summary; sub-2% categories excludable per DCWP §5-301
|
|
192
193
|
### Compliance regimes
|
|
193
194
|
|
|
194
195
|
- **Posture coordinator** — `b.compliance` cascades operator-declared regime into retention / audit / db / cryptoField via POSTURE_DEFAULTS:
|
|
@@ -232,6 +233,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
232
233
|
### Production
|
|
233
234
|
|
|
234
235
|
- **Cluster + scheduling** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`)
|
|
236
|
+
- **CRDTs** — state-based conflict-free replicated data types (`b.crdt`): grow-only / PN counters, grow-only / two-phase / observed-remove sets, a last-write-wins register, and an observed-remove map; each `merge` is commutative / associative / idempotent so replicas converge with no coordination — the substrate for active/active and offline-first state, with `state()` / `fromState()` for snapshot via `b.archive` / `b.backup`
|
|
235
237
|
- **Reliability** — retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`)
|
|
236
238
|
- **Transactional integration** — outbox + dedupe-on-receive inbox; exactly-once semantics across Postgres / SQLite (`b.outbox`, `b.inbox`); Debezium-shape change-event envelope on the outbox (`b.outbox.create({ envelope: "debezium" })`)
|
|
237
239
|
- **Backup + restore** — end-to-end-encrypted bundles with pre-flush fail-closed mode + ML-DSA-87 signed manifests + scheduled backup-restore drills (`b.backup`, `b.backup.scheduleTest`, `b.backupBundle.verifyManifestSignature`); restore with pulled-bundle footprint preflight (`b.restore`); disaster-recovery runbook generator (HIPAA / PCI-DSS / GDPR / SOC 2 / DORA postures) (`b.drRunbook`)
|
package/index.js
CHANGED
|
@@ -374,6 +374,7 @@ var dualControl = require("./lib/dual-control");
|
|
|
374
374
|
var retention = require("./lib/retention");
|
|
375
375
|
var legalHold = require("./lib/legal-hold");
|
|
376
376
|
var worm = require("./lib/worm");
|
|
377
|
+
var crdt = require("./lib/crdt");
|
|
377
378
|
var network = require("./lib/network");
|
|
378
379
|
var cloudEvents = require("./lib/cloud-events");
|
|
379
380
|
var dsr = require("./lib/dsr");
|
|
@@ -476,6 +477,7 @@ module.exports = {
|
|
|
476
477
|
quota: require("./lib/ai-quota"),
|
|
477
478
|
capability: require("./lib/ai-capability"),
|
|
478
479
|
dp: require("./lib/ai-dp"),
|
|
480
|
+
aedtBiasAudit: require("./lib/ai-aedt-bias-audit"),
|
|
479
481
|
},
|
|
480
482
|
promisePool: require("./lib/promise-pool"),
|
|
481
483
|
sdNotify: require("./lib/sd-notify"),
|
|
@@ -713,6 +715,7 @@ module.exports = {
|
|
|
713
715
|
retention: retention,
|
|
714
716
|
legalHold: legalHold,
|
|
715
717
|
worm: worm,
|
|
718
|
+
crdt: crdt,
|
|
716
719
|
network: network,
|
|
717
720
|
cloudEvents: cloudEvents,
|
|
718
721
|
dsr: dsr,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.ai.aedtBiasAudit
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title AEDT Bias Audit
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Compute the bias-audit statistics New York City Local Law 144 requires
|
|
9
|
+
* before an Automated Employment Decision Tool (AEDT) may be used to screen
|
|
10
|
+
* candidates or employees. The law (NYC Admin. Code §20-870 et seq., in force
|
|
11
|
+
* since 2023-07-05; DCWP rules 6 RCNY §5-300 et seq.) requires an independent
|
|
12
|
+
* annual audit that reports, for each demographic category, the rate at which
|
|
13
|
+
* the tool selects (or scores above the median for) that group and the
|
|
14
|
+
* <em>impact ratio</em> — that group's rate divided by the rate of the
|
|
15
|
+
* most-selected group. An impact ratio below the four-fifths (0.8) threshold
|
|
16
|
+
* from the EEOC Uniform Guidelines flags potential adverse impact; the law
|
|
17
|
+
* requires the number to be calculated and published, not any particular
|
|
18
|
+
* remediation.
|
|
19
|
+
*
|
|
20
|
+
* The audit is computed across three dimensions: sex, race/ethnicity, and
|
|
21
|
+
* the intersection of the two, using the EEOC categories. Categories that
|
|
22
|
+
* make up less than 2% of the audited data may be excluded from the impact-
|
|
23
|
+
* ratio calculation at the auditor's discretion (DCWP §5-301). This primitive
|
|
24
|
+
* takes the per-category counts — selected/total for a pass-fail tool, or
|
|
25
|
+
* scored-above-the-overall-median/total for a continuous-score tool — and
|
|
26
|
+
* returns the selection (or scoring) rate, impact ratio, and adverse-impact
|
|
27
|
+
* flag per group, plus the most-selected group and an overall flag. It is a
|
|
28
|
+
* pure calculation: the operator supplies the data an independent auditor
|
|
29
|
+
* collected, and gets back the figures the published summary must contain.
|
|
30
|
+
*
|
|
31
|
+
* @card
|
|
32
|
+
* NYC Local Law 144 AEDT bias audit (`b.ai.aedtBiasAudit`) — selection /
|
|
33
|
+
* scoring rates and four-fifths-rule impact ratios across sex, race/ethnicity,
|
|
34
|
+
* and their intersection, with the most-selected group and adverse-impact
|
|
35
|
+
* flags for the published audit summary.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var validateOpts = require("./validate-opts");
|
|
39
|
+
var { defineClass } = require("./framework-error");
|
|
40
|
+
|
|
41
|
+
var AedtBiasAuditError = defineClass("AedtBiasAuditError", { alwaysPermanent: true });
|
|
42
|
+
|
|
43
|
+
var FOUR_FIFTHS = 4 / 5; // EEOC Uniform Guidelines four-fifths adverse-impact threshold
|
|
44
|
+
var DEFAULT_MIN_SHARE = 0.02; // DCWP §5-301 — categories under 2% may be excluded
|
|
45
|
+
var DIMENSIONS = ["sex", "raceEthnicity", "intersectional"];
|
|
46
|
+
|
|
47
|
+
function _str(v, label) {
|
|
48
|
+
if (typeof v !== "string" || v.length === 0) throw new AedtBiasAuditError("aedt/bad-metadata", "aedtBiasAudit: metadata." + label + " must be a non-empty string");
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
function _count(v, label) {
|
|
52
|
+
if (typeof v !== "number" || !isFinite(v) || v < 0 || Math.floor(v) !== v) throw new AedtBiasAuditError("aedt/bad-count", "aedtBiasAudit: " + label + " must be a non-negative integer");
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reduce one dimension's per-group counts to the LL-144 figures.
|
|
57
|
+
function _auditDimension(groups, type, minShare) {
|
|
58
|
+
var names = Object.keys(groups);
|
|
59
|
+
var rows = [];
|
|
60
|
+
var dimensionTotal = 0;
|
|
61
|
+
var numeratorKey = type === "scoring" ? "scoredAboveMedian" : "selected";
|
|
62
|
+
|
|
63
|
+
names.forEach(function (name) {
|
|
64
|
+
var g = groups[name];
|
|
65
|
+
if (!g || typeof g !== "object") throw new AedtBiasAuditError("aedt/bad-count", "aedtBiasAudit: group '" + name + "' must be an object with " + numeratorKey + " + total");
|
|
66
|
+
var total = _count(g.total, name + ".total");
|
|
67
|
+
var num = _count(g[numeratorKey], name + "." + numeratorKey);
|
|
68
|
+
if (num > total) throw new AedtBiasAuditError("aedt/bad-count", "aedtBiasAudit: " + name + "." + numeratorKey + " (" + num + ") exceeds total (" + total + ")");
|
|
69
|
+
dimensionTotal += total;
|
|
70
|
+
rows.push({ category: name, total: total, _num: num, rate: total === 0 ? 0 : num / total });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Exclude sub-threshold categories (auditor discretion), then find the
|
|
74
|
+
// most-selected group among those that remain.
|
|
75
|
+
var maxRate = 0;
|
|
76
|
+
var mostSelected = null;
|
|
77
|
+
rows.forEach(function (r) {
|
|
78
|
+
r.share = dimensionTotal === 0 ? 0 : r.total / dimensionTotal;
|
|
79
|
+
r.excluded = dimensionTotal > 0 && r.share < minShare;
|
|
80
|
+
if (!r.excluded && r.rate > maxRate) { maxRate = r.rate; mostSelected = r.category; }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
rows.forEach(function (r) {
|
|
84
|
+
r.impactRatio = (r.excluded || maxRate === 0) ? null : r.rate / maxRate;
|
|
85
|
+
r.adverseImpact = r.impactRatio !== null && r.impactRatio < FOUR_FIFTHS;
|
|
86
|
+
delete r._num;
|
|
87
|
+
});
|
|
88
|
+
// Stable order: highest rate first, then category name.
|
|
89
|
+
rows.sort(function (a, b) { return b.rate - a.rate || (a.category < b.category ? -1 : a.category > b.category ? 1 : 0); });
|
|
90
|
+
return { rows: rows, mostSelected: mostSelected };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @primitive b.ai.aedtBiasAudit
|
|
95
|
+
* @signature b.ai.aedtBiasAudit(opts)
|
|
96
|
+
* @since 0.13.5
|
|
97
|
+
* @status stable
|
|
98
|
+
* @compliance nyc-ll144, soc2
|
|
99
|
+
* @related b.ai.disclosure.applyAll, b.ai.disclosure.chatbot
|
|
100
|
+
*
|
|
101
|
+
* Compute the NYC Local Law 144 bias-audit figures from per-category counts.
|
|
102
|
+
* <code>type</code> is <code>"selection"</code> for a pass-fail tool (each
|
|
103
|
+
* group entry is <code>{ selected, total }</code>) or <code>"scoring"</code>
|
|
104
|
+
* for a continuous-score tool (<code>{ scoredAboveMedian, total }</code>, where
|
|
105
|
+
* the count is candidates scoring above the <em>overall</em> median). Returns
|
|
106
|
+
* the selection/scoring rate, impact ratio (group rate ÷ most-selected group's
|
|
107
|
+
* rate), and an <code>adverseImpact</code> flag (impact ratio < 0.8) per
|
|
108
|
+
* group, across the <code>sex</code>, <code>raceEthnicity</code>, and
|
|
109
|
+
* <code>intersectional</code> dimensions, plus the most-selected group per
|
|
110
|
+
* dimension. Categories under <code>minCategoryShare</code> (2% by default) are
|
|
111
|
+
* marked <code>excluded</code> and left out of the impact-ratio basis. Throws
|
|
112
|
+
* <code>AedtBiasAuditError</code> on malformed input. The result is the data an
|
|
113
|
+
* employer must publish; the law mandates the calculation, not any remediation.
|
|
114
|
+
*
|
|
115
|
+
* @opts
|
|
116
|
+
* type: string, // "selection" | "scoring" (required)
|
|
117
|
+
* metadata: object, // { tool, auditor, auditDate, distributionDate? } (tool/auditor/auditDate required)
|
|
118
|
+
* categories: object, // { sex?, raceEthnicity?, intersectional? } → { <group>: { selected|scoredAboveMedian, total } }
|
|
119
|
+
* minCategoryShare: number, // default: 0.02 (DCWP §5-301 — sub-2% categories may be excluded)
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* var report = b.ai.aedtBiasAudit({
|
|
123
|
+
* type: "selection",
|
|
124
|
+
* metadata: { tool: "ResumeRanker v3", auditor: "Acme Audit LLC", auditDate: "2026-05-26" },
|
|
125
|
+
* categories: { sex: { Male: { selected: 60, total: 100 }, Female: { selected: 42, total: 100 } } },
|
|
126
|
+
* });
|
|
127
|
+
* report.results.sex[1].impactRatio; // → 0.7 (Female: 42% / 60%)
|
|
128
|
+
* report.results.sex[1].adverseImpact; // → true (below the 0.8 four-fifths threshold)
|
|
129
|
+
*/
|
|
130
|
+
function aedtBiasAudit(opts) {
|
|
131
|
+
opts = opts || {};
|
|
132
|
+
// Surface an unknown/typoed option as this primitive's own error type rather
|
|
133
|
+
// than the generic Error validateOpts throws, so the malformed-input contract
|
|
134
|
+
// (AedtBiasAuditError / e.code) holds for every bad-config path.
|
|
135
|
+
try { validateOpts(opts, ["type", "metadata", "categories", "minCategoryShare"], "aedtBiasAudit"); }
|
|
136
|
+
catch (e) { throw new AedtBiasAuditError("aedt/bad-opts", e && e.message || "aedtBiasAudit: invalid options"); }
|
|
137
|
+
if (opts.type !== "selection" && opts.type !== "scoring") throw new AedtBiasAuditError("aedt/bad-type", "aedtBiasAudit: type must be 'selection' or 'scoring'");
|
|
138
|
+
|
|
139
|
+
var md = opts.metadata || {};
|
|
140
|
+
var metadata = {
|
|
141
|
+
tool: _str(md.tool, "tool"),
|
|
142
|
+
auditor: _str(md.auditor, "auditor"),
|
|
143
|
+
auditDate: _str(md.auditDate, "auditDate"),
|
|
144
|
+
distributionDate: md.distributionDate != null ? _str(md.distributionDate, "distributionDate") : null,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
var minShare = opts.minCategoryShare != null ? opts.minCategoryShare : DEFAULT_MIN_SHARE;
|
|
148
|
+
if (typeof minShare !== "number" || !isFinite(minShare) || minShare < 0 || minShare >= 1) throw new AedtBiasAuditError("aedt/bad-share", "aedtBiasAudit: minCategoryShare must be a number in [0, 1)");
|
|
149
|
+
|
|
150
|
+
var cats = opts.categories || {};
|
|
151
|
+
var present = DIMENSIONS.filter(function (d) { return cats[d] && typeof cats[d] === "object" && Object.keys(cats[d]).length > 0; });
|
|
152
|
+
if (present.length === 0) throw new AedtBiasAuditError("aedt/no-categories", "aedtBiasAudit: at least one of sex / raceEthnicity / intersectional must carry group counts");
|
|
153
|
+
|
|
154
|
+
var results = {};
|
|
155
|
+
var mostSelected = {};
|
|
156
|
+
var adverseImpactGroups = [];
|
|
157
|
+
present.forEach(function (dim) {
|
|
158
|
+
var out = _auditDimension(cats[dim], opts.type, minShare);
|
|
159
|
+
results[dim] = out.rows;
|
|
160
|
+
mostSelected[dim] = out.mostSelected;
|
|
161
|
+
out.rows.forEach(function (r) { if (r.adverseImpact) adverseImpactGroups.push({ dimension: dim, category: r.category, impactRatio: r.impactRatio }); });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
type: opts.type,
|
|
166
|
+
metadata: metadata,
|
|
167
|
+
results: results,
|
|
168
|
+
summary: {
|
|
169
|
+
mostSelected: mostSelected,
|
|
170
|
+
adverseImpactGroups: adverseImpactGroups,
|
|
171
|
+
anyAdverseImpact: adverseImpactGroups.length > 0,
|
|
172
|
+
fourFifthsThreshold: FOUR_FIFTHS,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
aedtBiasAudit.FOUR_FIFTHS = FOUR_FIFTHS;
|
|
178
|
+
aedtBiasAudit.AedtBiasAuditError = AedtBiasAuditError;
|
|
179
|
+
|
|
180
|
+
module.exports = aedtBiasAudit;
|
package/lib/crdt.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.crdt
|
|
4
|
+
* @nav Data
|
|
5
|
+
* @title CRDTs
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Conflict-free Replicated Data Types — data structures that several
|
|
9
|
+
* replicas can update independently, with no coordination, and still
|
|
10
|
+
* converge to the same value once they have all seen each other's state.
|
|
11
|
+
* These are the state-based CvRDTs: each type's <code>merge</code> is a
|
|
12
|
+
* join over a semilattice, so it is commutative, associative, and
|
|
13
|
+
* idempotent — replicas can merge in any order, any number of times, and
|
|
14
|
+
* land on the same result. That makes them the substrate for eventually-
|
|
15
|
+
* consistent state across an active/active cluster, offline-first clients
|
|
16
|
+
* that reconcile on reconnect, or any "last writer need not win, but
|
|
17
|
+
* everyone agrees" counter / set / register / map.
|
|
18
|
+
*
|
|
19
|
+
* Every type exposes the same contract: local mutators (e.g.
|
|
20
|
+
* <code>inc</code>, <code>add</code>, <code>set</code>),
|
|
21
|
+
* <code>merge(other)</code> which returns a new converged instance without
|
|
22
|
+
* mutating either operand, <code>value()</code> for the materialized value,
|
|
23
|
+
* and <code>state()</code> / <code>fromState()</code> for a JSON-
|
|
24
|
+
* serializable form to snapshot (via <code>b.archive</code> /
|
|
25
|
+
* <code>b.backup</code>) or ship to a peer. Each replica carries a
|
|
26
|
+
* <code>replicaId</code> so per-replica contributions stay distinct.
|
|
27
|
+
*
|
|
28
|
+
* This release covers the state-based family — grow-only and PN counters,
|
|
29
|
+
* grow-only / two-phase / observed-remove sets, a last-write-wins register,
|
|
30
|
+
* and an observed-remove map. Operation-based sequence CRDTs (RGA), delta-
|
|
31
|
+
* state mutators, and a live event-bus replicator are not included; the
|
|
32
|
+
* state-based types merge correctly without a causal channel, which is the
|
|
33
|
+
* whole point.
|
|
34
|
+
*
|
|
35
|
+
* @card
|
|
36
|
+
* Conflict-free Replicated Data Types (`b.crdt`) — state-based CvRDT
|
|
37
|
+
* counters, sets, a last-write-wins register, and an observed-remove map
|
|
38
|
+
* whose `merge` is commutative, associative, and idempotent, so replicas
|
|
39
|
+
* converge with no coordination.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
var bCrypto = require("./crypto");
|
|
43
|
+
var safeJson = require("./safe-json");
|
|
44
|
+
var { defineClass } = require("./framework-error");
|
|
45
|
+
|
|
46
|
+
var CrdtError = defineClass("CrdtError", { alwaysPermanent: true });
|
|
47
|
+
|
|
48
|
+
function _replicaId(opts) {
|
|
49
|
+
var id = opts && opts.replicaId;
|
|
50
|
+
if (id == null) return bCrypto.generateToken(8); // allow:raw-byte-literal — random replica-id token length
|
|
51
|
+
if (typeof id !== "string" || id.length === 0) throw new CrdtError("crdt/bad-replica-id", "crdt: replicaId must be a non-empty string");
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
function _posInt(n, label) {
|
|
55
|
+
if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) throw new CrdtError("crdt/bad-value", "crdt: " + label + " must be a non-negative integer");
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
function _maxMerge(a, b) {
|
|
59
|
+
var out = {};
|
|
60
|
+
var k;
|
|
61
|
+
for (k in a) if (Object.prototype.hasOwnProperty.call(a, k)) out[k] = a[k];
|
|
62
|
+
for (k in b) if (Object.prototype.hasOwnProperty.call(b, k)) out[k] = (out[k] === undefined || b[k] > out[k]) ? b[k] : out[k];
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @primitive b.crdt.gCounter
|
|
68
|
+
* @signature b.crdt.gCounter(opts?)
|
|
69
|
+
* @since 0.13.4
|
|
70
|
+
* @status stable
|
|
71
|
+
* @compliance soc2
|
|
72
|
+
* @related b.crdt.pnCounter, b.crdt.gSet
|
|
73
|
+
*
|
|
74
|
+
* A grow-only counter: each replica tracks its own increment-only tally, and
|
|
75
|
+
* the value is their sum. <code>merge</code> takes the per-replica maximum, so
|
|
76
|
+
* it converges no matter the order. Increments only — use
|
|
77
|
+
* <code>pnCounter</code> when you also need to decrement.
|
|
78
|
+
*
|
|
79
|
+
* @opts
|
|
80
|
+
* replicaId: string, // this replica's id (default: random)
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* var a = b.crdt.gCounter({ replicaId: "a" }).inc(3);
|
|
84
|
+
* var c = b.crdt.gCounter({ replicaId: "c" }).inc(5);
|
|
85
|
+
* a.merge(c).value(); // → 8
|
|
86
|
+
*/
|
|
87
|
+
function gCounter(opts) {
|
|
88
|
+
opts = opts || {};
|
|
89
|
+
var replicaId = _replicaId(opts);
|
|
90
|
+
var counts = {};
|
|
91
|
+
if (opts._counts) { for (var k in opts._counts) if (Object.prototype.hasOwnProperty.call(opts._counts, k)) counts[k] = opts._counts[k]; }
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
type: "gCounter",
|
|
95
|
+
replicaId: replicaId,
|
|
96
|
+
inc: function (n) { n = n == null ? 1 : _posInt(n, "inc"); counts[replicaId] = (counts[replicaId] || 0) + n; return this; },
|
|
97
|
+
value: function () { var s = 0; for (var k in counts) if (Object.prototype.hasOwnProperty.call(counts, k)) s += counts[k]; return s; },
|
|
98
|
+
state: function () { return { type: "gCounter", counts: Object.assign({}, counts) }; },
|
|
99
|
+
merge: function (other) { return gCounter({ replicaId: replicaId, _counts: _maxMerge(counts, _otherState(other, "gCounter").counts) }); },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
gCounter.fromState = function (s, opts) { _assertState(s, "gCounter"); return gCounter(Object.assign({}, opts, { _counts: s.counts })); };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @primitive b.crdt.pnCounter
|
|
106
|
+
* @signature b.crdt.pnCounter(opts?)
|
|
107
|
+
* @since 0.13.4
|
|
108
|
+
* @status stable
|
|
109
|
+
* @compliance soc2
|
|
110
|
+
* @related b.crdt.gCounter, b.crdt.lwwRegister
|
|
111
|
+
*
|
|
112
|
+
* A positive-negative counter: two grow-only counters (increments and
|
|
113
|
+
* decrements) whose difference is the value, so it supports both
|
|
114
|
+
* <code>inc</code> and <code>dec</code> and still converges.
|
|
115
|
+
*
|
|
116
|
+
* @opts
|
|
117
|
+
* replicaId: string, // this replica's id (default: random)
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* var a = b.crdt.pnCounter({ replicaId: "a" }).inc(5).dec(2);
|
|
121
|
+
* var c = b.crdt.pnCounter({ replicaId: "c" }).inc(1);
|
|
122
|
+
* a.merge(c).value(); // → 4
|
|
123
|
+
*/
|
|
124
|
+
function pnCounter(opts) {
|
|
125
|
+
opts = opts || {};
|
|
126
|
+
var replicaId = _replicaId(opts);
|
|
127
|
+
var p = gCounter({ replicaId: replicaId, _counts: opts._p });
|
|
128
|
+
var n = gCounter({ replicaId: replicaId, _counts: opts._n });
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
type: "pnCounter",
|
|
132
|
+
replicaId: replicaId,
|
|
133
|
+
inc: function (by) { p.inc(by); return this; },
|
|
134
|
+
dec: function (by) { n.inc(by); return this; },
|
|
135
|
+
value: function () { return p.value() - n.value(); },
|
|
136
|
+
state: function () { return { type: "pnCounter", p: p.state().counts, n: n.state().counts }; },
|
|
137
|
+
merge: function (other) {
|
|
138
|
+
var o = _otherState(other, "pnCounter");
|
|
139
|
+
return pnCounter({ replicaId: replicaId, _p: _maxMerge(p.state().counts, o.p), _n: _maxMerge(n.state().counts, o.n) });
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
pnCounter.fromState = function (s, opts) { _assertState(s, "pnCounter"); return pnCounter(Object.assign({}, opts, { _p: s.p, _n: s.n })); };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @primitive b.crdt.gSet
|
|
147
|
+
* @signature b.crdt.gSet(opts?)
|
|
148
|
+
* @since 0.13.4
|
|
149
|
+
* @status stable
|
|
150
|
+
* @compliance soc2
|
|
151
|
+
* @related b.crdt.twoPSet, b.crdt.orSet
|
|
152
|
+
*
|
|
153
|
+
* A grow-only set: elements can be added but never removed; <code>merge</code>
|
|
154
|
+
* is set union. The simplest convergent set — reach for <code>orSet</code>
|
|
155
|
+
* when removal is needed. Elements may be strings or JSON-serializable values.
|
|
156
|
+
*
|
|
157
|
+
* @opts
|
|
158
|
+
* replicaId: string, // this replica's id (default: random)
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* var a = b.crdt.gSet().add("x");
|
|
162
|
+
* var c = b.crdt.gSet().add("y");
|
|
163
|
+
* a.merge(c).value(); // → ["x", "y"]
|
|
164
|
+
*/
|
|
165
|
+
function gSet(opts) {
|
|
166
|
+
opts = opts || {};
|
|
167
|
+
var replicaId = _replicaId(opts);
|
|
168
|
+
var els = new Set(opts._els || []);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
type: "gSet",
|
|
172
|
+
replicaId: replicaId,
|
|
173
|
+
add: function (x) { els.add(_key(x)); return this; },
|
|
174
|
+
has: function (x) { return els.has(_key(x)); },
|
|
175
|
+
value: function () { return _decodeKeys(els); },
|
|
176
|
+
state: function () { return { type: "gSet", els: Array.from(els) }; },
|
|
177
|
+
merge: function (other) { var o = _otherState(other, "gSet"); var u = new Set(els); o.els.forEach(function (e) { u.add(e); }); return gSet({ replicaId: replicaId, _els: u }); },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
gSet.fromState = function (s, opts) { _assertState(s, "gSet"); return gSet(Object.assign({}, opts, { _els: s.els })); };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @primitive b.crdt.twoPSet
|
|
184
|
+
* @signature b.crdt.twoPSet(opts?)
|
|
185
|
+
* @since 0.13.4
|
|
186
|
+
* @status stable
|
|
187
|
+
* @compliance soc2
|
|
188
|
+
* @related b.crdt.gSet, b.crdt.orSet
|
|
189
|
+
*
|
|
190
|
+
* A two-phase set: an add-set and a remove-set (tombstones). An element can be
|
|
191
|
+
* added and removed, but once removed it can never be re-added — remove wins
|
|
192
|
+
* permanently. When re-adding must work, use <code>orSet</code>.
|
|
193
|
+
*
|
|
194
|
+
* @opts
|
|
195
|
+
* replicaId: string, // this replica's id (default: random)
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* var s = b.crdt.twoPSet().add("a").add("b").remove("a");
|
|
199
|
+
* s.value(); // → ["b"]
|
|
200
|
+
*/
|
|
201
|
+
function twoPSet(opts) {
|
|
202
|
+
opts = opts || {};
|
|
203
|
+
var replicaId = _replicaId(opts);
|
|
204
|
+
var adds = new Set(opts._adds || []);
|
|
205
|
+
var removes = new Set(opts._removes || []);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
type: "twoPSet",
|
|
209
|
+
replicaId: replicaId,
|
|
210
|
+
add: function (x) { adds.add(_key(x)); return this; },
|
|
211
|
+
remove: function (x) { var k = _key(x); if (adds.has(k)) removes.add(k); return this; },
|
|
212
|
+
has: function (x) { var k = _key(x); return adds.has(k) && !removes.has(k); },
|
|
213
|
+
value: function () { var live = new Set(); adds.forEach(function (k) { if (!removes.has(k)) live.add(k); }); return _decodeKeys(live); },
|
|
214
|
+
state: function () { return { type: "twoPSet", adds: Array.from(adds), removes: Array.from(removes) }; },
|
|
215
|
+
merge: function (other) {
|
|
216
|
+
var o = _otherState(other, "twoPSet");
|
|
217
|
+
var a = new Set(adds), r = new Set(removes);
|
|
218
|
+
o.adds.forEach(function (e) { a.add(e); });
|
|
219
|
+
o.removes.forEach(function (e) { r.add(e); });
|
|
220
|
+
return twoPSet({ replicaId: replicaId, _adds: a, _removes: r });
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
twoPSet.fromState = function (s, opts) { _assertState(s, "twoPSet"); return twoPSet(Object.assign({}, opts, { _adds: s.adds, _removes: s.removes })); };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @primitive b.crdt.orSet
|
|
228
|
+
* @signature b.crdt.orSet(opts?)
|
|
229
|
+
* @since 0.13.4
|
|
230
|
+
* @status stable
|
|
231
|
+
* @compliance soc2
|
|
232
|
+
* @related b.crdt.gSet, b.crdt.twoPSet, b.crdt.orMap
|
|
233
|
+
*
|
|
234
|
+
* An observed-remove set: each add stamps a unique tag, and remove tombstones
|
|
235
|
+
* the tags it has observed for that element, so an element survives if any
|
|
236
|
+
* concurrent add was not seen by the remove — re-adding works, and a
|
|
237
|
+
* concurrent add-vs-remove resolves add-wins. <code>tombstoneRetention</code>
|
|
238
|
+
* optionally caps the tombstone set to bound memory against a remove flood; it
|
|
239
|
+
* drops the oldest tombstones, which can resurrect a concurrently-removed
|
|
240
|
+
* element, so leave it unset unless that trade-off is acceptable.
|
|
241
|
+
*
|
|
242
|
+
* Each add stamps a unique tag; remove tombstones the tags currently observed
|
|
243
|
+
* for that element. An element is present if it has a live (un-tombstoned) tag.
|
|
244
|
+
*
|
|
245
|
+
* @opts
|
|
246
|
+
* replicaId: string, // this replica's id (default: random)
|
|
247
|
+
* tombstoneRetention: number, // optional cap on retained tombstones (default: unbounded)
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* var a = b.crdt.orSet().add("x");
|
|
251
|
+
* var c = b.crdt.orSet.fromState(a.state()).add("x"); // re-add elsewhere
|
|
252
|
+
* a.remove("x");
|
|
253
|
+
* a.merge(c).value(); // → ["x"] (concurrent re-add survives)
|
|
254
|
+
*/
|
|
255
|
+
function orSet(opts) {
|
|
256
|
+
opts = opts || {};
|
|
257
|
+
var replicaId = _replicaId(opts);
|
|
258
|
+
var tombstoneRetention = opts.tombstoneRetention;
|
|
259
|
+
if (tombstoneRetention != null) _posInt(tombstoneRetention, "tombstoneRetention");
|
|
260
|
+
// elems: key -> array of tags ; tombstones: Set of removed tags
|
|
261
|
+
var elems = {};
|
|
262
|
+
if (opts._elems) { for (var k in opts._elems) if (Object.prototype.hasOwnProperty.call(opts._elems, k)) elems[k] = opts._elems[k].slice(); }
|
|
263
|
+
var tombstones = new Set(opts._tombstones || []);
|
|
264
|
+
var seq = 0;
|
|
265
|
+
|
|
266
|
+
function _tag() { return replicaId + ":" + (++seq) + ":" + bCrypto.generateToken(4); }
|
|
267
|
+
function _liveTags(key) { return (elems[key] || []).filter(function (t) { return !tombstones.has(t); }); }
|
|
268
|
+
function _gcTombstones() {
|
|
269
|
+
if (tombstoneRetention == null || tombstones.size <= tombstoneRetention) return;
|
|
270
|
+
// Bounded-memory tradeoff (opt-in): drop oldest tombstones. Documented to
|
|
271
|
+
// possibly resurrect a concurrently-removed element — full causal GC ships
|
|
272
|
+
// with the replicator slice.
|
|
273
|
+
var arr = Array.from(tombstones);
|
|
274
|
+
tombstones = new Set(arr.slice(arr.length - tombstoneRetention));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
type: "orSet",
|
|
279
|
+
replicaId: replicaId,
|
|
280
|
+
add: function (x) { var key = _key(x); (elems[key] = elems[key] || []).push(_tag()); return this; },
|
|
281
|
+
remove: function (x) { var key = _key(x); _liveTags(key).forEach(function (t) { tombstones.add(t); }); _gcTombstones(); return this; },
|
|
282
|
+
has: function (x) { return _liveTags(_key(x)).length > 0; },
|
|
283
|
+
value: function () { var live = []; for (var key in elems) if (Object.prototype.hasOwnProperty.call(elems, key) && _liveTags(key).length > 0) live.push(key); return _decodeKeys(new Set(live)); },
|
|
284
|
+
state: function () { var e = {}; for (var key in elems) if (Object.prototype.hasOwnProperty.call(elems, key)) e[key] = elems[key].slice(); return { type: "orSet", elems: e, tombstones: Array.from(tombstones) }; },
|
|
285
|
+
merge: function (other) {
|
|
286
|
+
var o = _otherState(other, "orSet");
|
|
287
|
+
var e = {};
|
|
288
|
+
var key;
|
|
289
|
+
for (key in elems) if (Object.prototype.hasOwnProperty.call(elems, key)) e[key] = elems[key].slice();
|
|
290
|
+
for (key in o.elems) if (Object.prototype.hasOwnProperty.call(o.elems, key)) {
|
|
291
|
+
var merged = (e[key] || []).concat(o.elems[key]);
|
|
292
|
+
e[key] = Array.from(new Set(merged)); // union of tags, dedup
|
|
293
|
+
}
|
|
294
|
+
var ts = new Set(tombstones);
|
|
295
|
+
o.tombstones.forEach(function (t) { ts.add(t); });
|
|
296
|
+
return orSet({ replicaId: replicaId, tombstoneRetention: tombstoneRetention, _elems: e, _tombstones: ts });
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
orSet.fromState = function (s, opts) { _assertState(s, "orSet"); return orSet(Object.assign({}, opts, { _elems: s.elems, _tombstones: s.tombstones })); };
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @primitive b.crdt.lwwRegister
|
|
304
|
+
* @signature b.crdt.lwwRegister(opts?)
|
|
305
|
+
* @since 0.13.4
|
|
306
|
+
* @status stable
|
|
307
|
+
* @compliance soc2
|
|
308
|
+
* @related b.crdt.pnCounter, b.crdt.orMap
|
|
309
|
+
*
|
|
310
|
+
* A last-write-wins register: holds a single value with a timestamp;
|
|
311
|
+
* <code>merge</code> keeps the higher-timestamped write, breaking ties by the
|
|
312
|
+
* higher <code>replicaId</code> so the outcome is deterministic. Pass an
|
|
313
|
+
* explicit timestamp to <code>set</code> for a logical clock, or omit it to
|
|
314
|
+
* use wall-clock milliseconds.
|
|
315
|
+
*
|
|
316
|
+
* @opts
|
|
317
|
+
* replicaId: string, // this replica's id (default: random)
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* var a = b.crdt.lwwRegister({ replicaId: "a" }).set("first", 1);
|
|
321
|
+
* var c = b.crdt.lwwRegister({ replicaId: "c" }).set("second", 2);
|
|
322
|
+
* a.merge(c).value(); // → "second"
|
|
323
|
+
*/
|
|
324
|
+
function lwwRegister(opts) {
|
|
325
|
+
opts = opts || {};
|
|
326
|
+
var replicaId = _replicaId(opts);
|
|
327
|
+
var current = opts._current || { value: null, ts: -1, replicaId: "" };
|
|
328
|
+
|
|
329
|
+
function _beats(a, b) { return a.ts > b.ts || (a.ts === b.ts && a.replicaId > b.replicaId); }
|
|
330
|
+
return {
|
|
331
|
+
type: "lwwRegister",
|
|
332
|
+
replicaId: replicaId,
|
|
333
|
+
set: function (v, ts) { ts = ts == null ? Date.now() : _posInt(ts, "ts"); var cand = { value: v, ts: ts, replicaId: replicaId }; if (_beats(cand, current)) current = cand; return this; },
|
|
334
|
+
value: function () { return current.value; },
|
|
335
|
+
timestamp: function () { return current.ts; },
|
|
336
|
+
state: function () { return { type: "lwwRegister", current: { value: current.value, ts: current.ts, replicaId: current.replicaId } }; },
|
|
337
|
+
merge: function (other) { var o = _otherState(other, "lwwRegister"); var win = _beats(o.current, current) ? o.current : current; return lwwRegister({ replicaId: replicaId, _current: { value: win.value, ts: win.ts, replicaId: win.replicaId } }); },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
lwwRegister.fromState = function (s, opts) { _assertState(s, "lwwRegister"); return lwwRegister(Object.assign({}, opts, { _current: s.current })); };
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @primitive b.crdt.orMap
|
|
344
|
+
* @signature b.crdt.orMap(opts?)
|
|
345
|
+
* @since 0.13.4
|
|
346
|
+
* @status stable
|
|
347
|
+
* @compliance soc2
|
|
348
|
+
* @related b.crdt.orSet, b.crdt.lwwRegister
|
|
349
|
+
*
|
|
350
|
+
* An observed-remove map: key presence follows observed-remove-set semantics
|
|
351
|
+
* (a key can be set, removed, and set again), and each key's value is a
|
|
352
|
+
* last-write-wins register, so concurrent writes to a live key converge by
|
|
353
|
+
* timestamp (higher wins, ties by replicaId). Removing a key clears its value
|
|
354
|
+
* register locally, so a re-add on the same replica starts clean; across
|
|
355
|
+
* replicas the value is strictly last-write-wins by timestamp — supply
|
|
356
|
+
* monotonic timestamps (the default wall-clock does) for re-add to win. Keys
|
|
357
|
+
* are non-empty strings.
|
|
358
|
+
*
|
|
359
|
+
* Keys follow OR-Set add/remove semantics; each key's value is an LWW register,
|
|
360
|
+
* so concurrent writes to the same key converge by last-write-wins.
|
|
361
|
+
*
|
|
362
|
+
* @opts
|
|
363
|
+
* replicaId: string, // this replica's id (default: random)
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* var a = b.crdt.orMap({ replicaId: "a" }).set("k", "v1", 1);
|
|
367
|
+
* var c = b.crdt.orMap({ replicaId: "c" }).set("k", "v2", 2);
|
|
368
|
+
* a.merge(c).value(); // → { k: "v2" }
|
|
369
|
+
*/
|
|
370
|
+
function orMap(opts) {
|
|
371
|
+
opts = opts || {};
|
|
372
|
+
var replicaId = _replicaId(opts);
|
|
373
|
+
var keys = orSet({ replicaId: replicaId, _elems: opts._keyElems, _tombstones: opts._keyTombstones });
|
|
374
|
+
var vals = {}; // key -> lwwRegister state
|
|
375
|
+
if (opts._vals) { for (var k in opts._vals) if (Object.prototype.hasOwnProperty.call(opts._vals, k)) vals[k] = opts._vals[k]; }
|
|
376
|
+
|
|
377
|
+
function _reg(key) { return lwwRegister.fromState(vals[key] || { type: "lwwRegister", current: { value: null, ts: -1, replicaId: "" } }, { replicaId: replicaId }); }
|
|
378
|
+
// Keys pass to the OR-Set raw (it encodes internally) and index `vals`
|
|
379
|
+
// directly, so the materialized value is keyed by the plain string.
|
|
380
|
+
return {
|
|
381
|
+
type: "orMap",
|
|
382
|
+
replicaId: replicaId,
|
|
383
|
+
set: function (key, v, ts) { key = _mapKey(key); keys.add(key); vals[key] = _reg(key).set(v, ts).state(); return this; },
|
|
384
|
+
// Removing a key clears its value register, so re-adding the key on this
|
|
385
|
+
// replica starts from a clean last-write-wins state rather than reusing the
|
|
386
|
+
// pre-remove value. (Across replicas the value still follows last-write-wins
|
|
387
|
+
// by timestamp — a concurrent, un-removed higher-timestamped write wins;
|
|
388
|
+
// making a re-add causally supersede needs vector clocks, which ship with
|
|
389
|
+
// the replicator slice.)
|
|
390
|
+
remove: function (key) { key = _mapKey(key); keys.remove(key); delete vals[key]; return this; },
|
|
391
|
+
has: function (key) { return keys.has(_mapKey(key)); },
|
|
392
|
+
get: function (key) { key = _mapKey(key); return keys.has(key) ? _reg(key).value() : undefined; },
|
|
393
|
+
value: function () { var out = {}; keys.value().forEach(function (key) { out[key] = _reg(key).value(); }); return out; },
|
|
394
|
+
state: function () { var ks = keys.state(); return { type: "orMap", keyElems: ks.elems, keyTombstones: ks.tombstones, vals: Object.assign({}, vals) }; },
|
|
395
|
+
merge: function (other) {
|
|
396
|
+
var o = _otherState(other, "orMap");
|
|
397
|
+
var mergedKeys = keys.merge({ state: function () { return { type: "orSet", elems: o.keyElems, tombstones: o.keyTombstones }; } }).state();
|
|
398
|
+
var v = {};
|
|
399
|
+
var key;
|
|
400
|
+
for (key in vals) if (Object.prototype.hasOwnProperty.call(vals, key)) v[key] = vals[key];
|
|
401
|
+
for (key in o.vals) if (Object.prototype.hasOwnProperty.call(o.vals, key)) {
|
|
402
|
+
if (!v[key]) { v[key] = o.vals[key]; }
|
|
403
|
+
else {
|
|
404
|
+
var merged = lwwRegister.fromState(v[key], { replicaId: replicaId }).merge({ state: function () { return o.vals[key]; } });
|
|
405
|
+
v[key] = merged.state();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return orMap({ replicaId: replicaId, _keyElems: mergedKeys.elems, _keyTombstones: mergedKeys.tombstones, _vals: v });
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
orMap.fromState = function (s, opts) { _assertState(s, "orMap"); return orMap(Object.assign({}, opts, { _keyElems: s.keyElems, _keyTombstones: s.keyTombstones, _vals: s.vals })); };
|
|
413
|
+
|
|
414
|
+
// ---- shared helpers --------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
// Set/map elements are keyed by a reversible string so structured values work.
|
|
417
|
+
function _key(x) {
|
|
418
|
+
if (typeof x === "string") return "s:" + x;
|
|
419
|
+
return "j:" + JSON.stringify(x);
|
|
420
|
+
}
|
|
421
|
+
function _mapKey(k) {
|
|
422
|
+
if (typeof k !== "string" || k.length === 0) throw new CrdtError("crdt/bad-key", "crdt.orMap: keys must be non-empty strings");
|
|
423
|
+
return k;
|
|
424
|
+
}
|
|
425
|
+
function _decodeKeys(set) {
|
|
426
|
+
// Sort by the encoded key (a deterministic, unique string for every element,
|
|
427
|
+
// including structured ones) BEFORE decoding, so the materialized array order
|
|
428
|
+
// is identical regardless of merge order — structured elements would all
|
|
429
|
+
// collapse to "[object Object]" if sorted by their decoded value.
|
|
430
|
+
return Array.from(set).sort().map(function (k) {
|
|
431
|
+
return k.charAt(0) === "s" ? k.slice(2) : safeJson.parse(k.slice(2));
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
function _otherState(other, expected) {
|
|
435
|
+
var s = other && typeof other.state === "function" ? other.state() : other;
|
|
436
|
+
_assertState(s, expected);
|
|
437
|
+
return s;
|
|
438
|
+
}
|
|
439
|
+
function _assertState(s, expected) {
|
|
440
|
+
if (!s || typeof s !== "object") throw new CrdtError("crdt/bad-state", "crdt: expected a " + expected + " state object");
|
|
441
|
+
if (s.type !== expected) throw new CrdtError("crdt/type-mismatch", "crdt: expected a " + expected + " state, got '" + s.type + "'");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
module.exports = {
|
|
445
|
+
gCounter: gCounter,
|
|
446
|
+
pnCounter: pnCounter,
|
|
447
|
+
gSet: gSet,
|
|
448
|
+
twoPSet: twoPSet,
|
|
449
|
+
orSet: orSet,
|
|
450
|
+
lwwRegister: lwwRegister,
|
|
451
|
+
orMap: orMap,
|
|
452
|
+
CrdtError: CrdtError,
|
|
453
|
+
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:6a334753-7eb6-4d7f-8dbb-f70506773504",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-27T02:26:01.865Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.5",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.5",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.5",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.5",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|