@siteping/adapter-drizzle 0.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.
Files changed (50) hide show
  1. package/README.md +40 -0
  2. package/dist/chunk-6G6UAERF.js +814 -0
  3. package/dist/chunk-6G6UAERF.js.map +1 -0
  4. package/dist/chunk-ICY2AW6S.js +11 -0
  5. package/dist/chunk-ICY2AW6S.js.map +1 -0
  6. package/dist/concurrency.d.cts +14 -0
  7. package/dist/concurrency.d.ts +14 -0
  8. package/dist/deep-link.d.cts +14 -0
  9. package/dist/deep-link.d.ts +14 -0
  10. package/dist/email.d.cts +22 -0
  11. package/dist/email.d.ts +22 -0
  12. package/dist/errors.d.cts +55 -0
  13. package/dist/errors.d.ts +55 -0
  14. package/dist/filters.d.cts +82 -0
  15. package/dist/filters.d.ts +82 -0
  16. package/dist/i18n.d.cts +85 -0
  17. package/dist/i18n.d.ts +85 -0
  18. package/dist/index.cjs +33 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +45 -0
  21. package/dist/index.d.ts +45 -0
  22. package/dist/index.js +7 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/libsql.cjs +1045 -0
  25. package/dist/libsql.cjs.map +1 -0
  26. package/dist/libsql.d.cts +1015 -0
  27. package/dist/libsql.d.ts +1015 -0
  28. package/dist/libsql.js +254 -0
  29. package/dist/libsql.js.map +1 -0
  30. package/dist/pg.cjs +1079 -0
  31. package/dist/pg.cjs.map +1 -0
  32. package/dist/pg.d.cts +968 -0
  33. package/dist/pg.d.ts +968 -0
  34. package/dist/pg.js +298 -0
  35. package/dist/pg.js.map +1 -0
  36. package/dist/schema.d.cts +317 -0
  37. package/dist/schema.d.ts +317 -0
  38. package/dist/screenshot-storage.d.cts +103 -0
  39. package/dist/screenshot-storage.d.ts +103 -0
  40. package/dist/siteping-core.d.cts +20 -0
  41. package/dist/siteping-core.d.ts +20 -0
  42. package/dist/store-helpers.d.cts +140 -0
  43. package/dist/store-helpers.d.ts +140 -0
  44. package/dist/type-utils.d.cts +66 -0
  45. package/dist/type-utils.d.ts +66 -0
  46. package/dist/types.d.cts +1206 -0
  47. package/dist/types.d.ts +1206 -0
  48. package/dist/wire.d.cts +68 -0
  49. package/dist/wire.d.ts +68 -0
  50. package/package.json +89 -0
