@enbox/dwn-sdk-js 0.4.16 → 0.4.17

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 (152) hide show
  1. package/dist/browser.mjs +1 -1
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/generated/precompiled-validators.js +734 -747
  4. package/dist/esm/generated/precompiled-validators.js.map +1 -1
  5. package/dist/esm/json-schemas/definitions.json +5 -0
  6. package/dist/esm/src/core/dwn-constant.js +2 -2
  7. package/dist/esm/src/core/dwn-error.js +0 -1
  8. package/dist/esm/src/core/dwn-error.js.map +1 -1
  9. package/dist/esm/src/core/protocol-authorization-validation.js +1 -24
  10. package/dist/esm/src/core/protocol-authorization-validation.js.map +1 -1
  11. package/dist/esm/src/core/protocol-authorization.js +1 -5
  12. package/dist/esm/src/core/protocol-authorization.js.map +1 -1
  13. package/dist/esm/src/core/validation-state-reader.js +2 -3
  14. package/dist/esm/src/core/validation-state-reader.js.map +1 -1
  15. package/dist/esm/src/handlers/records-count.js +18 -10
  16. package/dist/esm/src/handlers/records-count.js.map +1 -1
  17. package/dist/esm/src/handlers/records-query.js +10 -7
  18. package/dist/esm/src/handlers/records-query.js.map +1 -1
  19. package/dist/esm/src/handlers/records-subscribe.js +10 -7
  20. package/dist/esm/src/handlers/records-subscribe.js.map +1 -1
  21. package/dist/esm/src/handlers/records-write.js +8 -26
  22. package/dist/esm/src/handlers/records-write.js.map +1 -1
  23. package/dist/esm/src/index.js +1 -1
  24. package/dist/esm/src/index.js.map +1 -1
  25. package/dist/esm/src/interfaces/protocols-configure.js +4 -9
  26. package/dist/esm/src/interfaces/protocols-configure.js.map +1 -1
  27. package/dist/esm/src/store/index-level.js +143 -6
  28. package/dist/esm/src/store/index-level.js.map +1 -1
  29. package/dist/esm/src/store/message-store-level.js +158 -21
  30. package/dist/esm/src/store/message-store-level.js.map +1 -1
  31. package/dist/esm/src/store/storage-controller.js +1 -3
  32. package/dist/esm/src/store/storage-controller.js.map +1 -1
  33. package/dist/esm/src/types/protocols-types.js +0 -10
  34. package/dist/esm/src/types/protocols-types.js.map +1 -1
  35. package/dist/esm/src/utils/filter.js +35 -1
  36. package/dist/esm/src/utils/filter.js.map +1 -1
  37. package/dist/esm/src/utils/grant-key-coverage.js +4 -6
  38. package/dist/esm/src/utils/grant-key-coverage.js.map +1 -1
  39. package/dist/esm/src/utils/messages.js +4 -14
  40. package/dist/esm/src/utils/messages.js.map +1 -1
  41. package/dist/esm/src/utils/permission-scope.js +3 -9
  42. package/dist/esm/src/utils/permission-scope.js.map +1 -1
  43. package/dist/esm/src/utils/record-limit-occupancy.js +70 -202
  44. package/dist/esm/src/utils/record-limit-occupancy.js.map +1 -1
  45. package/dist/esm/src/utils/records.js +9 -9
  46. package/dist/esm/src/utils/records.js.map +1 -1
  47. package/dist/esm/tests/core/records-grant-authorization.spec.js +13 -0
  48. package/dist/esm/tests/core/records-grant-authorization.spec.js.map +1 -1
  49. package/dist/esm/tests/core/replication-replay-property.spec.js +2 -2
  50. package/dist/esm/tests/core/replication-replay-property.spec.js.map +1 -1
  51. package/dist/esm/tests/core/validation-read-closure.spec.js +1 -1
  52. package/dist/esm/tests/core/validation-read-closure.spec.js.map +1 -1
  53. package/dist/esm/tests/dwn.spec.js +2 -2
  54. package/dist/esm/tests/dwn.spec.js.map +1 -1
  55. package/dist/esm/tests/features/records-delivery.spec.js +2 -5
  56. package/dist/esm/tests/features/records-delivery.spec.js.map +1 -1
  57. package/dist/esm/tests/features/records-nested-query-scope.spec.js +138 -73
  58. package/dist/esm/tests/features/records-nested-query-scope.spec.js.map +1 -1
  59. package/dist/esm/tests/features/records-record-limit.spec.js +264 -95
  60. package/dist/esm/tests/features/records-record-limit.spec.js.map +1 -1
  61. package/dist/esm/tests/fuzz/filter.fuzz.spec.js +19 -4
  62. package/dist/esm/tests/fuzz/filter.fuzz.spec.js.map +1 -1
  63. package/dist/esm/tests/handlers/records-write.spec.js +57 -3
  64. package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
  65. package/dist/esm/tests/store/index-level.spec.js +114 -1
  66. package/dist/esm/tests/store/index-level.spec.js.map +1 -1
  67. package/dist/esm/tests/store/message-store-cross-context.spec.js +46 -0
  68. package/dist/esm/tests/store/message-store-cross-context.spec.js.map +1 -0
  69. package/dist/esm/tests/store/message-store-level.spec.js +6 -0
  70. package/dist/esm/tests/store/message-store-level.spec.js.map +1 -1
  71. package/dist/esm/tests/store/message-store.spec.js +81 -0
  72. package/dist/esm/tests/store/message-store.spec.js.map +1 -1
  73. package/dist/esm/tests/utils/filters.spec.js +25 -4
  74. package/dist/esm/tests/utils/filters.spec.js.map +1 -1
  75. package/dist/esm/tests/utils/permission-scope.spec.js +4 -0
  76. package/dist/esm/tests/utils/permission-scope.spec.js.map +1 -1
  77. package/dist/esm/tests/utils/records.spec.js +12 -0
  78. package/dist/esm/tests/utils/records.spec.js.map +1 -1
  79. package/dist/esm/tests/validation/json-schemas/records/records-write.spec.js +3 -1
  80. package/dist/esm/tests/validation/json-schemas/records/records-write.spec.js.map +1 -1
  81. package/dist/types/generated/precompiled-validators.d.ts.map +1 -1
  82. package/dist/types/src/core/dwn-constant.d.ts +2 -2
  83. package/dist/types/src/core/dwn-error.d.ts +0 -1
  84. package/dist/types/src/core/dwn-error.d.ts.map +1 -1
  85. package/dist/types/src/core/protocol-authorization-validation.d.ts +0 -10
  86. package/dist/types/src/core/protocol-authorization-validation.d.ts.map +1 -1
  87. package/dist/types/src/core/protocol-authorization.d.ts.map +1 -1
  88. package/dist/types/src/core/validation-state-reader.d.ts.map +1 -1
  89. package/dist/types/src/handlers/records-count.d.ts +1 -0
  90. package/dist/types/src/handlers/records-count.d.ts.map +1 -1
  91. package/dist/types/src/handlers/records-query.d.ts.map +1 -1
  92. package/dist/types/src/handlers/records-subscribe.d.ts.map +1 -1
  93. package/dist/types/src/handlers/records-write.d.ts +3 -0
  94. package/dist/types/src/handlers/records-write.d.ts.map +1 -1
  95. package/dist/types/src/index.d.ts +3 -3
  96. package/dist/types/src/index.d.ts.map +1 -1
  97. package/dist/types/src/interfaces/protocols-configure.d.ts.map +1 -1
  98. package/dist/types/src/store/index-level.d.ts +37 -0
  99. package/dist/types/src/store/index-level.d.ts.map +1 -1
  100. package/dist/types/src/store/message-store-level.d.ts +23 -6
  101. package/dist/types/src/store/message-store-level.d.ts.map +1 -1
  102. package/dist/types/src/store/storage-controller.d.ts.map +1 -1
  103. package/dist/types/src/types/message-store.d.ts +23 -2
  104. package/dist/types/src/types/message-store.d.ts.map +1 -1
  105. package/dist/types/src/types/protocols-types.d.ts +7 -24
  106. package/dist/types/src/types/protocols-types.d.ts.map +1 -1
  107. package/dist/types/src/types/query-types.d.ts +8 -1
  108. package/dist/types/src/types/query-types.d.ts.map +1 -1
  109. package/dist/types/src/types/records-types.d.ts +2 -1
  110. package/dist/types/src/types/records-types.d.ts.map +1 -1
  111. package/dist/types/src/utils/filter.d.ts +14 -1
  112. package/dist/types/src/utils/filter.d.ts.map +1 -1
  113. package/dist/types/src/utils/grant-key-coverage.d.ts.map +1 -1
  114. package/dist/types/src/utils/messages.d.ts.map +1 -1
  115. package/dist/types/src/utils/permission-scope.d.ts +0 -2
  116. package/dist/types/src/utils/permission-scope.d.ts.map +1 -1
  117. package/dist/types/src/utils/record-limit-occupancy.d.ts +25 -12
  118. package/dist/types/src/utils/record-limit-occupancy.d.ts.map +1 -1
  119. package/dist/types/src/utils/records.d.ts +3 -2
  120. package/dist/types/src/utils/records.d.ts.map +1 -1
  121. package/dist/types/tests/features/records-delivery.spec.d.ts.map +1 -1
  122. package/dist/types/tests/features/records-nested-query-scope.spec.d.ts.map +1 -1
  123. package/dist/types/tests/features/records-record-limit.spec.d.ts.map +1 -1
  124. package/dist/types/tests/handlers/records-write.spec.d.ts.map +1 -1
  125. package/dist/types/tests/store/message-store-cross-context.spec.d.ts +2 -0
  126. package/dist/types/tests/store/message-store-cross-context.spec.d.ts.map +1 -0
  127. package/dist/types/tests/store/message-store.spec.d.ts.map +1 -1
  128. package/package.json +4 -4
  129. package/src/core/dwn-constant.ts +2 -2
  130. package/src/core/dwn-error.ts +0 -2
  131. package/src/core/protocol-authorization-validation.ts +1 -33
  132. package/src/core/protocol-authorization.ts +0 -7
  133. package/src/core/validation-state-reader.ts +2 -3
  134. package/src/handlers/records-count.ts +34 -13
  135. package/src/handlers/records-query.ts +14 -11
  136. package/src/handlers/records-subscribe.ts +18 -11
  137. package/src/handlers/records-write.ts +17 -32
  138. package/src/index.ts +3 -3
  139. package/src/interfaces/protocols-configure.ts +4 -13
  140. package/src/store/index-level.ts +197 -10
  141. package/src/store/message-store-level.ts +216 -25
  142. package/src/store/storage-controller.ts +1 -3
  143. package/src/types/message-store.ts +25 -2
  144. package/src/types/protocols-types.ts +7 -26
  145. package/src/types/query-types.ts +10 -2
  146. package/src/types/records-types.ts +2 -1
  147. package/src/utils/filter.ts +40 -3
  148. package/src/utils/grant-key-coverage.ts +4 -7
  149. package/src/utils/messages.ts +4 -14
  150. package/src/utils/permission-scope.ts +4 -11
  151. package/src/utils/record-limit-occupancy.ts +105 -293
  152. package/src/utils/records.ts +9 -9
