@form-engine-ts/storage-d1 1.1.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/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/index.cjs +229 -0
- package/dist/index.d.cts +25 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +204 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nitta-a
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @form-engine-ts/storage-d1
|
|
2
|
+
|
|
3
|
+
Cloudflare D1 implementation of the complete form-engine-ts storage contract using structural Worker API types.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @form-engine-ts/core @form-engine-ts/storage-d1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createD1Storage } from "@form-engine-ts/storage-d1";
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
async fetch(request, env) {
|
|
18
|
+
const storage = createD1Storage({ db: env.DB, autoMigrate: true });
|
|
19
|
+
const submissions = await storage.listSubmissions("contact", 1, {
|
|
20
|
+
since: "2026-01-01T00:00:00.000Z",
|
|
21
|
+
until: "2026-01-31T23:59:59.999Z"
|
|
22
|
+
});
|
|
23
|
+
return Response.json(submissions);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The adapter depends only on structural `prepare/bind/first/all/run/batch` types, so application code owns the D1 binding and Worker lifecycle. Every value is bound through prepared statements. `autoMigrate` defaults to `false`; when enabled, migration statements run once in a transactional D1 batch before the first operation. `clear()` also batches deletion from the two configured tables and reports unsuccessful or malformed D1 results.
|
|
29
|
+
|
|
30
|
+
Schemas use a `(form_id, form_version)` primary key. Full schema/submission JSON is stored as text with searchable response metadata. Range boundaries are inclusive and results are ordered by submission time and then ID. Schema deletion does not cascade; `clearResponses(formId)` preserves schemas and other forms, while `clear()` retains both tables.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createD1Storage: () => createD1Storage
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
var import_core = require("@form-engine-ts/core");
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function isFormValue(value) {
|
|
31
|
+
return value === void 0 || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
32
|
+
}
|
|
33
|
+
function cloneJson(value) {
|
|
34
|
+
return JSON.parse(JSON.stringify(value));
|
|
35
|
+
}
|
|
36
|
+
function parseJson(value, location) {
|
|
37
|
+
if (typeof value !== "string") return cloneJson(value);
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(value);
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
throw new Error(`D1 JSON at ${location} is invalid.`, { cause });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function parseSubmission(value, location) {
|
|
45
|
+
const parsed = parseJson(value, location);
|
|
46
|
+
if (!isRecord(parsed) || typeof parsed.id !== "string" || typeof parsed.formId !== "string" || !Number.isInteger(parsed.formVersion) || typeof parsed.locale !== "string" || typeof parsed.submittedAt !== "string" || !isRecord(parsed.values) || !Object.values(parsed.values).every(isFormValue)) {
|
|
47
|
+
throw new Error(`D1 submission at ${location} is invalid.`);
|
|
48
|
+
}
|
|
49
|
+
return cloneJson(parsed);
|
|
50
|
+
}
|
|
51
|
+
function parseSchemaRow(value, index) {
|
|
52
|
+
if (!isRecord(value)) throw new Error(`D1 schema row ${index} is invalid.`);
|
|
53
|
+
const row = value;
|
|
54
|
+
const schema = parseJson(row.schema_json, `schema row ${index}`);
|
|
55
|
+
try {
|
|
56
|
+
(0, import_core.assertValidFormSchema)(schema);
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
throw new Error(`D1 schema row ${index} is invalid.`, { cause });
|
|
59
|
+
}
|
|
60
|
+
if (row.form_id !== schema.id || row.form_version !== schema.version) {
|
|
61
|
+
throw new Error(`D1 schema row ${index} has inconsistent metadata.`);
|
|
62
|
+
}
|
|
63
|
+
return cloneJson(schema);
|
|
64
|
+
}
|
|
65
|
+
function parseSubmissionRow(value, index) {
|
|
66
|
+
if (!isRecord(value)) throw new Error(`D1 submission row ${index} is invalid.`);
|
|
67
|
+
const row = value;
|
|
68
|
+
const submission = parseSubmission(row.submission_json, `submission row ${index}`);
|
|
69
|
+
if (row.response_id !== submission.id || row.form_id !== submission.formId || row.form_version !== submission.formVersion || row.locale !== submission.locale || row.submitted_at !== submission.submittedAt) {
|
|
70
|
+
throw new Error(`D1 submission row ${index} has inconsistent metadata.`);
|
|
71
|
+
}
|
|
72
|
+
return submission;
|
|
73
|
+
}
|
|
74
|
+
function identifier(value, fallback, optionName) {
|
|
75
|
+
const name = value ?? fallback;
|
|
76
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
77
|
+
throw new TypeError(`${optionName} must be a safe SQL identifier.`);
|
|
78
|
+
}
|
|
79
|
+
return `"${name}"`;
|
|
80
|
+
}
|
|
81
|
+
function boundStatement(db, sql, params = []) {
|
|
82
|
+
const prepared = db.prepare(sql);
|
|
83
|
+
return params.length === 0 ? prepared : prepared.bind(...params);
|
|
84
|
+
}
|
|
85
|
+
function assertSuccessful(result, operation) {
|
|
86
|
+
if (result.success !== true) throw new Error(`D1 ${operation} failed.`);
|
|
87
|
+
}
|
|
88
|
+
function createD1Storage(options) {
|
|
89
|
+
if (options?.db === void 0 || typeof options.db.prepare !== "function" || typeof options.db.batch !== "function") {
|
|
90
|
+
throw new TypeError("db with prepare and batch functions is required.");
|
|
91
|
+
}
|
|
92
|
+
const schemasTable = identifier(options.schemasTable, "form_schemas", "schemasTable");
|
|
93
|
+
const responsesTableName = options.responsesTable ?? "form_responses";
|
|
94
|
+
const responsesTable = identifier(responsesTableName, "form_responses", "responsesTable");
|
|
95
|
+
const responsesIndex = identifier(`${responsesTableName}_lookup_idx`, "form_responses_lookup_idx", "responsesTable");
|
|
96
|
+
let migration;
|
|
97
|
+
const run = async (sql, params = []) => {
|
|
98
|
+
const result = await boundStatement(options.db, sql, params).run();
|
|
99
|
+
assertSuccessful(result, "statement");
|
|
100
|
+
};
|
|
101
|
+
const all = async (sql, params = []) => {
|
|
102
|
+
const result = await boundStatement(options.db, sql, params).all();
|
|
103
|
+
assertSuccessful(result, "query");
|
|
104
|
+
if (!Array.isArray(result.results)) throw new Error("D1 query result is missing the results array.");
|
|
105
|
+
return result.results;
|
|
106
|
+
};
|
|
107
|
+
const ensureReady = async () => {
|
|
108
|
+
if (options.autoMigrate !== true) return;
|
|
109
|
+
migration ??= (async () => {
|
|
110
|
+
const results = await options.db.batch([
|
|
111
|
+
boundStatement(
|
|
112
|
+
options.db,
|
|
113
|
+
`CREATE TABLE IF NOT EXISTS ${schemasTable} (
|
|
114
|
+
form_id TEXT NOT NULL,
|
|
115
|
+
form_version INTEGER NOT NULL,
|
|
116
|
+
schema_json TEXT NOT NULL,
|
|
117
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
118
|
+
PRIMARY KEY (form_id, form_version)
|
|
119
|
+
)`
|
|
120
|
+
),
|
|
121
|
+
boundStatement(
|
|
122
|
+
options.db,
|
|
123
|
+
`CREATE TABLE IF NOT EXISTS ${responsesTable} (
|
|
124
|
+
response_id TEXT PRIMARY KEY,
|
|
125
|
+
form_id TEXT NOT NULL,
|
|
126
|
+
form_version INTEGER NOT NULL,
|
|
127
|
+
locale TEXT NOT NULL,
|
|
128
|
+
submitted_at TEXT NOT NULL,
|
|
129
|
+
submission_json TEXT NOT NULL
|
|
130
|
+
)`
|
|
131
|
+
),
|
|
132
|
+
boundStatement(
|
|
133
|
+
options.db,
|
|
134
|
+
`CREATE INDEX IF NOT EXISTS ${responsesIndex} ON ${responsesTable} (form_id, submitted_at, response_id)`
|
|
135
|
+
)
|
|
136
|
+
]);
|
|
137
|
+
if (results.length !== 3) throw new Error(`D1 migration returned ${results.length} results for 3 statements.`);
|
|
138
|
+
for (const result of results) assertSuccessful(result, "migration");
|
|
139
|
+
})();
|
|
140
|
+
await migration;
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
async saveSchema(schema) {
|
|
144
|
+
await ensureReady();
|
|
145
|
+
(0, import_core.assertValidFormSchema)(schema);
|
|
146
|
+
await run(
|
|
147
|
+
`INSERT INTO ${schemasTable} (form_id, form_version, schema_json, updated_at)
|
|
148
|
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
149
|
+
ON CONFLICT (form_id, form_version) DO UPDATE
|
|
150
|
+
SET schema_json = excluded.schema_json, updated_at = CURRENT_TIMESTAMP`,
|
|
151
|
+
[schema.id, schema.version, JSON.stringify(schema)]
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
async getSchema(formId, formVersion) {
|
|
155
|
+
await ensureReady();
|
|
156
|
+
const row = await boundStatement(
|
|
157
|
+
options.db,
|
|
158
|
+
`SELECT form_id, form_version, schema_json FROM ${schemasTable} WHERE form_id = ? AND form_version = ? LIMIT 1`,
|
|
159
|
+
[formId, formVersion]
|
|
160
|
+
).first();
|
|
161
|
+
return row === null ? null : parseSchemaRow(row, 0);
|
|
162
|
+
},
|
|
163
|
+
async listSchemas() {
|
|
164
|
+
await ensureReady();
|
|
165
|
+
const rows = await all(
|
|
166
|
+
`SELECT form_id, form_version, schema_json FROM ${schemasTable} ORDER BY form_id, form_version`
|
|
167
|
+
);
|
|
168
|
+
return rows.map(parseSchemaRow);
|
|
169
|
+
},
|
|
170
|
+
async deleteSchema(formId, formVersion) {
|
|
171
|
+
await ensureReady();
|
|
172
|
+
await run(`DELETE FROM ${schemasTable} WHERE form_id = ? AND form_version = ?`, [formId, formVersion]);
|
|
173
|
+
},
|
|
174
|
+
async saveSubmission(submission) {
|
|
175
|
+
await ensureReady();
|
|
176
|
+
const stored = parseSubmission(submission, `input "${String(submission?.id)}"`);
|
|
177
|
+
await run(
|
|
178
|
+
`INSERT INTO ${responsesTable}
|
|
179
|
+
(response_id, form_id, form_version, locale, submitted_at, submission_json)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
181
|
+
[stored.id, stored.formId, stored.formVersion, stored.locale, stored.submittedAt, JSON.stringify(stored)]
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
async listSubmissions(formId, formVersion, queryOptions) {
|
|
185
|
+
await ensureReady();
|
|
186
|
+
const conditions = ["form_id = ?"];
|
|
187
|
+
const params = [formId];
|
|
188
|
+
if (formVersion !== void 0) {
|
|
189
|
+
conditions.push("form_version = ?");
|
|
190
|
+
params.push(formVersion);
|
|
191
|
+
}
|
|
192
|
+
if (queryOptions?.since !== void 0) {
|
|
193
|
+
conditions.push("submitted_at >= ?");
|
|
194
|
+
params.push(queryOptions.since);
|
|
195
|
+
}
|
|
196
|
+
if (queryOptions?.until !== void 0) {
|
|
197
|
+
conditions.push("submitted_at <= ?");
|
|
198
|
+
params.push(queryOptions.until);
|
|
199
|
+
}
|
|
200
|
+
const rows = await all(
|
|
201
|
+
`SELECT response_id, form_id, form_version, locale, submitted_at, submission_json
|
|
202
|
+
FROM ${responsesTable} WHERE ${conditions.join(" AND ")} ORDER BY submitted_at, response_id`,
|
|
203
|
+
params
|
|
204
|
+
);
|
|
205
|
+
return rows.map(parseSubmissionRow);
|
|
206
|
+
},
|
|
207
|
+
async deleteSubmission(submissionId) {
|
|
208
|
+
await ensureReady();
|
|
209
|
+
await run(`DELETE FROM ${responsesTable} WHERE response_id = ?`, [submissionId]);
|
|
210
|
+
},
|
|
211
|
+
async clearResponses(formId) {
|
|
212
|
+
await ensureReady();
|
|
213
|
+
await run(`DELETE FROM ${responsesTable} WHERE form_id = ?`, [formId]);
|
|
214
|
+
},
|
|
215
|
+
async clear() {
|
|
216
|
+
await ensureReady();
|
|
217
|
+
const results = await options.db.batch([
|
|
218
|
+
boundStatement(options.db, `DELETE FROM ${responsesTable}`),
|
|
219
|
+
boundStatement(options.db, `DELETE FROM ${schemasTable}`)
|
|
220
|
+
]);
|
|
221
|
+
if (results.length !== 2) throw new Error(`D1 clear returned ${results.length} results for 2 statements.`);
|
|
222
|
+
for (const result of results) assertSuccessful(result, "clear");
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
227
|
+
0 && (module.exports = {
|
|
228
|
+
createD1Storage
|
|
229
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FormStorageAdapter } from '@form-engine-ts/core';
|
|
2
|
+
|
|
3
|
+
interface D1ResultLike<T = Record<string, unknown>> {
|
|
4
|
+
readonly success: boolean;
|
|
5
|
+
readonly results?: readonly T[];
|
|
6
|
+
}
|
|
7
|
+
interface D1PreparedStatementLike {
|
|
8
|
+
bind(...values: unknown[]): D1PreparedStatementLike;
|
|
9
|
+
first<T>(): Promise<T | null>;
|
|
10
|
+
all<T>(): Promise<D1ResultLike<T>>;
|
|
11
|
+
run<T = Record<string, unknown>>(): Promise<D1ResultLike<T>>;
|
|
12
|
+
}
|
|
13
|
+
interface D1DatabaseLike {
|
|
14
|
+
prepare(query: string): D1PreparedStatementLike;
|
|
15
|
+
batch(statements: readonly D1PreparedStatementLike[]): Promise<readonly D1ResultLike[]>;
|
|
16
|
+
}
|
|
17
|
+
interface D1StorageOptions {
|
|
18
|
+
readonly db: D1DatabaseLike;
|
|
19
|
+
readonly schemasTable?: string;
|
|
20
|
+
readonly responsesTable?: string;
|
|
21
|
+
readonly autoMigrate?: boolean;
|
|
22
|
+
}
|
|
23
|
+
declare function createD1Storage(options: D1StorageOptions): FormStorageAdapter;
|
|
24
|
+
|
|
25
|
+
export { type D1DatabaseLike, type D1PreparedStatementLike, type D1ResultLike, type D1StorageOptions, createD1Storage };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FormStorageAdapter } from '@form-engine-ts/core';
|
|
2
|
+
|
|
3
|
+
interface D1ResultLike<T = Record<string, unknown>> {
|
|
4
|
+
readonly success: boolean;
|
|
5
|
+
readonly results?: readonly T[];
|
|
6
|
+
}
|
|
7
|
+
interface D1PreparedStatementLike {
|
|
8
|
+
bind(...values: unknown[]): D1PreparedStatementLike;
|
|
9
|
+
first<T>(): Promise<T | null>;
|
|
10
|
+
all<T>(): Promise<D1ResultLike<T>>;
|
|
11
|
+
run<T = Record<string, unknown>>(): Promise<D1ResultLike<T>>;
|
|
12
|
+
}
|
|
13
|
+
interface D1DatabaseLike {
|
|
14
|
+
prepare(query: string): D1PreparedStatementLike;
|
|
15
|
+
batch(statements: readonly D1PreparedStatementLike[]): Promise<readonly D1ResultLike[]>;
|
|
16
|
+
}
|
|
17
|
+
interface D1StorageOptions {
|
|
18
|
+
readonly db: D1DatabaseLike;
|
|
19
|
+
readonly schemasTable?: string;
|
|
20
|
+
readonly responsesTable?: string;
|
|
21
|
+
readonly autoMigrate?: boolean;
|
|
22
|
+
}
|
|
23
|
+
declare function createD1Storage(options: D1StorageOptions): FormStorageAdapter;
|
|
24
|
+
|
|
25
|
+
export { type D1DatabaseLike, type D1PreparedStatementLike, type D1ResultLike, type D1StorageOptions, createD1Storage };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { assertValidFormSchema } from "@form-engine-ts/core";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function isFormValue(value) {
|
|
7
|
+
return value === void 0 || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
8
|
+
}
|
|
9
|
+
function cloneJson(value) {
|
|
10
|
+
return JSON.parse(JSON.stringify(value));
|
|
11
|
+
}
|
|
12
|
+
function parseJson(value, location) {
|
|
13
|
+
if (typeof value !== "string") return cloneJson(value);
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(value);
|
|
16
|
+
} catch (cause) {
|
|
17
|
+
throw new Error(`D1 JSON at ${location} is invalid.`, { cause });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function parseSubmission(value, location) {
|
|
21
|
+
const parsed = parseJson(value, location);
|
|
22
|
+
if (!isRecord(parsed) || typeof parsed.id !== "string" || typeof parsed.formId !== "string" || !Number.isInteger(parsed.formVersion) || typeof parsed.locale !== "string" || typeof parsed.submittedAt !== "string" || !isRecord(parsed.values) || !Object.values(parsed.values).every(isFormValue)) {
|
|
23
|
+
throw new Error(`D1 submission at ${location} is invalid.`);
|
|
24
|
+
}
|
|
25
|
+
return cloneJson(parsed);
|
|
26
|
+
}
|
|
27
|
+
function parseSchemaRow(value, index) {
|
|
28
|
+
if (!isRecord(value)) throw new Error(`D1 schema row ${index} is invalid.`);
|
|
29
|
+
const row = value;
|
|
30
|
+
const schema = parseJson(row.schema_json, `schema row ${index}`);
|
|
31
|
+
try {
|
|
32
|
+
assertValidFormSchema(schema);
|
|
33
|
+
} catch (cause) {
|
|
34
|
+
throw new Error(`D1 schema row ${index} is invalid.`, { cause });
|
|
35
|
+
}
|
|
36
|
+
if (row.form_id !== schema.id || row.form_version !== schema.version) {
|
|
37
|
+
throw new Error(`D1 schema row ${index} has inconsistent metadata.`);
|
|
38
|
+
}
|
|
39
|
+
return cloneJson(schema);
|
|
40
|
+
}
|
|
41
|
+
function parseSubmissionRow(value, index) {
|
|
42
|
+
if (!isRecord(value)) throw new Error(`D1 submission row ${index} is invalid.`);
|
|
43
|
+
const row = value;
|
|
44
|
+
const submission = parseSubmission(row.submission_json, `submission row ${index}`);
|
|
45
|
+
if (row.response_id !== submission.id || row.form_id !== submission.formId || row.form_version !== submission.formVersion || row.locale !== submission.locale || row.submitted_at !== submission.submittedAt) {
|
|
46
|
+
throw new Error(`D1 submission row ${index} has inconsistent metadata.`);
|
|
47
|
+
}
|
|
48
|
+
return submission;
|
|
49
|
+
}
|
|
50
|
+
function identifier(value, fallback, optionName) {
|
|
51
|
+
const name = value ?? fallback;
|
|
52
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
53
|
+
throw new TypeError(`${optionName} must be a safe SQL identifier.`);
|
|
54
|
+
}
|
|
55
|
+
return `"${name}"`;
|
|
56
|
+
}
|
|
57
|
+
function boundStatement(db, sql, params = []) {
|
|
58
|
+
const prepared = db.prepare(sql);
|
|
59
|
+
return params.length === 0 ? prepared : prepared.bind(...params);
|
|
60
|
+
}
|
|
61
|
+
function assertSuccessful(result, operation) {
|
|
62
|
+
if (result.success !== true) throw new Error(`D1 ${operation} failed.`);
|
|
63
|
+
}
|
|
64
|
+
function createD1Storage(options) {
|
|
65
|
+
if (options?.db === void 0 || typeof options.db.prepare !== "function" || typeof options.db.batch !== "function") {
|
|
66
|
+
throw new TypeError("db with prepare and batch functions is required.");
|
|
67
|
+
}
|
|
68
|
+
const schemasTable = identifier(options.schemasTable, "form_schemas", "schemasTable");
|
|
69
|
+
const responsesTableName = options.responsesTable ?? "form_responses";
|
|
70
|
+
const responsesTable = identifier(responsesTableName, "form_responses", "responsesTable");
|
|
71
|
+
const responsesIndex = identifier(`${responsesTableName}_lookup_idx`, "form_responses_lookup_idx", "responsesTable");
|
|
72
|
+
let migration;
|
|
73
|
+
const run = async (sql, params = []) => {
|
|
74
|
+
const result = await boundStatement(options.db, sql, params).run();
|
|
75
|
+
assertSuccessful(result, "statement");
|
|
76
|
+
};
|
|
77
|
+
const all = async (sql, params = []) => {
|
|
78
|
+
const result = await boundStatement(options.db, sql, params).all();
|
|
79
|
+
assertSuccessful(result, "query");
|
|
80
|
+
if (!Array.isArray(result.results)) throw new Error("D1 query result is missing the results array.");
|
|
81
|
+
return result.results;
|
|
82
|
+
};
|
|
83
|
+
const ensureReady = async () => {
|
|
84
|
+
if (options.autoMigrate !== true) return;
|
|
85
|
+
migration ??= (async () => {
|
|
86
|
+
const results = await options.db.batch([
|
|
87
|
+
boundStatement(
|
|
88
|
+
options.db,
|
|
89
|
+
`CREATE TABLE IF NOT EXISTS ${schemasTable} (
|
|
90
|
+
form_id TEXT NOT NULL,
|
|
91
|
+
form_version INTEGER NOT NULL,
|
|
92
|
+
schema_json TEXT NOT NULL,
|
|
93
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
94
|
+
PRIMARY KEY (form_id, form_version)
|
|
95
|
+
)`
|
|
96
|
+
),
|
|
97
|
+
boundStatement(
|
|
98
|
+
options.db,
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS ${responsesTable} (
|
|
100
|
+
response_id TEXT PRIMARY KEY,
|
|
101
|
+
form_id TEXT NOT NULL,
|
|
102
|
+
form_version INTEGER NOT NULL,
|
|
103
|
+
locale TEXT NOT NULL,
|
|
104
|
+
submitted_at TEXT NOT NULL,
|
|
105
|
+
submission_json TEXT NOT NULL
|
|
106
|
+
)`
|
|
107
|
+
),
|
|
108
|
+
boundStatement(
|
|
109
|
+
options.db,
|
|
110
|
+
`CREATE INDEX IF NOT EXISTS ${responsesIndex} ON ${responsesTable} (form_id, submitted_at, response_id)`
|
|
111
|
+
)
|
|
112
|
+
]);
|
|
113
|
+
if (results.length !== 3) throw new Error(`D1 migration returned ${results.length} results for 3 statements.`);
|
|
114
|
+
for (const result of results) assertSuccessful(result, "migration");
|
|
115
|
+
})();
|
|
116
|
+
await migration;
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
async saveSchema(schema) {
|
|
120
|
+
await ensureReady();
|
|
121
|
+
assertValidFormSchema(schema);
|
|
122
|
+
await run(
|
|
123
|
+
`INSERT INTO ${schemasTable} (form_id, form_version, schema_json, updated_at)
|
|
124
|
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
125
|
+
ON CONFLICT (form_id, form_version) DO UPDATE
|
|
126
|
+
SET schema_json = excluded.schema_json, updated_at = CURRENT_TIMESTAMP`,
|
|
127
|
+
[schema.id, schema.version, JSON.stringify(schema)]
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
async getSchema(formId, formVersion) {
|
|
131
|
+
await ensureReady();
|
|
132
|
+
const row = await boundStatement(
|
|
133
|
+
options.db,
|
|
134
|
+
`SELECT form_id, form_version, schema_json FROM ${schemasTable} WHERE form_id = ? AND form_version = ? LIMIT 1`,
|
|
135
|
+
[formId, formVersion]
|
|
136
|
+
).first();
|
|
137
|
+
return row === null ? null : parseSchemaRow(row, 0);
|
|
138
|
+
},
|
|
139
|
+
async listSchemas() {
|
|
140
|
+
await ensureReady();
|
|
141
|
+
const rows = await all(
|
|
142
|
+
`SELECT form_id, form_version, schema_json FROM ${schemasTable} ORDER BY form_id, form_version`
|
|
143
|
+
);
|
|
144
|
+
return rows.map(parseSchemaRow);
|
|
145
|
+
},
|
|
146
|
+
async deleteSchema(formId, formVersion) {
|
|
147
|
+
await ensureReady();
|
|
148
|
+
await run(`DELETE FROM ${schemasTable} WHERE form_id = ? AND form_version = ?`, [formId, formVersion]);
|
|
149
|
+
},
|
|
150
|
+
async saveSubmission(submission) {
|
|
151
|
+
await ensureReady();
|
|
152
|
+
const stored = parseSubmission(submission, `input "${String(submission?.id)}"`);
|
|
153
|
+
await run(
|
|
154
|
+
`INSERT INTO ${responsesTable}
|
|
155
|
+
(response_id, form_id, form_version, locale, submitted_at, submission_json)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
157
|
+
[stored.id, stored.formId, stored.formVersion, stored.locale, stored.submittedAt, JSON.stringify(stored)]
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
async listSubmissions(formId, formVersion, queryOptions) {
|
|
161
|
+
await ensureReady();
|
|
162
|
+
const conditions = ["form_id = ?"];
|
|
163
|
+
const params = [formId];
|
|
164
|
+
if (formVersion !== void 0) {
|
|
165
|
+
conditions.push("form_version = ?");
|
|
166
|
+
params.push(formVersion);
|
|
167
|
+
}
|
|
168
|
+
if (queryOptions?.since !== void 0) {
|
|
169
|
+
conditions.push("submitted_at >= ?");
|
|
170
|
+
params.push(queryOptions.since);
|
|
171
|
+
}
|
|
172
|
+
if (queryOptions?.until !== void 0) {
|
|
173
|
+
conditions.push("submitted_at <= ?");
|
|
174
|
+
params.push(queryOptions.until);
|
|
175
|
+
}
|
|
176
|
+
const rows = await all(
|
|
177
|
+
`SELECT response_id, form_id, form_version, locale, submitted_at, submission_json
|
|
178
|
+
FROM ${responsesTable} WHERE ${conditions.join(" AND ")} ORDER BY submitted_at, response_id`,
|
|
179
|
+
params
|
|
180
|
+
);
|
|
181
|
+
return rows.map(parseSubmissionRow);
|
|
182
|
+
},
|
|
183
|
+
async deleteSubmission(submissionId) {
|
|
184
|
+
await ensureReady();
|
|
185
|
+
await run(`DELETE FROM ${responsesTable} WHERE response_id = ?`, [submissionId]);
|
|
186
|
+
},
|
|
187
|
+
async clearResponses(formId) {
|
|
188
|
+
await ensureReady();
|
|
189
|
+
await run(`DELETE FROM ${responsesTable} WHERE form_id = ?`, [formId]);
|
|
190
|
+
},
|
|
191
|
+
async clear() {
|
|
192
|
+
await ensureReady();
|
|
193
|
+
const results = await options.db.batch([
|
|
194
|
+
boundStatement(options.db, `DELETE FROM ${responsesTable}`),
|
|
195
|
+
boundStatement(options.db, `DELETE FROM ${schemasTable}`)
|
|
196
|
+
]);
|
|
197
|
+
if (results.length !== 2) throw new Error(`D1 clear returned ${results.length} results for 2 statements.`);
|
|
198
|
+
for (const result of results) assertSuccessful(result, "clear");
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export {
|
|
203
|
+
createD1Storage
|
|
204
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@form-engine-ts/storage-d1",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"require": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/nitta-a/form-engine-ts.git",
|
|
28
|
+
"directory": "packages/storage-d1"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/nitta-a/form-engine-ts/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/nitta-a/form-engine-ts#readme",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"form",
|
|
36
|
+
"survey",
|
|
37
|
+
"storage",
|
|
38
|
+
"cloudflare",
|
|
39
|
+
"d1",
|
|
40
|
+
"edge",
|
|
41
|
+
"typescript"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@form-engine-ts/core": "1.1.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@cloudflare/workers-types": "^4.20240000.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@cloudflare/workers-types": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@cloudflare/workers-types": "^4.20240000.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external @form-engine-ts/core",
|
|
59
|
+
"check": "biome check . && tsc --noEmit",
|
|
60
|
+
"test": "vitest run --globals",
|
|
61
|
+
"typecheck": "tsc --noEmit"
|
|
62
|
+
}
|
|
63
|
+
}
|