@decaf-ts/for-fabric 0.13.9 → 0.13.11

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.
@@ -1,11 +1,11 @@
1
- import { CouchDBAdapter, CouchDBKeys, } from "@decaf-ts/for-couchdb";
1
+ import { CouchDBAdapter, CouchDBKeys, ensureDeterministicSort as couchEnsureDeterministicSort, getSortDirection, getSortFields, warnScanProneMangoOperators, } from "@decaf-ts/for-couchdb";
2
2
  import { Model, ValidationKeys } from "@decaf-ts/decorator-validation";
3
3
  import { createHash } from "crypto";
4
4
  import { FabricContractContext } from "./ContractContext.js";
5
5
  import { BadRequestError, BulkCrudOperationKeys, ConflictError, InternalError, NotFoundError, onCreate, onCreateUpdate, SerializationError, } from "@decaf-ts/db-decorators";
6
6
  import { Object as FabricObject, Property, Property as FabricProperty, } from "fabric-contract-api";
7
7
  import { Logging } from "@decaf-ts/logging";
8
- import { PersistenceKeys, UnsupportedError, Adapter, QueryError, PagingError, MigrationError, ObserverError, AuthorizationError, ForbiddenError, ConnectionError, Context, TransactionOperationKeys, promiseSequence, resolveBulkSequenceResult, } from "@decaf-ts/core";
8
+ import { PersistenceKeys, UnsupportedError, Adapter, QueryError, PagingError, MigrationError, ObserverError, AuthorizationError, ForbiddenError, ConnectionError, Context, OrderDirection, TransactionOperationKeys, promiseSequence, resolveBulkSequenceResult, } from "@decaf-ts/core";
9
9
  import { FabricContractRepository } from "./FabricContractRepository.js";
10
10
  import { FabricStatement } from "./FabricContractStatement.js";
11
11
  import { FabricContractSequence } from "./FabricContractSequence.js";
@@ -116,114 +116,104 @@ export class FabricContractAdapter extends CouchDBAdapter {
116
116
  .update(FabricContractAdapter.stableStringify(relevant))
117
117
  .digest("base64url");
118
118
  }
