@form-engine-ts/storage-azure-table 8.0.0 → 8.0.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 +4 -0
- package/dist/index.cjs +107 -0
- package/dist/index.d.cts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +107 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -48,6 +48,10 @@ Applications must migrate legacy data to the canonical `values` contract before
|
|
|
48
48
|
source can be supplied per call as `saveSubmission(submission, { validation })`. A source can be a FormSchema, a
|
|
49
49
|
Zod-compatible object with `safeParse`, or an async validator callback.
|
|
50
50
|
|
|
51
|
+
`saveSubmissionWithinLimit(submission, maxResponses, options?)` uses an ETag-protected capacity entity and an Azure
|
|
52
|
+
Table transaction to update capacity and create the response together. The submission codec must place a form's
|
|
53
|
+
responses in one partition (the default codec does), and the injected client must expose `submitTransaction`.
|
|
54
|
+
|
|
51
55
|
`listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
|
|
52
56
|
emitted text items rather than entities. Its opaque Base64 JSON cursor retains the Azure continuation token plus entity
|
|
53
57
|
and field indexes, so a page can resume inside a multi-answer entity without gaps or duplicates. Empty answers do not
|
package/dist/index.cjs
CHANGED
|
@@ -351,6 +351,9 @@ function isDuplicateEntityError(error) {
|
|
|
351
351
|
const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : "";
|
|
352
352
|
return isRecord(error) && (error.statusCode === 409 || error.code === "EntityAlreadyExists") || /alreadyexists|duplicate/iu.test(message);
|
|
353
353
|
}
|
|
354
|
+
function isPreconditionFailed(error) {
|
|
355
|
+
return isRecord(error) && (error.statusCode === 412 || error.code === "UpdateConditionNotSatisfied");
|
|
356
|
+
}
|
|
354
357
|
function matchesBuiltInFilters(submission, formId, options) {
|
|
355
358
|
return submission.formId === formId && (options.version === void 0 || submission.formVersion === options.version) && (options.since === void 0 || submission.submittedAt >= options.since) && (options.until === void 0 || submission.submittedAt <= options.until) && (options.locale === void 0 || submission.locale === options.locale);
|
|
356
359
|
}
|
|
@@ -532,6 +535,110 @@ function createAzureTableStorage(options = {}) {
|
|
|
532
535
|
return { status: "created", submission: stored, payloadHash };
|
|
533
536
|
}
|
|
534
537
|
},
|
|
538
|
+
async saveSubmissionWithinLimit(submission, maxResponses, saveOptions = {}) {
|
|
539
|
+
if (!Number.isInteger(maxResponses) || maxResponses < 1)
|
|
540
|
+
throw new RangeError("maxResponses must be a positive integer.");
|
|
541
|
+
ensureWritable();
|
|
542
|
+
const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
|
|
543
|
+
const explicitValidation = saveOptions.validator ?? saveOptions.validation;
|
|
544
|
+
const configuredValidation = options.submissionValidator ?? options.submissionSchema ?? options.validator ?? options.schema ?? options.validation;
|
|
545
|
+
if (explicitValidation !== void 0) await (0, import_core.assertValidFormSubmissionWith)(explicitValidation, stored);
|
|
546
|
+
if (explicitValidation === void 0 && configuredValidation !== void 0) {
|
|
547
|
+
await (0, import_core.assertValidFormSubmissionWith)(configuredValidation, stored);
|
|
548
|
+
}
|
|
549
|
+
if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
|
|
550
|
+
const schema = parseSchemaEntity(
|
|
551
|
+
await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
|
|
552
|
+
);
|
|
553
|
+
(0, import_core.assertValidFormSubmission)(schema, stored, options.schemaValidation);
|
|
554
|
+
}
|
|
555
|
+
const client = await submissionClient(stored.formId);
|
|
556
|
+
if (client.submitTransaction === void 0)
|
|
557
|
+
throw Object.assign(new Error("Azure Table transactions are required for atomic response limits."), {
|
|
558
|
+
code: "transaction_unsupported"
|
|
559
|
+
});
|
|
560
|
+
const partitionKey = codec.createPartitionKey(stored);
|
|
561
|
+
if (codec.createPartitionKeyFromQuery(stored.formId, { version: stored.formVersion }) !== partitionKey)
|
|
562
|
+
throw new Error("Atomic response limits require one Azure Table partition per form.");
|
|
563
|
+
const rowKey = codec.createRowKey(stored);
|
|
564
|
+
const payloadHash = await (0, import_core.hashFormSubmissionPayload)(stored);
|
|
565
|
+
const keys = physicalKeyNames(options.fieldMapping);
|
|
566
|
+
const createdEntity = codec.createEntity(stored);
|
|
567
|
+
assertCanonicalSubmissionEntity(createdEntity);
|
|
568
|
+
const submissionEntity = {
|
|
569
|
+
...mappedSubmissionEntity(createdEntity, stored, options.fieldMapping, valueCodec),
|
|
570
|
+
payloadHash,
|
|
571
|
+
partitionKey,
|
|
572
|
+
rowKey,
|
|
573
|
+
[keys.partitionKey]: partitionKey,
|
|
574
|
+
[keys.rowKey]: rowKey
|
|
575
|
+
};
|
|
576
|
+
const idempotent = saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false;
|
|
577
|
+
try {
|
|
578
|
+
const existingEntity = await client.getEntity(partitionKey, rowKey);
|
|
579
|
+
if (!idempotent) throw new Error(`A submission with ID "${stored.id}" already exists.`);
|
|
580
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
581
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await (0, import_core.hashFormSubmissionPayload)(existing);
|
|
582
|
+
if (existingPayloadHash === payloadHash) return { status: "duplicate", submission: existing, payloadHash };
|
|
583
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (!isNotFound(error)) throw error;
|
|
586
|
+
}
|
|
587
|
+
const capacityRowKey = `capacity_${stored.formVersion}`;
|
|
588
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
589
|
+
let capacity;
|
|
590
|
+
try {
|
|
591
|
+
capacity = await client.getEntity(partitionKey, capacityRowKey);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (!isNotFound(error)) throw error;
|
|
594
|
+
const count2 = await adapter.countSubmissions(stored.formId, stored.formVersion);
|
|
595
|
+
try {
|
|
596
|
+
await client.createEntity({ partitionKey, rowKey: capacityRowKey, kind: "capacity", count: count2 });
|
|
597
|
+
} catch (createError) {
|
|
598
|
+
if (!isDuplicateEntityError(createError)) throw createError;
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const count = await adapter.countSubmissions(stored.formId, stored.formVersion);
|
|
603
|
+
if (count >= maxResponses) return { status: "limit_reached" };
|
|
604
|
+
if (typeof capacity.etag !== "string") throw new Error("Azure Table capacity entity is missing an ETag.");
|
|
605
|
+
const actions = [
|
|
606
|
+
[
|
|
607
|
+
"update",
|
|
608
|
+
{ ...capacity, partitionKey, rowKey: capacityRowKey, count: count + 1 },
|
|
609
|
+
"Replace",
|
|
610
|
+
{ etag: capacity.etag }
|
|
611
|
+
],
|
|
612
|
+
["create", submissionEntity]
|
|
613
|
+
];
|
|
614
|
+
try {
|
|
615
|
+
await client.submitTransaction(actions);
|
|
616
|
+
if (idempotent) return { status: "created", submission: stored, payloadHash };
|
|
617
|
+
return;
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (isPreconditionFailed(error)) {
|
|
620
|
+
try {
|
|
621
|
+
const racedEntity = await client.getEntity(partitionKey, rowKey);
|
|
622
|
+
if (!idempotent) throw new Error(`A submission with ID "${stored.id}" already exists.`);
|
|
623
|
+
const raced = parseSubmissionEntity(racedEntity, codec, options.fieldMapping, valueCodec);
|
|
624
|
+
const existingPayloadHash2 = typeof racedEntity.payloadHash === "string" ? racedEntity.payloadHash : await (0, import_core.hashFormSubmissionPayload)(raced);
|
|
625
|
+
if (existingPayloadHash2 === payloadHash) return { status: "duplicate", submission: raced, payloadHash };
|
|
626
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash: existingPayloadHash2 };
|
|
627
|
+
} catch (lookupError) {
|
|
628
|
+
if (!isNotFound(lookupError)) throw lookupError;
|
|
629
|
+
}
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (!idempotent || !isDuplicateEntityError(error)) throw error;
|
|
633
|
+
const existingEntity = await client.getEntity(partitionKey, rowKey);
|
|
634
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
635
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await (0, import_core.hashFormSubmissionPayload)(existing);
|
|
636
|
+
if (existingPayloadHash === payloadHash) return { status: "duplicate", submission: existing, payloadHash };
|
|
637
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
throw new Error("Azure Table response capacity update conflicted too many times.");
|
|
641
|
+
},
|
|
535
642
|
async listSubmissions(formId, formVersion, queryOptions = {}) {
|
|
536
643
|
return listSubmissionCandidates(formId, {
|
|
537
644
|
...queryOptions,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TransactionAction } from '@azure/data-tables';
|
|
1
2
|
import { BaseSubmissionMetadata, PagedSubmissionStorageAdapter, TextAnswerPageQueryOptions, TextAnswerPage, TypedSubmissionPageQueryOptions, TypedSubmissionPage, FormSubmission, FormSchema, SubmissionPageQueryOptions, FormAnalytics, StorageSubmissionExportOptions, FormSubmissionValidationSource, ValidateFormSchemaOptions, FormLifecycleOptions, FormSubmissionValidator, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TypedTextAnswerPage } from '@form-engine-ts/core';
|
|
2
3
|
|
|
3
4
|
interface AzureTableListOptions {
|
|
@@ -23,6 +24,7 @@ interface AzureTableClientLike {
|
|
|
23
24
|
deleteEntity(partitionKey: string, rowKey: string, options?: {
|
|
24
25
|
readonly etag?: string;
|
|
25
26
|
}): Promise<unknown>;
|
|
27
|
+
readonly submitTransaction?: (actions: TransactionAction[]) => Promise<unknown>;
|
|
26
28
|
}
|
|
27
29
|
type AzureTableSubmissionEntity = Record<string, unknown> & {
|
|
28
30
|
readonly answers?: never;
|
|
@@ -97,8 +99,11 @@ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSu
|
|
|
97
99
|
readonly exportResponsesToCsv: (schema: FormSchema, options?: StorageSubmissionExportOptions) => Promise<string>;
|
|
98
100
|
readonly validateSubmission: (submission: FormSubmission, source?: FormSubmissionValidationSource) => Promise<void>;
|
|
99
101
|
}
|
|
100
|
-
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
102
|
+
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "saveSubmissionWithinLimit" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
101
103
|
readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
|
|
104
|
+
readonly saveSubmissionWithinLimit: (submission: FormSubmission<TMeta>, maxResponses: number, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta> | {
|
|
105
|
+
readonly status: "limit_reached";
|
|
106
|
+
}>;
|
|
102
107
|
readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
|
|
103
108
|
readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
|
|
104
109
|
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TransactionAction } from '@azure/data-tables';
|
|
1
2
|
import { BaseSubmissionMetadata, PagedSubmissionStorageAdapter, TextAnswerPageQueryOptions, TextAnswerPage, TypedSubmissionPageQueryOptions, TypedSubmissionPage, FormSubmission, FormSchema, SubmissionPageQueryOptions, FormAnalytics, StorageSubmissionExportOptions, FormSubmissionValidationSource, ValidateFormSchemaOptions, FormLifecycleOptions, FormSubmissionValidator, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TypedTextAnswerPage } from '@form-engine-ts/core';
|
|
2
3
|
|
|
3
4
|
interface AzureTableListOptions {
|
|
@@ -23,6 +24,7 @@ interface AzureTableClientLike {
|
|
|
23
24
|
deleteEntity(partitionKey: string, rowKey: string, options?: {
|
|
24
25
|
readonly etag?: string;
|
|
25
26
|
}): Promise<unknown>;
|
|
27
|
+
readonly submitTransaction?: (actions: TransactionAction[]) => Promise<unknown>;
|
|
26
28
|
}
|
|
27
29
|
type AzureTableSubmissionEntity = Record<string, unknown> & {
|
|
28
30
|
readonly answers?: never;
|
|
@@ -97,8 +99,11 @@ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSu
|
|
|
97
99
|
readonly exportResponsesToCsv: (schema: FormSchema, options?: StorageSubmissionExportOptions) => Promise<string>;
|
|
98
100
|
readonly validateSubmission: (submission: FormSubmission, source?: FormSubmissionValidationSource) => Promise<void>;
|
|
99
101
|
}
|
|
100
|
-
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
102
|
+
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "saveSubmissionWithinLimit" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
101
103
|
readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
|
|
104
|
+
readonly saveSubmissionWithinLimit: (submission: FormSubmission<TMeta>, maxResponses: number, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta> | {
|
|
105
|
+
readonly status: "limit_reached";
|
|
106
|
+
}>;
|
|
102
107
|
readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
|
|
103
108
|
readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
|
|
104
109
|
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
package/dist/index.js
CHANGED
|
@@ -333,6 +333,9 @@ function isDuplicateEntityError(error) {
|
|
|
333
333
|
const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : "";
|
|
334
334
|
return isRecord(error) && (error.statusCode === 409 || error.code === "EntityAlreadyExists") || /alreadyexists|duplicate/iu.test(message);
|
|
335
335
|
}
|
|
336
|
+
function isPreconditionFailed(error) {
|
|
337
|
+
return isRecord(error) && (error.statusCode === 412 || error.code === "UpdateConditionNotSatisfied");
|
|
338
|
+
}
|
|
336
339
|
function matchesBuiltInFilters(submission, formId, options) {
|
|
337
340
|
return submission.formId === formId && (options.version === void 0 || submission.formVersion === options.version) && (options.since === void 0 || submission.submittedAt >= options.since) && (options.until === void 0 || submission.submittedAt <= options.until) && (options.locale === void 0 || submission.locale === options.locale);
|
|
338
341
|
}
|
|
@@ -514,6 +517,110 @@ function createAzureTableStorage(options = {}) {
|
|
|
514
517
|
return { status: "created", submission: stored, payloadHash };
|
|
515
518
|
}
|
|
516
519
|
},
|
|
520
|
+
async saveSubmissionWithinLimit(submission, maxResponses, saveOptions = {}) {
|
|
521
|
+
if (!Number.isInteger(maxResponses) || maxResponses < 1)
|
|
522
|
+
throw new RangeError("maxResponses must be a positive integer.");
|
|
523
|
+
ensureWritable();
|
|
524
|
+
const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
|
|
525
|
+
const explicitValidation = saveOptions.validator ?? saveOptions.validation;
|
|
526
|
+
const configuredValidation = options.submissionValidator ?? options.submissionSchema ?? options.validator ?? options.schema ?? options.validation;
|
|
527
|
+
if (explicitValidation !== void 0) await assertValidFormSubmissionWith(explicitValidation, stored);
|
|
528
|
+
if (explicitValidation === void 0 && configuredValidation !== void 0) {
|
|
529
|
+
await assertValidFormSubmissionWith(configuredValidation, stored);
|
|
530
|
+
}
|
|
531
|
+
if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
|
|
532
|
+
const schema = parseSchemaEntity(
|
|
533
|
+
await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
|
|
534
|
+
);
|
|
535
|
+
assertValidFormSubmission(schema, stored, options.schemaValidation);
|
|
536
|
+
}
|
|
537
|
+
const client = await submissionClient(stored.formId);
|
|
538
|
+
if (client.submitTransaction === void 0)
|
|
539
|
+
throw Object.assign(new Error("Azure Table transactions are required for atomic response limits."), {
|
|
540
|
+
code: "transaction_unsupported"
|
|
541
|
+
});
|
|
542
|
+
const partitionKey = codec.createPartitionKey(stored);
|
|
543
|
+
if (codec.createPartitionKeyFromQuery(stored.formId, { version: stored.formVersion }) !== partitionKey)
|
|
544
|
+
throw new Error("Atomic response limits require one Azure Table partition per form.");
|
|
545
|
+
const rowKey = codec.createRowKey(stored);
|
|
546
|
+
const payloadHash = await hashFormSubmissionPayload(stored);
|
|
547
|
+
const keys = physicalKeyNames(options.fieldMapping);
|
|
548
|
+
const createdEntity = codec.createEntity(stored);
|
|
549
|
+
assertCanonicalSubmissionEntity(createdEntity);
|
|
550
|
+
const submissionEntity = {
|
|
551
|
+
...mappedSubmissionEntity(createdEntity, stored, options.fieldMapping, valueCodec),
|
|
552
|
+
payloadHash,
|
|
553
|
+
partitionKey,
|
|
554
|
+
rowKey,
|
|
555
|
+
[keys.partitionKey]: partitionKey,
|
|
556
|
+
[keys.rowKey]: rowKey
|
|
557
|
+
};
|
|
558
|
+
const idempotent = saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false;
|
|
559
|
+
try {
|
|
560
|
+
const existingEntity = await client.getEntity(partitionKey, rowKey);
|
|
561
|
+
if (!idempotent) throw new Error(`A submission with ID "${stored.id}" already exists.`);
|
|
562
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
563
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await hashFormSubmissionPayload(existing);
|
|
564
|
+
if (existingPayloadHash === payloadHash) return { status: "duplicate", submission: existing, payloadHash };
|
|
565
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (!isNotFound(error)) throw error;
|
|
568
|
+
}
|
|
569
|
+
const capacityRowKey = `capacity_${stored.formVersion}`;
|
|
570
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
571
|
+
let capacity;
|
|
572
|
+
try {
|
|
573
|
+
capacity = await client.getEntity(partitionKey, capacityRowKey);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (!isNotFound(error)) throw error;
|
|
576
|
+
const count2 = await adapter.countSubmissions(stored.formId, stored.formVersion);
|
|
577
|
+
try {
|
|
578
|
+
await client.createEntity({ partitionKey, rowKey: capacityRowKey, kind: "capacity", count: count2 });
|
|
579
|
+
} catch (createError) {
|
|
580
|
+
if (!isDuplicateEntityError(createError)) throw createError;
|
|
581
|
+
}
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const count = await adapter.countSubmissions(stored.formId, stored.formVersion);
|
|
585
|
+
if (count >= maxResponses) return { status: "limit_reached" };
|
|
586
|
+
if (typeof capacity.etag !== "string") throw new Error("Azure Table capacity entity is missing an ETag.");
|
|
587
|
+
const actions = [
|
|
588
|
+
[
|
|
589
|
+
"update",
|
|
590
|
+
{ ...capacity, partitionKey, rowKey: capacityRowKey, count: count + 1 },
|
|
591
|
+
"Replace",
|
|
592
|
+
{ etag: capacity.etag }
|
|
593
|
+
],
|
|
594
|
+
["create", submissionEntity]
|
|
595
|
+
];
|
|
596
|
+
try {
|
|
597
|
+
await client.submitTransaction(actions);
|
|
598
|
+
if (idempotent) return { status: "created", submission: stored, payloadHash };
|
|
599
|
+
return;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (isPreconditionFailed(error)) {
|
|
602
|
+
try {
|
|
603
|
+
const racedEntity = await client.getEntity(partitionKey, rowKey);
|
|
604
|
+
if (!idempotent) throw new Error(`A submission with ID "${stored.id}" already exists.`);
|
|
605
|
+
const raced = parseSubmissionEntity(racedEntity, codec, options.fieldMapping, valueCodec);
|
|
606
|
+
const existingPayloadHash2 = typeof racedEntity.payloadHash === "string" ? racedEntity.payloadHash : await hashFormSubmissionPayload(raced);
|
|
607
|
+
if (existingPayloadHash2 === payloadHash) return { status: "duplicate", submission: raced, payloadHash };
|
|
608
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash: existingPayloadHash2 };
|
|
609
|
+
} catch (lookupError) {
|
|
610
|
+
if (!isNotFound(lookupError)) throw lookupError;
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (!idempotent || !isDuplicateEntityError(error)) throw error;
|
|
615
|
+
const existingEntity = await client.getEntity(partitionKey, rowKey);
|
|
616
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
617
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await hashFormSubmissionPayload(existing);
|
|
618
|
+
if (existingPayloadHash === payloadHash) return { status: "duplicate", submission: existing, payloadHash };
|
|
619
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
throw new Error("Azure Table response capacity update conflicted too many times.");
|
|
623
|
+
},
|
|
517
624
|
async listSubmissions(formId, formVersion, queryOptions = {}) {
|
|
518
625
|
return listSubmissionCandidates(formId, {
|
|
519
626
|
...queryOptions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@form-engine-ts/storage-azure-table",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.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": "8.0.
|
|
42
|
+
"@form-engine-ts/core": "8.0.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@azure/data-tables": "^13.3.2"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@azure/data-tables": "^13.3.2",
|
|
54
|
-
"@form-engine-ts/storage": "8.0.
|
|
54
|
+
"@form-engine-ts/storage": "8.0.1"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external @form-engine-ts/core",
|