@loams/client 0.1.0 → 0.1.1

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 (45) hide show
  1. package/README.md +78 -2
  2. package/dist/app.d.ts +149 -0
  3. package/dist/app.js +317 -0
  4. package/dist/builders/filter.d.ts +96 -0
  5. package/dist/builders/filter.js +190 -0
  6. package/dist/builders/schema.d.ts +50 -0
  7. package/dist/builders/schema.js +92 -0
  8. package/dist/builders/search.d.ts +159 -0
  9. package/dist/builders/search.js +307 -0
  10. package/dist/gen/facade.d.ts +267 -2
  11. package/dist/gen/facade.js +194 -0
  12. package/dist/handle.d.ts +67 -0
  13. package/dist/handle.js +107 -0
  14. package/dist/index.d.ts +12 -5
  15. package/dist/index.js +12 -3
  16. package/dist/loams.d.ts +77 -2
  17. package/dist/loams.js +84 -6
  18. package/dist/runtime/batch.d.ts +29 -0
  19. package/dist/runtime/batch.js +95 -0
  20. package/dist/runtime/call.d.ts +63 -9
  21. package/dist/runtime/call.js +243 -41
  22. package/dist/runtime/consistency.d.ts +65 -11
  23. package/dist/runtime/consistency.js +212 -46
  24. package/dist/runtime/errors.d.ts +29 -2
  25. package/dist/runtime/errors.js +26 -0
  26. package/dist/runtime/pagination.js +8 -2
  27. package/dist/runtime/subscribe.d.ts +37 -0
  28. package/dist/runtime/subscribe.js +128 -0
  29. package/dist/vendor/proto/collection-v1.d.ts +3 -0
  30. package/dist/vendor/proto/collection-v1.js +7 -0
  31. package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.d.ts +1610 -0
  32. package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.js +399 -0
  33. package/dist/vendor/proto/gen/loams/collection/v1/document_pb.d.ts +1054 -0
  34. package/dist/vendor/proto/gen/loams/collection/v1/document_pb.js +327 -0
  35. package/dist/vendor/proto/gen/loams/collection/v1/query_pb.d.ts +2101 -0
  36. package/dist/vendor/proto/gen/loams/collection/v1/query_pb.js +608 -0
  37. package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.d.ts +2299 -0
  38. package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.js +596 -0
  39. package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.d.ts +399 -0
  40. package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.js +118 -0
  41. package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.d.ts +182 -0
  42. package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.js +72 -0
  43. package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.d.ts +688 -0
  44. package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.js +180 -0
  45. package/package.json +2 -2
@@ -1,68 +1,234 @@
1
- // Consistency tokens (design §44 §7.4, D609; runtime contract R4).
1
+ // Consistency tokens (design §44 §7.4, D609; runtime contract R4 as amended
2
+ // by SDKP §2.1).
2
3
  //
3
- // A write answers with a `consistency_token`; a read accepts one, so a caller
4
- // that just wrote can read its own write. Threading those by hand is the
5
- // caller's job today. The session store is the alternative §44 §7.4 asks for:
6
- // off by default, and when a call opts in, every response's token is merged
7
- // into the session and attached to later reads.
4
+ // A write answers with a token; a read accepts one, so a caller that just
5
+ // wrote can read its own write. The encoding is the server's
6
+ // (`crates/loams-collection/src/token.rs`):
8
7
  //
