@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 +18 -6
- package/dist/testing.cjs +67 -0
- package/dist/testing.d.cts +2 -1
- package/dist/testing.d.ts +2 -1
- package/dist/testing.js +66 -0
- package/package.json +2 -2
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 |
|
|
40
|
+
| Package | v8 contract status |
|
|
30
41
|
| --- | --- |
|
|
31
|
-
| `storage-memory` | lifecycle, pagination, idempotency |
|
|
32
|
-
| `storage-mongodb` | lifecycle, version state/events,
|
|
33
|
-
| `storage-azure-table` | lifecycle, pagination
|
|
34
|
-
| `storage-postgres` / `storage-sqlite`
|
|
35
|
-
| `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 |
|
|
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,
|
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
|
@@ -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": "
|
|
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": "
|
|
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",
|