felix-client 0.5.0 → 0.6.0-preview.2

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 +31 -10
  2. package/errors.js +189 -0
  3. package/index.d.ts +186 -14
  4. package/index.js +32 -90
  5. package/package.json +12 -11
package/README.md CHANGED
@@ -10,6 +10,19 @@ 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 2.28 or newer, so
20
+ Debian bookworm, RHEL 8 and Amazon Linux 2023), macOS (Intel and Apple silicon)
21
+ and Windows x86-64 are covered. Alpine and other musl systems are not. Node 18
22
+ or newer.
23
+
24
+ Full documentation: https://docs.getfelix.dev/clients/typescript/
25
+
13
26
  ## One surface, and it is asynchronous
14
27
 
15
28
  Python offers two surfaces because its sync one is the older idiom. Node has no
@@ -40,14 +53,15 @@ Everything the Python binding wraps, with one exception noted below.
40
53
 
41
54
  | | |
42
55
  |---|---|
43
- | Publish | `publish(tenant, ns, stream, payload, key?, ack?, atLeastOnce?)` |
56
+ | Publish | `publish(tenant, ns, stream, payload, key?, ack?, atLeastOnce?)` → the record's offset, or `null` when the broker acked before writing it |
44
57
  | Subscribe | `subscribe(...)` → `nextEvent()`, `close()`, `closed` |
45
58
  | Sharded subscribe | `subscribeSharded(..., start?, resume?)` → `nextEvent()`, `positions()`, `shards` |
46
59
  | Stream shape | `streamShards(...)`, `endpoints()` |
47
60
  | Cache | `cachePut` (with TTL), `cacheGet`, `cacheDelete` |
48
61
  | Counters | `counterAdd`, `counterGet` |
49
62
  | Cache watches | `watchCache(..., key?, prefix?, start?, retained?)` → `recv()`, `retainedCount` |
50
- | Consumer groups | `groupPoll`, `groupAck`, `groupNack`, `groupDeadLetters`, `groupDiscard`, `groupRedrive` |
63
+ | Consumer groups | `groupPoll`, `groupAck`, `groupNack`, `groupDeadLetters`, `groupDiscard`, `groupRedrive` (each follows the broker's redirect to the shard's leader) |
64
+ | 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
65
 
52
66
  Every handle has an idempotent `close()` and implements `Symbol.asyncDispose`,
53
67
  so on Node 24 and newer a `throw` releases it on the way out:
@@ -76,9 +90,11 @@ shard.
76
90
 
77
91
  Subscriber queues shed under the default policy rather than blocking the
78
92
  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 —
93
+ delivered event carries its log offset, and a jump in them is a drop —
80
94
  which is why `event.offset` is worth reading even when you do not resume from
81
- it.
95
+ it. The one gap that is not a drop is a new leader's generation-start
96
+ record, and the event after it says so: `offset - previous - 1n - skippedBefore`
97
+ records were dropped.
82
98
 
83
99
  ### A sharded subscription surfaces shard trouble rather than hiding it
84
100
 
@@ -119,16 +135,21 @@ resolved.
119
135
  try {
120
136
  await client.publish("t1", "default", "events", payload);
121
137
  } 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
138
+ if (err instanceof OutcomeUnknownError) reconcile(); // it may have been written
139
+ else if (err.retryable) retry(); // nothing was written
140
+ else if (err instanceof AuthError) giveUp(); // no amount of retrying grants a permission
124
141
  }
125
142
  ```
126
143
 
127
- `FelixError` is the base; `ConnectionError`, `AuthError`, `NotFoundError`,
144
+ `FelixError` is the base; `ConnectionError`, `ShardUnavailableError`,
145
+ `OverloadedError`, `OutcomeUnknownError`, `AuthError`, `NotFoundError`,
128
146
  `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.
147
+ binding's exceptions. A broker that sends error codes picks the class, and the
148
+ error carries its `code`, `retry` class and `detail`, as the Python exceptions
149
+ do; from an older broker they are `undefined` and the class comes from the
150
+ message. Each also carries a stable `kind` (`FELIX_AUTH`, …) for code that
151
+ would rather switch than test `instanceof`. Never match on the message — it is prose, and it will be
152
+ reworded.
132
153
 
133
154
  ## What is not wrapped
