@distrohelena/canton-typescript-sdk 0.1.43 → 0.1.44

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.
@@ -65,12 +65,19 @@ class GrpcContractCache {
65
65
  async populateAsync(parties, key) {
66
66
  const activeContracts = [];
67
67
  const seenPageTokens = new Set();
68
+ // Every continuation request must be identical to the first page's request apart from the page
69
+ // token: the participant validates each token against the request it was minted for, so adopting
70
+ // the response's now-explicit activeAtOffset into page 2+ makes it reject the token
71
+ // (INVALID_ACS_PAGE_TOKEN) — the token was prepared for a request with the field absent. The token
72
+ // itself pins the snapshot offset; the echoed offset is only tracked to validate it stays constant.
73
+ const baseRequest = (0, contracts_mapper_js_1.mapGrpcQueryContractsRequest)(parties === undefined ? { allParties: true } : { parties });
68
74
  let activeAtOffset;
69
75
  let pageToken;
70
76
  do {
71
- const page = materializeActiveContractsPage(await this.stateService.getActiveContractsPageAsync((0, contracts_mapper_js_1.mapGrpcQueryContractsRequest)(parties === undefined
72
- ? { allParties: true, activeAtOffset, pageToken }
73
- : { parties, activeAtOffset, pageToken })));
77
+ const page = materializeActiveContractsPage(await this.stateService.getActiveContractsPageAsync({
78
+ ...baseRequest,
79
+ pageToken: pageToken === undefined ? undefined : Uint8Array.from(pageToken),
80
+ }));
74
81
  if (activeAtOffset === undefined) {
75
82
  activeAtOffset = page.activeAtOffset;
76
83
  }
@@ -11,6 +11,7 @@ const query_source_js_1 = require("../query-source.js");
11
11
  const grpc_relation_mapper_js_1 = require("./grpc-relation-mapper.js");
12
12
  const grpc_package_relation_reader_js_1 = require("./grpc-package-relation-reader.js");
13
13
  const grpc_query_snapshot_reader_js_1 = require("./grpc-query-snapshot-reader.js");
14
+ const grpc_query_value_mapper_js_1 = require("./grpc-query-value-mapper.js");
14
15
  class GrpcQueryClient {
15
16
  options;
16
17
  source = query_source_js_1.QuerySource.grpc;
@@ -66,7 +67,7 @@ class DefaultGrpcQueryDataProvider {
66
67
  packages;
67
68
  constructor(options) {
68
69
  this.options = options;
69
- this.snapshots = new grpc_query_snapshot_reader_js_1.GrpcQuerySnapshotReader(options.stateService, options.updateService);
70
+ this.snapshots = new grpc_query_snapshot_reader_js_1.GrpcQuerySnapshotReader(options.stateService, options.updateService, { incrementalHistory: options.incrementalHistory });
70
71
  this.packages = new grpc_package_relation_reader_js_1.GrpcPackageRelationReader(options.packageService);
71
72
  }
72
73
  async readDatasetAsync(query) {
@@ -80,10 +81,16 @@ class DefaultGrpcQueryDataProvider {
80
81
  return cachedContractsDataset(cached.contracts, cached.activeAtOffset, this.options.endpointScope ?? "ledger");
81
82
  }
82
83
  else if (needsHistory) {
83
- console.warn(`[GrpcQueryClient] Falling back to a full ledger replay from offset 0 for a "${query.relation}" query. `
84
- + "This is expensive and should be an extreme edge case. It is usually triggered by a \"contracts\" query "
85
- + "that does not explicitly prove `active: true` (so archived contracts may be in scope), or by querying "
86
- + "\"transactions\"/\"events\"/\"exercises\" directly. Add an explicit active:true filter if only current state is needed.");
84
+ // With a warm incremental window only the new offsets are fetched, which is no longer worth a warning.
85
+ if (!(this.options.incrementalHistory === true && this.snapshots.hasHistoryCache)) {
86
+ console.warn(`[GrpcQueryClient] Falling back to a full ledger replay from offset 0 for a "${query.relation}" query. `
87
+ + "This is expensive and should be an extreme edge case. It is usually triggered by a \"contracts\" query "
88
+ + "that does not explicitly prove `active: true` (so archived contracts may be in scope), or by querying "
89
+ + "\"transactions\"/\"events\"/\"exercises\" directly. Add an explicit active:true filter if only current state is needed"
90
+ + (this.options.incrementalHistory === true
91
+ ? "; incrementalHistory is enabled, so later history queries will fetch only new offsets."
92
+ : ", or enable the incrementalHistory option to fetch only new offsets on repeat history queries."));
93
+ }
87
94
  const endInclusive = cached?.activeAtOffset ?? (await this.options.stateService.getLedgerEndAsync({})).offset;
88
95
  const history = await this.snapshots.readHistoryAsync(endInclusive);
89
96
  const transactions = history.updates.flatMap((response) => response.update.oneofKind === "transaction" ? [response.update.transaction] : []);
@@ -120,7 +127,7 @@ class DefaultGrpcQueryDataProvider {
120
127
  else if (query.relation === "watermark") {
121
128
  return (0, grpc_relation_mapper_js_1.createGrpcQueryDataset)((0, grpc_relation_mapper_js_1.mapGrpcQueryRelationFragment)([]), [], endInclusive, this.options.endpointScope ?? "ledger");
122
129
  }
123
- const active = await this.snapshots.readActiveContractsAsync(endInclusive, partiesFor(query));
130
+ const active = await this.snapshots.readActiveContractsAsync(endInclusive, partiesFor(query), pushdownTemplateRefsFor(query));
124
131
  const fragment = (0, grpc_relation_mapper_js_1.mapGrpcQueryRelationFragment)([], active.activeContracts);
125
132
  if (!requiresPackageMetadata(closure)) {
126
133
  return fragmentDataset(fragment, endInclusive, this.options.endpointScope ?? "ledger", false);
@@ -181,6 +188,110 @@ function predicateProvesActive(predicate) {
181
188
  function requiresPackageMetadata(closure) {
182
189
  return closure.has("packages") || closure.has("contractTypes") || closure.has("exercises") || closure.has("exerciseTypes");
183
190
  }
191
+ const MAX_PUSHDOWN_TEMPLATE_FILTERS = 25;
192
+ /**
193
+ * Extracts template filters the ACS request itself can apply, so non-matching contracts are never
194
+ * downloaded or materialized. The participant scans its whole ACS either way — the win is wire volume and
195
+ * client-side decode/freeze work, not node time. Correctness rule: the evaluator re-applies the complete
196
+ * predicate over whatever rows come back, so a pushed filter set only has to be a SUPERSET of possible
197
+ * matches — over-fetching is fine, under-fetching never happens because pins are only read from top-level
198
+ * AND conjuncts (anything under or/not is ignored) and any single conjunct constrains every matching row.
199
+ * Returns undefined (wildcard fetch) when no full package/module/entity pin can be proven or a pinned value
200
+ * is not a syntactically valid identifier (a malformed value can never match, but pushing it would make the
201
+ * node reject the request instead of returning the empty result the evaluator would produce).
202
+ */
203
+ function pushdownTemplateRefsFor(query) {
204
+ if (query.relation !== "contracts") {
205
+ return undefined;
206
+ }
207
+ const stringValues = (operator, value) => operator === "equals" && typeof value === "string"
208
+ ? [value]
209
+ : operator === "in" && Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
210
+ let packageRefs;
211
+ let moduleNames;
212
+ let entityNames;
213
+ let fqnRefs;
214
+ for (const conjunct of flattenAndConjuncts(query.predicate)) {
215
+ if (conjunct.kind === "scalar" && conjunct.path.length === 2 && conjunct.path[0] === "templateId") {
216
+ const values = stringValues(conjunct.operator, conjunct.value);
217
+ if (values === undefined) {
218
+ continue;
219
+ }
220
+ else if (conjunct.path[1] === "packageId") {
221
+ packageRefs ??= values;
222
+ }
223
+ else if (conjunct.path[1] === "moduleName") {
224
+ moduleNames ??= values;
225
+ }
226
+ else if (conjunct.path[1] === "entityName") {
227
+ entityNames ??= values;
228
+ }
229
+ }
230
+ else if (conjunct.kind === "relation" && conjunct.edge === "contractType" && conjunct.quantifier === "one") {
231
+ for (const inner of flattenAndConjuncts(conjunct.predicate)) {
232
+ if (inner.kind !== "scalar" || inner.path.length !== 1) {
233
+ continue;
234
+ }
235
+ const values = stringValues(inner.operator, inner.value);
236
+ if (values === undefined) {
237
+ continue;
238
+ }
239
+ else if (inner.path[0] === "packageName") {
240
+ packageRefs ??= values.map((name) => `#${name}`);
241
+ }
242
+ else if (inner.path[0] === "moduleName") {
243
+ moduleNames ??= values;
244
+ }
245
+ else if (inner.path[0] === "entityName") {
246
+ entityNames ??= values;
247
+ }
248
+ else if (inner.path[0] === "templateFqn") {
249
+ const triples = values.map(templateRefFromFqn);
250
+ if (triples.every((triple) => triple !== undefined)) {
251
+ fqnRefs ??= triples;
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ const refs = fqnRefs ?? (packageRefs !== undefined && moduleNames !== undefined && entityNames !== undefined
258
+ ? packageRefs.flatMap((packageId) => moduleNames.flatMap((moduleName) => entityNames.map((entityName) => ({ packageId, moduleName, entityName }))))
259
+ : undefined);
260
+ return refs !== undefined && refs.length > 0 && refs.length <= MAX_PUSHDOWN_TEMPLATE_FILTERS && refs.every(isValidTemplateRef)
261
+ ? refs
262
+ : undefined;
263
+ }
264
+ function flattenAndConjuncts(predicate) {
265
+ if (predicate === undefined) {
266
+ return [];
267
+ }
268
+ else if (predicate.kind === "and") {
269
+ return predicate.children.flatMap(flattenAndConjuncts);
270
+ }
271
+ return [predicate];
272
+ }
273
+ function templateRefFromFqn(fqn) {
274
+ const parts = fqn.split(":");
275
+ return parts.length === 3 && parts.every((part) => part.length > 0)
276
+ ? { packageId: `#${parts[0]}`, moduleName: parts[1], entityName: parts[2] }
277
+ : undefined;
278
+ }
279
+ function isValidTemplateRef(ref) {
280
+ const validPackage = ref.packageId.startsWith("#")
281
+ ? /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(ref.packageId.slice(1))
282
+ : /^[0-9a-f]{64}$/.test(ref.packageId);
283
+ if (!validPackage) {
284
+ return false;
285
+ }
286
+ try {
287
+ (0, grpc_query_value_mapper_js_1.validDottedNameString)(ref.moduleName, "template filter module name");
288
+ (0, grpc_query_value_mapper_js_1.validDottedNameString)(ref.entityName, "template filter entity name");
289
+ return true;
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
184
295
  /**
185
296
  * True when contractType metadata can be derived from the fetched creation events alone (see
186
297
  * contractTypeMetadataFromCreations), instead of decoding LF packages. Every CreatedEvent already carries its
@@ -9,7 +9,7 @@ const transaction_filter_js_1 = require("../../transports/grpc/generated/canton/
9
9
  const contracts_mapper_js_1 = require("../../transports/grpc/mappers/contracts-mapper.js");
10
10
  const query_dataset_js_1 = require("../canonical/query-dataset.js");
11
11
  const query_snapshot_incomplete_error_js_1 = require("../errors/query-snapshot-incomplete-error.js");
12
- const DEFAULT_OPTIONS = {
12
+ const DEFAULT_LIMITS = {
13
13
  maxHistoryPages: 10_000,
14
14
  maxHistoryUpdates: 1_000_000,
15
15
  maxActiveContractPages: 10_000,
@@ -20,11 +20,16 @@ class GrpcQuerySnapshotReader {
20
20
  stateService;
21
21
  updateService;
22
22
  options;
23
+ historyCache;
23
24
  constructor(stateService, updateService, options = {}) {
24
25
  this.stateService = stateService;
25
26
  this.updateService = updateService;
26
27
  this.options = validateOptions(options);
27
28
  }
29
+ /** Whether an incremental history window is already held, meaning the next read only fetches new offsets. */
30
+ get hasHistoryCache() {
31
+ return this.historyCache !== undefined;
32
+ }
28
33
  async readCurrentHistoryAsync() {
29
34
  const ledgerEnd = await this.stateService.getLedgerEndAsync({});
30
35
  return this.readHistoryAsync(ledgerEnd.offset);
@@ -32,26 +37,49 @@ class GrpcQuerySnapshotReader {
32
37
  async readHistoryAsync(endInclusive) {
33
38
  const end = parseOffset(endInclusive);
34
39
  if (end === undefined) {
35
- throw this.historyError(endInclusive, "invalid-offset");
40
+ throw this.historyError(LEDGER_BEGIN, endInclusive, "invalid-offset");
41
+ }
42
+ const cached = this.options.incrementalHistory ? this.historyCache : undefined;
43
+ // History is append-only, so a cached window ending at or past the requested offset already contains
44
+ // the complete answer: an exact hit is returned as-is, a shorter request is a prefix of the window.
45
+ if (cached !== undefined && cached.end >= end) {
46
+ const updates = cached.end === end
47
+ ? cached.updates
48
+ : cached.updates.filter((update) => {
49
+ const offset = parseOffset(extractUpdateOffset(update));
50
+ return offset !== undefined && offset <= end;
51
+ });
52
+ return freezeSnapshot({ endInclusive, updates: Object.freeze([...updates]) });
53
+ }
54
+ const beginExclusive = cached?.end ?? 0n;
55
+ const snapshot = await this.readHistoryRangeAsync(beginExclusive, end, endInclusive, cached?.updates ?? []);
56
+ if (this.options.incrementalHistory && (this.historyCache === undefined || this.historyCache.end < end)) {
57
+ this.historyCache = { end, updates: snapshot.updates };
36
58
  }
59
+ return snapshot;
60
+ }
61
+ async readHistoryRangeAsync(beginExclusive, end, endInclusive, seed) {
62
+ const begin = beginExclusive.toString();
37
63
  const pruned = await this.stateService.getLatestPrunedOffsetsAsync({});
38
64
  const prunedUpTo = parseOffset(pruned.participantPrunedUpToInclusive);
39
- if (prunedUpTo === undefined || prunedUpTo !== 0n) {
40
- throw this.historyError(endInclusive, "participant-pruned");
65
+ // Offsets at or below beginExclusive are already held (or not requested), so pruning only breaks the
66
+ // read when it reaches past the range start.
67
+ if (prunedUpTo === undefined || prunedUpTo > beginExclusive) {
68
+ throw this.historyError(begin, endInclusive, "participant-pruned");
41
69
  }
42
70
  const updateFormat = freezeDeep(createHistoryUpdateFormat());
43
71
  const updates = [];
44
72
  const observedPageTokens = new Set();
45
- let expectedLowestExclusive = 0n;
73
+ let expectedLowestExclusive = beginExclusive;
46
74
  let pageToken;
47
75
  let pagesRead = 0;
48
76
  let previousUpdateOffset;
49
77
  while (true) {
50
78
  if (pagesRead >= this.options.maxHistoryPages) {
51
- throw this.historyError(endInclusive, "max-pages-exceeded");
79
+ throw this.historyError(begin, endInclusive, "max-pages-exceeded");
52
80
  }
53
81
  const request = {
54
- beginOffsetExclusive: LEDGER_BEGIN,
82
+ beginOffsetExclusive: begin,
55
83
  endOffsetInclusive: endInclusive,
56
84
  updateFormat,
57
85
  descendingOrder: false,
@@ -62,18 +90,18 @@ class GrpcQuerySnapshotReader {
62
90
  const lowest = parseOffset(response.lowestPageOffsetExclusive);
63
91
  const highest = parseOffset(response.highestPageOffsetInclusive);
64
92
  if (lowest === undefined || highest === undefined) {
65
- throw this.historyError(endInclusive, "missing-boundary");
93
+ throw this.historyError(begin, endInclusive, "missing-boundary");
66
94
  }
67
95
  else if (lowest !== expectedLowestExclusive || highest < lowest || highest > end) {
68
- throw this.historyError(endInclusive, "page-boundary-mismatch");
96
+ throw this.historyError(begin, endInclusive, "page-boundary-mismatch");
69
97
  }
70
- if (response.updates.length > this.options.maxHistoryUpdates - updates.length) {
71
- throw this.historyError(endInclusive, "max-updates-exceeded");
98
+ if (response.updates.length > this.options.maxHistoryUpdates - seed.length - updates.length) {
99
+ throw this.historyError(begin, endInclusive, "max-updates-exceeded");
72
100
  }
73
101
  for (const update of response.updates) {
74
102
  const updateOffset = parseOffset(extractUpdateOffset(update));
75
103
  if (updateOffset === undefined || updateOffset <= lowest || updateOffset > highest || (previousUpdateOffset !== undefined && updateOffset <= previousUpdateOffset)) {
76
- throw this.historyError(endInclusive, "page-boundary-mismatch");
104
+ throw this.historyError(begin, endInclusive, "page-boundary-mismatch");
77
105
  }
78
106
  previousUpdateOffset = updateOffset;
79
107
  }
@@ -81,33 +109,36 @@ class GrpcQuerySnapshotReader {
81
109
  const nextPageToken = response.nextPageToken;
82
110
  if (nextPageToken === undefined || nextPageToken.length === 0) {
83
111
  if (highest !== end) {
84
- throw this.historyError(endInclusive, "nonterminal-page-without-token");
112
+ throw this.historyError(begin, endInclusive, "nonterminal-page-without-token");
85
113
  }
86
114
  return freezeSnapshot({
87
115
  endInclusive,
88
- updates: Object.freeze(updates),
116
+ updates: Object.freeze([...seed, ...updates]),
89
117
  });
90
118
  }
91
119
  else if (highest >= end) {
92
- throw this.historyError(endInclusive, "nonterminal-page-reaches-end");
120
+ throw this.historyError(begin, endInclusive, "nonterminal-page-reaches-end");
93
121
  }
94
122
  else if (highest <= lowest) {
95
- throw this.historyError(endInclusive, "page-boundary-mismatch");
123
+ throw this.historyError(begin, endInclusive, "page-boundary-mismatch");
96
124
  }
97
125
  const tokenKey = tokenKeyFor(nextPageToken);
98
126
  if (observedPageTokens.has(tokenKey)) {
99
- throw this.historyError(endInclusive, "repeated-page-token");
127
+ throw this.historyError(begin, endInclusive, "repeated-page-token");
100
128
  }
101
129
  observedPageTokens.add(tokenKey);
102
130
  expectedLowestExclusive = highest;
103
131
  pageToken = Uint8Array.from(nextPageToken);
104
132
  }
105
133
  }
106
- async readActiveContractsAsync(activeAtOffset, parties) {
134
+ async readActiveContractsAsync(activeAtOffset, parties, templateRefs) {
107
135
  if (parseOffset(activeAtOffset) === undefined) {
108
136
  throw this.activeError(activeAtOffset, "invalid-offset");
109
137
  }
110
- const eventFormat = freezeDeep(parties === undefined ? createAllPartiesEventFormat() : (0, contracts_mapper_js_1.mapGrpcQueryContractsRequest)({ parties }).eventFormat);
138
+ const eventFormat = freezeDeep((0, contracts_mapper_js_1.mapGrpcQueryContractsRequest)({
139
+ ...(parties === undefined ? { allParties: true } : { parties }),
140
+ ...(templateRefs === undefined || templateRefs.length === 0 ? {} : { templateRefs: [...templateRefs] }),
141
+ }).eventFormat);
111
142
  const activeContracts = [];
112
143
  const observedPageTokens = new Set();
113
144
  let pageToken;
@@ -152,9 +183,9 @@ class GrpcQuerySnapshotReader {
152
183
  pageToken = Uint8Array.from(nextPageToken);
153
184
  }
154
185
  }
155
- historyError(endInclusive, reason) {
186
+ historyError(beginExclusive, endInclusive, reason) {
156
187
  return new query_snapshot_incomplete_error_js_1.QuerySnapshotIncompleteError({
157
- beginExclusive: LEDGER_BEGIN,
188
+ beginExclusive,
158
189
  endInclusive,
159
190
  reason,
160
191
  });
@@ -170,13 +201,17 @@ class GrpcQuerySnapshotReader {
170
201
  }
171
202
  exports.GrpcQuerySnapshotReader = GrpcQuerySnapshotReader;
172
203
  function validateOptions(options) {
173
- const validated = { ...DEFAULT_OPTIONS, ...options };
204
+ const { incrementalHistory = false, ...limits } = options;
205
+ if (typeof incrementalHistory !== "boolean") {
206
+ throw new validation_error_js_1.ValidationError("incrementalHistory must be a boolean.");
207
+ }
208
+ const validated = { ...DEFAULT_LIMITS, ...limits };
174
209
  for (const [name, value] of Object.entries(validated)) {
175
210
  if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
176
211
  throw new validation_error_js_1.ValidationError(`${name} must be a finite positive integer.`);
177
212
  }
178
213
  }
179
- return Object.freeze(validated);
214
+ return Object.freeze({ ...validated, incrementalHistory });
180
215
  }
181
216
  function createHistoryUpdateFormat() {
182
217
  return {
@@ -56,6 +56,17 @@ function createFilters(request) {
56
56
  },
57
57
  });
58
58
  }
59
+ for (const ref of request.templateRefs ?? []) {
60
+ cumulative.push({
61
+ identifierFilter: {
62
+ oneofKind: "templateFilter",
63
+ templateFilter: {
64
+ templateId: { packageId: ref.packageId, moduleName: ref.moduleName, entityName: ref.entityName },
65
+ includeCreatedEventBlob,
66
+ },
67
+ },
68
+ });
69
+ }
59
70
  if (request.interfaceId) {
60
71
  cumulative.push({
61
72
  identifierFilter: {
@@ -61,12 +61,19 @@ export class GrpcContractCache {
61
61
  async populateAsync(parties, key) {
62
62
  const activeContracts = [];
63
63
  const seenPageTokens = new Set();
64
+ // Every continuation request must be identical to the first page's request apart from the page
65
+ // token: the participant validates each token against the request it was minted for, so adopting
66
+ // the response's now-explicit activeAtOffset into page 2+ makes it reject the token
67
+ // (INVALID_ACS_PAGE_TOKEN) — the token was prepared for a request with the field absent. The token
68
+ // itself pins the snapshot offset; the echoed offset is only tracked to validate it stays constant.
69
+ const baseRequest = mapGrpcQueryContractsRequest(parties === undefined ? { allParties: true } : { parties });
64
70
  let activeAtOffset;
65
71
  let pageToken;
66
72
  do {
67
- const page = materializeActiveContractsPage(await this.stateService.getActiveContractsPageAsync(mapGrpcQueryContractsRequest(parties === undefined
68
- ? { allParties: true, activeAtOffset, pageToken }
69
- : { parties, activeAtOffset, pageToken })));
73
+ const page = materializeActiveContractsPage(await this.stateService.getActiveContractsPageAsync({
74
+ ...baseRequest,
75
+ pageToken: pageToken === undefined ? undefined : Uint8Array.from(pageToken),
76
+ }));
70
77
  if (activeAtOffset === undefined) {
71
78
  activeAtOffset = page.activeAtOffset;
72
79
  }
@@ -17,6 +17,12 @@ export interface GrpcQueryClientOptions {
17
17
  readonly packageService: Pick<PackageServiceClient, "listPackagesAsync" | "getPackageAsync">;
18
18
  readonly contractCache?: GrpcContractCache;
19
19
  readonly endpointScope?: string;
20
+ /**
21
+ * Opt-in: after the first history replay, keep the materialized window in memory and only fetch offsets
22
+ * past it on later history queries — turning repeat full replays into delta reads. Off by default because
23
+ * the retained window lives for this client's lifetime and its RAM cost is the full replayed history.
24
+ */
25
+ readonly incrementalHistory?: boolean;
20
26
  }
21
27
  export declare class GrpcQueryClient implements QueryClient {
22
28
  private readonly options;
@@ -8,6 +8,7 @@ import { QuerySource } from "../query-source.js";
8
8
  import { contractTypeMetadataFromCreations, createGrpcQueryDataset, mapGrpcQueryRelationFragment, referencedGrpcPackageIds } from "./grpc-relation-mapper.js";
9
9
  import { GrpcPackageRelationReader } from "./grpc-package-relation-reader.js";
10
10
  import { GrpcQuerySnapshotReader } from "./grpc-query-snapshot-reader.js";
11
+ import { validDottedNameString } from "./grpc-query-value-mapper.js";
11
12
  export class GrpcQueryClient {
12
13
  options;
13
14
  source = QuerySource.grpc;
@@ -62,7 +63,7 @@ class DefaultGrpcQueryDataProvider {
62
63
  packages;
63
64
  constructor(options) {
64
65
  this.options = options;
65
- this.snapshots = new GrpcQuerySnapshotReader(options.stateService, options.updateService);
66
+ this.snapshots = new GrpcQuerySnapshotReader(options.stateService, options.updateService, { incrementalHistory: options.incrementalHistory });
66
67
  this.packages = new GrpcPackageRelationReader(options.packageService);
67
68
  }
68
69
  async readDatasetAsync(query) {
@@ -76,10 +77,16 @@ class DefaultGrpcQueryDataProvider {
76
77
  return cachedContractsDataset(cached.contracts, cached.activeAtOffset, this.options.endpointScope ?? "ledger");
77
78
  }
78
79
  else if (needsHistory) {
79
- console.warn(`[GrpcQueryClient] Falling back to a full ledger replay from offset 0 for a "${query.relation}" query. `
80
- + "This is expensive and should be an extreme edge case. It is usually triggered by a \"contracts\" query "
81
- + "that does not explicitly prove `active: true` (so archived contracts may be in scope), or by querying "
82
- + "\"transactions\"/\"events\"/\"exercises\" directly. Add an explicit active:true filter if only current state is needed.");
80
+ // With a warm incremental window only the new offsets are fetched, which is no longer worth a warning.
81
+ if (!(this.options.incrementalHistory === true && this.snapshots.hasHistoryCache)) {
82
+ console.warn(`[GrpcQueryClient] Falling back to a full ledger replay from offset 0 for a "${query.relation}" query. `
83
+ + "This is expensive and should be an extreme edge case. It is usually triggered by a \"contracts\" query "
84
+ + "that does not explicitly prove `active: true` (so archived contracts may be in scope), or by querying "
85
+ + "\"transactions\"/\"events\"/\"exercises\" directly. Add an explicit active:true filter if only current state is needed"
86
+ + (this.options.incrementalHistory === true
87
+ ? "; incrementalHistory is enabled, so later history queries will fetch only new offsets."
88
+ : ", or enable the incrementalHistory option to fetch only new offsets on repeat history queries."));
89
+ }
83
90
  const endInclusive = cached?.activeAtOffset ?? (await this.options.stateService.getLedgerEndAsync({})).offset;
84
91
  const history = await this.snapshots.readHistoryAsync(endInclusive);
85
92
  const transactions = history.updates.flatMap((response) => response.update.oneofKind === "transaction" ? [response.update.transaction] : []);
@@ -116,7 +123,7 @@ class DefaultGrpcQueryDataProvider {
116
123
  else if (query.relation === "watermark") {
117
124
  return createGrpcQueryDataset(mapGrpcQueryRelationFragment([]), [], endInclusive, this.options.endpointScope ?? "ledger");
118
125
  }
119
- const active = await this.snapshots.readActiveContractsAsync(endInclusive, partiesFor(query));
126
+ const active = await this.snapshots.readActiveContractsAsync(endInclusive, partiesFor(query), pushdownTemplateRefsFor(query));
120
127
  const fragment = mapGrpcQueryRelationFragment([], active.activeContracts);
121
128
  if (!requiresPackageMetadata(closure)) {
122
129
  return fragmentDataset(fragment, endInclusive, this.options.endpointScope ?? "ledger", false);
@@ -177,6 +184,110 @@ function predicateProvesActive(predicate) {
177
184
  function requiresPackageMetadata(closure) {
178
185
  return closure.has("packages") || closure.has("contractTypes") || closure.has("exercises") || closure.has("exerciseTypes");
179
186
  }
187
+ const MAX_PUSHDOWN_TEMPLATE_FILTERS = 25;
188
+ /**
189
+ * Extracts template filters the ACS request itself can apply, so non-matching contracts are never
190
+ * downloaded or materialized. The participant scans its whole ACS either way — the win is wire volume and
191
+ * client-side decode/freeze work, not node time. Correctness rule: the evaluator re-applies the complete
192
+ * predicate over whatever rows come back, so a pushed filter set only has to be a SUPERSET of possible
193
+ * matches — over-fetching is fine, under-fetching never happens because pins are only read from top-level
194
+ * AND conjuncts (anything under or/not is ignored) and any single conjunct constrains every matching row.
195
+ * Returns undefined (wildcard fetch) when no full package/module/entity pin can be proven or a pinned value
196
+ * is not a syntactically valid identifier (a malformed value can never match, but pushing it would make the
197
+ * node reject the request instead of returning the empty result the evaluator would produce).
198
+ */
199
+ function pushdownTemplateRefsFor(query) {
200
+ if (query.relation !== "contracts") {
201
+ return undefined;
202
+ }
203
+ const stringValues = (operator, value) => operator === "equals" && typeof value === "string"
204
+ ? [value]
205
+ : operator === "in" && Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
206
+ let packageRefs;
207
+ let moduleNames;
208
+ let entityNames;
209
+ let fqnRefs;
210
+ for (const conjunct of flattenAndConjuncts(query.predicate)) {
211
+ if (conjunct.kind === "scalar" && conjunct.path.length === 2 && conjunct.path[0] === "templateId") {
212
+ const values = stringValues(conjunct.operator, conjunct.value);
213
+ if (values === undefined) {
214
+ continue;
215
+ }
216
+ else if (conjunct.path[1] === "packageId") {
217
+ packageRefs ??= values;
218
+ }
219
+ else if (conjunct.path[1] === "moduleName") {
220
+ moduleNames ??= values;
221
+ }
222
+ else if (conjunct.path[1] === "entityName") {
223
+ entityNames ??= values;
224
+ }
225
+ }
226
+ else if (conjunct.kind === "relation" && conjunct.edge === "contractType" && conjunct.quantifier === "one") {
227
+ for (const inner of flattenAndConjuncts(conjunct.predicate)) {
228
+ if (inner.kind !== "scalar" || inner.path.length !== 1) {
229
+ continue;
230
+ }
231
+ const values = stringValues(inner.operator, inner.value);
232
+ if (values === undefined) {
233
+ continue;
234
+ }
235
+ else if (inner.path[0] === "packageName") {
236
+ packageRefs ??= values.map((name) => `#${name}`);
237
+ }
238
+ else if (inner.path[0] === "moduleName") {
239
+ moduleNames ??= values;
240
+ }
241
+ else if (inner.path[0] === "entityName") {
242
+ entityNames ??= values;
243
+ }
244
+ else if (inner.path[0] === "templateFqn") {
245
+ const triples = values.map(templateRefFromFqn);
246
+ if (triples.every((triple) => triple !== undefined)) {
247
+ fqnRefs ??= triples;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ const refs = fqnRefs ?? (packageRefs !== undefined && moduleNames !== undefined && entityNames !== undefined
254
+ ? packageRefs.flatMap((packageId) => moduleNames.flatMap((moduleName) => entityNames.map((entityName) => ({ packageId, moduleName, entityName }))))
255
+ : undefined);
256
+ return refs !== undefined && refs.length > 0 && refs.length <= MAX_PUSHDOWN_TEMPLATE_FILTERS && refs.every(isValidTemplateRef)
257
+ ? refs
258
+ : undefined;
259
+ }
260
+ function flattenAndConjuncts(predicate) {
261
+ if (predicate === undefined) {
262
+ return [];
263
+ }
264
+ else if (predicate.kind === "and") {
265
+ return predicate.children.flatMap(flattenAndConjuncts);
266
+ }
267
+ return [predicate];
268
+ }
269
+ function templateRefFromFqn(fqn) {
270
+ const parts = fqn.split(":");
271
+ return parts.length === 3 && parts.every((part) => part.length > 0)
272
+ ? { packageId: `#${parts[0]}`, moduleName: parts[1], entityName: parts[2] }
273
+ : undefined;
274
+ }
275
+ function isValidTemplateRef(ref) {
276
+ const validPackage = ref.packageId.startsWith("#")
277
+ ? /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(ref.packageId.slice(1))
278
+ : /^[0-9a-f]{64}$/.test(ref.packageId);
279
+ if (!validPackage) {
280
+ return false;
281
+ }
282
+ try {
283
+ validDottedNameString(ref.moduleName, "template filter module name");
284
+ validDottedNameString(ref.entityName, "template filter entity name");
285
+ return true;
286
+ }
287
+ catch {
288
+ return false;
289
+ }
290
+ }
180
291
  /**
181
292
  * True when contractType metadata can be derived from the fetched creation events alone (see
182
293
  * contractTypeMetadataFromCreations), instead of decoding LF packages. Every CreatedEvent already carries its
@@ -2,6 +2,7 @@ import { StateServiceClient } from "../../services/state/state-service-client.js
2
2
  import { UpdateServiceClient } from "../../services/update/update-service-client.js";
3
3
  import { type GetActiveContractsResponse as GetActiveContractsResponseType } from "../../transports/grpc/generated/canton/com/daml/ledger/api/v2/state_service.js";
4
4
  import { type GetUpdateResponse as GetUpdateResponseType } from "../../transports/grpc/generated/canton/com/daml/ledger/api/v2/update_service.js";
5
+ import { type GrpcQueryTemplateRef } from "../../transports/grpc/mappers/contracts-mapper.js";
5
6
  type StateSnapshotReader = Pick<StateServiceClient, "getLedgerEndAsync" | "getLatestPrunedOffsetsAsync" | "getActiveContractsPageAsync">;
6
7
  type UpdateSnapshotReader = Pick<UpdateServiceClient, "getUpdatesPageAsync">;
7
8
  export interface GrpcHistorySnapshot {
@@ -17,15 +18,25 @@ export interface GrpcQuerySnapshotReaderOptions {
17
18
  readonly maxHistoryUpdates?: number;
18
19
  readonly maxActiveContractPages?: number;
19
20
  readonly maxActiveContracts?: number;
21
+ /**
22
+ * Opt-in: retain the last replayed history window in memory and only fetch offsets past it on later
23
+ * reads. Off by default because the retained window lives for this reader's lifetime — its RAM cost is
24
+ * the full materialized history, bounded only by maxHistoryUpdates.
25
+ */
26
+ readonly incrementalHistory?: boolean;
20
27
  }
21
28
  export declare class GrpcQuerySnapshotReader {
22
29
  private readonly stateService;
23
30
  private readonly updateService;
24
31
  private readonly options;
32
+ private historyCache;
25
33
  constructor(stateService: StateSnapshotReader, updateService: UpdateSnapshotReader, options?: GrpcQuerySnapshotReaderOptions);
34
+ /** Whether an incremental history window is already held, meaning the next read only fetches new offsets. */
35
+ get hasHistoryCache(): boolean;
26
36
  readCurrentHistoryAsync(): Promise<GrpcHistorySnapshot>;
27
37
  readHistoryAsync(endInclusive: string): Promise<GrpcHistorySnapshot>;
28
- readActiveContractsAsync(activeAtOffset: string, parties?: readonly string[]): Promise<GrpcActiveContractSnapshot>;
38
+ private readHistoryRangeAsync;
39
+ readActiveContractsAsync(activeAtOffset: string, parties?: readonly string[], templateRefs?: readonly GrpcQueryTemplateRef[]): Promise<GrpcActiveContractSnapshot>;
29
40
  private historyError;
30
41
  private activeError;
31
42
  }
@@ -5,7 +5,7 @@ import { TransactionShape } from "../../transports/grpc/generated/canton/com/dam
5
5
  import { mapGrpcQueryContractsRequest } from "../../transports/grpc/mappers/contracts-mapper.js";
6
6
  import { immutableQueryValue } from "../canonical/query-dataset.js";
7
7
  import { QuerySnapshotIncompleteError } from "../errors/query-snapshot-incomplete-error.js";
8
- const DEFAULT_OPTIONS = {
8
+ const DEFAULT_LIMITS = {
9
9
  maxHistoryPages: 10_000,
10
10
  maxHistoryUpdates: 1_000_000,
11
11
  maxActiveContractPages: 10_000,
@@ -16,11 +16,16 @@ export class GrpcQuerySnapshotReader {
16
16
  stateService;
17
17
  updateService;
18
18
  options;
19
+ historyCache;
19
20
  constructor(stateService, updateService, options = {}) {
20
21
  this.stateService = stateService;
21
22
  this.updateService = updateService;
22
23
  this.options = validateOptions(options);
23
24
  }
25
+ /** Whether an incremental history window is already held, meaning the next read only fetches new offsets. */
26
+ get hasHistoryCache() {
27
+ return this.historyCache !== undefined;
28
+ }
24
29
  async readCurrentHistoryAsync() {
25
30
  const ledgerEnd = await this.stateService.getLedgerEndAsync({});
26
31
  return this.readHistoryAsync(ledgerEnd.offset);
@@ -28,26 +33,49 @@ export class GrpcQuerySnapshotReader {
28
33
  async readHistoryAsync(endInclusive) {
29
34
  const end = parseOffset(endInclusive);
30
35
  if (end === undefined) {
31
- throw this.historyError(endInclusive, "invalid-offset");
36
+ throw this.historyError(LEDGER_BEGIN, endInclusive, "invalid-offset");
37
+ }
38
+ const cached = this.options.incrementalHistory ? this.historyCache : undefined;
39
+ // History is append-only, so a cached window ending at or past the requested offset already contains
40
+ // the complete answer: an exact hit is returned as-is, a shorter request is a prefix of the window.
41
+ if (cached !== undefined && cached.end >= end) {
42
+ const updates = cached.end === end
43
+ ? cached.updates
44
+ : cached.updates.filter((update) => {
45
+ const offset = parseOffset(extractUpdateOffset(update));
46
+ return offset !== undefined && offset <= end;
47
+ });
48
+ return freezeSnapshot({ endInclusive, updates: Object.freeze([...updates]) });
49
+ }
50
+ const beginExclusive = cached?.end ?? 0n;
51
+ const snapshot = await this.readHistoryRangeAsync(beginExclusive, end, endInclusive, cached?.updates ?? []);
52
+ if (this.options.incrementalHistory && (this.historyCache === undefined || this.historyCache.end < end)) {
53
+ this.historyCache = { end, updates: snapshot.updates };
32
54
  }
55
+ return snapshot;
56
+ }
57
+ async readHistoryRangeAsync(beginExclusive, end, endInclusive, seed) {
58
+ const begin = beginExclusive.toString();
33
59
  const pruned = await this.stateService.getLatestPrunedOffsetsAsync({});
34
60
  const prunedUpTo = parseOffset(pruned.participantPrunedUpToInclusive);
35
- if (prunedUpTo === undefined || prunedUpTo !== 0n) {
36
- throw this.historyError(endInclusive, "participant-pruned");
61
+ // Offsets at or below beginExclusive are already held (or not requested), so pruning only breaks the
62
+ // read when it reaches past the range start.
63
+ if (prunedUpTo === undefined || prunedUpTo > beginExclusive) {
64
+ throw this.historyError(begin, endInclusive, "participant-pruned");
37
65
  }
38
66
  const updateFormat = freezeDeep(createHistoryUpdateFormat());
39
67
  const updates = [];
40
68
  const observedPageTokens = new Set();
41
- let expectedLowestExclusive = 0n;
69
+ let expectedLowestExclusive = beginExclusive;
42
70
  let pageToken;
43
71
  let pagesRead = 0;
44
72
  let previousUpdateOffset;
45
73
  while (true) {
46
74
  if (pagesRead >= this.options.maxHistoryPages) {
47
- throw this.historyError(endInclusive, "max-pages-exceeded");
75
+ throw this.historyError(begin, endInclusive, "max-pages-exceeded");
48
76
  }
49
77
  const request = {
50
- beginOffsetExclusive: LEDGER_BEGIN,
78
+ beginOffsetExclusive: begin,
51
79
  endOffsetInclusive: endInclusive,
52
80
  updateFormat,
53
81
  descendingOrder: false,
@@ -58,18 +86,18 @@ export class GrpcQuerySnapshotReader {
58
86
  const lowest = parseOffset(response.lowestPageOffsetExclusive);
59
87
  const highest = parseOffset(response.highestPageOffsetInclusive);
60
88
  if (lowest === undefined || highest === undefined) {
61
- throw this.historyError(endInclusive, "missing-boundary");
89
+ throw this.historyError(begin, endInclusive, "missing-boundary");
62
90
  }
63
91
  else if (lowest !== expectedLowestExclusive || highest < lowest || highest > end) {
64
- throw this.historyError(endInclusive, "page-boundary-mismatch");
92
+ throw this.historyError(begin, endInclusive, "page-boundary-mismatch");
65
93
  }
66
- if (response.updates.length > this.options.maxHistoryUpdates - updates.length) {
67
- throw this.historyError(endInclusive, "max-updates-exceeded");
94
+ if (response.updates.length > this.options.maxHistoryUpdates - seed.length - updates.length) {
95
+ throw this.historyError(begin, endInclusive, "max-updates-exceeded");
68
96
  }
69
97
  for (const update of response.updates) {
70
98
  const updateOffset = parseOffset(extractUpdateOffset(update));
71
99
  if (updateOffset === undefined || updateOffset <= lowest || updateOffset > highest || (previousUpdateOffset !== undefined && updateOffset <= previousUpdateOffset)) {
72
- throw this.historyError(endInclusive, "page-boundary-mismatch");
100
+ throw this.historyError(begin, endInclusive, "page-boundary-mismatch");
73
101
  }
74
102
  previousUpdateOffset = updateOffset;
75
103
  }
@@ -77,33 +105,36 @@ export class GrpcQuerySnapshotReader {
77
105
  const nextPageToken = response.nextPageToken;
78
106
  if (nextPageToken === undefined || nextPageToken.length === 0) {
79
107
  if (highest !== end) {
80
- throw this.historyError(endInclusive, "nonterminal-page-without-token");
108
+ throw this.historyError(begin, endInclusive, "nonterminal-page-without-token");
81
109
  }
82
110
  return freezeSnapshot({
83
111
  endInclusive,
84
- updates: Object.freeze(updates),
112
+ updates: Object.freeze([...seed, ...updates]),
85
113
  });
86
114
  }
87
115
  else if (highest >= end) {
88
- throw this.historyError(endInclusive, "nonterminal-page-reaches-end");
116
+ throw this.historyError(begin, endInclusive, "nonterminal-page-reaches-end");
89
117
  }
90
118
  else if (highest <= lowest) {
91
- throw this.historyError(endInclusive, "page-boundary-mismatch");
119
+ throw this.historyError(begin, endInclusive, "page-boundary-mismatch");
92
120
  }
93
121
  const tokenKey = tokenKeyFor(nextPageToken);
94
122
  if (observedPageTokens.has(tokenKey)) {
95
- throw this.historyError(endInclusive, "repeated-page-token");
123
+ throw this.historyError(begin, endInclusive, "repeated-page-token");
96
124
  }
97
125
  observedPageTokens.add(tokenKey);
98
126
  expectedLowestExclusive = highest;
99
127
  pageToken = Uint8Array.from(nextPageToken);
100
128
  }
101
129
  }
102
- async readActiveContractsAsync(activeAtOffset, parties) {
130
+ async readActiveContractsAsync(activeAtOffset, parties, templateRefs) {
103
131
  if (parseOffset(activeAtOffset) === undefined) {
104
132
  throw this.activeError(activeAtOffset, "invalid-offset");
105
133
  }
106
- const eventFormat = freezeDeep(parties === undefined ? createAllPartiesEventFormat() : mapGrpcQueryContractsRequest({ parties }).eventFormat);
134
+ const eventFormat = freezeDeep(mapGrpcQueryContractsRequest({
135
+ ...(parties === undefined ? { allParties: true } : { parties }),
136
+ ...(templateRefs === undefined || templateRefs.length === 0 ? {} : { templateRefs: [...templateRefs] }),
137
+ }).eventFormat);
107
138
  const activeContracts = [];
108
139
  const observedPageTokens = new Set();
109
140
  let pageToken;
@@ -148,9 +179,9 @@ export class GrpcQuerySnapshotReader {
148
179
  pageToken = Uint8Array.from(nextPageToken);
149
180
  }
150
181
  }
151
- historyError(endInclusive, reason) {
182
+ historyError(beginExclusive, endInclusive, reason) {
152
183
  return new QuerySnapshotIncompleteError({
153
- beginExclusive: LEDGER_BEGIN,
184
+ beginExclusive,
154
185
  endInclusive,
155
186
  reason,
156
187
  });
@@ -165,13 +196,17 @@ export class GrpcQuerySnapshotReader {
165
196
  }
166
197
  }
167
198
  function validateOptions(options) {
168
- const validated = { ...DEFAULT_OPTIONS, ...options };
199
+ const { incrementalHistory = false, ...limits } = options;
200
+ if (typeof incrementalHistory !== "boolean") {
201
+ throw new ValidationError("incrementalHistory must be a boolean.");
202
+ }
203
+ const validated = { ...DEFAULT_LIMITS, ...limits };
169
204
  for (const [name, value] of Object.entries(validated)) {
170
205
  if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
171
206
  throw new ValidationError(`${name} must be a finite positive integer.`);
172
207
  }
173
208
  }
174
- return Object.freeze(validated);
209
+ return Object.freeze({ ...validated, incrementalHistory });
175
210
  }
176
211
  function createHistoryUpdateFormat() {
177
212
  return {
@@ -1,10 +1,17 @@
1
1
  import { QueryContractsResponse } from "../../../core/types/responses/query-contracts-response.js";
2
2
  import { GetActiveContractsPageRequest } from "../generated/canton/com/daml/ledger/api/v2/state_service.js";
3
+ /** A fully qualified template reference; packageId is a concrete package id or a "#package-name" reference. */
4
+ export interface GrpcQueryTemplateRef {
5
+ readonly packageId: string;
6
+ readonly moduleName: string;
7
+ readonly entityName: string;
8
+ }
3
9
  export declare function mapGrpcQueryContractsRequest(request: {
4
10
  party?: string;
5
11
  parties?: readonly string[];
6
12
  allParties?: boolean;
7
13
  templateId?: string;
14
+ templateRefs?: readonly GrpcQueryTemplateRef[];
8
15
  interfaceId?: string;
9
16
  includeInterfaceView?: boolean;
10
17
  includeCreatedEventBlob?: boolean;
@@ -52,6 +52,17 @@ function createFilters(request) {
52
52
  },
53
53
  });
54
54
  }
55
+ for (const ref of request.templateRefs ?? []) {
56
+ cumulative.push({
57
+ identifierFilter: {
58
+ oneofKind: "templateFilter",
59
+ templateFilter: {
60
+ templateId: { packageId: ref.packageId, moduleName: ref.moduleName, entityName: ref.entityName },
61
+ includeCreatedEventBlob,
62
+ },
63
+ },
64
+ });
65
+ }
55
66
  if (request.interfaceId) {
56
67
  cumulative.push({
57
68
  identifierFilter: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@distrohelena/canton-typescript-sdk",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",