@form-engine-ts/storage 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 CHANGED
@@ -27,18 +27,24 @@ operations use the same core contracts regardless of the backing database.
27
27
  `countSubmissions(formId, formVersion?, options?)`. Implementations must apply inclusive `since` and `until` filters
28
28
  from `SubmissionQueryOptions`; submission pipelines no longer fall back to scanning every page.
29
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
+
30
35
  The `./testing` subpath exports framework-independent JSON fixtures and contract runners for pagination,
31
36
  idempotency, revision conflicts, translation metadata, CSV, and form deletion. Adapters expose
32
37
  `inspectFormDeletion` and `deleteForm` through the lifecycle contract when their implementation supports it;
33
38
  transactional deletion is required by default and `allowNonAtomic: true` is an explicit fallback.
34
39
 
35
- | Package | 7.18.x contract status |
40
+ | Package | v8 contract status |
36
41
  | --- | --- |
37
- | `storage-memory` | lifecycle, pagination, idempotency |
38
- | `storage-mongodb` | lifecycle, version state/events, transactions |
39
- | `storage-azure-table` | lifecycle, pagination; no native transaction |
40
- | `storage-postgres` / `storage-sqlite` / `storage-d1` | lifecycle, pagination; transaction depends on injected client |
41
- | `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 |
42
48
 
43
49
  Existing `StorageAdapter` methods remain valid. To migrate, pass `schemaValidation` to an adapter when
44
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,
@@ -133,6 +134,54 @@ async function runIdempotencyContract(save) {
133
134
  "changed payload must conflict"
134
135
  );
135
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
+ }
136
185
  async function runRevisionConflictContract(adapter) {
137
186
  const { schema, state } = createStorageContractFixtures();
138
187
  const planned = (0, import_core.createCloneTransitionPlan)(
@@ -212,6 +261,7 @@ function verifyStorageCodecVectors() {
212
261
  createStorageContractFixtures,
213
262
  runIdempotencyContract,
214
263
  runLifecycleContract,
264
+ runResponseLimitContract,
215
265
  runRevisionConflictContract,
216
266
  runStorageContract,
217
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
@@ -109,6 +109,54 @@ async function runIdempotencyContract(save) {
109
109
  "changed payload must conflict"
110
110
  );
111
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
+ }
112
160
  async function runRevisionConflictContract(adapter) {
113
161
  const { schema, state } = createStorageContractFixtures();
114
162
  const planned = createCloneTransitionPlan(
@@ -187,6 +235,7 @@ export {
187
235
  createStorageContractFixtures,
188
236
  runIdempotencyContract,
189
237
  runLifecycleContract,
238
+ runResponseLimitContract,
190
239
  runRevisionConflictContract,
191
240
  runStorageContract,
192
241
  storageContractScope,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage",
3
- "version": "8.0.0",
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": "8.0.0"
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",