@laserdata/laser-sdk 0.5.0 → 0.5.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 (71) hide show
  1. package/README.md +21 -14
  2. package/dist/agent/reliable-consumer.d.ts.map +1 -1
  3. package/dist/agent/reliable-consumer.js +4 -5
  4. package/dist/agent/reliable-consumer.js.map +1 -1
  5. package/dist/bridges/a2a.d.ts +1 -1
  6. package/dist/bridges/a2a.js +1 -1
  7. package/dist/client/capabilities.d.ts +12 -0
  8. package/dist/client/capabilities.d.ts.map +1 -1
  9. package/dist/client/capabilities.js +13 -5
  10. package/dist/client/capabilities.js.map +1 -1
  11. package/dist/client/errors.d.ts +14 -1
  12. package/dist/client/errors.d.ts.map +1 -1
  13. package/dist/client/errors.js +20 -0
  14. package/dist/client/errors.js.map +1 -1
  15. package/dist/client/internals.d.ts +1 -0
  16. package/dist/client/internals.d.ts.map +1 -1
  17. package/dist/client/internals.js +1 -0
  18. package/dist/client/internals.js.map +1 -1
  19. package/dist/client/laser.d.ts +1 -6
  20. package/dist/client/laser.d.ts.map +1 -1
  21. package/dist/client/laser.js +39 -30
  22. package/dist/client/laser.js.map +1 -1
  23. package/dist/index.d.ts +6 -4
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/managed/filters.d.ts +57 -128
  28. package/dist/managed/filters.d.ts.map +1 -1
  29. package/dist/managed/filters.js +278 -268
  30. package/dist/managed/filters.js.map +1 -1
  31. package/dist/stream/consumer-group.d.ts +168 -0
  32. package/dist/stream/consumer-group.d.ts.map +1 -0
  33. package/dist/stream/consumer-group.js +394 -0
  34. package/dist/stream/consumer-group.js.map +1 -0
  35. package/dist/stream/consumer.d.ts +38 -5
  36. package/dist/stream/consumer.d.ts.map +1 -1
  37. package/dist/stream/consumer.js +161 -37
  38. package/dist/stream/consumer.js.map +1 -1
  39. package/dist/stream/producer-statistics.d.ts +22 -0
  40. package/dist/stream/producer-statistics.d.ts.map +1 -0
  41. package/dist/stream/producer-statistics.js +214 -0
  42. package/dist/stream/producer-statistics.js.map +1 -0
  43. package/dist/stream/producer.d.ts +2 -0
  44. package/dist/stream/producer.d.ts.map +1 -1
  45. package/dist/stream/producer.js +16 -0
  46. package/dist/stream/producer.js.map +1 -1
  47. package/dist/stream/stream.d.ts +3 -1
  48. package/dist/stream/stream.d.ts.map +1 -1
  49. package/dist/stream/stream.js +4 -2
  50. package/dist/stream/stream.js.map +1 -1
  51. package/dist/stream/topic.d.ts +19 -3
  52. package/dist/stream/topic.d.ts.map +1 -1
  53. package/dist/stream/topic.js +29 -6
  54. package/dist/stream/topic.js.map +1 -1
  55. package/dist/wire/filter.d.ts +66 -3
  56. package/dist/wire/filter.d.ts.map +1 -1
  57. package/dist/wire/filter.js +195 -27
  58. package/dist/wire/filter.js.map +1 -1
  59. package/dist/wire/hello.d.ts +1 -0
  60. package/dist/wire/hello.d.ts.map +1 -1
  61. package/dist/wire/hello.js +2 -1
  62. package/dist/wire/hello.js.map +1 -1
  63. package/dist/wire/http.d.ts +1 -0
  64. package/dist/wire/http.d.ts.map +1 -1
  65. package/dist/wire/http.js +2 -0
  66. package/dist/wire/http.js.map +1 -1
  67. package/dist/wire/limits.d.ts +2 -0
  68. package/dist/wire/limits.d.ts.map +1 -1
  69. package/dist/wire/limits.js +2 -0
  70. package/dist/wire/limits.js.map +1 -1
  71. package/package.json +1 -1
@@ -1,4 +1,3 @@
1
- import { requireCapability } from "../client/capabilities.js";
2
1
  import { resultCodeIsRetryable } from "../wire/result.js";
3
2
  import { jsonCodec } from "../stream/codecs.js";
4
3
  import { decodeBrowseReply, encodeGetSchema } from "../wire/browse.js";
@@ -9,10 +8,9 @@ import { decodePolledBody, serverErrorCode } from "../iggy/apache-iggy.js";
9
8
  import { mintUlidValue } from "../runtime/ulid.js";
10
9
  import { decodeOne, encodeNamed } from "../wire/cbor.js";
11
10
  import { AGDX_FILTERED_ACK_CODE, AGDX_FILTERED_POLL_CODE, FILTER_OP_VERSION } from "../wire/codes.js";
12
- import { FilterMutateCommand, FilterOperationCommand, FilterPreviewCommand, FilterTestCommand, FilterValidateCommand, GetFilterBindingCommand, GetFilterCommand, ListFilterBindingsCommand, ListFilterRevisionsCommand, ListFiltersCommand } from "../wire/commands.js";
11
+ import { FilterMutateCommand, FilterOperationCommand, FilterPreviewCommand, FilterTestCommand, GetFilterBindingCommand, ListFilterBindingsCommand, ListFilterRevisionsCommand, ListFiltersCommand } from "../wire/commands.js";
13
12
  import { CompiledFilter, usesRegex } from "../wire/filter-eval.js";
14
- import { consumerFilterDigest, decodeFilterReply, encodeFilteredAck, encodeFilteredPollRequest, nextFilteredStart, validateCatalogPage, validateConsumerFilter, validateFilterGroupRef, validateFilterMutationRequest, validateFilterPreviewRequest, validateFilterTestRequest, validateFilteredPollRequest } from "../wire/filter.js";
15
- import { filterAnnounceEvaluates } from "../wire/hello.js";
13
+ import { decodeFilterReply, encodeFilteredAck, encodeFilteredPollRequest, nextFilteredStart, validateCatalogPage, validateFilterGroupRef, validateFilterMutationRequest, validateFilterPreviewRequest, validateFilterTestRequest, validateFilteredPollRequest } from "../wire/filter.js";
16
14
  import { MAX_FILTER_CATALOG_PAGE, MAX_FILTER_PARSE_DEPTH, MAX_FILTER_PREVIEW_EXAMINED, MAX_FILTERED_PAGE_BYTES } from "../wire/limits.js";
