@beezping/adapter-memory 0.6.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 +37 -0
- package/dist/email.d.cts +19 -0
- package/dist/email.d.ts +19 -0
- package/dist/errors.d.cts +41 -0
- package/dist/errors.d.ts +41 -0
- package/dist/filters.d.cts +74 -0
- package/dist/filters.d.ts +74 -0
- package/dist/i18n.d.cts +67 -0
- package/dist/i18n.d.ts +67 -0
- package/dist/index.cjs +274 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +251 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.cts +263 -0
- package/dist/schema.d.ts +263 -0
- package/dist/screenshot-storage.d.cts +78 -0
- package/dist/screenshot-storage.d.ts +78 -0
- package/dist/siteping-core.d.cts +17 -0
- package/dist/siteping-core.d.ts +17 -0
- package/dist/store-helpers.d.cts +112 -0
- package/dist/store-helpers.d.ts +112 -0
- package/dist/type-utils.d.cts +58 -0
- package/dist/type-utils.d.ts +58 -0
- package/dist/types.d.cts +947 -0
- package/dist/types.d.ts +947 -0
- package/dist/wire.d.cts +35 -0
- package/dist/wire.d.ts +35 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// ../core/src/filters.ts
|
|
2
|
+
var DEFAULT_PAGE_LIMIT = 50;
|
|
3
|
+
var MAX_PAGE_LIMIT = 100;
|
|
4
|
+
function toPositiveInteger(value, fallback) {
|
|
5
|
+
return value !== void 0 && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
|
|
6
|
+
}
|
|
7
|
+
function clampPagination(query) {
|
|
8
|
+
const page = toPositiveInteger(query.page, 1);
|
|
9
|
+
const limit = Math.min(toPositiveInteger(query.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
|
|
10
|
+
return { page, limit, skip: (page - 1) * limit };
|
|
11
|
+
}
|
|
12
|
+
function isUnreachableOffset(skip) {
|
|
13
|
+
return !Number.isSafeInteger(skip);
|
|
14
|
+
}
|
|
15
|
+
function applyFeedbackFilters(items, query) {
|
|
16
|
+
let results = items.filter((f) => f.projectName === query.projectName);
|
|
17
|
+
if (query.type) results = results.filter((f) => f.type === query.type);
|
|
18
|
+
if (query.statuses && query.statuses.length > 0) {
|
|
19
|
+
const allowed = query.statuses;
|
|
20
|
+
results = results.filter((f) => allowed.includes(f.status));
|
|
21
|
+
} else if (query.status) {
|
|
22
|
+
results = results.filter((f) => f.status === query.status);
|
|
23
|
+
}
|
|
24
|
+
if (query.url) results = results.filter((f) => f.url === query.url);
|
|
25
|
+
if (query.urlPattern) results = results.filter((f) => f.urlPattern === query.urlPattern);
|
|
26
|
+
if (query.search) {
|
|
27
|
+
const s = query.search.toLowerCase();
|
|
28
|
+
results = results.filter((f) => f.message.toLowerCase().includes(s));
|
|
29
|
+
}
|
|
30
|
+
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
31
|
+
const total = results.length;
|
|
32
|
+
const { limit, skip } = clampPagination(query);
|
|
33
|
+
if (isUnreachableOffset(skip)) return { feedbacks: [], total };
|
|
34
|
+
return { feedbacks: results.slice(skip, skip + limit), total };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ../core/src/type-utils.ts
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return typeof value === "object" && value !== null;
|
|
40
|
+
}
|
|
41
|
+
function hasOwn(value, key) {
|
|
42
|
+
return isRecord(value) && key in value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ../core/src/types.ts
|
|
46
|
+
var StoreNotFoundError = class extends Error {
|
|
47
|
+
code = "STORE_NOT_FOUND";
|
|
48
|
+
constructor(message = "Record not found", options) {
|
|
49
|
+
super(message, options);
|
|
50
|
+
this.name = "StoreNotFoundError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var StoreDuplicateError = class extends Error {
|
|
54
|
+
code = "STORE_DUPLICATE";
|
|
55
|
+
constructor(message = "Duplicate record", options) {
|
|
56
|
+
super(message, options);
|
|
57
|
+
this.name = "StoreDuplicateError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var StorePersistenceError = class extends Error {
|
|
61
|
+
code = "STORE_PERSISTENCE";
|
|
62
|
+
constructor(message = "Failed to persist store mutation", options) {
|
|
63
|
+
super(message, options);
|
|
64
|
+
this.name = "StorePersistenceError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function hasErrorCode(error, code) {
|
|
68
|
+
return hasOwn(error, "code") && error.code === code;
|
|
69
|
+
}
|
|
70
|
+
function isStorePersistence(error) {
|
|
71
|
+
if (error instanceof StorePersistenceError) return true;
|
|
72
|
+
return hasErrorCode(error, "STORE_PERSISTENCE");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ../core/src/store-helpers.ts
|
|
76
|
+
function buildAnnotationRecord(input, ctx) {
|
|
77
|
+
return {
|
|
78
|
+
id: ctx.id,
|
|
79
|
+
feedbackId: ctx.feedbackId,
|
|
80
|
+
cssSelector: input.cssSelector,
|
|
81
|
+
xpath: input.xpath,
|
|
82
|
+
textSnippet: input.textSnippet,
|
|
83
|
+
elementTag: input.elementTag,
|
|
84
|
+
elementId: input.elementId ?? null,
|
|
85
|
+
textPrefix: input.textPrefix,
|
|
86
|
+
textSuffix: input.textSuffix,
|
|
87
|
+
fingerprint: input.fingerprint,
|
|
88
|
+
neighborText: input.neighborText,
|
|
89
|
+
anchorKey: input.anchorKey ?? null,
|
|
90
|
+
xPct: input.xPct,
|
|
91
|
+
yPct: input.yPct,
|
|
92
|
+
wPct: input.wPct,
|
|
93
|
+
hPct: input.hPct,
|
|
94
|
+
scrollX: input.scrollX,
|
|
95
|
+
scrollY: input.scrollY,
|
|
96
|
+
viewportW: input.viewportW,
|
|
97
|
+
viewportH: input.viewportH,
|
|
98
|
+
devicePixelRatio: input.devicePixelRatio,
|
|
99
|
+
createdAt: ctx.now
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function buildFeedbackRecord(input, ctx) {
|
|
103
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
104
|
+
return {
|
|
105
|
+
id: ctx.id,
|
|
106
|
+
type: input.type,
|
|
107
|
+
message: input.message,
|
|
108
|
+
status: input.status,
|
|
109
|
+
projectName: input.projectName,
|
|
110
|
+
url: input.url,
|
|
111
|
+
urlPattern: input.urlPattern ?? null,
|
|
112
|
+
authorName: input.authorName,
|
|
113
|
+
authorEmail: input.authorEmail,
|
|
114
|
+
viewport: input.viewport,
|
|
115
|
+
userAgent: input.userAgent,
|
|
116
|
+
clientId: input.clientId,
|
|
117
|
+
resolvedAt: null,
|
|
118
|
+
createdAt: now,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
annotations: input.annotations.map(
|
|
121
|
+
(ann) => buildAnnotationRecord(ann, { id: ctx.annotationId(), feedbackId: ctx.id, now })
|
|
122
|
+
),
|
|
123
|
+
screenshotUrl: input.screenshotDataUrl ?? null,
|
|
124
|
+
screenshotRegion: input.screenshotRegion ?? null,
|
|
125
|
+
diagnostics: input.diagnostics ?? null
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function createCollectionStore(backend) {
|
|
129
|
+
let mutationQueueTail = Promise.resolve();
|
|
130
|
+
const runSerializedMutation = (mutation) => {
|
|
131
|
+
const mutationResult = mutationQueueTail.then(mutation);
|
|
132
|
+
mutationQueueTail = mutationResult.then(
|
|
133
|
+
() => void 0,
|
|
134
|
+
() => void 0
|
|
135
|
+
);
|
|
136
|
+
return mutationResult;
|
|
137
|
+
};
|
|
138
|
+
const insertFeedbackIfAbsent = async (data) => {
|
|
139
|
+
const feedbacks = await backend.load();
|
|
140
|
+
const existing = feedbacks.find((f) => f.clientId === data.clientId);
|
|
141
|
+
if (existing) return { feedback: existing, created: false };
|
|
142
|
+
const record = buildFeedbackRecord(data, {
|
|
143
|
+
id: backend.generateId(),
|
|
144
|
+
annotationId: () => backend.generateId()
|
|
145
|
+
});
|
|
146
|
+
const next = [record, ...feedbacks];
|
|
147
|
+
try {
|
|
148
|
+
await backend.persist(next);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (!record.screenshotUrl) throw err;
|
|
151
|
+
record.screenshotUrl = null;
|
|
152
|
+
await backend.persist(next);
|
|
153
|
+
}
|
|
154
|
+
return { feedback: record, created: true };
|
|
155
|
+
};
|
|
156
|
+
const createFeedbackIfAbsent = (data) => runSerializedMutation(() => insertFeedbackIfAbsent(data));
|
|
157
|
+
return {
|
|
158
|
+
createFeedbackIfAbsent,
|
|
159
|
+
async createFeedback(data) {
|
|
160
|
+
return (await createFeedbackIfAbsent(data)).feedback;
|
|
161
|
+
},
|
|
162
|
+
async getFeedbacks(query) {
|
|
163
|
+
return applyFeedbackFilters(await backend.load(), query);
|
|
164
|
+
},
|
|
165
|
+
async findByClientId(clientId) {
|
|
166
|
+
return (await backend.load()).find((f) => f.clientId === clientId) ?? null;
|
|
167
|
+
},
|
|
168
|
+
updateFeedback(id, data) {
|
|
169
|
+
return runSerializedMutation(async () => {
|
|
170
|
+
const feedbacks = await backend.load();
|
|
171
|
+
const current = feedbacks.find((f) => f.id === id);
|
|
172
|
+
if (!current) throw new StoreNotFoundError();
|
|
173
|
+
const updated = {
|
|
174
|
+
...current,
|
|
175
|
+
status: data.status,
|
|
176
|
+
resolvedAt: data.resolvedAt,
|
|
177
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
178
|
+
};
|
|
179
|
+
await backend.persist(feedbacks.map((f) => f === current ? updated : f));
|
|
180
|
+
return updated;
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
deleteFeedback(id) {
|
|
184
|
+
return runSerializedMutation(async () => {
|
|
185
|
+
const feedbacks = await backend.load();
|
|
186
|
+
if (!feedbacks.some((f) => f.id === id)) throw new StoreNotFoundError();
|
|
187
|
+
await backend.persist(feedbacks.filter((f) => f.id !== id));
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
deleteAllFeedbacks(projectName) {
|
|
191
|
+
return runSerializedMutation(async () => {
|
|
192
|
+
const feedbacks = await backend.load();
|
|
193
|
+
await backend.persist(feedbacks.filter((f) => f.projectName !== projectName));
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
async verifyProjectOwnership(id, projectName) {
|
|
197
|
+
const fb = (await backend.load()).find((f) => f.id === id);
|
|
198
|
+
return fb !== void 0 && fb.projectName === projectName;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/index.ts
|
|
204
|
+
var MemoryStore = class {
|
|
205
|
+
feedbacks = [];
|
|
206
|
+
idCounter = 1;
|
|
207
|
+
engine = createCollectionStore({
|
|
208
|
+
load: () => this.feedbacks,
|
|
209
|
+
persist: (next) => {
|
|
210
|
+
this.feedbacks = next;
|
|
211
|
+
},
|
|
212
|
+
generateId: () => `mem-${this.idCounter++}-${Date.now().toString(36)}`
|
|
213
|
+
});
|
|
214
|
+
createFeedback(data) {
|
|
215
|
+
return this.engine.createFeedback(data);
|
|
216
|
+
}
|
|
217
|
+
createFeedbackIfAbsent(data) {
|
|
218
|
+
return this.engine.createFeedbackIfAbsent(data);
|
|
219
|
+
}
|
|
220
|
+
getFeedbacks(query) {
|
|
221
|
+
return this.engine.getFeedbacks(query);
|
|
222
|
+
}
|
|
223
|
+
findByClientId(clientId) {
|
|
224
|
+
return this.engine.findByClientId(clientId);
|
|
225
|
+
}
|
|
226
|
+
updateFeedback(id, data) {
|
|
227
|
+
return this.engine.updateFeedback(id, data);
|
|
228
|
+
}
|
|
229
|
+
deleteFeedback(id) {
|
|
230
|
+
return this.engine.deleteFeedback(id);
|
|
231
|
+
}
|
|
232
|
+
deleteAllFeedbacks(projectName) {
|
|
233
|
+
return this.engine.deleteAllFeedbacks(projectName);
|
|
234
|
+
}
|
|
235
|
+
verifyProjectOwnership(id, projectName) {
|
|
236
|
+
return this.engine.verifyProjectOwnership(id, projectName);
|
|
237
|
+
}
|
|
238
|
+
/** Remove all data from this store instance. */
|
|
239
|
+
clear() {
|
|
240
|
+
this.feedbacks = [];
|
|
241
|
+
this.idCounter = 1;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
export {
|
|
245
|
+
MemoryStore,
|
|
246
|
+
StoreDuplicateError,
|
|
247
|
+
StoreNotFoundError,
|
|
248
|
+
StorePersistenceError,
|
|
249
|
+
isStorePersistence
|
|
250
|
+
};
|
|
251
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../core/src/filters.ts","../../core/src/type-utils.ts","../../core/src/types.ts","../../core/src/store-helpers.ts","../src/index.ts"],"sourcesContent":["/**\n * Shared feedback-record filtering and pagination — extracted from\n * `adapter-memory` and `adapter-localstorage` which previously kept two\n * near-identical copies of the same logic. Any adapter that holds an\n * in-memory snapshot of feedbacks can use it.\n *\n * Filtering order matches the historical adapter behaviour:\n * 1. projectName (always required)\n * 2. type\n * 3. status / statuses (`statuses` bucket wins when both are set)\n * 4. url\n * 5. urlPattern\n * 6. search (lowercase substring match on `message`)\n *\n * Pagination goes through `clampPagination`: `limit` capped at 100, `page`\n * 1-based, both clamped up to 1 rather than indexing backwards from the end\n * of the match set. Query adapters reuse the same helper so every store\n * paginates identically.\n */\n\nimport type { FeedbackQuery, FeedbackRecord } from \"./types.js\";\n\n/** Default page size when the caller omits `query.limit`. */\nexport const DEFAULT_PAGE_LIMIT = 50;\n/** Maximum allowed page size — defends against memory blow-ups on hostile callers. */\nexport const MAX_PAGE_LIMIT = 100;\n\nexport interface FilterResult {\n feedbacks: FeedbackRecord[];\n total: number;\n}\n\n/** Normalised pagination window — see {@link clampPagination}. */\nexport interface Pagination {\n /** 1-based page number, at least 1. */\n page: number;\n /** Page size in `[1, MAX_PAGE_LIMIT]`. */\n limit: number;\n /** Offset of the first row: `(page - 1) * limit`. */\n skip: number;\n}\n\nfunction toPositiveInteger(value: number | undefined, fallback: number): number {\n return value !== undefined && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;\n}\n\n/**\n * Normalise `page` / `limit` to the store contract: `page` is 1-based and\n * clamped up to 1, `limit` defaults to 50 and is clamped into `[1, 100]`,\n * non-finite values fall back to the defaults. `skip` is the derived row\n * offset for query backends (`OFFSET`, Prisma `skip`).\n *\n * Shared by the in-memory pipeline and query adapters (`PrismaStore`) so\n * every store paginates identically — the HTTP schema clamps the same way,\n * but direct callers (dashboard store mode, server actions) reach the store\n * without a schema in front of them.\n */\nexport function clampPagination(query: Pick<FeedbackQuery, \"page\" | \"limit\">): Pagination {\n const page = toPositiveInteger(query.page, 1);\n const limit = Math.min(toPositiveInteger(query.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);\n return { page, limit, skip: (page - 1) * limit };\n}\n\n/**\n * Whether a {@link clampPagination} offset lies beyond any row a store can\n * hold. `clampPagination` bounds `page` from below only, so a direct caller's\n * huge `page` yields an offset past `Number.MAX_SAFE_INTEGER` — or `Infinity`\n * — that SQL backends reject (`OFFSET` is a 64-bit integer in PostgreSQL and\n * SQLite; Prisma's `skip` rejects non-integers and 64-bit overflow).\n *\n * Every safe integer fits a signed 64-bit offset and no table holds more rows\n * than that, so query adapters answer such a page as empty — with the real\n * `total` — instead of issuing the query: the same result the in-memory\n * pipeline returns.\n *\n * @param skip - The `skip` returned by {@link clampPagination}.\n * @returns `true` when no row can sit at that offset.\n */\nexport function isUnreachableOffset(skip: number): boolean {\n return !Number.isSafeInteger(skip);\n}\n\n/**\n * Apply the standard feedback filter + pagination pipeline against an\n * in-memory snapshot. Used by `MemoryStore.getFeedbacks` and\n * `LocalStorageStore.getFeedbacks` so the two never drift.\n *\n * @param items All known feedback records (already include `annotations`).\n * @param query Filter and pagination options. `projectName` is required.\n */\nexport function applyFeedbackFilters(items: readonly FeedbackRecord[], query: FeedbackQuery): FilterResult {\n let results: FeedbackRecord[] = items.filter((f) => f.projectName === query.projectName);\n\n if (query.type) results = results.filter((f) => f.type === query.type);\n // `statuses` (bucket / any-of) wins over the exact `status` filter when both\n // are present; an empty array is treated as absent.\n if (query.statuses && query.statuses.length > 0) {\n const allowed = query.statuses;\n results = results.filter((f) => allowed.includes(f.status));\n } else if (query.status) {\n results = results.filter((f) => f.status === query.status);\n }\n if (query.url) results = results.filter((f) => f.url === query.url);\n if (query.urlPattern) results = results.filter((f) => f.urlPattern === query.urlPattern);\n if (query.search) {\n const s = query.search.toLowerCase();\n results = results.filter((f) => f.message.toLowerCase().includes(s));\n }\n\n // Newest first is part of the store contract (PrismaStore orders by\n // createdAt desc) — sort explicitly instead of relying on insertion order.\n // Array.prototype.sort is stable, so same-millisecond records keep their\n // insertion order (newest inserted first).\n results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());\n\n const total = results.length;\n // Both bounds are clamped (see `clampPagination`): `(page - 1) * limit`\n // goes negative for a non-positive page or limit, and `slice` reads\n // negative indices from the END — so `page: -1` used to return a window\n // whose position depended on how many records happened to match.\n const { limit, skip } = clampPagination(query);\n if (isUnreachableOffset(skip)) return { feedbacks: [], total };\n\n return { feedbacks: results.slice(skip, skip + limit), total };\n}\n","/**\n * General-purpose TypeScript utility types used across `@beezping/*`.\n *\n * These are kept dependency-free and re-exported from the package entry\n * so adapters and integrators can rely on the same primitives the core\n * uses internally.\n */\n\n/**\n * Force TypeScript to expand a computed type into a flat object literal in\n * tooltips and error messages. Purely cosmetic — same structural type, just\n * easier to read.\n *\n * @example\n * type Raw = Omit<FeedbackRecord, \"annotations\"> & { annotations: number };\n * type Pretty = Prettify<Raw>; // displayed as a flat object\n */\nexport type Prettify<T> = { [K in keyof T]: T[K] } & {};\n\n/**\n * Returns `Y` when `A` is exactly assignable to `B` and vice-versa,\n * otherwise `N`. Powers compile-time equality assertions.\n */\nexport type IfEquals<A, B, Y = true, N = false> =\n (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? Y : N;\n\n/**\n * Compile-time exact-type guard — resolves to `true` when `Actual` and\n * `Expected` are identical, `never` otherwise. Assign the result to a\n * `const _lock: AssertEqual<A, B> = true;` so any drift becomes a compile\n * error at the declaration site.\n */\nexport type AssertEqual<Actual, Expected> = IfEquals<Actual, Expected, true, never>;\n\n/**\n * JSON-serialized shape of `T` — the wire form produced by `Response.json()`\n * / `JSON.stringify`: `Date` becomes ISO `string` (nullability preserved),\n * arrays are serialized element-wise, everything else is untouched.\n *\n * Used to derive the `*Response` API types from the `*Record` store types so\n * the two can never drift: add a field to `FeedbackRecord` and\n * `FeedbackResponse` follows automatically.\n */\nexport type Serialized<T> = {\n [K in keyof T]: T[K] extends Date\n ? string\n : T[K] extends Date | null\n ? string | null\n : T[K] extends (infer U)[]\n ? Serialized<U>[]\n : T[K];\n};\n\n/**\n * Type guard that narrows `value` to a non-null `Record<PropertyKey, unknown>`.\n * Useful when validating arbitrary inputs before reading fields.\n */\nexport function isRecord(value: unknown): value is Record<PropertyKey, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\n/**\n * Returns true when `value` is an object that exposes the requested key.\n * Type-narrows `value` so the property can be accessed without further\n * casting — a strictly typed replacement for `\"k\" in obj`.\n *\n * Named after the standardised `Object.hasOwn` helper rather than the\n * legacy `Object.prototype.hasOwnProperty`, which the linter forbids\n * shadowing.\n */\nexport function hasOwn<K extends PropertyKey>(value: unknown, key: K): value is Record<K, unknown> {\n return isRecord(value) && key in value;\n}\n","import { type AssertEqual, hasOwn, type Prettify, type Serialized } from \"./type-utils.js\";\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\n/** FAB anchor — bottom-corner placement supported by the widget. */\nexport type SitepingPosition = \"bottom-right\" | \"bottom-left\";\n\n/** Visual theme — `auto` resolves to `light` or `dark` via system preference. */\nexport type SitepingTheme = \"light\" | \"dark\" | \"auto\";\n\n/** Built-in UI locales shipped with the widget. */\nexport const BUILTIN_LOCALES = [\"en\", \"fr\", \"de\", \"es\", \"it\", \"pt\", \"ru\"] as const;\nexport type BuiltinLocale = (typeof BUILTIN_LOCALES)[number];\n\n/**\n * Locale identifier accepted by the widget. Built-in locales are kept as\n * literal strings so editors auto-complete them, but arbitrary BCP-47 tags\n * are also accepted (custom dictionaries registered via `registerLocale`).\n */\nexport type SitepingLocale = BuiltinLocale | (string & {});\n\n/**\n * Reasons reported through `SitepingConfig.onSkip` — production environment,\n * mobile viewport, or server-side rendering (no `window`/`document`).\n */\nexport type SitepingSkipReason = \"production\" | \"mobile\" | \"ssr\";\n\n/** Per-channel + per-buffer-size diagnostics configuration. */\nexport interface DiagnosticsCaptureOptions {\n console?: boolean | undefined;\n network?: boolean | undefined;\n maxConsoleEntries?: number | undefined;\n maxNetworkEntries?: number | undefined;\n}\n\n/** Identity payload supplied by the host application — bypasses the modal. */\nexport interface SitepingIdentity {\n name: string;\n email: string;\n}\n\n/** Deep-link configuration — controls how a feedback id is read from the URL. */\nexport interface SitepingDeepLinkOptions {\n /** Query parameter name carrying the feedback id. Defaults to `\"siteping\"`. */\n param?: string | undefined;\n}\n\n/**\n * Extra request headers for HTTP mode — a static map, or a factory (sync or\n * async) invoked once per request to produce fresh values (e.g. a short-lived\n * session token).\n */\nexport type SitepingHeadersOption =\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n\n/**\n * Cookie policy for HTTP-mode requests — forwarded verbatim as the\n * `credentials` option of every `fetch` the widget (or the dashboard's\n * endpoint source) makes. Mirrors the DOM `RequestCredentials` union,\n * declared here so core stays free of DOM lib types.\n *\n * - `\"same-origin\"` (default): cookies only when the endpoint shares the\n * page's origin — the browser's own default.\n * - `\"include\"`: also send cookies to a cross-origin endpoint. Required when a\n * server on another origin authenticates with a session cookie; the server\n * must answer with credentialed CORS (the page's exact origin plus\n * `Access-Control-Allow-Credentials: true`, e.g. `@beezping/server`'s\n * `allowedOrigins`).\n * - `\"omit\"`: never send cookies, even same-origin.\n */\nexport type SitepingRequestCredentials = \"omit\" | \"same-origin\" | \"include\";\n\n/** Every accepted {@link SitepingRequestCredentials} value — runtime guard source for untyped (script-tag) consumers. */\nexport const REQUEST_CREDENTIALS_MODES = [\n \"omit\",\n \"same-origin\",\n \"include\",\n] as const satisfies readonly SitepingRequestCredentials[];\n\n/**\n * Credentials mode used when none is configured — the browser's own `fetch`\n * default, so leaving the option unset never changes cookie behavior (and\n * never opts a cross-origin endpoint into cookie-carrying, CSRF-prone requests).\n */\nexport const DEFAULT_REQUEST_CREDENTIALS = \"same-origin\" satisfies SitepingRequestCredentials;\n\n/**\n * Narrow an untyped config value to a {@link SitepingRequestCredentials} mode.\n *\n * @param value - Raw `credentials` option (may come from an untyped script-tag config).\n * @returns `true` when `value` is one of {@link REQUEST_CREDENTIALS_MODES}.\n */\nexport function isRequestCredentials(value: unknown): value is SitepingRequestCredentials {\n return typeof value === \"string\" && (REQUEST_CREDENTIALS_MODES as readonly string[]).includes(value);\n}\n\n/**\n * Actionable description of a rejected `credentials` option — shared by the\n * widget init guard and the dashboard's endpoint source so both report the\n * same wording.\n *\n * @param value - The rejected raw value (a config value, never user content).\n * @returns e.g. `invalid \\`credentials\\` \"all\". Expected one of \"omit\", \"same-origin\", \"include\".`\n */\nexport function describeInvalidRequestCredentials(value: unknown): string {\n const accepted = REQUEST_CREDENTIALS_MODES.map((mode) => `\"${mode}\"`).join(\", \");\n return `invalid \\`credentials\\` ${String(JSON.stringify(value))}. Expected one of ${accepted}.`;\n}\n\n/**\n * Options shared by both widget modes (HTTP and direct store).\n *\n * Do not use this type directly — use {@link SitepingConfig}, the\n * discriminated union that adds the mode-specific fields.\n */\nexport interface SitepingBaseConfig {\n /** Required — project identifier used to scope feedbacks */\n projectName: string;\n /** FAB position — defaults to 'bottom-right' */\n position?: SitepingPosition | undefined;\n /**\n * Show the \"toggle markers visibility\" item in the FAB radial menu.\n * Defaults to `true` (current behavior). Set to `false` to hide that\n * item entirely — useful for hosts that always want markers visible\n * (e.g. dedicated review tools) or that find the eye icon redundant\n * when no marker is on screen.\n *\n * Hiding the item also removes its keyboard navigation slot — the\n * remaining two items still respond to ArrowUp/ArrowDown/Home/End.\n * The marker-visibility state itself is unaffected; markers stay\n * visible (the previous default state) and `annotations:toggle` is\n * simply never emitted from the FAB.\n */\n showAnnotationsToggle?: boolean | undefined;\n /** Accent color for the widget UI — defaults to '#0066ff' */\n accentColor?: string | undefined;\n /**\n * Render the widget even when it would normally be skipped — this bypasses\n * BOTH the production-environment guard AND the mobile-viewport guard.\n * It does NOT bypass the SSR guard: without `window`/`document` the widget\n * never renders and `onSkip(\"ssr\")` fires instead.\n * Defaults to false. Use it for dedicated review tools, staging environments,\n * or responsive testing where you always want the widget present.\n */\n forceShow?: boolean | undefined;\n /**\n * Minimum viewport width (px) at or above which the widget renders. Below it,\n * the widget is skipped and `onSkip(\"mobile\")` fires. Defaults to `768`.\n *\n * Set lower (e.g. `0`) to allow narrow/mobile viewports, or use `forceShow`\n * to bypass the viewport check entirely.\n */\n minViewportWidth?: number | undefined;\n /** Enable debug logging of lifecycle events — defaults to false */\n debug?: boolean | undefined;\n /** Color theme — defaults to 'light' */\n theme?: SitepingTheme | undefined;\n /** UI locale — defaults to 'en'. Built-in: en, fr, de, es, it, pt (Brazilian), ru. Any other string falls back to English. */\n locale?: SitepingLocale | undefined;\n /**\n * Returns the current page scope for annotations and panel filtering.\n * Called on initial markers load and on `instance.refresh()`.\n *\n * Default: `{ url: window.location.pathname, urlPattern: null }` — annotations\n * are scoped strictly to the current pathname.\n *\n * Apps with parameterized routes (e.g. React Router) should return both the\n * concrete URL and the route template (e.g. `/orders/:orderId`) so the panel\n * can offer a \"this type of page\" filter that groups feedbacks by template.\n */\n getPageScope?: (() => PageScope) | undefined;\n /**\n * When true (default), the widget filters initial markers and panel results\n * by `feedback.url === scope.url`, so annotations created on one page never\n * leak to other pages — even if their CSS selector accidentally matches.\n * Set to `false` to revert to the legacy project-wide behavior.\n */\n scopeAnnotationsByUrl?: boolean | undefined;\n /**\n * Capture a JPEG screenshot of the annotated area on submit. Defaults to\n * `false` — opt-in because:\n *\n * - it adds runtime weight (~60 KB gzip dynamic chunk for html2canvas-pro,\n * loaded only on first capture),\n * - it embeds page content in the feedback (privacy/GDPR consideration —\n * inform end users in your widget host UI when enabling).\n *\n * `html2canvas-pro` ships as a regular dependency of `@beezping/widget` so the\n * dynamic import always resolves; you don't need to install anything extra.\n *\n * **Masking sensitive elements:** add `data-siteping-ignore=\"true\"` to any\n * element you do NOT want captured (password fields, credit-card forms,\n * API tokens shown in the UI, etc.). The capture predicate skips matching\n * elements *and their descendants*. Do this BEFORE turning on screenshots\n * in production — once a feedback is saved, the screenshot is in your DB\n * (or object storage) regardless of what was on the page.\n */\n enableScreenshot?: boolean | undefined;\n /**\n * Enable right-click (`contextmenu`) to instantly open the comment composer\n * at the cursor location. When enabled, a document-level listener intercepts\n * right-clicks, prevents the browser's native context menu, and enters the\n * annotation flow anchored to the element under the cursor. Defaults to\n * `false` — the browser's native context menu is never hijacked unless the\n * host explicitly opts in.\n *\n * Keyboard-triggered context menus (≣ Menu key, Shift+F10) always get the\n * native menu; only mouse right-click and touch/pen long-press open the\n * composer.\n *\n * **Modifier-key escape hatch:** holding Shift, Ctrl, Alt, or Meta while\n * right-clicking always falls through to the native context menu, giving\n * users (and devtools) an escape hatch regardless of this setting.\n *\n * Right-clicks on SitePing's own UI (FAB, panel, markers, popup) are\n * ignored — the native menu is shown as expected.\n *\n * Note: on Android, `contextmenu` fires on long-press. The widget already\n * hides below `minViewportWidth` (default 768 px), but tablets above that\n * threshold will trigger this flow on long-press.\n */\n enableRightClickComment?: boolean | undefined;\n /**\n * Capture the last few `console.*` calls and failed network requests\n * (HTTP >= 400 or network error) at the moment a feedback is submitted.\n *\n * Lets reviewers replay the technical context that led to the report —\n * stack traces, 500 responses, dead third-party scripts. Great for the\n * \"the page just doesn't work\" feedback that contains zero detail.\n *\n * - `true` — capture with defaults (50 console / 20 network entries).\n * - `false` (default) — no capture, no monkey-patching.\n * - object — per-channel toggles + custom buffer sizes.\n *\n * **Privacy considerations:** console messages may contain anything the\n * host page logs, including user data. Failed network requests record the\n * URL (with query string) but never the response body. Inform end users\n * before enabling in environments where they might log sensitive values.\n */\n captureDiagnostics?: boolean | DiagnosticsCaptureOptions | undefined;\n /** Called when the widget is skipped (production mode, mobile viewport, SSR — no DOM) */\n onSkip?: (reason: SitepingSkipReason) => void;\n /**\n * Auto-focus a specific annotation when its ID appears in the URL query\n * string. Lets hosts deeplink directly into a feedback from external\n * systems (Zammad tickets, Slack notifications, dashboard rows).\n *\n * When enabled, the widget reads the configured query parameter from\n * `window.location.search` right after the initial markers load. If the\n * value matches a visible feedback ID, the widget scrolls the annotation\n * into view, pins its highlight, and pulses the marker — the same visual\n * affordance a marker click produces.\n *\n * - `false` / `undefined` (default): no URL parsing. Existing behavior\n * unchanged, no host URL inspection.\n * - `true`: enabled with default query parameter name `siteping`.\n * - object: enabled with a custom parameter name. Use this to avoid\n * clashes with host-app query keys.\n *\n * Only the initial load triggers focus. Subsequent URL changes (SPA\n * navigation, `history.pushState`, hash updates) are ignored —\n * deliberate, to avoid surprising re-scrolls during normal browsing.\n * Hosts that need re-focus on route change can call\n * `instance.focusFeedback(id)` explicitly.\n */\n deepLink?: boolean | SitepingDeepLinkOptions | undefined;\n /**\n * Automatically re-fetch feedbacks when the page changes during client-side\n * (SPA) navigation. Enabled by default.\n *\n * The widget is normally mounted once (singleton) inside a persistent layout\n * — e.g. a Next.js App Router `layout.tsx`, which does NOT remount on\n * client-side navigation. Without this, init runs a single time and both the\n * panel list and the page markers stay frozen on the page where the widget\n * first mounted. With it on, the widget patches the History API\n * (`pushState`/`replaceState`, which SPA routers call instead of triggering\n * `popstate`) and listens for `popstate`/`hashchange`, then re-fetches when\n * the scope key (`getPageScope().url` + template) actually changes.\n *\n * This re-fetches data only — it deliberately does NOT re-focus or re-scroll\n * to an annotation (deep-link focus stays initial-load only; see `deepLink`),\n * so normal browsing is never interrupted by a surprise scroll.\n *\n * - `true` (default) — watch navigation and re-fetch on route change.\n * - `false` — never touch the History API; hosts drive updates manually via\n * `instance.refresh()`.\n */\n watchNavigation?: boolean | undefined;\n /**\n * Pre-fill author identity from the host application — typically the\n * currently signed-in user. When set, the widget uses these values\n * directly and never shows the identity modal, even on first feedback.\n *\n * Use case: SSO-integrated apps where the end user is already\n * authenticated by the host. Avoids the awkward \"enter your name and\n * email\" prompt for users the host already knows.\n *\n * When unset (default), the widget falls back to localStorage and shows\n * the modal on first feedback as before — existing behavior unchanged.\n *\n * Note: `config.identity` is **not** persisted to localStorage. It is\n * read at widget init time, not on every render. Hosts that need live\n * identity updates after sign-in/sign-out should currently remount the\n * widget (e.g. via a React `key` on the wrapping component). See\n * https://github.com/NeosiaNexus/SitePing/issues/85 for tracking a\n * future enhancement that propagates identity updates without a remount.\n */\n identity?: SitepingIdentity | undefined;\n\n // Events\n /** Called when the feedback panel is opened. */\n onOpen?: (() => void) | undefined;\n /** Called when the feedback panel is closed. */\n onClose?: (() => void) | undefined;\n /** Called after a feedback is successfully submitted. */\n onFeedbackSent?: ((feedback: FeedbackResponse) => void) | undefined;\n /**\n * Called when a feedback API call fails.\n *\n * The widget always emits a `SitepingError` (or a subclass:\n * `SitepingNetworkError`, `SitepingValidationError`, `SitepingAuthError`)\n * for HTTP-mode failures — host apps can `instanceof` to drive retry\n * logic, or read `error.code` (`\"NETWORK\" | \"VALIDATION\" | \"AUTH\" |\n * \"SERVER\"`) and `error.retryable`. The type is widened to `Error` so\n * direct-store callers can still surface raw errors without breaking the\n * contract.\n */\n onError?: ((error: Error) => void) | undefined;\n /** Called when the user starts drawing an annotation. */\n onAnnotationStart?: (() => void) | undefined;\n /** Called when the user finishes drawing an annotation. */\n onAnnotationEnd?: (() => void) | undefined;\n}\n\n/**\n * HTTP mode — the widget talks to a server endpoint backed by a store\n * adapter (e.g. `@beezping/adapter-prisma` request handlers).\n */\nexport interface SitepingHttpConfig extends SitepingBaseConfig {\n /** HTTP endpoint that receives feedbacks (e.g. '/api/siteping'). */\n endpoint: string;\n /**\n * Convenience auth for HTTP mode — sent as `Authorization: Bearer <apiKey>`\n * on every request to `endpoint`.\n *\n * **WARNING: the widget runs in every visitor's browser, so a static key\n * configured here is public** — anyone can read it from your page source\n * and replay it against your API. Only use `apiKey` for internal tools\n * already behind your own login. On public sites, prefer `headers` with a\n * per-request factory returning a short-lived session token.\n */\n apiKey?: string | undefined;\n /**\n * Extra headers for every HTTP-mode request — a static map, or a factory\n * (sync or async) called once per request (e.g. to fetch a fresh session\n * token). Merged over the widget's generated headers, so an explicit\n * `Authorization` entry overrides `apiKey`. A throwing/rejecting factory\n * fails the request like a network error.\n */\n headers?: SitepingHeadersOption | undefined;\n /**\n * Cookie policy for every HTTP-mode request (submit, list, update, delete\n * and the offline retry-queue replay). Defaults to `\"same-origin\"` — the\n * browser default, so existing setups are unchanged.\n *\n * Set `\"include\"` when `endpoint` lives on **another origin** and the\n * server authenticates with a session cookie (e.g. a custom\n * `access.authenticate` in `@beezping/server`): without it the browser\n * never attaches the cookie and every request is rejected as\n * unauthenticated. The server must allow the page's origin explicitly with\n * credentialed CORS (`allowedOrigins`) — a wildcard origin never works\n * with cookies. Only opt in for origins you trust: cookie-authenticated\n * cross-origin requests rely on the server's CSRF defenses (strict origin\n * allowlist, `SameSite` cookies).\n *\n * An unknown value (untyped script-tag config) is rejected at init: the\n * widget logs an error and does not load.\n */\n credentials?: SitepingRequestCredentials | undefined;\n /** Not available in HTTP mode — use either `endpoint` or `store`, never both. */\n store?: never;\n}\n\n/**\n * Store mode — the widget talks to a `SitepingStore` directly in the\n * browser, no server needed (demos, prototypes, localStorage persistence).\n */\nexport interface SitepingStoreConfig extends SitepingBaseConfig {\n /** Direct store for client-side mode. Bypasses HTTP entirely. */\n store: SitepingStore;\n /** Not available in store mode — use either `endpoint` or `store`, never both. */\n endpoint?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n apiKey?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n headers?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n credentials?: never;\n}\n\n/**\n * Configuration options for the Siteping widget.\n *\n * A discriminated union over the two transport modes: pass `endpoint`\n * (HTTP mode, optionally with `apiKey`/`headers`/`credentials`) **or** `store` (direct\n * client-side mode) — never both, never neither. Invalid combinations are\n * compile errors instead of runtime warnings.\n */\nexport type SitepingConfig = SitepingHttpConfig | SitepingStoreConfig;\n\n/** Instance returned by initSiteping() with lifecycle methods. */\nexport interface SitepingInstance {\n /** Remove the widget from the DOM and clean up all listeners. */\n destroy: () => void;\n /** Open the panel programmatically */\n open: () => void;\n /** Close the panel */\n close: () => void;\n /** Reload feedbacks from server */\n refresh: () => void;\n /**\n * Scroll the matching annotation into view, pin its highlight, and\n * pulse its marker. Returns `true` when a visible feedback matched the\n * given ID, `false` otherwise (unknown ID, feedback on another URL when\n * `scopeAnnotationsByUrl` filtered it out, or markers not yet loaded).\n *\n * Counterpart to the `deepLink` config option for hosts that prefer to\n * drive focus from JS (e.g., a notification click handler) instead of a\n * URL query parameter.\n */\n focusFeedback: (feedbackId: string) => boolean;\n /** Subscribe to a public widget event */\n on: <K extends keyof SitepingPublicEvents>(event: K, listener: SitepingPublicEventListener<K>) => SitepingUnsubscribe;\n /** Unsubscribe from a public widget event */\n off: <K extends keyof SitepingPublicEvents>(event: K, listener: SitepingPublicEventListener<K>) => void;\n}\n\n/** Listener signature for a single `SitepingPublicEvents` key. */\nexport type SitepingPublicEventListener<K extends keyof SitepingPublicEvents> = (\n ...args: SitepingPublicEvents[K]\n) => void;\n\n/** Disposer returned by `SitepingInstance.on` — call once to detach the listener. */\nexport type SitepingUnsubscribe = () => void;\n\n/** Events exposed to consumers via SitepingInstance.on / .off */\nexport interface SitepingPublicEvents {\n \"feedback:sent\": [FeedbackResponse];\n \"feedback:deleted\": [FeedbackResponse[\"id\"]];\n /**\n * A feedback API call failed. Same payload contract as\n * `SitepingConfig.onError` — a `SitepingError` subclass in HTTP mode,\n * possibly a raw `Error` in store mode.\n */\n \"feedback:error\": [Error];\n \"panel:open\": [];\n \"panel:close\": [];\n /** The user started drawing an annotation. */\n \"annotation:start\": [];\n /** The user finished drawing an annotation. */\n \"annotation:end\": [];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback\n// ---------------------------------------------------------------------------\n\n/** Single source of truth for feedback types — used by both TS types and Zod schemas. */\nexport const FEEDBACK_TYPES = [\"question\", \"change\", \"bug\", \"other\"] as const;\nexport type FeedbackType = (typeof FEEDBACK_TYPES)[number];\n\n/** Single source of truth for feedback statuses. */\nexport const FEEDBACK_STATUSES = [\"open\", \"in_progress\", \"resolved\", \"wont_fix\"] as const;\nexport type FeedbackStatus = (typeof FEEDBACK_STATUSES)[number];\n\n/**\n * Terminal statuses — the feedback needs no further action. `resolvedAt` is\n * the closure timestamp: set when a feedback enters a closed status, null\n * while it is open or in progress. The derivation happens at the edge (HTTP\n * handler, dashboard) — store adapters persist whatever they are given.\n */\nexport const CLOSED_FEEDBACK_STATUSES = [\"resolved\", \"wont_fix\"] as const;\n/** A terminal status — `resolved` or `wont_fix`. */\nexport type ClosedFeedbackStatus = (typeof CLOSED_FEEDBACK_STATUSES)[number];\n\n/** Non-terminal statuses — the feedback still needs attention. */\nexport const OPEN_FEEDBACK_STATUSES = [\"open\", \"in_progress\"] as const;\n/** A non-terminal status — `open` or `in_progress`. */\nexport type OpenFeedbackStatus = (typeof OPEN_FEEDBACK_STATUSES)[number];\n\n// Adding a fifth status without assigning it to exactly one bucket is a\n// compile error here.\nconst _statusBucketsCoverAll: AssertEqual<OpenFeedbackStatus | ClosedFeedbackStatus, FeedbackStatus> = true;\nvoid _statusBucketsCoverAll;\n\n/** Whether a status is terminal (`resolved` or `wont_fix`). Narrows the status type. */\nexport function isClosedStatus(status: FeedbackStatus): status is ClosedFeedbackStatus {\n return (CLOSED_FEEDBACK_STATUSES as readonly FeedbackStatus[]).includes(status);\n}\n\n/**\n * Page scope returned by `SitepingConfig.getPageScope()`.\n *\n * - `url`: concrete page identifier — usually `window.location.pathname`,\n * used as the strict scope for marker rendering.\n * - `urlPattern`: optional parameterized template (e.g. `/orders/:orderId`)\n * used by the panel's \"this type of page\" filter to group feedbacks across\n * instances of the same page kind.\n */\nexport interface PageScope {\n url: string;\n urlPattern: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Abstract Store — adapter pattern\n// ---------------------------------------------------------------------------\n\n/** Input for creating a feedback record in the store. */\nexport interface FeedbackCreateInput {\n projectName: string;\n type: FeedbackType;\n message: string;\n status: FeedbackStatus;\n url: string;\n /**\n * Optional parameterized URL template (e.g. `/orders/:orderId`) for the page\n * where the feedback was created. Allows the panel to filter feedbacks by\n * \"this type of page\" across different instances. Null when the host did not\n * provide a `getPageScope` callback or the route has no template.\n */\n urlPattern?: string | null | undefined;\n viewport: string;\n userAgent: string;\n authorName: string;\n authorEmail: string;\n clientId: string;\n annotations: AnnotationCreateInput[];\n /**\n * Base64 JPEG `data:` URL captured by the widget at submit time.\n *\n * Adapters with a configured `ScreenshotStorage` are expected to upload\n * this and persist the returned URL on `FeedbackRecord.screenshotUrl`.\n * Adapters without storage may persist the data URL inline (memory /\n * localStorage / dev) — the widget then renders it directly.\n */\n screenshotDataUrl?: string | null | undefined;\n /**\n * Where the client's annotation rect sits within the screenshot image,\n * as fractions [0, 1] of the image dimensions. Present when the widget\n * captured context around the drawn rect; null for legacy captures that\n * were cropped exactly to the rect (dashboards then render the image\n * without an overlay).\n */\n screenshotRegion?: ScreenshotRegion | null | undefined;\n /**\n * Optional console + failed-network snapshot captured by the widget when\n * `SitepingConfig.captureDiagnostics` is enabled. Stored as JSON on\n * `FeedbackRecord.diagnostics` so reviewers can replay the context.\n */\n diagnostics?: DiagnosticsSnapshot | null | undefined;\n}\n\n/** Input for a single annotation when creating a feedback. */\nexport interface AnnotationCreateInput {\n cssSelector: string;\n xpath: string;\n textSnippet: string;\n elementTag: string;\n elementId?: string | undefined;\n textPrefix: string;\n textSuffix: string;\n fingerprint: string;\n neighborText: string;\n /**\n * Semantic anchor identifier from the closest ancestor's `data-feedback-anchor`\n * attribute. When set, this is the most stable re-anchoring signal because\n * hosts deliberately place these on layout/section roots that survive DOM\n * refactors and viewport changes. Null when no semantic ancestor exists.\n */\n anchorKey?: string | null | undefined;\n xPct: number;\n yPct: number;\n wPct: number;\n hPct: number;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n}\n\n/** Query parameters for fetching feedbacks. */\nexport interface FeedbackQuery {\n projectName: string;\n type?: FeedbackType | undefined;\n /** Exact single-status filter. For \"any of a set\" (bucket) semantics, use `statuses`. */\n status?: FeedbackStatus | undefined;\n /**\n * Filter to feedbacks whose status is any of the listed values — bucket\n * semantics used by the panel's binary tabs (e.g. \"Open\" passes\n * `[\"open\", \"in_progress\"]`). When both `status` and `statuses` are set,\n * `statuses` wins. An empty array is treated as absent (no status filter).\n */\n statuses?: readonly FeedbackStatus[] | undefined;\n search?: string | undefined;\n page?: number | undefined;\n limit?: number | undefined;\n /**\n * Filter to feedbacks created on this exact URL (path). Used by the panel's\n * \"this page\" filter and by the markers loader to keep page scopes isolated.\n */\n url?: string | undefined;\n /**\n * Filter to feedbacks created on this URL pattern (e.g. `/orders/:orderId`).\n * Used by the panel's \"this type of page\" filter to group feedbacks across\n * different concrete instances of the same template.\n */\n urlPattern?: string | undefined;\n}\n\n/**\n * Update payload for patching a feedback.\n *\n * A discriminated union encoding the closure invariant: a feedback entering\n * a closed status carries its closure timestamp, an open one carries `null`.\n * `{ status: \"resolved\", resolvedAt: null }` is a compile error instead of a\n * silent data bug. Build it from a plain `FeedbackStatus` with\n * {@link toFeedbackUpdate}.\n */\nexport type FeedbackUpdateInput =\n | { status: OpenFeedbackStatus; resolvedAt: null }\n | { status: ClosedFeedbackStatus; resolvedAt: Date };\n\n/**\n * Derive the {@link FeedbackUpdateInput} for a status change — the closure\n * timestamp is stamped for closed statuses and cleared otherwise. This is\n * the edge derivation described on {@link CLOSED_FEEDBACK_STATUSES}; store\n * adapters persist the result verbatim.\n */\nexport function toFeedbackUpdate(status: FeedbackStatus, closedAt: Date = new Date()): FeedbackUpdateInput {\n return isClosedStatus(status) ? { status, resolvedAt: closedAt } : { status, resolvedAt: null };\n}\n\n/** A persisted feedback record returned by the store. */\nexport interface FeedbackRecord {\n id: string;\n type: FeedbackType;\n message: string;\n status: FeedbackStatus;\n projectName: string;\n url: string;\n /**\n * Parameterized URL template the feedback was created on.\n * Null for legacy records or hosts without `getPageScope`.\n */\n urlPattern: string | null;\n authorName: string;\n authorEmail: string;\n viewport: string;\n userAgent: string;\n clientId: string;\n resolvedAt: Date | null;\n createdAt: Date;\n updatedAt: Date;\n annotations: AnnotationRecord[];\n /**\n * URL the widget renders as `<img src>`. Either an `https://...` from a\n * configured `ScreenshotStorage`, or a `data:image/jpeg;base64,...` URL\n * inline-persisted by adapters without storage. Null when no screenshot\n * was captured (legacy records, capture failed, or host disabled it).\n */\n screenshotUrl: string | null;\n /**\n * Annotation rect position within the screenshot image, as fractions of\n * its dimensions. Null for legacy captures cropped exactly to the rect.\n */\n screenshotRegion: ScreenshotRegion | null;\n /**\n * Console + failed-network snapshot captured at submit time. Null when\n * diagnostics weren't enabled on the widget side.\n */\n diagnostics: DiagnosticsSnapshot | null;\n}\n\n/** A persisted annotation record returned by the store. */\nexport interface AnnotationRecord {\n id: string;\n feedbackId: string;\n cssSelector: string;\n xpath: string;\n textSnippet: string;\n elementTag: string;\n elementId: string | null;\n textPrefix: string;\n textSuffix: string;\n fingerprint: string;\n neighborText: string;\n /**\n * Semantic anchor identifier from `data-feedback-anchor`. Null for legacy\n * annotations or those drawn outside any anchored region.\n */\n anchorKey: string | null;\n xPct: number;\n yPct: number;\n wPct: number;\n hPct: number;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n createdAt: Date;\n}\n\n// ---------------------------------------------------------------------------\n// Store errors — throw these from adapter implementations\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when a record is not found during update or delete.\n *\n * Handlers translate this to HTTP 404. Adapters MUST throw this (not\n * ORM-specific errors) so the handler layer remains ORM-agnostic.\n */\nexport class StoreNotFoundError extends Error {\n readonly code = \"STORE_NOT_FOUND\" as const;\n constructor(message = \"Record not found\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StoreNotFoundError\";\n }\n}\n\n/**\n * Thrown when a unique constraint is violated (e.g. duplicate `clientId`).\n *\n * Handlers use this to return the existing record instead of failing.\n */\nexport class StoreDuplicateError extends Error {\n readonly code = \"STORE_DUPLICATE\" as const;\n constructor(message = \"Duplicate record\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StoreDuplicateError\";\n }\n}\n\n/**\n * Thrown when a store accepts a mutation but cannot persist it — e.g.\n * `localStorage` is full (QuotaExceededError). Adapters MUST throw this rather\n * than swallow the failure, so callers learn the write was lost instead of\n * seeing a phantom success.\n */\nexport class StorePersistenceError extends Error {\n readonly code = \"STORE_PERSISTENCE\" as const;\n constructor(message = \"Failed to persist store mutation\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StorePersistenceError\";\n }\n}\n\n/** Shape of any ORM error that carries a Prisma-style `code` field. */\ntype CodedError<C extends string = string> = { code: C };\n\nfunction hasErrorCode<C extends string>(error: unknown, code: C): error is CodedError<C> {\n return hasOwn(error, \"code\") && error.code === code;\n}\n\n/**\n * Type guard — works for `StoreNotFoundError` and ORM-specific equivalents\n * (e.g. Prisma P2025). Matches the stable `STORE_NOT_FOUND` code as well as\n * `instanceof`: every consumer package bundles its own copy of core (tsup\n * `noExternal`), so an adapter's instance fails `instanceof` against the\n * server's class identity.\n */\nexport function isStoreNotFound(\n error: unknown,\n): error is StoreNotFoundError | CodedError<\"STORE_NOT_FOUND\"> | CodedError<\"P2025\"> {\n if (error instanceof StoreNotFoundError) return true;\n // Backwards compat: Prisma's P2025\n return hasErrorCode(error, \"STORE_NOT_FOUND\") || hasErrorCode(error, \"P2025\");\n}\n\n/**\n * Type guard — works for `StoreDuplicateError` and ORM-specific equivalents\n * (e.g. Prisma P2002). Matches the stable `STORE_DUPLICATE` code as well as\n * `instanceof`, for the same cross-bundle reason as {@link isStoreNotFound}.\n */\nexport function isStoreDuplicate(\n error: unknown,\n): error is StoreDuplicateError | CodedError<\"STORE_DUPLICATE\"> | CodedError<\"P2002\"> {\n if (error instanceof StoreDuplicateError) return true;\n // Backwards compat: Prisma's P2002\n return hasErrorCode(error, \"STORE_DUPLICATE\") || hasErrorCode(error, \"P2002\");\n}\n\n/**\n * Type guard for `StorePersistenceError`. Matches on the stable `code` field\n * in addition to `instanceof`: every consumer package bundles its own copy of\n * core (tsup `noExternal`), so an instance thrown by one package fails an\n * `instanceof` check against another package's class identity.\n */\nexport function isStorePersistence(error: unknown): error is StorePersistenceError | CodedError<\"STORE_PERSISTENCE\"> {\n if (error instanceof StorePersistenceError) return true;\n return hasErrorCode(error, \"STORE_PERSISTENCE\");\n}\n\n// ---------------------------------------------------------------------------\n// Store helpers — shared conversion logic for adapters\n// ---------------------------------------------------------------------------\n\n/** Flatten a widget `AnnotationPayload` (nested anchor + rect) into a flat `AnnotationCreateInput`. */\nexport function flattenAnnotation(ann: AnnotationPayload): AnnotationCreateInput {\n return {\n cssSelector: ann.anchor.cssSelector,\n xpath: ann.anchor.xpath,\n textSnippet: ann.anchor.textSnippet,\n elementTag: ann.anchor.elementTag,\n elementId: ann.anchor.elementId,\n textPrefix: ann.anchor.textPrefix,\n textSuffix: ann.anchor.textSuffix,\n fingerprint: ann.anchor.fingerprint,\n neighborText: ann.anchor.neighborText,\n anchorKey: ann.anchor.anchorKey ?? null,\n xPct: ann.rect.xPct,\n yPct: ann.rect.yPct,\n wPct: ann.rect.wPct,\n hPct: ann.rect.hPct,\n scrollX: ann.scrollX,\n scrollY: ann.scrollY,\n viewportW: ann.viewportW,\n viewportH: ann.viewportH,\n devicePixelRatio: ann.devicePixelRatio,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Abstract Store — adapter pattern\n// ---------------------------------------------------------------------------\n\n/**\n * Outcome of `SitepingStore.createFeedbackIfAbsent` — the record plus whether\n * this very call inserted it.\n */\nexport interface FeedbackCreateOutcome {\n feedback: FeedbackRecord;\n /**\n * `true` when this call inserted the record, `false` when a record with the\n * same `clientId` already existed and is returned instead (a replay, or a\n * concurrent request that won the race).\n */\n created: boolean;\n}\n\n/** Paginated result returned by `SitepingStore.getFeedbacks`. */\nexport interface FeedbackPage {\n feedbacks: FeedbackRecord[];\n total: number;\n}\n\n/**\n * Abstract storage interface for Siteping.\n *\n * Any adapter (Prisma, Drizzle, raw SQL, localStorage, etc.) implements this\n * interface. The HTTP handler and widget `StoreClient` operate against\n * `SitepingStore`, decoupled from the storage backend.\n *\n * ## Error contract\n *\n * - **`updateFeedback` / `deleteFeedback`**: throw `StoreNotFoundError` when\n * the record does not exist.\n * - **`createFeedback`**: either return the existing record on duplicate\n * `clientId` (idempotent) or throw `StoreDuplicateError`. The handler\n * handles both patterns — but only a throw, or the optional\n * `createFeedbackIfAbsent`, tells it the record was not inserted by this\n * call; stores that return the existing record should implement\n * `createFeedbackIfAbsent` so creation side effects never run twice.\n * - **All mutations**: when a write is accepted but cannot be persisted\n * (e.g. storage quota), throw `StorePersistenceError` instead of reporting\n * a phantom success. Detect it with `isStorePersistence`.\n * - Other methods should not throw on empty results — return empty arrays or `null`.\n */\nexport interface SitepingStore {\n /** Create a feedback with its annotations. Idempotent on `clientId` — return existing record on duplicate, or throw `StoreDuplicateError`. Throws `StorePersistenceError` when the write cannot be persisted. */\n createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord>;\n /** Paginated query with optional filters. Returns empty array (not error) when no results. */\n getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage>;\n /** Lookup by client-generated UUID. Returns `null` (not error) when not found. */\n findByClientId(clientId: string): Promise<FeedbackRecord | null>;\n /** Update status/resolvedAt. Throws `StoreNotFoundError` if `id` does not exist, `StorePersistenceError` when the write cannot be persisted. */\n updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord>;\n /** Delete a single record. Throws `StoreNotFoundError` if `id` does not exist, `StorePersistenceError` when the write cannot be persisted. */\n deleteFeedback(id: string): Promise<void>;\n /** Bulk delete all feedbacks for a project. No-op (not error) if none exist. Throws `StorePersistenceError` when the write cannot be persisted. */\n deleteAllFeedbacks(projectName: string): Promise<void>;\n /**\n * Optional — return `true` when the record with `id` belongs to\n * `projectName`, `false` otherwise (including when it does not exist).\n *\n * HTTP handlers use this to reject cross-project PATCH/DELETE requests.\n * Implement it whenever your store serves multiple projects. When absent,\n * handlers rely on `id` alone — so `createSitepingHandler` refuses to start\n * with a custom `access.authorize` (which may scope callers to projects)\n * over a store that lacks it.\n */\n verifyProjectOwnership?(id: string, projectName: string): Promise<boolean>;\n /**\n * Optional — `createFeedback` that reports whether this call inserted the\n * record (`created: true`) or found an existing one with the same\n * `clientId` (`created: false`). The dedup check and the insert must be\n * atomic, like `createFeedback`'s: of N concurrent calls with the same\n * `clientId`, exactly one may report `created: true`, and all must return\n * that same record. `createCollectionStore` guarantees this within one\n * store instance by serializing its mutations; stores shared across\n * processes need an atomic backend primitive (unique constraint,\n * transaction, compare-and-set).\n *\n * HTTP handlers prefer it over `createFeedback` to fire creation side\n * effects (webhooks, `onCreated`) exactly once when concurrent requests\n * race on the same `clientId`. Stores whose `createFeedback` throws\n * `StoreDuplicateError` on a duplicate already give that signal and may\n * leave it out.\n */\n createFeedbackIfAbsent?(data: FeedbackCreateInput): Promise<FeedbackCreateOutcome>;\n}\n\n/** Payload sent from the widget to the server when submitting feedback. */\nexport interface FeedbackPayload {\n projectName: string;\n type: FeedbackType;\n message: string;\n url: string;\n /**\n * Parameterized URL template (e.g. `/orders/:orderId`) supplied by\n * `SitepingConfig.getPageScope()`. Null when the host did not provide one.\n */\n urlPattern?: string | null | undefined;\n viewport: string;\n userAgent: string;\n authorName: string;\n authorEmail: string;\n annotations: AnnotationPayload[];\n /** Client-generated UUID for deduplication */\n clientId: string;\n /**\n * Base64 JPEG `data:` URL of the annotated area. Captured by the widget\n * when `enableScreenshot: true` is set in `SitepingConfig`. Null when\n * disabled or when capture failed silently.\n */\n screenshotDataUrl?: string | null | undefined;\n /**\n * Annotation rect position within the screenshot image — see\n * `ScreenshotRegion`. Null/absent when no screenshot was captured or the\n * capture predates contextual framing.\n */\n screenshotRegion?: ScreenshotRegion | null | undefined;\n /**\n * Snapshot of the last few console messages and failed network requests\n * captured at submit time when `captureDiagnostics` is enabled.\n */\n diagnostics?: DiagnosticsSnapshot | null | undefined;\n}\n\n/** Single source of truth for console diagnostic severity levels. */\nexport const CONSOLE_DIAGNOSTIC_LEVELS = [\"log\", \"info\", \"warn\", \"error\"] as const;\n/** Severity levels persisted in `ConsoleDiagnosticEntry`. */\nexport type ConsoleDiagnosticLevel = (typeof CONSOLE_DIAGNOSTIC_LEVELS)[number];\n\n/** A single console entry captured by `ConsoleBuffer`. */\nexport interface ConsoleDiagnosticEntry {\n level: ConsoleDiagnosticLevel;\n /** ISO 8601 timestamp captured at log time. */\n timestamp: string;\n /** Best-effort string representation of the original console args. */\n message: string;\n}\n\n/** A single failed network request captured by `NetworkBuffer`. */\nexport interface NetworkDiagnosticEntry {\n url: string;\n method: string;\n /** HTTP status; 0 when the request never reached the server. */\n status: number;\n /** End-to-end duration in ms. */\n durationMs: number;\n /** ISO 8601 timestamp at the moment the request was initiated. */\n timestamp: string;\n}\n\n/**\n * Diagnostics captured by the widget when `captureDiagnostics` is enabled.\n *\n * Both arrays are bounded (default: 50 console / 20 network). Adapters that\n * support diagnostics should persist this as a JSON blob alongside the\n * feedback so reviewers can replay the context that led to the report.\n */\nexport interface DiagnosticsSnapshot {\n console: ConsoleDiagnosticEntry[];\n network: NetworkDiagnosticEntry[];\n}\n\n// ---------------------------------------------------------------------------\n// Annotation — multi-selector anchoring (Hypothesis / W3C Web Annotation)\n// ---------------------------------------------------------------------------\n\n/** DOM anchoring data for re-attaching annotations to page elements. */\nexport interface AnchorData {\n /** CSS selector generated by @medv/finder — primary anchor */\n cssSelector: string;\n /** XPath — fallback 1 */\n xpath: string;\n /** First ~120 chars of element innerText — empty string if none */\n textSnippet: string;\n /** Tag name for validation (e.g. \"DIV\", \"SECTION\") */\n elementTag: string;\n /** Element id attribute if available — most stable */\n elementId?: string | undefined;\n /** ~32 chars of text before this element in document flow (disambiguation) */\n textPrefix: string;\n /** ~32 chars of text after this element in document flow (disambiguation) */\n textSuffix: string;\n /** Structural fingerprint: \"childCount:siblingIdx:attrHash\" */\n fingerprint: string;\n /** Text content of adjacent sibling elements (context) */\n neighborText: string;\n /**\n * Semantic anchor identifier from the closest ancestor's `data-feedback-anchor`\n * attribute. When set, this is the highest-priority re-anchoring signal —\n * hosts deliberately place these on layout/section roots that survive\n * viewport changes and DOM refactors.\n */\n anchorKey?: string | null | undefined;\n}\n\n/**\n * Where the client's annotation rect sits within the captured screenshot,\n * as fractions [0, 1] of the image dimensions. The widget captures context\n * around the drawn rect and records the rect's position here so dashboards\n * can re-render the annotation on top of the image. Survives downscaling\n * (fractions are resolution-independent).\n */\nexport interface ScreenshotRegion {\n /** X offset of the rect as fraction of image width — [0, 1] */\n xPct: number;\n /** Y offset of the rect as fraction of image height — [0, 1] */\n yPct: number;\n /** Rect width as fraction of image width — [0, 1] */\n wPct: number;\n /** Rect height as fraction of image height — [0, 1] */\n hPct: number;\n}\n\n/** Drawn rectangle coordinates as percentages relative to the anchor element. */\nexport interface RectData {\n /** X offset as fraction of anchor element width — must be in range [0, 1] */\n xPct: number;\n /** Y offset as fraction of anchor element height — must be in range [0, 1] */\n yPct: number;\n /** Width as fraction of anchor element width — must be in range [0, 1] */\n wPct: number;\n /** Height as fraction of anchor element height — must be in range [0, 1] */\n hPct: number;\n}\n\n/** Annotation data sent as part of a feedback submission. */\nexport interface AnnotationPayload {\n anchor: AnchorData;\n rect: RectData;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n}\n\n// ---------------------------------------------------------------------------\n// API responses\n// ---------------------------------------------------------------------------\n\n/**\n * Feedback record as returned by the API — derived from\n * {@link FeedbackRecord}: dates are serialized to ISO strings and `clientId`\n * is omitted (server-side dedup concern, never exposed on the wire). Adding\n * a field to `FeedbackRecord` updates this type automatically.\n *\n * Note: `authorEmail` may be an empty string — HTTP adapters redact it for\n * unauthenticated requests; the full value requires a Bearer-authenticated\n * request.\n */\nexport type FeedbackResponse = Prettify<Serialized<Omit<FeedbackRecord, \"clientId\">>>;\n\n/**\n * Annotation record as returned by the API — {@link AnnotationRecord} with\n * `createdAt` serialized to an ISO string.\n */\nexport type AnnotationResponse = Prettify<Serialized<AnnotationRecord>>;\n\n/** Paginated `FeedbackResponse` shape returned by the API. */\nexport interface FeedbackResponseList {\n feedbacks: FeedbackResponse[];\n total: number;\n}\n","/**\n * Record-construction helpers and the collection-store engine.\n *\n * Every snapshot-style adapter (memory, localStorage, flat file, KV, …)\n * needs the same three ingredients: turn a `FeedbackCreateInput` into a\n * `FeedbackRecord` (null-normalizing optional fields, stamping ids and\n * timestamps), filter/paginate with `applyFeedbackFilters`, and implement\n * the dedup/update/delete choreography of the `SitepingStore` contract.\n *\n * `buildFeedbackRecord` / `buildAnnotationRecord` cover the first part for\n * any adapter. `createCollectionStore` covers all of it: give it `load`,\n * `persist`, and `generateId`, and it returns a fully conformant\n * `SitepingStore` — writing a new snapshot adapter is ~20 lines plus its\n * storage specifics.\n */\n\nimport { applyFeedbackFilters } from \"./filters.js\";\nimport type {\n AnnotationCreateInput,\n AnnotationRecord,\n FeedbackCreateInput,\n FeedbackCreateOutcome,\n FeedbackPage,\n FeedbackQuery,\n FeedbackRecord,\n FeedbackUpdateInput,\n SitepingStore,\n} from \"./types.js\";\nimport { StoreNotFoundError } from \"./types.js\";\n\n// ---------------------------------------------------------------------------\n// Record construction\n// ---------------------------------------------------------------------------\n\n/**\n * Build a persisted `AnnotationRecord` from its create input — normalizes\n * the optional anchor fields to `null` and stamps identity/timestamp.\n */\nexport function buildAnnotationRecord(\n input: AnnotationCreateInput,\n ctx: { id: string; feedbackId: string; now: Date },\n): AnnotationRecord {\n return {\n id: ctx.id,\n feedbackId: ctx.feedbackId,\n cssSelector: input.cssSelector,\n xpath: input.xpath,\n textSnippet: input.textSnippet,\n elementTag: input.elementTag,\n elementId: input.elementId ?? null,\n textPrefix: input.textPrefix,\n textSuffix: input.textSuffix,\n fingerprint: input.fingerprint,\n neighborText: input.neighborText,\n anchorKey: input.anchorKey ?? null,\n xPct: input.xPct,\n yPct: input.yPct,\n wPct: input.wPct,\n hPct: input.hPct,\n scrollX: input.scrollX,\n scrollY: input.scrollY,\n viewportW: input.viewportW,\n viewportH: input.viewportH,\n devicePixelRatio: input.devicePixelRatio,\n createdAt: ctx.now,\n };\n}\n\n/**\n * Build a persisted `FeedbackRecord` (with its annotations) from a create\n * input — normalizes every optional field to `null` and stamps ids and\n * timestamps. Adapters without external screenshot storage keep the data\n * URL inline on `screenshotUrl`, which is what this helper does; adapters\n * with a `ScreenshotStorage` upload first and override `screenshotUrl`.\n */\nexport function buildFeedbackRecord(\n input: FeedbackCreateInput,\n ctx: { id: string; annotationId: () => string; now?: Date },\n): FeedbackRecord {\n const now = ctx.now ?? new Date();\n return {\n id: ctx.id,\n type: input.type,\n message: input.message,\n status: input.status,\n projectName: input.projectName,\n url: input.url,\n urlPattern: input.urlPattern ?? null,\n authorName: input.authorName,\n authorEmail: input.authorEmail,\n viewport: input.viewport,\n userAgent: input.userAgent,\n clientId: input.clientId,\n resolvedAt: null,\n createdAt: now,\n updatedAt: now,\n annotations: input.annotations.map((ann) =>\n buildAnnotationRecord(ann, { id: ctx.annotationId(), feedbackId: ctx.id, now }),\n ),\n screenshotUrl: input.screenshotDataUrl ?? null,\n screenshotRegion: input.screenshotRegion ?? null,\n diagnostics: input.diagnostics ?? null,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Collection-store engine\n// ---------------------------------------------------------------------------\n\n/**\n * Storage primitives behind a collection store. `load`/`persist` may be\n * sync or async — the engine awaits both, so in-memory arrays, localStorage\n * and async KV stores all fit the same three functions.\n */\nexport interface CollectionStoreBackend {\n /**\n * Return the current full snapshot of feedback records. The engine never\n * mutates this array — handing out a live cache is safe.\n */\n load(): FeedbackRecord[] | Promise<FeedbackRecord[]>;\n /**\n * Persist the full snapshot — always a new array, never the one `load()`\n * returned. Throw `StorePersistenceError` when the write is lost (quota,\n * storage disabled, …) — never swallow the failure. Because the loaded\n * snapshot is left untouched, a throw here leaves a cached `load()` result\n * consistent with durable storage: no phantom record, no half-applied\n * update.\n */\n persist(feedbacks: FeedbackRecord[]): void | Promise<void>;\n /** Generate a unique id for a new feedback or annotation record. */\n generateId(): string;\n}\n\n/**\n * A `SitepingStore` with the optional `verifyProjectOwnership` and\n * `createFeedbackIfAbsent` guaranteed — what `createCollectionStore` returns.\n */\nexport type CollectionStore = SitepingStore &\n Required<Pick<SitepingStore, \"verifyProjectOwnership\" | \"createFeedbackIfAbsent\">>;\n\n/**\n * Build a fully conformant `SitepingStore` on top of a snapshot backend.\n *\n * The engine implements the whole store contract: clientId dedup (idempotent\n * create, with `createFeedbackIfAbsent` reporting inserts), newest-first\n * ordering, the standard filter/pagination pipeline,\n * `StoreNotFoundError` on missing update/delete, project-scoped bulk delete,\n * and `verifyProjectOwnership`. The snapshot returned by `load` is never\n * mutated: every write hands `persist` a new array, so a failed write leaves\n * a cached snapshot exactly as it was. When `persist` fails during `createFeedback`\n * and the record carries an inline screenshot, the engine retries once\n * without the screenshot (by far the heaviest field) so the text feedback\n * survives a storage-quota hit; if that also fails, the error propagates —\n * returning the record would claim a success that was never persisted.\n *\n * Every read-modify-write mutation (`createFeedbackIfAbsent`,\n * `createFeedback`, `updateFeedback`, `deleteFeedback`,\n * `deleteAllFeedbacks`) runs through one promise queue per returned store,\n * so its `load` → check → `persist` sequence never interleaves with another\n * mutation of the same instance. That makes `createFeedbackIfAbsent` report\n * `created: true` exactly once per `clientId` and prevents concurrent writes\n * from overwriting each other's snapshot. The guarantee is scoped to one\n * store instance in one JS process: two instances over the same storage (two\n * server processes on a shared file, two browser tabs on the same\n * localStorage key) are not coordinated — backends that need that must\n * provide their own atomic primitive (a unique constraint, a transaction, a\n * compare-and-set). A failed mutation rejects with its original error and\n * does not block the ones queued after it. Reads are not queued: they see the\n * last persisted snapshot.\n *\n * @example\n * ```ts\n * export class MemoryStore implements SitepingStore {\n * private feedbacks: FeedbackRecord[] = [];\n * private readonly store = createCollectionStore({\n * load: () => this.feedbacks,\n * persist: (next) => {\n * this.feedbacks = next;\n * },\n * generateId: () => crypto.randomUUID(),\n * });\n * createFeedback = this.store.createFeedback;\n * // …delegate the remaining methods the same way\n * }\n * ```\n */\nexport function createCollectionStore(backend: CollectionStoreBackend): CollectionStore {\n // Every mutation below builds a NEW array for `persist` instead of editing\n // the loaded one in place. A backend whose `load()` serves a live cache (an\n // in-memory array, a KV read-through) would otherwise see the change before\n // the write is confirmed — and when `persist` throws, the phantom record\n // stays visible, and the widget's retry of the same clientId dedups against\n // it instead of being written for real.\n\n /**\n * Tail of the mutation queue. It always settles as fulfilled (failures are\n * observed by the caller of the failed mutation, not by the queue), so one\n * rejected write never blocks the mutations queued after it.\n */\n let mutationQueueTail: Promise<void> = Promise.resolve();\n\n /**\n * Run a read-modify-write mutation after every previously queued one has\n * settled, so no two mutations of this store interleave their `load` and\n * `persist`. Resolves or rejects exactly like `mutation` itself.\n */\n const runSerializedMutation = <MutationResult>(mutation: () => Promise<MutationResult>): Promise<MutationResult> => {\n const mutationResult = mutationQueueTail.then(mutation);\n mutationQueueTail = mutationResult.then(\n () => undefined,\n () => undefined,\n );\n return mutationResult;\n };\n\n const insertFeedbackIfAbsent = async (data: FeedbackCreateInput): Promise<FeedbackCreateOutcome> => {\n const feedbacks = await backend.load();\n\n // ClientId dedup — idempotent\n const existing = feedbacks.find((f) => f.clientId === data.clientId);\n if (existing) return { feedback: existing, created: false };\n\n const record = buildFeedbackRecord(data, {\n id: backend.generateId(),\n annotationId: () => backend.generateId(),\n });\n\n const next = [record, ...feedbacks];\n try {\n await backend.persist(next);\n } catch (err) {\n if (!record.screenshotUrl) throw err;\n record.screenshotUrl = null;\n await backend.persist(next);\n }\n return { feedback: record, created: true };\n };\n\n const createFeedbackIfAbsent = (data: FeedbackCreateInput): Promise<FeedbackCreateOutcome> =>\n runSerializedMutation(() => insertFeedbackIfAbsent(data));\n\n return {\n createFeedbackIfAbsent,\n\n async createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord> {\n return (await createFeedbackIfAbsent(data)).feedback;\n },\n\n async getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage> {\n return applyFeedbackFilters(await backend.load(), query);\n },\n\n async findByClientId(clientId: string): Promise<FeedbackRecord | null> {\n return (await backend.load()).find((f) => f.clientId === clientId) ?? null;\n },\n\n updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord> {\n return runSerializedMutation(async () => {\n const feedbacks = await backend.load();\n const current = feedbacks.find((f) => f.id === id);\n if (!current) throw new StoreNotFoundError();\n\n const updated: FeedbackRecord = {\n ...current,\n status: data.status,\n resolvedAt: data.resolvedAt,\n updatedAt: new Date(),\n };\n await backend.persist(feedbacks.map((f) => (f === current ? updated : f)));\n return updated;\n });\n },\n\n deleteFeedback(id: string): Promise<void> {\n return runSerializedMutation(async () => {\n const feedbacks = await backend.load();\n if (!feedbacks.some((f) => f.id === id)) throw new StoreNotFoundError();\n\n await backend.persist(feedbacks.filter((f) => f.id !== id));\n });\n },\n\n deleteAllFeedbacks(projectName: string): Promise<void> {\n return runSerializedMutation(async () => {\n const feedbacks = await backend.load();\n await backend.persist(feedbacks.filter((f) => f.projectName !== projectName));\n });\n },\n\n async verifyProjectOwnership(id: string, projectName: string): Promise<boolean> {\n const fb = (await backend.load()).find((f) => f.id === id);\n return fb !== undefined && fb.projectName === projectName;\n },\n };\n}\n","import {\n createCollectionStore,\n type FeedbackCreateInput,\n type FeedbackCreateOutcome,\n type FeedbackPage,\n type FeedbackQuery,\n type FeedbackRecord,\n type FeedbackUpdateInput,\n type SitepingStore,\n} from \"@beezping/core\";\n\nexport type { SitepingStore } from \"@beezping/core\";\nexport { isStorePersistence, StoreDuplicateError, StoreNotFoundError, StorePersistenceError } from \"@beezping/core\";\n\n/**\n * In-memory `SitepingStore` implementation.\n *\n * Zero dependencies, works in any JS environment (Node, Bun, Deno, browser,\n * Cloudflare Workers). Data lives in a plain array — lost on process restart.\n *\n * Use cases:\n * - **Testing** — fast, isolated store for unit/integration tests\n * - **Demos** — lightweight store that needs no database or localStorage\n * - **Reference** — simplest possible adapter for contributors to study\n *\n * All store semantics (clientId dedup, filtering, pagination, error\n * contract) come from core's `createCollectionStore` engine — this class\n * only supplies the storage primitives: an array, an id generator.\n *\n * @example\n * ```ts\n * import { MemoryStore } from '@beezping/adapter-memory'\n *\n * const store = new MemoryStore()\n * // Pass to createSitepingHandler({ store }) or initSiteping({ store })\n * ```\n */\nexport class MemoryStore implements SitepingStore {\n private feedbacks: FeedbackRecord[] = [];\n private idCounter = 1;\n\n private readonly engine = createCollectionStore({\n load: () => this.feedbacks,\n persist: (next) => {\n this.feedbacks = next;\n },\n generateId: () => `mem-${this.idCounter++}-${Date.now().toString(36)}`,\n });\n\n createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord> {\n return this.engine.createFeedback(data);\n }\n\n createFeedbackIfAbsent(data: FeedbackCreateInput): Promise<FeedbackCreateOutcome> {\n return this.engine.createFeedbackIfAbsent(data);\n }\n\n getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage> {\n return this.engine.getFeedbacks(query);\n }\n\n findByClientId(clientId: string): Promise<FeedbackRecord | null> {\n return this.engine.findByClientId(clientId);\n }\n\n updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord> {\n return this.engine.updateFeedback(id, data);\n }\n\n deleteFeedback(id: string): Promise<void> {\n return this.engine.deleteFeedback(id);\n }\n\n deleteAllFeedbacks(projectName: string): Promise<void> {\n return this.engine.deleteAllFeedbacks(projectName);\n }\n\n verifyProjectOwnership(id: string, projectName: string): Promise<boolean> {\n return this.engine.verifyProjectOwnership(id, projectName);\n }\n\n /** Remove all data from this store instance. */\n clear(): void {\n this.feedbacks = [];\n this.idCounter = 1;\n }\n}\n"],"mappings":"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","names":[]}
|