@laserdata/laser-sdk 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) 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/iggy/apache-iggy.d.ts +3 -0
  24. package/dist/iggy/apache-iggy.d.ts.map +1 -1
  25. package/dist/iggy/apache-iggy.js +6 -1
  26. package/dist/iggy/apache-iggy.js.map +1 -1
  27. package/dist/index.d.ts +6 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/managed/filters.d.ts +57 -128
  32. package/dist/managed/filters.d.ts.map +1 -1
  33. package/dist/managed/filters.js +292 -271
  34. package/dist/managed/filters.js.map +1 -1
  35. package/dist/stream/consumer-group.d.ts +168 -0
  36. package/dist/stream/consumer-group.d.ts.map +1 -0
  37. package/dist/stream/consumer-group.js +411 -0
  38. package/dist/stream/consumer-group.js.map +1 -0
  39. package/dist/stream/consumer.d.ts +38 -5
  40. package/dist/stream/consumer.d.ts.map +1 -1
  41. package/dist/stream/consumer.js +161 -37
  42. package/dist/stream/consumer.js.map +1 -1
  43. package/dist/stream/producer-statistics.d.ts +22 -0
  44. package/dist/stream/producer-statistics.d.ts.map +1 -0
  45. package/dist/stream/producer-statistics.js +214 -0
  46. package/dist/stream/producer-statistics.js.map +1 -0
  47. package/dist/stream/producer.d.ts +2 -0
  48. package/dist/stream/producer.d.ts.map +1 -1
  49. package/dist/stream/producer.js +16 -0
  50. package/dist/stream/producer.js.map +1 -1
  51. package/dist/stream/stream.d.ts +3 -1
  52. package/dist/stream/stream.d.ts.map +1 -1
  53. package/dist/stream/stream.js +4 -2
  54. package/dist/stream/stream.js.map +1 -1
  55. package/dist/stream/topic.d.ts +19 -3
  56. package/dist/stream/topic.d.ts.map +1 -1
  57. package/dist/stream/topic.js +29 -6
  58. package/dist/stream/topic.js.map +1 -1
  59. package/dist/wire/filter.d.ts +66 -3
  60. package/dist/wire/filter.d.ts.map +1 -1
  61. package/dist/wire/filter.js +195 -27
  62. package/dist/wire/filter.js.map +1 -1
  63. package/dist/wire/hello.d.ts +1 -0
  64. package/dist/wire/hello.d.ts.map +1 -1
  65. package/dist/wire/hello.js +2 -1
  66. package/dist/wire/hello.js.map +1 -1
  67. package/dist/wire/http.d.ts +1 -0
  68. package/dist/wire/http.d.ts.map +1 -1
  69. package/dist/wire/http.js +2 -0
  70. package/dist/wire/http.js.map +1 -1
  71. package/dist/wire/limits.d.ts +2 -0
  72. package/dist/wire/limits.d.ts.map +1 -1
  73. package/dist/wire/limits.js +2 -0
  74. package/dist/wire/limits.js.map +1 -1
  75. 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,49 @@ 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 = {}, beforeId) {
135
122
  const request = {
136
123
  v: FILTER_OP_VERSION,
137
- filterId,
124
+ nameContains,
125
+ ...(beforeId !== undefined ? { beforeId } : {}),
138
126
  page: options.page ?? 0,
139
127
  pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE
140
128
  };
141
129
  validateCatalogPage(request.page, request.pageSize);
142
- const outcome = await this.catalog(ListFilterRevisionsCommand, request);
143
- if (outcome.kind === "revisions")
130
+ const outcome = await this.catalog(ListFiltersCommand, request);
131
+ if (outcome.kind === "filters")
144
132
  return outcome.page;
145
- throw unexpected("revisions");
133
+ throw unexpected("filters");
134
+ }
135
+ /**
136
+ * Drop a saved filter. The catalog releases every group bound to it.
137
+ *
138
+ * @internal
139
+ */
140
+ async dropFilter(filterId) {
141
+ const applied = await this.apply({ kind: "drop", filterId });
142
+ if (applied.result.kind === "dropped")
143
+ return applied.catalogPosition;
144
+ throw unexpected("drop");
146
145
  }
147
146
  /** The binding of one consumer group. Throws with reason `not_found` when it is unbound. */
148
147
  async binding(group) {
@@ -175,119 +174,80 @@ export class Filters {
175
174
  operationId
176
175
  });
177
176
  if (outcome.kind === "mutation")
