@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.
- package/README.md +78 -2
- package/dist/app.d.ts +149 -0
- package/dist/app.js +317 -0
- package/dist/builders/filter.d.ts +96 -0
- package/dist/builders/filter.js +190 -0
- package/dist/builders/schema.d.ts +50 -0
- package/dist/builders/schema.js +92 -0
- package/dist/builders/search.d.ts +159 -0
- package/dist/builders/search.js +307 -0
- package/dist/gen/facade.d.ts +267 -2
- package/dist/gen/facade.js +194 -0
- package/dist/handle.d.ts +67 -0
- package/dist/handle.js +107 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +12 -3
- package/dist/loams.d.ts +77 -2
- package/dist/loams.js +84 -6
- package/dist/runtime/batch.d.ts +29 -0
- package/dist/runtime/batch.js +95 -0
- package/dist/runtime/call.d.ts +63 -9
- package/dist/runtime/call.js +243 -41
- package/dist/runtime/consistency.d.ts +65 -11
- package/dist/runtime/consistency.js +212 -46
- package/dist/runtime/errors.d.ts +29 -2
- package/dist/runtime/errors.js +26 -0
- package/dist/runtime/pagination.js +8 -2
- package/dist/runtime/subscribe.d.ts +37 -0
- package/dist/runtime/subscribe.js +128 -0
- package/dist/vendor/proto/collection-v1.d.ts +3 -0
- package/dist/vendor/proto/collection-v1.js +7 -0
- package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.d.ts +1610 -0
- package/dist/vendor/proto/gen/loams/collection/v1/collection_pb.js +399 -0
- package/dist/vendor/proto/gen/loams/collection/v1/document_pb.d.ts +1054 -0
- package/dist/vendor/proto/gen/loams/collection/v1/document_pb.js +327 -0
- package/dist/vendor/proto/gen/loams/collection/v1/query_pb.d.ts +2101 -0
- package/dist/vendor/proto/gen/loams/collection/v1/query_pb.js +608 -0
- package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.d.ts +2299 -0
- package/dist/vendor/proto/gen/loams/graph/v1/graph_pb.js +596 -0
- package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.d.ts +399 -0
- package/dist/vendor/proto/gen/loams/operations/v1/operations_pb.js +118 -0
- package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.d.ts +182 -0
- package/dist/vendor/proto/gen/loams/sql/v1/sql_pb.js +72 -0
- package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.d.ts +688 -0
- package/dist/vendor/proto/gen/loams/stream/v1/stream_pb.js +180 -0
- 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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
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)
|
|
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
|
|
69
|
+
* A parsed consistency token: `parse`, `toString`, `merge`, `equals`.
|
|
27
70
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = typeof token === 'string' ? ConsistencyToken.parse(token) : token;
|
|
49
213
|
}
|
|
50
|
-
|
|
51
|
-
this.#
|
|
52
|
-
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.#rejected += 1;
|
|
216
|
+
throw error;
|
|
53
217
|
}
|
|
54
|
-
this.#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
225
|
+
/** @deprecated The old name of `rejected`, from before tokens merged. */
|
|
60
226
|
get conflicts() {
|
|
61
|
-
return this.#
|
|
227
|
+
return this.#rejected;
|
|
62
228
|
}
|
|
63
|
-
/** Forgets
|
|
229
|
+
/** Forgets every token, so the next read is not held to one. */
|
|
64
230
|
clear() {
|
|
65
|
-
this.#
|
|
66
|
-
this.#
|
|
231
|
+
this.#tokens.clear();
|
|
232
|
+
this.#rejected = 0;
|
|
67
233
|
}
|
|
68
234
|
}
|
package/dist/runtime/errors.d.ts
CHANGED
|
@@ -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 {
|
package/dist/runtime/errors.js
CHANGED
|
@@ -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
|
-
|
|
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,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';
|