@@ -10,12 +10,12 @@ import { EncryptionControl } from '../core/encryption-control.js';
10
10
  import { Message } from '../core/message.js';
11
11
  import { messageReplyFromError } from '../core/message-reply.js';
12
12
  import { ProtocolAuthorization } from '../core/protocol-authorization.js';
13
- import { queryRecordsWithRecordLimitOccupancy } from '../utils/record-limit-occupancy.js';
14
13
  import { Records } from '../utils/records.js';
15
14
  import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
16
15
  import { RecordsQuery } from '../interfaces/records-query.js';
17
16
  import { SortDirection } from '../types/query-types.js';
18
17
  import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
18
+ import { queryRecordsWithRecordLimitOccupancy, resolveRecordLimitOccupancy } from '../utils/record-limit-occupancy.js';
19
19
 
20
20
  type RecordsQueryProjectionInput = {
21
21
  tenant: string;
@@ -254,17 +254,22 @@ export class RecordsQueryHandler implements MethodHandler {
254
254
  const {
255
255
  tenant, recordsQuery, requester, filters, messageSort, pagination
256
256
  } = input;
257
+ const recordLimit = await resolveRecordLimitOccupancy({
258
+ validationStateReader : this.deps.validationStateReader,
259
+ tenant,
260
+ recordsFilter : recordsQuery.message.descriptor.filter,
261
+ messageTimestamp : recordsQuery.message.descriptor.messageTimestamp,
262
+ });
257
263
  const controlFilters = Records.buildControlRecordsFilters(filters);
258
264
  const currentAudienceRecordIdCache = new Map<string, string | undefined>();
259
265
  if (controlFilters.length === 0) {
260
266
  const result = await queryRecordsWithRecordLimitOccupancy({
261
- messageStore : this.deps.messageStore,
262
- validationStateReader : this.deps.validationStateReader,
267
+ messageStore: this.deps.messageStore,
263
268
  tenant,
264
269
  filters,
270
+ recordLimit,
265
271
  messageSort,
266
272
  pagination,
267
- messageTimestamp : recordsQuery.message.descriptor.messageTimestamp,
268
273
  });
269
274
  return EncryptionControl.projectCurrentAudienceRecordPage({
270
275
  messageStore : this.deps.messageStore,
@@ -280,13 +285,12 @@ export class RecordsQueryHandler implements MethodHandler {
280
285
 
281
286
  if (pagination?.limit === undefined || pagination.limit <= 0) {
282
287
  const result = await queryRecordsWithRecordLimitOccupancy({
283
- messageStore : this.deps.messageStore,
284
- validationStateReader : this.deps.validationStateReader,
288
+ messageStore: this.deps.messageStore,
285
289
  tenant,
286
290
  filters,
291
+ recordLimit,
287
292
  messageSort,
288
293
  pagination,
289
- messageTimestamp : recordsQuery.message.descriptor.messageTimestamp,
290
294
  });
291
295
  const projectedResult = await EncryptionControl.projectCurrentAudienceRecordPage({
292
296
  messageStore : this.deps.messageStore,
@@ -311,13 +315,12 @@ export class RecordsQueryHandler implements MethodHandler {
311
315
  do {
312
316
  const remainingLimit = pagination.limit - visibleMessages.length;
313
317
  const result = await queryRecordsWithRecordLimitOccupancy({
314
- messageStore : this.deps.messageStore,
315
- validationStateReader : this.deps.validationStateReader,
318
+ messageStore : this.deps.messageStore,
316
319
  tenant,
317
320
  filters,
321
+ recordLimit,
318
322
  messageSort,
319
- pagination : { ...pagination, cursor, limit: remainingLimit },
320
- messageTimestamp : recordsQuery.message.descriptor.messageTimestamp,
323
+ pagination : { ...pagination, cursor, limit: remainingLimit },
321
324
  });
322
325
  const projectedResult = await EncryptionControl.projectCurrentAudienceRecordPage({
323
326
  messageStore : this.deps.messageStore,
@@ -17,7 +17,11 @@ import { RecordsSubscribe } from '../interfaces/records-subscribe.js';
17
17
  import { SortDirection } from '../types/query-types.js';
18
18
  import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
19
19
  import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
20
- import { isRecordLimitOccupant, queryRecordsWithRecordLimitOccupancy } from '../utils/record-limit-occupancy.js';
20
+ import {
21
+ isRecordLimitOccupant,
22
+ queryRecordsWithRecordLimitOccupancy,
23
+ resolveRecordLimitOccupancy,
24
+ } from '../utils/record-limit-occupancy.js';
21
25
 
22
26
  type ProjectedRecordsSubscriptionHandler = {
23
27
  listener: SubscriptionListener;
@@ -604,17 +608,22 @@ export class RecordsSubscribeHandler implements MethodHandler {
604
608
  messageSort: MessageSort,
605
609
  pagination: { cursor?: PaginationCursor; limit?: number } | undefined,
606
610
  ): Promise<{ messages: RecordsQueryReplyEntry[], cursor?: PaginationCursor }> {
611
+ const recordLimit = await resolveRecordLimitOccupancy({
612
+ validationStateReader : this.deps.validationStateReader,
613
+ tenant,
614
+ recordsFilter : recordsSubscribe.message.descriptor.filter,
615
+ messageTimestamp : recordsSubscribe.message.descriptor.messageTimestamp,
616
+ });
607
617
  const controlFilters = Records.buildControlRecordsFilters(filters);
608
618
  const currentAudienceRecordIdCache = new Map<string, string | undefined>();
609
619
  if (controlFilters.length === 0) {
610
620
  const result = await queryRecordsWithRecordLimitOccupancy({
611
- messageStore : this.deps.messageStore,
612
- validationStateReader : this.deps.validationStateReader,
621
+ messageStore: this.deps.messageStore,
613
622
  tenant,
614
623
  filters,
624
+ recordLimit,
615
625
  messageSort,
616
626
  pagination,
617
- messageTimestamp : recordsSubscribe.message.descriptor.messageTimestamp,
618
627
  });
619
628
  return EncryptionControl.projectCurrentAudienceRecordPage({
620
629
  messageStore: this.deps.messageStore,
@@ -627,13 +636,12 @@ export class RecordsSubscribeHandler implements MethodHandler {
627
636
 
628
637
  if (pagination?.limit === undefined || pagination.limit <= 0) {
629
638
  const result = await queryRecordsWithRecordLimitOccupancy({
630
- messageStore : this.deps.messageStore,
631
- validationStateReader : this.deps.validationStateReader,
639
+ messageStore: this.deps.messageStore,
632
640
  tenant,
633
641
  filters,
642
+ recordLimit,
634
643
  messageSort,
635
644
  pagination,
636
- messageTimestamp : recordsSubscribe.message.descriptor.messageTimestamp,
637
645
  });
638
646
  const projectedResult = await EncryptionControl.projectCurrentAudienceRecordPage({
639
647
  messageStore: this.deps.messageStore,
@@ -655,13 +663,12 @@ export class RecordsSubscribeHandler implements MethodHandler {
655
663
  do {
656
664
  const remainingLimit = pagination.limit - visibleMessages.length;
657
665
  const result = await queryRecordsWithRecordLimitOccupancy({
658
- messageStore : this.deps.messageStore,
659
- validationStateReader : this.deps.validationStateReader,
666
+ messageStore : this.deps.messageStore,
660
667
  tenant,
661
668
  filters,
669
+ recordLimit,
662
670
  messageSort,
663
- pagination : { ...pagination, cursor, limit: remainingLimit },
664
- messageTimestamp : recordsSubscribe.message.descriptor.messageTimestamp,
671
+ pagination : { ...pagination, cursor, limit: remainingLimit },
665
672
  });
666
673
  const projectedResult = await EncryptionControl.projectCurrentAudienceRecordPage({
667
674
  messageStore: this.deps.messageStore,
@@ -1,5 +1,6 @@
1
1
  import type { CoreProtocol } from '../core/core-protocol.js';
2
2
  import type { ProgressToken } from '../types/subscriptions.js';
3
+ import type { ProtocolRuleSet } from '../types/protocols-types.js';
3
4
  import type { GenericMessage, GenericMessageReply } from '../types/message-types.js';
4
5
  import type { HandlerDependencies, MethodHandler } from '../types/method-handler.js';
5
6
  import type { RecordsQueryReplyEntry, RecordsWriteMessage } from '../types/records-types.js';
@@ -64,8 +65,13 @@ export class RecordsWriteHandler implements MethodHandler {
64
65
  return messageReplyFromError(e, 400);
65
66
  }
66
67
 
68
+ let protocolRuleSet: ProtocolRuleSet;
67
69
  try {
68
- await ProtocolAuthorization.validateReferentialIntegrity(tenant, recordsWrite, this.deps.validationStateReader);
70
+ protocolRuleSet = await ProtocolAuthorization.validateReferentialIntegrity(
71
+ tenant,
72
+ recordsWrite,
73
+ this.deps.validationStateReader,
74
+ );
69
75
  } catch (e) {
70
76
  return messageReplyFromError(e, 400);
71
77
  }
@@ -87,7 +93,7 @@ export class RecordsWriteHandler implements MethodHandler {
87
93
  // messageTimestamp is <= the most recent squash record at the same path and parent context.
88
94
  // The squash record acts as a temporal floor — no record older than the latest squash can exist.
89
95
  try {
90
- await this.enforceSquashBackstop(tenant, message);
96
+ await this.enforceSquashBackstop(tenant, message, protocolRuleSet);
91
97
  } catch (e) {
92
98
  return messageReplyFromError(e, 409);
93
99
  }
@@ -445,38 +451,17 @@ export class RecordsWriteHandler implements MethodHandler {
445
451
  * and there exists a squash record at the same protocol path and parent context whose
446
452
  * `messageTimestamp` is >= the incoming message's `messageTimestamp`, reject with 409.
447
453
  *
454
+ * The rule set is the exact policy already resolved by referential-integrity validation. Reusing
455
+ * it prevents a second store lookup from observing different state or failing open.
456
+ *
448
457
  * This check only applies to protocol-based records at `$squash: true` paths.
449
458
  */
450
- private async enforceSquashBackstop(tenant: string, message: RecordsWriteMessage): Promise<void> {
451
- // Only applies to protocol-based records
452
- if (message.descriptor.protocol === undefined || message.descriptor.protocolPath === undefined) {
453
- return;
454
- }
455
-
456
- // Fetch the protocol definition active at the incoming message timestamp to check if $squash is enabled at this path.
457
- // The reader resolves core protocols (e.g. permissions) from the registry.
458
- let protocolDefinition;
459
- try {
460
- protocolDefinition = await this.deps.validationStateReader.fetchProtocolDefinition(
461
- tenant,
462
- message.descriptor.protocol,
463
- message.descriptor.messageTimestamp,
464
- );
465
- } catch (error) {
466
- // If the protocol definition can't be found, skip the backstop check.
467
- // Authorization will handle the missing protocol error later.
468
- console.warn(`enforceSquashBackstop: failed to fetch protocol definition for '${message.descriptor.protocol}':`, error);
469
- return;
470
- }
471
-
472
- // Walk the structure to find the rule set for this protocol path
473
- const pathSegments = message.descriptor.protocolPath.split('/');
474
- let ruleSet = protocolDefinition.structure[pathSegments[0]];
475
- for (let i = 1; i < pathSegments.length && ruleSet !== undefined; i++) {
476
- ruleSet = ruleSet[pathSegments[i]] as typeof ruleSet;
477
- }
478
-
479
- if (ruleSet?.$squash !== true) {
459
+ private async enforceSquashBackstop(
460
+ tenant: string,
461
+ message: RecordsWriteMessage,
462
+ ruleSet: ProtocolRuleSet,
463
+ ): Promise<void> {
464
+ if (ruleSet.$squash !== true) {
480
465
  return;
481
466
  }
482
467
 
package/src/index.ts CHANGED
@@ -9,9 +9,8 @@ export { StoreValidationStateReader } from './core/validation-state-reader.js';
9
9
  export type { RecordedValidationRead } from './core/recording-validation-state-reader.js';
10
10
  export { RecordingValidationStateReader } from './core/recording-validation-state-reader.js';
11
11
  export type { MessagesFilter, MessagesQueryDescriptor, MessagesQueryMessage, MessagesQueryReply, MessagesQueryReplyEntry, MessagesReadMessage, MessagesReadReply, MessagesReadReplyEntry, MessagesReadDescriptor, MessagesSubscribeDescriptor, MessagesSubscribeMessage, MessagesSubscribeReply, MessagesSubscribeMessageOptions } from './types/messages-types.js';
12
- export type { GT, LT, Filter, FilterValue, KeyValues, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions, RangeValue, StartsWithFilter } from './types/query-types.js';
12
+ export type { GT, LT, Filter, FilterValue, KeyValues, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions, RangeValue, StartsWithFilter, SubtreeFilter } from './types/query-types.js';
13
13
  export type { ProtocolsConfigureDescriptor, ProtocolDefinition, ProtocolTypes, ProtocolRuleSet, ProtocolsQueryFilter, ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply, ProtocolActionRule, ProtocolDeliveryStrategy, ProtocolKeyAgreement, ProtocolsQueryDescriptor, ProtocolRecordLimitDefinition, ProtocolSizeDefinition, ProtocolTagsDefinition, ProtocolTagSchema, ProtocolType, ProtocolUses } from './types/protocols-types.js';
14
- export { ProtocolRecordLimitStrategy } from './types/protocols-types.js';
15
14
  export type { DataEncodedRecordsWriteMessage, RecordsCountDescriptor, RecordsCountMessage, RecordsCountReply, RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadMessage, RecordsReadReply, RecordsSubscribeDescriptor, RecordsSubscribeMessage, RecordsSubscribeReply, RecordsWriteDescriptor, RecordsWriteTags, RecordsWriteTagValue, RecordsWriteMessage, RecordsWriteSignaturePayload, RecordsDeleteDescriptor, RecordsQueryDescriptor, RecordsReadDescriptor, RecordsSubscribeMessageOptions, RecordsWriteMessageOptions, InternalRecordsWriteMessage, RecordEvent, RecordsWriteTagsFilter } from './types/records-types.js';
16
15
  export type { GeneralJws, SignatureEntry } from './types/jws-types.js';
17
16
  export { authenticate } from './core/auth.js';
@@ -53,6 +52,7 @@ export { DwnError, DwnErrorCode } from './core/dwn-error.js';
53
52
  export type { DwnErrorInfo } from './core/dwn-error.js';
54
53
  export { DwnInterfaceName, DwnMethodName } from './enums/dwn-interface-method.js';
55
54
  export { Encoder } from './utils/encoder.js';
55
+ export { assertValidSubtreeFilters, isSubtreeFilter } from './utils/filter.js';
56
56
  export { MessagesSubscribe } from './interfaces/messages-subscribe.js';
57
57
  export type { MessagesSubscribeOptions } from './interfaces/messages-subscribe.js';
58
58
  export { Encryption, ContentEncryptionAlgorithm, KeyAgreementAlgorithm, ROLE_AUDIENCE_DERIVATION_SCHEME, SEAL_DERIVATION_SCHEME } from './utils/encryption.js';
@@ -82,7 +82,7 @@ export type { MessagesReadOptions } from './interfaces/messages-read.js';
82
82
  export { MessagesQuery } from './interfaces/messages-query.js';
83
83
  export type { MessagesQueryOptions } from './interfaces/messages-query.js';
84
84
  export type { UnionMessageReply } from './core/message-reply.js';
85
- export type { MessageStore, MessageStoreLatestStateTransition, MessageStoreOptions, MessageStorePutResult } from './types/message-store.js';
85
+ export type { MessageStore, MessageStoreLatestStateTransition, MessageStoreOptions, MessageStorePutResult, MessageStoreQueryOptions, RecordLimitOccupancy } from './types/message-store.js';
86
86
  export { Replication } from './utils/replication.js';
87
87
  export type { MessageInterface } from './types/message-interface.js';
88
88
  export { PermissionGrant } from './protocols/permission-grant.js';
@@ -9,6 +9,7 @@ import type {
9
9
 
10
10
  import { AbstractMessage } from '../core/abstract-message.js';
11
11
  import { DwnConstant } from '../core/dwn-constant.js';
12
+ import { FilterUtility } from '../utils/filter.js';
12
13
  import { Message } from '../core/message.js';
13
14
  import { PermissionGrant } from '../protocols/permission-grant.js';
14
15
  import { ProtocolsGrantAuthorization } from '../core/protocols-grant-authorization.js';
@@ -18,7 +19,7 @@ import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
18
19
  import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
19
20
  import { getRoleAudienceContextId, getRuleSetAtPath, isCrossProtocolRef, parseCrossProtocolRef } from '../utils/protocols.js';
20
21
  import { normalizeProtocolUrl, normalizeSchemaUrl, validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
21
- import { ProtocolAction, ProtocolActor, ProtocolRecordLimitStrategy } from '../types/protocols-types.js';
22
+ import { ProtocolAction, ProtocolActor } from '../types/protocols-types.js';
22
23
 
23
24
  export type ProtocolsConfigureOptions = {
24
25
  messageTimestamp?: string;
@@ -300,7 +301,7 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
300
301
 
301
302
  // Validate $recordLimit
302
303
  if (ruleSet.$recordLimit !== undefined) {
303
- const { max, strategy } = ruleSet.$recordLimit;
304
+ const { max } = ruleSet.$recordLimit;
304
305
 
305
306
  if (!Number.isInteger(max) || max < 1) {
306
307
  throw new DwnError(
@@ -315,15 +316,6 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
315
316
  `Invalid $recordLimit.max value ${max} at protocol path '${ruleSetProtocolPath}': must be <= ${DwnConstant.maxRecordLimit}.`
316
317
  );
317
318
  }
318
-
319
- const validStrategies = Object.values(ProtocolRecordLimitStrategy) as string[];
320
- if (!validStrategies.includes(strategy as string)) {
321
- throw new DwnError(
322
- DwnErrorCode.ProtocolsConfigureInvalidRecordLimit,
323
- `Invalid $recordLimit.strategy '${strategy}' at protocol path '${ruleSetProtocolPath}': ` +
324
- `must be one of ${validStrategies.join(', ')}.`
325
- );
326
- }
327
319
  }
328
320
 
329
321
  if (ruleSet.$tags !== undefined) {
@@ -412,8 +404,7 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
412
404
  ProtocolsConfigure.validateCrossProtocolAlias(actionRule.of, uses, ruleSetProtocolPath, 'of');
413
405
  } else {
414
406
  // Local `of`: must be self or ancestor
415
- const isSelfOrAncestor = ruleSetProtocolPath === actionRule.of
416
- || ruleSetProtocolPath.startsWith(actionRule.of + '/');
407
+ const isSelfOrAncestor = FilterUtility.matchesSubtree(actionRule.of, ruleSetProtocolPath);
417
408
  if (!isSelfOrAncestor) {
418
409
  throw new DwnError(
419
410
  DwnErrorCode.ProtocolsConfigureInvalidActionOfNotAnAncestor,
@@ -4,6 +4,7 @@ import type { LevelWrapperBatchOperation, LevelWrapperIteratorOptions, } from '.
4
4
  import { isEmptyObject } from '@enbox/common';
5
5
  import { lexicographicalCompare } from '../utils/string.js';
6
6
  import { SortDirection } from '../types/query-types.js';
7
+ import { assertValidSubtreeFilters, FilterSelector, FilterUtility, isSubtreeFilter } from '../utils/filter.js';
7
8
  import {
8
9
  countWithCompoundIndex,
9
10
  createCompoundIndexDeleteOperation,
@@ -13,7 +14,6 @@ import {
13
14
  } from './index-level-compound.js';
14
15
  import { createLevelDatabase, LevelWrapper } from './level-wrapper.js';
15
16
  import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
16
- import { FilterSelector, FilterUtility } from '../utils/filter.js';
17
17
 
18
18
  export type IndexLevelConfig = {
19
19
  location: string,
@@ -318,17 +318,12 @@ export class IndexLevel {
318
318
  * @returns {IndexedItem[]} an array of `IndexedItem` that match the given filters.
319
319
  */
320
320
  async query(tenant: string, filters: Filter[], queryOptions: QueryOptions, options?: IndexLevelOptions): Promise<IndexedItem[]> {
321
+ assertValidSubtreeFilters(filters);
321
322
 
322
323
  // Strategy 1: try compound index for single-filter queries
323
- if (filters.length === 1 && !isEmptyObject(filters[0])) {
324
- const compoundResult = selectCompoundIndex(filters[0], queryOptions, this._compoundIndexes);
325
- if (compoundResult !== undefined) {
326
- return queryWithCompoundIndex(
327
- this.db, tenant, filters[0], queryOptions, compoundResult,
328
- IndexLevel.encodeValue, IndexLevel.delimiter,
329
- this.queryWithIteratorPaging.bind(this), options
330
- );
331
- }
324
+ const compoundQuery = this.queryUsingCompoundIndex(tenant, filters, queryOptions, options);
325
+ if (compoundQuery !== undefined) {
326
+ return compoundQuery;
332
327
  }
333
328
 
334
329
  // Strategy 2: in-memory paging for concise filters
@@ -340,6 +335,47 @@ export class IndexLevel {
340
335
  return this.queryWithIteratorPaging(tenant, filters, queryOptions, options);
341
336
  }
342
337
 
338
+ /**
339
+ * Queries one bounded page without selecting the in-memory strategy.
340
+ *
341
+ * Projection callers use this when they must close the outer iterator before
342
+ * opening related index ranges. A matching compound index is preferred;
343
+ * otherwise the requested-sort index is streamed directly.
344
+ */
345
+ async queryWithBoundedPaging(
346
+ tenant: string,
347
+ filters: Filter[],
348
+ queryOptions: QueryOptions,
349
+ options?: IndexLevelOptions,
350
+ ): Promise<IndexedItem[]> {
351
+ assertValidSubtreeFilters(filters);
352
+
353
+ const compoundQuery = this.queryUsingCompoundIndex(tenant, filters, queryOptions, options);
354
+ return compoundQuery ?? this.queryWithIteratorPaging(tenant, filters, queryOptions, options);
355
+ }
356
+
357
+ private queryUsingCompoundIndex(
358
+ tenant: string,
359
+ filters: Filter[],
360
+ queryOptions: QueryOptions,
361
+ options?: IndexLevelOptions,
362
+ ): Promise<IndexedItem[]> | undefined {
363
+ if (filters.length !== 1 || isEmptyObject(filters[0])) {
364
+ return undefined;
365
+ }
366
+
367
+ const compoundIndex = selectCompoundIndex(filters[0], queryOptions, this._compoundIndexes);
368
+ if (compoundIndex === undefined) {
369
+ return undefined;
370
+ }
371
+
372
+ return queryWithCompoundIndex(
373
+ this.db, tenant, filters[0], queryOptions, compoundIndex,
374
+ IndexLevel.encodeValue, IndexLevel.delimiter,
375
+ this.queryWithIteratorPaging.bind(this), options
376
+ );
377
+ }
378
+
343
379
  /**
344
380
  * Counts the number of items that match the given filters without loading full records.
345
381
  *
@@ -348,6 +384,7 @@ export class IndexLevel {
348
384
  */
349
385
  async count(tenant: string, filters: Filter[], queryOptions: Omit<QueryOptions, 'limit' | 'cursor'>,
350
386
  options?: IndexLevelOptions): Promise<number> {
387
+ assertValidSubtreeFilters(filters);
351
388
 
352
389
  // try compound index for single-filter queries
353
390
  if (filters.length === 1 && !isEmptyObject(filters[0])) {
@@ -365,6 +402,117 @@ export class IndexLevel {
365
402
  return results.length;
366
403
  }
367
404
 
405
+ /**
406
+ * Finds the inclusive rank boundary for one `$recordLimit` parent group.
407
+ *
408
+ * The existing `parentId` index keeps the scan isolated to one nested-record
409
+ * group. Root records have no `parentId`, so they are read from the exact
410
+ * `protocolPath` range and explicitly fenced to rows without a parent. The
411
+ * bounded max-heap retains only the earliest `max` rank keys while the range
412
+ * is streamed; no group-sized array is materialized.
413
+ */
414
+ public async findRecordLimitRankCutoff(
415
+ tenant: string,
416
+ candidateFilter: Filter,
417
+ parentId: string | undefined,
418
+ max: number,
419
+ options?: IndexLevelOptions,
420
+ ): Promise<string | undefined> {
421
+ if (!Number.isInteger(max) || max < 1) {
422
+ throw new TypeError(`IndexLevel: record-limit max must be a positive integer.`);
423
+ }
424
+
425
+ const protocolPath = candidateFilter.protocolPath;
426
+ if (typeof protocolPath !== 'string') {
427
+ throw new TypeError(`IndexLevel: record-limit candidate filter must contain one protocolPath.`);
428
+ }
429
+
430
+ const indexName = parentId === undefined ? 'protocolPath' : 'parentId';
431
+ const indexValue = parentId ?? protocolPath;
432
+ const matchPrefix = IndexLevel.keySegmentJoin(IndexLevel.encodeValue(indexValue), '');
433
+ const partition = await this.getIndexPartition(tenant, indexName);
434
+ const rankHeap: string[] = [];
435
+
436
+ for await (const [key, value] of partition.iterator({ gt: matchPrefix }, options)) {
437
+ if (!key.startsWith(matchPrefix)) {
438
+ break;
439
+ }
440
+
441
+ const item = JSON.parse(value) as IndexedItem;
442
+ if ((parentId === undefined && item.indexes.parentId !== undefined) ||
443
+ !FilterUtility.matchFilter(item.indexes, candidateFilter)) {
444
+ continue;
445
+ }
446
+
447
+ IndexLevel.addToBoundedMaxHeap(rankHeap, IndexLevel.createRecordLimitRankKey(item.indexes), max);
448
+ }
449
+
450
+ return rankHeap[0];
451
+ }
452
+
453
+ /**
454
+ * Builds the deterministic `$recordLimit` rank key: oldest creation time,
455
+ * then record ID. Message CID deliberately does not participate because
456
+ * occupancy belongs to the logical record, not a particular update.
457
+ */
458
+ public static createRecordLimitRankKey(indexes: KeyValues): string {
459
+ const { dateCreated, recordId } = indexes;
460
+ if (typeof dateCreated !== 'string' || typeof recordId !== 'string') {
461
+ throw new TypeError(`IndexLevel: record-limit candidates require string dateCreated and recordId indexes.`);
462
+ }
463
+
464
+ return IndexLevel.keySegmentJoin(IndexLevel.encodeValue(dateCreated), IndexLevel.encodeValue(recordId));
465
+ }
466
+
467
+ private static addToBoundedMaxHeap(heap: string[], value: string, max: number): void {
468
+ if (heap.length < max) {
469
+ heap.push(value);
470
+ IndexLevel.siftRecordLimitRankUp(heap, heap.length - 1);
471
+ return;
472
+ }
473
+
474
+ if (lexicographicalCompare(value, heap[0]) >= 0) {
475
+ return;
476
+ }
477
+
478
+ heap[0] = value;
479
+ IndexLevel.siftRecordLimitRankDown(heap, 0);
480
+ }
481
+
482
+ private static siftRecordLimitRankUp(heap: string[], startIndex: number): void {
483
+ let index = startIndex;
484
+ while (index > 0) {
485
+ const parentIndex = (index - 1) >>> 1;
486
+ if (lexicographicalCompare(heap[parentIndex], heap[index]) >= 0) {
487
+ return;
488
+ }
489
+
490
+ [heap[parentIndex], heap[index]] = [heap[index], heap[parentIndex]];
491
+ index = parentIndex;
492
+ }
493
+ }
494
+
495
+ private static siftRecordLimitRankDown(heap: string[], startIndex: number): void {
496
+ let index = startIndex;
497
+ while (true) {
498
+ const leftIndex = index * 2 + 1;
499
+ if (leftIndex >= heap.length) {
500
+ return;
501
+ }
502
+
503
+ const rightIndex = leftIndex + 1;
504
+ const largerChildIndex = rightIndex < heap.length && lexicographicalCompare(heap[rightIndex], heap[leftIndex]) > 0
505
+ ? rightIndex
506
+ : leftIndex;
507
+ if (lexicographicalCompare(heap[index], heap[largerChildIndex]) >= 0) {
508
+ return;
509
+ }
510
+
511
+ [heap[index], heap[largerChildIndex]] = [heap[largerChildIndex], heap[index]];
512
+ index = largerChildIndex;
513
+ }
514
+ }
515
+
368
516
  /**
369
517
  * Queries the sort property index for items that match the filters. If no filters are provided, all items are returned.
370
518
  * This query is a linear iterator over the sorted index, checking each item for a match.
@@ -589,6 +737,17 @@ export class IndexLevel {
589
737
  const exactMatches = await this.filterExactMatches(tenant, propertyName, propertyValue, levelOptions);
590
738
  processResults(exactMatches);
591
739
  }
740
+ } else if (isSubtreeFilter(propertyFilter)) {
741
+ const exactMatches = await this.filterExactMatches(tenant, propertyName, propertyFilter.subtree, levelOptions);
742
+ processResults(exactMatches);
743
+
744
+ const descendantMatches = await this.filterSubtreeDescendantMatches(
745
+ tenant,
746
+ propertyName,
747
+ propertyFilter.subtree,
748
+ levelOptions,
749
+ );
750
+ processResults(descendantMatches);
592
751
  } else if (FilterUtility.isRangeFilter(propertyFilter)) {
593
752
  // `propertyFilter` is a `RangeFilter`
594
753
  const rangeMatches = await this.filterRangeMatches(tenant, propertyName, propertyFilter, levelOptions);
@@ -633,6 +792,34 @@ export class IndexLevel {
633
792
  return matches;
634
793
  }
635
794
 
795
+ /**
796
+ * Returns every slash-delimited descendant of one hierarchical path.
797
+ *
798
+ * The lower bound removes JSON's closing quote so it is the encoded key
799
+ * prefix itself. ASCII `/` and `0` are adjacent in the store's lexicographic
800
+ * ordering, making `path0` the first value outside that prefix without
801
+ * relying on a finite Unicode sentinel.
802
+ */
803
+ private async filterSubtreeDescendantMatches(
804
+ tenant: string,
805
+ propertyName: string,
806
+ subtree: string,
807
+ options?: IndexLevelOptions,
808
+ ): Promise<IndexedItem[]> {
809
+ const encodedPrefix = IndexLevel.encodeValue(`${subtree}/`).slice(0, -1);
810
+ const iteratorOptions: LevelWrapperIteratorOptions<string> = {
811
+ gte : encodedPrefix,
812
+ lt : IndexLevel.encodeValue(`${subtree}0`),
813
+ };
814
+
815
+ const filterPartition = await this.getIndexPartition(tenant, propertyName);
816
+ const matches: IndexedItem[] = [];
817
+ for await (const [ _key, value ] of filterPartition.iterator(iteratorOptions, options)) {
818
+ matches.push(JSON.parse(value) as IndexedItem);
819
+ }
820
+ return matches;
821
+ }
822
+
636
823
  /**
637
824
  * Returns items that match the range filter.
638
825
  *