134
155
 
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. */
@@ -31,6 +37,12 @@ export interface GroupRecord {
31
37
  * treat a retry differently. `0` means the broker did not report it.
32
38
  */
33
39
  attempts: number;
40
+ /**
41
+ * How many offsets directly below this one were settled without being
42
+ * delivered (generation starts, records retention removed). A hole with this
43
+ * count will not fill. `0n` from a broker that predates it.
44
+ */
45
+ skippedBefore: bigint;
34
46
  }
35
47
 
36
48
  /** One change observed by a cache watch. */
@@ -58,21 +70,39 @@ export interface CacheWatchItem {
58
70
  * `start = laggedResumeFrom` is gapless.
59
71
  */
60
72
  laggedResumeFrom: bigint | null;
73
+ /**
74
+ * Set when the watch's shard moved to another broker. The watch follows it
75
+ * there on its own and carries on where it left off.
76
+ */
77
+ shardMoved: ShardMoved | null;
78
+ }
79
+
80
+ /**
81
+ * Where a shard went when it moved to another broker. Cache watches and
82
+ * sharded subscriptions follow it on their own; this is a notice.
83
+ */
84
+ export interface ShardMoved {
85
+ resumeFrom: bigint | null;
86
+ nodeId: string | null;
87
+ addr: string | null;
88
+ generation: bigint;
61
89
  }
62
90
 
63
91
  /**
64
92
  * An item from a sharded subscription.
65
93
  *
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.
94
+ * Exactly one of `event`, `lostError`, `recovered` and `shardMoved` is set, and
95
+ * `shard` says which shard it concerns. A lost shard does not affect the
96
+ * others: they keep delivering while that one is re-established, and it
97
+ * resumes from its own last offset so nothing is skipped. A moved shard is
98
+ * followed: its records carry on from the new owner, or a loss comes next.
70
99
  */
71
100
  export interface ShardEvent {
72
101
  shard: number;
73
102
  event: Event | null;
74
103
  lostError: string | null;
75
104
  recovered: boolean | null;
105
+ shardMoved: ShardMoved | null;
76
106
  }
77
107
 
78
108
  /** How much the broker must have done before a publish resolves. */
@@ -84,19 +114,55 @@ export type StartPosition = "latest" | "earliest" | bigint;
84
114
  /** Per-shard offsets, keyed by shard number. */
85
115
  export type ShardPositions = Record<string, bigint>;
86
116
 
117
+ /**
118
+ * What the broker says a caller may do after an error. `retry`, `retry_after`
119
+ * and `redirect` mean nothing was applied; `outcome_unknown` means it may have
120
+ * been, so only an idempotent request is safe to send again.
121
+ */
122
+ export type RetryClass = "retry" | "retry_after" | "redirect" | "outcome_unknown" | "fatal";
123
+
124
+ /** Extra facts the broker sent with an error. Every field is optional. */
125
+ export interface ErrorDetail {
126
+ /**
127
+ * For `shard_unavailable`: `not_assigned`, `owner_unavailable`, `not_ready`,
128
+ * `stale`, `fenced` or `moving`.
129
+ */
130
+ reason?: string;
131
+ /**
132
+ * How long the broker suggests waiting: for `retry_after`, and as a hint with
133
+ * `retry` for a shard that is `moving`.
134
+ */
135
+ retry_after_ms?: number;
136
+ }
137
+
87
138
  /** Base class for every error this client raises. */
88
139
  export declare class FelixError extends Error {
89
140
  /**
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.
141
+ * Which kind of failure this is (`FELIX_AUTH`, `FELIX_SHARD_UNAVAILABLE`,
142
+ * ...), the same thing the class says. Always set.
143
+ */
144
+ readonly kind: string;
145
+ /**
146
+ * The broker's error code, such as `shard_unavailable` or `quorum_timeout`.
147
+ * `undefined` when the broker predates error codes or the failure was local.
148
+ */
149
+ readonly code: string | undefined;
150
+ /** The broker's retry class, or `undefined` when it sent no code. */
151
+ readonly retry: RetryClass | undefined;
152
+ /** Extra facts from the broker, or `undefined`. */
153
+ readonly detail: ErrorDetail | undefined;
154
+ /**
155
+ * Whether sending the same request again could succeed without applying it
156
+ * twice. Decided by `retry` when the broker sent one, and otherwise by the
157
+ * class: `ConnectionError`, `ShardUnavailableError` and `OverloadedError`.
93
158
  */
94
- readonly code: string;
95
- /** Whether retrying could plausibly succeed. Only `ConnectionError` says yes. */
96
159
  readonly retryable: boolean;
97
160
  }