119
- static parseSyntheticPrivateBookmark(bookmark) {
120
- if (typeof bookmark !== "string")
121
- return undefined;
122
- if (!bookmark.startsWith(FabricContractAdapter.PRIVATE_BOOKMARK_PREFIX))
123
- return undefined;
124
- const raw = bookmark.slice(FabricContractAdapter.PRIVATE_BOOKMARK_PREFIX.length);
125
- try {
126
- const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
127
- if (!parsed ||
128
- typeof parsed !== "object" ||
129
- typeof parsed.sortField !== "string" ||
130
- typeof parsed.direction !== "string" ||
131
- typeof parsed.idField !== "string" ||
132
- typeof parsed.lastId !== "string"
133
- || typeof parsed.queryHash !== "string") {
134
- return undefined;
135
- }
136
- return {
137
- sortField: parsed.sortField,
138
- direction: parsed.direction === "desc" ? "desc" : "asc",
139
- idField: parsed.idField,
140
- lastValue: parsed.lastValue,
141
- lastId: parsed.lastId,
142
- queryHash: parsed.queryHash,
143
- };
144
- }
145
- catch {
146
- return undefined;
147
- }
148
- }
149
- static buildSyntheticPrivateBookmark(cursor) {
150
- return `${FabricContractAdapter.PRIVATE_BOOKMARK_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString("base64url")}`;
151
- }
152
- static buildPrivateCursorSelector(selector, sortField, direction, cursor) {
119
+ static buildPrivateContinuationSelector(selector, sortField, direction, idField, lastValue, lastId, variant) {
153
120
  const cmp = direction === "desc" ? "$lt" : "$gt";
154
- if (cursor.idField === sortField) {
121
+ const idCmp = direction === "desc" ? "$lt" : "$gt";
122
+ if (idField === sortField) {
155
123
  const continuation = {
156
124
  [sortField]: {
157
- [cmp]: cursor.lastValue,
125
+ [cmp]: lastValue,
158
126
  },
159
127
  };
160
- if (!Object.keys(selector || {}).length)
128
+ if (!selector || !Object.keys(selector).length) {
161
129
  return continuation;
130
+ }
162
131
  return {
163
132
  $and: [selector, continuation],
164
133
  };
165
134
  }
166
- const continuation = {
167
- $or: [
135
+ const clauses = variant === "same"
136
+ ? [
168
137
  {
169
138
  [sortField]: {
170
- [cmp]: cursor.lastValue,
139
+ $eq: lastValue,
171
140
  },
172
141
  },
173
142
  {
174
- $and: [
175
- {
176
- [sortField]: {
177
- $eq: cursor.lastValue,
178
- },
179
- },
180
- {
181
- [cursor.idField]: {
182
- [cmp]: cursor.lastId,
183
- },
184
- },
185
- ],
143
+ [idField]: {
144
+ [idCmp]: lastId,
145
+ },
186
146
  },
187
- ],
188
- };
189
- if (!Object.keys(selector || {}).length)
190
- return continuation;
147
+ ]
148
+ : [
149
+ {
150
+ [sortField]: {
151
+ [cmp]: lastValue,
152
+ },
153
+ },
154
+ ];
155
+ if (!selector || !Object.keys(selector).length) {
156
+ return clauses.length === 1
157
+ ? clauses[0]
158
+ : {
159
+ $and: clauses,
160
+ };
161
+ }
191
162
  return {
192
- $and: [selector, continuation],
163
+ $and: [selector, ...clauses],
193
164
  };
194
165
  }
195
- static normalizePrivateSort(query, idField = "id") {
196
- if (!Array.isArray(query.sort) || query.sort.length === 0) {
197
- throw new PagingError("Private collection pagination requires an explicit Mango sort");
166
+ static buildOrlessKeysetContinuationQueries(query, cursor, pageSize) {
167
+ const baseQuery = { ...query };
168
+ delete baseQuery.skip;
169
+ delete baseQuery.bookmark;
170
+ delete baseQuery.limit;
171
+ if (cursor.idField === cursor.sortField) {
172
+ const single = {
173
+ ...baseQuery,
174
+ selector: FabricContractAdapter.buildPrivateContinuationSelector((baseQuery.selector || {}), cursor.sortField, cursor.direction, cursor.idField, cursor.lastValue, cursor.lastId, "same"),
175
+ limit: pageSize + 1,
176
+ };
177
+ return [single];
198
178
  }
199
- const firstSort = Object.entries(query.sort[0] || {})[0];
200
- if (!firstSort || typeof firstSort[0] !== "string") {
201
- throw new PagingError("Private collection pagination requires a valid first sort field");
179
+ const first = {
180
+ ...baseQuery,
181
+ selector: FabricContractAdapter.buildPrivateContinuationSelector((baseQuery.selector || {}), cursor.sortField, cursor.direction, cursor.idField, cursor.lastValue, cursor.lastId, "same"),
182
+ limit: pageSize + 1,
183
+ };
184
+ const second = {
185
+ ...baseQuery,
186
+ selector: FabricContractAdapter.buildPrivateContinuationSelector((baseQuery.selector || {}), cursor.sortField, cursor.direction, cursor.idField, cursor.lastValue, cursor.lastId, "next"),
187
+ limit: pageSize + 1,
188
+ };
189
+ return [first, second];
190
+ }
191
+ static ensureDeterministicPrivateSort(query, tieBreaker) {
192
+ if (!Array.isArray(query.sort) || !query.sort.length) {
193
+ throw new PagingError("Private Mango pagination requires an explicit sort. Add orderBy(...) so a stable generated index can be selected.");
202
194
  }
203
- const sortField = firstSort[0];
204
- const direction = String(firstSort[1] || "asc").toLowerCase();
205
- if (direction !== "asc" && direction !== "desc") {
206
- throw new PagingError(`Unsupported private pagination sort direction: ${direction}`);
195
+ const direction = (getSortDirection(query) ||
196
+ OrderDirection.ASC);
197
+ couchEnsureDeterministicSort(query, tieBreaker, direction);
198
+ const sortFields = getSortFields(query);
199
+ const sortField = sortFields[0];
200
+ if (!sortField) {
201
+ throw new PagingError("Private Mango pagination requires a valid first sort field.");
207
202
  }
208
- const hasIdTieBreaker = idField === sortField ||
209
- query.sort.some((entry) => {
210
- const [field, dir] = Object.entries(entry || {})[0] || [];
211
- return (field === idField && String(dir || "").toLowerCase() === direction);
212
- });
213
- if (!hasIdTieBreaker && idField !== sortField) {
214
- query.sort = [...query.sort, { [idField]: direction }];
203
+ if (!sortFields.includes(tieBreaker)) {
204
+ throw new PagingError(`Private Mango pagination requires tie-breaker sort field "${tieBreaker}".`);
215
205
  }
216
206
  return {
217
207
  sortField,
218
208
  direction: direction,
219
- idField,
209
+ idField: tieBreaker,
220
210
  };
221
211
  }
222
212
  static parsePrivateResultValue(value) {
223
213
  try {
224
214
  const parsed = JSON.parse(value.toString("utf8"));
225
- if (!parsed || typeof parsed !== "object") {
226
- throw new Error("Private query result is not an object");
215
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
216
+ throw new Error("Private query result is not a JSON object");
227
217
  }
228
218
  return parsed;
229
219
  }
@@ -231,6 +221,102 @@ export class FabricContractAdapter extends CouchDBAdapter {
231
221
  throw new SerializationError(`Failed to parse private query result while building bookmark: ${e}`);
232
222
  }
233
223
  }
224
+ static async executePrivateMangoPageQueries(stub, collection, queries, pageSize, log) {
225
+ const paged = [];
226
+ for (const query of queries) {
227
+ if (paged.length >= pageSize + 1) {
228
+ break;
229
+ }
230
+ const remaining = pageSize + 1 - paged.length;
231
+ const queryForExecution = {
232
+ ...query,
233
+ limit: remaining,
234
+ };
235
+ delete queryForExecution.skip;
236
+ delete queryForExecution.bookmark;
237
+ log.debug(`Querying collection ${collection} for ${JSON.stringify(queryForExecution)}`);
238
+ const response = await stub.getPrivateDataQueryResult(collection, JSON.stringify(queryForExecution));
239
+ const iterator = (response.iterator ||
240
+ response);
241
+ try {
242
+ while (paged.length < pageSize + 1) {
243
+ const res = await iterator.next();
244
+ if (res.done)
245
+ break;
246
+ if (!res.value || !res.value.value)
247
+ continue;
248
+ paged.push({
249
+ key: res.value.key,
250
+ value: Buffer.isBuffer(res.value.value)
251
+ ? res.value.value
252
+ : Buffer.from(res.value.value.toString("utf8")),
253
+ });
254
+ }
255
+ }
256
+ finally {
257
+ await iterator.close();
258
+ }
259
+ }
260
+ return paged;
261
+ }
262
+ static toFabricPaginationResponse(docs, bookmark) {
263
+ let idx = 0;
264
+ const iterator = {
265
+ async next() {
266
+ if (idx < docs.length) {
267
+ return {
268
+ value: docs[idx++],
269
+ done: false,
270
+ };
271
+ }
272
+ return {
273
+ value: undefined,
274
+ done: true,
275
+ };
276
+ },
277
+ async close() { },
278
+ };
279
+ return {
280
+ iterator: iterator,
281
+ metadata: {
282
+ fetchedRecordsCount: docs.length,
283
+ bookmark,
284
+ },
285
+ };
286
+ }
287
+ static parseSyntheticPrivateBookmark(bookmark) {
288
+ if (typeof bookmark !== "string")
289
+ return undefined;
290
+ if (!bookmark.startsWith(FabricContractAdapter.PRIVATE_BOOKMARK_PREFIX))
291
+ return undefined;
292
+ const raw = bookmark.slice(FabricContractAdapter.PRIVATE_BOOKMARK_PREFIX.length);
293
+ try {
294
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
295
+ if (!parsed ||
296
+ typeof parsed !== "object" ||
297
+ typeof parsed.sortField !== "string" ||
298
+ typeof parsed.direction !== "string" ||
299
+ typeof parsed.idField !== "string" ||
300
+ typeof parsed.lastId !== "string"
301
+ || typeof parsed.queryHash !== "string") {
302
+ return undefined;
303
+ }
304
+ return {
305
+ sortField: parsed.sortField,
306
+ direction: parsed.direction === "desc" ? "desc" : "asc",
307
+ idField: parsed.idField,
308
+ lastValue: parsed.lastValue,
309
+ lastId: parsed.lastId,
310
+ queryHash: parsed.queryHash,
311
+ };
312
+ }
313
+ catch {
314
+ return undefined;
315
+ }
316
+ }
317
+ static buildSyntheticPrivateBookmark(cursor) {
318
+ return `${FabricContractAdapter.PRIVATE_BOOKMARK_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString("base64url")}`;
319
+ }
234
320
  getClient() {
235
321
  throw new UnsupportedError("Client is not supported in Fabric contracts");
236
322
  }
@@ -623,76 +709,48 @@ export class FabricContractAdapter extends CouchDBAdapter {
623
709
  }
624
710
  case "queryResultPaginated": {
625
711
  const [stub, rawInput, limit, skip, bookmark, ...args] = argsList;
626
- const { log } = thisArg["logCtx"](args, prop);
712
+ const { log, ctx } = thisArg["logCtx"](args, prop);
627
713
  if (skip !== undefined && skip !== null && Number(skip) > 0) {
628
- throw new PagingError("Private collection pagination does not support skip/offset pagination. Use the returned bookmark instead.");
714
+ throw new PagingError("Private Mango pagination does not support skip/offset pagination. Use the returned synthetic bookmark instead.");
629
715
  }
630
716
  const pageSize = Math.max(1, Number(limit) || 250);
631
717
  const query = { ...rawInput };
632
- const queryPkField = typeof query["__pkField"] === "string" &&
633
- query["__pkField"].trim().length
634
- ? String(query["__pkField"])
635
- : "id";
636
- delete query["__pkField"];
637
- const { sortField, direction: sortDirection, idField, } = FabricContractAdapter.normalizePrivateSort(query, queryPkField);
718
+ const queryInput = query;
719
+ const queryPkField = typeof queryInput["__pkField"] === "string" &&
720
+ queryInput["__pkField"].trim().length
721
+ ? String(queryInput["__pkField"])
722
+ : String(ctx?.getOrUndefined("privatePaginationTieBreaker") ||
723
+ "id");
724
+ delete queryInput["__pkField"];
638
725
  const syntheticCursor = FabricContractAdapter.parseSyntheticPrivateBookmark(bookmark);
639
726
  if (bookmark !== undefined &&
640
727
  bookmark !== null &&
641
728
  bookmark !== "" &&
642
729
  !syntheticCursor) {
643
- throw new PagingError("Private collection pagination only supports adapter-generated synthetic bookmarks");
730
+ throw new PagingError("Private Mango pagination only supports adapter-generated synthetic bookmarks.");
731
+ }
732
+ if (!Array.isArray(query.sort) || !query.sort.length) {
733
+ throw new PagingError("Private Mango pagination requires an explicit sort. Add orderBy(...) so a stable generated index can be selected.");
644
734
  }
735
+ const { sortField, direction: sortDirection, idField } = FabricContractAdapter.ensureDeterministicPrivateSort(query, queryPkField);
736
+ warnScanProneMangoOperators(query.selector || {}, log);
645
737
  log.debug(`Private paginated query input collection=${collection} limit=${limit} skip=${skip} bookmark=${bookmark} sortField=${sortField} direction=${sortDirection} synthetic=${Boolean(syntheticCursor)}`);
646
- delete query.skip;
647
- delete query.bookmark;
648
738
  query.limit = pageSize + 1;
649
739
  const queryHash = FabricContractAdapter.privateQueryHash(query);
650
740
  if (syntheticCursor) {
651
741
  if (syntheticCursor.queryHash !== queryHash) {
652
- throw new PagingError("Private collection bookmark does not match the current query");
653
- }
654
- query.selector = FabricContractAdapter.buildPrivateCursorSelector(query.selector || {}, syntheticCursor.sortField, syntheticCursor.direction, syntheticCursor);
655
- }
656
- log.debug(`Querying collection ${collection} for ${JSON.stringify(query)}`);
657
- const response = await stub.getPrivateDataQueryResult(collection, JSON.stringify(query));
658
- const iterator = (response.iterator ||
659
- response);
660
- const responseMetadata = response.metadata ||
661
- iterator.metadata ||
662
- {};
663
- log.debug(`Private paginated response collection=${collection} metadata=${JSON.stringify(responseMetadata)}`);
664
- log.verbose(`iterator from collection ${collection} received`);
665
- const paged = [];
666
- let hasMore = false;
667
- try {
668
- while (true) {
669
- const res = await iterator.next();
670
- if (res.done)
671
- break;
672
- if (res.value && res.value.value) {
673
- const key = res.value.key;
674
- if (paged.length < pageSize) {
675
- paged.push({
676
- key,
677
- value: Buffer.isBuffer(res.value.value)
678
- ? res.value.value
679
- : Buffer.from(res.value.value.toString("utf8")),
680
- });
681
- }
682
- else {
683
- hasMore = true;
684
- break; // early exit
685
- }
686
- }
742
+ throw new PagingError("Private Mango bookmark does not match the current query.");
687
743
  }
688
744
  }
689
- finally {
690
- await iterator.close();
691
- }
692
- log.debug(`Private paginated collection=${collection} produced ${paged.length} rows hasMore=${hasMore}`);
745
+ const queries = syntheticCursor
746
+ ? FabricContractAdapter.buildOrlessKeysetContinuationQueries(query, syntheticCursor, pageSize)
747
+ : [{ ...query, limit: pageSize + 1 }];
748
+ const paged = await FabricContractAdapter.executePrivateMangoPageQueries(stub, collection, queries, pageSize, log);
749
+ const hasMore = paged.length > pageSize;
750
+ const docs = hasMore ? paged.slice(0, pageSize) : paged;
693
751
  let nextBookmark = "";
694
- if (hasMore && paged.length) {
695
- const last = paged[paged.length - 1];
752
+ if (hasMore && docs.length) {
753
+ const last = docs[docs.length - 1];
696
754
  const lastDoc = FabricContractAdapter.parsePrivateResultValue(last.value);
697
755
  if (!(sortField in lastDoc)) {
698
756
  throw new PagingError(`Cannot build private pagination bookmark: sorted field "${sortField}" is missing from the last result`);
@@ -712,24 +770,7 @@ export class FabricContractAdapter extends CouchDBAdapter {
712
770
  queryHash,
713
771
  });
714
772
  }
715
- // Wrap the page in an async iterator for resultIterator()
716
- let idx = 0;
717
- const arrayIterator = {
718
- async next() {
719
- if (idx < paged.length) {
720
- return { value: paged[idx++], done: false };
721
- }
722
- return { value: undefined, done: true };
723
- },
724
- async close() { },
725
- };
726
- return {
727
- iterator: arrayIterator,
728
- metadata: {
729
- fetchedRecordsCount: paged.length,
730
- bookmark: nextBookmark,
731
- },
732
- };
773
+ return FabricContractAdapter.toFabricPaginationResponse(docs, nextBookmark);
733
774
  }
734
775
  default:
735
776
  throw new InternalError(`Unsupported method override ${String(prop)}`);
@@ -799,6 +840,8 @@ export class FabricContractAdapter extends CouchDBAdapter {
799
840
  segregated: false,
800
841
  rebuildWithTransient: false,
801
842
  fullySegregated: false,
843
+ strictPrivateMangoPagination: true,
844
+ privatePaginationTieBreaker: "id",
802
845
  };
803
846
  if (flags instanceof FabricContractContext || flags instanceof Context) {
804
847
  flags = flags.toOverrides();
@@ -933,6 +976,8 @@ export class FabricContractAdapter extends CouchDBAdapter {
933
976
  const originalInput = { ...rawInput };
934
977
  const { skip, limit } = originalInput;
935
978
  const bookmark = originalInput["bookmark"];
979
+ const hasSort = Array.isArray(originalInput.sort) &&
980
+ originalInput.sort.length > 0;
936
981
  const pkField = typeof originalInput["__pkField"] === "string" &&
937
982
  originalInput["__pkField"].trim().length
938
983
  ? String(originalInput["__pkField"])
@@ -940,7 +985,8 @@ export class FabricContractAdapter extends CouchDBAdapter {
940
985
  const hasSkip = skip !== undefined && skip !== null && Number(skip) > 0;
941
986
  const hasBookmark = bookmark !== undefined && bookmark !== null && bookmark !== "";
942
987
  const paginationActive = Boolean(limit || hasSkip || hasBookmark);
943
- const shouldPaginate = Boolean(paginationActive && (enableSegregates || hasSkip || hasBookmark));
988
+ const shouldPaginate = Boolean(paginationActive &&
989
+ (enableSegregates || hasSkip || hasBookmark || hasSort));
944
990
  const baseInput = { ...originalInput };
945
991
  delete baseInput["skip"];
946
992
  delete baseInput["bookmark"];