178
- return outcome.outcome;
177
+ return checkedMutationOutcome(operationId, outcome.outcome);
179
178
  throw unexpected("operation");
180
179
  }
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
180
  /** Add a revision. `expectedRevision` must still be the latest one. */
194
181
  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;
182
+ const applied = await this.apply({ kind: "revise", filterId, expectedRevision, filter });
183
+ if (applied.result.kind === "revised")
184
+ return applied.result.revision;
198
185
  throw unexpected("revise");
199
186
  }
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");
187
+ /** Pause or resume a revision without changing its executable content or digest. */
188
+ async setRevisionEnabled(filterId, revision, enabled) {
189
+ await this.apply({ kind: "set_revision_enabled", filterId, revision, enabled });
225
190
  }
226
- /** Release this exact saved binding, even after its group name was reused. */
227
- async unbindBinding(binding) {
228
- const result = await this.apply({
191
+ /**
192
+ * Give `group` its policy in one catalog transaction: a definition saved as
193
+ * the group's own filter, or one of its existing revisions. A repeat with
194
+ * the same digest keeps the binding, another digest conflicts. The returned
195
+ * position is where the fold applied it, carried by the group's reads so
196
+ * they never see this configuration as absent.
197
+ */
198
+ async configureGroup(group, policy, operationId = mintUlidValue(), expectedIdentity) {
199
+ const identity = expectedIdentity ?? (await nativeGroup(this.transport, group, group.group)).identity;
200
+ const applied = await this.apply({ kind: "configure_group", group, policy, expectedIdentity: identity }, operationId);
201
+ if (applied.result.kind === "bound")
202
+ return bound(applied.result.binding, applied);
203
+ throw unexpected("configure_group");
204
+ }
205
+ /**
206
+ * Release this exact saved binding, including a group incarnation that was
207
+ * deleted and recreated under the same name.
208
+ */
209
+ async releaseGroup(binding) {
210
+ const applied = await this.apply({
229
211
  kind: "unbind",
230
212
  group: binding.group,
231
213
  expectedDigest: binding.digest,
232
214
  expectedIdentity: binding.identity
233
215
  });
234
- if (result.kind === "unbound")
235
- return result.binding;
216
+ if (applied.result.kind === "unbound")
217
+ return bound(applied.result.binding, applied);
236
218
  throw unexpected("unbind");
237
219
  }
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
220
  /** Send one mutation under a caller-chosen `operationId`. The outcome may be pending. */
275
221
  async mutate(operationId, mutation) {
276
222
  const request = { v: FILTER_OP_VERSION, operationId, mutation };
277
223
  validateFilterMutationRequest(request);
278
224
  const outcome = await this.catalog(FilterMutateCommand, request);
279
225
  if (outcome.kind === "mutation")
280
- return outcome.outcome;
226
+ return checkedMutationOutcome(operationId, outcome.outcome);
281
227
  throw unexpected("mutate");
282
228
  }
283
- /** Wait for the outcome of `operationId` for at most `timeoutMs`. */
229
+ // Submit a mutation and wait for its authoritative outcome. A lost reply is
230
+ // retried under the same id, which the catalog answers with the first
231
+ // attempt's outcome. A rejection throws `FilterExecutionError`. An outcome
232
+ // still pending after `DEFAULT_OUTCOME_WAIT_MS` throws
233
+ // `AmbiguousMutationError` naming the operation id.
234
+ async apply(mutation, operationId = mintUlidValue()) {
235
+ const submitted = await this.submit(operationId, mutation);
236
+ const outcome = submitted.status.kind === "pending"
237
+ ? await this.waitForOutcome(operationId, DEFAULT_OUTCOME_WAIT_MS)
238
+ : submitted;
239
+ return {
240
+ result: settled(operationId, outcome.status),
241
+ ...(outcome.catalogPosition !== undefined ? { catalogPosition: outcome.catalogPosition } : {})
242
+ };
243
+ }
284
244
  async waitForOutcome(operationId, timeoutMs) {
285
245
  const deadline = Date.now() + timeoutMs;
286
246
  for (;;) {
287
247
  try {
288
248
  const outcome = await this.operation(operationId);
289
249
  if (outcome.status.kind !== "pending")
290
- return settled(operationId, outcome.status);
250
+ return outcome;
291
251
  }
292
252
  catch (error) {
293
253
  if (!isNotYetApplied(error))
@@ -327,76 +287,55 @@ export class Filters {
327
287
  throw filterError(reply.error);
328
288
  }
329
289
  }
330
- /** Builds a {@link FilteredReader}. Start from `Filters.reader`. */
290
+ function checkedMutationOutcome(operationId, outcome) {
291
+ if (outcome.v !== FILTER_OP_VERSION ||
292
+ outcome.operationId !== operationId ||
293
+ (outcome.catalogPosition?.operationId !== undefined &&
294
+ outcome.catalogPosition.operationId !== operationId)) {
295
+ throw new ProtocolError("filter mutation outcome does not match the requested operation");
296
+ }
297
+ return outcome;
298
+ }
299
+ function bound(binding, applied) {
300
+ return {
301
+ binding,
302
+ ...(applied.catalogPosition !== undefined ? { catalogPosition: applied.catalogPosition } : {})
303
+ };
304
+ }
305
+ /** Builds a {@link FilteredReader} over one consumer group. Start from `ConsumerGroup.reader()`. */
331
306
  export class FilteredReaderBuilder {
332
307
  transport;
333
308
  capabilities;
334
309
  filters;
335
310
  source;
336
- consumerName;
337
- partitionIds = [];
338
- filterRef;
311
+ consumer;
312
+ filter;
313
+ minCatalogPosition;
339
314
  startAt = { kind: "next" };
340
315
  pageCount = DEFAULT_COUNT;
316
+ examinedBudget;
341
317
  replyBytes = DEFAULT_MAX_REPLY_BYTES;
342
318
  mode = "primary";
343
319
  guarded = false;
344
320
  idleMs = DEFAULT_IDLE_INTERVAL_MS;
345
321
  maxUnacked = DEFAULT_MAX_UNACKED_PAGES;
346
- constructor(transport, capabilities, filters, source) {
322
+ selectedPartitions = new Set();
323
+ constructor(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
347
324
  this.transport = transport;
348
325
  this.capabilities = capabilities;
349
326
  this.filters = filters;
350
327
  this.source = source;
328
+ this.consumer = consumer;
329
+ this.filter = filter;
330
+ this.minCatalogPosition = minCatalogPosition;
351
331
  }
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 });
332
+ /** @internal */
333
+ static create(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
334
+ return new FilteredReaderBuilder(transport, capabilities, filters, source, consumer, filter, minCatalogPosition);
396
335
  }
397
336
  /**
398
337
  * 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
338
+ * stored offset. A partition the group hands this reader later always resumes
400
339
  * after the group's stored offset, so the previous owner's unacknowledged
401
340
  * backlog is read, not skipped.
402
341
  */
@@ -404,11 +343,29 @@ export class FilteredReaderBuilder {
404
343
  this.startAt = start;
405
344
  return this;
406
345
  }
346
+ /** Read this partition only while the group assigns it to this member. Repeat for several. */
347
+ partition(partitionId) {
348
+ if (!Number.isSafeInteger(partitionId) || partitionId < 0 || partitionId > 0xffff_ffff) {
349
+ throw new InvalidError("partition must be an unsigned 32-bit integer");
350
+ }
351
+ this.selectedPartitions.add(partitionId);
352
+ return this;
353
+ }
407
354
  /** Most matching records per page. */
408
355
  count(count) {
409
356
  this.pageCount = count;
410
357
  return this;
411
358
  }
359
+ /**
360
+ * Most source records one page examines, independent of `count`. Without it
361
+ * the server's own budget bounds the scan. A page that examines its budget
362
+ * without a match returns empty and the reader continues from where it
363
+ * stopped.
364
+ */
365
+ maxExamined(records) {
366
+ this.examinedBudget = records;
367
+ return this;
368
+ }
412
369
  /** Most record bytes per page. The server may lower it. */
413
370
  maxReplyBytes(bytes) {
414
371
  this.replyBytes = bytes;
@@ -447,92 +404,67 @@ export class FilteredReaderBuilder {
447
404
  }
448
405
  async build() {
449
406
  const capabilities = await this.capabilities();
450
- if (!capabilities.filters.native) {
451
- throw new UnsupportedError("consumer filters are not served by this server");
407
+ if (this.filter.kind === "group" && !capabilities.filters.groupPolicyReads) {
408
+ throw new UnsupportedError("this server serves consumer filters but not group-aware reads, upgrade it before reading groups through the Laser SDK");
452
409
  }
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)}`);
410
+ if (this.filter.kind !== "group" && !capabilities.filters.native) {
411
+ throw new UnsupportedError("consumer filters are not served by this server");
458
412
  }
459
413
  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(..)");
414
+ 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
415
  }
416
+ const consumer = this.consumer;
470
417
  const request = {
471
418
  v: FILTER_OP_VERSION,
472
419
  source: this.source,
473
420
  partitionId: 0,
474
421
  consumer,
475
- filter,
422
+ filter: this.filter,
476
423
  start: this.startAt,
477
424
  count: this.pageCount,
478
425
  maxReplyBytes: this.replyBytes,
479
- readMode: this.mode
426
+ readMode: this.mode,
427
+ ...(this.examinedBudget !== undefined ? { maxExamined: this.examinedBudget } : {}),
428
+ ...(this.minCatalogPosition !== undefined
429
+ ? { minCatalogPosition: this.minCatalogPosition }
430
+ : {})
480
431
  };
481
432
  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
433
  const pinnedSource = consumer.kind === "group_id"
489
434
  ? await sourceIncarnation(this.transport, this.source)
490
435
  : undefined;
491
- const coordinator = consumer.kind !== "consumer" &&
492
- this.transport.connectsNodes === true &&
493
- this.transport.openCoordinator !== undefined
436
+ // A group reader owns the connection that holds its membership, so two
437
+ // readers are two members. An injected client has only the shared one.
438
+ const coordinator = this.transport.connectsNodes === true && this.transport.openCoordinator !== undefined
494
439
  ? await this.transport.openCoordinator()
495
440
  : sharedCoordinator(this.transport);
496
441
  let membership;
497
- let partitionIds;
498
- let discovers = false;
499
442
  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
- }
443
+ membership = await Membership.join(coordinator, this.source, consumer.kind === "group_id" ? Number(consumer.id) : consumer.name, this.transport, pinnedSource);
511
444
  }
512
445
  catch (error) {
513
446
  await coordinator.close();
514
447
  throw error;
515
448
  }
516
- return new FilteredReader({
449
+ return FilteredReader.create({
517
450
  transport: this.transport,
518
451
  ...(pinnedSource === undefined ? {} : { pinnedSource }),
519
452
  filters: this.filters,
520
453
  coordinator,
521
454
  request,
522
- inlineDigest: filter.kind === "inline" ? consumerFilterDigest(filter.filter) : undefined,
523
455
  start: this.startAt,
524
456
  idleIntervalMs: this.idleMs,
525
457
  maxUnackedPages: this.maxUnacked,
526
- guard,
458
+ guard: undefined,
527
459
  guardEnabled: this.guarded,
528
460
  membership,
529
- discovers,
530
- partitionIds
461
+ selectedPartitions: new Set(this.selectedPartitions),
462
+ partitionIds: membership.partitions.filter((partition) => this.selectedPartitions.size === 0 || this.selectedPartitions.has(partition))
531
463
  });
532
464
  }
533
465
  }
534
466
  /**
535
- * Reads only the records a consumer filter selects, with their original
467
+ * Reads the records a consumer group's policy selects, with their original
536
468
  * offsets, and stores progress through fenced acknowledgments. Pages are read
537
469
  * ahead of acknowledgments, and a page's safe offset is stored only after it
538
470
  * and every earlier page of the partition are fully handled. Pages that
@@ -554,7 +486,6 @@ export class FilteredReader {
554
486
  sequence = 0;
555
487
  owner = {};
556
488
  retiredOwners = new WeakSet();
557
- listedAt;
558
489
  closed = false;
559
490
  deferredError;
560
491
  pinnedSource;
@@ -565,11 +496,14 @@ export class FilteredReader {
565
496
  this.routes = new Routes(settings.transport, settings.coordinator);
566
497
  this.guard = settings.guard;
567
498
  this.pinnedSource = settings.pinnedSource;
568
- this.listedAt = settings.discovers ? performance.now() : undefined;
569
499
  for (const partitionId of settings.partitionIds) {
570
500
  this.progress.set(partitionId, new PartitionProgress(settings.start));
571
501
  }
572
502
  }
503
+ /** @internal */
504
+ static create(settings) {
505
+ return new FilteredReader(settings);
506
+ }
573
507
  /** The next page with at least one match. Waits while nothing is new, up to `timeoutMs` when given. */
574
508
  async nextPage(options = {}) {
575
509
  const deadline = options.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
@@ -641,6 +575,43 @@ export class FilteredReader {
641
575
  this.partitionProgress(page.partitionId).completePage(sequence);
642
576
  await this.flush(page.partitionId);
643
577
  }
578
+ /**
579
+ * Mark this partition's records through `record` handled without storing
580
+ * yet. The normal consumer delivers in order, so every delivery completes
581
+ * the prefix through it, and it stores that prefix on its own cadence
582
+ * through `flushCompleted`.
583
+ *
584
+ * @internal
585
+ */
586
+ handled(record) {
587
+ const tag = record[READER_TAG];
588
+ if (tag?.owner !== this.owner)
589
+ return;
590
+ this.progress.get(record.partitionId)?.completeThrough(tag.sequence, record.offset);
591
+ }
592
+ /**
593
+ * Store every completed prefix now.
594
+ *
595
+ * @internal
596
+ */
597
+ async flushCompleted() {
598
+ if (this.settings.request.readMode !== "primary")
599
+ return;
600
+ let failure;
601
+ for (const partitionId of [...this.progress.keys()]) {
602
+ try {
603
+ await this.flush(partitionId);
604
+ }
605
+ catch (error) {
606
+ failure ??= error;
607
+ }
608
+ }
609
+ if (failure !== undefined) {
610
+ throw failure instanceof Error
611
+ ? failure
612
+ : new TransportError("storing group progress failed", true, { cause: failure });
613
+ }
614
+ }
644
615
  /** The partitions this reader reads now. */
645
616
  partitions() {
646
617
  return [...this.progress.keys()].filter((partitionId) => !this.revoked.has(partitionId));
@@ -725,9 +696,6 @@ export class FilteredReader {
725
696
  }
726
697
  if (this.settings.membership?.isDue() === true)
727
698
  await this.refreshMembership();
728
- if (this.listedAt !== undefined && performance.now() - this.listedAt >= ASSIGNMENT_REFRESH_MS) {
729
- await this.refreshPartitions();
730
- }
731
699
  const readable = this.partitions();
732
700
  if (readable.length === 0)
733
701
  return { kind: "idle" };
@@ -796,6 +764,13 @@ export class FilteredReader {
796
764
  return { kind: "empty", more: false };
797
765
  }
798
766
  this.restartAfter(partitionId, error);
767
+ // The group's policy changed under this read. The partition restarted
768
+ // from its stored offset and is read again at once under the policy the
769
+ // group holds now. Only an explicit acknowledgment of a record read
770
+ // under the old policy reports the change.
771
+ if (error instanceof FilterExecutionError && error.reason === "conflict") {
772
+ return { kind: "empty", more: true };
773
+ }
799
774
  throw error;
800
775
  }
801
776
  this.requireOpen();
@@ -811,7 +786,7 @@ export class FilteredReader {
811
786
  if (recordHead.getUint32(12, true) !== page.matched || page.matched > request.count)
812
787
  throw new ProtocolError("filtered record body has invalid counts");
813
788
  const messages = decodePolledBody(page.records);
814
- validatePage(request, this.settings.inlineDigest, page, messages);
789
+ validatePage(request, page, messages);
815
790
  if (request.consumer.kind === "group_id") {
816
791
  const pinned = this.pinnedSource;
817
792
  const current = page.generation;
@@ -828,16 +803,19 @@ export class FilteredReader {
828
803
  this.pinnedSource ??= current;
829
804
  }
830
805
  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);
806
+ // An unfiltered page ran no filter, so there is nothing to check.
807
+ if (page.policy.mode === "filtered") {
808
+ if (this.settings.guardEnabled && this.guard === undefined) {
809
+ this.guard = await compileGuard(this.settings.transport, await boundDefinition(this.settings.filters, request.source, page));
837
810
  }
838
- catch (error) {
839
- this.restartAfter(partitionId, error);
840
- throw error;
811
+ if (this.guard !== undefined) {
812
+ try {
813
+ checkGuard(this.guard, page, messages, unevaluated);
814
+ }
815
+ catch (error) {
816
+ this.restartAfter(partitionId, error);
817
+ throw error;
818
+ }
841
819
  }
842
820
  }
843
821
  this.sequence += 1;
@@ -869,7 +847,7 @@ export class FilteredReader {
869
847
  partitionId,
870
848
  offset: message.offset,
871
849
  frontier: page.frontier,
872
- evaluated: !unevaluated.has(message.offset),
850
+ evaluated: page.policy.mode === "filtered" && !unevaluated.has(message.offset),
873
851
  payload: message.payload,
874
852
  headers: message.headers,
875
853
  headersMalformed: message.headersMalformed !== undefined,
@@ -907,8 +885,7 @@ export class FilteredReader {
907
885
  }
908
886
  if (error.reason === "source_changed")
909
887
  this.settings.membership?.expire();
910
- const request = this.settings.request;
911
- if (request.filter.kind === "bound")
888
+ if (this.settings.guardEnabled)
912
889
  this.guard = undefined;
913
890
  }
914
891
  // Store the completed prefix of one partition. A lost reply keeps the target
@@ -927,8 +904,10 @@ export class FilteredReader {
927
904
  partitionId,
928
905
  consumer: request.consumer,
929
906
  generation: target.generation,
930
- digest: target.digest,
931
- offset: target.offset
907
+ ...(target.digest === undefined ? {} : { digest: target.digest }),
908
+ offset: target.offset,
909
+ mode: target.mode,
910
+ policyGeneration: target.policyGeneration
932
911
  };
933
912
  const encoded = encodeFilteredAck(ack);
934
913
  for (let rerouted = false;; rerouted = true) {
@@ -949,6 +928,7 @@ export class FilteredReader {
949
928
  return;
950
929
  }
951
930
  catch (error) {
931
+ this.restartAfter(partitionId, error);
952
932
  if (rerouted || !isRouteLost(error))
953
933
  throw error;
954
934
  }
@@ -988,11 +968,13 @@ export class FilteredReader {
988
968
  if (membership === undefined || !(await membership.sync()))
989
969
  return;
990
970
  this.requireOpen();
991
- const assigned = new Set(membership.partitions);
971
+ const selection = this.settings.selectedPartitions;
972
+ const assigned = new Set(membership.partitions.filter((partition) => selection === undefined || selection.size === 0 || selection.has(partition)));
992
973
  const rejoined = membership.takeRejoined();
993
974
  const stale = rejoined ? this.routes.detach() : [];
994
975
  if (rejoined) {
995
- if (this.settings.request.filter.kind === "bound")
976
+ // A rejoin can land in a recreated group with another binding.
977
+ if (this.settings.guardEnabled)
996
978
  this.guard = undefined;
997
979
  this.retiredOwners.add(this.owner);
998
980
  this.owner = {};
@@ -1022,17 +1004,6 @@ export class FilteredReader {
1022
1004
  for (const partitionId of left)
1023
1005
  await this.routes.release(partitionId);
1024
1006
  }
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
1007
  // Forget a revoked partition once nothing of it is in flight.
1037
1008
  retire(partitionId) {
1038
1009
  const progress = this.progress.get(partitionId);
@@ -1162,7 +1133,9 @@ class PartitionProgress {
1162
1133
  : {
1163
1134
  offset: page.safeAckOffset,
1164
1135
  generation: page.generation,
1165
- digest: page.policy.digest,
1136
+ ...(page.policy.digest === undefined ? {} : { digest: page.policy.digest }),
1137
+ mode: page.policy.mode,
1138
+ policyGeneration: page.policy.policyGeneration,
1166
1139
  ...(page.policy.groupId !== undefined ? { groupId: page.policy.groupId } : {})
1167
1140
  };
1168
1141
  const newest = this.pages.at(-1);
@@ -1233,6 +1206,11 @@ class Routes {
1233
1206
  opening = new Map();
1234
1207
  partitions = new Map();
1235
1208
  closed = false;
1209
+ // Whether the deployment is one node, learned on the first route. A single
1210
+ // node is every partition's primary, and the address it advertises can be
1211
+ // one the caller cannot reach (a port mapped by a container runtime, a slot
1212
+ // behind a proxy), so its data connection dials what the caller dialed.
1213
+ singleNode;
1236
1214
  /** Data connections these routes opened. */
1237
1215
  opened = 0;
1238
1216
  constructor(transport, coordinator) {
@@ -1252,7 +1230,12 @@ class Routes {
1252
1230
  ? encodeOffsetRouting(source, consumer, partitionId)
1253
1231
  : encodePollRouting(source, consumer, partitionId)));
1254
1232
  this.requireOpen();
1255
- const endpoint = `${route.ip}:${String(route.tcpPort)}`;
1233
+ if (this.singleNode === undefined) {
1234
+ const count = this.transport.clusterNodeCount;
1235
+ this.singleNode = count === undefined ? false : (await count.call(this.transport)) <= 1;
1236
+ }
1237
+ const dial = this.singleNode ? { ...route, ip: "", tcpPort: 0 } : route;
1238
+ const endpoint = this.singleNode ? "caller" : `${route.ip}:${String(route.tcpPort)}`;
1256
1239
  const existing = this.connections.get(endpoint);
1257
1240
  const reuse = existing === undefined ? undefined : sessionReuse(existing.session, route.session);
1258
1241
  if (existing !== undefined && reuse === "raise") {
@@ -1268,7 +1251,7 @@ class Routes {
1268
1251
  await this.dropEndpoint(endpoint);
1269
1252
  }
1270
1253
  if (!this.connections.has(endpoint)) {
1271
- if (route.tcpPort === 0) {
1254
+ if (!this.singleNode && route.tcpPort === 0) {
1272
1255
  throw new UnsupportedError(`partition primary ${route.name} serves no TCP endpoint`);
1273
1256
  }
1274
1257
  let pending = this.opening.get(endpoint);
@@ -1281,7 +1264,7 @@ class Routes {
1281
1264
  }
1282
1265
  pending = this.opening.get(endpoint);
1283
1266
  if (pending === undefined && !this.connections.has(endpoint)) {
1284
- pending = attach(open.bind(this.transport), route).then((connection) => {
1267
+ pending = attach(open.bind(this.transport), dial).then((connection) => {
1285
1268
  this.opened += 1;
1286
1269
  return connection;
1287
1270
  });
@@ -1374,7 +1357,7 @@ async function attach(open, route) {
1374
1357
  return connection;
1375
1358
  }
1376
1359
  // The shared transport as a coordinator, for a reader that has no connection
1377
- // of its own: an injected client, or an independent reader.
1360
+ // of its own: an injected client.
1378
1361
  function sharedCoordinator(transport) {
1379
1362
  return {
1380
1363
  send: (code, payload) => transport.sendManaged(code, payload),
@@ -1387,10 +1370,6 @@ function sharedCoordinator(transport) {
1387
1370
  close: () => Promise.resolve()
1388
1371
  };
1389
1372
  }
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
1373
  /** This reader's membership of a consumer group, held by its coordinator connection. */
1395
1374
  class Membership {
1396
1375
  coordinator;
@@ -1507,6 +1486,37 @@ async function sourceIncarnation(transport, source) {
1507
1486
  topicCreatedAtMicros: topic.created
1508
1487
  };
1509
1488
  }
1489
+ /**
1490
+ * Read a consumer group's native id and name, and the stream and topic
1491
+ * incarnations that scope it. Throws with reason `not_found` when the group
1492
+ * does not exist.
1493
+ */
1494
+ export async function nativeGroup(transport, source, group) {
1495
+ const incarnation = await sourceIncarnation(transport, source);
1496
+ const reply = await transport.sendManaged(GET_CONSUMER_GROUP_CODE, concat([identifier(source.stream), identifier(source.topic), identifier(group)]));
1497
+ // The reply begins with id:u32, partitions:u32, members:u32, then the name
1498
+ // as one length byte and its bytes. A missing group answers an empty body.
1499
+ if (reply.byteLength === 0)
1500
+ throw filterError({
1501
+ code: { kind: "known", name: "NotFound" },
1502
+ reason: "not_found",
1503
+ message: `consumer group ${String(group)} does not exist`
1504
+ });
1505
+ if (reply.byteLength < 13)
1506
+ throw new ProtocolError("consumer group metadata is truncated");
1507
+ const view = new DataView(reply.buffer, reply.byteOffset, reply.byteLength);
1508
+ const id = view.getUint32(0, true);
1509
+ if (typeof group === "number" && id !== group)
1510
+ throw new ProtocolError("consumer group metadata identifies another group");
1511
+ const length = view.getUint8(12);
1512
+ if (reply.byteLength < 13 + length)
1513
+ throw new ProtocolError("consumer group metadata is truncated");
1514
+ return {
1515
+ id,
1516
+ name: new TextDecoder().decode(reply.subarray(13, 13 + length)),
1517
+ identity: { ...incarnation, groupId: BigInt(id) }
1518
+ };
1519
+ }
1510
1520
  function identifier(name) {
1511
1521
  if (typeof name === "number") {
1512
1522
  const encoded = new Uint8Array(6);
@@ -1614,7 +1624,7 @@ function sameBytes(left, right) {
1614
1624
  // Re-evaluate every returned record with the shared evaluator before the
1615
1625
  // caller sees it.
1616
1626
  function checkGuard(guard, page, messages, unevaluated) {
1617
- if (!sameBytes(page.policy.digest, guard.digest)) {
1627
+ if (page.policy.digest === undefined || !sameBytes(page.policy.digest, guard.digest)) {
1618
1628
  throw filterError({
1619
1629
  code: { kind: "known", name: "Conflict" },
1620
1630
  reason: "conflict",
@@ -1747,7 +1757,7 @@ async function boundDefinition(filters, requested, page) {
1747
1757
  identity.topicId === source.topicId &&
1748
1758
  identity.topicCreatedAtMicros === source.topicCreatedAtMicros);
1749
1759
  if (binding !== undefined) {
1750
- if (!sameBytes(binding.digest, page.policy.digest))
1760
+ if (page.policy.digest === undefined || !sameBytes(binding.digest, page.policy.digest))
1751
1761
  throw new ProtocolError("served policy differs from the group's catalog binding");
1752
1762
  return definition(filters, {
1753
1763
  kind: "revision",
@@ -1766,8 +1776,8 @@ async function boundDefinition(filters, requested, page) {
1766
1776
  async function definition(filters, filter) {
1767
1777
  if (filter.kind === "inline")
1768
1778
  return filter.filter;
1769
- if (filter.kind === "bound")
1770
- throw new ConfigError("resolve a bound guard from the served page");
1779
+ if (filter.kind === "bound" || filter.kind === "group")
1780
+ throw new ConfigError("resolve a group guard from the served page");
1771
1781
  const { filterId, revision } = filter;
1772
1782
  for (let page = 0;; page += 1) {
1773
1783
  const revisions = await filters.revisions(filterId, { page, pageSize: MAX_FILTER_CATALOG_PAGE });
@@ -1831,7 +1841,7 @@ function sameGeneration(left, right) {
1831
1841
  left.partitionCreatedRevision === right.partitionCreatedRevision &&
1832
1842
  left.purgeGeneration === right.purgeGeneration);
1833
1843
  }
1834
- function validatePage(request, inlineDigest, page, messages) {
1844
+ function validatePage(request, page, messages) {
1835
1845
  const invalid = () => {
1836
1846
  throw new ProtocolError("filtered page does not match its request or scan boundary");
1837
1847
  };
@@ -1844,14 +1854,20 @@ function validatePage(request, inlineDigest, page, messages) {
1844
1854
  page.matched !== messages.length ||
1845
1855
  page.matched > request.count ||
1846
1856
  page.matched > page.examined ||
1847
- page.policy.digest.length !== 32 ||
1857
+ (request.maxExamined !== undefined && page.examined > request.maxExamined) ||
1858
+ (page.policy.mode === "filtered") !== (page.policy.digest !== undefined) ||
1859
+ (page.policy.digest !== undefined && page.policy.digest.length !== 32) ||
1860
+ (page.policy.mode === "unfiltered" && page.policy.groupId === undefined) ||
1861
+ (page.policy.mode === "unfiltered" && page.matched !== page.examined) ||
1862
+ (page.policy.mode === "unfiltered" &&
1863
+ (page.policy.filterId !== undefined || page.policy.revision !== undefined)) ||
1864
+ // A strict group read runs a binding and never accepts an unfiltered page.
1865
+ (page.policy.mode === "unfiltered" && request.filter.kind !== "group") ||
1848
1866
  (request.consumer.kind !== "consumer") !== (page.policy.groupId !== undefined))
1849
1867
  invalid();
1850
1868
  const body = new DataView(page.records.buffer, page.records.byteOffset, page.records.byteLength);
1851
1869
  if (body.getUint32(0, true) !== page.partitionId || body.getBigUint64(4, true) !== page.frontier)
1852
1870
  invalid();
1853
- if (inlineDigest !== undefined && !sameBytes(inlineDigest, page.policy.digest))
1854
- invalid();
1855
1871
  if (request.filter.kind === "revision" &&
1856
1872
  (page.policy.filterId !== request.filter.filterId ||
1857
1873
  page.policy.revision !== request.filter.revision))
@@ -1861,7 +1877,12 @@ function validatePage(request, inlineDigest, page, messages) {
1861
1877
  const cursor = request.start.continuation;
1862
1878
  if (!sameGeneration(cursor.generation, page.generation) ||
1863
1879
  cursor.groupId !== page.policy.groupId ||
1864
- !sameBytes(cursor.digest, page.policy.digest) ||
1880
+ cursor.mode !== page.policy.mode ||
1881
+ cursor.policyGeneration !== page.policy.policyGeneration ||
1882
+ (cursor.digest === undefined) !== (page.policy.digest === undefined) ||
1883
+ (cursor.digest !== undefined &&
1884
+ page.policy.digest !== undefined &&
1885
+ !sameBytes(cursor.digest, page.policy.digest)) ||
1865
1886
  cursor.readMode !== request.readMode)
1866
1887
  invalid();
1867
1888
  start = cursor.nextScanOffset;