98
161
 
99
- /** The broker could not be reached, or the connection was lost mid-call. */
162
+ /**
163
+ * The broker could not be reached, the connection was lost mid-call, or the
164
+ * broker is shutting down (`draining`).
165
+ */
100
166
  export declare class ConnectionError extends FelixError {}
101
167
  /** The token was rejected, or lacks the permission this call needs. */
102
168
  export declare class AuthError extends FelixError {}
@@ -106,6 +172,73 @@ export declare class NotFoundError extends FelixError {}
106
172
  export declare class CursorError extends FelixError {}
107
173
  /** A bad argument to this client, rather than a failure of the call. */
108
174
  export declare class InvalidArgumentError extends FelixError {}
175
+ /**
176
+ * Nobody can serve the shard right now, typically while it moves
177
+ * (`shard_unavailable`), or another broker owns it (`not_leader`). Nothing was
178
+ * applied; retrying is safe.
179
+ */
180
+ export declare class ShardUnavailableError extends FelixError {}
181
+ /** The broker is shedding load (`overloaded`). Nothing was applied. */
182
+ export declare class OverloadedError extends FelixError {}
183
+ /**
184
+ * The write may or may not have been applied: `quorum_timeout`,
185
+ * `leadership_lost`, `unacknowledged`, or any error whose retry class is
186
+ * `outcome_unknown`. Only an idempotent request is safe to send again.
187
+ */
188
+ export declare class OutcomeUnknownError extends FelixError {}
189
+
190
+ /** A commit was refused before it was sent. Nothing was written. */
191
+ export declare class CommitError extends FelixError {}
192
+ /**
193
+ * An op names a stream other than the commit's: a different stream is a
194
+ * different log, and a commit writes one.
195
+ */
196
+ export declare class NotOnOwningShardError extends CommitError {
197
+ /** Which op, counting from 0. */
198
+ readonly index: number;
199
+ /** The stream that op named. */
200
+ readonly stream: string;
201
+ /** The commit's own stream. */
202
+ readonly owner: string;
203
+ }
204
+ /** A commit carries exactly one event (publish or enqueue). */
205
+ export declare class EventCountError extends CommitError {
206
+ readonly count: number;
207
+ }
208
+
209
+ /**
210
+ * One part of an atomic commit. Exactly one op per commit is an event
211
+ * (`publish` or `enqueue`); every op names the same stream.
212
+ */
213
+ export type CommitOp =
214
+ | { op: "publish"; stream: string; payload: Buffer }
215
+ | { op: "enqueue"; queue: string; payload: Buffer }
216
+ | { op: "put"; stream: string; key: string; value: Buffer }
217
+ | { op: "delete"; stream: string; key: string };
218
+
219
+ /** Builders for `CommitOp`s; each returns the plain object. */
220
+ export declare const CommitOp: {
221
+ publish(stream: string, payload: Buffer | string): CommitOp;
222
+ enqueue(queue: string, payload: Buffer | string): CommitOp;
223
+ put(stream: string, key: string, value: Buffer | string): CommitOp;
224
+ delete(stream: string, key: string): CommitOp;
225
+ };
226
+
227
+ /** A commit the broker made durable. */
228
+ export interface CommitReceipt {
229
+ /** Where the event is read, and the version of every key the commit wrote. */
230
+ offset: bigint;
231
+ }
232
+
233
+ /** A key in a stream shard's state. */
234
+ export interface StateValue {
235
+ /** Absent when the key was never written or was deleted. */
236
+ value: Buffer | null;
237
+ /** Offset of the commit that wrote `value`. */
238
+ version: bigint | null;
239
+ /** Offset of the last commit the answer reflects. */
240
+ asOf: bigint | null;
241
+ }
109
242
 
110
243
  /** A live subscription. Read it with `nextEvent`, and `close` it when done. */
