@form-engine-ts/storage 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 +12 -6
- package/dist/testing.cjs +50 -0
- package/dist/testing.d.cts +2 -1
- package/dist/testing.d.ts +2 -1
- package/dist/testing.js +49 -0
- package/package.json +2 -2
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 |
|
|
40
|
+
| Package | v8 contract status |
|
|
36
41
|
| --- | --- |
|
|
37
|
-
| `storage-memory` | lifecycle, pagination, idempotency |
|
|
38
|
-
| `storage-mongodb` | lifecycle, version state/events,
|
|
39
|
-
| `storage-azure-table` | lifecycle, pagination
|
|
40
|
-
| `storage-postgres` / `storage-sqlite`
|
|
41
|
-
| `storage-
|
|
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,
|
package/dist/testing.d.cts
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.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.
|
|
3
|
+
"version": "8.0.2",
|
|
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.
|
|
45
|
+
"@form-engine-ts/core": "8.0.2"
|
|
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",
|