@form-engine-ts/storage 7.18.1 → 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 CHANGED
@@ -21,18 +21,30 @@ Both adapters implement the required `UnifiedSubmissionStorageAdapter` surface:
21
21
  free-text answer paging, idempotent saving, typed validation, response aggregation, and CSV export. These common
22
22
  operations use the same core contracts regardless of the backing database.
23
23
 
24
+ ## Migration notes
25
+
26
+ `StorageAdapter`, `TypedStorageAdapter`, and `UnifiedSubmissionStorageAdapter` now require
27
+ `countSubmissions(formId, formVersion?, options?)`. Implementations must apply inclusive `since` and `until` filters
28
+ from `SubmissionQueryOptions`; submission pipelines no longer fall back to scanning every page.
29
+
30
+ Built-in adapters also expose `saveSubmissionWithinLimit(submission, maxResponses, options?)`. This operation checks
31
+ capacity and saves atomically for one form version, preserves idempotent duplicate/conflict results, and returns
32
+ `{ status: "limit_reached" }` without writing when full. Database adapters require their documented transaction or
33
+ conditional-write capability; they throw `transaction_unsupported` rather than performing a racy check-then-save.
34
+
24
35
  The `./testing` subpath exports framework-independent JSON fixtures and contract runners for pagination,
25
36
  idempotency, revision conflicts, translation metadata, CSV, and form deletion. Adapters expose
26
37
  `inspectFormDeletion` and `deleteForm` through the lifecycle contract when their implementation supports it;
27
38
  transactional deletion is required by default and `allowNonAtomic: true` is an explicit fallback.
28
39
 
29
- | Package | 7.18.x contract status |
40
+ | Package | v8 contract status |
30
41
  | --- | --- |
31
- | `storage-memory` | lifecycle, pagination, idempotency |
32
- | `storage-mongodb` | lifecycle, version state/events, transactions |
33
- | `storage-azure-table` | lifecycle, pagination; no native transaction |
34
- | `storage-postgres` / `storage-sqlite` / `storage-d1` | lifecycle, pagination; transaction depends on injected client |
35
- | `storage-localstorage` | lifecycle, pagination; non-atomic only |
42
+ | `storage-memory` | lifecycle, pagination, idempotency, process-local atomic response limits |
43
+ | `storage-mongodb` | lifecycle, version state/events, transaction-backed response limits |
44
+ | `storage-azure-table` | lifecycle, pagination, same-partition transactional response limits |
45
+ | `storage-postgres` / `storage-sqlite` | lifecycle, pagination, transaction-backed response limits |
46
+ | `storage-d1` | lifecycle, pagination, single-statement conditional response limits |
47
+ | `storage-localstorage` | lifecycle, pagination, same-JavaScript-agent response limits |
36
48
 
37
49
  Existing `StorageAdapter` methods remain valid. To migrate, pass `schemaValidation` to an adapter when
38
50
  Content Mode policy must be enforced at persistence, then use `inspectFormDeletion` before deletion and
