@form-engine-ts/storage-azure-table 8.0.0 → 8.0.2

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
@@ -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.0",
3
+ "version": "8.0.2",
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.0"
42
+ "@form-engine-ts/core": "8.0.2"
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.0"
54
+ "@form-engine-ts/storage": "8.0.2"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup src/index.ts --format esm,cjs --dts --clean --external @form-engine-ts/core",