@@ -0,0 +1,814 @@
1
+ // src/shared/replicas.ts
2
+ function primaryOf(db) {
3
+ return "$primary" in db ? db.$primary : db;
4
+ }
5
+
6
+ // ../core/src/type-utils.ts
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null;
9
+ }
10
+ function hasOwn(value, key) {
11
+ return isRecord(value) && key in value;
12
+ }
13
+
14
+ // ../core/src/types.ts
15
+ var MAX_COMMENTS_PER_FEEDBACK = 100;
16
+ var StoreNotFoundError = class extends Error {
17
+ code = "STORE_NOT_FOUND";
18
+ constructor(message = "Record not found", options) {
19
+ super(message, options);
20
+ this.name = "StoreNotFoundError";
21
+ }
22
+ };
23
+ var StoreDuplicateError = class extends Error {
24
+ code = "STORE_DUPLICATE";
25
+ constructor(message = "Duplicate record", options) {
26
+ super(message, options);
27
+ this.name = "StoreDuplicateError";
28
+ }
29
+ };
30
+ var StorePersistenceError = class extends Error {
31
+ code = "STORE_PERSISTENCE";
32
+ constructor(message = "Failed to persist store mutation", options) {
33
+ super(message, options);
34
+ this.name = "StorePersistenceError";
35
+ }
36
+ };
37
+ var StoreLimitError = class extends Error {
38
+ code = "STORE_LIMIT";
39
+ constructor(message = "Store limit reached", options) {
40
+ super(message, options);
41
+ this.name = "StoreLimitError";
42
+ }
43
+ };
44
+ function hasErrorCode(error, code) {
45
+ return hasOwn(error, "code") && error.code === code;
46
+ }
47
+ function isStoreNotFound(error) {
48
+ if (error instanceof StoreNotFoundError) return true;
49
+ return hasErrorCode(error, "STORE_NOT_FOUND") || hasErrorCode(error, "P2025");
50
+ }
51
+ function isStoreDuplicate(error) {
52
+ if (error instanceof StoreDuplicateError) return true;
53
+ return hasErrorCode(error, "STORE_DUPLICATE") || hasErrorCode(error, "P2002");
54
+ }
55
+ function isStorePersistence(error) {
56
+ if (error instanceof StorePersistenceError) return true;
57
+ return hasErrorCode(error, "STORE_PERSISTENCE");
58
+ }
59
+
60
+ // ../core/src/concurrency.ts
61
+ async function settleWithConcurrencyLimit(items, concurrency, task) {
62
+ const results = new Array(items.length);
63
+ let nextIndex = 0;
64
+ const runWorker = async () => {
65
+ while (nextIndex < items.length) {
66
+ const index = nextIndex;
67
+ nextIndex += 1;
68
+ try {
69
+ results[index] = { status: "fulfilled", value: await task(items[index]) };
70
+ } catch (error) {
71
+ results[index] = { status: "rejected", reason: error };
72
+ }
73
+ }
74
+ };
75
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
76
+ await Promise.all(Array.from({ length: workerCount }, runWorker));
77
+ return results;
78
+ }
79
+
80
+ // ../core/src/filters.ts
81
+ var DEFAULT_PAGE_LIMIT = 50;
82
+ var MAX_PAGE_LIMIT = 100;
83
+ function toPositiveInteger(value, fallback) {
84
+ return value !== void 0 && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
85
+ }
86
+ function clampPagination(query) {
87
+ const page = toPositiveInteger(query.page, 1);
88
+ const limit = Math.min(toPositiveInteger(query.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
89
+ return { page, limit, skip: (page - 1) * limit };
90
+ }
91
+ function isUnreachableOffset(skip) {
92
+ return !Number.isSafeInteger(skip);
93
+ }
94
+
95
+ // ../core/src/screenshot-storage.ts
96
+ var SCREENSHOT_DELETE_CONCURRENCY = 32;
97
+ function screenshotMimeType(dataUrl) {
98
+ return /^data:(image\/(?:jpeg|png|webp))[;,]/i.exec(dataUrl)?.[1]?.toLowerCase() ?? "image/jpeg";
99
+ }
100
+
101
+ // ../core/src/store-helpers.ts
102
+ function buildAnnotationRecord(input, ctx) {
103
+ return {
104
+ id: ctx.id,
105
+ feedbackId: ctx.feedbackId,
106
+ cssSelector: input.cssSelector,
107
+ xpath: input.xpath,
108
+ textSnippet: input.textSnippet,
109
+ elementTag: input.elementTag,
110
+ elementId: input.elementId ?? null,
111
+ textPrefix: input.textPrefix,
112
+ textSuffix: input.textSuffix,
113
+ fingerprint: input.fingerprint,
114
+ neighborText: input.neighborText,
115
+ anchorKey: input.anchorKey ?? null,
116
+ xPct: input.xPct,
117
+ yPct: input.yPct,
118
+ wPct: input.wPct,
119
+ hPct: input.hPct,
120
+ scrollX: input.scrollX,
121
+ scrollY: input.scrollY,
122
+ viewportW: input.viewportW,
123
+ viewportH: input.viewportH,
124
+ devicePixelRatio: input.devicePixelRatio,
125
+ createdAt: ctx.now
126
+ };
127
+ }
128
+ function buildCommentRecord(input, ctx) {
129
+ return {
130
+ id: ctx.id,
131
+ feedbackId: ctx.feedbackId,
132
+ body: input.body,
133
+ authorName: input.authorName,
134
+ authorEmail: input.authorEmail,
135
+ authorRole: input.authorRole,
136
+ clientId: input.clientId,
137
+ createdAt: ctx.now ?? /* @__PURE__ */ new Date()
138
+ };
139
+ }
140
+ function buildFeedbackRecord(input, ctx) {
141
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
142
+ return {
143
+ id: ctx.id,
144
+ type: input.type,
145
+ message: input.message,
146
+ status: input.status,
147
+ projectName: input.projectName,
148
+ url: input.url,
149
+ urlPattern: input.urlPattern ?? null,
150
+ authorName: input.authorName,
151
+ authorEmail: input.authorEmail,
152
+ viewport: input.viewport,
153
+ userAgent: input.userAgent,
154
+ clientId: input.clientId,
155
+ resolvedAt: null,
156
+ createdAt: now,
157
+ updatedAt: now,
158
+ annotations: input.annotations.map(
159
+ (ann) => buildAnnotationRecord(ann, { id: ctx.annotationId(), feedbackId: ctx.id, now })
160
+ ),
161
+ screenshotUrl: input.screenshotDataUrl ?? null,
162
+ screenshotRegion: input.screenshotRegion ?? null,
163
+ diagnostics: input.diagnostics ?? null
164
+ };
165
+ }
166
+
167
+ // src/constants/errors.ts
168
+ var DRIZZLE_STORE_MESSAGE_PREFIX = "[siteping] DrizzleStore";
169
+ var DRIZZLE_QUERY_ERROR_MESSAGE_PREFIX = "Failed query:";
170
+ var FOREIGN_KEY_VIOLATION_SQLSTATE = "23503";
171
+ var DRIVER_ERROR_DIAGNOSTIC_FIELDS = [
172
+ "code",
173
+ "errno",
174
+ "syscall",
175
+ "severity",
176
+ "schema",
177
+ "table",
178
+ "column",
179
+ "dataType",
180
+ "constraint",
181
+ "routine",
182
+ "severity_local",
183
+ "schema_name",
184
+ "table_name",
185
+ "column_name",
186
+ "constraint_name",
187
+ "extendedCode",
188
+ "rawCode"
189
+ ];
190
+
191
+ // src/constants/deletes.ts
192
+ var PROJECT_DELETE_CHUNK_SIZE = 500;
193
+
194
+ // src/constants/screenshots.ts
195
+ var INLINE_SCREENSHOT_URL_PREFIX = "data:";
196
+ var SCREENSHOT_REFERENCE_LOOKUP_BATCH_SIZE = 500;
197
+ var INLINE_SCREENSHOT_URL_LIKE_PATTERN = `${INLINE_SCREENSHOT_URL_PREFIX}%`;
198
+
199
+ // src/shared/errors.ts
200
+ import { DrizzleQueryError } from "drizzle-orm";
201
+ function isDrizzleQueryError(error) {
202
+ return error instanceof DrizzleQueryError || error.message.startsWith(DRIZZLE_QUERY_ERROR_MESSAGE_PREFIX);
203
+ }
204
+ function withoutStatement(error, above = []) {
205
+ if (!(error instanceof Error)) return error;
206
+ const chain = [...above, error];
207
+ const keepsCause = error.cause !== void 0 && !chain.includes(error.cause);
208
+ const cause = keepsCause ? withoutStatement(error.cause, chain) : void 0;
209
+ if (isDrizzleQueryError(error)) return cause;
210
+ const copy = new Error(error.message, keepsCause ? { cause } : void 0);
211
+ const kept = (value) => ({ value, writable: true, configurable: true });
212
+ Object.defineProperties(copy, { name: kept(error.name), stack: kept(error.stack) });
213
+ const diagnostics = DRIVER_ERROR_DIAGNOSTIC_FIELDS.map((field) => [field, Reflect.get(error, field)]);
214
+ return Object.assign(copy, Object.fromEntries(diagnostics.filter(([, value]) => value !== void 0)));
215
+ }
216
+ function withDriverErrors(gateway) {
217
+ return new Proxy(gateway, {
218
+ get(target, property, receiver) {
219
+ const member = Reflect.get(target, property, receiver);
220
+ if (typeof member !== "function") return member;
221
+ return async (...args) => {
222
+ try {
223
+ return await member.apply(target, args);
224
+ } catch (error) {
225
+ throw withoutStatement(error);
226
+ }
227
+ };
228
+ }
229
+ });
230
+ }
231
+
232
+ // src/constants/text.ts
233
+ var UNSTORABLE_CODE_UNITS = /\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
234
+ var REPLACEMENT_CHARACTER = "\uFFFD";
235
+
236
+ // src/shared/text.ts
237
+ function toStorableText(text) {
238
+ return text.replace(UNSTORABLE_CODE_UNITS, REPLACEMENT_CHARACTER);
239
+ }
240
+ function isPlainObject(value) {
241
+ if (typeof value !== "object" || value === null) return false;
242
+ const prototype = Object.getPrototypeOf(value);
243
+ return prototype === Object.prototype || prototype === null;
244
+ }
245
+ function toStorableValue(value) {
246
+ if (typeof value === "string") return toStorableText(value);
247
+ if (Array.isArray(value)) return value.map(toStorableValue);
248
+ if (!isPlainObject(value)) return value;
249
+ return Object.fromEntries(
250
+ Object.entries(value).map(([key, item]) => [toStorableText(key), toStorableValue(item)])
251
+ );
252
+ }
253
+
254
+ // src/shared/store.ts
255
+ var silentLogger = {
256
+ warn() {
257
+ }
258
+ };
259
+ var systemClock = () => /* @__PURE__ */ new Date();
260
+ function isStoreContractError(error) {
261
+ return isStoreNotFound(error) || isStoreDuplicate(error) || isStorePersistence(error);
262
+ }
263
+ async function persistMutation(mutation, identifiers, run) {
264
+ try {
265
+ return await run();
266
+ } catch (error) {
267
+ if (isStoreContractError(error)) throw error;
268
+ const context = Object.entries(identifiers).map(([name, value]) => `${name}=${value}`).join(" ");
269
+ throw new StorePersistenceError(`${DRIZZLE_STORE_MESSAGE_PREFIX}.${mutation} failed (${context})`, {
270
+ cause: error
271
+ });
272
+ }
273
+ }
274
+ function isForeignKeyViolation(error) {
275
+ const seen = [];
276
+ for (let current = error; current instanceof Error && !seen.includes(current); current = current.cause) {
277
+ if (Reflect.get(current, "code") === FOREIGN_KEY_VIOLATION_SQLSTATE) return true;
278
+ seen.push(current);
279
+ }
280
+ return false;
281
+ }
282
+ function groupByFeedback(rows) {
283
+ const byFeedback = /* @__PURE__ */ new Map();
284
+ for (const row of rows) {
285
+ const siblings = byFeedback.get(row.feedbackId);
286
+ if (siblings) siblings.push(row);
287
+ else byFeedback.set(row.feedbackId, [row]);
288
+ }
289
+ return byFeedback;
290
+ }
291
+ function isUploadedScreenshotUrl(url) {
292
+ return typeof url === "string" && url.length > 0 && !url.startsWith(INLINE_SCREENSHOT_URL_PREFIX);
293
+ }
294
+ var DrizzleSitepingStore = class {
295
+ gateway;
296
+ screenshotStorage;
297
+ logger;
298
+ now;
299
+ inlineScreenshotWarned = false;
300
+ constructor(gateway, options = {}) {
301
+ this.gateway = withDriverErrors(gateway);
302
+ this.screenshotStorage = options.screenshotStorage;
303
+ this.logger = options.logger ?? silentLogger;
304
+ this.now = options.now ?? systemClock;
305
+ }
306
+ async createFeedback(data) {
307
+ return (await this.createFeedbackIfAbsent(data)).feedback;
308
+ }
309
+ /**
310
+ * Insert the feedback unless a row with the same `clientId` exists. The
311
+ * unique `client_id` index plus `ON CONFLICT DO NOTHING` make the check
312
+ * atomic across store instances and processes: of N concurrent calls, only
313
+ * the one whose insert lands reports `created: true`.
314
+ *
315
+ * @throws `StorePersistenceError` when a database call fails: the lookup
316
+ * of the `clientId`, the insert, or — when the insert loses the race — the
317
+ * read-back of the winning row. If that row was deleted in the meantime,
318
+ * an `Error` naming the `clientId` is thrown (the caller may retry the
319
+ * submission). On every failure path, the screenshot this attempt uploaded
320
+ * is discarded first — no committed row references it.
321
+ */
322
+ async createFeedbackIfAbsent(submitted) {
323
+ const data = toStorableValue(submitted);
324
+ const identifiers = { clientId: data.clientId };
325
+ const existing = await persistMutation("createFeedback", identifiers, () => this.findByClientId(data.clientId));
326
+ if (existing) return { feedback: existing, created: false };
327
+ const id = crypto.randomUUID();
328
+ const screenshotUrl = await this.persistScreenshot(data.screenshotDataUrl, {
329
+ feedbackId: id,
330
+ clientId: data.clientId
331
+ });
332
+ const { annotations, ...feedback } = buildFeedbackRecord(data, {
333
+ id,
334
+ annotationId: () => crypto.randomUUID(),
335
+ // The clock value as is: rows created in the same millisecond — by this
336
+ // or any other instance — are ordered by the database-wide insertion
337
+ // ordinal, so a later insert always lists first.
338
+ now: this.now()
339
+ });
340
+ const row = { ...feedback, screenshotUrl };
341
+ let inserted;
342
+ try {
343
+ inserted = await persistMutation(
344
+ "createFeedback",
345
+ identifiers,
346
+ () => this.gateway.insertFeedback(row, annotations)
347
+ );
348
+ } catch (error) {
349
+ await this.discardScreenshots([screenshotUrl], { clientId: data.clientId });
350
+ throw error;
351
+ }
352
+ if (inserted) {
353
+ return { feedback: { ...row, annotations, comments: [] }, created: true };
354
+ }
355
+ let winner;
356
+ try {
357
+ winner = await persistMutation("createFeedback", identifiers, () => this.findByClientId(data.clientId));
358
+ } finally {
359
+ await this.discardScreenshots([screenshotUrl], { clientId: data.clientId });
360
+ }
361
+ if (!winner) {
362
+ throw new Error(
363
+ `${DRIZZLE_STORE_MESSAGE_PREFIX}.createFeedbackIfAbsent: clientId ${data.clientId} conflicted but no row was found (the winning row was deleted before it could be read back)`
364
+ );
365
+ }
366
+ return { feedback: winner, created: false };
367
+ }
368
+ async getFeedbacks(submittedQuery) {
369
+ const query = toStorableValue(submittedQuery);
370
+ const { limit, skip } = clampPagination(query);
371
+ const filter = { projectName: query.projectName };
372
+ if (query.type) filter.type = query.type;
373
+ if (query.statuses && query.statuses.length > 0) filter.statuses = query.statuses;
374
+ else if (query.status) filter.statuses = [query.status];
375
+ if (query.url) filter.url = query.url;
376
+ if (query.urlPattern) filter.urlPattern = query.urlPattern;
377
+ if (query.search) filter.search = query.search;
378
+ if (isUnreachableOffset(skip)) {
379
+ return { feedbacks: [], total: await this.gateway.countFeedbacks(filter) };
380
+ }
381
+ const { rows, total } = await this.gateway.findFeedbacks(filter, { limit, offset: skip });
382
+ return { feedbacks: await this.withRelations(rows), total };
383
+ }
384
+ async findByClientId(clientId) {
385
+ const row = await this.gateway.findByClientId(toStorableText(clientId));
386
+ return row ? (await this.withRelations([row]))[0] ?? null : null;
387
+ }
388
+ /**
389
+ * Update a feedback's status. Its annotations and thread are read before the
390
+ * update — annotations never change after the insert, and the thread is the
391
+ * one at the time of the update: once the update commits, no database call
392
+ * is left that could fail and make the caller retry an applied update.
393
+ *
394
+ * @throws `StoreNotFoundError` when no row has that id.
395
+ * @throws `StorePersistenceError` when reading the annotations, the thread
396
+ * or the update fails — a failed read leaves the row untouched.
397
+ */
398
+ async updateFeedback(submittedId, data) {
399
+ const id = toStorableText(submittedId);
400
+ const { relationsOf, row } = await persistMutation("updateFeedback", { id }, async () => {
401
+ const relationsOf2 = await this.readRelations([id]);
402
+ const row2 = await this.gateway.updateStatus(id, {
403
+ status: data.status,
404
+ resolvedAt: data.resolvedAt,
405
+ updatedAt: this.now()
406
+ });
407
+ return { relationsOf: relationsOf2, row: row2 };
408
+ });
409
+ if (!row) throw new StoreNotFoundError();
410
+ return { ...row, ...relationsOf(id) };
411
+ }
412
+ async deleteFeedback(submittedId) {
413
+ const id = toStorableText(submittedId);
414
+ const deleted = await persistMutation(
415
+ "deleteFeedback",
416
+ { id },
417
+ () => this.gateway.deleteById(id, this.deleteOptions())
418
+ );
419
+ if (!deleted) throw new StoreNotFoundError();
420
+ await this.discardScreenshots(deleted.screenshotUrls);
421
+ }
422
+ /**
423
+ * Delete every feedback of a project. Rows go first, storage second: orphaned
424
+ * objects are acceptable, rows pointing at deleted screenshots are not.
425
+ *
426
+ * Without a `ScreenshotStorage.delete` hook, one atomic statement (or batch)
427
+ * removes the project and reads nothing back. With one, the removed URLs are
428
+ * needed for cleanup, so rows go in chunks of
429
+ * {@link PROJECT_DELETE_CHUNK_SIZE}, each deleted atomically and its
430
+ * screenshots cleaned up before the next one: every driver response and the
431
+ * URLs held in memory stay bounded however large the project. The call
432
+ * returns once the project has no row left, whatever deletes run alongside.
433
+ *
434
+ * @throws `StorePersistenceError` when a chunk fails. The chunks before it
435
+ * stay deleted and their screenshots are already cleaned up; the delete is
436
+ * idempotent, so retrying it removes the remaining rows. When the failing
437
+ * chunk committed although the driver reported an error, its rows are gone
438
+ * and their screenshots are left in the storage as orphans. Also thrown
439
+ * when the database keeps rows it is told to delete (a row-level security
440
+ * policy or a trigger that skips deletes), instead of retrying forever.
441
+ */
442
+ async deleteAllFeedbacks(submittedProjectName) {
443
+ const projectName = toStorableText(submittedProjectName);
444
+ if (!this.deleteOptions().collectScreenshotUrls) {
445
+ await persistMutation("deleteAllFeedbacks", { projectName }, () => this.gateway.deleteByProject(projectName));
446
+ return;
447
+ }
448
+ let remainingAfterEmptyChunk = Number.POSITIVE_INFINITY;
449
+ for (; ; ) {
450
+ const deleted = await persistMutation(
451
+ "deleteAllFeedbacks",
452
+ { projectName },
453
+ () => this.gateway.deleteProjectChunk(projectName, PROJECT_DELETE_CHUNK_SIZE)
454
+ );
455
+ if (deleted.deletedCount > 0) {
456
+ remainingAfterEmptyChunk = Number.POSITIVE_INFINITY;
457
+ await this.discardScreenshots(deleted.screenshotUrls);
458
+ continue;
459
+ }
460
+ const remaining = await persistMutation(
461
+ "deleteAllFeedbacks",
462
+ { projectName },
463
+ () => this.gateway.countFeedbacks({ projectName })
464
+ );
465
+ if (remaining === 0) return;
466
+ if (remaining >= remainingAfterEmptyChunk) {
467
+ throw new StorePersistenceError(
468
+ `${DRIZZLE_STORE_MESSAGE_PREFIX}.deleteAllFeedbacks: the database kept ${remaining} rows it was told to delete (projectName=${projectName})`
469
+ );
470
+ }
471
+ remainingAfterEmptyChunk = remaining;
472
+ }
473
+ }
474
+ async verifyProjectOwnership(id, projectName) {
475
+ return await this.gateway.findProjectName(toStorableText(id)) === toStorableText(projectName);
476
+ }
477
+ /**
478
+ * Add a comment as the last of its thread, in one statement that also
479
+ * checks the feedback, the cap on `client` comments and the `clientId` —
480
+ * atomic across store instances and processes, except that posts racing
481
+ * for the last free slot may overshoot the cap by the ones that run
482
+ * concurrently.
483
+ *
484
+ * @throws `StoreNotFoundError` when the feedback does not exist, including
485
+ * when it is deleted while the comment is being inserted.
486
+ * @throws `StoreLimitError` when a `client` comment meets a thread already holding `MAX_COMMENTS_PER_FEEDBACK` of them.
487
+ * @throws `StorePersistenceError` when a database call fails.
488
+ */
489
+ async addComment(submittedFeedbackId, submitted) {
490
+ const feedbackId = toStorableText(submittedFeedbackId);
491
+ const data = toStorableValue(submitted);
492
+ const comment = buildCommentRecord(data, { id: crypto.randomUUID(), feedbackId, now: this.now() });
493
+ const identifiers = { feedbackId, clientId: data.clientId };
494
+ let inserted;
495
+ try {
496
+ inserted = await persistMutation(
497
+ "addComment",
498
+ identifiers,
499
+ () => this.gateway.insertComment(comment, MAX_COMMENTS_PER_FEEDBACK)
500
+ );
501
+ } catch (error) {
502
+ if (!isForeignKeyViolation(error)) throw error;
503
+ const feedbackGone = await this.gateway.findProjectName(feedbackId).then(
504
+ (projectName2) => projectName2 === null,
505
+ () => false
506
+ );
507
+ if (feedbackGone) throw new StoreNotFoundError();
508
+ throw error;
509
+ }
510
+ if (inserted) return comment;
511
+ const replayed = await persistMutation(
512
+ "addComment",
513
+ identifiers,
514
+ () => this.gateway.findCommentByClientId(data.clientId)
515
+ );
516
+ if (replayed) return replayed;
517
+ const projectName = await persistMutation(
518
+ "addComment",
519
+ identifiers,
520
+ () => this.gateway.findProjectName(feedbackId)
521
+ );
522
+ if (projectName === null) throw new StoreNotFoundError();
523
+ throw new StoreLimitError(`A thread holds at most ${MAX_COMMENTS_PER_FEEDBACK} client comments`);
524
+ }
525
+ async deleteComment(submittedFeedbackId, submittedCommentId) {
526
+ const feedbackId = toStorableText(submittedFeedbackId);
527
+ const commentId = toStorableText(submittedCommentId);
528
+ const deleted = await persistMutation(
529
+ "deleteComment",
530
+ { feedbackId, commentId },
531
+ () => this.gateway.deleteComment(feedbackId, commentId)
532
+ );
533
+ if (!deleted) throw new StoreNotFoundError();
534
+ }
535
+ /**
536
+ * Deletes read the removed screenshot URLs back only when a
537
+ * `ScreenshotStorage.delete` hook can clean them up: without one there is
538
+ * nothing to do with them, and inline data URLs would be materialized for
539
+ * every deleted row.
540
+ */
541
+ deleteOptions() {
542
+ return { collectScreenshotUrls: typeof this.screenshotStorage?.delete === "function" };
543
+ }
544
+ async withRelations(rows) {
545
+ if (rows.length === 0) return [];
546
+ const relationsOf = await this.readRelations(rows.map((row) => row.id));
547
+ return rows.map((row) => ({ ...row, ...relationsOf(row.id) }));
548
+ }
549
+ /** Read the annotations and threads of `feedbackIds`, then hand out each feedback's. */
550
+ async readRelations(feedbackIds) {
551
+ const [annotations, comments] = await Promise.all([
552
+ this.gateway.findAnnotations(feedbackIds),
553
+ this.gateway.findComments(feedbackIds)
554
+ ]);
555
+ const annotationsOf = groupByFeedback(annotations);
556
+ const commentsOf = groupByFeedback(comments);
557
+ return (feedbackId) => ({
558
+ annotations: annotationsOf.get(feedbackId) ?? [],
559
+ comments: commentsOf.get(feedbackId) ?? []
560
+ });
561
+ }
562
+ /**
563
+ * Value to persist on `screenshotUrl`: the storage URL, `null` when the
564
+ * upload fails (an inline fallback would bloat the database unnoticed
565
+ * during a storage outage), or the inline data URL without storage.
566
+ *
567
+ * @param dataUrl - Screenshot submitted with the feedback, if any.
568
+ * @param attempt - `feedbackId` is the id this create attempt will insert
569
+ * the row under — unique per attempt and never client-supplied — so
570
+ * racing attempts on one `clientId` never write the same object;
571
+ * `clientId` is only reported in logs.
572
+ */
573
+ async persistScreenshot(dataUrl, { feedbackId, clientId }) {
574
+ if (!dataUrl) return null;
575
+ if (this.screenshotStorage) {
576
+ try {
577
+ const { url } = await this.screenshotStorage.upload(dataUrl, {
578
+ feedbackId,
579
+ mimeType: screenshotMimeType(dataUrl)
580
+ });
581
+ return url;
582
+ } catch (error) {
583
+ this.logger.warn(
584
+ "[siteping] DrizzleStore: screenshotStorage.upload failed \u2014 feedback saved without screenshot",
585
+ { clientId, feedbackId, error }
586
+ );
587
+ return null;
588
+ }
589
+ }
590
+ if (!this.inlineScreenshotWarned) {
591
+ this.inlineScreenshotWarned = true;
592
+ this.logger.warn(
593
+ "[siteping] DrizzleStore: no screenshotStorage configured \u2014 screenshots are stored inline as base64. Configure a ScreenshotStorage for production.",
594
+ {}
595
+ );
596
+ }
597
+ return dataUrl;
598
+ }
599
+ /**
600
+ * Best-effort cleanup through `ScreenshotStorage.delete` of the screenshots
601
+ * no stored feedback references any more; inline data URLs were never
602
+ * uploaded and are skipped.
603
+ *
604
+ * The `ScreenshotStorage` contract makes every URL unique to the feedback id
605
+ * it was uploaded for, and each create attempt uploads under a fresh id, so
606
+ * a URL passed here belongs to one row only and no concurrent create can
607
+ * acquire it: deleting it after the reference check cannot race with a new
608
+ * reference. The check still runs because the owning row may exist after
609
+ * all — an insert that committed although the driver reported a failure —
610
+ * and, as defense in depth, it keeps objects a contract-breaking storage
611
+ * shares across rows (a concurrent create that reuses such a URL after the
612
+ * check is outside the contract and not covered). When the check itself
613
+ * fails, every object is kept (an orphan is acceptable, a row pointing at a
614
+ * deleted screenshot is not).
615
+ *
616
+ * Deletes run through a pool of at most {@link SCREENSHOT_DELETE_CONCURRENCY}
617
+ * concurrent calls, so a project delete freeing thousands of objects does not
618
+ * flood the storage. Failures are logged, never thrown. Each delete runs
619
+ * inside its own promise, so a hook that throws synchronously is settled like
620
+ * a rejection: it neither fails an already committed delete nor skips the
621
+ * remaining objects.
622
+ *
623
+ * @param urls - `screenshotUrl` values of removed rows or of a discarded upload.
624
+ * @param context - Identifiers added to the log lines (never payload data).
625
+ */
626
+ async discardScreenshots(urls, context = {}) {
627
+ const remove = this.screenshotStorage?.delete?.bind(this.screenshotStorage);
628
+ if (!remove) return;
629
+ const candidates = [...new Set(urls.filter(isUploadedScreenshotUrl))];
630
+ if (candidates.length === 0) return;
631
+ let referenced;
632
+ try {
633
+ referenced = await this.findReferencedScreenshotUrls(candidates);
634
+ } catch (lookupError) {
635
+ this.logger.warn(
636
+ `${DRIZZLE_STORE_MESSAGE_PREFIX}: screenshot references could not be checked \u2014 screenshots kept`,
637
+ { ...context, screenshotUrls: candidates, error: lookupError }
638
+ );
639
+ return;
640
+ }
641
+ const unreferenced = candidates.filter((url) => !referenced.has(url));
642
+ const results = await settleWithConcurrencyLimit(
643
+ unreferenced,
644
+ SCREENSHOT_DELETE_CONCURRENCY,
645
+ async (url) => remove(url)
646
+ );
647
+ results.forEach((result, index) => {
648
+ if (result.status === "rejected") {
649
+ this.logger.warn(`${DRIZZLE_STORE_MESSAGE_PREFIX}: screenshotStorage.delete failed \u2014 object left in place`, {
650
+ ...context,
651
+ screenshotUrl: unreferenced[index],
652
+ error: result.reason
653
+ });
654
+ }
655
+ });
656
+ }
657
+ /**
658
+ * The URLs among `screenshotUrls` that a stored row still references, looked
659
+ * up in batches of {@link SCREENSHOT_REFERENCE_LOOKUP_BATCH_SIZE}.
660
+ *
661
+ * @param screenshotUrls - Distinct uploaded screenshot URLs.
662
+ */
663
+ async findReferencedScreenshotUrls(screenshotUrls) {
664
+ const referenced = /* @__PURE__ */ new Set();
665
+ for (let start = 0; start < screenshotUrls.length; start += SCREENSHOT_REFERENCE_LOOKUP_BATCH_SIZE) {
666
+ const batch = screenshotUrls.slice(start, start + SCREENSHOT_REFERENCE_LOOKUP_BATCH_SIZE);
667
+ for (const url of await this.gateway.findReferencedScreenshotUrls(batch)) referenced.add(url);
668
+ }
669
+ return referenced;
670
+ }
671
+ };
672
+
673
+ // src/constants/search.ts
674
+ var LIKE_ESCAPE_CHARACTER = "\\";
675
+ var LIKE_SPECIAL_CHARACTERS = /[\\%_]/g;
676
+ var FOLDED_TEXT_LIKE_OPERATOR = "LIKE";
677
+ var CASE_INSENSITIVE_LIKE_OPERATOR = {
678
+ postgres: "ILIKE",
679
+ sqlite: "LIKE"
680
+ };
681
+
682
+ // src/constants/sql.ts
683
+ var GREATEST_VALUE_FUNCTION = {
684
+ postgres: "GREATEST",
685
+ sqlite: "MAX"
686
+ };
687
+ var INSERTED_FEEDBACK_CTE_ALIAS = "siteping_inserted_feedback";
688
+ var INSERTED_ANNOTATIONS_CTE_ALIAS = "siteping_inserted_annotations";
689
+ var VALUES_LIST_ALIAS = "siteping_values";
690
+ var POSTGRES_IDENTIFIER_MAX_BYTES = 63;
691
+
692
+ // src/shared/comments.ts
693
+ import { sql } from "drizzle-orm";
694
+ function guardedCommentInsert({ feedbacks, comments }, comment, maxComments) {
695
+ const inThread = sql`${comments.feedbackId} = ${comment.feedbackId}`;
696
+ const exists = sql`EXISTS (SELECT 1 FROM ${feedbacks} WHERE ${feedbacks.id} = ${comment.feedbackId})`;
697
+ return {
698
+ values: {
699
+ ...comment,
700
+ position: sql`(SELECT COALESCE(MAX(${comments.position}) + 1, 0) FROM ${comments} WHERE ${inThread})`
701
+ },
702
+ condition: comment.authorRole === "client" ? sql`${exists} AND (SELECT COUNT(*) FROM ${comments} WHERE ${inThread} AND ${comments.authorRole} = 'client') < ${maxComments}` : exists
703
+ };
704
+ }
705
+
706
+ // src/shared/deletes.ts
707
+ import { sql as sql2 } from "drizzle-orm";
708
+ function uploadedScreenshotUrl(screenshotUrl) {
709
+ return sql2`CASE WHEN ${screenshotUrl} LIKE ${INLINE_SCREENSHOT_URL_LIKE_PATTERN} THEN NULL ELSE ${screenshotUrl} END`;
710
+ }
711
+ function deletedFeedbackColumns(columns, { collectScreenshotUrls }) {
712
+ return collectScreenshotUrls ? { id: columns.id, screenshotUrl: uploadedScreenshotUrl(columns.screenshotUrl) } : { id: columns.id };
713
+ }
714
+ function toDeletedFeedbacks(rows) {
715
+ return {
716
+ deletedCount: rows.length,
717
+ screenshotUrls: rows.flatMap(
718
+ (row) => "screenshotUrl" in row ? [typeof row.screenshotUrl === "string" ? row.screenshotUrl : null] : []
719
+ )
720
+ };
721
+ }
722
+
723
+ // src/shared/feedbacks.ts
724
+ import { desc } from "drizzle-orm";
725
+ function feedbackRecordColumns(columns) {
726
+ const { creationSequence: _creationSequence, messageSearch: _messageSearch, ...recordColumns2 } = columns;
727
+ return recordColumns2;
728
+ }
729
+ function newestFeedbackFirst(createdAt, insertionOrder) {
730
+ return [desc(createdAt), desc(insertionOrder)];
731
+ }
732
+
733
+ // src/shared/filters.ts
734
+ import { and, eq, inArray, isNull, sql as sql3 } from "drizzle-orm";
735
+ function toContainsPattern(search) {
736
+ const escaped = search.replace(LIKE_SPECIAL_CHARACTERS, (character) => `${LIKE_ESCAPE_CHARACTER}${character}`);
737
+ return `%${escaped}%`;
738
+ }
739
+ function toSearchableText(text) {
740
+ return text.toLowerCase();
741
+ }
742
+ function containsCondition(target, likeOperator, search) {
743
+ return sql3`${target} ${sql3.raw(likeOperator)} ${toContainsPattern(search)} ESCAPE ${LIKE_ESCAPE_CHARACTER}`;
744
+ }
745
+ function withSearchableMessage(feedback) {
746
+ return { ...feedback, messageSearch: toSearchableText(feedback.message) };
747
+ }
748
+ function messageContainsCondition(columns, fallbackLikeOperator, search) {
749
+ const foldedSearch = toSearchableText(search);
750
+ const foldedMatch = containsCondition(columns.messageSearch, FOLDED_TEXT_LIKE_OPERATOR, foldedSearch);
751
+ const fallbackMatch = containsCondition(columns.message, fallbackLikeOperator, foldedSearch);
752
+ return sql3`(${foldedMatch} OR (${isNull(columns.messageSearch)} AND ${fallbackMatch}))`;
753
+ }
754
+ function buildFeedbackWhere(columns, filter, fallbackLikeOperator) {
755
+ const conditions = [eq(columns.projectName, filter.projectName)];
756
+ if (filter.type) conditions.push(eq(columns.type, filter.type));
757
+ if (filter.statuses) conditions.push(inArray(columns.status, [...filter.statuses]));
758
+ if (filter.url) conditions.push(eq(columns.url, filter.url));
759
+ if (filter.urlPattern) conditions.push(eq(columns.urlPattern, filter.urlPattern));
760
+ if (filter.search) conditions.push(messageContainsCondition(columns, fallbackLikeOperator, filter.search));
761
+ return and(...conditions);
762
+ }
763
+
764
+ // src/shared/rows.ts
765
+ import { getTableColumns, is, SQL, sql as sql4 } from "drizzle-orm";
766
+ var keepParamAsIs = (param) => param;
767
+ function recordColumns(columns) {
768
+ const { position: _position, ...record } = columns;
769
+ return record;
770
+ }
771
+ function selectValues(table, rows, condition, encodeParam = keepParamAsIs) {
772
+ const columns = Object.entries(getTableColumns(table));
773
+ const tuples = rows.map((row) => {
774
+ const values = columns.map(([key, column]) => {
775
+ const value = row[key];
776
+ return is(value, SQL) ? value : encodeParam(sql4`${sql4.param(value ?? null, column)}`, column);
777
+ });
778
+ return sql4`(${sql4.join(values, sql4`, `)})`;
779
+ });
780
+ return sql4`SELECT * FROM (VALUES ${sql4.join(tuples, sql4`, `)}) AS ${sql4.identifier(VALUES_LIST_ALIAS)} WHERE ${condition}`;
781
+ }
782
+
783
+ // src/shared/timestamps.ts
784
+ import { sql as sql5 } from "drizzle-orm";
785
+ function monotonicUpdatedAt(table, updatedAt, greatestFunction) {
786
+ return sql5`${sql5.raw(greatestFunction)}(${sql5.param(updatedAt, table.updatedAt)}, ${table.createdAt}, ${table.updatedAt})`;
787
+ }
788
+
789
+ export {
790
+ primaryOf,
791
+ StoreNotFoundError,
792
+ StoreDuplicateError,
793
+ StorePersistenceError,
794
+ StoreLimitError,
795
+ isStorePersistence,
796
+ DRIZZLE_STORE_MESSAGE_PREFIX,
797
+ DrizzleSitepingStore,
798
+ CASE_INSENSITIVE_LIKE_OPERATOR,
799
+ GREATEST_VALUE_FUNCTION,
800
+ INSERTED_FEEDBACK_CTE_ALIAS,
801
+ INSERTED_ANNOTATIONS_CTE_ALIAS,
802
+ POSTGRES_IDENTIFIER_MAX_BYTES,
803
+ guardedCommentInsert,
804
+ deletedFeedbackColumns,
805
+ toDeletedFeedbacks,
806
+ feedbackRecordColumns,
807
+ newestFeedbackFirst,
808
+ withSearchableMessage,
809
+ buildFeedbackWhere,
810
+ recordColumns,
811
+ selectValues,
812
+ monotonicUpdatedAt
813
+ };
814
+ //# sourceMappingURL=chunk-6G6UAERF.js.map