felix-client 0.5.0 → 0.6.0-preview
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/README.md +29 -10
- package/errors.js +189 -0
- package/index.d.ts +172 -14
- package/index.js +32 -90
- package/package.json +9 -8
package/README.md
CHANGED
|
@@ -10,6 +10,17 @@ the places that matter. Here they exist once, in `felix-client`, and every
|
|
|
10
10
|
language binds to them. The Python binding is built on the same reasoning and
|
|
11
11
|
exposes the same surface.
|
|
12
12
|
|
|
13
|
+
```bash
|
|
14
|
+
npm install felix-client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The binary ships as one package per platform, declared as optional
|
|
18
|
+
dependencies, so npm fetches only the one your machine needs. Nothing is
|
|
19
|
+
compiled at install time. Linux (x86-64 and arm64, glibc), macOS (Intel and
|
|
20
|
+
Apple silicon) and Windows x86-64 are covered. Node 18 or newer.
|
|
21
|
+
|
|
22
|
+
Full documentation: https://gabloe.github.io/felix/clients/typescript/
|
|
23
|
+
|
|
13
24
|
## One surface, and it is asynchronous
|
|
14
25
|
|
|
15
26
|
Python offers two surfaces because its sync one is the older idiom. Node has no
|
|
@@ -40,14 +51,15 @@ Everything the Python binding wraps, with one exception noted below.
|
|
|
40
51
|
|
|
41
52
|
| | |
|
|
42
53
|
|---|---|
|
|
43
|
-
| Publish | `publish(tenant, ns, stream, payload, key?, ack?, atLeastOnce?)` |
|
|
54
|
+
| Publish | `publish(tenant, ns, stream, payload, key?, ack?, atLeastOnce?)` → the record's offset, or `null` when the broker acked before writing it |
|
|
44
55
|
| Subscribe | `subscribe(...)` → `nextEvent()`, `close()`, `closed` |
|
|
45
56
|
| Sharded subscribe | `subscribeSharded(..., start?, resume?)` → `nextEvent()`, `positions()`, `shards` |
|
|
46
57
|
| Stream shape | `streamShards(...)`, `endpoints()` |
|
|
47
58
|
| Cache | `cachePut` (with TTL), `cacheGet`, `cacheDelete` |
|
|
48
59
|
| Counters | `counterAdd`, `counterGet` |
|
|
49
60
|
| Cache watches | `watchCache(..., key?, prefix?, start?, retained?)` → `recv()`, `retainedCount` |
|
|
50
|
-
| Consumer groups | `groupPoll`, `groupAck`, `groupNack`, `groupDeadLetters`, `groupDiscard`, `groupRedrive` |
|
|
61
|
+
| Consumer groups | `groupPoll`, `groupAck`, `groupNack`, `groupDeadLetters`, `groupDiscard`, `groupRedrive` (each follows the broker's redirect to the shard's leader) |
|
|
62
|
+
| Atomic commits | `commit(tenant, ns, entityKey, [CommitOp.publish \| enqueue, put, delete])` → `{ offset }`, `stateGet(tenant, ns, stream, entityKey, key)` → `{ value, version, asOf }`; `NotOnOwningShardError` and `EventCountError` refuse a commit that is not one record. See [atomic commits](../../../docs/atomic-commit.md) |
|
|
51
63
|
|
|
52
64
|
Every handle has an idempotent `close()` and implements `Symbol.asyncDispose`,
|
|
53
65
|
so on Node 24 and newer a `throw` releases it on the way out:
|
|
@@ -76,9 +88,11 @@ shard.
|
|
|
76
88
|
|
|
77
89
|
Subscriber queues shed under the default policy rather than blocking the
|
|
78
90
|
publisher, so a subscriber can silently miss records. On a durable stream each
|
|
79
|
-
delivered event carries its log offset, and a jump in them is
|
|
91
|
+
delivered event carries its log offset, and a jump in them is a drop —
|
|
80
92
|
which is why `event.offset` is worth reading even when you do not resume from
|
|
81
|
-
it.
|
|
93
|
+
it. The one gap that is not a drop is a new leader's generation-start
|
|
94
|
+
record, and the event after it says so: `offset - previous - 1n - skippedBefore`
|
|
95
|
+
records were dropped.
|
|
82
96
|
|
|
83
97
|
### A sharded subscription surfaces shard trouble rather than hiding it
|
|
84
98
|
|
|
@@ -119,16 +133,21 @@ resolved.
|
|
|
119
133
|
try {
|
|
120
134
|
await client.publish("t1", "default", "events", payload);
|
|
121
135
|
} catch (err) {
|
|
122
|
-
if (err instanceof
|
|
123
|
-
else if (err
|
|
136
|
+
if (err instanceof OutcomeUnknownError) reconcile(); // it may have been written
|
|
137
|
+
else if (err.retryable) retry(); // nothing was written
|
|
138
|
+
else if (err instanceof AuthError) giveUp(); // no amount of retrying grants a permission
|
|
124
139
|
}
|
|
125
140
|
```
|
|
126
141
|
|
|
127
|
-
`FelixError` is the base; `ConnectionError`, `
|
|
142
|
+
`FelixError` is the base; `ConnectionError`, `ShardUnavailableError`,
|
|
143
|
+
`OverloadedError`, `OutcomeUnknownError`, `AuthError`, `NotFoundError`,
|
|
128
144
|
`CursorError` and `InvalidArgumentError` are the branches, mirroring the Python
|
|
129
|
-
binding's exceptions.
|
|
130
|
-
|
|
131
|
-
and
|
|
145
|
+
binding's exceptions. A broker that sends error codes picks the class, and the
|
|
146
|
+
error carries its `code`, `retry` class and `detail`, as the Python exceptions
|
|
147
|
+
do; from an older broker they are `undefined` and the class comes from the
|
|
148
|
+
message. Each also carries a stable `kind` (`FELIX_AUTH`, …) for code that
|
|
149
|
+
would rather switch than test `instanceof`. Never match on the message — it is prose, and it will be
|
|
150
|
+
reworded.
|
|
132
151
|
|
|
133
152
|
## What is not wrapped
|
|
134
153
|
|
package/errors.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The error classes, and turning a native error into one of them.
|
|
2
|
+
//
|
|
3
|
+
// The native layer cannot set properties on its errors: napi errors carry only
|
|
4
|
+
// a status from napi's own fixed enum and a message. So it prefixes the message
|
|
5
|
+
// with a kind, and with the broker's code, retry class and detail when the
|
|
6
|
+
// broker sent them:
|
|
7
|
+
//
|
|
8
|
+
// FELIX_AUTH: <text>
|
|
9
|
+
// FELIX_SHARD_UNAVAILABLE {"code":"shard_unavailable","retry":"retry"}\n<text>
|
|
10
|
+
//
|
|
11
|
+
// and this file lifts that onto a typed error. Compact JSON never holds a raw
|
|
12
|
+
// newline, which is what makes the second form safe to split. Both halves ship
|
|
13
|
+
// as one package, so the prefix is an internal detail rather than something a
|
|
14
|
+
// caller parses. Kept apart from `index.js` so it can be tested without the
|
|
15
|
+
// addon.
|
|
16
|
+
//
|
|
17
|
+
// The class hierarchy mirrors the Python binding's exceptions, because the
|
|
18
|
+
// distinction is the same one in both languages: what an application can
|
|
19
|
+
// *decide* from a failure.
|
|
20
|
+
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
/** Retry classes under which sending the request again cannot duplicate it. */
|
|
24
|
+
const SAFE_TO_RETRY = new Set(["retry", "retry_after", "redirect"]);
|
|
25
|
+
|
|
26
|
+
/** Base class for every error this client raises. */
|
|
27
|
+
class FelixError extends Error {
|
|
28
|
+
constructor(kind, message, meta = null) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = new.target.name;
|
|
31
|
+
/**
|
|
32
|
+
* Which kind of failure this is (`FELIX_AUTH`, `FELIX_SHARD_UNAVAILABLE`,
|
|
33
|
+
* ...), the same thing the class says. Always set.
|
|
34
|
+
*/
|
|
35
|
+
this.kind = kind;
|
|
36
|
+
/**
|
|
37
|
+
* The broker's error code, such as `shard_unavailable`. `undefined` when
|
|
38
|
+
* the broker sent none: an older broker, or a failure in the client.
|
|
39
|
+
*/
|
|
40
|
+
this.code = meta?.code;
|
|
41
|
+
/**
|
|
42
|
+
* What the broker says the caller may do: `retry`, `retry_after`,
|
|
43
|
+
* `redirect`, `outcome_unknown` or `fatal`. `undefined` without a code.
|
|
44
|
+
*/
|
|
45
|
+
this.retry = meta?.retry;
|
|
46
|
+
/** Extra facts, such as `reason` or `retry_after_ms`, or `undefined`. */
|
|
47
|
+
this.detail = meta?.detail;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether sending the same request again could succeed without applying
|
|
52
|
+
* it twice.
|
|
53
|
+
*
|
|
54
|
+
* The broker's retry class decides when it sent one. Otherwise the class
|
|
55
|
+
* does: only a connection failure, an unavailable shard and an overloaded
|
|
56
|
+
* broker say yes.
|
|
57
|
+
*/
|
|
58
|
+
get retryable() {
|
|
59
|
+
if (this.retry !== undefined) return SAFE_TO_RETRY.has(this.retry);
|
|
60
|
+
return this.constructor.retryableByDefault;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static get retryableByDefault() {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The broker could not be reached, the connection was lost mid-call, or the
|
|
70
|
+
* broker is shutting down.
|
|
71
|
+
*/
|
|
72
|
+
class ConnectionError extends FelixError {
|
|
73
|
+
static get retryableByDefault() {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The token was rejected, or lacks the permission this call needs. */
|
|
79
|
+
class AuthError extends FelixError {}
|
|
80
|
+
|
|
81
|
+
/** The tenant, namespace, stream or cache does not exist on the broker. */
|
|
82
|
+
class NotFoundError extends FelixError {}
|
|
83
|
+
|
|
84
|
+
/** The requested start offset is gone — retention discarded it. */
|
|
85
|
+
class CursorError extends FelixError {}
|
|
86
|
+
|
|
87
|
+
/** A bad argument to this client, rather than a failure of the call. */
|
|
88
|
+
class InvalidArgumentError extends FelixError {}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Nobody can serve the shard right now, typically while it moves. Nothing was
|
|
92
|
+
* applied.
|
|
93
|
+
*/
|
|
94
|
+
class ShardUnavailableError extends FelixError {
|
|
95
|
+
static get retryableByDefault() {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The broker is shedding load. Nothing was applied; retry after a pause. */
|
|
101
|
+
class OverloadedError extends FelixError {
|
|
102
|
+
static get retryableByDefault() {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The write may or may not have been applied. Only an idempotent request is
|
|
109
|
+
* safe to send again.
|
|
110
|
+
*/
|
|
111
|
+
class OutcomeUnknownError extends FelixError {}
|
|
112
|
+
|
|
113
|
+
/** A commit was refused before it was sent. Nothing was written. */
|
|
114
|
+
class CommitError extends FelixError {}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An op names a stream other than the commit's. A different stream is a
|
|
118
|
+
* different log, and a commit writes one. `index` is the op, `stream` what it
|
|
119
|
+
* named, `owner` the commit's stream.
|
|
120
|
+
*/
|
|
121
|
+
class NotOnOwningShardError extends CommitError {
|
|
122
|
+
constructor(kind, message, meta = null) {
|
|
123
|
+
super(kind, message, null);
|
|
124
|
+
this.index = meta?.index;
|
|
125
|
+
this.stream = meta?.stream;
|
|
126
|
+
this.owner = meta?.owner;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A commit carries exactly one event (publish or enqueue); this had `count`. */
|
|
131
|
+
class EventCountError extends CommitError {
|
|
132
|
+
constructor(kind, message, meta = null) {
|
|
133
|
+
super(kind, message, null);
|
|
134
|
+
this.count = meta?.count;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const CLASSES = new Map([
|
|
139
|
+
["FELIX_CONNECTION", ConnectionError],
|
|
140
|
+
["FELIX_AUTH", AuthError],
|
|
141
|
+
["FELIX_NOT_FOUND", NotFoundError],
|
|
142
|
+
["FELIX_CURSOR", CursorError],
|
|
143
|
+
["FELIX_INVALID", InvalidArgumentError],
|
|
144
|
+
["FELIX_SHARD_UNAVAILABLE", ShardUnavailableError],
|
|
145
|
+
["FELIX_OVERLOADED", OverloadedError],
|
|
146
|
+
["FELIX_OUTCOME_UNKNOWN", OutcomeUnknownError],
|
|
147
|
+
["FELIX_COMMIT", CommitError],
|
|
148
|
+
["FELIX_NOT_ON_OWNING_SHARD", NotOnOwningShardError],
|
|
149
|
+
["FELIX_EVENT_COUNT", EventCountError],
|
|
150
|
+
["FELIX_ERROR", FelixError],
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const PREFIX = /^(FELIX_[A-Z_]+)(?:: | (\{[^\n]*\})\n)/;
|
|
154
|
+
|
|
155
|
+
/** Lift a native error into a typed one, leaving anything else alone. */
|
|
156
|
+
function typed(err) {
|
|
157
|
+
const message = err && typeof err.message === "string" ? err.message : "";
|
|
158
|
+
const match = PREFIX.exec(message);
|
|
159
|
+
const Class = match && CLASSES.get(match[1]);
|
|
160
|
+
if (!Class) return err;
|
|
161
|
+
let meta = null;
|
|
162
|
+
if (match[2]) {
|
|
163
|
+
try {
|
|
164
|
+
meta = JSON.parse(match[2]);
|
|
165
|
+
} catch {
|
|
166
|
+
return err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const out = new Class(match[1], message.slice(match[0].length), meta);
|
|
170
|
+
// Keep the native stack: it names the call that failed.
|
|
171
|
+
if (err.stack) out.stack = err.stack.replace(message, out.message);
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
FelixError,
|
|
177
|
+
ConnectionError,
|
|
178
|
+
AuthError,
|
|
179
|
+
NotFoundError,
|
|
180
|
+
CursorError,
|
|
181
|
+
InvalidArgumentError,
|
|
182
|
+
ShardUnavailableError,
|
|
183
|
+
OverloadedError,
|
|
184
|
+
OutcomeUnknownError,
|
|
185
|
+
CommitError,
|
|
186
|
+
NotOnOwningShardError,
|
|
187
|
+
EventCountError,
|
|
188
|
+
typed,
|
|
189
|
+
};
|
package/index.d.ts
CHANGED
|
@@ -13,11 +13,17 @@ export interface Event {
|
|
|
13
13
|
/**
|
|
14
14
|
* The record's log offset on a durable stream, absent on an ephemeral one.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Subscriber queues shed under the default policy rather than blocking the
|
|
17
|
+
* publisher, so a gap here is the signal that it happened:
|
|
18
|
+
* `offset - previous - 1n - skippedBefore` records were dropped.
|
|
19
19
|
*/
|
|
20
20
|
offset: bigint | null;
|
|
21
|
+
/**
|
|
22
|
+
* How many offsets just before `offset` hold no event. Non-zero only on the
|
|
23
|
+
* first event after a leader change, whose generation-start record took an
|
|
24
|
+
* offset; `0n` from a broker that predates it.
|
|
25
|
+
*/
|
|
26
|
+
skippedBefore: bigint;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** One record handed out by a consumer group. */
|
|
@@ -58,21 +64,39 @@ export interface CacheWatchItem {
|
|
|
58
64
|
* `start = laggedResumeFrom` is gapless.
|
|
59
65
|
*/
|
|
60
66
|
laggedResumeFrom: bigint | null;
|
|
67
|
+
/**
|
|
68
|
+
* Set when the watch's shard moved to another broker. The watch follows it
|
|
69
|
+
* there on its own and carries on where it left off.
|
|
70
|
+
*/
|
|
71
|
+
shardMoved: ShardMoved | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where a shard went when it moved to another broker. Cache watches and
|
|
76
|
+
* sharded subscriptions follow it on their own; this is a notice.
|
|
77
|
+
*/
|
|
78
|
+
export interface ShardMoved {
|
|
79
|
+
resumeFrom: bigint | null;
|
|
80
|
+
nodeId: string | null;
|
|
81
|
+
addr: string | null;
|
|
82
|
+
generation: bigint;
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
86
|
* An item from a sharded subscription.
|
|
65
87
|
*
|
|
66
|
-
* Exactly one of `event`, `lostError` and `
|
|
67
|
-
* which shard it concerns. A lost shard does not affect the
|
|
68
|
-
* delivering while that one is re-established, and it
|
|
69
|
-
* last offset so nothing is skipped.
|
|
88
|
+
* Exactly one of `event`, `lostError`, `recovered` and `shardMoved` is set, and
|
|
89
|
+
* `shard` says which shard it concerns. A lost shard does not affect the
|
|
90
|
+
* others: they keep delivering while that one is re-established, and it
|
|
91
|
+
* resumes from its own last offset so nothing is skipped. A moved shard is
|
|
92
|
+
* followed: its records carry on from the new owner, or a loss comes next.
|
|
70
93
|
*/
|
|
71
94
|
export interface ShardEvent {
|
|
72
95
|
shard: number;
|
|
73
96
|
event: Event | null;
|
|
74
97
|
lostError: string | null;
|
|
75
98
|
recovered: boolean | null;
|
|
99
|
+
shardMoved: ShardMoved | null;
|
|
76
100
|
}
|
|
77
101
|
|
|
78
102
|
/** How much the broker must have done before a publish resolves. */
|
|
@@ -84,19 +108,55 @@ export type StartPosition = "latest" | "earliest" | bigint;
|
|
|
84
108
|
/** Per-shard offsets, keyed by shard number. */
|
|
85
109
|
export type ShardPositions = Record<string, bigint>;
|
|
86
110
|
|
|
111
|
+
/**
|
|
112
|
+
* What the broker says a caller may do after an error. `retry`, `retry_after`
|
|
113
|
+
* and `redirect` mean nothing was applied; `outcome_unknown` means it may have
|
|
114
|
+
* been, so only an idempotent request is safe to send again.
|
|
115
|
+
*/
|
|
116
|
+
export type RetryClass = "retry" | "retry_after" | "redirect" | "outcome_unknown" | "fatal";
|
|
117
|
+
|
|
118
|
+
/** Extra facts the broker sent with an error. Every field is optional. */
|
|
119
|
+
export interface ErrorDetail {
|
|
120
|
+
/**
|
|
121
|
+
* For `shard_unavailable`: `not_assigned`, `owner_unavailable`, `not_ready`,
|
|
122
|
+
* `stale`, `fenced` or `moving`.
|
|
123
|
+
*/
|
|
124
|
+
reason?: string;
|
|
125
|
+
/**
|
|
126
|
+
* How long the broker suggests waiting: for `retry_after`, and as a hint with
|
|
127
|
+
* `retry` for a shard that is `moving`.
|
|
128
|
+
*/
|
|
129
|
+
retry_after_ms?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
87
132
|
/** Base class for every error this client raises. */
|
|
88
133
|
export declare class FelixError extends Error {
|
|
89
134
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
135
|
+
* Which kind of failure this is (`FELIX_AUTH`, `FELIX_SHARD_UNAVAILABLE`,
|
|
136
|
+
* ...), the same thing the class says. Always set.
|
|
137
|
+
*/
|
|
138
|
+
readonly kind: string;
|
|
139
|
+
/**
|
|
140
|
+
* The broker's error code, such as `shard_unavailable` or `quorum_timeout`.
|
|
141
|
+
* `undefined` when the broker predates error codes or the failure was local.
|
|
142
|
+
*/
|
|
143
|
+
readonly code: string | undefined;
|
|
144
|
+
/** The broker's retry class, or `undefined` when it sent no code. */
|
|
145
|
+
readonly retry: RetryClass | undefined;
|
|
146
|
+
/** Extra facts from the broker, or `undefined`. */
|
|
147
|
+
readonly detail: ErrorDetail | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* Whether sending the same request again could succeed without applying it
|
|
150
|
+
* twice. Decided by `retry` when the broker sent one, and otherwise by the
|
|
151
|
+
* class: `ConnectionError`, `ShardUnavailableError` and `OverloadedError`.
|
|
93
152
|
*/
|
|
94
|
-
readonly code: string;
|
|
95
|
-
/** Whether retrying could plausibly succeed. Only `ConnectionError` says yes. */
|
|
96
153
|
readonly retryable: boolean;
|
|
97
154
|
}
|
|
98
155
|
|
|
99
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* The broker could not be reached, the connection was lost mid-call, or the
|
|
158
|
+
* broker is shutting down (`draining`).
|
|
159
|
+
*/
|
|
100
160
|
export declare class ConnectionError extends FelixError {}
|
|
101
161
|
/** The token was rejected, or lacks the permission this call needs. */
|
|
102
162
|
export declare class AuthError extends FelixError {}
|
|
@@ -106,6 +166,73 @@ export declare class NotFoundError extends FelixError {}
|
|
|
106
166
|
export declare class CursorError extends FelixError {}
|
|
107
167
|
/** A bad argument to this client, rather than a failure of the call. */
|
|
108
168
|
export declare class InvalidArgumentError extends FelixError {}
|
|
169
|
+
/**
|
|
170
|
+
* Nobody can serve the shard right now, typically while it moves
|
|
171
|
+
* (`shard_unavailable`), or another broker owns it (`not_leader`). Nothing was
|
|
172
|
+
* applied; retrying is safe.
|
|
173
|
+
*/
|
|
174
|
+
export declare class ShardUnavailableError extends FelixError {}
|
|
175
|
+
/** The broker is shedding load (`overloaded`). Nothing was applied. */
|
|
176
|
+
export declare class OverloadedError extends FelixError {}
|
|
177
|
+
/**
|
|
178
|
+
* The write may or may not have been applied: `quorum_timeout`,
|
|
179
|
+
* `leadership_lost`, `unacknowledged`, or any error whose retry class is
|
|
180
|
+
* `outcome_unknown`. Only an idempotent request is safe to send again.
|
|
181
|
+
*/
|
|
182
|
+
export declare class OutcomeUnknownError extends FelixError {}
|
|
183
|
+
|
|
184
|
+
/** A commit was refused before it was sent. Nothing was written. */
|
|
185
|
+
export declare class CommitError extends FelixError {}
|
|
186
|
+
/**
|
|
187
|
+
* An op names a stream other than the commit's: a different stream is a
|
|
188
|
+
* different log, and a commit writes one.
|
|
189
|
+
*/
|
|
190
|
+
export declare class NotOnOwningShardError extends CommitError {
|
|
191
|
+
/** Which op, counting from 0. */
|
|
192
|
+
readonly index: number;
|
|
193
|
+
/** The stream that op named. */
|
|
194
|
+
readonly stream: string;
|
|
195
|
+
/** The commit's own stream. */
|
|
196
|
+
readonly owner: string;
|
|
197
|
+
}
|
|
198
|
+
/** A commit carries exactly one event (publish or enqueue). */
|
|
199
|
+
export declare class EventCountError extends CommitError {
|
|
200
|
+
readonly count: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One part of an atomic commit. Exactly one op per commit is an event
|
|
205
|
+
* (`publish` or `enqueue`); every op names the same stream.
|
|
206
|
+
*/
|
|
207
|
+
export type CommitOp =
|
|
208
|
+
| { op: "publish"; stream: string; payload: Buffer }
|
|
209
|
+
| { op: "enqueue"; queue: string; payload: Buffer }
|
|
210
|
+
| { op: "put"; stream: string; key: string; value: Buffer }
|
|
211
|
+
| { op: "delete"; stream: string; key: string };
|
|
212
|
+
|
|
213
|
+
/** Builders for `CommitOp`s; each returns the plain object. */
|
|
214
|
+
export declare const CommitOp: {
|
|
215
|
+
publish(stream: string, payload: Buffer | string): CommitOp;
|
|
216
|
+
enqueue(queue: string, payload: Buffer | string): CommitOp;
|
|
217
|
+
put(stream: string, key: string, value: Buffer | string): CommitOp;
|
|
218
|
+
delete(stream: string, key: string): CommitOp;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** A commit the broker made durable. */
|
|
222
|
+
export interface CommitReceipt {
|
|
223
|
+
/** Where the event is read, and the version of every key the commit wrote. */
|
|
224
|
+
offset: bigint;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A key in a stream shard's state. */
|
|
228
|
+
export interface StateValue {
|
|
229
|
+
/** Absent when the key was never written or was deleted. */
|
|
230
|
+
value: Buffer | null;
|
|
231
|
+
/** Offset of the commit that wrote `value`. */
|
|
232
|
+
version: bigint | null;
|
|
233
|
+
/** Offset of the last commit the answer reflects. */
|
|
234
|
+
asOf: bigint | null;
|
|
235
|
+
}
|
|
109
236
|
|
|
110
237
|
/** A live subscription. Read it with `nextEvent`, and `close` it when done. */
|
|
111
238
|
export declare class SubscriptionHandle {
|
|
@@ -180,6 +307,10 @@ export declare class Client {
|
|
|
180
307
|
* TLS is not optional — QUIC has no unencrypted mode. Pass `caFile` to trust
|
|
181
308
|
* a specific CA (what a self-signed development broker needs), or omit it to
|
|
182
309
|
* use the operating system's trust store.
|
|
310
|
+
*
|
|
311
|
+
* `offerAlpn` offers the `felix/1` ALPN. A broker with
|
|
312
|
+
* `FELIX_TLS_REQUIRE_ALPN=true` serves only clients that do; a broker older
|
|
313
|
+
* than ALPN support refuses them, which is why it is off by default.
|
|
183
314
|
*/
|
|
184
315
|
static connect(
|
|
185
316
|
addrs: string | string[],
|
|
@@ -187,6 +318,7 @@ export declare class Client {
|
|
|
187
318
|
token: string,
|
|
188
319
|
serverName?: string,
|
|
189
320
|
caFile?: string,
|
|
321
|
+
offerAlpn?: boolean,
|
|
190
322
|
): Promise<Client>;
|
|
191
323
|
|
|
192
324
|
/**
|
|
@@ -204,6 +336,10 @@ export declare class Client {
|
|
|
204
336
|
* certain to land, and may land twice. That is a delivery guarantee you
|
|
205
337
|
* choose, never one this client assumes — and it cannot be combined with
|
|
206
338
|
* `key`, which the re-send path does not yet carry.
|
|
339
|
+
*
|
|
340
|
+
* Resolves to the record's log offset, or `null` when the broker
|
|
341
|
+
* acknowledged before writing it, the stream has no log, the broker is too
|
|
342
|
+
* old to say, or `ack` is `"none"`.
|
|
207
343
|
*/
|
|
208
344
|
publish(
|
|
209
345
|
tenantId: string,
|
|
@@ -213,7 +349,7 @@ export declare class Client {
|
|
|
213
349
|
key?: Buffer,
|
|
214
350
|
ack?: AckMode,
|
|
215
351
|
atLeastOnce?: boolean,
|
|
216
|
-
): Promise<
|
|
352
|
+
): Promise<bigint | null>;
|
|
217
353
|
|
|
218
354
|
/**
|
|
219
355
|
* Subscribe to a stream.
|
|
@@ -293,6 +429,28 @@ export declare class Client {
|
|
|
293
429
|
key: string,
|
|
294
430
|
): Promise<Buffer | null>;
|
|
295
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Commit `ops` as one record on the shard `entityKey` routes to. Every
|
|
434
|
+
* reader sees all of it or none of it. Rejects with `EventCountError` or
|
|
435
|
+
* `NotOnOwningShardError` before anything is sent when `ops` does not carry
|
|
436
|
+
* exactly one event or names two streams. See docs/atomic-commit.md.
|
|
437
|
+
*/
|
|
438
|
+
commit(
|
|
439
|
+
tenantId: string,
|
|
440
|
+
namespace: string,
|
|
441
|
+
entityKey: Buffer,
|
|
442
|
+
ops: CommitOp[],
|
|
443
|
+
): Promise<CommitReceipt>;
|
|
444
|
+
|
|
445
|
+
/** `key` in the state of `stream`'s shard that `entityKey` routes to. */
|
|
446
|
+
stateGet(
|
|
447
|
+
tenantId: string,
|
|
448
|
+
namespace: string,
|
|
449
|
+
stream: string,
|
|
450
|
+
entityKey: Buffer,
|
|
451
|
+
key: string,
|
|
452
|
+
): Promise<StateValue>;
|
|
453
|
+
|
|
296
454
|
/** Add to a counter and return its new value. `delta` may be negative. */
|
|
297
455
|
counterAdd(
|
|
298
456
|
tenantId: string,
|
package/index.js
CHANGED
|
@@ -1,91 +1,15 @@
|
|
|
1
1
|
// The package's JavaScript half: load the addon, and give its errors an
|
|
2
|
-
// identity a caller can branch on.
|
|
3
|
-
//
|
|
4
|
-
// The native layer cannot set `err.code` itself — napi puts an error's status
|
|
5
|
-
// there and `#[napi]` requires that status to be napi's own fixed enum. So it
|
|
6
|
-
// prefixes the message with a Felix code and this file lifts it onto a typed
|
|
7
|
-
// error. Both halves ship as one package, so that prefix is an internal detail
|
|
8
|
-
// rather than something a caller parses.
|
|
9
|
-
//
|
|
10
|
-
// The class hierarchy mirrors the Python binding's exceptions, because the
|
|
11
|
-
// distinction is the same one in both languages: what an application can
|
|
12
|
-
// *decide* from a failure. A connection failure is worth another attempt
|
|
13
|
-
// against another broker; an authorization failure, a missing stream, or a
|
|
14
|
-
// discarded offset will fail the same way every time.
|
|
2
|
+
// identity a caller can branch on. The error classes and how a native error
|
|
3
|
+
// becomes one are in `errors.js`.
|
|
15
4
|
|
|
16
5
|
"use strict";
|
|
17
6
|
|
|
18
7
|
const { existsSync } = require("node:fs");
|
|
19
8
|
const { join } = require("node:path");
|
|
20
9
|
|
|
21
|
-
|
|
22
|
-
class FelixError extends Error {
|
|
23
|
-
constructor(code, message) {
|
|
24
|
-
super(message);
|
|
25
|
-
this.name = new.target.name;
|
|
26
|
-
/**
|
|
27
|
-
* A stable identifier for *why* this failed. Branch on this, or on the
|
|
28
|
-
* class, rather than on the message — the message is prose and will be
|
|
29
|
-
* reworded.
|
|
30
|
-
*/
|
|
31
|
-
this.code = code;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Whether retrying could plausibly succeed.
|
|
36
|
-
*
|
|
37
|
-
* Only `ConnectionError` says yes. An authorization failure, a missing
|
|
38
|
-
* stream, or a discarded offset fails the same way every time — retrying
|
|
39
|
-
* them burns a budget on a call that cannot succeed.
|
|
40
|
-
*/
|
|
41
|
-
get retryable() {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The broker could not be reached, or the connection was lost mid-call. */
|
|
47
|
-
class ConnectionError extends FelixError {
|
|
48
|
-
get retryable() {
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The token was rejected, or lacks the permission this call needs. */
|
|
54
|
-
class AuthError extends FelixError {}
|
|
55
|
-
|
|
56
|
-
/** The tenant, namespace, stream or cache does not exist on the broker. */
|
|
57
|
-
class NotFoundError extends FelixError {}
|
|
58
|
-
|
|
59
|
-
/** The requested start offset is gone — retention discarded it. */
|
|
60
|
-
class CursorError extends FelixError {}
|
|
10
|
+
const errors = require("./errors.js");
|
|
61
11
|
|
|
62
|
-
|
|
63
|
-
class InvalidArgumentError extends FelixError {}
|
|
64
|
-
|
|
65
|
-
const CLASSES = new Map([
|
|
66
|
-
["FELIX_CONNECTION", ConnectionError],
|
|
67
|
-
["FELIX_AUTH", AuthError],
|
|
68
|
-
["FELIX_NOT_FOUND", NotFoundError],
|
|
69
|
-
["FELIX_CURSOR", CursorError],
|
|
70
|
-
["FELIX_INVALID", InvalidArgumentError],
|
|
71
|
-
["FELIX_ERROR", FelixError],
|
|
72
|
-
]);
|
|
73
|
-
|
|
74
|
-
/** Lift a native error into a typed one, leaving anything else alone. */
|
|
75
|
-
function typed(err) {
|
|
76
|
-
const message = err && typeof err.message === "string" ? err.message : "";
|
|
77
|
-
const at = message.indexOf(": ");
|
|
78
|
-
if (at > 0) {
|
|
79
|
-
const Class = CLASSES.get(message.slice(0, at));
|
|
80
|
-
if (Class) {
|
|
81
|
-
const out = new Class(message.slice(0, at), message.slice(at + 2));
|
|
82
|
-
// Keep the native stack: it names the call that failed.
|
|
83
|
-
if (err.stack) out.stack = err.stack.replace(message, out.message);
|
|
84
|
-
return out;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return err;
|
|
88
|
-
}
|
|
12
|
+
const { typed } = errors;
|
|
89
13
|
|
|
90
14
|
/**
|
|
91
15
|
* What this machine's binary is called, in napi's naming.
|
|
@@ -119,7 +43,7 @@ function loadAddon() {
|
|
|
119
43
|
// The repository shares one target directory across every crate, this one
|
|
120
44
|
// included (`.cargo/config.toml`), so a development build lands at the root
|
|
121
45
|
// rather than beside this file.
|
|
122
|
-
const roots = [join(__dirname, "target"), join(__dirname, "..", "..", "target")];
|
|
46
|
+
const roots = [join(__dirname, "target"), join(__dirname, "..", "..", "..", "target")];
|
|
123
47
|
const names = [
|
|
124
48
|
"libfelix_typescript.dylib",
|
|
125
49
|
"libfelix_typescript.so",
|
|
@@ -167,7 +91,7 @@ function loadAddon() {
|
|
|
167
91
|
`felix-client: no native addon for ${tag}. Either this platform has no ` +
|
|
168
92
|
`published binary, or the optional dependency ${pkg} did not install. ` +
|
|
169
93
|
`From a checkout, build it with \`napi build --release\` or ` +
|
|
170
|
-
`\`cargo build --release\` in crates/felix-typescript.`,
|
|
94
|
+
`\`cargo build --release\` in crates/sdk/felix-typescript.`,
|
|
171
95
|
);
|
|
172
96
|
}
|
|
173
97
|
|
|
@@ -227,11 +151,22 @@ function wrap(value) {
|
|
|
227
151
|
});
|
|
228
152
|
}
|
|
229
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Builders for the ops `client.commit` takes. Each returns a plain object, so
|
|
156
|
+
* writing the object literal by hand works just as well.
|
|
157
|
+
*/
|
|
158
|
+
const CommitOp = {
|
|
159
|
+
publish: (stream, payload) => ({ op: "publish", stream, payload: Buffer.from(payload) }),
|
|
160
|
+
enqueue: (queue, payload) => ({ op: "enqueue", queue, payload: Buffer.from(payload) }),
|
|
161
|
+
put: (stream, key, value) => ({ op: "put", stream, key, value: Buffer.from(value) }),
|
|
162
|
+
delete: (stream, key) => ({ op: "delete", stream, key }),
|
|
163
|
+
};
|
|
164
|
+
|
|
230
165
|
/** The public `Client`: a façade over the native one that types its errors. */
|
|
231
166
|
const Client = {
|
|
232
|
-
async connect(addrs, tenantId, token, serverName, caFile) {
|
|
167
|
+
async connect(addrs, tenantId, token, serverName, caFile, offerAlpn) {
|
|
233
168
|
try {
|
|
234
|
-
const client = await native.Client.connect(addrs, tenantId, token, serverName, caFile);
|
|
169
|
+
const client = await native.Client.connect(addrs, tenantId, token, serverName, caFile, offerAlpn);
|
|
235
170
|
return wrap(client);
|
|
236
171
|
} catch (err) {
|
|
237
172
|
throw typed(err);
|
|
@@ -241,12 +176,19 @@ const Client = {
|
|
|
241
176
|
|
|
242
177
|
module.exports = {
|
|
243
178
|
Client,
|
|
244
|
-
FelixError,
|
|
245
|
-
ConnectionError,
|
|
246
|
-
AuthError,
|
|
247
|
-
NotFoundError,
|
|
248
|
-
CursorError,
|
|
249
|
-
InvalidArgumentError,
|
|
179
|
+
FelixError: errors.FelixError,
|
|
180
|
+
ConnectionError: errors.ConnectionError,
|
|
181
|
+
AuthError: errors.AuthError,
|
|
182
|
+
NotFoundError: errors.NotFoundError,
|
|
183
|
+
CursorError: errors.CursorError,
|
|
184
|
+
InvalidArgumentError: errors.InvalidArgumentError,
|
|
185
|
+
ShardUnavailableError: errors.ShardUnavailableError,
|
|
186
|
+
OverloadedError: errors.OverloadedError,
|
|
187
|
+
OutcomeUnknownError: errors.OutcomeUnknownError,
|
|
188
|
+
CommitError: errors.CommitError,
|
|
189
|
+
NotOnOwningShardError: errors.NotOnOwningShardError,
|
|
190
|
+
EventCountError: errors.EventCountError,
|
|
191
|
+
CommitOp,
|
|
250
192
|
/** The unwrapped addon, for anyone who wants it. Errors are untyped there. */
|
|
251
193
|
native,
|
|
252
194
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "felix-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0-preview",
|
|
4
4
|
"description": "Node.js/TypeScript bindings for the Felix client, over the Rust client rather than a reimplementation of it",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"felix",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
20
|
"url": "https://github.com/gabloe/felix.git",
|
|
21
|
-
"directory": "crates/felix-typescript"
|
|
21
|
+
"directory": "crates/sdk/felix-typescript"
|
|
22
22
|
},
|
|
23
23
|
"bugs": {
|
|
24
24
|
"url": "https://github.com/gabloe/felix/issues"
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"types": "index.d.ts",
|
|
28
28
|
"files": [
|
|
29
29
|
"index.js",
|
|
30
|
+
"errors.js",
|
|
30
31
|
"index.d.ts",
|
|
31
32
|
"README.md",
|
|
32
33
|
"LICENSE"
|
|
@@ -50,13 +51,13 @@
|
|
|
50
51
|
"scripts": {
|
|
51
52
|
"build": "napi build --platform --release",
|
|
52
53
|
"build:debug": "napi build --platform",
|
|
53
|
-
"test": "node --test test/conformance.test.mjs"
|
|
54
|
+
"test": "node --test test/errors.test.mjs test/conformance.test.mjs"
|
|
54
55
|
},
|
|
55
56
|
"optionalDependencies": {
|
|
56
|
-
"felix-client-darwin-arm64": "0.
|
|
57
|
-
"felix-client-darwin-x64": "0.
|
|
58
|
-
"felix-client-linux-arm64-gnu": "0.
|
|
59
|
-
"felix-client-linux-x64-gnu": "0.
|
|
60
|
-
"felix-client-win32-x64-msvc": "0.
|
|
57
|
+
"felix-client-darwin-arm64": "0.6.0-preview",
|
|
58
|
+
"felix-client-darwin-x64": "0.6.0-preview",
|
|
59
|
+
"felix-client-linux-arm64-gnu": "0.6.0-preview",
|
|
60
|
+
"felix-client-linux-x64-gnu": "0.6.0-preview",
|
|
61
|
+
"felix-client-win32-x64-msvc": "0.6.0-preview"
|
|
61
62
|
}
|
|
62
63
|
}
|