@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 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 at = now();
306
- const waitedMs = previous === void 0 ? 0 : Math.max(0, previous + minIntervalMs - at);
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, at + waitedMs);
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, at) {
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
- at
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)}`, at);
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, at = DEFAULT_CONTEXT) {
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
- at
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
- at
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
- at
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
- at
1570
+ at2
1503
1571
  );
1504
1572
  }
1505
- expiresAt = new Date(instantMs(value, path, at)).toISOString();
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
- at
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, at = DEFAULT_CONTEXT) {
1521
- instantMs(input.expiresAt, "expiresAt", at);
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
- at
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
- at
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 at = now instanceof Date ? now.getTime() : instantMs(now, "now", DEFAULT_CONTEXT);
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 (at >= expires) return "expired";
1543
- if (at >= expires - lease.renewBeforeSeconds * 1e3) return "due";
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, at, needle) {
1864
- for (let k = 0; k < needle.length; k++) if (haystack[at + k] !== needle[k]) return false;
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,