9
- // **The token's encoding is not in the protos yet.** §05 §5 defines the
10
- // semantics (offsets per stream and partition, `STRONG` as the default,
11
- // `EVENTUAL`, `AT_LEAST{token}`) and API1's write paths carry it as an opaque
12
- // `v1:` string; §44 §7.4 says it merges by "max offset per stream and
13
- // partition", which needs the encoding to be parsed. Until that lands this
14
- // store keeps the token it was given, refuses to merge two different tokens
15
- // into a wrong one, and says so — a silently-wrong consistency token reads
16
- // stale data, which is worse than an error.
17
- import { LoamsError } from './errors.js';
8
+ // v1:s<stream>/p<partition>@<offset>,s<stream>/p<partition>@<offset>,…
9
+ //
10
+ // Each item is "the next offset after the write" for one `(stream, partition)`.
11
+ // Merging two tokens keeps, per pair, the larger offset: a read held to the
12
+ // merge waits for both writes. Every number is canonical decimal (no sign, no
13
+ // leading zeros), so a token has exactly one spelling, and a malformed token is
14
+ // `invalid_argument` here, before any network call.
15
+ //
16
+ // The session store is off by default. With `sessionConsistency: true` every
17
+ // token a response carries is merged into it, and every read whose request has
18
+ // a `consistency` field is sent `at_least = session` when the caller left the
19
+ // field unset.
20
+ //
21
+ // Offsets are `bigint`: an offset is a `u64` on the server, and a `number`
22
+ // silently rounds above 2^53, which would make a read wait for the wrong
23
+ // position.
18
24
  import { Code } from '@connectrpc/connect';
25
+ import { InvalidArgumentError } from './errors.js';
19
26
  /** The prefix every token carries (§44 §7.4). */
20
27
  export const TOKEN_PREFIX = 'v1:';
21
- /** Whether a string looks like a consistency token. */
28
+ /** Whether a string looks like a consistency token (the prefix only). */
22
29
  export function isConsistencyToken(value) {
23
- return value.startsWith(TOKEN_PREFIX) && value.length > TOKEN_PREFIX.length;
30
+ return value.startsWith(TOKEN_PREFIX);
31
+ }
32
+ const U32_MAX = 0xffffffffn;
33
+ const U64_MAX = 0xffffffffffffffffn;
34
+ /** A decimal `u64` with no sign and no leading zeros (except `"0"`). */
35
+ function canonical(text, max) {
36
+ if (text === '' || !/^[0-9]+$/.test(text) || (text.length > 1 && text.startsWith('0'))) {
37
+ return undefined;
38
+ }
39
+ const value = BigInt(text);
40
+ return value <= max ? value : undefined;
41
+ }
42
+ /** `s<stream>/p<partition>@<offset>`. */
43
+ function parseItem(item) {
44
+ const match = /^s([^/]*)\/p([^@]*)@(.*)$/.exec(item);
45
+ if (match === null) {
46
+ return undefined;
47
+ }
48
+ const stream = canonical(match[1], U64_MAX);
49
+ const partition = canonical(match[2], U32_MAX);
50
+ const offset = canonical(match[3], U64_MAX);
51
+ if (stream === undefined || partition === undefined || offset === undefined) {
52
+ return undefined;
53
+ }
54
+ return { stream, partition: Number(partition), offset };
55
+ }
56
+ function compare(a, b) {
57
+ if (a.stream !== b.stream) {
58
+ return a.stream < b.stream ? -1 : 1;
59
+ }
60
+ if (a.partition !== b.partition) {
61
+ return a.partition - b.partition;
62
+ }
63
+ if (a.offset !== b.offset) {
64
+ return a.offset < b.offset ? -1 : 1;
65
+ }
66
+ return 0;
24
67
  }
