@trokky/trokky 3.1.0 → 3.3.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.
Files changed (114) hide show
  1. package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.d.ts +140 -0
  2. package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.d.ts.map +1 -0
  3. package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.js +853 -0
  4. package/dist/adapters/cloudflare-d1/cloudflare-d1-adapter.js.map +1 -0
  5. package/dist/adapters/cloudflare-d1/index.d.ts +5 -0
  6. package/dist/adapters/cloudflare-d1/index.d.ts.map +1 -0
  7. package/dist/adapters/cloudflare-d1/index.js +12 -0
  8. package/dist/adapters/cloudflare-d1/index.js.map +1 -0
  9. package/dist/adapters/cloudflare-d1/rate-limit-store.d.ts +46 -0
  10. package/dist/adapters/cloudflare-d1/rate-limit-store.d.ts.map +1 -0
  11. package/dist/adapters/cloudflare-d1/rate-limit-store.js +96 -0
  12. package/dist/adapters/cloudflare-d1/rate-limit-store.js.map +1 -0
  13. package/dist/adapters/cloudflare-d1/types.d.ts +69 -0
  14. package/dist/adapters/cloudflare-d1/types.d.ts.map +1 -0
  15. package/dist/adapters/cloudflare-d1/types.js +2 -0
  16. package/dist/adapters/cloudflare-d1/types.js.map +1 -0
  17. package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.d.ts +86 -0
  18. package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.d.ts.map +1 -0
  19. package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.js +493 -0
  20. package/dist/adapters/cloudflare-r2/cloudflare-r2-adapter.js.map +1 -0
  21. package/dist/adapters/cloudflare-r2/index.d.ts +3 -0
  22. package/dist/adapters/cloudflare-r2/index.d.ts.map +1 -0
  23. package/dist/adapters/cloudflare-r2/index.js +11 -0
  24. package/dist/adapters/cloudflare-r2/index.js.map +1 -0
  25. package/dist/adapters/cloudflare-r2/types.d.ts +43 -0
  26. package/dist/adapters/cloudflare-r2/types.d.ts.map +1 -0
  27. package/dist/adapters/cloudflare-r2/types.js +2 -0
  28. package/dist/adapters/cloudflare-r2/types.js.map +1 -0
  29. package/dist/adapters/filesystem-data/filesystem-data-adapter.d.ts +9 -0
  30. package/dist/adapters/filesystem-data/filesystem-data-adapter.d.ts.map +1 -1
  31. package/dist/adapters/filesystem-data/filesystem-data-adapter.js +28 -0
  32. package/dist/adapters/filesystem-data/filesystem-data-adapter.js.map +1 -1
  33. package/dist/adapters/postgres-data/postgres-data-adapter.d.ts +9 -0
  34. package/dist/adapters/postgres-data/postgres-data-adapter.d.ts.map +1 -1
  35. package/dist/adapters/postgres-data/postgres-data-adapter.js +59 -0
  36. package/dist/adapters/postgres-data/postgres-data-adapter.js.map +1 -1
  37. package/dist/core/core/engine.d.ts +10 -0
  38. package/dist/core/core/engine.d.ts.map +1 -1
  39. package/dist/core/core/engine.js +16 -2
  40. package/dist/core/core/engine.js.map +1 -1
  41. package/dist/core/media/image-processor.d.ts.map +1 -1
  42. package/dist/core/media/image-processor.js +5 -13
  43. package/dist/core/media/image-processor.js.map +1 -1
  44. package/dist/core/media/processors/sharp.d.ts.map +1 -1
  45. package/dist/core/media/processors/sharp.js +13 -2
  46. package/dist/core/media/processors/sharp.js.map +1 -1
  47. package/dist/core/media/processors/types.d.ts +1 -1
  48. package/dist/core/media/processors/types.d.ts.map +1 -1
  49. package/dist/core/security/rate-limit-store.d.ts +56 -0
  50. package/dist/core/security/rate-limit-store.d.ts.map +1 -0
  51. package/dist/core/security/rate-limit-store.js +55 -0
  52. package/dist/core/security/rate-limit-store.js.map +1 -0
  53. package/dist/core/security/rate-limiter.d.ts +39 -2
  54. package/dist/core/security/rate-limiter.d.ts.map +1 -1
  55. package/dist/core/security/rate-limiter.js +52 -20
  56. package/dist/core/security/rate-limiter.js.map +1 -1
  57. package/dist/core/services/media-service.d.ts +34 -0
  58. package/dist/core/services/media-service.d.ts.map +1 -1
  59. package/dist/core/services/media-service.js +172 -120
  60. package/dist/core/services/media-service.js.map +1 -1
  61. package/dist/core/services/user-service.d.ts +42 -0
  62. package/dist/core/services/user-service.d.ts.map +1 -1
  63. package/dist/core/services/user-service.js +114 -1
  64. package/dist/core/services/user-service.js.map +1 -1
  65. package/dist/core/types/storage-adapters.d.ts +18 -0
  66. package/dist/core/types/storage-adapters.d.ts.map +1 -1
  67. package/dist/core/utils/retry.d.ts +45 -0
  68. package/dist/core/utils/retry.d.ts.map +1 -0
  69. package/dist/core/utils/retry.js +47 -0
  70. package/dist/core/utils/retry.js.map +1 -0
  71. package/dist/i18n/config.d.ts +32 -0
  72. package/dist/i18n/config.d.ts.map +1 -1
  73. package/dist/i18n/locales/en/auth.json +16 -0
  74. package/dist/i18n/locales/fr/auth.json +16 -0
  75. package/dist/integrations/workers/adapter.d.ts +28 -0
  76. package/dist/integrations/workers/adapter.d.ts.map +1 -0
  77. package/dist/integrations/workers/adapter.js +111 -0
  78. package/dist/integrations/workers/adapter.js.map +1 -0
  79. package/dist/integrations/workers/index.d.ts +22 -0
  80. package/dist/integrations/workers/index.d.ts.map +1 -0
  81. package/dist/integrations/workers/index.js +60 -0
  82. package/dist/integrations/workers/index.js.map +1 -0
  83. package/dist/routes/auth/auth-flow-store.d.ts +17 -0
  84. package/dist/routes/auth/auth-flow-store.d.ts.map +1 -1
  85. package/dist/routes/auth/auth-flow-store.js +27 -0
  86. package/dist/routes/auth/auth-flow-store.js.map +1 -1
  87. package/dist/routes/auth/oauth.d.ts.map +1 -1
  88. package/dist/routes/auth/oauth.js +5 -12
  89. package/dist/routes/auth/oauth.js.map +1 -1
  90. package/dist/routes/auth/passkey.d.ts.map +1 -1
  91. package/dist/routes/auth/passkey.js +5 -8
  92. package/dist/routes/auth/passkey.js.map +1 -1
  93. package/dist/routes/handlers/auth.d.ts +14 -0
  94. package/dist/routes/handlers/auth.d.ts.map +1 -1
  95. package/dist/routes/handlers/auth.js +55 -0
  96. package/dist/routes/handlers/auth.js.map +1 -1
  97. package/dist/structure/validation/schemas.d.ts +60 -60
  98. package/package.json +20 -1
  99. package/dist/core/media/processors/cloudflare-images.d.ts +0 -83
  100. package/dist/core/media/processors/cloudflare-images.d.ts.map +0 -1
  101. package/dist/core/media/processors/cloudflare-images.js +0 -241
  102. package/dist/core/media/processors/cloudflare-images.js.map +0 -1
  103. package/dist/core/media/processors/cloudflare-transform-store.d.ts +0 -127
  104. package/dist/core/media/processors/cloudflare-transform-store.d.ts.map +0 -1
  105. package/dist/core/media/processors/cloudflare-transform-store.js +0 -237
  106. package/dist/core/media/processors/cloudflare-transform-store.js.map +0 -1
  107. package/dist/core/media/processors/cloudflare-transformations.d.ts +0 -81
  108. package/dist/core/media/processors/cloudflare-transformations.d.ts.map +0 -1
  109. package/dist/core/media/processors/cloudflare-transformations.js +0 -144
  110. package/dist/core/media/processors/cloudflare-transformations.js.map +0 -1
  111. package/dist/core/media/processors/workers-images.d.ts +0 -112
  112. package/dist/core/media/processors/workers-images.d.ts.map +0 -1
  113. package/dist/core/media/processors/workers-images.js +0 -257
  114. package/dist/core/media/processors/workers-images.js.map +0 -1