111
244
  export declare class SubscriptionHandle {
@@ -180,6 +313,10 @@ export declare class Client {
180
313
  * TLS is not optional — QUIC has no unencrypted mode. Pass `caFile` to trust
181
314
  * a specific CA (what a self-signed development broker needs), or omit it to
182
315
  * use the operating system's trust store.
316
+ *
317
+ * `offerAlpn` offers the `felix/1` ALPN. A broker with
318
+ * `FELIX_TLS_REQUIRE_ALPN=true` serves only clients that do; a broker older
319
+ * than ALPN support refuses them, which is why it is off by default.
183
320
  */
184
321
  static connect(
185
322
  addrs: string | string[],
@@ -187,6 +324,7 @@ export declare class Client {
187
324
  token: string,
188
325
  serverName?: string,
189
326
  caFile?: string,
327
+ offerAlpn?: boolean,
190
328
  ): Promise<Client>;
191
329
 
192
330
  /**
@@ -204,6 +342,10 @@ export declare class Client {
204
342
  * certain to land, and may land twice. That is a delivery guarantee you
205
343
  * choose, never one this client assumes — and it cannot be combined with
206
344
  * `key`, which the re-send path does not yet carry.
345
+ *
346
+ * Resolves to the record's log offset, or `null` when the broker
347
+ * acknowledged before writing it, the stream has no log, the broker is too
348
+ * old to say, or `ack` is `"none"`.
207
349
  */
208
350
  publish(
209
351
  tenantId: string,
@@ -213,7 +355,7 @@ export declare class Client {
213
355
  key?: Buffer,
214
356
  ack?: AckMode,
215
357
  atLeastOnce?: boolean,
216
- ): Promise<void>;
358
+ ): Promise<bigint | null>;
217
359
 
218
360
  /**
219
361
  * Subscribe to a stream.
@@ -293,6 +435,28 @@ export declare class Client {
293
435
  key: string,
294
436
  ): Promise<Buffer | null>;
295
437
 
438
+ /**
439
+ * Commit `ops` as one record on the shard `entityKey` routes to. Every
440
+ * reader sees all of it or none of it. Rejects with `EventCountError` or
441
+ * `NotOnOwningShardError` before anything is sent when `ops` does not carry
442
+ * exactly one event or names two streams. See docs/atomic-commit.md.
443
+ */
444
+ commit(
445
+ tenantId: string,
446
+ namespace: string,
447
+ entityKey: Buffer,
448
+ ops: CommitOp[],
449
+ ): Promise<CommitReceipt>;
450
+
451
+ /** `key` in the state of `stream`'s shard that `entityKey` routes to. */
452
+ stateGet(
453
+ tenantId: string,
454
+ namespace: string,
455
+ stream: string,
456
+ entityKey: Buffer,
457
+ key: string,
458
+ ): Promise<StateValue>;
459
+
296
460
  /** Add to a counter and return its new value. `delta` may be negative. */
297
461
  counterAdd(
298
462
  tenantId: string,
@@ -354,6 +518,14 @@ export declare class Client {
354
518
  group: string,
355
519
  maxRecords?: number,
356
520
  waitMs?: number,
521
+ /**
522
+ * This member's name, stable across its restarts, 1 to 128 bytes. With
523
+ * `reclaim`, the records a previous process under the same name still
524
+ * held come back first, instead of after the visibility timeout. Only a
525
+ * connection's first such poll reclaims, so it is safe to leave set.
526
+ */
527
+ consumer?: string,
528
+ reclaim?: boolean,
357
529
  ): Promise<GroupRecord[]>;
358
530
 
359
531
  /** Finish a record: it will not be handed out again. */
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.2",
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",
@@ -14,19 +14,20 @@
14
14
  ],
15
15
  "license": "Apache-2.0",
16
16
  "author": "Gabriel Loewen",
17
- "homepage": "https://gabloe.github.io/felix/clients/typescript/",
17
+ "homepage": "https://docs.getfelix.dev/clients/typescript/",
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://github.com/gabloe/felix.git",
21
- "directory": "crates/felix-typescript"
20
+ "url": "https://github.com/GetFelix/felix.git",
21
+ "directory": "crates/sdk/felix-typescript"
22
22
  },
23
23
  "bugs": {
24
- "url": "https://github.com/gabloe/felix/issues"
24
+ "url": "https://github.com/GetFelix/felix/issues"
25
25
  },
26
26
  "main": "index.js",
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.2",
58
+ "felix-client-darwin-x64": "0.6.0-preview.2",
59
+ "felix-client-linux-arm64-gnu": "0.6.0-preview.2",
60
+ "felix-client-linux-x64-gnu": "0.6.0-preview.2",
61
+ "felix-client-win32-x64-msvc": "0.6.0-preview.2"
61
62
  }
62
63
  }