@particle-academy/fancy-connector-core 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +6 -1
- package/dist/index.cjs +93 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -1
- package/dist/index.d.ts +67 -1
- package/dist/index.js +90 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,42 @@ All notable changes to `fancy-connector-core` are documented here, in
|
|
|
6
6
|
**This package is pre-1.0, so breaking changes land in MINOR releases.** The
|
|
7
7
|
version number is not a promise it can keep yet; the entries below are.
|
|
8
8
|
|
|
9
|
+
## [0.14.0] - 2026-11-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`readWithCursor` / `CursorRead::read`: a read that resumes from a
|
|
14
|
+
HOST-HELD cursor, and pages.** It's for Gmail's `history.list` after a push,
|
|
15
|
+
and Drive's `changes.list` after a channel notification, where the delivery
|
|
16
|
+
says only that something moved. The package holds no state: the host stores
|
|
17
|
+
the cursor, passes it in, and persists `cursor.next` when the run succeeds.
|
|
18
|
+
- `next` is read ONLY from the LAST page: Gmail's `historyId` is the
|
|
19
|
+
mailbox's current one on every page.
|
|
20
|
+
- More than `page.maxPages` is refused as `cursor-overflow`, never a partial
|
|
21
|
+
read.
|
|
22
|
+
- The provider's `expiredStatus` (Gmail's 404) is refused as
|
|
23
|
+
`cursor-expired`. Every other failure is rethrown unchanged.
|
|
24
|
+
- No stored cursor is refused as `cursor-missing`, and the provider is not
|
|
25
|
+
called.
|
|
26
|
+
- A collected list that's absent is `[]`; one that isn't a list is
|
|
27
|
+
`bad-page`.
|
|
28
|
+
- Cursors are strings.
|
|
29
|
+
- A refusal carries the caller's `resyncQuery`.
|
|
30
|
+
|
|
31
|
+
The reasons ship as data (`CURSOR_READ_REASONS`, `CursorRead::REASONS`).
|
|
32
|
+
Both runtimes read `fixtures/cursor-read/cases.json`: 14 authored cases
|
|
33
|
+
against a scripted provider, every call compared.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- The README and the npm description now say plainly that PHP apps install
|
|
38
|
+
the package with Composer.
|
|
39
|
+
- `fixtures/jwt` gains rows 0015 to 0024: signature length, values at and
|
|
40
|
+
above the modulus, and valid RSA signatures over malformed PKCS#1 v1.5
|
|
41
|
+
blocks.
|
|
42
|
+
|
|
43
|
+
`CONNECTOR_API_VERSION` is unchanged: everything here is optional.
|
|
44
|
+
|
|
9
45
|
## [0.13.0] - 2026-11-01
|
|
10
46
|
|
|
11
47
|
### Added
|
package/README.md
CHANGED
|
@@ -9,10 +9,15 @@ versioned rendering, chain posting, declared metric shapes, credential-free
|
|
|
9
9
|
probes, and API-drift reporting.
|
|
10
10
|
|
|
11
11
|
Matched **TypeScript + PHP**, one repository, **zero runtime dependencies in
|
|
12
|
-
either ecosystem**.
|
|
12
|
+
either ecosystem**. TypeScript and Node apps install it from npm; **PHP apps
|
|
13
|
+
(Laravel included) install it with Composer**. Both packages are released
|
|
14
|
+
together at the same version.
|
|
13
15
|
|
|
14
16
|
```bash
|
|
17
|
+
# TypeScript / Node
|
|
15
18
|
npm install @particle-academy/fancy-connector-core
|
|
19
|
+
|
|
20
|
+
# PHP (Composer)
|
|
16
21
|
composer require particle-academy/fancy-connector-core
|
|
17
22
|
```
|
|
18
23
|
|
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,7 @@ __export(src_exports, {
|
|
|
25
25
|
CONNECTOR_API_VERSION: () => CONNECTOR_API_VERSION,
|
|
26
26
|
CONNECTOR_MODES: () => CONNECTOR_MODES,
|
|
27
27
|
CREDENTIAL_SCOPES: () => CREDENTIAL_SCOPES,
|
|
28
|
+
CURSOR_READ_REASONS: () => CURSOR_READ_REASONS,
|
|
28
29
|
ConnectorAmbiguous: () => ConnectorAmbiguous,
|
|
29
30
|
ConnectorApiMismatch: () => ConnectorApiMismatch,
|
|
30
31
|
ConnectorAuthError: () => ConnectorAuthError,
|
|
@@ -36,6 +37,7 @@ __export(src_exports, {
|
|
|
36
37
|
ConnectorRequestError: () => ConnectorRequestError,
|
|
37
38
|
ConnectorTransient: () => ConnectorTransient,
|
|
38
39
|
ConnectorUnreachable: () => ConnectorUnreachable,
|
|
40
|
+
CursorReadError: () => CursorReadError,
|
|
39
41
|
DEFAULT_IDEMPOTENCY_WINDOW_SECONDS: () => DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
|
|
40
42
|
DEFAULT_RETRY: () => DEFAULT_RETRY,
|
|
41
43
|
DELIVERY_DECODE_KINDS: () => DELIVERY_DECODE_KINDS,
|
|
@@ -104,6 +106,7 @@ __export(src_exports, {
|
|
|
104
106
|
probe: () => probe,
|
|
105
107
|
providerProblems: () => providerProblems,
|
|
106
108
|
providerSandboxIsSelectable: () => providerSandboxIsSelectable,
|
|
109
|
+
readWithCursor: () => readWithCursor,
|
|
107
110
|
registerConnectionHost: () => registerConnectionHost,
|
|
108
111
|
registerTransport: () => registerTransport,
|
|
109
112
|
render: () => render,
|
|
@@ -302,10 +305,10 @@ var lastCallAt = /* @__PURE__ */ new Map();
|
|
|
302
305
|
async function respectRate(channel, minIntervalMs, now = () => Date.now(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
303
306
|
if (minIntervalMs <= 0) return { waitedMs: 0 };
|
|
304
307
|
const previous = lastCallAt.get(channel);
|
|
305
|
-
const
|
|
306
|
-
const waitedMs = previous === void 0 ? 0 : Math.max(0, previous + minIntervalMs -
|
|
308
|
+
const at2 = now();
|
|
309
|
+
const waitedMs = previous === void 0 ? 0 : Math.max(0, previous + minIntervalMs - at2);
|
|
307
310
|
if (waitedMs > 0) await sleep(waitedMs);
|
|
308
|
-
lastCallAt.set(channel,
|
|
311
|
+
lastCallAt.set(channel, at2 + waitedMs);
|
|
309
312
|
return { waitedMs };
|
|
310
313
|
}
|
|
311
314
|
function resetRateState() {
|
|
@@ -803,6 +806,71 @@ function readString(config, key) {
|
|
|
803
806
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
804
807
|
}
|
|
805
808
|
|
|
809
|
+
// src/cursor-read.ts
|
|
810
|
+
var CURSOR_READ_REASONS = ["cursor-missing", "cursor-expired", "cursor-overflow", "bad-page"];
|
|
811
|
+
var CursorReadError = class extends Error {
|
|
812
|
+
constructor(reason, message, resyncQuery) {
|
|
813
|
+
super(message);
|
|
814
|
+
this.reason = reason;
|
|
815
|
+
this.resyncQuery = resyncQuery;
|
|
816
|
+
this.name = "CursorReadError";
|
|
817
|
+
}
|
|
818
|
+
reason;
|
|
819
|
+
resyncQuery;
|
|
820
|
+
};
|
|
821
|
+
async function readWithCursor(spec, cursor, call, options = {}) {
|
|
822
|
+
const refuse = (reason, message) => new CursorReadError(reason, message, options.resyncQuery);
|
|
823
|
+
const used = asCursor(cursor);
|
|
824
|
+
if (used === null) throw refuse("cursor-missing", `no stored ${spec.param}: the host seeds one before the first read`);
|
|
825
|
+
const collected = [];
|
|
826
|
+
let last;
|
|
827
|
+
let token = null;
|
|
828
|
+
let pages = 0;
|
|
829
|
+
for (; ; ) {
|
|
830
|
+
if (spec.page && pages === spec.page.maxPages) {
|
|
831
|
+
throw refuse("cursor-overflow", `more than ${spec.page.maxPages} pages since ${spec.param} ${used}`);
|
|
832
|
+
}
|
|
833
|
+
const params = { [spec.param]: used };
|
|
834
|
+
if (spec.page && token !== null) params[spec.page.param] = token;
|
|
835
|
+
try {
|
|
836
|
+
last = await call(params);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
const status = error?.status;
|
|
839
|
+
if (spec.expiredStatus !== void 0 && status === spec.expiredStatus) {
|
|
840
|
+
throw refuse("cursor-expired", `${spec.param} ${used} is out of the provider's range (${status})`);
|
|
841
|
+
}
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
pages++;
|
|
845
|
+
if (!spec.page) break;
|
|
846
|
+
const items = at(last, spec.page.collect);
|
|
847
|
+
if (items !== void 0 && !Array.isArray(items)) {
|
|
848
|
+
throw refuse("bad-page", `${spec.page.collect} is not a list`);
|
|
849
|
+
}
|
|
850
|
+
if (items) collected.push(...items);
|
|
851
|
+
token = asCursor(at(last, spec.page.from));
|
|
852
|
+
if (token === null) break;
|
|
853
|
+
}
|
|
854
|
+
const data = spec.page ? withList(last, spec.page.collect, collected) : last;
|
|
855
|
+
return { data, cursor: { used, next: asCursor(at(last, spec.advanceFrom)) } };
|
|
856
|
+
}
|
|
857
|
+
function asCursor(value) {
|
|
858
|
+
if (typeof value === "string") return value === "" ? null : value;
|
|
859
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
function at(value, path) {
|
|
863
|
+
return path.split(".").reduce(
|
|
864
|
+
(v, key) => v !== null && typeof v === "object" && !Array.isArray(v) ? v[key] : void 0,
|
|
865
|
+
value
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
function withList(data, path, list) {
|
|
869
|
+
const [head, ...rest] = path.split(".");
|
|
870
|
+
const base = data !== null && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
871
|
+
return { ...base, [head]: rest.length ? withList(base[head], rest.join("."), list) : list };
|
|
872
|
+
}
|
|
873
|
+
|
|
806
874
|
// src/drift.ts
|
|
807
875
|
function resolveRef(document, node, depth = 0) {
|
|
808
876
|
if (depth > 8 || node === null || typeof node !== "object") return node;
|
|
@@ -1457,32 +1525,32 @@ function base64urlBytes(segment) {
|
|
|
1457
1525
|
var EXPIRES_AT_UNITS = ["rfc3339", "epoch-ms"];
|
|
1458
1526
|
var INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
1459
1527
|
var DEFAULT_CONTEXT = { service: "", operation: "subscription" };
|
|
1460
|
-
function instantMs(value, field,
|
|
1528
|
+
function instantMs(value, field, at2) {
|
|
1461
1529
|
if (typeof value !== "string" || !INSTANT.test(value)) {
|
|
1462
1530
|
throw new ConnectorConfigError(
|
|
1463
1531
|
`${field} must be an RFC 3339 instant such as "2026-09-22T00:00:00Z", got ${JSON.stringify(value)}. A provider's own shape \u2014 Google's epoch milliseconds, say \u2014 is converted by the connector, never guessed at here.`,
|
|
1464
|
-
|
|
1532
|
+
at2
|
|
1465
1533
|
);
|
|
1466
1534
|
}
|
|
1467
1535
|
const ms = Date.parse(value.replace(/(\.\d{3})\d+/, "$1"));
|
|
1468
1536
|
if (Number.isNaN(ms)) {
|
|
1469
|
-
throw new ConnectorConfigError(`${field} is not a real instant: ${JSON.stringify(value)}`,
|
|
1537
|
+
throw new ConnectorConfigError(`${field} is not a real instant: ${JSON.stringify(value)}`, at2);
|
|
1470
1538
|
}
|
|
1471
1539
|
return ms;
|
|
1472
1540
|
}
|
|
1473
|
-
function leaseFromResponse(declaration, response,
|
|
1541
|
+
function leaseFromResponse(declaration, response, at2 = DEFAULT_CONTEXT) {
|
|
1474
1542
|
const { expiresAtFrom: path, expiresAtUnit: unit } = declaration;
|
|
1475
1543
|
if (!EXPIRES_AT_UNITS.includes(unit)) {
|
|
1476
1544
|
throw new ConnectorConfigError(
|
|
1477
1545
|
`expiresAtUnit must be one of ${EXPIRES_AT_UNITS.join(" | ")}, got ${JSON.stringify(unit)}.`,
|
|
1478
|
-
|
|
1546
|
+
at2
|
|
1479
1547
|
);
|
|
1480
1548
|
}
|
|
1481
1549
|
const value = readPath(response, path);
|
|
1482
1550
|
if (value === void 0 || value === null) {
|
|
1483
1551
|
throw new ConnectorConfigError(
|
|
1484
1552
|
`${path} is not present in the response, so no lease can be built \u2014 a subscription with no known expiry is one nobody renews.`,
|
|
1485
|
-
|
|
1553
|
+
at2
|
|
1486
1554
|
);
|
|
1487
1555
|
}
|
|
1488
1556
|
let expiresAt;
|
|
@@ -1491,7 +1559,7 @@ function leaseFromResponse(declaration, response, at = DEFAULT_CONTEXT) {
|
|
|
1491
1559
|
if (!Number.isSafeInteger(ms)) {
|
|
1492
1560
|
throw new ConnectorConfigError(
|
|
1493
1561
|
`${path} must be epoch milliseconds as digits under "epoch-ms", got ${JSON.stringify(value)}.`,
|
|
1494
|
-
|
|
1562
|
+
at2
|
|
1495
1563
|
);
|
|
1496
1564
|
}
|
|
1497
1565
|
expiresAt = new Date(ms).toISOString();
|
|
@@ -1499,14 +1567,14 @@ function leaseFromResponse(declaration, response, at = DEFAULT_CONTEXT) {
|
|
|
1499
1567
|
if (typeof value !== "string" || !INSTANT.test(value)) {
|
|
1500
1568
|
throw new ConnectorConfigError(
|
|
1501
1569
|
`${path} must be an RFC 3339 instant under "rfc3339", got ${JSON.stringify(value)}.`,
|
|
1502
|
-
|
|
1570
|
+
at2
|
|
1503
1571
|
);
|
|
1504
1572
|
}
|
|
1505
|
-
expiresAt = new Date(instantMs(value, path,
|
|
1573
|
+
expiresAt = new Date(instantMs(value, path, at2)).toISOString();
|
|
1506
1574
|
}
|
|
1507
1575
|
return subscriptionLease(
|
|
1508
1576
|
{ expiresAt, renewBeforeSeconds: declaration.renewBeforeSeconds, renewOperation: declaration.renewOperation },
|
|
1509
|
-
|
|
1577
|
+
at2
|
|
1510
1578
|
);
|
|
1511
1579
|
}
|
|
1512
1580
|
function readPath(value, path) {
|
|
@@ -1517,18 +1585,18 @@ function readPath(value, path) {
|
|
|
1517
1585
|
}
|
|
1518
1586
|
return current;
|
|
1519
1587
|
}
|
|
1520
|
-
function subscriptionLease(input,
|
|
1521
|
-
instantMs(input.expiresAt, "expiresAt",
|
|
1588
|
+
function subscriptionLease(input, at2 = DEFAULT_CONTEXT) {
|
|
1589
|
+
instantMs(input.expiresAt, "expiresAt", at2);
|
|
1522
1590
|
if (!Number.isInteger(input.renewBeforeSeconds) || input.renewBeforeSeconds <= 0) {
|
|
1523
1591
|
throw new ConnectorConfigError(
|
|
1524
1592
|
`renewBeforeSeconds must be a positive whole number of seconds, got ${JSON.stringify(input.renewBeforeSeconds)}. Zero would make the lease due only at the instant it expires, which is a lease nobody renews.`,
|
|
1525
|
-
|
|
1593
|
+
at2
|
|
1526
1594
|
);
|
|
1527
1595
|
}
|
|
1528
1596
|
if (typeof input.renewOperation !== "string" || input.renewOperation.trim() === "") {
|
|
1529
1597
|
throw new ConnectorConfigError(
|
|
1530
1598
|
"renewOperation must name the operation the host calls when the lease is due; a due lease with nothing to call is one nobody renews.",
|
|
1531
|
-
|
|
1599
|
+
at2
|
|
1532
1600
|
);
|
|
1533
1601
|
}
|
|
1534
1602
|
return { expiresAt: input.expiresAt, renewBeforeSeconds: input.renewBeforeSeconds, renewOperation: input.renewOperation };
|
|
@@ -1537,10 +1605,10 @@ function leaseRenewAt(lease) {
|
|
|
1537
1605
|
return new Date(instantMs(lease.expiresAt, "expiresAt", DEFAULT_CONTEXT) - lease.renewBeforeSeconds * 1e3).toISOString();
|
|
1538
1606
|
}
|
|
1539
1607
|
function leaseState(lease, now) {
|
|
1540
|
-
const
|
|
1608
|
+
const at2 = now instanceof Date ? now.getTime() : instantMs(now, "now", DEFAULT_CONTEXT);
|
|
1541
1609
|
const expires = instantMs(lease.expiresAt, "expiresAt", DEFAULT_CONTEXT);
|
|
1542
|
-
if (
|
|
1543
|
-
if (
|
|
1610
|
+
if (at2 >= expires) return "expired";
|
|
1611
|
+
if (at2 >= expires - lease.renewBeforeSeconds * 1e3) return "due";
|
|
1544
1612
|
return "active";
|
|
1545
1613
|
}
|
|
1546
1614
|
function leaseAction(lease, now) {
|
|
@@ -1860,8 +1928,8 @@ function splitMultipart(body, boundary) {
|
|
|
1860
1928
|
if (partStart >= 0 && partStart <= body.length) children.push(body.subarray(partStart));
|
|
1861
1929
|
return children;
|
|
1862
1930
|
}
|
|
1863
|
-
function startsWith(haystack,
|
|
1864
|
-
for (let k = 0; k < needle.length; k++) if (haystack[
|
|
1931
|
+
function startsWith(haystack, at2, needle) {
|
|
1932
|
+
for (let k = 0; k < needle.length; k++) if (haystack[at2 + k] !== needle[k]) return false;
|
|
1865
1933
|
return true;
|
|
1866
1934
|
}
|
|
1867
1935
|
function parseMime(raw) {
|
|
@@ -2503,6 +2571,7 @@ function hasDelivery(value) {
|
|
|
2503
2571
|
CONNECTOR_API_VERSION,
|
|
2504
2572
|
CONNECTOR_MODES,
|
|
2505
2573
|
CREDENTIAL_SCOPES,
|
|
2574
|
+
CURSOR_READ_REASONS,
|
|
2506
2575
|
ConnectorAmbiguous,
|
|
2507
2576
|
ConnectorApiMismatch,
|
|
2508
2577
|
ConnectorAuthError,
|
|
@@ -2514,6 +2583,7 @@ function hasDelivery(value) {
|
|
|
2514
2583
|
ConnectorRequestError,
|
|
2515
2584
|
ConnectorTransient,
|
|
2516
2585
|
ConnectorUnreachable,
|
|
2586
|
+
CursorReadError,
|
|
2517
2587
|
DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
|
|
2518
2588
|
DEFAULT_RETRY,
|
|
2519
2589
|
DELIVERY_DECODE_KINDS,
|
|
@@ -2582,6 +2652,7 @@ function hasDelivery(value) {
|
|
|
2582
2652
|
probe,
|
|
2583
2653
|
providerProblems,
|
|
2584
2654
|
providerSandboxIsSelectable,
|
|
2655
|
+
readWithCursor,
|
|
2585
2656
|
registerConnectionHost,
|
|
2586
2657
|
registerTransport,
|
|
2587
2658
|
render,
|