@form-engine-ts/storage-azure-table 2.7.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 +7 -0
- package/dist/index.cjs +190 -0
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +190 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,3 +35,10 @@ unsupported expressions remain client-filtered with identical semantics.
|
|
|
35
35
|
to fill the requested logical page after client-side filtering. `buildSubmissionFilter` can replace filter generation.
|
|
36
36
|
The deprecated `client`, `submissionCodec`, and `toODataFilter` options remain available for compatibility. The caller
|
|
37
37
|
owns table creation, credentials, retries, and client lifecycle.
|
|
38
|
+
|
|
39
|
+
`listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
|
|
40
|
+
emitted text items rather than entities. Its opaque Base64 JSON cursor retains the Azure continuation token plus entity
|
|
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`. 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
|
@@ -27,6 +27,97 @@ __export(index_exports, {
|
|
|
27
27
|
});
|
|
28
28
|
module.exports = __toCommonJS(index_exports);
|
|
29
29
|
var import_core = require("@form-engine-ts/core");
|
|
30
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
31
|
+
function encodeBase64(bytes) {
|
|
32
|
+
let result = "";
|
|
33
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
34
|
+
const first = bytes[index] ?? 0;
|
|
35
|
+
const second = bytes[index + 1] ?? 0;
|
|
36
|
+
const third = bytes[index + 2] ?? 0;
|
|
37
|
+
const combined = first << 16 | second << 8 | third;
|
|
38
|
+
result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
|
|
39
|
+
result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
|
|
40
|
+
result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
|
|
41
|
+
result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
function decodeBase64(value) {
|
|
46
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
47
|
+
throw new TypeError("cursor must be a valid Base64 token.");
|
|
48
|
+
}
|
|
49
|
+
const bytes = [];
|
|
50
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
51
|
+
const characters = value.slice(index, index + 4);
|
|
52
|
+
const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
|
|
53
|
+
const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
|
|
54
|
+
bytes.push(combined >> 16 & 255);
|
|
55
|
+
if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
|
|
56
|
+
if (characters[3] !== "=") bytes.push(combined & 255);
|
|
57
|
+
}
|
|
58
|
+
return new Uint8Array(bytes);
|
|
59
|
+
}
|
|
60
|
+
function encodeAzureTextAnswerCursor(value) {
|
|
61
|
+
return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
|
|
62
|
+
}
|
|
63
|
+
function decodeAzureTextAnswerCursor(cursor) {
|
|
64
|
+
try {
|
|
65
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
|
|
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
|
+
throw new TypeError("Azure text answer cursor payload is invalid.");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
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 },
|
|
76
|
+
entityIndex: value.entityIndex,
|
|
77
|
+
fieldIndex: value.fieldIndex
|
|
78
|
+
};
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
if (cause instanceof TypeError && cause.message === "Azure text answer cursor payload is invalid.") throw cause;
|
|
81
|
+
throw new TypeError("cursor must be a valid Azure text answer cursor.", { cause });
|
|
82
|
+
}
|
|
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
|
+
}
|
|
106
|
+
function textAnswerQuery(fieldIdOrOptions, options) {
|
|
107
|
+
const query = typeof fieldIdOrOptions === "string" ? options ?? {} : fieldIdOrOptions ?? {};
|
|
108
|
+
const requested = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : query.fieldIds;
|
|
109
|
+
if (requested?.some((fieldId) => fieldId.trim().length === 0)) {
|
|
110
|
+
throw new TypeError("fieldIds must not contain empty values.");
|
|
111
|
+
}
|
|
112
|
+
const fieldIds = requested === void 0 ? void 0 : [...new Set(requested)];
|
|
113
|
+
return { query, ...fieldIds === void 0 ? {} : { fieldIds } };
|
|
114
|
+
}
|
|
115
|
+
function submissionTextAnswers(submission, fieldIds) {
|
|
116
|
+
const entries = fieldIds === void 0 ? Object.entries(submission.values) : fieldIds.map((id) => [id, submission.values[id]]);
|
|
117
|
+
return entries.flatMap(
|
|
118
|
+
([fieldId, value]) => typeof value === "string" && value.length > 0 ? [{ fieldId, text: value }] : []
|
|
119
|
+
);
|
|
120
|
+
}
|
|
30
121
|
function cloneJson(value) {
|
|
31
122
|
return JSON.parse(JSON.stringify(value));
|
|
32
123
|
}
|
|
@@ -300,6 +391,105 @@ function createAzureTableStorage(options = {}) {
|
|
|
300
391
|
...continuationToken === void 0 || continuationToken.length === 0 ? {} : { nextCursor: continuationToken }
|
|
301
392
|
};
|
|
302
393
|
},
|
|
394
|
+
async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
|
|
395
|
+
const { query, fieldIds } = textAnswerQuery(fieldIdOrOptions, providedOptions);
|
|
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);
|
|
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
|
+
};
|
|
411
|
+
const client = await submissionClient(formId, query);
|
|
412
|
+
const items = [];
|
|
413
|
+
let tableContinuationToken = cursor?.tableContinuationToken;
|
|
414
|
+
let entityStartIndex = cursor?.entityIndex ?? 0;
|
|
415
|
+
let fieldStartIndex = cursor?.fieldIndex ?? 0;
|
|
416
|
+
let scannedPages = 0;
|
|
417
|
+
while (scannedPages < maxScanPages) {
|
|
418
|
+
const requestToken = tableContinuationToken;
|
|
419
|
+
const iterator = client.listEntities({ queryOptions: { filter: odataFilter } }).byPage({
|
|
420
|
+
maxPageSize: pageSize,
|
|
421
|
+
...requestToken === void 0 ? {} : { continuationToken: requestToken }
|
|
422
|
+
});
|
|
423
|
+
const result = await iterator.next();
|
|
424
|
+
if (result.done === true) break;
|
|
425
|
+
scannedPages += 1;
|
|
426
|
+
const page = result.value;
|
|
427
|
+
for (let entityIndex = entityStartIndex; entityIndex < page.length; entityIndex += 1) {
|
|
428
|
+
const raw = page[entityIndex];
|
|
429
|
+
if (raw === void 0) continue;
|
|
430
|
+
const submission = deserializeIfMatching(raw);
|
|
431
|
+
if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query) || !(0, import_core.matchesSubmissionPageFilters)(submission, query)) {
|
|
432
|
+
fieldStartIndex = 0;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const answers = submissionTextAnswers(submission, fieldIds);
|
|
436
|
+
for (let fieldIndex = entityIndex === entityStartIndex ? fieldStartIndex : 0; fieldIndex < answers.length; fieldIndex += 1) {
|
|
437
|
+
const answer = answers[fieldIndex];
|
|
438
|
+
if (answer === void 0) continue;
|
|
439
|
+
items.push({
|
|
440
|
+
responseId: submission.id,
|
|
441
|
+
formId: submission.formId,
|
|
442
|
+
formVersion: submission.formVersion,
|
|
443
|
+
fieldId: answer.fieldId,
|
|
444
|
+
text: answer.text,
|
|
445
|
+
locale: submission.locale,
|
|
446
|
+
submittedAt: submission.submittedAt,
|
|
447
|
+
...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
|
|
448
|
+
});
|
|
449
|
+
if (items.length < pageSize) continue;
|
|
450
|
+
const nextFieldIndex = fieldIndex + 1;
|
|
451
|
+
const hasFieldsInEntity = nextFieldIndex < answers.length;
|
|
452
|
+
const hasEntitiesInPage = entityIndex + 1 < page.length;
|
|
453
|
+
const nextPageToken = page.continuationToken;
|
|
454
|
+
const hasMore = hasFieldsInEntity || hasEntitiesInPage || nextPageToken !== void 0;
|
|
455
|
+
if (!hasMore) return { items, hasMore: false };
|
|
456
|
+
const nextCursor = hasFieldsInEntity ? {
|
|
457
|
+
...cursorContext,
|
|
458
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
459
|
+
entityIndex,
|
|
460
|
+
fieldIndex: nextFieldIndex
|
|
461
|
+
} : hasEntitiesInPage ? {
|
|
462
|
+
...cursorContext,
|
|
463
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
464
|
+
entityIndex: entityIndex + 1,
|
|
465
|
+
fieldIndex: 0
|
|
466
|
+
} : {
|
|
467
|
+
...cursorContext,
|
|
468
|
+
...nextPageToken === void 0 ? {} : { tableContinuationToken: nextPageToken },
|
|
469
|
+
entityIndex: 0,
|
|
470
|
+
fieldIndex: 0
|
|
471
|
+
};
|
|
472
|
+
return { items, hasMore: true, nextCursor: encodeAzureTextAnswerCursor(nextCursor) };
|
|
473
|
+
}
|
|
474
|
+
fieldStartIndex = 0;
|
|
475
|
+
}
|
|
476
|
+
tableContinuationToken = page.continuationToken;
|
|
477
|
+
entityStartIndex = 0;
|
|
478
|
+
fieldStartIndex = 0;
|
|
479
|
+
if (tableContinuationToken === void 0) break;
|
|
480
|
+
}
|
|
481
|
+
if (tableContinuationToken === void 0) return { items, hasMore: false };
|
|
482
|
+
return {
|
|
483
|
+
items,
|
|
484
|
+
hasMore: true,
|
|
485
|
+
nextCursor: encodeAzureTextAnswerCursor({
|
|
486
|
+
...cursorContext,
|
|
487
|
+
tableContinuationToken,
|
|
488
|
+
entityIndex: 0,
|
|
489
|
+
fieldIndex: 0
|
|
490
|
+
})
|
|
491
|
+
};
|
|
492
|
+
},
|
|
303
493
|
async deleteSubmission(submissionId) {
|
|
304
494
|
const client = await submissionClient("");
|
|
305
495
|
for await (const raw of client.listEntities()) {
|
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
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { assertValidFormSchema, matchesSubmissionPageFilters, normalizeSubmissionPageSize } from "@form-engine-ts/core";
|
|
3
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
4
|
+
function encodeBase64(bytes) {
|
|
5
|
+
let result = "";
|
|
6
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
7
|
+
const first = bytes[index] ?? 0;
|
|
8
|
+
const second = bytes[index + 1] ?? 0;
|
|
9
|
+
const third = bytes[index + 2] ?? 0;
|
|
10
|
+
const combined = first << 16 | second << 8 | third;
|
|
11
|
+
result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
|
|
12
|
+
result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
|
|
13
|
+
result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
|
|
14
|
+
result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
function decodeBase64(value) {
|
|
19
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
20
|
+
throw new TypeError("cursor must be a valid Base64 token.");
|
|
21
|
+
}
|
|
22
|
+
const bytes = [];
|
|
23
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
24
|
+
const characters = value.slice(index, index + 4);
|
|
25
|
+
const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
|
|
26
|
+
const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
|
|
27
|
+
bytes.push(combined >> 16 & 255);
|
|
28
|
+
if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
|
|
29
|
+
if (characters[3] !== "=") bytes.push(combined & 255);
|
|
30
|
+
}
|
|
31
|
+
return new Uint8Array(bytes);
|
|
32
|
+
}
|
|
33
|
+
function encodeAzureTextAnswerCursor(value) {
|
|
34
|
+
return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
|
|
35
|
+
}
|
|
36
|
+
function decodeAzureTextAnswerCursor(cursor) {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
|
|
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
|
+
throw new TypeError("Azure text answer cursor payload is invalid.");
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
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 },
|
|
49
|
+
entityIndex: value.entityIndex,
|
|
50
|
+
fieldIndex: value.fieldIndex
|
|
51
|
+
};
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
if (cause instanceof TypeError && cause.message === "Azure text answer cursor payload is invalid.") throw cause;
|
|
54
|
+
throw new TypeError("cursor must be a valid Azure text answer cursor.", { cause });
|
|
55
|
+
}
|
|
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
|
+
}
|
|
79
|
+
function textAnswerQuery(fieldIdOrOptions, options) {
|
|
80
|
+
const query = typeof fieldIdOrOptions === "string" ? options ?? {} : fieldIdOrOptions ?? {};
|
|
81
|
+
const requested = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : query.fieldIds;
|
|
82
|
+
if (requested?.some((fieldId) => fieldId.trim().length === 0)) {
|
|
83
|
+
throw new TypeError("fieldIds must not contain empty values.");
|
|
84
|
+
}
|
|
85
|
+
const fieldIds = requested === void 0 ? void 0 : [...new Set(requested)];
|
|
86
|
+
return { query, ...fieldIds === void 0 ? {} : { fieldIds } };
|
|
87
|
+
}
|
|
88
|
+
function submissionTextAnswers(submission, fieldIds) {
|
|
89
|
+
const entries = fieldIds === void 0 ? Object.entries(submission.values) : fieldIds.map((id) => [id, submission.values[id]]);
|
|
90
|
+
return entries.flatMap(
|
|
91
|
+
([fieldId, value]) => typeof value === "string" && value.length > 0 ? [{ fieldId, text: value }] : []
|
|
92
|
+
);
|
|
93
|
+
}
|
|
3
94
|
function cloneJson(value) {
|
|
4
95
|
return JSON.parse(JSON.stringify(value));
|
|
5
96
|
}
|
|
@@ -273,6 +364,105 @@ function createAzureTableStorage(options = {}) {
|
|
|
273
364
|
...continuationToken === void 0 || continuationToken.length === 0 ? {} : { nextCursor: continuationToken }
|
|
274
365
|
};
|
|
275
366
|
},
|
|
367
|
+
async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
|
|
368
|
+
const { query, fieldIds } = textAnswerQuery(fieldIdOrOptions, providedOptions);
|
|
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);
|
|
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
|
+
};
|
|
384
|
+
const client = await submissionClient(formId, query);
|
|
385
|
+
const items = [];
|
|
386
|
+
let tableContinuationToken = cursor?.tableContinuationToken;
|
|
387
|
+
let entityStartIndex = cursor?.entityIndex ?? 0;
|
|
388
|
+
let fieldStartIndex = cursor?.fieldIndex ?? 0;
|
|
389
|
+
let scannedPages = 0;
|
|
390
|
+
while (scannedPages < maxScanPages) {
|
|
391
|
+
const requestToken = tableContinuationToken;
|
|
392
|
+
const iterator = client.listEntities({ queryOptions: { filter: odataFilter } }).byPage({
|
|
393
|
+
maxPageSize: pageSize,
|
|
394
|
+
...requestToken === void 0 ? {} : { continuationToken: requestToken }
|
|
395
|
+
});
|
|
396
|
+
const result = await iterator.next();
|
|
397
|
+
if (result.done === true) break;
|
|
398
|
+
scannedPages += 1;
|
|
399
|
+
const page = result.value;
|
|
400
|
+
for (let entityIndex = entityStartIndex; entityIndex < page.length; entityIndex += 1) {
|
|
401
|
+
const raw = page[entityIndex];
|
|
402
|
+
if (raw === void 0) continue;
|
|
403
|
+
const submission = deserializeIfMatching(raw);
|
|
404
|
+
if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query) || !matchesSubmissionPageFilters(submission, query)) {
|
|
405
|
+
fieldStartIndex = 0;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const answers = submissionTextAnswers(submission, fieldIds);
|
|
409
|
+
for (let fieldIndex = entityIndex === entityStartIndex ? fieldStartIndex : 0; fieldIndex < answers.length; fieldIndex += 1) {
|
|
410
|
+
const answer = answers[fieldIndex];
|
|
411
|
+
if (answer === void 0) continue;
|
|
412
|
+
items.push({
|
|
413
|
+
responseId: submission.id,
|
|
414
|
+
formId: submission.formId,
|
|
415
|
+
formVersion: submission.formVersion,
|
|
416
|
+
fieldId: answer.fieldId,
|
|
417
|
+
text: answer.text,
|
|
418
|
+
locale: submission.locale,
|
|
419
|
+
submittedAt: submission.submittedAt,
|
|
420
|
+
...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
|
|
421
|
+
});
|
|
422
|
+
if (items.length < pageSize) continue;
|
|
423
|
+
const nextFieldIndex = fieldIndex + 1;
|
|
424
|
+
const hasFieldsInEntity = nextFieldIndex < answers.length;
|
|
425
|
+
const hasEntitiesInPage = entityIndex + 1 < page.length;
|
|
426
|
+
const nextPageToken = page.continuationToken;
|
|
427
|
+
const hasMore = hasFieldsInEntity || hasEntitiesInPage || nextPageToken !== void 0;
|
|
428
|
+
if (!hasMore) return { items, hasMore: false };
|
|
429
|
+
const nextCursor = hasFieldsInEntity ? {
|
|
430
|
+
...cursorContext,
|
|
431
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
432
|
+
entityIndex,
|
|
433
|
+
fieldIndex: nextFieldIndex
|
|
434
|
+
} : hasEntitiesInPage ? {
|
|
435
|
+
...cursorContext,
|
|
436
|
+
...requestToken === void 0 ? {} : { tableContinuationToken: requestToken },
|
|
437
|
+
entityIndex: entityIndex + 1,
|
|
438
|
+
fieldIndex: 0
|
|
439
|
+
} : {
|
|
440
|
+
...cursorContext,
|
|
441
|
+
...nextPageToken === void 0 ? {} : { tableContinuationToken: nextPageToken },
|
|
442
|
+
entityIndex: 0,
|
|
443
|
+
fieldIndex: 0
|
|
444
|
+
};
|
|
445
|
+
return { items, hasMore: true, nextCursor: encodeAzureTextAnswerCursor(nextCursor) };
|
|
446
|
+
}
|
|
447
|
+
fieldStartIndex = 0;
|
|
448
|
+
}
|
|
449
|
+
tableContinuationToken = page.continuationToken;
|
|
450
|
+
entityStartIndex = 0;
|
|
451
|
+
fieldStartIndex = 0;
|
|
452
|
+
if (tableContinuationToken === void 0) break;
|
|
453
|
+
}
|
|
454
|
+
if (tableContinuationToken === void 0) return { items, hasMore: false };
|
|
455
|
+
return {
|
|
456
|
+
items,
|
|
457
|
+
hasMore: true,
|
|
458
|
+
nextCursor: encodeAzureTextAnswerCursor({
|
|
459
|
+
...cursorContext,
|
|
460
|
+
tableContinuationToken,
|
|
461
|
+
entityIndex: 0,
|
|
462
|
+
fieldIndex: 0
|
|
463
|
+
})
|
|
464
|
+
};
|
|
465
|
+
},
|
|
276
466
|
async deleteSubmission(submissionId) {
|
|
277
467
|
const client = await submissionClient("");
|
|
278
468
|
for await (const raw of client.listEntities()) {
|
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.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.
|
|
42
|
+
"@form-engine-ts/core": "2.9.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@azure/data-tables": "^13.3.2"
|