@form-engine-ts/storage-azure-table 2.8.0 → 2.9.1
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 +3 -1
- package/dist/index.cjs +51 -7
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +51 -7
- package/package.json +2 -2
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 !==
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
457
|
+
...cursorContext,
|
|
458
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
418
459
|
entityIndex,
|
|
419
460
|
fieldIndex: nextFieldIndex
|
|
420
461
|
} : hasEntitiesInPage ? {
|
|
421
|
-
|
|
462
|
+
...cursorContext,
|
|
463
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
422
464
|
entityIndex: entityIndex + 1,
|
|
423
465
|
fieldIndex: 0
|
|
424
466
|
} : {
|
|
425
|
-
|
|
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 !==
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
430
|
+
...cursorContext,
|
|
431
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
391
432
|
entityIndex,
|
|
392
433
|
fieldIndex: nextFieldIndex
|
|
393
434
|
} : hasEntitiesInPage ? {
|
|
394
|
-
|
|
435
|
+
...cursorContext,
|
|
436
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
395
437
|
entityIndex: entityIndex + 1,
|
|
396
438
|
fieldIndex: 0
|
|
397
439
|
} : {
|
|
398
|
-
|
|
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.
|
|
3
|
+
"version": "2.9.1",
|
|
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.
|
|
42
|
+
"@form-engine-ts/core": "2.9.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@azure/data-tables": "^13.3.2"
|