17
15
  const DEFAULT_COUNT = 100;
18
16
  const DEFAULT_MAX_REPLY_BYTES = 1024 * 1024;
@@ -29,6 +27,7 @@ const OUTCOME_POLL_INTERVAL_MS = 100;
29
27
  export const DEFAULT_OUTCOME_WAIT_MS = 30_000;
30
28
  const GET_STREAM_CODE = 200;
31
29
  const GET_TOPIC_CODE = 300;
30
+ const GET_CONSUMER_GROUP_CODE = 600;
32
31
  const ATTACH_CONSUMER_SESSION_CODE = 14;
33
32
  const SYNC_CONSUMER_GROUP_CODE = 606;
34
33
  const GET_CONSUMER_OFFSET_ROUTING_CODE = 123;
@@ -51,9 +50,9 @@ const UTF8 = new TextEncoder();
51
50
  */
52
51
  export const READER_TAG = Symbol("laser.filters.reader");
53
52
  /**
54
- * Server-side consumer filters: filtered readers, previews, sample tests, and
55
- * the saved-filter catalog. Build it with `Laser.filters()`. Native reads need
56
- * a server that serves consumer filters. The catalog also needs a managed
53
+ * The catalog and native filter commands behind a consumer group's filter
54
+ * handle, reached through `ConsumerGroup.filter()`. Native commands need a
55
+ * server that serves consumer filters. The catalog also needs a managed
57
56
  * plane. Either missing throws `UnsupportedError`.
58
57
  */
