@form-engine-ts/storage-azure-table 2.8.0 → 2.9.0

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.
package/README.md CHANGED
@@ -39,4 +39,6 @@ owns table creation, credentials, retries, and client lifecycle.
39
39
  `listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
40
40
  emitted text items rather than entities. Its opaque Base64 JSON cursor retains the Azure continuation token plus entity
41
41
  and field indexes, so a page can resume inside a multi-answer entity without gaps or duplicates. Empty answers do not
42
- consume the item limit, and scanning remains bounded by `maxScanPages`.
42
+ consume the item limit, and scanning remains bounded by `maxScanPages`. Cursor format version 1 also records the form,
43
+ version, sorted fields, and a SHA-256 filter fingerprint. Reusing a cursor with different query context throws
44
+ `invalid_cursor_context`.
package/dist/index.cjs CHANGED
@@ -63,11 +63,16 @@ function encodeAzureTextAnswerCursor(value) {
63
63
  function decodeAzureTextAnswerCursor(cursor) {
64
64
  try {
65
65
  const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
66
- if (!isRecord(value) || value.tableContinuationToken !== null && typeof value.tableContinuationToken !== "string" || !Number.isSafeInteger(value.entityIndex) || value.entityIndex < 0 || !Number.isSafeInteger(value.fieldIndex) || value.fieldIndex < 0) {
66
+ if (!isRecord(value) || value.formatVersion !== 1 || typeof value.formId !== "string" || value.formId.length === 0 || value.formVersion !== void 0 && (!Number.isSafeInteger(value.formVersion) || value.formVersion < 1) || !Array.isArray(value.fieldIdsSorted) || value.fieldIdsSorted.some((fieldId) => typeof fieldId !== "string") || typeof value.filterFingerprint !== "string" || value.filterFingerprint.length === 0 || value.tableContinuationToken !== void 0 && (typeof value.tableContinuationToken !== "string" || value.tableContinuationToken.length === 0) || !Number.isSafeInteger(value.entityIndex) || value.entityIndex < 0 || !Number.isSafeInteger(value.fieldIndex) || value.fieldIndex < 0) {
67
67
  throw new TypeError("Azure text answer cursor payload is invalid.");
68
68
  }
69
69
  return {
70
- tableContinuationToken: value.tableContinuationToken,
70
+ formatVersion: 1,
71
+ formId: value.formId,
72
+ ...value.formVersion === void 0 ? {} : { formVersion: value.formVersion },
73
+ fieldIdsSorted: value.fieldIdsSorted,
74
+ filterFingerprint: value.filterFingerprint,
75
+ ...value.tableContinuationToken === void 0 ? {} : { tableContinuationToken: value.tableContinuationToken },
71
76
  entityIndex: value.entityIndex,
72
77
  fieldIndex: value.fieldIndex
73
78
  };
@@ -76,6 +81,28 @@ function decodeAzureTextAnswerCursor(cursor) {
76
81
  throw new TypeError("cursor must be a valid Azure text answer cursor.", { cause });
77
82
  }
78
83
  }
84
+ function canonicalValue(value) {
85
+ if (typeof value === "function") return JSON.stringify(`function:${String(value)}`);
86
+ if (value === void 0) return "undefined";
87
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
88
+ if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : JSON.stringify(String(value));
89
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
90
+ if (!isRecord(value)) return JSON.stringify(String(value));
91
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalValue(value[key])}`).join(",")}}`;
92
+ }
93
+ async function textAnswerFilterFingerprint(odataFilter, query, allFields) {
94
+ const canonical = canonicalValue({
95
+ odataFilter,
96
+ filter: query.filter,
97
+ metadataFilters: query.metadataFilters,
98
+ allFields
99
+ });
100
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
101
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
102
+ }
103
+ function cursorContextMatches(cursor, formId, formVersion, fieldIdsSorted, filterFingerprint) {
104
+ return cursor.formId === formId && cursor.formVersion === formVersion && cursor.filterFingerprint === filterFingerprint && cursor.fieldIdsSorted.length === fieldIdsSorted.length && cursor.fieldIdsSorted.every((fieldId, index) => fieldId === fieldIdsSorted[index]);
105
+ }
79
106
  function textAnswerQuery(fieldIdOrOptions, options) {
80
107
  const query = typeof fieldIdOrOptions === "string" ? options ?? {} : fieldIdOrOptions ?? {};
81
108
  const requested = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : query.fieldIds;
@@ -367,16 +394,29 @@ function createAzureTableStorage(options = {}) {
367
394
  async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
368
395
  const { query, fieldIds } = textAnswerQuery(fieldIdOrOptions, providedOptions);
369
396
  const pageSize = (0, import_core.normalizeSubmissionPageSize)(query.pageSize);
397
+ const fieldIdsSorted = [...fieldIds ?? []].sort();
398
+ const odataFilter = queryFilter(formId, query);
399
+ const filterFingerprint = await textAnswerFilterFingerprint(odataFilter, query, fieldIds === void 0);
370
400
  const cursor = query.cursor === void 0 ? void 0 : decodeAzureTextAnswerCursor(query.cursor);
401
+ if (cursor !== void 0 && !cursorContextMatches(cursor, formId, query.version, fieldIdsSorted, filterFingerprint)) {
402
+ throw new TypeError("invalid_cursor_context");
403
+ }
404
+ const cursorContext = {
405
+ formatVersion: 1,
406
+ formId,
407
+ ...query.version === void 0 ? {} : { formVersion: query.version },
408
+ fieldIdsSorted,
409
+ filterFingerprint
410
+ };
371
411
  const client = await submissionClient(formId, query);
372
412
  const items = [];
373
- let tableContinuationToken = cursor?.tableContinuationToken ?? void 0;
413
+ let tableContinuationToken = cursor?.tableContinuationToken;
374
414
  let entityStartIndex = cursor?.entityIndex ?? 0;
375
415
  let fieldStartIndex = cursor?.fieldIndex ?? 0;
376
416
  let scannedPages = 0;
377
417
  while (scannedPages < maxScanPages) {
378
418
  const requestToken = tableContinuationToken;
379
- const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
419
+ const iterator = client.listEntities({ queryOptions: { filter: odataFilter } }).byPage({
380
420
  maxPageSize: pageSize,
381
421
  ...requestToken === void 0 ? {} : { continuationToken: requestToken }
382
422
  });
@@ -414,15 +454,18 @@ function createAzureTableStorage(options = {}) {
414
454
  const hasMore = hasFieldsInEntity || hasEntitiesInPage || nextPageToken !== void 0;
415
455
  if (!hasMore) return { items, hasMore: false };
416
456
  const nextCursor = hasFieldsInEntity ? {
417
- tableContinuationToken: requestToken ?? null,
457
+ ...cursorContext,
458
+ ...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
418
459
  entityIndex,
419
460
  fieldIndex: nextFieldIndex
420
461
  } : hasEntitiesInPage ? {
421
- tableContinuationToken: requestToken ?? null,
462
+ ...cursorContext,
463
+ ...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
422
464
  entityIndex: entityIndex + 1,
423
465
  fieldIndex: 0
424
466
  } : {
425
- tableContinuationToken: nextPageToken ?? null,
467
+ ...cursorContext,
468
+ ...nextPageToken === void 0 ? {} : { tableContinuationToken: nextPageToken },
426
469
  entityIndex: 0,
427
470
  fieldIndex: 0
428
471
  };
@@ -440,6 +483,7 @@ function createAzureTableStorage(options = {}) {
440
483
  items,
441
484
  hasMore: true,
442
485
  nextCursor: encodeAzureTextAnswerCursor({
486
+ ...cursorContext,
443
487
  tableContinuationToken,
444
488
  entityIndex: 0,
445
489
  fieldIndex: 0
package/dist/index.d.cts CHANGED
@@ -55,9 +55,19 @@ interface AzureTableStorageOptions<T = FormSubmission> {
55
55
  readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
56
56
  readonly maxScanPages?: number;
57
57
  }
58
+ interface AzureTextAnswerCursorPayload {
59
+ readonly formatVersion: 1;
60
+ readonly formId: string;
61
+ readonly formVersion?: number;
62
+ readonly fieldIdsSorted: readonly string[];
63
+ readonly filterFingerprint: string;
64
+ readonly tableContinuationToken?: string;
65
+ readonly entityIndex: number;
66
+ readonly fieldIndex: number;
67
+ }
58
68
  declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
59
69
  declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
60
70
  declare function submissionFilterToOData(filter: SubmissionFilter): string | undefined;
61
71
  declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
62
72
 
63
- export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
73
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTextAnswerCursorPayload, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
package/dist/index.d.ts CHANGED
@@ -55,9 +55,19 @@ interface AzureTableStorageOptions<T = FormSubmission> {
55
55
  readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
56
56
  readonly maxScanPages?: number;
57
57
  }
58
+ interface AzureTextAnswerCursorPayload {
59
+ readonly formatVersion: 1;
60
+ readonly formId: string;
61
+ readonly formVersion?: number;
62
+ readonly fieldIdsSorted: readonly string[];
63
+ readonly filterFingerprint: string;
64
+ readonly tableContinuationToken?: string;
65
+ readonly entityIndex: number;
66
+ readonly fieldIndex: number;
67
+ }
58
68
  declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
59
69
  declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
60
70
  declare function submissionFilterToOData(filter: SubmissionFilter): string | undefined;
61
71
  declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
62
72
 
63
- export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
73
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTextAnswerCursorPayload, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
package/dist/index.js CHANGED
@@ -36,11 +36,16 @@ function encodeAzureTextAnswerCursor(value) {
36
36
  function decodeAzureTextAnswerCursor(cursor) {
37
37
  try {
38
38
  const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
39
- if (!isRecord(value) || value.tableContinuationToken !== null && typeof value.tableContinuationToken !== "string" || !Number.isSafeInteger(value.entityIndex) || value.entityIndex < 0 || !Number.isSafeInteger(value.fieldIndex) || value.fieldIndex < 0) {
39
+ if (!isRecord(value) || value.formatVersion !== 1 || typeof value.formId !== "string" || value.formId.length === 0 || value.formVersion !== void 0 && (!Number.isSafeInteger(value.formVersion) || value.formVersion < 1) || !Array.isArray(value.fieldIdsSorted) || value.fieldIdsSorted.some((fieldId) => typeof fieldId !== "string") || typeof value.filterFingerprint !== "string" || value.filterFingerprint.length === 0 || value.tableContinuationToken !== void 0 && (typeof value.tableContinuationToken !== "string" || value.tableContinuationToken.length === 0) || !Number.isSafeInteger(value.entityIndex) || value.entityIndex < 0 || !Number.isSafeInteger(value.fieldIndex) || value.fieldIndex < 0) {
40
40
  throw new TypeError("Azure text answer cursor payload is invalid.");
41
41
  }
42
42
  return {
43
- tableContinuationToken: value.tableContinuationToken,
43
+ formatVersion: 1,
44
+ formId: value.formId,
45
+ ...value.formVersion === void 0 ? {} : { formVersion: value.formVersion },
46
+ fieldIdsSorted: value.fieldIdsSorted,
47
+ filterFingerprint: value.filterFingerprint,
48
+ ...value.tableContinuationToken === void 0 ? {} : { tableContinuationToken: value.tableContinuationToken },
44
49
  entityIndex: value.entityIndex,
45
50
  fieldIndex: value.fieldIndex
46
51
  };
@@ -49,6 +54,28 @@ function decodeAzureTextAnswerCursor(cursor) {
49
54
  throw new TypeError("cursor must be a valid Azure text answer cursor.", { cause });
50
55
  }
51
56
  }
57
+ function canonicalValue(value) {
58
+ if (typeof value === "function") return JSON.stringify(`function:${String(value)}`);
59
+ if (value === void 0) return "undefined";
60
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
61
+ if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : JSON.stringify(String(value));
62
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
63
+ if (!isRecord(value)) return JSON.stringify(String(value));
64
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalValue(value[key])}`).join(",")}}`;
65
+ }
66
+ async function textAnswerFilterFingerprint(odataFilter, query, allFields) {
67
+ const canonical = canonicalValue({
68
+ odataFilter,
69
+ filter: query.filter,
70
+ metadataFilters: query.metadataFilters,
71
+ allFields
72
+ });
73
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
74
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
75
+ }
76
+ function cursorContextMatches(cursor, formId, formVersion, fieldIdsSorted, filterFingerprint) {
77
+ return cursor.formId === formId && cursor.formVersion === formVersion && cursor.filterFingerprint === filterFingerprint && cursor.fieldIdsSorted.length === fieldIdsSorted.length && cursor.fieldIdsSorted.every((fieldId, index) => fieldId === fieldIdsSorted[index]);
78
+ }
52
79
  function textAnswerQuery(fieldIdOrOptions, options) {
53
80
  const query = typeof fieldIdOrOptions === "string" ? options ?? {} : fieldIdOrOptions ?? {};
54
81
  const requested = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : query.fieldIds;
@@ -340,16 +367,29 @@ function createAzureTableStorage(options = {}) {
340
367
  async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
341
368
  const { query, fieldIds } = textAnswerQuery(fieldIdOrOptions, providedOptions);
342
369
  const pageSize = normalizeSubmissionPageSize(query.pageSize);
370
+ const fieldIdsSorted = [...fieldIds ?? []].sort();
371
+ const odataFilter = queryFilter(formId, query);
372
+ const filterFingerprint = await textAnswerFilterFingerprint(odataFilter, query, fieldIds === void 0);
343
373
  const cursor = query.cursor === void 0 ? void 0 : decodeAzureTextAnswerCursor(query.cursor);
374
+ if (cursor !== void 0 && !cursorContextMatches(cursor, formId, query.version, fieldIdsSorted, filterFingerprint)) {
375
+ throw new TypeError("invalid_cursor_context");
376
+ }
377
+ const cursorContext = {
378
+ formatVersion: 1,
379
+ formId,
380
+ ...query.version === void 0 ? {} : { formVersion: query.version },
381
+ fieldIdsSorted,
382
+ filterFingerprint
383
+ };
344
384
  const client = await submissionClient(formId, query);
345
385
  const items = [];
346
- let tableContinuationToken = cursor?.tableContinuationToken ?? void 0;
386
+ let tableContinuationToken = cursor?.tableContinuationToken;
347
387
  let entityStartIndex = cursor?.entityIndex ?? 0;
348
388
  let fieldStartIndex = cursor?.fieldIndex ?? 0;
349
389
  let scannedPages = 0;
350
390
  while (scannedPages < maxScanPages) {
351
391
  const requestToken = tableContinuationToken;
352
- const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
392
+ const iterator = client.listEntities({ queryOptions: { filter: odataFilter } }).byPage({
353
393
  maxPageSize: pageSize,
354
394
  ...requestToken === void 0 ? {} : { continuationToken: requestToken }
355
395
  });
@@ -387,15 +427,18 @@ function createAzureTableStorage(options = {}) {
387
427
  const hasMore = hasFieldsInEntity || hasEntitiesInPage || nextPageToken !== void 0;
388
428
  if (!hasMore) return { items, hasMore: false };
389
429
  const nextCursor = hasFieldsInEntity ? {
390
- tableContinuationToken: requestToken ?? null,
430
+ ...cursorContext,
431
+ ...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
391
432
  entityIndex,
392
433
  fieldIndex: nextFieldIndex
393
434
  } : hasEntitiesInPage ? {
394
- tableContinuationToken: requestToken ?? null,
435
+ ...cursorContext,
436
+ ...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
395
437
  entityIndex: entityIndex + 1,
396
438
  fieldIndex: 0
397
439
  } : {
398
- tableContinuationToken: nextPageToken ?? null,
440
+ ...cursorContext,
441
+ ...nextPageToken === void 0 ? {} : { tableContinuationToken: nextPageToken },
399
442
  entityIndex: 0,
400
443
  fieldIndex: 0
401
444
  };
@@ -413,6 +456,7 @@ function createAzureTableStorage(options = {}) {
413
456
  items,
414
457
  hasMore: true,
415
458
  nextCursor: encodeAzureTextAnswerCursor({
459
+ ...cursorContext,
416
460
  tableContinuationToken,
417
461
  entityIndex: 0,
418
462
  fieldIndex: 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-azure-table",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -39,7 +39,7 @@
39
39
  "typescript"
40
40
  ],
41
41
  "dependencies": {
42
- "@form-engine-ts/core": "2.8.0"
42
+ "@form-engine-ts/core": "2.9.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"