package/dist/testing.cjs CHANGED
@@ -23,6 +23,7 @@ __export(testing_exports, {
23
23
  createStorageContractFixtures: () => createStorageContractFixtures,
24
24
  runIdempotencyContract: () => runIdempotencyContract,
25
25
  runLifecycleContract: () => runLifecycleContract,
26
+ runResponseLimitContract: () => runResponseLimitContract,
26
27
  runRevisionConflictContract: () => runRevisionConflictContract,
27
28
  runStorageContract: () => runStorageContract,
28
29
  storageContractScope: () => storageContractScope,
@@ -84,6 +85,23 @@ async function runStorageContract(adapter) {
84
85
  check(JSON.stringify(loaded) === JSON.stringify(submissions), "submission ordering/metadata round trip");
85
86
  const passed = ["schema", "submission", "translation_metadata"];
86
87
  const unsupported = [];
88
+ check(await adapter.countSubmissions(schema.id) === submissions.length, "count must include all form versions");
89
+ check(
90
+ await adapter.countSubmissions(schema.id, schema.version) === submissions.length,
91
+ "count must filter by form version"
92
+ );
93
+ check(
94
+ await adapter.countSubmissions(schema.id, schema.version, {
95
+ since: "2026-01-01T00:00:00.000Z",
96
+ until: "2026-01-01T00:00:00.000Z"
97
+ }) === submissions.length,
98
+ "count must include since/until boundaries"
99
+ );
100
+ check(
101
+ await adapter.countSubmissions(schema.id, schema.version, { since: "2026-01-01T00:00:00.001Z" }) === 0,
102
+ "count must filter by submission time"
103
+ );
104
+ passed.push("count");
87
105
  if ("listSubmissionPage" in adapter && typeof adapter.listSubmissionPage === "function") {
88
106
  const paged = adapter;
89
107
  const ids = [];
@@ -116,6 +134,54 @@ async function runIdempotencyContract(save) {
116
134
  "changed payload must conflict"
117
135
  );
118
136
  }
137
+ async function runResponseLimitContract(adapter) {
138
+ const [submission] = createStorageContractFixtures().submissions;
139
+ if (submission === void 0) throw new Error("Missing fixture");
140
+ const saveWithinLimit = adapter.saveSubmissionWithinLimit?.bind(adapter);
141
+ check(saveWithinLimit !== void 0, "adapter must implement saveSubmissionWithinLimit");
142
+ const created = await saveWithinLimit(submission, 1, { idempotent: true });
143
+ check(created?.status === "created", "first limited save must be created");
144
+ const duplicate = await saveWithinLimit(submission, 1, { idempotent: true });
145
+ check(duplicate?.status === "duplicate", "idempotent retry must succeed after reaching the limit");
146
+ const conflict = await saveWithinLimit({ ...submission, values: { "question-1": "option-2" } }, 1, {
147
+ idempotent: true
148
+ });
149
+ check(conflict?.status === "conflict", "changed retry must conflict after reaching the limit");
150
+ const limited = await saveWithinLimit({ ...submission, id: "contract-limit" }, 1, {
151
+ idempotent: true
152
+ });
153
+ check(limited?.status === "limit_reached", "second submission must be rejected at the limit");
154
+ const nextVersion = await saveWithinLimit(
155
+ { ...submission, id: "contract-next-version", formVersion: submission.formVersion + 1 },
156
+ 1,
157
+ { idempotent: true }
158
+ );
159
+ check(nextVersion?.status === "created", "response limits must be scoped by form version");
160
+ const retrySubmission = { ...submission, id: "contract-concurrent-retry", formVersion: submission.formVersion + 2 };
161
+ const retryResults = await Promise.all([
162
+ saveWithinLimit(retrySubmission, 1, { idempotent: true }),
163
+ saveWithinLimit(retrySubmission, 1, { idempotent: true })
164
+ ]);
165
+ const retryStatuses = retryResults.map((result) => result?.status).sort();
166
+ check(
167
+ JSON.stringify(retryStatuses) === JSON.stringify(["created", "duplicate"]),
168
+ "concurrent idempotent retries must create once"
169
+ );
170
+ const concurrentVersion = submission.formVersion + 3;
171
+ const capacityResults = await Promise.all([
172
+ saveWithinLimit({ ...submission, id: "contract-concurrent-a", formVersion: concurrentVersion }, 1, {
173
+ idempotent: true
174
+ }),
175
+ saveWithinLimit({ ...submission, id: "contract-concurrent-b", formVersion: concurrentVersion }, 1, {
176
+ idempotent: true
177
+ })
178
+ ]);
179
+ const capacityStatuses = capacityResults.map((result) => result?.status).sort();
180
+ check(
181
+ JSON.stringify(capacityStatuses) === JSON.stringify(["created", "limit_reached"]),
182
+ "concurrent submissions must not exceed the response limit"
183
+ );
184
+ }
119
185
  async function runRevisionConflictContract(adapter) {
120
186
  const { schema, state } = createStorageContractFixtures();
121
187
  const planned = (0, import_core.createCloneTransitionPlan)(
@@ -195,6 +261,7 @@ function verifyStorageCodecVectors() {
195
261
  createStorageContractFixtures,
196
262
  runIdempotencyContract,
197
263
  runLifecycleContract,
264
+ runResponseLimitContract,
198
265
  runRevisionConflictContract,
199
266
  runStorageContract,
200
267
  storageContractScope,
@@ -32,8 +32,9 @@ interface StorageContractReport {
32
32
  /** Run against an empty, disposable adapter. Never clears a caller's database. */
33
33
  declare function runStorageContract(adapter: FormStorageAdapter): Promise<StorageContractReport>;
34
34
  declare function runIdempotencyContract(save: (submission: FormSubmission) => Promise<undefined | SubmissionSaveResult>): Promise<void>;
35
+ declare function runResponseLimitContract(adapter: FormStorageAdapter): Promise<void>;
35
36
  declare function runRevisionConflictContract(adapter: VersionedFormStorageAdapter): Promise<void>;
36
37
  declare function runLifecycleContract(adapter: FormStorageAdapter): Promise<void>;
37
38
  declare function verifyStorageCodecVectors(): void;
38
39
 
39
- export { type StorageContractReport, createStorageContractFixtures, runIdempotencyContract, runLifecycleContract, runRevisionConflictContract, runStorageContract, storageContractScope, verifyStorageCodecVectors };
40
+ export { type StorageContractReport, createStorageContractFixtures, runIdempotencyContract, runLifecycleContract, runResponseLimitContract, runRevisionConflictContract, runStorageContract, storageContractScope, verifyStorageCodecVectors };
package/dist/testing.d.ts CHANGED
@@ -32,8 +32,9 @@ interface StorageContractReport {
32
32
  /** Run against an empty, disposable adapter. Never clears a caller's database. */
33
33
  declare function runStorageContract(adapter: FormStorageAdapter): Promise<StorageContractReport>;
34
34
  declare function runIdempotencyContract(save: (submission: FormSubmission) => Promise<undefined | SubmissionSaveResult>): Promise<void>;
35
+ declare function runResponseLimitContract(adapter: FormStorageAdapter): Promise<void>;
35
36
  declare function runRevisionConflictContract(adapter: VersionedFormStorageAdapter): Promise<void>;
36
37
  declare function runLifecycleContract(adapter: FormStorageAdapter): Promise<void>;
37
38
  declare function verifyStorageCodecVectors(): void;
38
39
 
39
- export { type StorageContractReport, createStorageContractFixtures, runIdempotencyContract, runLifecycleContract, runRevisionConflictContract, runStorageContract, storageContractScope, verifyStorageCodecVectors };
40
+ export { type StorageContractReport, createStorageContractFixtures, runIdempotencyContract, runLifecycleContract, runResponseLimitContract, runRevisionConflictContract, runStorageContract, storageContractScope, verifyStorageCodecVectors };
package/dist/testing.js CHANGED
@@ -60,6 +60,23 @@ async function runStorageContract(adapter) {
60
60
  check(JSON.stringify(loaded) === JSON.stringify(submissions), "submission ordering/metadata round trip");
61
61
  const passed = ["schema", "submission", "translation_metadata"];
62
62
  const unsupported = [];
63
+ check(await adapter.countSubmissions(schema.id) === submissions.length, "count must include all form versions");
64
+ check(
65
+ await adapter.countSubmissions(schema.id, schema.version) === submissions.length,
66
+ "count must filter by form version"
67
+ );
68
+ check(
69
+ await adapter.countSubmissions(schema.id, schema.version, {
70
+ since: "2026-01-01T00:00:00.000Z",
71
+ until: "2026-01-01T00:00:00.000Z"
72
+ }) === submissions.length,
73
+ "count must include since/until boundaries"
74
+ );
75
+ check(
76
+ await adapter.countSubmissions(schema.id, schema.version, { since: "2026-01-01T00:00:00.001Z" }) === 0,
77
+ "count must filter by submission time"
78
+ );
79
+ passed.push("count");
63
80
  if ("listSubmissionPage" in adapter && typeof adapter.listSubmissionPage === "function") {
64
81
  const paged = adapter;
65
82
  const ids = [];
@@ -92,6 +109,54 @@ async function runIdempotencyContract(save) {
92
109
  "changed payload must conflict"
93
110
  );
94
111
  }
112
+ async function runResponseLimitContract(adapter) {
113
+ const [submission] = createStorageContractFixtures().submissions;
114
+ if (submission === void 0) throw new Error("Missing fixture");
115
+ const saveWithinLimit = adapter.saveSubmissionWithinLimit?.bind(adapter);
116
+ check(saveWithinLimit !== void 0, "adapter must implement saveSubmissionWithinLimit");
117
+ const created = await saveWithinLimit(submission, 1, { idempotent: true });
118
+ check(created?.status === "created", "first limited save must be created");
119
+ const duplicate = await saveWithinLimit(submission, 1, { idempotent: true });
120
+ check(duplicate?.status === "duplicate", "idempotent retry must succeed after reaching the limit");
121
+ const conflict = await saveWithinLimit({ ...submission, values: { "question-1": "option-2" } }, 1, {
122
+ idempotent: true
123
+ });
124
+ check(conflict?.status === "conflict", "changed retry must conflict after reaching the limit");
125
+ const limited = await saveWithinLimit({ ...submission, id: "contract-limit" }, 1, {
126
+ idempotent: true
127
+ });
128
+ check(limited?.status === "limit_reached", "second submission must be rejected at the limit");
129
+ const nextVersion = await saveWithinLimit(
130
+ { ...submission, id: "contract-next-version", formVersion: submission.formVersion + 1 },
131
+ 1,
132
+ { idempotent: true }
133
+ );
134
+ check(nextVersion?.status === "created", "response limits must be scoped by form version");
135
+ const retrySubmission = { ...submission, id: "contract-concurrent-retry", formVersion: submission.formVersion + 2 };
136
+ const retryResults = await Promise.all([
137
+ saveWithinLimit(retrySubmission, 1, { idempotent: true }),
138
+ saveWithinLimit(retrySubmission, 1, { idempotent: true })
139
+ ]);
140
+ const retryStatuses = retryResults.map((result) => result?.status).sort();
141
+ check(
142
+ JSON.stringify(retryStatuses) === JSON.stringify(["created", "duplicate"]),
143
+ "concurrent idempotent retries must create once"
144
+ );
145
+ const concurrentVersion = submission.formVersion + 3;
146
+ const capacityResults = await Promise.all([
147
+ saveWithinLimit({ ...submission, id: "contract-concurrent-a", formVersion: concurrentVersion }, 1, {
148
+ idempotent: true
149
+ }),
150
+ saveWithinLimit({ ...submission, id: "contract-concurrent-b", formVersion: concurrentVersion }, 1, {
151
+ idempotent: true
152
+ })
153
+ ]);
154
+ const capacityStatuses = capacityResults.map((result) => result?.status).sort();
155
+ check(
156
+ JSON.stringify(capacityStatuses) === JSON.stringify(["created", "limit_reached"]),
157
+ "concurrent submissions must not exceed the response limit"
158
+ );
159
+ }
95
160
  async function runRevisionConflictContract(adapter) {
96
161
  const { schema, state } = createStorageContractFixtures();
97
162
  const planned = createCloneTransitionPlan(
@@ -170,6 +235,7 @@ export {
170
235
  createStorageContractFixtures,
171
236
  runIdempotencyContract,
172
237
  runLifecycleContract,
238
+ runResponseLimitContract,
173
239
  runRevisionConflictContract,
174
240
  runStorageContract,
175
241
  storageContractScope,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage",
3
- "version": "7.18.1",
3
+ "version": "8.0.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,7 +42,7 @@
42
42
  "url": "git+https://github.com/nitta-a/form-engine-ts.git"
43
43
  },
44
44
  "dependencies": {
45
- "@form-engine-ts/core": "7.18.1"
45
+ "@form-engine-ts/core": "8.0.1"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsup src/index.ts src/types.ts src/pagination.ts src/testing.ts --format esm,cjs --dts --clean --external @form-engine-ts/core",