@allus-fyi/company-data 0.0.3
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/LICENSE +21 -0
- package/README.md +706 -0
- package/dist/cjs/buffer.js +352 -0
- package/dist/cjs/client.js +396 -0
- package/dist/cjs/config.js +241 -0
- package/dist/cjs/crypto.js +288 -0
- package/dist/cjs/errors.js +96 -0
- package/dist/cjs/http.js +272 -0
- package/dist/cjs/index.js +74 -0
- package/dist/cjs/models.js +300 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pump.js +279 -0
- package/dist/cjs/webhooks.js +335 -0
- package/dist/cjs/xml.js +257 -0
- package/dist/esm/buffer.js +348 -0
- package/dist/esm/client.js +392 -0
- package/dist/esm/config.js +237 -0
- package/dist/esm/crypto.js +281 -0
- package/dist/esm/errors.js +86 -0
- package/dist/esm/http.js +267 -0
- package/dist/esm/index.js +37 -0
- package/dist/esm/models.js +292 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/pump.js +275 -0
- package/dist/esm/webhooks.js +329 -0
- package/dist/esm/xml.js +252 -0
- package/dist/types/buffer.d.ts +109 -0
- package/dist/types/client.d.ts +150 -0
- package/dist/types/config.d.ts +86 -0
- package/dist/types/crypto.d.ts +125 -0
- package/dist/types/errors.d.ts +73 -0
- package/dist/types/http.d.ts +80 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/models.d.ts +154 -0
- package/dist/types/pump.d.ts +118 -0
- package/dist/types/webhooks.d.ts +99 -0
- package/dist/types/xml.d.ts +42 -0
- package/docs/config.md +93 -0
- package/docs/errors.md +87 -0
- package/docs/model.md +141 -0
- package/docs/pump.md +130 -0
- package/docs/webhooks.md +140 -0
- package/package.json +54 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* allus company-data SDK for TypeScript / Node — one of six language ports that
|
|
4
|
+
* share an identical API surface.
|
|
5
|
+
*
|
|
6
|
+
* This package wraps the allus company-data API: point it at a JSON config file and
|
|
7
|
+
* it hands back typed, plaintext, your-slug-keyed conclusions with transparent
|
|
8
|
+
* hybrid decryption.
|
|
9
|
+
*
|
|
10
|
+
* Exported: config loading, the decryption core, the full error taxonomy, the
|
|
11
|
+
* HTTP/auth layer, the output model, the crash-safe changes pump (durable file
|
|
12
|
+
* buffer + pump), the `Client` facade, and the webhook receiver helpers. `Client`
|
|
13
|
+
* is the one object an integrating company touches.
|
|
14
|
+
*
|
|
15
|
+
* Both ESM (`import { Client } from '@allus/company-data'`) and CommonJS
|
|
16
|
+
* (`const { Client } = require('@allus/company-data')`) are supported via the
|
|
17
|
+
* package's `exports` map.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.VERSION = exports.XmlParseError = exports.parseXml = exports.loadAccountKey = exports.handleWebhook = exports.parseWebhook = exports.verifyWebhook = exports.MAX_BATCH = exports.Pump = exports.FileBuffer = exports.DATE_TYPES = exports.BINARY_TYPES = exports.STRUCTURED_TYPES = exports.LogEntry = exports.Change = exports.Value = exports.Connection = exports.RequestField = exports.FetchTransport = exports.HttpClient = exports.RateLimitError = exports.WebhookError = exports.DecryptError = exports.ApiError = exports.AuthError = exports.ConfigError = exports.AllusError = exports.GCM_TAG_LEN = exports.GCM_IV_LEN = exports.BinaryHandle = exports.decrypt = exports.loadPrivateKey = exports.SINGLE_WEBHOOK_KEY = exports.Config = exports.Client = void 0;
|
|
21
|
+
// client facade — the main entry point
|
|
22
|
+
var client_js_1 = require("./client.js");
|
|
23
|
+
Object.defineProperty(exports, "Client", { enumerable: true, get: function () { return client_js_1.Client; } });
|
|
24
|
+
// config
|
|
25
|
+
var config_js_1 = require("./config.js");
|
|
26
|
+
Object.defineProperty(exports, "Config", { enumerable: true, get: function () { return config_js_1.Config; } });
|
|
27
|
+
Object.defineProperty(exports, "SINGLE_WEBHOOK_KEY", { enumerable: true, get: function () { return config_js_1.SINGLE_WEBHOOK_KEY; } });
|
|
28
|
+
// crypto
|
|
29
|
+
var crypto_js_1 = require("./crypto.js");
|
|
30
|
+
Object.defineProperty(exports, "loadPrivateKey", { enumerable: true, get: function () { return crypto_js_1.loadPrivateKey; } });
|
|
31
|
+
Object.defineProperty(exports, "decrypt", { enumerable: true, get: function () { return crypto_js_1.decrypt; } });
|
|
32
|
+
Object.defineProperty(exports, "BinaryHandle", { enumerable: true, get: function () { return crypto_js_1.BinaryHandle; } });
|
|
33
|
+
Object.defineProperty(exports, "GCM_IV_LEN", { enumerable: true, get: function () { return crypto_js_1.GCM_IV_LEN; } });
|
|
34
|
+
Object.defineProperty(exports, "GCM_TAG_LEN", { enumerable: true, get: function () { return crypto_js_1.GCM_TAG_LEN; } });
|
|
35
|
+
// errors
|
|
36
|
+
var errors_js_1 = require("./errors.js");
|
|
37
|
+
Object.defineProperty(exports, "AllusError", { enumerable: true, get: function () { return errors_js_1.AllusError; } });
|
|
38
|
+
Object.defineProperty(exports, "ConfigError", { enumerable: true, get: function () { return errors_js_1.ConfigError; } });
|
|
39
|
+
Object.defineProperty(exports, "AuthError", { enumerable: true, get: function () { return errors_js_1.AuthError; } });
|
|
40
|
+
Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return errors_js_1.ApiError; } });
|
|
41
|
+
Object.defineProperty(exports, "DecryptError", { enumerable: true, get: function () { return errors_js_1.DecryptError; } });
|
|
42
|
+
Object.defineProperty(exports, "WebhookError", { enumerable: true, get: function () { return errors_js_1.WebhookError; } });
|
|
43
|
+
Object.defineProperty(exports, "RateLimitError", { enumerable: true, get: function () { return errors_js_1.RateLimitError; } });
|
|
44
|
+
// transport
|
|
45
|
+
var http_js_1 = require("./http.js");
|
|
46
|
+
Object.defineProperty(exports, "HttpClient", { enumerable: true, get: function () { return http_js_1.HttpClient; } });
|
|
47
|
+
Object.defineProperty(exports, "FetchTransport", { enumerable: true, get: function () { return http_js_1.FetchTransport; } });
|
|
48
|
+
// output model
|
|
49
|
+
var models_js_1 = require("./models.js");
|
|
50
|
+
Object.defineProperty(exports, "RequestField", { enumerable: true, get: function () { return models_js_1.RequestField; } });
|
|
51
|
+
Object.defineProperty(exports, "Connection", { enumerable: true, get: function () { return models_js_1.Connection; } });
|
|
52
|
+
Object.defineProperty(exports, "Value", { enumerable: true, get: function () { return models_js_1.Value; } });
|
|
53
|
+
Object.defineProperty(exports, "Change", { enumerable: true, get: function () { return models_js_1.Change; } });
|
|
54
|
+
Object.defineProperty(exports, "LogEntry", { enumerable: true, get: function () { return models_js_1.LogEntry; } });
|
|
55
|
+
Object.defineProperty(exports, "STRUCTURED_TYPES", { enumerable: true, get: function () { return models_js_1.STRUCTURED_TYPES; } });
|
|
56
|
+
Object.defineProperty(exports, "BINARY_TYPES", { enumerable: true, get: function () { return models_js_1.BINARY_TYPES; } });
|
|
57
|
+
Object.defineProperty(exports, "DATE_TYPES", { enumerable: true, get: function () { return models_js_1.DATE_TYPES; } });
|
|
58
|
+
// changes pump
|
|
59
|
+
var buffer_js_1 = require("./buffer.js");
|
|
60
|
+
Object.defineProperty(exports, "FileBuffer", { enumerable: true, get: function () { return buffer_js_1.FileBuffer; } });
|
|
61
|
+
var pump_js_1 = require("./pump.js");
|
|
62
|
+
Object.defineProperty(exports, "Pump", { enumerable: true, get: function () { return pump_js_1.Pump; } });
|
|
63
|
+
Object.defineProperty(exports, "MAX_BATCH", { enumerable: true, get: function () { return pump_js_1.MAX_BATCH; } });
|
|
64
|
+
// webhook receiver helpers
|
|
65
|
+
var webhooks_js_1 = require("./webhooks.js");
|
|
66
|
+
Object.defineProperty(exports, "verifyWebhook", { enumerable: true, get: function () { return webhooks_js_1.verifyWebhook; } });
|
|
67
|
+
Object.defineProperty(exports, "parseWebhook", { enumerable: true, get: function () { return webhooks_js_1.parseWebhook; } });
|
|
68
|
+
Object.defineProperty(exports, "handleWebhook", { enumerable: true, get: function () { return webhooks_js_1.handleWebhook; } });
|
|
69
|
+
Object.defineProperty(exports, "loadAccountKey", { enumerable: true, get: function () { return webhooks_js_1.loadAccountKey; } });
|
|
70
|
+
// XML (XXE-safe parser — exported for advanced use / testing)
|
|
71
|
+
var xml_js_1 = require("./xml.js");
|
|
72
|
+
Object.defineProperty(exports, "parseXml", { enumerable: true, get: function () { return xml_js_1.parseXml; } });
|
|
73
|
+
Object.defineProperty(exports, "XmlParseError", { enumerable: true, get: function () { return xml_js_1.XmlParseError; } });
|
|
74
|
+
exports.VERSION = '0.1.0';
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Output model — the conclusions.
|
|
4
|
+
*
|
|
5
|
+
* The consumer works with these and nothing else. They are produced by factories
|
|
6
|
+
* that turn a *hardened* API JSON object (slug-keyed `values`; NO person source
|
|
7
|
+
* field) into typed objects, decrypting ciphertext via the injected crypto closures.
|
|
8
|
+
*
|
|
9
|
+
* RequestField { slug, label, type, oneTime, mandatory } // YOUR request config
|
|
10
|
+
* Connection { id, personId, displayName, connectedAt, values: {<slug>: Value} }
|
|
11
|
+
* Value { value, live, updatedAt }
|
|
12
|
+
* Change { id, event, personId, shareCode?, slug?, value?, live?, at } // id = stable dedup key
|
|
13
|
+
* LogEntry { type, message, metadata, at }
|
|
14
|
+
*
|
|
15
|
+
* Typed values:
|
|
16
|
+
* - `email`/`phone`/`url`/`text` → string
|
|
17
|
+
* - `address`/`bank`/`creditcard` → a parsed object (the decrypted plaintext is a
|
|
18
|
+
* JSON object string → parsed)
|
|
19
|
+
* - `date`/`date_of_birth` → a JS `Date` (UTC midnight; falls back to the
|
|
20
|
+
* raw string if it can't be parsed)
|
|
21
|
+
* - `photo`/`document`/`legal_document` → a lazy {@link BinaryHandle}
|
|
22
|
+
* (`.bytes()` fetches the slot file endpoint, decrypts, parses the envelope,
|
|
23
|
+
* base64-decodes the `full`/`file` data URI)
|
|
24
|
+
*
|
|
25
|
+
* Every model carries `.raw` — the underlying (hardened) API object — for debugging
|
|
26
|
+
* or an edge case the SDK didn't model. It never contains the person's source field.
|
|
27
|
+
*
|
|
28
|
+
* Decryption is config-driven: the factory takes a `decryptValue`
|
|
29
|
+
* callable (a closure over the loaded service private key) and, for binaries, a
|
|
30
|
+
* `binaryFetch` callable — never a key/secret argument.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.LogEntry = exports.Change = exports.Connection = exports.Value = exports.RequestField = exports.DATE_TYPES = exports.BINARY_TYPES = exports.STRUCTURED_TYPES = void 0;
|
|
34
|
+
const crypto_js_1 = require("./crypto.js");
|
|
35
|
+
/** Field types whose decrypted plaintext is a JSON object → a parsed object. */
|
|
36
|
+
exports.STRUCTURED_TYPES = ['address', 'bank', 'creditcard'];
|
|
37
|
+
/** Field types whose value is a lazy binary handle (served as a value_url). */
|
|
38
|
+
exports.BINARY_TYPES = ['photo', 'document', 'legal_document'];
|
|
39
|
+
/** Field types whose decrypted plaintext is an ISO date. */
|
|
40
|
+
exports.DATE_TYPES = ['date', 'date_of_birth'];
|
|
41
|
+
function parseIsoDate(value) {
|
|
42
|
+
if (value === null || value === undefined || value === '')
|
|
43
|
+
return null;
|
|
44
|
+
const raw = String(value);
|
|
45
|
+
const ms = Date.parse(raw);
|
|
46
|
+
if (Number.isNaN(ms))
|
|
47
|
+
return null;
|
|
48
|
+
return new Date(ms);
|
|
49
|
+
}
|
|
50
|
+
function coerceBool(value) {
|
|
51
|
+
if (value === null || value === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof value === 'boolean')
|
|
54
|
+
return value;
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
const low = value.trim().toLowerCase();
|
|
57
|
+
if (low === 'true' || low === '1')
|
|
58
|
+
return true;
|
|
59
|
+
if (low === 'false' || low === '0' || low === '')
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return Boolean(value);
|
|
63
|
+
}
|
|
64
|
+
function parseDateOnly(value) {
|
|
65
|
+
const head = value.trim().slice(0, 10);
|
|
66
|
+
// Strict YYYY-MM-DD; build a UTC date so there's no timezone drift.
|
|
67
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(head);
|
|
68
|
+
if (!m)
|
|
69
|
+
return null;
|
|
70
|
+
const year = Number(m[1]);
|
|
71
|
+
const month = Number(m[2]);
|
|
72
|
+
const day = Number(m[3]);
|
|
73
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
74
|
+
// Round-trip guard (rejects e.g. 1990-13-40).
|
|
75
|
+
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return d;
|
|
79
|
+
}
|
|
80
|
+
// ── definitions ──────────────────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* A request-field DEFINITION — YOUR config, never the person's.
|
|
83
|
+
*
|
|
84
|
+
* `mandatory` folds the API's two flags: it is true when the field is mandatory to
|
|
85
|
+
* provide OR mandatory to stay connected.
|
|
86
|
+
*/
|
|
87
|
+
class RequestField {
|
|
88
|
+
constructor(slug, label, type, oneTime, mandatory, raw) {
|
|
89
|
+
this.slug = slug;
|
|
90
|
+
this.label = label;
|
|
91
|
+
this.type = type;
|
|
92
|
+
this.oneTime = oneTime;
|
|
93
|
+
this.mandatory = mandatory;
|
|
94
|
+
this.raw = raw;
|
|
95
|
+
}
|
|
96
|
+
static fromApi(obj) {
|
|
97
|
+
return new RequestField(String(obj['slug'] ?? ''), obj['label'] != null ? String(obj['label']) : '', obj['type'] != null ? String(obj['type']) : '', Boolean(coerceBool(obj['one_time'])), Boolean(coerceBool(obj['mandatory_provide']) || coerceBool(obj['mandatory_connected'])), obj);
|
|
98
|
+
}
|
|
99
|
+
/** Parse the `/request-fields` response → a list of definitions. */
|
|
100
|
+
static listFromApi(body) {
|
|
101
|
+
const items = listOf(body, 'request_fields');
|
|
102
|
+
return items.map((o) => RequestField.fromApi(o));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.RequestField = RequestField;
|
|
106
|
+
// ── values ───────────────────────────────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* A single answer for one of YOUR request slots.
|
|
109
|
+
*
|
|
110
|
+
* `value` is the typed plaintext (string / object / Date / lazy BinaryHandle);
|
|
111
|
+
* `live` = the person chose "keep connected" (auto-updates) vs a one-time snapshot;
|
|
112
|
+
* `updatedAt` = when this answer last changed. Both ride on the Value (per-answer),
|
|
113
|
+
* not the definition.
|
|
114
|
+
*/
|
|
115
|
+
class Value {
|
|
116
|
+
constructor(value, live, updatedAt, raw) {
|
|
117
|
+
this.value = value;
|
|
118
|
+
this.live = live;
|
|
119
|
+
this.updatedAt = updatedAt;
|
|
120
|
+
this.raw = raw;
|
|
121
|
+
}
|
|
122
|
+
/** Build a typed Value from one hardened `{value|value_url, live, updatedAt}` entry. */
|
|
123
|
+
static fromApi(obj, opts) {
|
|
124
|
+
const live = Boolean(coerceBool(obj['live']));
|
|
125
|
+
const updatedAt = parseIsoDate(obj['updatedAt'] ?? obj['updated_at']);
|
|
126
|
+
const typed = typedValue(obj, opts);
|
|
127
|
+
return new Value(typed, live, updatedAt, obj);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
exports.Value = Value;
|
|
131
|
+
function typedValue(obj, opts) {
|
|
132
|
+
const ftype = (opts.fieldType ?? '').toLowerCase();
|
|
133
|
+
// Binary → a lazy handle over the slot value_url (no eager fetch/decrypt).
|
|
134
|
+
if (exports.BINARY_TYPES.includes(ftype) || 'value_url' in obj) {
|
|
135
|
+
const valueUrl = obj['value_url'];
|
|
136
|
+
if (valueUrl === undefined || valueUrl === null) {
|
|
137
|
+
// Binary type but no url (e.g. unanswered) → an empty handle.
|
|
138
|
+
return new crypto_js_1.BinaryHandle({});
|
|
139
|
+
}
|
|
140
|
+
return new crypto_js_1.BinaryHandle({
|
|
141
|
+
valueUrl: String(valueUrl),
|
|
142
|
+
fetch: opts.binaryFetch ?? null,
|
|
143
|
+
decrypt: opts.decryptValue,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Non-binary → decrypt the ciphertext wrapper to plaintext.
|
|
147
|
+
const ciphertext = obj['value'];
|
|
148
|
+
if (ciphertext === undefined || ciphertext === null) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const plaintext = opts.decryptValue(ciphertext);
|
|
152
|
+
if (exports.STRUCTURED_TYPES.includes(ftype)) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(plaintext);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new crypto_js_1.DecryptError(`structured value for type '${ftype}' is not valid JSON`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (exports.DATE_TYPES.includes(ftype)) {
|
|
161
|
+
const d = parseDateOnly(plaintext);
|
|
162
|
+
return d !== null ? d : plaintext;
|
|
163
|
+
}
|
|
164
|
+
// text/email/phone/url and anything unknown → the plaintext string.
|
|
165
|
+
return plaintext;
|
|
166
|
+
}
|
|
167
|
+
// ── connection ─────────────────────────────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* A connected person — identity + the slug-keyed value map.
|
|
170
|
+
*
|
|
171
|
+
* NO source field anywhere: `values` is keyed by YOUR request slug.
|
|
172
|
+
*/
|
|
173
|
+
class Connection {
|
|
174
|
+
constructor(id, personId, displayName, connectedAt, values, raw) {
|
|
175
|
+
this.id = id;
|
|
176
|
+
this.personId = personId;
|
|
177
|
+
this.displayName = displayName;
|
|
178
|
+
this.connectedAt = connectedAt;
|
|
179
|
+
this.values = values;
|
|
180
|
+
this.raw = raw;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Build a Connection from a hardened `connectionDetail` (or list) object.
|
|
184
|
+
*
|
|
185
|
+
* `connectionDetail` returns `{connection_id, user_id, values}` and no
|
|
186
|
+
* displayName/connectedAt, so those can be supplied via `identity` (the matching
|
|
187
|
+
* row from the list endpoint, which carries them).
|
|
188
|
+
*/
|
|
189
|
+
static fromApi(obj, opts) {
|
|
190
|
+
const identity = opts.identity ?? {};
|
|
191
|
+
const connId = String(obj['connection_id'] ?? obj['id'] ?? identity['connection_id'] ?? '');
|
|
192
|
+
const personId = String(obj['user_id'] ?? obj['person_id'] ?? obj['person_user_id'] ?? identity['user_id'] ?? '');
|
|
193
|
+
const displayNameRaw = obj['display_name'] ?? identity['display_name'];
|
|
194
|
+
const displayName = displayNameRaw != null ? String(displayNameRaw) : null;
|
|
195
|
+
const connectedAt = parseIsoDate(obj['connected_at'] ?? identity['connected_at']);
|
|
196
|
+
const values = {};
|
|
197
|
+
const valuesObj = obj['values'];
|
|
198
|
+
if (valuesObj !== null && typeof valuesObj === 'object' && !Array.isArray(valuesObj)) {
|
|
199
|
+
for (const [slug, entry] of Object.entries(valuesObj)) {
|
|
200
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
|
201
|
+
continue;
|
|
202
|
+
values[slug] = Value.fromApi(entry, {
|
|
203
|
+
fieldType: opts.typeForSlug(slug),
|
|
204
|
+
decryptValue: opts.decryptValue,
|
|
205
|
+
binaryFetch: opts.binaryFetch,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return new Connection(connId, personId, displayName, connectedAt, values, obj);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
exports.Connection = Connection;
|
|
213
|
+
// ── change ───────────────────────────────────────────────────────────────────
|
|
214
|
+
/**
|
|
215
|
+
* A change feed / webhook event.
|
|
216
|
+
*
|
|
217
|
+
* `id` is the stable server change-row id (the pump dedupes on it after a
|
|
218
|
+
* crash/replay); `at` is the change time (there is NO separate
|
|
219
|
+
* `updatedAt` on a change). `slug`/`value`/`live` are present only on
|
|
220
|
+
* `field_updated` (connection/consent events carry no slot/value).
|
|
221
|
+
*/
|
|
222
|
+
class Change {
|
|
223
|
+
constructor(id, event, personId,
|
|
224
|
+
/** The person's profile share code (present on every event; may be null). */
|
|
225
|
+
shareCode, slug, value, live, at, raw) {
|
|
226
|
+
this.id = id;
|
|
227
|
+
this.event = event;
|
|
228
|
+
this.personId = personId;
|
|
229
|
+
this.shareCode = shareCode;
|
|
230
|
+
this.slug = slug;
|
|
231
|
+
this.value = value;
|
|
232
|
+
this.live = live;
|
|
233
|
+
this.at = at;
|
|
234
|
+
this.raw = raw;
|
|
235
|
+
}
|
|
236
|
+
/** Build a Change from one hardened changes-feed / webhook event object. */
|
|
237
|
+
static fromApi(obj, opts) {
|
|
238
|
+
const slug = obj['slug'] != null ? String(obj['slug']) : null;
|
|
239
|
+
const event = obj['event'] != null ? String(obj['event']) : '';
|
|
240
|
+
const live = 'live' in obj ? coerceBool(obj['live']) : null;
|
|
241
|
+
let value = null;
|
|
242
|
+
if (event === 'field_updated' && slug !== null) {
|
|
243
|
+
// Reuse the Value typing path so feed + connection produce identical typed
|
|
244
|
+
// values (incl. the same lazy BinaryHandle for binaries).
|
|
245
|
+
if ('value' in obj || 'value_url' in obj) {
|
|
246
|
+
value = typedValue(obj, {
|
|
247
|
+
fieldType: opts.typeForSlug(slug),
|
|
248
|
+
decryptValue: opts.decryptValue,
|
|
249
|
+
binaryFetch: opts.binaryFetch,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const personIdRaw = obj['person_user_id'] ?? obj['person_id'];
|
|
254
|
+
const shareCodeRaw = obj['share_code'];
|
|
255
|
+
return new Change(String(obj['id'] ?? ''), event, personIdRaw != null ? String(personIdRaw) : null, shareCodeRaw != null ? String(shareCodeRaw) : null, slug, value, live, parseIsoDate(obj['at']), obj);
|
|
256
|
+
}
|
|
257
|
+
/** Parse the `/changes` response → a list of typed Change events. */
|
|
258
|
+
static listFromApi(body, opts) {
|
|
259
|
+
const items = listOf(body, 'changes');
|
|
260
|
+
return items.map((o) => Change.fromApi(o, opts));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
exports.Change = Change;
|
|
264
|
+
// ── log ────────────────────────────────────────────────────────────────────
|
|
265
|
+
/** A service activity-log entry — ops events only, never person data. */
|
|
266
|
+
class LogEntry {
|
|
267
|
+
constructor(type, message, metadata, at, raw) {
|
|
268
|
+
this.type = type;
|
|
269
|
+
this.message = message;
|
|
270
|
+
this.metadata = metadata;
|
|
271
|
+
this.at = at;
|
|
272
|
+
this.raw = raw;
|
|
273
|
+
}
|
|
274
|
+
static fromApi(obj) {
|
|
275
|
+
return new LogEntry(obj['type'] != null ? String(obj['type']) : '', obj['message'] != null ? String(obj['message']) : null, obj['metadata'] ?? null, parseIsoDate(obj['at'] ?? obj['created_at']), obj);
|
|
276
|
+
}
|
|
277
|
+
/** Parse the `/logs` response → a list of log entries. */
|
|
278
|
+
static listFromApi(body) {
|
|
279
|
+
const items = listOf(body, 'items');
|
|
280
|
+
return items.map((o) => LogEntry.fromApi(o));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
exports.LogEntry = LogEntry;
|
|
284
|
+
// ── shared list extraction ───────────────────────────────────────────────────
|
|
285
|
+
/**
|
|
286
|
+
* Pull the named array out of a `{<key>: [...]}` response, or accept a bare array.
|
|
287
|
+
* Mirrors the Python `body.get(key, []) if dict else (body or [])`.
|
|
288
|
+
*/
|
|
289
|
+
function listOf(body, key) {
|
|
290
|
+
let items;
|
|
291
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
292
|
+
items = body[key] ?? [];
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
items = body ?? [];
|
|
296
|
+
}
|
|
297
|
+
if (!Array.isArray(items))
|
|
298
|
+
return [];
|
|
299
|
+
return items.filter((o) => o !== null && typeof o === 'object' && !Array.isArray(o));
|
|
300
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
package/dist/cjs/pump.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Crash-safe streaming changes pump.
|
|
4
|
+
*
|
|
5
|
+
* The changes feed is a server-side **drain-on-fetch queue**: a fetch returns up to
|
|
6
|
+
* N events (default 100, max 500) and deletes those rows in the same transaction —
|
|
7
|
+
* the API keeps no copy. So consumption cannot be a plain list: a consumer crash
|
|
8
|
+
* mid-batch would lose events the API already deleted, and a huge backlog must not
|
|
9
|
+
* materialize in memory. The pump solves both:
|
|
10
|
+
*
|
|
11
|
+
* processChanges(handler) — one Change at a time, until the feed is empty, then
|
|
12
|
+
* RETURNS. No follow/daemon mode (you schedule re-runs
|
|
13
|
+
* yourself).
|
|
14
|
+
*
|
|
15
|
+
* Per cycle:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Replay first** — deliver any un-acked events already in the local buffer
|
|
18
|
+
* (from a previous crashed run), oldest-first.
|
|
19
|
+
* 2. **Drain** — when the buffer is empty, fetch ONE batch (≤ batchSize, ≤500) and
|
|
20
|
+
* **persist it to the durable buffer (fsync) BEFORE handing anything out**.
|
|
21
|
+
* 3. **Deliver one-by-one** — for each buffered event oldest-first: decrypt its
|
|
22
|
+
* value (at delivery — never on disk), build the typed Change, call the handler.
|
|
23
|
+
* 4. **Ack / retry / dead-letter** — on success remove the event from the buffer;
|
|
24
|
+
* on error retry with backoff up to maxRetries, then (onError "deadletter")
|
|
25
|
+
* move it to the dead-letter store and continue (one poison event never wedges
|
|
26
|
+
* the stream), or (onError "halt") stop and re-throw.
|
|
27
|
+
* 5. Repeat until a drain returns empty AND the buffer is drained → return.
|
|
28
|
+
*
|
|
29
|
+
* Durability invariants (every port preserves these):
|
|
30
|
+
* (1) Decrypt INSIDE the delivery attempt — a DecryptError on a persisted poison
|
|
31
|
+
* event is dead-lettered IMMEDIATELY (re-decrypt can't help → it does NOT burn
|
|
32
|
+
* the retry budget); it never propagates out and wedges replay.
|
|
33
|
+
* (2) A re-failing dead-letter is updated IN PLACE within deadletter/ (never routed
|
|
34
|
+
* back through pending/).
|
|
35
|
+
* (3) Stored attempt count is monotonic = max(existing, new) (handled by the buffer).
|
|
36
|
+
* (4) dead_letter writes the new copy BEFORE unlinking pending (at-least-once safe).
|
|
37
|
+
*
|
|
38
|
+
* Injection (so tests + the real Client share one pump): the pump takes a
|
|
39
|
+
* `fetchChanges(limit) -> Promise<event[]>` source (the raw drain-on-fetch call,
|
|
40
|
+
* returning ciphertext event objects) and a `decrypt(event) -> Change` callable
|
|
41
|
+
* (closes over the loaded service private key — config-only key handling). No
|
|
42
|
+
* key/secret is ever a method argument.
|
|
43
|
+
*/
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.Pump = exports.MAX_BATCH = void 0;
|
|
46
|
+
const buffer_js_1 = require("./buffer.js");
|
|
47
|
+
const errors_js_1 = require("./errors.js");
|
|
48
|
+
// The drain-on-fetch queue caps a fetch at 500. The pump clamps any requested
|
|
49
|
+
// batch size to this.
|
|
50
|
+
exports.MAX_BATCH = 500;
|
|
51
|
+
const DEFAULT_BATCH = 100;
|
|
52
|
+
// Default retry/backoff for a failing handler.
|
|
53
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
54
|
+
const DEFAULT_BACKOFF_S = 0.5;
|
|
55
|
+
const MAX_BACKOFF_S = 30.0;
|
|
56
|
+
const defaultSleep = (seconds) => new Promise((res) => setTimeout(res, Math.max(0, seconds) * 1000));
|
|
57
|
+
function defaultBackoff(attempt) {
|
|
58
|
+
return Math.min(DEFAULT_BACKOFF_S * 2 ** (attempt - 1), MAX_BACKOFF_S);
|
|
59
|
+
}
|
|
60
|
+
function clampBatch(value) {
|
|
61
|
+
let v = Math.trunc(Number(value));
|
|
62
|
+
if (!Number.isFinite(v))
|
|
63
|
+
v = DEFAULT_BATCH;
|
|
64
|
+
if (v < 1)
|
|
65
|
+
v = 1;
|
|
66
|
+
if (v > exports.MAX_BATCH)
|
|
67
|
+
v = exports.MAX_BATCH;
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The crash-safe changes pump.
|
|
72
|
+
*
|
|
73
|
+
* Wires a durable {@link FileBuffer} (under `config.cacheDir`) to an injected drain
|
|
74
|
+
* source + decrypt callable.
|
|
75
|
+
*/
|
|
76
|
+
class Pump {
|
|
77
|
+
constructor(config, opts) {
|
|
78
|
+
this.fetchChanges = opts.fetchChanges;
|
|
79
|
+
this.decryptChange = opts.decrypt;
|
|
80
|
+
this.log = opts.logger ?? {};
|
|
81
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
82
|
+
// The buffer recovers whatever is already on disk — that recovery IS the
|
|
83
|
+
// replay-on-restart in step 1.
|
|
84
|
+
this._buffer = new buffer_js_1.FileBuffer(config.cacheDir);
|
|
85
|
+
}
|
|
86
|
+
get buffer() {
|
|
87
|
+
return this._buffer;
|
|
88
|
+
}
|
|
89
|
+
// ── the pump ──────────────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Stream events through `handler` until the feed is empty, then return.
|
|
92
|
+
*
|
|
93
|
+
* `handler` is called with one typed {@link Change} at a time and must be
|
|
94
|
+
* idempotent (at-least-once delivery; dedup on `Change.id`).
|
|
95
|
+
*
|
|
96
|
+
* Options: `batchSize` (clamped ≤500), `maxRetries`, `onError`
|
|
97
|
+
* (`"deadletter"` — default — or `"halt"`), `backoff` (attempt → seconds).
|
|
98
|
+
*/
|
|
99
|
+
async processChanges(handler, options = {}) {
|
|
100
|
+
const onError = options.onError ?? 'deadletter';
|
|
101
|
+
if (onError !== 'deadletter' && onError !== 'halt') {
|
|
102
|
+
throw new TypeError("onError must be 'deadletter' or 'halt'");
|
|
103
|
+
}
|
|
104
|
+
const size = clampBatch(options.batchSize ?? DEFAULT_BATCH);
|
|
105
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
106
|
+
const backoff = options.backoff ?? defaultBackoff;
|
|
107
|
+
for (;;) {
|
|
108
|
+
// 1. Replay anything already buffered (a previous crashed run), then deliver
|
|
109
|
+
// it. If the buffer is empty, drain ONE batch first.
|
|
110
|
+
let pending = this._buffer.pending();
|
|
111
|
+
if (pending.length > 0) {
|
|
112
|
+
this.log.info?.(`pump replay: ${pending.length} buffered event(s)`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const drained = await this.drainIntoBuffer(size);
|
|
116
|
+
if (drained === 0) {
|
|
117
|
+
// A drain returned empty AND the buffer is drained → done.
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
pending = this._buffer.pending();
|
|
121
|
+
}
|
|
122
|
+
// 3+4. Deliver each buffered event oldest-first; ack/retry/dead-letter.
|
|
123
|
+
for (const event of pending) {
|
|
124
|
+
await this.deliverOne(event, handler, { maxRetries, onError, backoff });
|
|
125
|
+
}
|
|
126
|
+
// Loop: re-check the buffer (now drained) and try another drain.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async drainIntoBuffer(size) {
|
|
130
|
+
const batch = (await this.fetchChanges(size)) || [];
|
|
131
|
+
this.log.info?.(`pump drain: fetched ${batch.length} event(s) (limit=${size})`);
|
|
132
|
+
if (batch.length === 0) {
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
// Persist-before-deliver: the durable backup the API no longer has.
|
|
136
|
+
this._buffer.append(batch);
|
|
137
|
+
return batch.length;
|
|
138
|
+
}
|
|
139
|
+
async deliverOne(event, handler, opts) {
|
|
140
|
+
const changeId = event['id'];
|
|
141
|
+
let attempts = 0;
|
|
142
|
+
for (;;) {
|
|
143
|
+
attempts += 1;
|
|
144
|
+
try {
|
|
145
|
+
// Decrypt only now — never on disk (ciphertext at rest).
|
|
146
|
+
// Inside the try so a poison-ciphertext DecryptError is contained.
|
|
147
|
+
const change = this.decryptChange(event);
|
|
148
|
+
this.log.debug?.(`pump deliver: id=${String(changeId)} attempt=${attempts}`);
|
|
149
|
+
await handler(change);
|
|
150
|
+
}
|
|
151
|
+
catch (exc) {
|
|
152
|
+
if (exc instanceof errors_js_1.DecryptError) {
|
|
153
|
+
// A poison event: re-decrypting won't help, so don't burn retries.
|
|
154
|
+
if (opts.onError === 'halt') {
|
|
155
|
+
this.log.error?.(`pump halt: id=${String(changeId)} undecryptable (${exc.message})`);
|
|
156
|
+
throw exc;
|
|
157
|
+
}
|
|
158
|
+
this._buffer.deadLetter(changeId, `DecryptError: ${exc.message}`, attempts);
|
|
159
|
+
this.log.error?.(`pump dead-letter (undecryptable): id=${String(changeId)}: ${exc.message}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// A handler error.
|
|
163
|
+
const err = exc;
|
|
164
|
+
if (attempts <= opts.maxRetries) {
|
|
165
|
+
const delay = Math.max(0, opts.backoff(attempts));
|
|
166
|
+
this.log.warn?.(`pump retry: id=${String(changeId)} attempt=${attempts} failed (${err.message}); backoff ${delay}s`);
|
|
167
|
+
if (delay)
|
|
168
|
+
await this.sleep(delay);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// Retries exhausted.
|
|
172
|
+
if (opts.onError === 'halt') {
|
|
173
|
+
this.log.error?.(`pump halt: id=${String(changeId)} failed after ${attempts} attempt(s)`);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
this._buffer.deadLetter(changeId, String(err.message ?? err), attempts);
|
|
177
|
+
this.log.error?.(`pump dead-letter: id=${String(changeId)} after ${attempts} attempt(s): ${err.message}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Success → per-item ack (remove from the buffer).
|
|
181
|
+
this._buffer.ack(changeId);
|
|
182
|
+
this.log.debug?.(`pump ack: id=${String(changeId)}`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ── advanced primitive ─────────────────────────────────────────────────────
|
|
187
|
+
/**
|
|
188
|
+
* Raw, UNBUFFERED drain → a list of typed Changes (advanced).
|
|
189
|
+
*
|
|
190
|
+
* Fetches one batch (clamped ≤500) and returns the decrypted Changes directly — it
|
|
191
|
+
* does NOT persist anything to the buffer, so **you own durability** if you use it.
|
|
192
|
+
* Prefer {@link processChanges} for safe consumption.
|
|
193
|
+
*/
|
|
194
|
+
async drainBatch(max = DEFAULT_BATCH) {
|
|
195
|
+
const size = clampBatch(max);
|
|
196
|
+
const batch = (await this.fetchChanges(size)) || [];
|
|
197
|
+
this.log.info?.(`drainBatch: fetched ${batch.length} event(s) (limit=${size})`);
|
|
198
|
+
return batch.map((event) => this.decryptChange(event));
|
|
199
|
+
}
|
|
200
|
+
// ── dead-letter inspect / re-drive ─────────────────────────────────────────
|
|
201
|
+
/** The local dead-letter store (ciphertext + error + attempt count). */
|
|
202
|
+
deadLetters() {
|
|
203
|
+
return this._buffer.deadLetters();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Re-drive every dead-lettered event through `handler`.
|
|
207
|
+
*
|
|
208
|
+
* On success the dead-letter record is removed; on repeated failure it is
|
|
209
|
+
* re-dead-lettered IN PLACE (`"deadletter"`) or the error is re-thrown (`"halt"`).
|
|
210
|
+
* They are never re-fetched from the API (it already deleted them) — the local
|
|
211
|
+
* store is their only home. Returns the count successfully re-driven.
|
|
212
|
+
*/
|
|
213
|
+
async retryDeadLetters(handler, options = {}) {
|
|
214
|
+
const onError = options.onError ?? 'deadletter';
|
|
215
|
+
if (onError !== 'deadletter' && onError !== 'halt') {
|
|
216
|
+
throw new TypeError("onError must be 'deadletter' or 'halt'");
|
|
217
|
+
}
|
|
218
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
219
|
+
const backoff = options.backoff ?? defaultBackoff;
|
|
220
|
+
let redriven = 0;
|
|
221
|
+
for (const record of this._buffer.deadLetters()) {
|
|
222
|
+
const changeId = record['id'];
|
|
223
|
+
// Strip the reserved failure block before re-decrypting the event.
|
|
224
|
+
const event = {};
|
|
225
|
+
for (const [k, v] of Object.entries(record)) {
|
|
226
|
+
if (k === '_deadletter' || k === 'error' || k === 'attempts')
|
|
227
|
+
continue;
|
|
228
|
+
event[k] = v;
|
|
229
|
+
}
|
|
230
|
+
let attempts = 0;
|
|
231
|
+
for (;;) {
|
|
232
|
+
attempts += 1;
|
|
233
|
+
try {
|
|
234
|
+
// Decrypt inside the loop so an undecryptable dead-letter (the poison
|
|
235
|
+
// case) is contained here too — it updates its own record in place
|
|
236
|
+
// instead of crashing the re-drive.
|
|
237
|
+
const change = this.decryptChange(event);
|
|
238
|
+
await handler(change);
|
|
239
|
+
}
|
|
240
|
+
catch (exc) {
|
|
241
|
+
if (exc instanceof errors_js_1.DecryptError) {
|
|
242
|
+
if (onError === 'halt') {
|
|
243
|
+
this.log.error?.(`retryDeadLetters halt: id=${String(changeId)} undecryptable (${exc.message})`);
|
|
244
|
+
throw exc;
|
|
245
|
+
}
|
|
246
|
+
this._buffer.updateDeadLetter(changeId, `DecryptError: ${exc.message}`, attempts);
|
|
247
|
+
this.log.warn?.(`retryDeadLetters: id=${String(changeId)} still undecryptable (${exc.message})`);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
const err = exc;
|
|
251
|
+
if (attempts <= maxRetries) {
|
|
252
|
+
const delay = Math.max(0, backoff(attempts));
|
|
253
|
+
if (delay)
|
|
254
|
+
await this.sleep(delay);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (onError === 'halt') {
|
|
258
|
+
this.log.error?.(`retryDeadLetters halt: id=${String(changeId)} failed again`);
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
// Refresh the stored attempt count + error IN PLACE — the record stays in
|
|
262
|
+
// deadletter/ and never re-enters pending/, so there is no crash window
|
|
263
|
+
// (between an append and a re-dead-letter) where it could resurrect as a
|
|
264
|
+
// live pending event.
|
|
265
|
+
this._buffer.updateDeadLetter(changeId, String(err.message ?? err), attempts);
|
|
266
|
+
this.log.warn?.(`retryDeadLetters: id=${String(changeId)} still failing (${err.message})`);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
// Success.
|
|
270
|
+
this._buffer.removeDeadLetter(changeId);
|
|
271
|
+
this.log.info?.(`retryDeadLetters: id=${String(changeId)} re-driven OK`);
|
|
272
|
+
redriven += 1;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return redriven;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
exports.Pump = Pump;
|