@@ -0,0 +1,853 @@
1
+ /**
2
+ * Cloudflare D1 data storage adapter.
3
+ *
4
+ * D1 is SQLite, which differs from Postgres in ways that are invisible until a
5
+ * conformance test fails, so the choices below are deliberate:
6
+ *
7
+ * - **Timestamps are generated in JS, never by SQL.** SQLite's `CURRENT_TIMESTAMP`
8
+ * has one-second resolution; the suite orders fixtures 20ms apart, so rows would
9
+ * tie and sort arbitrarily.
10
+ * - **Document payloads are one JSON TEXT blob.** Shredding fields into columns
11
+ * loses the distinction between `0`, `false`, `''`, `null` and absent, all of
12
+ * which must round-trip byte-identically.
13
+ * - **Filters compare value *and* `json_type`.** `json_extract` collapses JSON
14
+ * booleans to 0/1, so a naive equality makes `false` match a stored `0`.
15
+ * - **Sorts use `json_extract`**, which yields real SQL numbers; the text
16
+ * accessors would sort 13 before 2.
17
+ * - **There is no `beginTransaction`.** D1 has no interactive transactions, and
18
+ * the interface allows omitting it. The three places that need atomicity each
19
+ * do it in one statement: the document upsert, `saveUserIf`'s conditional
20
+ * UPDATE, and `consumeAuthFlowState`'s `DELETE … RETURNING`.
21
+ */
22
+ import { InvalidInputError, SecurityValidator, createLogger } from '../../core/index.js';
23
+ /** Field names allowed in a sort or filter position. */
24
+ const FIELD_PATTERN = /^[a-zA-Z0-9_.\-]+$/;
25
+ /**
26
+ * Document fields that live in their own column. Anything else — including any
27
+ * other leading-underscore field a schema defines — is addressed through `data`.
28
+ */
29
+ const DOCUMENT_SYSTEM_COLUMNS = {
30
+ id: 'id',
31
+ _id: 'id',
32
+ _status: 'status',
33
+ _revision: 'revision',
34
+ _createdAt: 'created_at',
35
+ _updatedAt: 'updated_at',
36
+ _createdBy: 'created_by',
37
+ _updatedBy: 'updated_by',
38
+ _createdByType: 'created_by_type',
39
+ _updatedByType: 'updated_by_type'
40
+ };
41
+ /** The user INSERT column list, in the order `userInsertValues` binds them. */
42
+ const USER_INSERT_COLUMNS = `(id, username, email, password_hash, first_name, last_name, role, permissions,
43
+ is_active, profile_image, preferences, oauth_providers, mfa, passkeys,
44
+ last_login_at, created_at, updated_at)`;
45
+ /** User columns an update may set, in write order. */
46
+ const USER_UPDATE_COLUMNS = [
47
+ { key: 'username', column: 'username' },
48
+ { key: 'email', column: 'email' },
49
+ { key: 'passwordHash', column: 'password_hash' },
50
+ { key: 'firstName', column: 'first_name' },
51
+ { key: 'lastName', column: 'last_name' },
52
+ { key: 'role', column: 'role' },
53
+ { key: 'permissions', column: 'permissions', json: true },
54
+ { key: 'isActive', column: 'is_active', bool: true },
55
+ { key: 'profileImage', column: 'profile_image' },
56
+ { key: 'preferences', column: 'preferences', json: true },
57
+ { key: 'oauthProviders', column: 'oauth_providers', json: true },
58
+ { key: 'mfa', column: 'mfa', json: true },
59
+ { key: 'passkeys', column: 'passkeys', json: true },
60
+ { key: 'lastLoginAt', column: 'last_login_at' }
61
+ ];
62
+ /** A SQL NULL must surface as `undefined`; `null` is reserved for real JSON nulls. */
63
+ function orUndefined(value) {
64
+ return value === null || value === undefined ? undefined : value;
65
+ }
66
+ /** JSON columns hold NULL when absent, never '{}' or '[]'. */
67
+ function jsonColumn(value) {
68
+ return value === null || value === undefined ? null : JSON.stringify(value);
69
+ }
70
+ function parseJsonColumn(value) {
71
+ if (value === null || value === undefined)
72
+ return undefined;
73
+ try {
74
+ return JSON.parse(value);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
80
+ /** SQLite has no boolean; 0/1 round-trips through INTEGER. */
81
+ function boolColumn(value) {
82
+ return value === null || value === undefined ? null : value ? 1 : 0;
83
+ }
84
+ /** The `json_type` a stored value must have to equal this JS value. */
85
+ function expectedJsonType(value) {
86
+ if (value === null)
87
+ return 'null';
88
+ if (typeof value === 'boolean')
89
+ return value ? 'true' : 'false';
90
+ if (typeof value === 'number')
91
+ return Number.isInteger(value) ? 'integer' : 'real';
92
+ if (typeof value === 'string')
93
+ return 'text';
94
+ if (Array.isArray(value))
95
+ return 'array';
96
+ return 'object';
97
+ }
98
+ /** A JSON path that survives field names containing dots or hyphens. */
99
+ function jsonPath(field) {
100
+ return `$."${field.replace(/"/g, '""')}"`;
101
+ }
102
+ function assertField(field) {
103
+ if (!FIELD_PATTERN.test(field)) {
104
+ throw new InvalidInputError(`Invalid field name: ${field}`, 'field');
105
+ }
106
+ }
107
+ export class CloudflareD1Adapter {
108
+ db;
109
+ tablePrefix;
110
+ autoMigrate;
111
+ logger = createLogger('adapter', 'CloudflareD1');
112
+ migrated = false;
113
+ migrationPromise = null;
114
+ /** D1 rejects a single value over ~2MB, well below the 10MB other adapters allow. */
115
+ MAX_DOCUMENT_SIZE = 1_500_000;
116
+ MAX_LIST_LIMIT = 1000;
117
+ constructor(config) {
118
+ if (!config?.database) {
119
+ throw new InvalidInputError('A D1 database binding is required', 'database');
120
+ }
121
+ this.db = config.database;
122
+ this.tablePrefix = config.tablePrefix ?? 'trokky_';
123
+ this.autoMigrate = config.autoMigrate ?? true;
124
+ }
125
+ // ---------------------------------------------------------------- plumbing
126
+ table(name) {
127
+ return `${this.tablePrefix}${name}`;
128
+ }
129
+ now() {
130
+ return new Date().toISOString();
131
+ }
132
+ /** Migrations run once; concurrent callers await the same promise. */
133
+ async ready() {
134
+ if (this.migrated)
135
+ return;
136
+ if (!this.autoMigrate) {
137
+ this.migrated = true;
138
+ return;
139
+ }
140
+ if (!this.migrationPromise) {
141
+ this.migrationPromise = this.migrate().then(() => {
142
+ this.migrated = true;
143
+ }, error => {
144
+ this.migrationPromise = null;
145
+ throw error;
146
+ });
147
+ }
148
+ await this.migrationPromise;
149
+ }
150
+ /**
151
+ * Create the schema. No DEFAULTs on the JSON columns: a default of '{}' would
152
+ * turn an absent value into an empty object on read, where `undefined` is
153
+ * required.
154
+ */
155
+ async migrate() {
156
+ const statements = [
157
+ `CREATE TABLE IF NOT EXISTS ${this.table('documents')} (
158
+ collection TEXT NOT NULL,
159
+ id TEXT NOT NULL,
160
+ data TEXT NOT NULL,
161
+ status TEXT,
162
+ created_at TEXT NOT NULL,
163
+ updated_at TEXT NOT NULL,
164
+ created_by TEXT,
165
+ updated_by TEXT,
166
+ created_by_type TEXT,
167
+ updated_by_type TEXT,
168
+ revision INTEGER NOT NULL DEFAULT 1,
169
+ PRIMARY KEY (collection, id)
170
+ )`,
171
+ `CREATE INDEX IF NOT EXISTS ${this.table('documents')}_collection_created
172
+ ON ${this.table('documents')} (collection, created_at DESC)`,
173
+ `CREATE TABLE IF NOT EXISTS ${this.table('users')} (
174
+ id TEXT PRIMARY KEY,
175
+ username TEXT NOT NULL UNIQUE,
176
+ email TEXT NOT NULL UNIQUE,
177
+ password_hash TEXT,
178
+ first_name TEXT,
179
+ last_name TEXT,
180
+ role TEXT,
181
+ permissions TEXT,
182
+ is_active INTEGER,
183
+ profile_image TEXT,
184
+ preferences TEXT,
185
+ oauth_providers TEXT,
186
+ mfa TEXT,
187
+ passkeys TEXT,
188
+ last_login_at TEXT,
189
+ created_at TEXT NOT NULL,
190
+ updated_at TEXT NOT NULL
191
+ )`,
192
+ `CREATE INDEX IF NOT EXISTS ${this.table('users')}_created ON ${this.table('users')} (created_at DESC)`,
193
+ `CREATE TABLE IF NOT EXISTS ${this.table('app_tokens')} (
194
+ id TEXT PRIMARY KEY,
195
+ name TEXT NOT NULL,
196
+ description TEXT,
197
+ hash TEXT NOT NULL UNIQUE,
198
+ permissions TEXT,
199
+ created_by TEXT,
200
+ is_active INTEGER,
201
+ usage_count INTEGER DEFAULT 0,
202
+ last_used_at TEXT,
203
+ expires_at TEXT,
204
+ created_at TEXT NOT NULL,
205
+ updated_at TEXT NOT NULL
206
+ )`,
207
+ `CREATE TABLE IF NOT EXISTS ${this.table('auth_flow_state')} (
208
+ id TEXT PRIMARY KEY,
209
+ kind TEXT NOT NULL,
210
+ data TEXT NOT NULL,
211
+ expires_at TEXT NOT NULL,
212
+ created_at TEXT NOT NULL
213
+ )`,
214
+ `CREATE INDEX IF NOT EXISTS ${this.table('auth_flow_state')}_expires
215
+ ON ${this.table('auth_flow_state')} (expires_at)`,
216
+ `CREATE TABLE IF NOT EXISTS ${this.table('settings')} (
217
+ id INTEGER PRIMARY KEY CHECK (id = 1),
218
+ data TEXT NOT NULL,
219
+ updated_at TEXT NOT NULL
220
+ )`
221
+ ];
222
+ for (const sql of statements) {
223
+ await this.db.prepare(sql).run();
224
+ }
225
+ this.logger.info('D1 schema ready');
226
+ }
227
+ async close() {
228
+ // The binding is owned by the platform; there is nothing to release.
229
+ }
230
+ async healthCheck() {
231
+ try {
232
+ await this.ready();
233
+ const row = await this.db.prepare('SELECT 1 AS ok').first();
234
+ return row?.ok === 1;
235
+ }
236
+ catch (error) {
237
+ this.logger.warn('D1 health check failed', error);
238
+ return false;
239
+ }
240
+ }
241
+ // --------------------------------------------------------------- documents
242
+ mapRowToDocument(row) {
243
+ const data = parseJsonColumn(row.data) ?? {};
244
+ // Any `_status` that leaked into the payload must not shadow the column.
245
+ delete data._status;
246
+ return {
247
+ id: row.id,
248
+ _id: row.id,
249
+ _collection: row.collection,
250
+ _createdAt: new Date(row.created_at),
251
+ _updatedAt: new Date(row.updated_at),
252
+ _createdBy: orUndefined(row.created_by),
253
+ _updatedBy: orUndefined(row.updated_by),
254
+ _createdByType: orUndefined(row.created_by_type),
255
+ _updatedByType: orUndefined(row.updated_by_type),
256
+ _revision: Number(row.revision ?? 1),
257
+ _status: (orUndefined(row.status) ?? 'draft'),
258
+ ...data
259
+ };
260
+ }
261
+ async getDocument(collection, id) {
262
+ SecurityValidator.validateCollectionName(collection);
263
+ SecurityValidator.validateDocumentId(id);
264
+ await this.ready();
265
+ const row = await this.db
266
+ .prepare(`SELECT * FROM ${this.table('documents')} WHERE collection = ? AND id = ?`)
267
+ .bind(collection, id)
268
+ .first();
269
+ return row ? this.mapRowToDocument(row) : null;
270
+ }
271
+ async saveDocument(collection, id, data, auditContext) {
272
+ SecurityValidator.validateCollectionName(collection);
273
+ SecurityValidator.validateDocumentId(id);
274
+ SecurityValidator.validateDocumentData(data);
275
+ await this.ready();
276
+ const { _status, ...payload } = (data ?? {});
277
+ const serialized = JSON.stringify(payload);
278
+ if (serialized.length > this.MAX_DOCUMENT_SIZE) {
279
+ throw new InvalidInputError(`Document exceeds the ${this.MAX_DOCUMENT_SIZE} byte limit for D1`, 'data');
280
+ }
281
+ const now = this.now();
282
+ const status = _status ?? null;
283
+ const actor = auditContext?.userId ?? null;
284
+ const actorType = auditContext?.userType ?? null;
285
+ const documents = this.table('documents');
286
+ // created_by / created_by_type are absent from the UPDATE list on purpose:
287
+ // that is what preserves the original creator across later saves.
288
+ const row = await this.db
289
+ .prepare(`INSERT INTO ${documents}
290
+ (collection, id, data, status, created_at, updated_at,
291
+ created_by, updated_by, created_by_type, updated_by_type, revision)
292
+ VALUES (?, ?, ?, COALESCE(?, 'draft'), ?, ?, ?, ?, ?, ?, 1)
293
+ ON CONFLICT(collection, id) DO UPDATE SET
294
+ data = excluded.data,
295
+ status = COALESCE(?, ${documents}.status, 'draft'),
296
+ updated_at = excluded.updated_at,
297
+ updated_by = excluded.updated_by,
298
+ updated_by_type = excluded.updated_by_type,
299
+ revision = COALESCE(${documents}.revision, 1) + 1
300
+ RETURNING *`)
301
+ .bind(collection, id, serialized, status, now, now, actor, actor, actorType, actorType, status)
302
+ .first();
303
+ if (!row) {
304
+ throw new Error(`Failed to save document ${collection}/${id}`);
305
+ }
306
+ return this.mapRowToDocument(row);
307
+ }
308
+ async deleteDocument(collection, id) {
309
+ SecurityValidator.validateCollectionName(collection);
310
+ SecurityValidator.validateDocumentId(id);
311
+ await this.ready();
312
+ const result = await this.db
313
+ .prepare(`DELETE FROM ${this.table('documents')} WHERE collection = ? AND id = ?`)
314
+ .bind(collection, id)
315
+ .run();
316
+ if ((result.meta?.changes ?? 0) === 0) {
317
+ throw new InvalidInputError(`Document ${collection}/${id} not found`, 'id');
318
+ }
319
+ }
320
+ /**
321
+ * Filter clauses shared by listDocuments and countDocuments so the two can
322
+ * never disagree. A data-field comparison checks `json_type` as well as the
323
+ * value: json_extract turns JSON true/false into 1/0, so value alone would
324
+ * make `false` match a stored `0`.
325
+ */
326
+ buildFilterClauses(filter) {
327
+ const clauses = [];
328
+ const values = [];
329
+ if (!filter)
330
+ return { sql: '', values };
331
+ for (const [field, value] of Object.entries(filter)) {
332
+ assertField(field);
333
+ const column = DOCUMENT_SYSTEM_COLUMNS[field];
334
+ if (column) {
335
+ if (value === null || value === undefined) {
336
+ clauses.push(`${column} IS NULL`);
337
+ }
338
+ else {
339
+ clauses.push(`${column} = ?`);
340
+ values.push(typeof value === 'boolean' ? boolColumn(value) : value);
341
+ }
342
+ continue;
343
+ }
344
+ const path = jsonPath(field);
345
+ clauses.push(`json_extract(data, ?) IS ? AND json_type(data, ?) = ?`);
346
+ values.push(path, value === null || typeof value === 'object' ? JSON.stringify(value) : value, path, expectedJsonType(value));
347
+ }
348
+ return { sql: clauses.length ? ` AND ${clauses.join(' AND ')}` : '', values };
349
+ }
350
+ /**
351
+ * ORDER BY terms. Data fields go through json_extract, which yields a real SQL
352
+ * number, so 2 sorts before 13; the text accessors would not.
353
+ */
354
+ buildSortClause(sort) {
355
+ const terms = sort === undefined ? [] : Array.isArray(sort) ? sort : [sort];
356
+ if (terms.length === 0) {
357
+ return { sql: ' ORDER BY created_at DESC', values: [] };
358
+ }
359
+ const parts = [];
360
+ const values = [];
361
+ for (const term of terms) {
362
+ let field = term;
363
+ let direction = 'ASC';
364
+ if (field.startsWith('-')) {
365
+ direction = 'DESC';
366
+ field = field.slice(1);
367
+ }
368
+ else {
369
+ const suffix = /^(.+)\.(asc|desc)$/i.exec(field);
370
+ if (suffix) {
371
+ field = suffix[1];
372
+ direction = suffix[2].toUpperCase();
373
+ }
374
+ }
375
+ // Validate after stripping, or '-title' reads as a field called '-title'.
376
+ assertField(field);
377
+ const column = DOCUMENT_SYSTEM_COLUMNS[field];
378
+ if (column) {
379
+ parts.push(`${column} ${direction}`);
380
+ }
381
+ else {
382
+ parts.push(`json_extract(data, ?) ${direction}`);
383
+ values.push(jsonPath(field));
384
+ }
385
+ }
386
+ return { sql: ` ORDER BY ${parts.join(', ')}`, values };
387
+ }
388
+ async listDocuments(collection, options = {}) {
389
+ SecurityValidator.validateCollectionName(collection);
390
+ await this.ready();
391
+ const filter = this.buildFilterClauses(options.filter);
392
+ const sort = this.buildSortClause(options.sort);
393
+ // No implicit page size: an unbounded call returns everything up to the cap.
394
+ const limit = Math.min(options.limit ?? this.MAX_LIST_LIMIT, this.MAX_LIST_LIMIT);
395
+ const offset = options.offset ?? 0;
396
+ const result = await this.db
397
+ .prepare(`SELECT * FROM ${this.table('documents')} WHERE collection = ?${filter.sql}${sort.sql} LIMIT ? OFFSET ?`)
398
+ .bind(collection, ...filter.values, ...sort.values, limit, offset)
399
+ .all();
400
+ return (result.results ?? []).map(row => this.mapRowToDocument(row));
401
+ }
402
+ async countDocuments(collection, filter) {
403
+ SecurityValidator.validateCollectionName(collection);
404
+ await this.ready();
405
+ const clauses = this.buildFilterClauses(filter);
406
+ const row = await this.db
407
+ .prepare(`SELECT COUNT(*) AS count FROM ${this.table('documents')} WHERE collection = ?${clauses.sql}`)
408
+ .bind(collection, ...clauses.values)
409
+ .first();
410
+ return Number(row?.count ?? 0);
411
+ }
412
+ async documentExists(collection, id) {
413
+ SecurityValidator.validateCollectionName(collection);
414
+ SecurityValidator.validateDocumentId(id);
415
+ await this.ready();
416
+ const row = await this.db
417
+ .prepare(`SELECT 1 AS present FROM ${this.table('documents')} WHERE collection = ? AND id = ?`)
418
+ .bind(collection, id)
419
+ .first();
420
+ return row !== null && row !== undefined;
421
+ }
422
+ // ------------------------------------------------------------------- users
423
+ mapRowToUser(row) {
424
+ return {
425
+ id: row.id,
426
+ username: row.username,
427
+ email: row.email,
428
+ passwordHash: row.password_hash ?? '',
429
+ firstName: orUndefined(row.first_name),
430
+ lastName: orUndefined(row.last_name),
431
+ role: orUndefined(row.role),
432
+ // No `?? []` here: an absent column must read back as undefined. The empty
433
+ // array is a *create* default, applied when the row is written.
434
+ permissions: parseJsonColumn(row.permissions),
435
+ isActive: row.is_active === null || row.is_active === undefined ? true : row.is_active === 1,
436
+ profileImage: orUndefined(row.profile_image),
437
+ preferences: parseJsonColumn(row.preferences),
438
+ oauthProviders: parseJsonColumn(row.oauth_providers),
439
+ mfa: parseJsonColumn(row.mfa),
440
+ passkeys: parseJsonColumn(row.passkeys),
441
+ lastLoginAt: orUndefined(row.last_login_at),
442
+ createdAt: row.created_at,
443
+ updatedAt: row.updated_at
444
+ };
445
+ }
446
+ async getUser(id) {
447
+ await this.ready();
448
+ const row = await this.db
449
+ .prepare(`SELECT * FROM ${this.table('users')} WHERE id = ?`)
450
+ .bind(id)
451
+ .first();
452
+ return row ? this.mapRowToUser(row) : null;
453
+ }
454
+ /**
455
+ * Build the SET list from the keys actually present. A key holding `null`
456
+ * clears the column; a key that is absent, or present as `undefined`, leaves
457
+ * it alone. COALESCE cannot express that difference.
458
+ */
459
+ buildUserAssignments(userData) {
460
+ const sql = [];
461
+ const values = [];
462
+ for (const { key, column, json, bool } of USER_UPDATE_COLUMNS) {
463
+ if (!(key in userData))
464
+ continue;
465
+ const value = userData[key];
466
+ if (value === undefined)
467
+ continue;
468
+ sql.push(`${column} = ?`);
469
+ if (value === null) {
470
+ values.push(null);
471
+ }
472
+ else if (json) {
473
+ values.push(jsonColumn(value));
474
+ }
475
+ else if (bool) {
476
+ values.push(boolColumn(value));
477
+ }
478
+ else {
479
+ values.push(value);
480
+ }
481
+ }
482
+ return { sql, values };
483
+ }
484
+ async saveUser(id, userData) {
485
+ await this.ready();
486
+ const existing = await this.getUser(id);
487
+ const now = this.now();
488
+ const users = this.table('users');
489
+ const input = (userData ?? {});
490
+ if (existing) {
491
+ const assignments = this.buildUserAssignments(input);
492
+ assignments.sql.push('updated_at = ?');
493
+ assignments.values.push(now);
494
+ const row = await this.db
495
+ .prepare(`UPDATE ${users} SET ${assignments.sql.join(', ')} WHERE id = ? RETURNING *`)
496
+ .bind(...assignments.values, id)
497
+ .first();
498
+ if (!row)
499
+ throw new Error(`Failed to update user ${id}`);
500
+ return this.mapRowToUser(row);
501
+ }
502
+ const row = await this.db
503
+ .prepare(`INSERT INTO ${users} ${USER_INSERT_COLUMNS}
504
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
505
+ RETURNING *`)
506
+ .bind(...this.userInsertValues(id, input, now))
507
+ .first();
508
+ if (!row)
509
+ throw new Error(`Failed to create user ${id}`);
510
+ return this.mapRowToUser(row);
511
+ }
512
+ /** The bind list for a user INSERT, shared so the two insert paths cannot drift. */
513
+ userInsertValues(id, input, now) {
514
+ return [
515
+ id,
516
+ input.username ?? null,
517
+ input.email ?? null,
518
+ input.passwordHash ?? '',
519
+ input.firstName ?? null,
520
+ input.lastName ?? null,
521
+ input.role ?? null,
522
+ jsonColumn(input.permissions ?? []),
523
+ boolColumn(input.isActive === undefined ? true : input.isActive),
524
+ input.profileImage ?? null,
525
+ jsonColumn(input.preferences),
526
+ jsonColumn(input.oauthProviders),
527
+ jsonColumn(input.mfa),
528
+ jsonColumn(input.passkeys),
529
+ input.lastLoginAt ?? null,
530
+ now,
531
+ now
532
+ ];
533
+ }
534
+ /**
535
+ * Create the first user, atomically. SQLite serialises writers, so a single
536
+ * `INSERT ... SELECT ... WHERE NOT EXISTS` is the check and the write under one
537
+ * lock: of two concurrent calls, exactly one inserts and the other gets no row.
538
+ */
539
+ async createFirstUser(id, userData) {
540
+ SecurityValidator.validateDocumentId(id);
541
+ await this.ready();
542
+ const users = this.table('users');
543
+ const values = this.userInsertValues(id, (userData ?? {}), this.now());
544
+ const row = await this.db
545
+ .prepare(`INSERT INTO ${users} ${USER_INSERT_COLUMNS}
546
+ SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
547
+ WHERE NOT EXISTS (SELECT 1 FROM ${users})
548
+ RETURNING *`)
549
+ .bind(...values)
550
+ .first();
551
+ return row ? this.mapRowToUser(row) : null;
552
+ }
553
+ /**
554
+ * Compare-and-swap in one statement. SQLite makes a single UPDATE atomic, so
555
+ * no transaction is needed: the WHERE clause is the comparison, and RETURNING
556
+ * proves whether it matched.
557
+ */
558
+ async saveUserIf(id, userData, condition) {
559
+ await this.ready();
560
+ const assignments = this.buildUserAssignments((userData ?? {}));
561
+ assignments.sql.push('updated_at = ?');
562
+ assignments.values.push(this.now());
563
+ const row = await this.db
564
+ .prepare(`UPDATE ${this.table('users')} SET ${assignments.sql.join(', ')}
565
+ WHERE id = ? AND password_hash = ? RETURNING *`)
566
+ .bind(...assignments.values, id, condition.passwordHash)
567
+ .first();
568
+ return row ? this.mapRowToUser(row) : null;
569
+ }
570
+ async listUsers(options = {}) {
571
+ await this.ready();
572
+ const clauses = [];
573
+ const values = [];
574
+ if (options.role !== undefined) {
575
+ clauses.push('role = ?');
576
+ values.push(options.role);
577
+ }
578
+ if (options.isActive !== undefined) {
579
+ clauses.push('is_active = ?');
580
+ values.push(boolColumn(options.isActive));
581
+ }
582
+ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
583
+ const limit = Math.min(options.limit ?? this.MAX_LIST_LIMIT, this.MAX_LIST_LIMIT);
584
+ const offset = options.offset ?? 0;
585
+ const result = await this.db
586
+ .prepare(`SELECT * FROM ${this.table('users')}${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
587
+ .bind(...values, limit, offset)
588
+ .all();
589
+ return (result.results ?? []).map(row => this.mapRowToUser(row));
590
+ }
591
+ async deleteUser(id) {
592
+ await this.ready();
593
+ const result = await this.db.prepare(`DELETE FROM ${this.table('users')} WHERE id = ?`).bind(id).run();
594
+ if ((result.meta?.changes ?? 0) === 0) {
595
+ throw new InvalidInputError(`User ${id} not found`, 'id');
596
+ }
597
+ }
598
+ async findUserBy(column, value) {
599
+ if (!value || typeof value !== 'string') {
600
+ throw new InvalidInputError(`A ${column} is required`, column);
601
+ }
602
+ await this.ready();
603
+ const row = await this.db
604
+ .prepare(`SELECT * FROM ${this.table('users')} WHERE ${column} = ?`)
605
+ .bind(value)
606
+ .first();
607
+ return row ? this.mapRowToUser(row) : null;
608
+ }
609
+ async getUserByUsername(username) {
610
+ return this.findUserBy('username', username);
611
+ }
612
+ async getUserByEmail(email) {
613
+ return this.findUserBy('email', email);
614
+ }
615
+ async isUsernameAvailable(username) {
616
+ return (await this.getUserByUsername(username)) === null;
617
+ }
618
+ async isEmailAvailable(email) {
619
+ return (await this.getUserByEmail(email)) === null;
620
+ }
621
+ async getUserByOAuthProvider(provider, providerId) {
622
+ await this.ready();
623
+ const result = await this.db
624
+ .prepare(`SELECT * FROM ${this.table('users')} WHERE oauth_providers IS NOT NULL`)
625
+ .all();
626
+ for (const row of result.results ?? []) {
627
+ const providers = parseJsonColumn(row.oauth_providers) ?? [];
628
+ if (providers.some(p => p.provider === provider && String(p.providerId) === String(providerId))) {
629
+ return this.mapRowToUser(row);
630
+ }
631
+ }
632
+ return null;
633
+ }
634
+ async getUserByPasskeyCredentialId(credentialId) {
635
+ if (!credentialId || !/^[A-Za-z0-9_-]+$/.test(credentialId)) {
636
+ throw new InvalidInputError('Invalid credential id', 'credentialId');
637
+ }
638
+ await this.ready();
639
+ const result = await this.db
640
+ .prepare(`SELECT * FROM ${this.table('users')} WHERE passkeys IS NOT NULL`)
641
+ .all();
642
+ for (const row of result.results ?? []) {
643
+ const passkeys = parseJsonColumn(row.passkeys) ?? [];
644
+ if (passkeys.some(p => p.credentialId === credentialId || p.id === credentialId)) {
645
+ return this.mapRowToUser(row);
646
+ }
647
+ }
648
+ return null;
649
+ }
650
+ // -------------------------------------------------------------- app tokens
651
+ mapRowToAppToken(row) {
652
+ return {
653
+ id: row.id,
654
+ name: row.name,
655
+ description: orUndefined(row.description) ?? '',
656
+ hash: row.hash,
657
+ // Unlike a user's permissions, a token's are always an array: there is no
658
+ // "cleared" state for them, and the type says string[].
659
+ permissions: parseJsonColumn(row.permissions) ?? [],
660
+ createdBy: orUndefined(row.created_by),
661
+ isActive: row.is_active === null || row.is_active === undefined ? true : row.is_active === 1,
662
+ usageCount: Number(row.usage_count ?? 0),
663
+ lastUsedAt: orUndefined(row.last_used_at),
664
+ expiresAt: orUndefined(row.expires_at),
665
+ createdAt: row.created_at,
666
+ updatedAt: row.updated_at
667
+ };
668
+ }
669
+ async getAppToken(id) {
670
+ await this.ready();
671
+ const row = await this.db
672
+ .prepare(`SELECT * FROM ${this.table('app_tokens')} WHERE id = ?`)
673
+ .bind(id)
674
+ .first();
675
+ return row ? this.mapRowToAppToken(row) : null;
676
+ }
677
+ async getAppTokenByHash(hash) {
678
+ if (!hash || typeof hash !== 'string') {
679
+ throw new InvalidInputError('Token hash is required', 'hash');
680
+ }
681
+ await this.ready();
682
+ const row = await this.db
683
+ .prepare(`SELECT * FROM ${this.table('app_tokens')} WHERE hash = ? AND is_active = 1`)
684
+ .bind(hash)
685
+ .first();
686
+ if (!row)
687
+ return null;
688
+ await this.db
689
+ .prepare(`UPDATE ${this.table('app_tokens')} SET last_used_at = ?, usage_count = COALESCE(usage_count,0) + 1 WHERE id = ?`)
690
+ .bind(this.now(), row.id)
691
+ .run();
692
+ return this.mapRowToAppToken(row);
693
+ }
694
+ async saveAppToken(id, data) {
695
+ await this.ready();
696
+ const now = this.now();
697
+ const input = (data ?? {});
698
+ const tokens = this.table('app_tokens');
699
+ const existing = await this.getAppToken(id);
700
+ if (existing) {
701
+ const sql = [];
702
+ const values = [];
703
+ for (const [key, column, json, bool] of [
704
+ ['name', 'name', false, false],
705
+ ['description', 'description', false, false],
706
+ ['permissions', 'permissions', true, false],
707
+ ['isActive', 'is_active', false, true],
708
+ ['expiresAt', 'expires_at', false, false],
709
+ ['lastUsedAt', 'last_used_at', false, false]
710
+ ]) {
711
+ if (!(key in input) || input[key] === undefined)
712
+ continue;
713
+ sql.push(`${column} = ?`);
714
+ values.push(json ? jsonColumn(input[key]) : bool ? boolColumn(input[key]) : input[key]);
715
+ }
716
+ sql.push('updated_at = ?');
717
+ values.push(now);
718
+ const row = await this.db
719
+ .prepare(`UPDATE ${tokens} SET ${sql.join(', ')} WHERE id = ? RETURNING *`)
720
+ .bind(...values, id)
721
+ .first();
722
+ if (!row)
723
+ throw new Error(`Failed to update app token ${id}`);
724
+ return this.mapRowToAppToken(row);
725
+ }
726
+ const row = await this.db
727
+ .prepare(`INSERT INTO ${tokens}
728
+ (id, name, description, hash, permissions, created_by, is_active,
729
+ usage_count, last_used_at, expires_at, created_at, updated_at)
730
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?) RETURNING *`)
731
+ .bind(id, input.name ?? null, input.description ?? null, input.hash ?? null, jsonColumn(input.permissions ?? []), input.createdBy ?? null, boolColumn(input.isActive === undefined ? true : input.isActive), 0, input.lastUsedAt ?? null, input.expiresAt ?? null, now, now)
732
+ .first();
733
+ if (!row)
734
+ throw new Error(`Failed to create app token ${id}`);
735
+ return this.mapRowToAppToken(row);
736
+ }
737
+ async listAppTokens(options = {}) {
738
+ await this.ready();
739
+ const clauses = [];
740
+ const values = [];
741
+ if (options.createdBy !== undefined) {
742
+ clauses.push('created_by = ?');
743
+ values.push(options.createdBy);
744
+ }
745
+ if (options.isActive !== undefined) {
746
+ clauses.push('is_active = ?');
747
+ values.push(boolColumn(options.isActive));
748
+ }
749
+ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
750
+ const limit = Math.min(options.limit ?? this.MAX_LIST_LIMIT, this.MAX_LIST_LIMIT);
751
+ const result = await this.db
752
+ .prepare(`SELECT * FROM ${this.table('app_tokens')}${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
753
+ .bind(...values, limit, options.offset ?? 0)
754
+ .all();
755
+ return (result.results ?? []).map(row => this.mapRowToAppToken(row));
756
+ }
757
+ async deleteAppToken(id) {
758
+ await this.ready();
759
+ const result = await this.db.prepare(`DELETE FROM ${this.table('app_tokens')} WHERE id = ?`).bind(id).run();
760
+ if ((result.meta?.changes ?? 0) === 0) {
761
+ throw new InvalidInputError(`App token ${id} not found`, 'id');
762
+ }
763
+ }
764
+ // ------------------------------------------------------------- flow state
765
+ async saveAuthFlowState(state) {
766
+ SecurityValidator.validateDocumentId(state.id);
767
+ await this.ready();
768
+ await this.db
769
+ .prepare(`INSERT INTO ${this.table('auth_flow_state')} (id, kind, data, expires_at, created_at)
770
+ VALUES (?,?,?,?,?)
771
+ ON CONFLICT(id) DO UPDATE SET
772
+ kind = excluded.kind, data = excluded.data, expires_at = excluded.expires_at`)
773
+ .bind(state.id, state.kind, JSON.stringify(state.data ?? {}), state.expiresAt, this.now())
774
+ .run();
775
+ }
776
+ /**
777
+ * One statement, so two concurrent callers cannot both win. Kind and expiry
778
+ * are part of the WHERE clause: presenting another flow's id neither returns
779
+ * it nor destroys it.
780
+ */
781
+ async consumeAuthFlowState(id, kind) {
782
+ await this.ready();
783
+ const row = await this.db
784
+ .prepare(`DELETE FROM ${this.table('auth_flow_state')}
785
+ WHERE id = ? AND kind = ? AND expires_at > ?
786
+ RETURNING id, kind, data, expires_at`)
787
+ .bind(id, kind, this.now())
788
+ .first();
789
+ if (!row)
790
+ return null;
791
+ return {
792
+ id: row.id,
793
+ kind: row.kind,
794
+ data: parseJsonColumn(row.data) ?? {},
795
+ expiresAt: row.expires_at
796
+ };
797
+ }
798
+ async deleteExpiredAuthFlowStates() {
799
+ await this.ready();
800
+ const result = await this.db
801
+ .prepare(`DELETE FROM ${this.table('auth_flow_state')} WHERE expires_at <= ?`)
802
+ .bind(this.now())
803
+ .run();
804
+ return result.meta?.changes ?? 0;
805
+ }
806
+ // ---------------------------------------------------------------- settings
807
+ async getSettings() {
808
+ await this.ready();
809
+ const row = await this.db
810
+ .prepare(`SELECT data FROM ${this.table('settings')} WHERE id = 1`)
811
+ .first();
812
+ return row ? parseJsonColumn(row.data) ?? null : null;
813
+ }
814
+ async saveSettings(settings) {
815
+ await this.ready();
816
+ await this.db
817
+ .prepare(`INSERT INTO ${this.table('settings')} (id, data, updated_at) VALUES (1, ?, ?)
818
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`)
819
+ .bind(JSON.stringify(settings ?? {}), this.now())
820
+ .run();
821
+ }
822
+ async getStorageInfo() {
823
+ await this.ready();
824
+ const collections = await this.db
825
+ .prepare(`SELECT DISTINCT collection FROM ${this.table('documents')}`)
826
+ .all();
827
+ const counts = await this.db.batch([
828
+ this.db.prepare(`SELECT COUNT(*) AS count FROM ${this.table('documents')}`),
829
+ this.db.prepare(`SELECT COUNT(*) AS count FROM ${this.table('users')}`),
830
+ this.db.prepare(`SELECT COUNT(*) AS count FROM ${this.table('app_tokens')}`)
831
+ ]);
832
+ const at = (index) => Number(counts[index]?.results?.[0]?.count ?? 0);
833
+ return {
834
+ type: 'cloudflare-d1',
835
+ collections: (collections.results ?? []).map(row => row.collection),
836
+ documentCount: at(0),
837
+ userCount: at(1),
838
+ tokenCount: at(2)
839
+ };
840
+ }
841
+ /** Not implemented: D1 has no interactive transactions. */
842
+ beginTransaction = undefined;
843
+ /** Escape hatch for callers that need raw SQL, e.g. migrations. */
844
+ async execute(sql, params = []) {
845
+ await this.ready();
846
+ const statement = params.length
847
+ ? this.db.prepare(sql).bind(...params)
848
+ : this.db.prepare(sql);
849
+ const result = await statement.all();
850
+ return result.results ?? [];
851
+ }
852
+ }
853
+ //# sourceMappingURL=cloudflare-d1-adapter.js.map