@form-engine-ts/storage-azure-table 8.1.6 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,7 +49,8 @@ source can be supplied per call as `saveSubmission(submission, { validation })`.
49
49
  Zod-compatible object with `safeParse`, or an async validator callback.
50
50
 
51
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
52
+ Table transaction to update capacity and create the response together. Set `responseLimitScope: "form"` to share its
53
+ capacity row across all versions. The submission codec must place a form's
53
54
  responses in one partition (the default codec does), and the injected client must expose `submitTransaction`.
54
55
 
55
56
  `listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
package/dist/index.cjs CHANGED
@@ -404,16 +404,18 @@ function createAzureTableStorage(options = {}) {
404
404
  );
405
405
  };
406
406
  const lifecycleBackend = {
407
- resources: ["schema", "submission"],
407
+ resources: ["schema", "submission", "runtimeSettings"],
408
408
  async list(formId) {
409
409
  const records = [];
410
- for (const kind of ["schema", "submission"]) {
411
- const client = kind === "schema" ? await schemaClient(formId) : await submissionClient(formId);
412
- const filter = kind === "schema" ? `PartitionKey eq '${escapeOData(formId)}' and kind eq 'schema'` : queryFilter(formId, {});
410
+ for (const kind of ["schema", "submission", "runtimeSettings"]) {
411
+ const isSchema = kind === "schema" || kind === "runtimeSettings";
412
+ const client = isSchema ? await schemaClient(formId) : await submissionClient(formId);
413
+ const filter = isSchema ? `PartitionKey eq '${escapeOData(formId)}' and kind eq '${kind === "schema" ? "schema" : "runtimeSettings"}'` : queryFilter(formId, {});
413
414
  for await (const raw of client.listEntities({ queryOptions: { filter } })) {
414
- if (kind === "schema" && raw.kind !== "schema") continue;
415
- const value = kind === "schema" ? parseSchemaEntity(raw, options.schemaValidation) : deserializeIfMatching(raw);
416
- if (value === void 0 || ("formId" in value ? value.formId : value.id) !== formId) continue;
415
+ if (isSchema && raw.kind !== kind) continue;
416
+ const value = kind === "schema" ? parseSchemaEntity(raw, options.schemaValidation) : kind === "runtimeSettings" ? parseJson(raw.payload, "runtime settings") : deserializeIfMatching(raw);
417
+ const resourceFormId = isRecord(value) ? typeof value.formId === "string" ? value.formId : typeof value.id === "string" ? value.id : void 0 : void 0;
418
+ if (resourceFormId !== formId) continue;
417
419
  if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string")
418
420
  throw new TypeError("Missing entity keys.");
419
421
  records.push({ kind, id: JSON.stringify([raw.partitionKey, raw.rowKey]), value: { raw, value, formId } });
@@ -428,7 +430,7 @@ function createAzureTableStorage(options = {}) {
428
430
  const { raw, formId } = resource.value;
429
431
  if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string" || typeof raw.etag !== "string")
430
432
  throw new TypeError("Conditional deletion requires entity keys and an ETag.");
431
- const client = resource.kind === "schema" ? await schemaClient(formId) : await submissionClient(formId);
433
+ const client = resource.kind === "schema" || resource.kind === "runtimeSettings" ? await schemaClient(formId) : await submissionClient(formId);
432
434
  try {
433
435
  await client.deleteEntity(raw.partitionKey, raw.rowKey, { etag: raw.etag });
434
436
  return 1;
@@ -474,6 +476,31 @@ function createAzureTableStorage(options = {}) {
474
476
  throw error;
475
477
  }
476
478
  },
479
+ async getRuntimeSettings(formId) {
480
+ try {
481
+ const entity = await (await schemaClient(formId)).getEntity(formId, "runtime-settings");
482
+ const settings = parseJson(entity.payload, "runtime settings");
483
+ (0, import_core.assertValidFormRuntimeSettings)(settings);
484
+ if (settings.formId !== formId) throw new Error("Azure Table runtime settings have inconsistent metadata.");
485
+ return cloneJson(settings);
486
+ } catch (error) {
487
+ if (isNotFound(error)) return null;
488
+ throw error;
489
+ }
490
+ },
491
+ async saveRuntimeSettings(settings) {
492
+ ensureWritable();
493
+ (0, import_core.assertValidFormRuntimeSettings)(settings);
494
+ await (await schemaClient(settings.formId)).upsertEntity(
495
+ {
496
+ partitionKey: settings.formId,
497
+ rowKey: "runtime-settings",
498
+ kind: "runtimeSettings",
499
+ payload: JSON.stringify(settings)
500
+ },
501
+ "Replace"
502
+ );
503
+ },
477
504
  async listSchemas() {
478
505
  const found = [];
479
506
  for await (const raw of (await schemaClient("")).listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
@@ -555,7 +582,11 @@ function createAzureTableStorage(options = {}) {
555
582
  code: "transaction_unsupported"
556
583
  });
557
584
  const partitionKey = codec.createPartitionKey(stored);
558
- if (codec.createPartitionKeyFromQuery(stored.formId, { version: stored.formVersion }) !== partitionKey)
585
+ const formScope = saveOptions.responseLimitScope === "form";
586
+ const capacityVersion = formScope ? void 0 : stored.formVersion;
587
+ if (codec.createPartitionKeyFromQuery(stored.formId, {
588
+ ...capacityVersion === void 0 ? {} : { version: capacityVersion }
589
+ }) !== partitionKey)
559
590
  throw new Error("Atomic response limits require one Azure Table partition per form.");
560
591
  const rowKey = codec.createRowKey(stored);
561
592
  const payloadHash = await (0, import_core.hashFormSubmissionPayload)(stored);
@@ -581,14 +612,14 @@ function createAzureTableStorage(options = {}) {
581
612
  } catch (error) {
582
613
  if (!isNotFound(error)) throw error;
583
614
  }
584
- const capacityRowKey = `capacity_${stored.formVersion}`;
615
+ const capacityRowKey = formScope ? "capacity_form" : `capacity_${stored.formVersion}`;
585
616
  for (let attempt = 0; attempt < 5; attempt += 1) {
586
617
  let capacity;
587
618
  try {
588
619
  capacity = await client.getEntity(partitionKey, capacityRowKey);
589
620
  } catch (error) {
590
621
  if (!isNotFound(error)) throw error;
591
- const count2 = await adapter.countSubmissions(stored.formId, stored.formVersion);
622
+ const count2 = await adapter.countSubmissions(stored.formId, capacityVersion);
592
623
  try {
593
624
  await client.createEntity({ partitionKey, rowKey: capacityRowKey, kind: "capacity", count: count2 });
594
625
  } catch (createError) {
@@ -596,7 +627,7 @@ function createAzureTableStorage(options = {}) {
596
627
  }
597
628
  continue;
598
629
  }
599
- const count = await adapter.countSubmissions(stored.formId, stored.formVersion);
630
+ const count = await adapter.countSubmissions(stored.formId, capacityVersion);
600
631
  if (count >= maxResponses) return { status: "limit_reached" };
601
632
  if (typeof capacity.etag !== "string") throw new Error("Azure Table capacity entity is missing an ETag.");
602
633
  const actions = [
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TransactionAction } from '@azure/data-tables';
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
+ import { BaseSubmissionMetadata, PagedSubmissionStorageAdapter, FormRuntimeSettings, TextAnswerPageQueryOptions, TextAnswerPage, TypedSubmissionPageQueryOptions, TypedSubmissionPage, FormSubmission, FormSchema, SubmissionPageQueryOptions, FormAnalytics, StorageSubmissionExportOptions, FormSubmissionValidationSource, ValidateFormSchemaOptions, FormLifecycleOptions, FormSubmissionValidator, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TypedTextAnswerPage } from '@form-engine-ts/core';
3
3
 
4
4
  interface AzureTableListOptions {
5
5
  readonly queryOptions?: {
@@ -82,6 +82,8 @@ interface AzureTableStorageOptions<T = FormSubmission> {
82
82
  readonly schema?: FormSubmissionValidationSource;
83
83
  }
84
84
  interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
85
+ getRuntimeSettings?(formId: string): Promise<FormRuntimeSettings | null>;
86
+ saveRuntimeSettings?(settings: FormRuntimeSettings): Promise<void>;
85
87
  readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TextAnswerPage>;
86
88
  readonly fetchSubmissionPage?: (formId: string, options?: TypedSubmissionPageQueryOptions<TMeta>) => Promise<TypedSubmissionPage<TMeta>>;
87
89
  readonly fetchPage: (formId: string, options?: {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TransactionAction } from '@azure/data-tables';
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
+ import { BaseSubmissionMetadata, PagedSubmissionStorageAdapter, FormRuntimeSettings, TextAnswerPageQueryOptions, TextAnswerPage, TypedSubmissionPageQueryOptions, TypedSubmissionPage, FormSubmission, FormSchema, SubmissionPageQueryOptions, FormAnalytics, StorageSubmissionExportOptions, FormSubmissionValidationSource, ValidateFormSchemaOptions, FormLifecycleOptions, FormSubmissionValidator, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TypedTextAnswerPage } from '@form-engine-ts/core';
3
3
 
4
4
  interface AzureTableListOptions {
5
5
  readonly queryOptions?: {
@@ -82,6 +82,8 @@ interface AzureTableStorageOptions<T = FormSubmission> {
82
82
  readonly schema?: FormSubmissionValidationSource;
83
83
  }
84
84
  interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
85
+ getRuntimeSettings?(formId: string): Promise<FormRuntimeSettings | null>;
86
+ saveRuntimeSettings?(settings: FormRuntimeSettings): Promise<void>;
85
87
  readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TextAnswerPage>;
86
88
  readonly fetchSubmissionPage?: (formId: string, options?: TypedSubmissionPageQueryOptions<TMeta>) => Promise<TypedSubmissionPage<TMeta>>;
87
89
  readonly fetchPage: (formId: string, options?: {
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/index.ts
2
2
  import {
3
3
  aggregateResponses,
4
+ assertValidFormRuntimeSettings,
4
5
  assertValidFormSchema,
5
6
  assertValidFormSubmission,
6
7
  assertValidFormSubmissionWith,
@@ -388,16 +389,18 @@ function createAzureTableStorage(options = {}) {
388
389
  );
389
390
  };
390
391
  const lifecycleBackend = {
391
- resources: ["schema", "submission"],
392
+ resources: ["schema", "submission", "runtimeSettings"],
392
393
  async list(formId) {
393
394
  const records = [];
394
- for (const kind of ["schema", "submission"]) {
395
- const client = kind === "schema" ? await schemaClient(formId) : await submissionClient(formId);
396
- const filter = kind === "schema" ? `PartitionKey eq '${escapeOData(formId)}' and kind eq 'schema'` : queryFilter(formId, {});
395
+ for (const kind of ["schema", "submission", "runtimeSettings"]) {
396
+ const isSchema = kind === "schema" || kind === "runtimeSettings";
397
+ const client = isSchema ? await schemaClient(formId) : await submissionClient(formId);
398
+ const filter = isSchema ? `PartitionKey eq '${escapeOData(formId)}' and kind eq '${kind === "schema" ? "schema" : "runtimeSettings"}'` : queryFilter(formId, {});
397
399
  for await (const raw of client.listEntities({ queryOptions: { filter } })) {
398
- if (kind === "schema" && raw.kind !== "schema") continue;
399
- const value = kind === "schema" ? parseSchemaEntity(raw, options.schemaValidation) : deserializeIfMatching(raw);
400
- if (value === void 0 || ("formId" in value ? value.formId : value.id) !== formId) continue;
400
+ if (isSchema && raw.kind !== kind) continue;
401
+ const value = kind === "schema" ? parseSchemaEntity(raw, options.schemaValidation) : kind === "runtimeSettings" ? parseJson(raw.payload, "runtime settings") : deserializeIfMatching(raw);
402
+ const resourceFormId = isRecord(value) ? typeof value.formId === "string" ? value.formId : typeof value.id === "string" ? value.id : void 0 : void 0;
403
+ if (resourceFormId !== formId) continue;
401
404
  if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string")
402
405
  throw new TypeError("Missing entity keys.");
403
406
  records.push({ kind, id: JSON.stringify([raw.partitionKey, raw.rowKey]), value: { raw, value, formId } });
@@ -412,7 +415,7 @@ function createAzureTableStorage(options = {}) {
412
415
  const { raw, formId } = resource.value;
413
416
  if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string" || typeof raw.etag !== "string")
414
417
  throw new TypeError("Conditional deletion requires entity keys and an ETag.");
415
- const client = resource.kind === "schema" ? await schemaClient(formId) : await submissionClient(formId);
418
+ const client = resource.kind === "schema" || resource.kind === "runtimeSettings" ? await schemaClient(formId) : await submissionClient(formId);
416
419
  try {
417
420
  await client.deleteEntity(raw.partitionKey, raw.rowKey, { etag: raw.etag });
418
421
  return 1;
@@ -458,6 +461,31 @@ function createAzureTableStorage(options = {}) {
458
461
  throw error;
459
462
  }
460
463
  },
464
+ async getRuntimeSettings(formId) {
465
+ try {
466
+ const entity = await (await schemaClient(formId)).getEntity(formId, "runtime-settings");
467
+ const settings = parseJson(entity.payload, "runtime settings");
468
+ assertValidFormRuntimeSettings(settings);
469
+ if (settings.formId !== formId) throw new Error("Azure Table runtime settings have inconsistent metadata.");
470
+ return cloneJson(settings);
471
+ } catch (error) {
472
+ if (isNotFound(error)) return null;
473
+ throw error;
474
+ }
475
+ },
476
+ async saveRuntimeSettings(settings) {
477
+ ensureWritable();
478
+ assertValidFormRuntimeSettings(settings);
479
+ await (await schemaClient(settings.formId)).upsertEntity(
480
+ {
481
+ partitionKey: settings.formId,
482
+ rowKey: "runtime-settings",
483
+ kind: "runtimeSettings",
484
+ payload: JSON.stringify(settings)
485
+ },
486
+ "Replace"
487
+ );
488
+ },
461
489
  async listSchemas() {
462
490
  const found = [];
463
491
  for await (const raw of (await schemaClient("")).listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
@@ -539,7 +567,11 @@ function createAzureTableStorage(options = {}) {
539
567
  code: "transaction_unsupported"
540
568
  });
541
569
  const partitionKey = codec.createPartitionKey(stored);
542
- if (codec.createPartitionKeyFromQuery(stored.formId, { version: stored.formVersion }) !== partitionKey)
570
+ const formScope = saveOptions.responseLimitScope === "form";
571
+ const capacityVersion = formScope ? void 0 : stored.formVersion;
572
+ if (codec.createPartitionKeyFromQuery(stored.formId, {
573
+ ...capacityVersion === void 0 ? {} : { version: capacityVersion }
574
+ }) !== partitionKey)
543
575
  throw new Error("Atomic response limits require one Azure Table partition per form.");
544
576
  const rowKey = codec.createRowKey(stored);
545
577
  const payloadHash = await hashFormSubmissionPayload(stored);
@@ -565,14 +597,14 @@ function createAzureTableStorage(options = {}) {
565
597
  } catch (error) {
566
598
  if (!isNotFound(error)) throw error;
567
599
  }
568
- const capacityRowKey = `capacity_${stored.formVersion}`;
600
+ const capacityRowKey = formScope ? "capacity_form" : `capacity_${stored.formVersion}`;
569
601
  for (let attempt = 0; attempt < 5; attempt += 1) {
570
602
  let capacity;
571
603
  try {
572
604
  capacity = await client.getEntity(partitionKey, capacityRowKey);
573
605
  } catch (error) {
574
606
  if (!isNotFound(error)) throw error;
575
- const count2 = await adapter.countSubmissions(stored.formId, stored.formVersion);
607
+ const count2 = await adapter.countSubmissions(stored.formId, capacityVersion);
576
608
  try {
577
609
  await client.createEntity({ partitionKey, rowKey: capacityRowKey, kind: "capacity", count: count2 });
578
610
  } catch (createError) {
@@ -580,7 +612,7 @@ function createAzureTableStorage(options = {}) {
580
612
  }
581
613
  continue;
582
614
  }
583
- const count = await adapter.countSubmissions(stored.formId, stored.formVersion);
615
+ const count = await adapter.countSubmissions(stored.formId, capacityVersion);
584
616
  if (count >= maxResponses) return { status: "limit_reached" };
585
617
  if (typeof capacity.etag !== "string") throw new Error("Azure Table capacity entity is missing an ETag.");
586
618
  const actions = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-azure-table",
3
- "version": "8.1.6",
3
+ "version": "8.2.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": "8.1.6"
42
+ "@form-engine-ts/core": "8.2.0"
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.1.6"
54
+ "@form-engine-ts/storage": "8.2.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup src/index.ts --format esm,cjs --dts --clean --external @form-engine-ts/core",