@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.
- package/README.md +40 -0
- package/dist/chunk-6G6UAERF.js +814 -0
- package/dist/chunk-6G6UAERF.js.map +1 -0
- package/dist/chunk-ICY2AW6S.js +11 -0
- package/dist/chunk-ICY2AW6S.js.map +1 -0
- package/dist/concurrency.d.cts +14 -0
- package/dist/concurrency.d.ts +14 -0
- package/dist/deep-link.d.cts +14 -0
- package/dist/deep-link.d.ts +14 -0
- package/dist/email.d.cts +22 -0
- package/dist/email.d.ts +22 -0
- package/dist/errors.d.cts +55 -0
- package/dist/errors.d.ts +55 -0
- package/dist/filters.d.cts +82 -0
- package/dist/filters.d.ts +82 -0
- package/dist/i18n.d.cts +85 -0
- package/dist/i18n.d.ts +85 -0
- package/dist/index.cjs +33 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/libsql.cjs +1045 -0
- package/dist/libsql.cjs.map +1 -0
- package/dist/libsql.d.cts +1015 -0
- package/dist/libsql.d.ts +1015 -0
- package/dist/libsql.js +254 -0
- package/dist/libsql.js.map +1 -0
- package/dist/pg.cjs +1079 -0
- package/dist/pg.cjs.map +1 -0
- package/dist/pg.d.cts +968 -0
- package/dist/pg.d.ts +968 -0
- package/dist/pg.js +298 -0
- package/dist/pg.js.map +1 -0
- package/dist/schema.d.cts +317 -0
- package/dist/schema.d.ts +317 -0
- package/dist/screenshot-storage.d.cts +103 -0
- package/dist/screenshot-storage.d.ts +103 -0
- package/dist/siteping-core.d.cts +20 -0
- package/dist/siteping-core.d.ts +20 -0
- package/dist/store-helpers.d.cts +140 -0
- package/dist/store-helpers.d.ts +140 -0
- package/dist/type-utils.d.cts +66 -0
- package/dist/type-utils.d.ts +66 -0
- package/dist/types.d.cts +1206 -0
- package/dist/types.d.ts +1206 -0
- package/dist/wire.d.cts +68 -0
- package/dist/wire.d.ts +68 -0
- 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
|