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.
Files changed (5) hide show
  1. package/README.md +29 -10
  2. package/errors.js +189 -0
  3. package/index.d.ts +172 -14
  4. package/index.js +32 -90
  5. 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 exactly a drop —
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 ConnectionError) retry(); // err.retryable === true
123
- else if (err instanceof AuthError) giveUp(); // no amount of retrying grants a permission
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`, `AuthError`, `NotFoundError`,
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. Each carries a stable `code` as well, for code that would
130
- rather switch than test `instanceof`. Never match on the message — it is prose,
131
- and it will be reworded.
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
- * A jump in these is exactly a drop: subscriber queues shed under the
17
- * default policy rather than blocking the publisher, so a gap here is the
18
- * signal that it happened.
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 `recovered` is set, and `shard` says
67
- * which shard it concerns. A lost shard does not affect the others: they keep
68
- * delivering while that one is re-established, and it resumes from its own
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
- * A stable identifier for *why* this failed. Branch on this, or on the
91
- * class, rather than on the message — the message is prose and will be
92
- * reworded.
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
- /** The broker could not be reached, or the connection was lost mid-call. */
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<void>;
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
- /** Base class for every error this client raises. */
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
- /** A bad argument to this client, rather than a failure of the call. */
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.5.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.5.0",
57
- "felix-client-darwin-x64": "0.5.0",
58
- "felix-client-linux-arm64-gnu": "0.5.0",
59
- "felix-client-linux-x64-gnu": "0.5.0",
60
- "felix-client-win32-x64-msvc": "0.5.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
  }