@particle-academy/fancy-connector-core 0.0.0 → 0.1.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 +103 -103
- package/LICENSE +21 -21
- package/README.md +175 -175
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +71 -71
package/CHANGELOG.md
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `fancy-connector-core` are documented here, in
|
|
4
|
-
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
5
|
-
|
|
6
|
-
**This package is pre-1.0, so breaking changes land in MINOR releases.** The
|
|
7
|
-
version number is not a promise it can keep yet; the entries below are.
|
|
8
|
-
|
|
9
|
-
## [Unreleased]
|
|
10
|
-
|
|
11
|
-
## [0.1.0] — unreleased
|
|
12
|
-
|
|
13
|
-
First cut. Extracted and generalised from the `_connector` shared runtime in the
|
|
14
|
-
fancy-flow node marketplace, then rebuilt around the requirements in The Ripple
|
|
15
|
-
Effect's packager brief, which came from five adapters dry-verified against real
|
|
16
|
-
APIs.
|
|
17
|
-
|
|
18
|
-
The connector **catalogue** lives in `fancy-connectors` and releases on its own
|
|
19
|
-
clock; this package is the runtime it is written on.
|
|
20
|
-
|
|
21
|
-
### Added
|
|
22
|
-
|
|
23
|
-
- **Core runtime, TypeScript and PHP, with zero runtime dependencies in either
|
|
24
|
-
ecosystem.** WebCrypto and `fetch` on Node; `hash()` and cURL behind a
|
|
25
|
-
swappable `Transport` in PHP.
|
|
26
|
-
- **`delivery` — a four-way failure classification.** `unreachable` /
|
|
27
|
-
`refused-explicitly` / `ambiguous` / `rejected`, with retryability a function
|
|
28
|
-
of the kind **and** the connector's declared idempotency. An unrecognised
|
|
29
|
-
error falls to `ambiguous`, never to safe.
|
|
30
|
-
- **`DeliveryDeclaration`** — `idempotent` + a cited `why` + `minIntervalMs` +
|
|
31
|
-
`rateSource` (`documented` | `self-imposed`) + a dated `Citation`.
|
|
32
|
-
- **`seam` — `ProviderAdapter` and `Connector`**, deliberately separate, plus
|
|
33
|
-
the written list of everything the package never owns: approval, liveness, the
|
|
34
|
-
approved-bytes comparison, consent, second review, journals, credential
|
|
35
|
-
storage.
|
|
36
|
-
- **`text`** — `measure(text, unit)` over graphemes / characters / UTF-8 bytes,
|
|
37
|
-
and a `ByteRange` type producible only by a function that encoded the string.
|
|
38
|
-
- **`render`** — pure, versioned (version **inside** the payload hash), loss
|
|
39
|
-
reported rather than applied, driven by a declarative `RenderRules` so most
|
|
40
|
-
providers need no rendering code at all.
|
|
41
|
-
- **`chain`** — root fixed, parent advancing, with the post function as an
|
|
42
|
-
argument so threading is testable without a live session.
|
|
43
|
-
- **`metrics`** — declared metric shapes, `compareShape()`,
|
|
44
|
-
`capabilityProblems()`, and `reported()` so *absent stays absent* is a
|
|
45
|
-
property of the code.
|
|
46
|
-
- **`probe`** — dry-verify against the real API with a deliberately invalid
|
|
47
|
-
credential, per-provider `authStatuses`, a 2xx counted as a failure, and
|
|
48
|
-
offline reported as **skipped** rather than failed.
|
|
49
|
-
- **`drift`** — API-drift *reporting*: a narrow projection of an OpenAPI
|
|
50
|
-
document against the operations a connector actually calls, a recorded-shape
|
|
51
|
-
fallback for the majority of providers that publish nothing, and `unchecked`
|
|
52
|
-
as a first-class outcome that is never collapsed into `clean`.
|
|
53
|
-
- **Explicit-credentials resolution.** Credentials passed straight in bypass the
|
|
54
|
-
connection registry entirely; an incomplete set is a loud failure rather than
|
|
55
|
-
a silent fall-through.
|
|
56
|
-
- **`CONNECTOR_API_VERSION` and `assertConnectorApi`** (`ConnectorApi` in PHP) —
|
|
57
|
-
the compatibility number that makes a separate release clock safe for a
|
|
58
|
-
catalogue whose connectors ship as VENDORED source. It moves only when the
|
|
59
|
-
surface a connector is written against moves, the window is the current
|
|
60
|
-
version and the one before, and a mismatch names which side is behind rather
|
|
61
|
-
than reporting "incompatible".
|
|
62
|
-
|
|
63
|
-
#### Two things the PHP core does differently, and why
|
|
64
|
-
|
|
65
|
-
- **A custom `Transport` must throw `TransportException`** — via
|
|
66
|
-
`unreachable()`, `ambiguous()` or `fromCurlErrno()` — to get the *always safe
|
|
67
|
-
to retry* case. PHP has no error-code vocabulary the way Node does, and
|
|
68
|
-
matching on message text is not an option: it is localised and changes between
|
|
69
|
-
HTTP-client versions. **What a consumer must do:** nothing if you use the
|
|
70
|
-
bundled `CurlTransport`. If you bind your own, throw `TransportException`
|
|
71
|
-
rather than a bare exception — a bare one is classified `Ambiguous`, which is
|
|
72
|
-
conservative and correct but gives up the retry a refused connection deserves.
|
|
73
|
-
- **`RunIdentity` is bridged rather than implemented.** PHP interfaces are
|
|
74
|
-
nominal, so `FancyFlow\Runtime\RunIdentity` — which matches the shape exactly —
|
|
75
|
-
is not an instance of ours. `ForeignRunIdentity::adapt()` wraps any object
|
|
76
|
-
carrying the five members, and every `Idempotency` entry point runs its
|
|
77
|
-
argument through it. **What a consumer must do: nothing.** Pass `$ctx->run`
|
|
78
|
-
straight in, exactly as the TypeScript side does.
|
|
79
|
-
|
|
80
|
-
### Fixed
|
|
81
|
-
|
|
82
|
-
- **An ambiguous failure was retried on a non-idempotent connector.** The
|
|
83
|
-
runtime this was extracted from classified a thrown transport as
|
|
84
|
-
`ConnectorTransient` with `retryable = true` and retried it twice. A thrown
|
|
85
|
-
transport includes a timeout, which may be a request the provider already
|
|
86
|
-
acted on — so any connector without an idempotency key could be retried into a
|
|
87
|
-
silent double write. **What a consumer must do: nothing, unless you relied on
|
|
88
|
-
that behaviour.** A connector that genuinely is safe to repeat now says so
|
|
89
|
-
with `idempotent: true` and keeps its retries; everything else becomes
|
|
90
|
-
conservative, which is the correct direction.
|
|
91
|
-
- **The sentence splitter broke URLs.** `splitToFit` treated every `.` as a
|
|
92
|
-
sentence terminator, so `https://example.test/x` was a candidate break point
|
|
93
|
-
and a link could land across two messages at a low limit. A terminator now
|
|
94
|
-
only ends a sentence when whitespace or the end follows it. **What a consumer
|
|
95
|
-
must do: nothing** — but any payload hash computed under the old splitting
|
|
96
|
-
will differ, so an approval recorded against it will correctly refuse.
|
|
97
|
-
- **The core no longer imports a workflow engine.** The run identity used for
|
|
98
|
-
idempotency keys is declared structurally, so `fancy-flow`'s `RunIdentity`
|
|
99
|
-
satisfies it with no dependency in either direction and a host that has never
|
|
100
|
-
heard of a workflow engine can implement it.
|
|
101
|
-
|
|
102
|
-
[Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...HEAD
|
|
103
|
-
[0.1.0]: https://github.com/Particle-Academy/fancy-connector-core/releases/tag/v0.1.0
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `fancy-connector-core` are documented here, in
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
5
|
+
|
|
6
|
+
**This package is pre-1.0, so breaking changes land in MINOR releases.** The
|
|
7
|
+
version number is not a promise it can keep yet; the entries below are.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.1.0] — unreleased
|
|
12
|
+
|
|
13
|
+
First cut. Extracted and generalised from the `_connector` shared runtime in the
|
|
14
|
+
fancy-flow node marketplace, then rebuilt around the requirements in The Ripple
|
|
15
|
+
Effect's packager brief, which came from five adapters dry-verified against real
|
|
16
|
+
APIs.
|
|
17
|
+
|
|
18
|
+
The connector **catalogue** lives in `fancy-connectors` and releases on its own
|
|
19
|
+
clock; this package is the runtime it is written on.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Core runtime, TypeScript and PHP, with zero runtime dependencies in either
|
|
24
|
+
ecosystem.** WebCrypto and `fetch` on Node; `hash()` and cURL behind a
|
|
25
|
+
swappable `Transport` in PHP.
|
|
26
|
+
- **`delivery` — a four-way failure classification.** `unreachable` /
|
|
27
|
+
`refused-explicitly` / `ambiguous` / `rejected`, with retryability a function
|
|
28
|
+
of the kind **and** the connector's declared idempotency. An unrecognised
|
|
29
|
+
error falls to `ambiguous`, never to safe.
|
|
30
|
+
- **`DeliveryDeclaration`** — `idempotent` + a cited `why` + `minIntervalMs` +
|
|
31
|
+
`rateSource` (`documented` | `self-imposed`) + a dated `Citation`.
|
|
32
|
+
- **`seam` — `ProviderAdapter` and `Connector`**, deliberately separate, plus
|
|
33
|
+
the written list of everything the package never owns: approval, liveness, the
|
|
34
|
+
approved-bytes comparison, consent, second review, journals, credential
|
|
35
|
+
storage.
|
|
36
|
+
- **`text`** — `measure(text, unit)` over graphemes / characters / UTF-8 bytes,
|
|
37
|
+
and a `ByteRange` type producible only by a function that encoded the string.
|
|
38
|
+
- **`render`** — pure, versioned (version **inside** the payload hash), loss
|
|
39
|
+
reported rather than applied, driven by a declarative `RenderRules` so most
|
|
40
|
+
providers need no rendering code at all.
|
|
41
|
+
- **`chain`** — root fixed, parent advancing, with the post function as an
|
|
42
|
+
argument so threading is testable without a live session.
|
|
43
|
+
- **`metrics`** — declared metric shapes, `compareShape()`,
|
|
44
|
+
`capabilityProblems()`, and `reported()` so *absent stays absent* is a
|
|
45
|
+
property of the code.
|
|
46
|
+
- **`probe`** — dry-verify against the real API with a deliberately invalid
|
|
47
|
+
credential, per-provider `authStatuses`, a 2xx counted as a failure, and
|
|
48
|
+
offline reported as **skipped** rather than failed.
|
|
49
|
+
- **`drift`** — API-drift *reporting*: a narrow projection of an OpenAPI
|
|
50
|
+
document against the operations a connector actually calls, a recorded-shape
|
|
51
|
+
fallback for the majority of providers that publish nothing, and `unchecked`
|
|
52
|
+
as a first-class outcome that is never collapsed into `clean`.
|
|
53
|
+
- **Explicit-credentials resolution.** Credentials passed straight in bypass the
|
|
54
|
+
connection registry entirely; an incomplete set is a loud failure rather than
|
|
55
|
+
a silent fall-through.
|
|
56
|
+
- **`CONNECTOR_API_VERSION` and `assertConnectorApi`** (`ConnectorApi` in PHP) —
|
|
57
|
+
the compatibility number that makes a separate release clock safe for a
|
|
58
|
+
catalogue whose connectors ship as VENDORED source. It moves only when the
|
|
59
|
+
surface a connector is written against moves, the window is the current
|
|
60
|
+
version and the one before, and a mismatch names which side is behind rather
|
|
61
|
+
than reporting "incompatible".
|
|
62
|
+
|
|
63
|
+
#### Two things the PHP core does differently, and why
|
|
64
|
+
|
|
65
|
+
- **A custom `Transport` must throw `TransportException`** — via
|
|
66
|
+
`unreachable()`, `ambiguous()` or `fromCurlErrno()` — to get the *always safe
|
|
67
|
+
to retry* case. PHP has no error-code vocabulary the way Node does, and
|
|
68
|
+
matching on message text is not an option: it is localised and changes between
|
|
69
|
+
HTTP-client versions. **What a consumer must do:** nothing if you use the
|
|
70
|
+
bundled `CurlTransport`. If you bind your own, throw `TransportException`
|
|
71
|
+
rather than a bare exception — a bare one is classified `Ambiguous`, which is
|
|
72
|
+
conservative and correct but gives up the retry a refused connection deserves.
|
|
73
|
+
- **`RunIdentity` is bridged rather than implemented.** PHP interfaces are
|
|
74
|
+
nominal, so `FancyFlow\Runtime\RunIdentity` — which matches the shape exactly —
|
|
75
|
+
is not an instance of ours. `ForeignRunIdentity::adapt()` wraps any object
|
|
76
|
+
carrying the five members, and every `Idempotency` entry point runs its
|
|
77
|
+
argument through it. **What a consumer must do: nothing.** Pass `$ctx->run`
|
|
78
|
+
straight in, exactly as the TypeScript side does.
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- **An ambiguous failure was retried on a non-idempotent connector.** The
|
|
83
|
+
runtime this was extracted from classified a thrown transport as
|
|
84
|
+
`ConnectorTransient` with `retryable = true` and retried it twice. A thrown
|
|
85
|
+
transport includes a timeout, which may be a request the provider already
|
|
86
|
+
acted on — so any connector without an idempotency key could be retried into a
|
|
87
|
+
silent double write. **What a consumer must do: nothing, unless you relied on
|
|
88
|
+
that behaviour.** A connector that genuinely is safe to repeat now says so
|
|
89
|
+
with `idempotent: true` and keeps its retries; everything else becomes
|
|
90
|
+
conservative, which is the correct direction.
|
|
91
|
+
- **The sentence splitter broke URLs.** `splitToFit` treated every `.` as a
|
|
92
|
+
sentence terminator, so `https://example.test/x` was a candidate break point
|
|
93
|
+
and a link could land across two messages at a low limit. A terminator now
|
|
94
|
+
only ends a sentence when whitespace or the end follows it. **What a consumer
|
|
95
|
+
must do: nothing** — but any payload hash computed under the old splitting
|
|
96
|
+
will differ, so an approval recorded against it will correctly refuse.
|
|
97
|
+
- **The core no longer imports a workflow engine.** The run identity used for
|
|
98
|
+
idempotency keys is declared structurally, so `fancy-flow`'s `RunIdentity`
|
|
99
|
+
satisfies it with no dependency in either direction and a host that has never
|
|
100
|
+
heard of a workflow engine can implement it.
|
|
101
|
+
|
|
102
|
+
[Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...HEAD
|
|
103
|
+
[0.1.0]: https://github.com/Particle-Academy/fancy-connector-core/releases/tag/v0.1.0
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Particle Academy
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
# fancy-connector-core
|
|
2
|
-
|
|
3
|
-
**The runtime under every Fancy connector.** Modes and estates, connections, one
|
|
4
|
-
call path, a failure taxonomy that knows the difference between *it never
|
|
5
|
-
arrived* and *nobody can tell*, deterministic fakers, webhook verification, pure
|
|
6
|
-
versioned rendering, chain posting, declared metric shapes, credential-free
|
|
7
|
-
probes, and API-drift reporting.
|
|
8
|
-
|
|
9
|
-
Matched **TypeScript + PHP**, one repository, **zero runtime dependencies in
|
|
10
|
-
either ecosystem**.
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
npm install @particle-academy/fancy-connector-core
|
|
14
|
-
composer require particle-academy/fancy-connector-core
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## It owns the wire. It never owns a gate.
|
|
20
|
-
|
|
21
|
-
Approval, liveness, the approved-bytes comparison, consent, second review and
|
|
22
|
-
every journal belong to the **host** — because each is enforced in one place and
|
|
23
|
-
every connector inherits it from the host's dispatch path rather than
|
|
24
|
-
implementing it.
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
await connector.call(target, { dryRun, credentials });
|
|
28
|
-
// ^^^^^^ ^^^^^^^^^^^
|
|
29
|
-
// yours yours
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Three of those promises are **tests**, not intentions:
|
|
33
|
-
|
|
34
|
-
1. **Nothing here reads the environment.** No `process.env`, no `getenv`. Your
|
|
35
|
-
credential storage discipline — a gitignored file behind a whitelist, a secret
|
|
36
|
-
manager, Laravel config — stays yours, because the package cannot reach past
|
|
37
|
-
it.
|
|
38
|
-
2. **Nothing here contacts a URL of its own.** No telemetry, no phone-home, no
|
|
39
|
-
default endpoint. Even the drift checker does not fetch: you fetch, and pass
|
|
40
|
-
the document in.
|
|
41
|
-
3. **Nothing here retries an ambiguous failure** unless the connector declared
|
|
42
|
-
that repeating a request is harmless.
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## The one that matters: retry, without ever sending twice
|
|
47
|
-
|
|
48
|
-
A network hiccup should not lose a call. A retry after a request that succeeded
|
|
49
|
-
and whose response was lost is a **double write** — the exact failure an
|
|
50
|
-
idempotency gate exists to prevent, arriving through the door marked
|
|
51
|
-
*reliability*.
|
|
52
|
-
|
|
53
|
-
Nearly every retry helper reconciles those the same way and loses: it catches an
|
|
54
|
-
error, waits, and tries again, because from the caller's side all failures look
|
|
55
|
-
alike.
|
|
56
|
-
|
|
57
|
-
| what happened | did the provider receive it? | retry? |
|
|
58
|
-
|---|---|---|
|
|
59
|
-
| DNS failure, connection refused | **no** | always safe |
|
|
60
|
-
| HTTP 429 / 5xx | **yes**, and it explicitly refused | safe — it says it did nothing |
|
|
61
|
-
| timeout, abort, **anything unrecognised** | **unknown** | only where the connector is idempotent |
|
|
62
|
-
| other 4xx | yes, and the answer was a real no | never |
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
import { classifyError, shouldRetry } from "@particle-academy/fancy-connector-core";
|
|
66
|
-
|
|
67
|
-
shouldRetry(classifyError(err).kind, { idempotent: connector.delivery.idempotent });
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`idempotent` defaults to **false**, and `delivery.why` must cite the mechanism —
|
|
71
|
-
`Idempotency-Key`, or the absence of one — because `idempotent: true` is the one
|
|
72
|
-
claim whose failure is a public duplicate.
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
## Text, and two one-line bugs that ASCII hides
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
import { measure, linkRanges, sliceByteRange } from "@particle-academy/fancy-connector-core";
|
|
80
|
-
|
|
81
|
-
measure("👍", "graphemes"); // 1 — "👍".length is 2
|
|
82
|
-
linkRanges("👍 https://example.test"); // UTF-8 BYTE ranges, not char offsets
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
`ByteRange` is a **type**, producible only by a function that encoded the string.
|
|
86
|
-
A character-offset implementation is correct for ASCII and silently corrupts the
|
|
87
|
-
*link* on any post containing an emoji — the post looks fine and goes somewhere
|
|
88
|
-
wrong.
|
|
89
|
-
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
## Rendering: pure, versioned, honest about loss
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
const payload = render(copy, {
|
|
96
|
-
limit: 300, unit: "graphemes", thread: true, links: true, label: "Bluesky",
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
payload.segments; // one entry per message; more than one means a thread
|
|
100
|
-
payload.problems; // what could NOT be fitted. Never silently applied.
|
|
101
|
-
await payloadHash(payload); // RENDERER_VERSION is INSIDE the hash
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Show the payload before you approve it, recompute it at dispatch, refuse on
|
|
105
|
-
mismatch. That is the only way "what was approved is what was sent" survives the
|
|
106
|
-
existence of adaptation — and the renderer version being inside the hash is what
|
|
107
|
-
stops it being checked separately and forgotten.
|
|
108
|
-
|
|
109
|
-
---
|
|
110
|
-
|
|
111
|
-
## Threading
|
|
112
|
-
|
|
113
|
-
```ts
|
|
114
|
-
await postChain(payload.segments.map((s) => s.text), answering, post);
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Root fixed at the top of the chain; parent advancing to whatever was just posted.
|
|
118
|
-
Reverse them and every message attaches to the first — a fan, not a thread — and
|
|
119
|
-
the provider's response looks identical either way. The post function is an
|
|
120
|
-
**argument** so that is reachable by a test.
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## Probes: an auth error proves the transport
|
|
125
|
-
|
|
126
|
-
A connector written from documentation can be wrong in three ways that look
|
|
127
|
-
identical until a credential arrives: the URL is wrong, the request shape is
|
|
128
|
-
wrong, or the error handling never runs. **All three are invisible to a fake
|
|
129
|
-
server, because a fake server agrees with whatever your code does.**
|
|
130
|
-
|
|
131
|
-
So call the real API with a deliberately invalid credential and require an
|
|
132
|
-
auth-shaped refusal.
|
|
133
|
-
|
|
134
|
-
- `authStatuses` is declared per provider — a Discord webhook answers `404` for
|
|
135
|
-
an unknown id, so 404 IS its auth answer.
|
|
136
|
-
- **A 2xx is a failure.** The credential is reaching nothing.
|
|
137
|
-
- **Offline is `skip`, never `fail`.** A check that goes red on a train gets
|
|
138
|
-
ignored, and is then worth nothing when it goes red for real.
|
|
139
|
-
|
|
140
|
-
---
|
|
141
|
-
|
|
142
|
-
## Drift: it reports, it never adapts
|
|
143
|
-
|
|
144
|
-
A connector's worst failure is silent — the provider changes its API and nothing
|
|
145
|
-
says so until something breaks.
|
|
146
|
-
|
|
147
|
-
**And the fix is not a connector that reshapes itself.** That connector cannot be
|
|
148
|
-
reviewed, its behaviour depends on when it ran, and adaptation is only ever a
|
|
149
|
-
guess at intent. So:
|
|
150
|
-
|
|
151
|
-
> Drift reports. The provider decides. A person acts.
|
|
152
|
-
|
|
153
|
-
Drift also never changes runtime behaviour: it runs out of band, and a check that
|
|
154
|
-
failed a working call because a *document* changed would be a self-inflicted
|
|
155
|
-
outage.
|
|
156
|
-
|
|
157
|
-
What is checked is a narrow projection, not a diff — *for the operations this
|
|
158
|
-
connector actually calls, do the path, method, sent fields and read fields still
|
|
159
|
-
exist?* Where a provider publishes no spec (most do not), the fallback is a
|
|
160
|
-
recorded shape: field **names** only.
|
|
161
|
-
|
|
162
|
-
`outcome` is `clean` | `drifted` | **`unchecked`**. The third is never collapsed
|
|
163
|
-
into the first — a checker that could not see is not a checker that saw nothing
|
|
164
|
-
wrong.
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## Documentation
|
|
169
|
-
|
|
170
|
-
- [`AGENTS.md`](./AGENTS.md) — the invariants, the traps, and what a change here
|
|
171
|
-
breaks.
|
|
172
|
-
- `.ai/plans/fancy-connectors.md` in the envelope — the architecture, the
|
|
173
|
-
Socialite seam, the drift research, and every decision with its reason.
|
|
174
|
-
|
|
175
|
-
MIT.
|
|
1
|
+
# fancy-connector-core
|
|
2
|
+
|
|
3
|
+
**The runtime under every Fancy connector.** Modes and estates, connections, one
|
|
4
|
+
call path, a failure taxonomy that knows the difference between *it never
|
|
5
|
+
arrived* and *nobody can tell*, deterministic fakers, webhook verification, pure
|
|
6
|
+
versioned rendering, chain posting, declared metric shapes, credential-free
|
|
7
|
+
probes, and API-drift reporting.
|
|
8
|
+
|
|
9
|
+
Matched **TypeScript + PHP**, one repository, **zero runtime dependencies in
|
|
10
|
+
either ecosystem**.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @particle-academy/fancy-connector-core
|
|
14
|
+
composer require particle-academy/fancy-connector-core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## It owns the wire. It never owns a gate.
|
|
20
|
+
|
|
21
|
+
Approval, liveness, the approved-bytes comparison, consent, second review and
|
|
22
|
+
every journal belong to the **host** — because each is enforced in one place and
|
|
23
|
+
every connector inherits it from the host's dispatch path rather than
|
|
24
|
+
implementing it.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
await connector.call(target, { dryRun, credentials });
|
|
28
|
+
// ^^^^^^ ^^^^^^^^^^^
|
|
29
|
+
// yours yours
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Three of those promises are **tests**, not intentions:
|
|
33
|
+
|
|
34
|
+
1. **Nothing here reads the environment.** No `process.env`, no `getenv`. Your
|
|
35
|
+
credential storage discipline — a gitignored file behind a whitelist, a secret
|
|
36
|
+
manager, Laravel config — stays yours, because the package cannot reach past
|
|
37
|
+
it.
|
|
38
|
+
2. **Nothing here contacts a URL of its own.** No telemetry, no phone-home, no
|
|
39
|
+
default endpoint. Even the drift checker does not fetch: you fetch, and pass
|
|
40
|
+
the document in.
|
|
41
|
+
3. **Nothing here retries an ambiguous failure** unless the connector declared
|
|
42
|
+
that repeating a request is harmless.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## The one that matters: retry, without ever sending twice
|
|
47
|
+
|
|
48
|
+
A network hiccup should not lose a call. A retry after a request that succeeded
|
|
49
|
+
and whose response was lost is a **double write** — the exact failure an
|
|
50
|
+
idempotency gate exists to prevent, arriving through the door marked
|
|
51
|
+
*reliability*.
|
|
52
|
+
|
|
53
|
+
Nearly every retry helper reconciles those the same way and loses: it catches an
|
|
54
|
+
error, waits, and tries again, because from the caller's side all failures look
|
|
55
|
+
alike.
|
|
56
|
+
|
|
57
|
+
| what happened | did the provider receive it? | retry? |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| DNS failure, connection refused | **no** | always safe |
|
|
60
|
+
| HTTP 429 / 5xx | **yes**, and it explicitly refused | safe — it says it did nothing |
|
|
61
|
+
| timeout, abort, **anything unrecognised** | **unknown** | only where the connector is idempotent |
|
|
62
|
+
| other 4xx | yes, and the answer was a real no | never |
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { classifyError, shouldRetry } from "@particle-academy/fancy-connector-core";
|
|
66
|
+
|
|
67
|
+
shouldRetry(classifyError(err).kind, { idempotent: connector.delivery.idempotent });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`idempotent` defaults to **false**, and `delivery.why` must cite the mechanism —
|
|
71
|
+
`Idempotency-Key`, or the absence of one — because `idempotent: true` is the one
|
|
72
|
+
claim whose failure is a public duplicate.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Text, and two one-line bugs that ASCII hides
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { measure, linkRanges, sliceByteRange } from "@particle-academy/fancy-connector-core";
|
|
80
|
+
|
|
81
|
+
measure("👍", "graphemes"); // 1 — "👍".length is 2
|
|
82
|
+
linkRanges("👍 https://example.test"); // UTF-8 BYTE ranges, not char offsets
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`ByteRange` is a **type**, producible only by a function that encoded the string.
|
|
86
|
+
A character-offset implementation is correct for ASCII and silently corrupts the
|
|
87
|
+
*link* on any post containing an emoji — the post looks fine and goes somewhere
|
|
88
|
+
wrong.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Rendering: pure, versioned, honest about loss
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const payload = render(copy, {
|
|
96
|
+
limit: 300, unit: "graphemes", thread: true, links: true, label: "Bluesky",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
payload.segments; // one entry per message; more than one means a thread
|
|
100
|
+
payload.problems; // what could NOT be fitted. Never silently applied.
|
|
101
|
+
await payloadHash(payload); // RENDERER_VERSION is INSIDE the hash
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Show the payload before you approve it, recompute it at dispatch, refuse on
|
|
105
|
+
mismatch. That is the only way "what was approved is what was sent" survives the
|
|
106
|
+
existence of adaptation — and the renderer version being inside the hash is what
|
|
107
|
+
stops it being checked separately and forgotten.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Threading
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
await postChain(payload.segments.map((s) => s.text), answering, post);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Root fixed at the top of the chain; parent advancing to whatever was just posted.
|
|
118
|
+
Reverse them and every message attaches to the first — a fan, not a thread — and
|
|
119
|
+
the provider's response looks identical either way. The post function is an
|
|
120
|
+
**argument** so that is reachable by a test.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Probes: an auth error proves the transport
|
|
125
|
+
|
|
126
|
+
A connector written from documentation can be wrong in three ways that look
|
|
127
|
+
identical until a credential arrives: the URL is wrong, the request shape is
|
|
128
|
+
wrong, or the error handling never runs. **All three are invisible to a fake
|
|
129
|
+
server, because a fake server agrees with whatever your code does.**
|
|
130
|
+
|
|
131
|
+
So call the real API with a deliberately invalid credential and require an
|
|
132
|
+
auth-shaped refusal.
|
|
133
|
+
|
|
134
|
+
- `authStatuses` is declared per provider — a Discord webhook answers `404` for
|
|
135
|
+
an unknown id, so 404 IS its auth answer.
|
|
136
|
+
- **A 2xx is a failure.** The credential is reaching nothing.
|
|
137
|
+
- **Offline is `skip`, never `fail`.** A check that goes red on a train gets
|
|
138
|
+
ignored, and is then worth nothing when it goes red for real.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Drift: it reports, it never adapts
|
|
143
|
+
|
|
144
|
+
A connector's worst failure is silent — the provider changes its API and nothing
|
|
145
|
+
says so until something breaks.
|
|
146
|
+
|
|
147
|
+
**And the fix is not a connector that reshapes itself.** That connector cannot be
|
|
148
|
+
reviewed, its behaviour depends on when it ran, and adaptation is only ever a
|
|
149
|
+
guess at intent. So:
|
|
150
|
+
|
|
151
|
+
> Drift reports. The provider decides. A person acts.
|
|
152
|
+
|
|
153
|
+
Drift also never changes runtime behaviour: it runs out of band, and a check that
|
|
154
|
+
failed a working call because a *document* changed would be a self-inflicted
|
|
155
|
+
outage.
|
|
156
|
+
|
|
157
|
+
What is checked is a narrow projection, not a diff — *for the operations this
|
|
158
|
+
connector actually calls, do the path, method, sent fields and read fields still
|
|
159
|
+
exist?* Where a provider publishes no spec (most do not), the fallback is a
|
|
160
|
+
recorded shape: field **names** only.
|
|
161
|
+
|
|
162
|
+
`outcome` is `clean` | `drifted` | **`unchecked`**. The third is never collapsed
|
|
163
|
+
into the first — a checker that could not see is not a checker that saw nothing
|
|
164
|
+
wrong.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Documentation
|
|
169
|
+
|
|
170
|
+
- [`AGENTS.md`](./AGENTS.md) — the invariants, the traps, and what a change here
|
|
171
|
+
breaks.
|
|
172
|
+
- `.ai/plans/fancy-connectors.md` in the envelope — the architecture, the
|
|
173
|
+
Socialite seam, the drift research, and every decision with its reason.
|
|
174
|
+
|
|
175
|
+
MIT.
|