59
58
  export class Filters {
@@ -63,17 +62,10 @@ export class Filters {
63
62
  this.transport = transport;
64
63
  this.capabilities = capabilities;
65
64
  }
66
- /** A filtered reader over `stream` / `topic`. */
67
- reader(stream, topic) {
68
- return new FilteredReaderBuilder(this.transport, this.capabilities, this, { stream, topic });
69
- }
70
- /** Validate and compile `filter` on the server without running it. */
71
- async validate(filter) {
72
- validateConsumerFilter(filter);
73
- const outcome = await this.native(FilterValidateCommand, filter);
74
- if (outcome.kind === "validated")
75
- return outcome.validation;
76
- throw unexpected("validate");
65
+ /** Throws `UnsupportedError` unless the group filter catalog is served. */
66
+ async requireCatalog() {
67
+ const capabilities = await this.capabilities();
68
+ requireManagedCommand(capabilities, FilterMutateCommand);
77
69
  }
78
70
  /** Evaluate a filter against one supplied record and explain the verdict. */
79
71
  async test(filter, payload, headers = []) {
@@ -107,42 +99,48 @@ export class Filters {
107
99
  return outcome.preview;
108
100
  throw unexpected("preview");
109
101
  }
110
- /** One saved filter with its latest revision and bindings. */
111
- async get(filterId) {
112
- const outcome = await this.catalog(GetFilterCommand, { v: FILTER_OP_VERSION, filterId });
113
- if (outcome.kind === "filter")
114
- return outcome.detail;
115
- throw unexpected("get");
116
- }
117
- /** One page of saved filters, newest first. */
118
- async list(options = {}) {
102
+ /** One page of a filter's revisions, newest first. */
103
+ async revisions(filterId, options = {}) {
119
104
  const request = {
120
105
  v: FILTER_OP_VERSION,
121
- ...(options.nameContains !== undefined ? { nameContains: options.nameContains } : {}),
122
- ...(options.state !== undefined ? { state: options.state } : {}),
123
- ...(options.beforeId !== undefined ? { beforeId: options.beforeId } : {}),
106
+ filterId,
124
107
  page: options.page ?? 0,
125
108
  pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE
126
109
  };
127
110
  validateCatalogPage(request.page, request.pageSize);
128
- const outcome = await this.catalog(ListFiltersCommand, request);
129
- if (outcome.kind === "filters")
111
+ const outcome = await this.catalog(ListFilterRevisionsCommand, request);
112
+ if (outcome.kind === "revisions")
130
113
  return outcome.page;
131
- throw unexpected("list");
114
+ throw unexpected("revisions");
132
115
  }
133
- /** One page of a filter's revisions, newest first. */
134
- async revisions(filterId, options = {}) {
116
+ /**
117
+ * One page of saved filters whose name contains `nameContains`, newest first.
118
+ *
119
+ * @internal
120
+ */
121
+ async list(nameContains, options = {}) {
135
122
  const request = {
136
123
  v: FILTER_OP_VERSION,
137
- filterId,
124
+ nameContains,
138
125
  page: options.page ?? 0,
139
126
  pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE
140
127
  };
141
128
  validateCatalogPage(request.page, request.pageSize);
142
- const outcome = await this.catalog(ListFilterRevisionsCommand, request);
143
- if (outcome.kind === "revisions")
129
+ const outcome = await this.catalog(ListFiltersCommand, request);
130
+ if (outcome.kind === "filters")
144
131
  return outcome.page;
145
- throw unexpected("revisions");
132
+ throw unexpected("filters");
133
+ }
134
+ /**
135
+ * Drop a saved filter. The catalog releases every group bound to it.
136
+ *
137
+ * @internal
138
+ */
139
+ async dropFilter(filterId) {
140
+ const applied = await this.apply({ kind: "drop", filterId });
141
+ if (applied.result.kind === "dropped")
142
+ return applied.catalogPosition;
143
+ throw unexpected("drop");
146
144
  }
147
145
  /** The binding of one consumer group. Throws with reason `not_found` when it is unbound. */
148
146
  async binding(group) {
@@ -175,119 +173,80 @@ export class Filters {
175
173
  operationId
176
174
  });
177
175
  if (outcome.kind === "mutation")
178
- return outcome.outcome;
176
+ return checkedMutationOutcome(operationId, outcome.outcome);
179
177
  throw unexpected("operation");
180
178
  }
181
- /** Save a new filter as revision 1. */
182
- async register(name, filter, options = {}) {
183
- const result = await this.apply({
184
- kind: "register",
185
- name,
186
- description: options.description ?? "",
187
- filter
188
- });
189
- if (result.kind === "registered")
190
- return result.revision;
191
- throw unexpected("register");
192
- }
193
179
  /** Add a revision. `expectedRevision` must still be the latest one. */
194
180
  async revise(filterId, expectedRevision, filter) {
195
- const result = await this.apply({ kind: "revise", filterId, expectedRevision, filter });
196
- if (result.kind === "revised")
197
- return result.revision;
181
+ const applied = await this.apply({ kind: "revise", filterId, expectedRevision, filter });
182
+ if (applied.result.kind === "revised")
183
+ return applied.result.revision;
198
184
  throw unexpected("revise");
199
185
  }
200
- /** Replace a filter's description. */
201
- async describe(filterId, description) {
202
- await this.apply({ kind: "describe", filterId, description });
203
- }
204
- /** Hide a filter from new bindings. Existing bindings keep executing. */
205
- async archive(filterId) {
206
- await this.apply({ kind: "archive", filterId });
207
- }
208
- /** Delete a filter. Throws with reason `conflict` while a consumer group is bound to it. */
209
- async delete(filterId) {
210
- await this.apply({ kind: "drop", filterId });
211
- }
212
- /** Pin an existing consumer group to one revision. */
213
- async bind(group, filterId, revision) {
214
- const result = await this.apply({ kind: "bind", group, filterId, revision });
215
- if (result.kind === "bound")
216
- return result.binding;
217
- throw unexpected("bind");
218
- }
219
- /** Release a consumer group while it is still bound to `expectedDigest`. */
220
- async unbind(group, expectedDigest) {
221
- const result = await this.apply({ kind: "unbind", group, expectedDigest });
222
- if (result.kind === "unbound")
223
- return result.binding;
224
- throw unexpected("unbind");
186
+ /** Pause or resume a revision without changing its executable content or digest. */
187
+ async setRevisionEnabled(filterId, revision, enabled) {
188
+ await this.apply({ kind: "set_revision_enabled", filterId, revision, enabled });
225
189
  }
226
- /** Release this exact saved binding, even after its group name was reused. */
227
- async unbindBinding(binding) {
228
- const result = await this.apply({
190
+ /**
191
+ * Give `group` its policy in one catalog transaction: a definition saved as
192
+ * the group's own filter, or one of its existing revisions. A repeat with
193
+ * the same digest keeps the binding, another digest conflicts. The returned
194
+ * position is where the fold applied it, carried by the group's reads so
195
+ * they never see this configuration as absent.
196
+ */
197
+ async configureGroup(group, policy, operationId = mintUlidValue(), expectedIdentity) {
198
+ const identity = expectedIdentity ?? (await nativeGroup(this.transport, group, group.group)).identity;
199
+ const applied = await this.apply({ kind: "configure_group", group, policy, expectedIdentity: identity }, operationId);
200
+ if (applied.result.kind === "bound")
201
+ return bound(applied.result.binding, applied);
202
+ throw unexpected("configure_group");
203
+ }
204
+ /**
205
+ * Release this exact saved binding, including a group incarnation that was
206
+ * deleted and recreated under the same name.
207
+ */
208
+ async releaseGroup(binding) {
209
+ const applied = await this.apply({
229
210
  kind: "unbind",
230
211
  group: binding.group,
231
212
  expectedDigest: binding.digest,
232
213
  expectedIdentity: binding.identity
233
214
  });
234
- if (result.kind === "unbound")
235
- return result.binding;
215
+ if (applied.result.kind === "unbound")
216
+ return bound(applied.result.binding, applied);
236
217
  throw unexpected("unbind");
237
218
  }
238
- /**
239
- * Submit a mutation under a new operation id and wait for its authoritative
240
- * outcome. A lost reply is retried under the same id, which the catalog
241
- * answers with the first attempt's outcome. A rejection throws
242
- * `FilterExecutionError`. An outcome still pending after
243
- * {@link DEFAULT_OUTCOME_WAIT_MS} throws `AmbiguousMutationError` naming the
244
- * operation id.
245
- */
246
- async apply(mutation) {
247
- return this.applyAs(mintUlidValue(), mutation);
248
- }
249
- /**
250
- * {@link Filters.apply} under a caller-chosen `operationId`, so a caller that
251
- * records the id first can resume the same mutation after a crash.
252
- */
253
- async applyAs(operationId, mutation) {
254
- const outcome = await this.submit(operationId, mutation);
255
- if (outcome.status.kind === "pending") {
256
- return this.waitForOutcome(operationId, DEFAULT_OUTCOME_WAIT_MS);
257
- }
258
- return settled(operationId, outcome.status);
259
- }
260
- /** Create the group if absent, then bind it. A failed bind can leave an unbound group. */
261
- async createConsumerGroup(group, filterId, revision) {
262
- const capabilities = await this.capabilities();
263
- requireCapability(capabilities, "filters");
264
- requireManagedCommand(capabilities, FilterMutateCommand);
265
- if (this.transport.ensureConsumerGroup === undefined)
266
- throw new ConfigError("this transport cannot create consumer groups");
267
- await this.transport.ensureConsumerGroup(group.stream, group.topic, group.group);
268
- return this.bind(group, filterId, revision);
269
- }
270
- /** Pause or resume a revision without changing its executable content or digest. */
271
- async setRevisionEnabled(filterId, revision, enabled) {
272
- await this.apply({ kind: "set_revision_enabled", filterId, revision, enabled });
273
- }
274
219
  /** Send one mutation under a caller-chosen `operationId`. The outcome may be pending. */
275
220
  async mutate(operationId, mutation) {
276
221
  const request = { v: FILTER_OP_VERSION, operationId, mutation };
277
222
  validateFilterMutationRequest(request);
278
223
  const outcome = await this.catalog(FilterMutateCommand, request);
279
224
  if (outcome.kind === "mutation")
280
- return outcome.outcome;
225
+ return checkedMutationOutcome(operationId, outcome.outcome);
281
226
  throw unexpected("mutate");
282
227
  }
283
- /** Wait for the outcome of `operationId` for at most `timeoutMs`. */
228
+ // Submit a mutation and wait for its authoritative outcome. A lost reply is
229
+ // retried under the same id, which the catalog answers with the first
230
+ // attempt's outcome. A rejection throws `FilterExecutionError`. An outcome
231
+ // still pending after `DEFAULT_OUTCOME_WAIT_MS` throws
232
+ // `AmbiguousMutationError` naming the operation id.
233
+ async apply(mutation, operationId = mintUlidValue()) {
234
+ const submitted = await this.submit(operationId, mutation);
235
+ const outcome = submitted.status.kind === "pending"
236
+ ? await this.waitForOutcome(operationId, DEFAULT_OUTCOME_WAIT_MS)
237
+ : submitted;
238
+ return {
239
+ result: settled(operationId, outcome.status),
240
+ ...(outcome.catalogPosition !== undefined ? { catalogPosition: outcome.catalogPosition } : {})
241
+ };
242
+ }
284
243
  async waitForOutcome(operationId, timeoutMs) {
285
244
  const deadline = Date.now() + timeoutMs;
286
245
  for (;;) {
287
246
  try {
288
247
  const outcome = await this.operation(operationId);
289
248
  if (outcome.status.kind !== "pending")
290
- return settled(operationId, outcome.status);
249
+ return outcome;
291
250
  }
292
251
  catch (error) {
293
252
  if (!isNotYetApplied(error))
@@ -327,76 +286,55 @@ export class Filters {
327
286
  throw filterError(reply.error);
328
287
  }
329
288
  }
330
- /** Builds a {@link FilteredReader}. Start from `Filters.reader`. */
289
+ function checkedMutationOutcome(operationId, outcome) {
290
+ if (outcome.v !== FILTER_OP_VERSION ||
291
+ outcome.operationId !== operationId ||
292
+ (outcome.catalogPosition?.operationId !== undefined &&
293
+ outcome.catalogPosition.operationId !== operationId)) {
294
+ throw new ProtocolError("filter mutation outcome does not match the requested operation");
295
+ }
296
+ return outcome;
297
+ }
298
+ function bound(binding, applied) {
299
+ return {
300
+ binding,
301
+ ...(applied.catalogPosition !== undefined ? { catalogPosition: applied.catalogPosition } : {})
302
+ };
303
+ }
304
+ /** Builds a {@link FilteredReader} over one consumer group. Start from `ConsumerGroup.reader()`. */
331
305
  export class FilteredReaderBuilder {
332
306
  transport;
333
307
  capabilities;
334
308
  filters;
335
309
  source;
336
- consumerName;
337
- partitionIds = [];
338
- filterRef;
310
+ consumer;
311
+ filter;
312
+ minCatalogPosition;
339
313
  startAt = { kind: "next" };
340
314
  pageCount = DEFAULT_COUNT;
315
+ examinedBudget;
341
316
  replyBytes = DEFAULT_MAX_REPLY_BYTES;
342
317
  mode = "primary";
343
318
  guarded = false;
344
319
  idleMs = DEFAULT_IDLE_INTERVAL_MS;
345
320
  maxUnacked = DEFAULT_MAX_UNACKED_PAGES;
346
- constructor(transport, capabilities, filters, source) {
321
+ selectedPartitions = new Set();
322
+ constructor(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
347
323
  this.transport = transport;
348
324
  this.capabilities = capabilities;
349
325
  this.filters = filters;
350
326
  this.source = source;
327
+ this.consumer = consumer;
328
+ this.filter = filter;
329
+ this.minCatalogPosition = minCatalogPosition;
351
330
  }
352
- /** Read as the independent consumer `name`. */
353
- consumer(name) {
354
- this.consumerName = { kind: "consumer", name };
355
- return this;
356
- }
357
- /**
358
- * Read as a member of the existing consumer group `name`. A reader built
359
- * from a connection string joins over its own connection, so every reader
360
- * is its own member.
361
- */
362
- group(name) {
363
- this.consumerName = { kind: "group", name };
364
- return this;
365
- }
366
- /** Read the existing group by its native numeric identity. */
367
- groupId(id) {
368
- if (typeof id === "number" && !Number.isSafeInteger(id))
369
- throw new InvalidError("consumer group id must be an integer");
370
- const value = BigInt(id);
371
- if (value < 0n || value > 0xffffffffn)
372
- throw new InvalidError("consumer group id exceeds 32 bits");
373
- this.consumerName = { kind: "group_id", id: value };
374
- return this;
375
- }
376
- /**
377
- * Read only `partitionId`. Repeat for more. An independent consumer reads
378
- * every partition when none is named, including partitions added later.
379
- */
380
- partition(partitionId) {
381
- this.partitionIds.push(partitionId);
382
- return this;
383
- }
384
- /** Which filter runs. A group defaults to its bound revision. */
385
- filter(filter) {
386
- this.filterRef = filter;
387
- return this;
388
- }
389
- /** Run `filter` from the request. Needs no catalog. */
390
- inline(filter) {
391
- return this.filter({ kind: "inline", filter });
392
- }
393
- /** Run a saved revision. */
394
- revision(filterId, revision) {
395
- return this.filter({ kind: "revision", filterId, revision });
331
+ /** @internal */
332
+ static create(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
333
+ return new FilteredReaderBuilder(transport, capabilities, filters, source, consumer, filter, minCatalogPosition);
396
334
  }
397
335
  /**
398
336
  * Where each partition read at the build starts. Defaults to after the
399
- * stored offset. A partition a group hands this reader later always resumes
337
+ * stored offset. A partition the group hands this reader later always resumes
400
338
  * after the group's stored offset, so the previous owner's unacknowledged
401
339
  * backlog is read, not skipped.
402
340
  */
@@ -404,11 +342,29 @@ export class FilteredReaderBuilder {
404
342
  this.startAt = start;
405
343
  return this;
406
344
  }
345
+ /** Read this partition only while the group assigns it to this member. Repeat for several. */
346
+ partition(partitionId) {
347
+ if (!Number.isSafeInteger(partitionId) || partitionId < 0 || partitionId > 0xffff_ffff) {
348
+ throw new InvalidError("partition must be an unsigned 32-bit integer");
349
+ }
350
+ this.selectedPartitions.add(partitionId);
351
+ return this;
352
+ }
407
353
  /** Most matching records per page. */
408
354
  count(count) {
409
355
  this.pageCount = count;
410
356
  return this;
411
357
  }
358
+ /**
359
+ * Most source records one page examines, independent of `count`. Without it
360
+ * the server's own budget bounds the scan. A page that examines its budget
361
+ * without a match returns empty and the reader continues from where it
362
+ * stopped.
363
+ */
364
+ maxExamined(records) {
365
+ this.examinedBudget = records;
366
+ return this;
367
+ }
412
368
  /** Most record bytes per page. The server may lower it. */
413
369
  maxReplyBytes(bytes) {
414
370
  this.replyBytes = bytes;
@@ -447,92 +403,67 @@ export class FilteredReaderBuilder {
447
403
  }
448
404
  async build() {
449
405
  const capabilities = await this.capabilities();
450
- if (!capabilities.filters.native) {
451
- throw new UnsupportedError("consumer filters are not served by this server");
406
+ if (this.filter.kind === "group" && !capabilities.filters.groupPolicyReads) {
407
+ throw new UnsupportedError("this server serves consumer filters but not group-aware reads, upgrade it before reading groups through the Laser SDK");
452
408
  }
453
- const evaluation = capabilities.filters.evaluation;
454
- if (this.filterRef?.kind === "inline" &&
455
- evaluation !== undefined &&
456
- !filterAnnounceEvaluates(evaluation, this.filterRef.filter.evaluatorVersion, this.filterRef.filter.codec)) {
457
- throw new UnsupportedError(`this server does not evaluate ${this.filterRef.filter.codec} filters of evaluator version ${String(this.filterRef.filter.evaluatorVersion)}`);
409
+ if (this.filter.kind !== "group" && !capabilities.filters.native) {
410
+ throw new UnsupportedError("consumer filters are not served by this server");
458
411
  }
459
412
  if (this.mode === "primary" && this.transport.connectsNodes !== true) {
460
- throw new ConfigError("primary filtered reads open data connections, so they need a Laser connected from a connection string. Read in local mode for an injected client");
461
- }
462
- const consumer = this.consumerName;
463
- if (consumer === undefined) {
464
- throw new ConfigError("a filtered reader needs .consumer(name), .group(name), or .groupId(id)");
465
- }
466
- const filter = this.filterRef ?? (consumer.kind !== "consumer" ? { kind: "bound" } : undefined);
467
- if (filter === undefined) {
468
- throw new ConfigError("an independent filtered reader needs .inline(..), .revision(..), or .filter(..)");
413
+ throw new ConfigError("primary group reads open data connections, so they need a Laser connected from a connection string. Read in local mode for an injected client");
469
414
  }
415
+ const consumer = this.consumer;
470
416
  const request = {
471
417
  v: FILTER_OP_VERSION,
472
418
  source: this.source,
473
419
  partitionId: 0,
474
420
  consumer,
475
- filter,
421
+ filter: this.filter,
476
422
  start: this.startAt,
477
423
  count: this.pageCount,
478
424
  maxReplyBytes: this.replyBytes,
479
- readMode: this.mode
425
+ readMode: this.mode,
426
+ ...(this.examinedBudget !== undefined ? { maxExamined: this.examinedBudget } : {}),
427
+ ...(this.minCatalogPosition !== undefined
428
+ ? { minCatalogPosition: this.minCatalogPosition }
429
+ : {})
480
430
  };
481
431
  validateFilteredPollRequest(request);
482
- const guard = this.guarded && filter.kind !== "bound"
483
- ? await compileGuard(this.transport, await definition(this.filters, filter))
484
- : undefined;
485
- if (consumer.kind !== "consumer" && this.partitionIds.length > 0) {
486
- throw new ConfigError("a group reader reads the partitions the group assigns it");
487
- }
488
432
  const pinnedSource = consumer.kind === "group_id"
489
433
  ? await sourceIncarnation(this.transport, this.source)
490
434
  : undefined;
491
- const coordinator = consumer.kind !== "consumer" &&
492
- this.transport.connectsNodes === true &&
493
- this.transport.openCoordinator !== undefined
435
+ // A group reader owns the connection that holds its membership, so two
436
+ // readers are two members. An injected client has only the shared one.
437
+ const coordinator = this.transport.connectsNodes === true && this.transport.openCoordinator !== undefined
494
438
  ? await this.transport.openCoordinator()
495
439
  : sharedCoordinator(this.transport);
496
440
  let membership;
497
- let partitionIds;
498
- let discovers = false;
499
441
  try {
500
- if (consumer.kind !== "consumer") {
501
- membership = await Membership.join(coordinator, this.source, consumer.kind === "group_id" ? Number(consumer.id) : consumer.name, this.transport, pinnedSource);
502
- partitionIds = membership.partitions;
503
- }
504
- else if (this.partitionIds.length > 0) {
505
- partitionIds = this.partitionIds;
506
- }
507
- else {
508
- partitionIds = await topicPartitions(this.transport, this.source);
509
- discovers = true;
510
- }
442
+ membership = await Membership.join(coordinator, this.source, consumer.kind === "group_id" ? Number(consumer.id) : consumer.name, this.transport, pinnedSource);
511
443
  }
512
444
  catch (error) {
513
445
  await coordinator.close();
514
446
  throw error;
515
447
  }
516
- return new FilteredReader({
448
+ return FilteredReader.create({
517
449
  transport: this.transport,
518
450
  ...(pinnedSource === undefined ? {} : { pinnedSource }),
519
451
  filters: this.filters,
520
452
  coordinator,
521
453
  request,
522
- inlineDigest: filter.kind === "inline" ? consumerFilterDigest(filter.filter) : undefined,
523
454
  start: this.startAt,
524
455
  idleIntervalMs: this.idleMs,
525
456
  maxUnackedPages: this.maxUnacked,
526
- guard,
457
+ guard: undefined,
527
458
  guardEnabled: this.guarded,
528
459
  membership,
529
- discovers,
530
- partitionIds
460
+ selectedPartitions: new Set(this.selectedPartitions),
461
+ partitionIds: membership.partitions.filter((partition) => this.selectedPartitions.size === 0 || this.selectedPartitions.has(partition))
531
462
  });
532
463
  }
533
464
  }
534
465
  /**
535
- * Reads only the records a consumer filter selects, with their original
466
+ * Reads the records a consumer group's policy selects, with their original
536
467
  * offsets, and stores progress through fenced acknowledgments. Pages are read
537
468
  * ahead of acknowledgments, and a page's safe offset is stored only after it
538
469
  * and every earlier page of the partition are fully handled. Pages that
@@ -554,7 +485,6 @@ export class FilteredReader {
554
485
  sequence = 0;
555
486
  owner = {};
556
487
  retiredOwners = new WeakSet();
557
- listedAt;
558
488
  closed = false;
559
489
  deferredError;
560
490
  pinnedSource;
@@ -565,11 +495,14 @@ export class FilteredReader {
565
495
  this.routes = new Routes(settings.transport, settings.coordinator);
566
496
  this.guard = settings.guard;
567
497
  this.pinnedSource = settings.pinnedSource;
568
- this.listedAt = settings.discovers ? performance.now() : undefined;
569
498
  for (const partitionId of settings.partitionIds) {
570
499
  this.progress.set(partitionId, new PartitionProgress(settings.start));
571
500
  }
572
501
  }
502
+ /** @internal */
503
+ static create(settings) {
504
+ return new FilteredReader(settings);
505
+ }
573
506
  /** The next page with at least one match. Waits while nothing is new, up to `timeoutMs` when given. */
574
507
  async nextPage(options = {}) {
575
508
  const deadline = options.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
@@ -641,6 +574,43 @@ export class FilteredReader {
641
574
  this.partitionProgress(page.partitionId).completePage(sequence);
642
575
  await this.flush(page.partitionId);
643
576
  }
577
+ /**
578
+ * Mark this partition's records through `record` handled without storing
579
+ * yet. The normal consumer delivers in order, so every delivery completes
580
+ * the prefix through it, and it stores that prefix on its own cadence
581
+ * through `flushCompleted`.
582
+ *
583
+ * @internal
584
+ */
585
+ handled(record) {
586
+ const tag = record[READER_TAG];
587
+ if (tag?.owner !== this.owner)
588
+ return;
589
+ this.progress.get(record.partitionId)?.completeThrough(tag.sequence, record.offset);
590
+ }
591
+ /**
592
+ * Store every completed prefix now.
593
+ *
594
+ * @internal
595
+ */
596
+ async flushCompleted() {
597
+ if (this.settings.request.readMode !== "primary")
598
+ return;
599
+ let failure;
600
+ for (const partitionId of [...this.progress.keys()]) {
601
+ try {
602
+ await this.flush(partitionId);
603
+ }
604
+ catch (error) {
605
+ failure ??= error;
606
+ }
607
+ }
608
+ if (failure !== undefined) {
609
+ throw failure instanceof Error
610
+ ? failure
611
+ : new TransportError("storing group progress failed", true, { cause: failure });
612
+ }
613
+ }
644
614
  /** The partitions this reader reads now. */
645
615
  partitions() {
646
616
  return [...this.progress.keys()].filter((partitionId) => !this.revoked.has(partitionId));
@@ -725,9 +695,6 @@ export class FilteredReader {
725
695
  }
726
696
  if (this.settings.membership?.isDue() === true)
727
697
  await this.refreshMembership();
728
- if (this.listedAt !== undefined && performance.now() - this.listedAt >= ASSIGNMENT_REFRESH_MS) {
729
- await this.refreshPartitions();
730
- }
731
698
  const readable = this.partitions();
732
699
  if (readable.length === 0)
733
700
  return { kind: "idle" };
@@ -796,6 +763,13 @@ export class FilteredReader {
796
763
  return { kind: "empty", more: false };
797
764
  }
798
765
  this.restartAfter(partitionId, error);
766
+ // The group's policy changed under this read. The partition restarted
767
+ // from its stored offset and is read again at once under the policy the
768
+ // group holds now. Only an explicit acknowledgment of a record read
769
+ // under the old policy reports the change.
770
+ if (error instanceof FilterExecutionError && error.reason === "conflict") {
771
+ return { kind: "empty", more: true };
772
+ }
799
773
  throw error;
800
774
  }
801
775
  this.requireOpen();
@@ -811,7 +785,7 @@ export class FilteredReader {
811
785
  if (recordHead.getUint32(12, true) !== page.matched || page.matched > request.count)
812
786
  throw new ProtocolError("filtered record body has invalid counts");
813
787
  const messages = decodePolledBody(page.records);
814
- validatePage(request, this.settings.inlineDigest, page, messages);
788
+ validatePage(request, page, messages);
815
789
  if (request.consumer.kind === "group_id") {
816
790
  const pinned = this.pinnedSource;
817
791
  const current = page.generation;
@@ -828,16 +802,19 @@ export class FilteredReader {
828
802
  this.pinnedSource ??= current;
829
803
  }
830
804
  const unevaluated = new Set(page.unevaluated);
831
- if (this.settings.guardEnabled && this.guard === undefined) {
832
- this.guard = await compileGuard(this.settings.transport, await boundDefinition(this.settings.filters, request.source, page));
833
- }
834
- if (this.guard !== undefined) {
835
- try {
836
- checkGuard(this.guard, page, messages, unevaluated);
805
+ // An unfiltered page ran no filter, so there is nothing to check.
806
+ if (page.policy.mode === "filtered") {
807
+ if (this.settings.guardEnabled && this.guard === undefined) {
808
+ this.guard = await compileGuard(this.settings.transport, await boundDefinition(this.settings.filters, request.source, page));
837
809
  }
838
- catch (error) {
839
- this.restartAfter(partitionId, error);
840
- throw error;
810
+ if (this.guard !== undefined) {
811
+ try {
812
+ checkGuard(this.guard, page, messages, unevaluated);
813
+ }
814
+ catch (error) {
815
+ this.restartAfter(partitionId, error);
816
+ throw error;
817
+ }
841
818
  }
842
819
  }
843
820
  this.sequence += 1;
@@ -869,7 +846,7 @@ export class FilteredReader {
869
846
  partitionId,
870
847
  offset: message.offset,
871
848
  frontier: page.frontier,
872
- evaluated: !unevaluated.has(message.offset),
849
+ evaluated: page.policy.mode === "filtered" && !unevaluated.has(message.offset),
873
850
  payload: message.payload,
874
851
  headers: message.headers,
875
852
  headersMalformed: message.headersMalformed !== undefined,
@@ -907,8 +884,7 @@ export class FilteredReader {
907
884
  }
908
885
  if (error.reason === "source_changed")
909
886
  this.settings.membership?.expire();
910
- const request = this.settings.request;
911
- if (request.filter.kind === "bound")
887
+ if (this.settings.guardEnabled)
912
888
  this.guard = undefined;
913
889
  }
914
890
  // Store the completed prefix of one partition. A lost reply keeps the target
@@ -927,8 +903,10 @@ export class FilteredReader {
927
903
  partitionId,
928
904
  consumer: request.consumer,
929
905
  generation: target.generation,
930
- digest: target.digest,
931
- offset: target.offset
906
+ ...(target.digest === undefined ? {} : { digest: target.digest }),
907
+ offset: target.offset,
908
+ mode: target.mode,
909
+ policyGeneration: target.policyGeneration
932
910
  };
933
911
  const encoded = encodeFilteredAck(ack);
934
912
  for (let rerouted = false;; rerouted = true) {
@@ -949,6 +927,7 @@ export class FilteredReader {
949
927
  return;
950
928
  }
951
929
  catch (error) {
930
+ this.restartAfter(partitionId, error);
952
931
  if (rerouted || !isRouteLost(error))
953
932
  throw error;
954
933
  }
@@ -988,11 +967,13 @@ export class FilteredReader {
988
967
  if (membership === undefined || !(await membership.sync()))
989
968
  return;
990
969
  this.requireOpen();
991
- const assigned = new Set(membership.partitions);
970
+ const selection = this.settings.selectedPartitions;
971
+ const assigned = new Set(membership.partitions.filter((partition) => selection === undefined || selection.size === 0 || selection.has(partition)));
992
972
  const rejoined = membership.takeRejoined();
993
973
  const stale = rejoined ? this.routes.detach() : [];
994
974
  if (rejoined) {
995
- if (this.settings.request.filter.kind === "bound")
975
+ // A rejoin can land in a recreated group with another binding.
976
+ if (this.settings.guardEnabled)
996
977
  this.guard = undefined;
997
978
  this.retiredOwners.add(this.owner);
998
979
  this.owner = {};
@@ -1022,17 +1003,6 @@ export class FilteredReader {
1022
1003
  for (const partitionId of left)
1023
1004
  await this.routes.release(partitionId);
1024
1005
  }
1025
- // An independent reader of every partition picks up partitions added to
1026
- // the topic since the build, resuming their stored progress.
1027
- async refreshPartitions() {
1028
- const listed = await topicPartitions(this.settings.transport, this.settings.request.source);
1029
- this.listedAt = performance.now();
1030
- for (const partitionId of listed) {
1031
- if (!this.progress.has(partitionId)) {
1032
- this.progress.set(partitionId, new PartitionProgress({ kind: "next" }));
1033
- }
1034
- }
1035
- }
1036
1006
  // Forget a revoked partition once nothing of it is in flight.
1037
1007
  retire(partitionId) {
1038
1008
  const progress = this.progress.get(partitionId);
@@ -1162,7 +1132,9 @@ class PartitionProgress {
1162
1132
  : {
1163
1133
  offset: page.safeAckOffset,
1164
1134
  generation: page.generation,
1165
- digest: page.policy.digest,
1135
+ ...(page.policy.digest === undefined ? {} : { digest: page.policy.digest }),
1136
+ mode: page.policy.mode,
1137
+ policyGeneration: page.policy.policyGeneration,
1166
1138
  ...(page.policy.groupId !== undefined ? { groupId: page.policy.groupId } : {})
1167
1139
  };
1168
1140
  const newest = this.pages.at(-1);
@@ -1374,7 +1346,7 @@ async function attach(open, route) {
1374
1346
  return connection;
1375
1347
  }
1376
1348
  // The shared transport as a coordinator, for a reader that has no connection
1377
- // of its own: an injected client, or an independent reader.
1349
+ // of its own: an injected client.
1378
1350
  function sharedCoordinator(transport) {
1379
1351
  return {
1380
1352
  send: (code, payload) => transport.sendManaged(code, payload),
@@ -1387,10 +1359,6 @@ function sharedCoordinator(transport) {
1387
1359
  close: () => Promise.resolve()
1388
1360
  };
1389
1361
  }
1390
- async function topicPartitions(transport, source) {
1391
- const count = await transport.getTopicPartitionCount(source.stream, source.topic);
1392
- return Array.from({ length: count }, (_, index) => index);
1393
- }
1394
1362
  /** This reader's membership of a consumer group, held by its coordinator connection. */
1395
1363
  class Membership {
1396
1364
  coordinator;
@@ -1507,6 +1475,37 @@ async function sourceIncarnation(transport, source) {
1507
1475
  topicCreatedAtMicros: topic.created
1508
1476
  };
1509
1477
  }
1478
+ /**
1479
+ * Read a consumer group's native id and name, and the stream and topic
1480
+ * incarnations that scope it. Throws with reason `not_found` when the group
1481
+ * does not exist.
1482
+ */
1483
+ export async function nativeGroup(transport, source, group) {
1484
+ const incarnation = await sourceIncarnation(transport, source);
1485
+ const reply = await transport.sendManaged(GET_CONSUMER_GROUP_CODE, concat([identifier(source.stream), identifier(source.topic), identifier(group)]));
1486
+ // The reply begins with id:u32, partitions:u32, members:u32, then the name
1487
+ // as one length byte and its bytes. A missing group answers an empty body.
1488
+ if (reply.byteLength === 0)
1489
+ throw filterError({
1490
+ code: { kind: "known", name: "NotFound" },
1491
+ reason: "not_found",
1492
+ message: `consumer group ${String(group)} does not exist`
1493
+ });
1494
+ if (reply.byteLength < 13)
1495
+ throw new ProtocolError("consumer group metadata is truncated");
1496
+ const view = new DataView(reply.buffer, reply.byteOffset, reply.byteLength);
1497
+ const id = view.getUint32(0, true);
1498
+ if (typeof group === "number" && id !== group)
1499
+ throw new ProtocolError("consumer group metadata identifies another group");
1500
+ const length = view.getUint8(12);
1501
+ if (reply.byteLength < 13 + length)
1502
+ throw new ProtocolError("consumer group metadata is truncated");
1503
+ return {
1504
+ id,
1505
+ name: new TextDecoder().decode(reply.subarray(13, 13 + length)),
1506
+ identity: { ...incarnation, groupId: BigInt(id) }
1507
+ };
1508
+ }
1510
1509
  function identifier(name) {
1511
1510
  if (typeof name === "number") {
1512
1511
  const encoded = new Uint8Array(6);
@@ -1614,7 +1613,7 @@ function sameBytes(left, right) {
1614
1613
  // Re-evaluate every returned record with the shared evaluator before the
1615
1614
  // caller sees it.
1616
1615
  function checkGuard(guard, page, messages, unevaluated) {
1617
- if (!sameBytes(page.policy.digest, guard.digest)) {
1616
+ if (page.policy.digest === undefined || !sameBytes(page.policy.digest, guard.digest)) {
1618
1617
  throw filterError({
1619
1618
  code: { kind: "known", name: "Conflict" },
1620
1619
  reason: "conflict",
@@ -1747,7 +1746,7 @@ async function boundDefinition(filters, requested, page) {
1747
1746
  identity.topicId === source.topicId &&
1748
1747
  identity.topicCreatedAtMicros === source.topicCreatedAtMicros);
1749
1748
  if (binding !== undefined) {
1750
- if (!sameBytes(binding.digest, page.policy.digest))
1749
+ if (page.policy.digest === undefined || !sameBytes(binding.digest, page.policy.digest))
1751
1750
  throw new ProtocolError("served policy differs from the group's catalog binding");
1752
1751
  return definition(filters, {
1753
1752
  kind: "revision",
@@ -1766,8 +1765,8 @@ async function boundDefinition(filters, requested, page) {
1766
1765
  async function definition(filters, filter) {
1767
1766
  if (filter.kind === "inline")
1768
1767
  return filter.filter;
1769
- if (filter.kind === "bound")
1770
- throw new ConfigError("resolve a bound guard from the served page");
1768
+ if (filter.kind === "bound" || filter.kind === "group")
1769
+ throw new ConfigError("resolve a group guard from the served page");
1771
1770
  const { filterId, revision } = filter;
1772
1771
  for (let page = 0;; page += 1) {
1773
1772
  const revisions = await filters.revisions(filterId, { page, pageSize: MAX_FILTER_CATALOG_PAGE });
@@ -1831,7 +1830,7 @@ function sameGeneration(left, right) {
1831
1830
  left.partitionCreatedRevision === right.partitionCreatedRevision &&
1832
1831
  left.purgeGeneration === right.purgeGeneration);
1833
1832
  }
1834
- function validatePage(request, inlineDigest, page, messages) {
1833
+ function validatePage(request, page, messages) {
1835
1834
  const invalid = () => {
1836
1835
  throw new ProtocolError("filtered page does not match its request or scan boundary");
1837
1836
  };
@@ -1844,14 +1843,20 @@ function validatePage(request, inlineDigest, page, messages) {
1844
1843
  page.matched !== messages.length ||
1845
1844
  page.matched > request.count ||
1846
1845
  page.matched > page.examined ||
1847
- page.policy.digest.length !== 32 ||
1846
+ (request.maxExamined !== undefined && page.examined > request.maxExamined) ||
1847
+ (page.policy.mode === "filtered") !== (page.policy.digest !== undefined) ||
1848
+ (page.policy.digest !== undefined && page.policy.digest.length !== 32) ||
1849
+ (page.policy.mode === "unfiltered" && page.policy.groupId === undefined) ||
1850
+ (page.policy.mode === "unfiltered" && page.matched !== page.examined) ||
1851
+ (page.policy.mode === "unfiltered" &&
1852
+ (page.policy.filterId !== undefined || page.policy.revision !== undefined)) ||
1853
+ // A strict group read runs a binding and never accepts an unfiltered page.
1854
+ (page.policy.mode === "unfiltered" && request.filter.kind !== "group") ||
1848
1855
  (request.consumer.kind !== "consumer") !== (page.policy.groupId !== undefined))
1849
1856
  invalid();
1850
1857
  const body = new DataView(page.records.buffer, page.records.byteOffset, page.records.byteLength);
1851
1858
  if (body.getUint32(0, true) !== page.partitionId || body.getBigUint64(4, true) !== page.frontier)
1852
1859
  invalid();
1853
- if (inlineDigest !== undefined && !sameBytes(inlineDigest, page.policy.digest))
1854
- invalid();
1855
1860
  if (request.filter.kind === "revision" &&
1856
1861
  (page.policy.filterId !== request.filter.filterId ||
1857
1862
  page.policy.revision !== request.filter.revision))
@@ -1861,7 +1866,12 @@ function validatePage(request, inlineDigest, page, messages) {
1861
1866
  const cursor = request.start.continuation;
1862
1867
  if (!sameGeneration(cursor.generation, page.generation) ||
1863
1868
  cursor.groupId !== page.policy.groupId ||
1864
- !sameBytes(cursor.digest, page.policy.digest) ||
1869
+ cursor.mode !== page.policy.mode ||
1870
+ cursor.policyGeneration !== page.policy.policyGeneration ||
1871
+ (cursor.digest === undefined) !== (page.policy.digest === undefined) ||
1872
+ (cursor.digest !== undefined &&
1873
+ page.policy.digest !== undefined &&
1874
+ !sameBytes(cursor.digest, page.policy.digest)) ||
1865
1875
  cursor.readMode !== request.readMode)
1866
1876
  invalid();
1867
1877
  start = cursor.nextScanOffset;