25
68
  /**
26
- * A session's consistency token.
69
+ * A parsed consistency token: `parse`, `toString`, `merge`, `equals`.
27
70
  *
28
- * Merging is deliberately conservative while the encoding is opaque: the store
29
- * keeps what it has, and a second, different token is a conflict the caller has
30
- * to resolve, not something to merge blindly.
71
+ * Immutable. `merge` returns a new token, normalised (sorted by stream and
72
+ * partition, one item per pair, the max offset kept), which is exactly what
73
+ * `ConsistencyToken::merge` on the server produces, so the two print the same.
74
+ */
75
+ export class ConsistencyToken {
76
+ positions;
77
+ constructor(positions) {
78
+ this.positions = positions;
79
+ }
80
+ /** The token that names nothing: `v1:`. */
81
+ static empty() {
82
+ return new ConsistencyToken([]);
83
+ }
84
+ /** A token from its items, in the order given. */
85
+ static of(positions) {
86
+ return new ConsistencyToken([...positions]);
87
+ }
88
+ /** Parses the text form, or throws `InvalidArgumentError`. The items are
89
+ * kept in the order given, as the server's parser keeps them. */
90
+ static parse(text) {
91
+ const invalid = () => new InvalidArgumentError(`invalid consistency token ${JSON.stringify(text)}`, {
92
+ code: Code.InvalidArgument,
93
+ reason: 'invalid_consistency_token',
94
+ metadata: { field: 'consistency_token' },
95
+ });
96
+ if (!text.startsWith(TOKEN_PREFIX)) {
97
+ throw invalid();
98
+ }
99
+ const body = text.slice(TOKEN_PREFIX.length);
100
+ if (body === '') {
101
+ return ConsistencyToken.empty();
102
+ }
103
+ const positions = [];
104
+ for (const item of body.split(',')) {
105
+ const position = parseItem(item);
106
+ if (position === undefined) {
107
+ throw invalid();
108
+ }
109
+ positions.push(position);
110
+ }
111
+ return new ConsistencyToken(positions);
112
+ }
113
+ /** Whether `text` parses. */
114
+ static isValid(text) {
115
+ try {
116
+ ConsistencyToken.parse(text);
117
+ return true;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ /** Sorted by `(stream, partition)`, one item per pair, the max offset kept. */
124
+ normalized() {
125
+ const sorted = [...this.positions].sort(compare);
126
+ const items = [];
127
+ for (const item of sorted) {
128
+ const last = items[items.length - 1];
129
+ if (last !== undefined && last.stream === item.stream && last.partition === item.partition) {
130
+ items[items.length - 1] = item;
131
+ }
132
+ else {
133
+ items.push(item);
134
+ }
135
+ }
136
+ return new ConsistencyToken(items);
137
+ }
138
+ /** Both tokens' offsets; a pair both name keeps the larger. Normalised. */
139
+ merge(other) {
140
+ const right = typeof other === 'string' ? ConsistencyToken.parse(other) : other;
141
+ return new ConsistencyToken([...this.positions, ...right.positions]).normalized();
142
+ }
143
+ /** The offset for `(stream, partition)`, the max if listed twice. */
144
+ offset(stream, partition) {
145
+ const s = BigInt(stream);
146
+ let found;
147
+ for (const item of this.positions) {
148
+ if (item.stream === s && item.partition === partition) {
149
+ found = found === undefined || item.offset > found ? item.offset : found;
150
+ }
151
+ }
152
+ return found;
153
+ }
154
+ /** Whether the two tokens name the same positions (after normalising). */
155
+ equals(other) {
156
+ const right = typeof other === 'string' ? ConsistencyToken.parse(other) : other;
157
+ return this.normalized().toString() === right.normalized().toString();
158
+ }
159
+ /** Whether the token names nothing. */
160
+ get isEmpty() {
161
+ return this.positions.length === 0;
162
+ }
163
+ /** The text form, `v1:` plus the items joined by `,`, in this token's order. */
164
+ toString() {
165
+ return (TOKEN_PREFIX +
166
+ this.positions
167
+ .map((item) => `s${item.stream}/p${item.partition}@${item.offset}`)
168
+ .join(','));
169
+ }
170
+ toJSON() {
171
+ return this.toString();
172
+ }
173
+ }
174
+ /**
175
+ * A session's consistency tokens: every token a response carried, merged, kept
176
+ * **per proto package** (R4, SDKP-17).
177
+ *
178
+ * A Graph read refuses a token naming another package's stream, so a
179
+ * collection write's token must never become a graph read's `at_least`: the
180
+ * session holds one merged token per scope, and the call path passes the
181
+ * call's package as the scope. A caller using the store directly may leave the
182
+ * scope out, which is one shared scope.
183
+ *
184
+ * `record` folds a token in; `current` is the scope's merge, as text. A token
185
+ * that does not parse is counted in `rejected` and otherwise ignored: the write
186
+ * that answered with it has already happened, and turning a committed write
187
+ * into a thrown error invites a caller to retry it.
31
188
  */
32
189
  export class ConsistencySession {
33
- #token;
34
- /** How many times two different tokens met. Surfaced so the limitation is
35
- * visible rather than silent. */
36
- #conflicts = 0;
37
- /** The token to attach to the next read. */
38
- current() {
39
- return this.#token;
40
- }
41
- /** Folds a token the server returned in. */
42
- record(token) {
190
+ #tokens = new Map();
191
+ #rejected = 0;
192
+ /** The token to attach to the next read in `scope`, or undefined. */
193
+ current(scope = '') {
194
+ return this.#tokens.get(scope)?.toString();
195
+ }
196
+ /** The merged token of `scope`, parsed. */
197
+ token(scope = '') {
198
+ return this.#tokens.get(scope);
199
+ }
200
+ /** The scopes the session holds a token for. */
201
+ scopes() {
202
+ return [...this.#tokens.keys()];
203
+ }
204
+ /** Folds a token the server returned, in `scope`, in. Throws
205
+ * `InvalidArgumentError` on a malformed token, after counting it. */
206
+ record(token, scope = '') {
43
207
  if (token === undefined || token === '') {
44
208
  return;
45
209
  }
46
- if (!isConsistencyToken(token)) {
47
- this.#conflicts += 1;
48
- throw new LoamsError(`not a consistency token: ${token}`, { code: Code.Internal });
210
+ let parsed;
211
+ try {
212
+ parsed = typeof token === 'string' ? ConsistencyToken.parse(token) : token;
49
213
  }
50
- if (this.#token === undefined || this.#token === token) {
51
- this.#token = token;
52
- return;
214
+ catch (error) {
215
+ this.#rejected += 1;
216
+ throw error;
53
217
  }
54
- this.#conflicts += 1;
55
- throw new LoamsError('two different consistency tokens met and the encoding cannot merge them yet; ' +
56
- 'the session keeps the first. Merging by stream and partition offset arrives with ' +
57
- 'the write paths that carry offsets (§44 §7.4, D609).', { code: Code.FailedPrecondition });
218
+ const held = this.#tokens.get(scope);
219
+ this.#tokens.set(scope, held === undefined ? parsed.normalized() : held.merge(parsed));
220
+ }
221
+ /** How many tokens the session could not parse. */
222
+ get rejected() {
223
+ return this.#rejected;
58
224
  }
59
- /** How many unmergeable pairs the session has seen. */
225
+ /** @deprecated The old name of `rejected`, from before tokens merged. */
60
226
  get conflicts() {
61
- return this.#conflicts;
227
+ return this.#rejected;
62
228
  }
63
- /** Forgets the token, so the next read is not held to it. */
229
+ /** Forgets every token, so the next read is not held to one. */
64
230
  clear() {
65
- this.#token = undefined;
66
- this.#conflicts = 0;
231
+ this.#tokens.clear();
232
+ this.#rejected = 0;
67
233
  }
68
234
  }
@@ -1,5 +1,13 @@
1
1
  import { Code, ConnectError } from '@connectrpc/connect';
2
2
  import { type Reason } from '../gen/facade.js';
3
+ /**
4
+ * Reasons the SDK raises itself, without a server answer (SDKP §2.1, R12 and
5
+ * R13). They are registered in `docs/api/reasons.md` as client-side, and are
6
+ * kept here as well so the SDK does not depend on when that registry lands.
7
+ */
8
+ export declare const CLIENT_REASONS: readonly ["client_deadline_exceeded", "batch_partially_applied"];
9
+ /** A reason the SDK raises itself. */
10
+ export type ClientReason = (typeof CLIENT_REASONS)[number];
3
11
  /** What every Loams failure carries. */
4
12
  export declare class LoamsError extends Error {
5
13
  /** The Connect code, the canonical classification. */
@@ -7,7 +15,7 @@ export declare class LoamsError extends Error {
7
15
  /** The stable cause, when the server sent an `ErrorInfo`. Undefined means
8
16
  * the failure came from below the API (a socket, a timeout, a CORS
9
17
  * rejection), not from a Loams service. */
10
- readonly reason: Reason | undefined;
18
+ readonly reason: Reason | ClientReason | undefined;
11
19
  /** Structured context the server sent. Never secrets. */
12
20
  readonly metadata: Readonly<Record<string, string>>;
13
21
  /** A short next step in the caller's locale, when the server sent one. */
@@ -19,7 +27,7 @@ export declare class LoamsError extends Error {
19
27
  readonly unknownReason: string | undefined;
20
28
  constructor(message: string, init: {
21
29
  code: Code;
22
- reason?: Reason;
30
+ reason?: Reason | ClientReason;
23
31
  unknownReason?: string;
24
32
  metadata?: Record<string, string>;
25
33
  hint?: string;
@@ -69,6 +77,25 @@ export declare class FeatureNotInVariantError extends UnimplementedError {
69
77
  variant?: string;
70
78
  });
71
79
  }
80
+ /**
81
+ * A `documents.writeBatch` that stopped at a failed chunk (runtime contract
82
+ * R13). The chunks before it are applied and stay applied; `token` is their
83
+ * merged consistency token, so a caller can read what did land, and `cause` is
84
+ * the failed chunk's own error.
85
+ */
86
+ export declare class BatchError extends LoamsError {
87
+ /** How many chunks were applied before the failure. */
88
+ readonly chunksApplied: number;
89
+ /** How many documents those chunks carried. */
90
+ readonly rowsApplied: number;
91
+ /** The merged token of the applied chunks, if any carried one. */
92
+ readonly token: string | undefined;
93
+ constructor(message: string, init: ConstructorParameters<typeof LoamsError>[1] & {
94
+ chunksApplied: number;
95
+ rowsApplied: number;
96
+ token?: string;
97
+ });
98
+ }
72
99
  /** A token the server rejected as expired: `unauthenticated` with reason
73
100
  * `token_expired`. The runtime refreshes once and retries once (D608, R1). */
74
101
  export declare class TokenExpiredError extends UnauthenticatedError {
@@ -13,6 +13,12 @@ import { REASONS } from '../gen/facade.js';
13
13
  * union does not have is a server newer than this SDK, and is surfaced rather
14
14
  * than dropped. */
15
15
  const KNOWN_REASONS = new Set(REASONS);
16
+ /**
17
+ * Reasons the SDK raises itself, without a server answer (SDKP §2.1, R12 and
18
+ * R13). They are registered in `docs/api/reasons.md` as client-side, and are
19
+ * kept here as well so the SDK does not depend on when that registry lands.
20
+ */
21
+ export const CLIENT_REASONS = ['client_deadline_exceeded', 'batch_partially_applied'];
16
22
  /** What every Loams failure carries. */
17
23
  export class LoamsError extends Error {
18
24
  /** The Connect code, the canonical classification. */
@@ -84,6 +90,26 @@ export class FeatureNotInVariantError extends UnimplementedError {
84
90
  this.variant = init.variant;
85
91
  }
86
92
  }
93
+ /**
94
+ * A `documents.writeBatch` that stopped at a failed chunk (runtime contract
95
+ * R13). The chunks before it are applied and stay applied; `token` is their
96
+ * merged consistency token, so a caller can read what did land, and `cause` is
97
+ * the failed chunk's own error.
98
+ */
99
+ export class BatchError extends LoamsError {
100
+ /** How many chunks were applied before the failure. */
101
+ chunksApplied;
102
+ /** How many documents those chunks carried. */
103
+ rowsApplied;
104
+ /** The merged token of the applied chunks, if any carried one. */
105
+ token;
106
+ constructor(message, init) {
107
+ super(message, init);
108
+ this.chunksApplied = init.chunksApplied;
109
+ this.rowsApplied = init.rowsApplied;
110
+ this.token = init.token;
111
+ }
112
+ }
87
113
  /** A token the server rejected as expired: `unauthenticated` with reason
88
114
  * `token_expired`. The runtime refreshes once and retries once (D608, R1). */
89
115
  export class TokenExpiredError extends UnauthenticatedError {
@@ -25,13 +25,15 @@ export async function* paginate(binding, fetch, request, options = {}) {
25
25
  ...request,
26
26
  ...(pageToken === undefined ? {} : { [pageTokenField]: pageToken }),
27
27
  }, options));
28
- const items = page[pagination.items];
28
+ // The binding spells the field as the proto does (`service_accounts`);
29
+ // a decoded message has it in TypeScript case (`serviceAccounts`).
30
+ const items = page[pagination.items] ?? page[camelCase(pagination.items)];
29
31
  if (Array.isArray(items)) {
30
32
  for (const item of items) {
31
33
  yield item;
32
34
  }
33
35
  }
34
- const next = page[pagination.nextPageToken];
36
+ const next = page[pagination.nextPageToken] ?? page[camelCase(pagination.nextPageToken)];
35
37
  if (typeof next !== 'string' || next === '') {
36
38
  return;
37
39
  }
@@ -45,3 +47,7 @@ export function pageFields(binding) {
45
47
  }
46
48
  return { pageSizeField: 'pageSize', pageTokenField: 'pageToken' };
47
49
  }
50
+ /** `snake_case` to `lowerCamelCase`, as protobuf-es names a field. */
51
+ function camelCase(name) {
52
+ return name.replace(/_([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
53
+ }
@@ -0,0 +1,37 @@
1
+ /** The first and the largest pause after an empty fetch. */
2
+ export declare const SUBSCRIBE_MIN_BACKOFF_MS = 100;
3
+ export declare const SUBSCRIBE_MAX_BACKOFF_MS = 1000;
4
+ /** Where a subscription starts and how much each fetch asks for. */
5
+ export interface SubscribeRequest {
6
+ namespace?: string;
7
+ stream: string;
8
+ partition?: number;
9
+ /** The first offset to read; default 0. */
10
+ fromOffset?: bigint | number;
11
+ maxRecords?: number;
12
+ maxBytes?: number;
13
+ /** A server long poll per fetch, when the server offers it (P0c). */
14
+ maxWaitMs?: number;
15
+ }
16
+ /** One record, as far as the loop needs to know it. */
17
+ export interface OffsetRecord {
18
+ readonly offset: bigint | number | string;
19
+ }
20
+ /** One `Fetch`. */
21
+ export type RecordFetcher<R extends OffsetRecord> = (request: Record<string, unknown>, options?: {
22
+ signal?: AbortSignal;
23
+ }) => Promise<{
24
+ records?: readonly R[];
25
+ } | undefined>;
26
+ /** A running subscription: an async iterable that knows where it is. */
27
+ export interface Subscription<R> extends AsyncIterableIterator<R> {
28
+ /** The next offset to read. */
29
+ readonly offset: bigint;
30
+ /** Stops the loop; the same as `return()`. */
31
+ cancel(): void;
32
+ }
33
+ /** Starts a subscription over `fetch`. Nothing is sent until the first
34
+ * `next()`. */
35
+ export declare function subscribe<R extends OffsetRecord>(fetch: RecordFetcher<R>, request: SubscribeRequest, options?: {
36
+ signal?: AbortSignal;
37
+ }): Subscription<R>;
@@ -0,0 +1,128 @@
1
+ // `streams.subscribe` (runtime contract R14, SDKP §2.5 and SDKP-2).
2
+ //
3
+ // const sub = loams.streams.subscribe({ stream: 'events', partition: 0, fromOffset: 1n });
4
+ // for await (const record of sub) {
5
+ // handle(record);
6
+ // if (done) break; // cancels the loop
7
+ // }
8
+ // resumeLater(sub.offset); // the next offset to read
9
+ //
10
+ // A client loop over unary `Fetch` rather than a server stream, so it works on
11
+ // every transport (Connect-unary included) and through every proxy:
12
+ //
13
+ // - The next offset is the last record's offset + 1.
14
+ // - An empty fetch backs off 100 ms, doubling, up to 1 s, and the backoff
15
+ // resets when a record arrives. When the caller passes `maxWaitMs`, the
16
+ // server long-polls instead and the backoff only applies when it answers
17
+ // empty at its deadline.
18
+ // - `offset` is always the next offset to read, so a caller can stop and
19
+ // resume without losing or repeating a record.
20
+ // - Breaking out of `for await`, calling `return()`, or aborting `signal`
21
+ // stops the loop; no further fetch is sent.
22
+ //
23
+ // A fetch that fails is retried by the call path as any `safe` call is; a
24
+ // failure that outlasts the retries ends the iteration with that error, and
25
+ // `offset` still says where to resume.
26
+ import { sleep } from './retry.js';
27
+ /** The first and the largest pause after an empty fetch. */
28
+ export const SUBSCRIBE_MIN_BACKOFF_MS = 100;
29
+ export const SUBSCRIBE_MAX_BACKOFF_MS = 1_000;
30
+ function asBigInt(value) {
31
+ if (value === undefined) {
32
+ return 0n;
33
+ }
34
+ return typeof value === 'bigint' ? value : BigInt(value);
35
+ }
36
+ /** Starts a subscription over `fetch`. Nothing is sent until the first
37
+ * `next()`. */
38
+ export function subscribe(fetch, request, options = {}) {
39
+ let offset = asBigInt(request.fromOffset);
40
+ let buffer = [];
41
+ let backoff = SUBSCRIBE_MIN_BACKOFF_MS;
42
+ let done = false;
43
+ const controller = new AbortController();
44
+ const onAbort = () => {
45
+ done = true;
46
+ controller.abort(options.signal?.reason);
47
+ };
48
+ if (options.signal?.aborted === true) {
49
+ onAbort();
50
+ }
51
+ else {
52
+ options.signal?.addEventListener('abort', onAbort, { once: true });
53
+ }
54
+ const finish = () => {
55
+ done = true;
56
+ options.signal?.removeEventListener('abort', onAbort);
57
+ if (!controller.signal.aborted) {
58
+ controller.abort();
59
+ }
60
+ return { done: true, value: undefined };
61
+ };
62
+ const fetchRequest = () => {
63
+ const out = {
64
+ stream: request.stream,
65
+ partition: request.partition ?? 0,
66
+ offset,
67
+ };
68
+ for (const key of ['namespace', 'maxRecords', 'maxBytes', 'maxWaitMs']) {
69
+ if (request[key] !== undefined) {
70
+ out[key] = request[key];
71
+ }
72
+ }
73
+ return out;
74
+ };
75
+ const subscription = {
76
+ get offset() {
77
+ return offset;
78
+ },
79
+ cancel() {
80
+ finish();
81
+ },
82
+ async next() {
83
+ while (!done) {
84
+ const record = buffer.shift();
85
+ if (record !== undefined) {
86
+ offset = asBigInt(record.offset) + 1n;
87
+ return { done: false, value: record };
88
+ }
89
+ let answer;
90
+ try {
91
+ answer = await fetch(fetchRequest(), { signal: controller.signal });
92
+ }
93
+ catch (error) {
94
+ if (done) {
95
+ return finish();
96
+ }
97
+ finish();
98
+ throw error;
99
+ }
100
+ if (done) {
101
+ return finish();
102
+ }
103
+ // A record below the next offset is one already yielded (a server that
104
+ // answers from a segment start); skipping it keeps resume exact.
105
+ buffer = (answer?.records ?? []).filter((r) => asBigInt(r.offset) >= offset);
106
+ if (buffer.length > 0) {
107
+ backoff = SUBSCRIBE_MIN_BACKOFF_MS;
108
+ continue;
109
+ }
110
+ try {
111
+ await sleep(backoff, controller.signal);
112
+ }
113
+ catch {
114
+ return finish();
115
+ }
116
+ backoff = Math.min(backoff * 2, SUBSCRIBE_MAX_BACKOFF_MS);
117
+ }
118
+ return finish();
119
+ },
120
+ return() {
121
+ return Promise.resolve(finish());
122
+ },
123
+ [Symbol.asyncIterator]() {
124
+ return this;
125
+ },
126
+ };
127
+ return subscription;
128
+ }
@@ -0,0 +1,3 @@
1
+ export * from './gen/loams/collection/v1/collection_pb.js';
2
+ export * from './gen/loams/collection/v1/document_pb.js';
3
+ export * from './gen/loams/collection/v1/query_pb.js';
@@ -0,0 +1,7 @@
1
+ // `loams.collection.v1` as one module: the package spans three proto files
2
+ // (`collection`, `document`, `query`), and the SDK facade imports a package's
3
+ // types from one specifier (`@loams/proto/collection-v1`), as `./admin.ts`
4
+ // does for `loams.admin.v1`. Each file stays importable on its own path too.
5
+ export * from './gen/loams/collection/v1/collection_pb.js';
6
+ export * from './gen/loams/collection/v1/document_pb.js';
7
+ export * from './gen/loams/collection/v1/query_pb.js';