@beezping/adapter-memory 0.6.0 → 0.7.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/dist/email.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The one email pattern every Siteping surface validates against.
2
+ * The one email pattern every Beezping surface validates against.
3
3
  *
4
4
  * The widget's identity modal and the HTTP handler's schema used to disagree
5
5
  * (a permissive regex on one side, an ASCII-only default on the other), so an
@@ -8,12 +8,15 @@
8
8
  * sides, makes that drift impossible.
9
9
  *
10
10
  * Deliberately Unicode-aware: internationalised local parts and domains
11
- * (`françois@exemple.fr`, `user@münchen.de`) are real addresses and common in
12
- * the audience this widget serves. Structure follows the usual rules — a
13
- * local part of 1 to 64 characters with no leading, trailing or doubled dot,
11
+ * (`françois@exemple.fr`, `user@münchen.de`, `user@हिंदी.भारत`) are real
12
+ * addresses and common in the audience this widget serves. Combining marks
13
+ * (`\p{M}`) are allowed alongside letters — scripts like Devanagari need them,
14
+ * and NFD-composed input spells `ç` as `c` + U+0327 — except as the first
15
+ * character of a domain label. Structure follows the usual rules — a local
16
+ * part of 1 to 64 characters with no leading, trailing or doubled dot,
14
17
  * dot-separated domain labels of at most 63 characters that neither start nor
15
18
  * end with a hyphen, and a final label of at least two characters.
16
19
  */
17
20
  export declare const EMAIL_PATTERN: RegExp;
18
- /** Whether `value` is an email address Siteping accepts — see {@link EMAIL_PATTERN}. */
21
+ /** Whether `value` is an email address Beezping accepts — see {@link EMAIL_PATTERN}. */
19
22
  export declare function isValidEmail(value: string): boolean;
package/dist/email.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The one email pattern every Siteping surface validates against.
2
+ * The one email pattern every Beezping surface validates against.
3
3
  *
4
4
  * The widget's identity modal and the HTTP handler's schema used to disagree
5
5
  * (a permissive regex on one side, an ASCII-only default on the other), so an
@@ -8,12 +8,15 @@
8
8
  * sides, makes that drift impossible.
9
9
  *
10
10
  * Deliberately Unicode-aware: internationalised local parts and domains
11
- * (`françois@exemple.fr`, `user@münchen.de`) are real addresses and common in
12
- * the audience this widget serves. Structure follows the usual rules — a
13
- * local part of 1 to 64 characters with no leading, trailing or doubled dot,
11
+ * (`françois@exemple.fr`, `user@münchen.de`, `user@हिंदी.भारत`) are real
12
+ * addresses and common in the audience this widget serves. Combining marks
13
+ * (`\p{M}`) are allowed alongside letters — scripts like Devanagari need them,
14
+ * and NFD-composed input spells `ç` as `c` + U+0327 — except as the first
15
+ * character of a domain label. Structure follows the usual rules — a local
16
+ * part of 1 to 64 characters with no leading, trailing or doubled dot,
14
17
  * dot-separated domain labels of at most 63 characters that neither start nor
15
18
  * end with a hyphen, and a final label of at least two characters.
16
19
  */
17
20
  export declare const EMAIL_PATTERN: RegExp;
18
- /** Whether `value` is an email address Siteping accepts — see {@link EMAIL_PATTERN}. */
21
+ /** Whether `value` is an email address Beezping accepts — see {@link EMAIL_PATTERN}. */
19
22
  export declare function isValidEmail(value: string): boolean;
package/dist/errors.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Typed error hierarchy for Siteping client/server boundaries.
2
+ * Typed error hierarchy for Beezping client/server boundaries.
3
3
  *
4
4
  * Consumers can `instanceof`-check or read `code` / `retryable` instead of
5
5
  * pattern-matching error messages. Designed to be additive on top of the
@@ -7,35 +7,49 @@
7
7
  * remain the canonical signals for server-side store implementations.
8
8
  *
9
9
  * Usage on the widget side (api-client.ts):
10
- * - fetch failures / aborts / timeouts → `SitepingNetworkError` (retryable)
11
- * - HTTP 4xx (except 401/403) → `SitepingValidationError` (not retryable)
12
- * - HTTP 401 / 403 → `SitepingAuthError` (not retryable)
13
- * - everything else → `SitepingError` generic
10
+ * - fetch failures / aborts / timeouts → `BeezpingNetworkError` (retryable)
11
+ * - HTTP 4xx (except 401/403) → `BeezpingValidationError` (not retryable)
12
+ * - HTTP 401 / 403 → `BeezpingAuthError` (not retryable)
13
+ * - everything else → `BeezpingError` generic
14
+ *
15
+ * Store mode (store-client.ts): a `createFeedback` that does not settle in
16
+ * time → `BeezpingError` with code `"TIMEOUT"` (retryable).
14
17
  *
15
18
  * `retryable` is meta information surfaced to host apps that want to wire
16
19
  * their own retry/queue/backoff strategy — the widget already retries
17
20
  * network failures via its built-in retry queue.
18
21
  */
19
22
  /**
20
- * Discriminant string carried by every `SitepingError`. Subclasses pin a
23
+ * Discriminant string carried by every `BeezpingError`. Subclasses pin a
21
24
  * literal value; the base class accepts a wider string so userland can
22
25
  * extend the hierarchy without colliding with built-ins.
23
26
  */
24
- export type SitepingErrorCode = "NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | (string & {});
25
- export declare class SitepingError<TCode extends SitepingErrorCode = SitepingErrorCode> extends Error {
27
+ export type BeezpingErrorCode = "NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | "TIMEOUT" | (string & {});
28
+ export declare class BeezpingError<TCode extends BeezpingErrorCode = BeezpingErrorCode> extends Error {
26
29
  readonly code: TCode;
27
30
  readonly retryable: boolean;
28
31
  constructor(message: string, code: TCode, retryable: boolean);
29
32
  }
30
33
  /** Network-level failure: connection refused, DNS, CORS, timeout, abort. Retryable. */
31
- export declare class SitepingNetworkError extends SitepingError<"NETWORK"> {
34
+ export declare class BeezpingNetworkError extends BeezpingError<"NETWORK"> {
32
35
  constructor(message: string);
33
36
  }
34
- /** Server rejected the request (4xx, not auth). Validation problem on the client side. */
35
- export declare class SitepingValidationError extends SitepingError<"VALIDATION"> {
36
- constructor(message: string);
37
+ /**
38
+ * Server rejected the request (4xx, not auth). Validation problem on the
39
+ * client side. `status` is the HTTP status when a response said so: a 404
40
+ * (the target is gone) and a 409 (a full thread) call for other answers
41
+ * than a 400.
42
+ */
43
+ export declare class BeezpingValidationError extends BeezpingError<"VALIDATION"> {
44
+ readonly status: number | undefined;
45
+ constructor(message: string, status?: number);
37
46
  }
38
47
  /** Server rejected auth (401 or 403). Not retryable without fresh credentials. */
39
- export declare class SitepingAuthError extends SitepingError<"AUTH"> {
40
- constructor(message: string);
48
+ export declare class BeezpingAuthError extends BeezpingError<"AUTH"> {
49
+ /**
50
+ * `401`: the credentials are missing or no longer work — drop a dead token
51
+ * on this one. `403`: they work, and the server's policy refuses this request.
52
+ */
53
+ readonly status: 401 | 403;
54
+ constructor(message: string, status: 401 | 403);
41
55
  }
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Typed error hierarchy for Siteping client/server boundaries.
2
+ * Typed error hierarchy for Beezping client/server boundaries.
3
3
  *
4
4
  * Consumers can `instanceof`-check or read `code` / `retryable` instead of
5
5
  * pattern-matching error messages. Designed to be additive on top of the
@@ -7,35 +7,49 @@
7
7
  * remain the canonical signals for server-side store implementations.
8
8
  *
9
9
  * Usage on the widget side (api-client.ts):
10
- * - fetch failures / aborts / timeouts → `SitepingNetworkError` (retryable)
11
- * - HTTP 4xx (except 401/403) → `SitepingValidationError` (not retryable)
12
- * - HTTP 401 / 403 → `SitepingAuthError` (not retryable)
13
- * - everything else → `SitepingError` generic
10
+ * - fetch failures / aborts / timeouts → `BeezpingNetworkError` (retryable)
11
+ * - HTTP 4xx (except 401/403) → `BeezpingValidationError` (not retryable)
12
+ * - HTTP 401 / 403 → `BeezpingAuthError` (not retryable)
13
+ * - everything else → `BeezpingError` generic
14
+ *
15
+ * Store mode (store-client.ts): a `createFeedback` that does not settle in
16
+ * time → `BeezpingError` with code `"TIMEOUT"` (retryable).
14
17
  *
15
18
  * `retryable` is meta information surfaced to host apps that want to wire
16
19
  * their own retry/queue/backoff strategy — the widget already retries
17
20
  * network failures via its built-in retry queue.
18
21
  */
19
22
  /**
20
- * Discriminant string carried by every `SitepingError`. Subclasses pin a
23
+ * Discriminant string carried by every `BeezpingError`. Subclasses pin a
21
24
  * literal value; the base class accepts a wider string so userland can
22
25
  * extend the hierarchy without colliding with built-ins.
23
26
  */
24
- export type SitepingErrorCode = "NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | (string & {});
25
- export declare class SitepingError<TCode extends SitepingErrorCode = SitepingErrorCode> extends Error {
27
+ export type BeezpingErrorCode = "NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | "TIMEOUT" | (string & {});
28
+ export declare class BeezpingError<TCode extends BeezpingErrorCode = BeezpingErrorCode> extends Error {
26
29
  readonly code: TCode;
27
30
  readonly retryable: boolean;
28
31
  constructor(message: string, code: TCode, retryable: boolean);
29
32
  }
30
33
  /** Network-level failure: connection refused, DNS, CORS, timeout, abort. Retryable. */
31
- export declare class SitepingNetworkError extends SitepingError<"NETWORK"> {
34
+ export declare class BeezpingNetworkError extends BeezpingError<"NETWORK"> {
32
35
  constructor(message: string);
33
36
  }
34
- /** Server rejected the request (4xx, not auth). Validation problem on the client side. */
35
- export declare class SitepingValidationError extends SitepingError<"VALIDATION"> {
36
- constructor(message: string);
37
+ /**
38
+ * Server rejected the request (4xx, not auth). Validation problem on the
39
+ * client side. `status` is the HTTP status when a response said so: a 404
40
+ * (the target is gone) and a 409 (a full thread) call for other answers
41
+ * than a 400.
42
+ */
43
+ export declare class BeezpingValidationError extends BeezpingError<"VALIDATION"> {
44
+ readonly status: number | undefined;
45
+ constructor(message: string, status?: number);
37
46
  }
38
47
  /** Server rejected auth (401 or 403). Not retryable without fresh credentials. */
39
- export declare class SitepingAuthError extends SitepingError<"AUTH"> {
40
- constructor(message: string);
48
+ export declare class BeezpingAuthError extends BeezpingError<"AUTH"> {
49
+ /**
50
+ * `401`: the credentials are missing or no longer work — drop a dead token
51
+ * on this one. `403`: they work, and the server's policy refuses this request.
52
+ */
53
+ readonly status: 401 | 403;
54
+ constructor(message: string, status: 401 | 403);
41
55
  }
@@ -4,13 +4,14 @@
4
4
  * near-identical copies of the same logic. Any adapter that holds an
5
5
  * in-memory snapshot of feedbacks can use it.
6
6
  *
7
- * Filtering order matches the historical adapter behaviour:
8
- * 1. projectName (always required)
9
- * 2. type
10
- * 3. status / statuses (`statuses` bucket wins when both are set)
11
- * 4. url
12
- * 5. urlPattern
13
- * 6. search (lowercase substring match on `message`)
7
+ * A record matches when it passes every active filter (see
8
+ * {@link matchesFeedbackQuery}):
9
+ * - projectName (always required)
10
+ * - type
11
+ * - status / statuses (`statuses` bucket wins when both are set)
12
+ * - url
13
+ * - urlPattern
14
+ * - search (lowercase substring match on `message`)
14
15
  *
15
16
  * Pagination goes through `clampPagination`: `limit` capped at 100, `page`
16
17
  * 1-based, both clamped up to 1 rather than indexing backwards from the end
@@ -63,6 +64,13 @@ export declare function clampPagination(query: Pick<FeedbackQuery, "page" | "lim
63
64
  * @returns `true` when no row can sit at that offset.
64
65
  */
65
66
  export declare function isUnreachableOffset(skip: number): boolean;
67
+ /**
68
+ * Whether one record passes every filter of `query` — the filter half of
69
+ * {@link applyFeedbackFilters}, exposed so a client holding a single record
70
+ * (e.g. the dashboard deciding whether an optimistic edit still belongs in
71
+ * its list) applies exactly the stores' semantics. Pagination is ignored.
72
+ */
73
+ export declare function matchesFeedbackQuery(record: FeedbackRecord, query: FeedbackQuery): boolean;
66
74
  /**
67
75
  * Apply the standard feedback filter + pagination pipeline against an
68
76
  * in-memory snapshot. Used by `MemoryStore.getFeedbacks` and
package/dist/filters.d.ts CHANGED
@@ -4,13 +4,14 @@
4
4
  * near-identical copies of the same logic. Any adapter that holds an
5
5
  * in-memory snapshot of feedbacks can use it.
6
6
  *
7
- * Filtering order matches the historical adapter behaviour:
8
- * 1. projectName (always required)
9
- * 2. type
10
- * 3. status / statuses (`statuses` bucket wins when both are set)
11
- * 4. url
12
- * 5. urlPattern
13
- * 6. search (lowercase substring match on `message`)
7
+ * A record matches when it passes every active filter (see
8
+ * {@link matchesFeedbackQuery}):
9
+ * - projectName (always required)
10
+ * - type
11
+ * - status / statuses (`statuses` bucket wins when both are set)
12
+ * - url
13
+ * - urlPattern
14
+ * - search (lowercase substring match on `message`)
14
15
  *
15
16
  * Pagination goes through `clampPagination`: `limit` capped at 100, `page`
16
17
  * 1-based, both clamped up to 1 rather than indexing backwards from the end
@@ -63,6 +64,13 @@ export declare function clampPagination(query: Pick<FeedbackQuery, "page" | "lim
63
64
  * @returns `true` when no row can sit at that offset.
64
65
  */
65
66
  export declare function isUnreachableOffset(skip: number): boolean;
67
+ /**
68
+ * Whether one record passes every filter of `query` — the filter half of
69
+ * {@link applyFeedbackFilters}, exposed so a client holding a single record
70
+ * (e.g. the dashboard deciding whether an optimistic edit still belongs in
71
+ * its list) applies exactly the stores' semantics. Pagination is ignored.
72
+ */
73
+ export declare function matchesFeedbackQuery(record: FeedbackRecord, query: FeedbackQuery): boolean;
66
74
  /**
67
75
  * Apply the standard feedback filter + pagination pipeline against an
68
76
  * in-memory snapshot. Used by `MemoryStore.getFeedbacks` and
package/dist/i18n.d.cts CHANGED
@@ -48,6 +48,24 @@ export interface I18n<T> {
48
48
  */
49
49
  registerLocale(code: string, translations: Partial<T>): void;
50
50
  }
51
+ /**
52
+ * `locale` as `Intl` accepts it. Locale options take any string, and a
53
+ * backend-style tag (`fr_FR` from PHP / WordPress) makes every `Intl`
54
+ * constructor and `toLocale*` call throw a RangeError: `_` reads as `-`, and
55
+ * a tag `Intl` still rejects falls back to English, as `createT` does.
56
+ * Silent, so it is safe on a render path; see `canonicalizeLocale` for the
57
+ * one-time, warning variant.
58
+ */
59
+ export declare function intlLocale(locale: string): string;
60
+ /**
61
+ * The host's locale tag as `Intl` accepts it. `BeezpingLocale` takes any
62
+ * string, and PHP / WordPress hand out POSIX-style tags (`fr_FR`, `pt_BR`)
63
+ * that every `Intl` constructor rejects with a RangeError: `_` becomes `-`,
64
+ * then the tag is canonicalised (`fr-FR`). Falls back to `"en"`, with a
65
+ * warning, only when the tag is still invalid (empty, garbage) — meant to run
66
+ * once, where the config is read.
67
+ */
68
+ export declare function canonicalizeLocale(locale: string): string;
51
69
  /**
52
70
  * Build the i18n machinery for one message catalog.
53
71
  *
package/dist/i18n.d.ts CHANGED
@@ -48,6 +48,24 @@ export interface I18n<T> {
48
48
  */
49
49
  registerLocale(code: string, translations: Partial<T>): void;
50
50
  }
51
+ /**
52
+ * `locale` as `Intl` accepts it. Locale options take any string, and a
53
+ * backend-style tag (`fr_FR` from PHP / WordPress) makes every `Intl`
54
+ * constructor and `toLocale*` call throw a RangeError: `_` reads as `-`, and
55
+ * a tag `Intl` still rejects falls back to English, as `createT` does.
56
+ * Silent, so it is safe on a render path; see `canonicalizeLocale` for the
57
+ * one-time, warning variant.
58
+ */
59
+ export declare function intlLocale(locale: string): string;
60
+ /**
61
+ * The host's locale tag as `Intl` accepts it. `BeezpingLocale` takes any
62
+ * string, and PHP / WordPress hand out POSIX-style tags (`fr_FR`, `pt_BR`)
63
+ * that every `Intl` constructor rejects with a RangeError: `_` becomes `-`,
64
+ * then the tag is canonicalised (`fr-FR`). Falls back to `"en"`, with a
65
+ * warning, only when the tag is still invalid (empty, garbage) — meant to run
66
+ * once, where the config is read.
67
+ */
68
+ export declare function canonicalizeLocale(locale: string): string;
51
69
  /**
52
70
  * Build the i18n machinery for one message catalog.
53
71
  *
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  MemoryStore: () => MemoryStore,
24
24
  StoreDuplicateError: () => StoreDuplicateError,
25
+ StoreLimitError: () => StoreLimitError,
25
26
  StoreNotFoundError: () => StoreNotFoundError,
26
27
  StorePersistenceError: () => StorePersistenceError,
27
28
  isStorePersistence: () => isStorePersistence
@@ -39,28 +40,21 @@ function clampPagination(query) {
39
40
  const limit = Math.min(toPositiveInteger(query.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
40
41
  return { page, limit, skip: (page - 1) * limit };
41
42
  }
42
- function isUnreachableOffset(skip) {
43
- return !Number.isSafeInteger(skip);
43
+ function sortTime(record) {
44
+ const time = record.createdAt.getTime();
45
+ return Number.isNaN(time) ? Number.MIN_SAFE_INTEGER : time;
46
+ }
47
+ function matchesFeedbackQuery(record, query) {
48
+ const { type, status, statuses, url, urlPattern, search } = query;
49
+ return record.projectName === query.projectName && (!type || record.type === type) && // `statuses` (bucket / any-of) wins over the exact `status` filter when
50
+ // both are present; an empty array is treated as absent.
51
+ (statuses && statuses.length > 0 ? statuses.includes(record.status) : !status || record.status === status) && (!url || record.url === url) && (!urlPattern || record.urlPattern === urlPattern) && (!search || record.message.toLowerCase().includes(search.toLowerCase()));
44
52
  }
45
53
  function applyFeedbackFilters(items, query) {
46
- let results = items.filter((f) => f.projectName === query.projectName);
47
- if (query.type) results = results.filter((f) => f.type === query.type);
48
- if (query.statuses && query.statuses.length > 0) {
49
- const allowed = query.statuses;
50
- results = results.filter((f) => allowed.includes(f.status));
51
- } else if (query.status) {
52
- results = results.filter((f) => f.status === query.status);
53
- }
54
- if (query.url) results = results.filter((f) => f.url === query.url);
55
- if (query.urlPattern) results = results.filter((f) => f.urlPattern === query.urlPattern);
56
- if (query.search) {
57
- const s = query.search.toLowerCase();
58
- results = results.filter((f) => f.message.toLowerCase().includes(s));
59
- }
60
- results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
54
+ const results = items.filter((f) => matchesFeedbackQuery(f, query));
55
+ results.sort((a, b) => sortTime(b) - sortTime(a));
61
56
  const total = results.length;
62
57
  const { limit, skip } = clampPagination(query);
63
- if (isUnreachableOffset(skip)) return { feedbacks: [], total };
64
58
  return { feedbacks: results.slice(skip, skip + limit), total };
65
59
  }
66
60
 
@@ -73,6 +67,7 @@ function hasOwn(value, key) {
73
67
  }
74
68
 
75
69
  // ../core/src/types.ts
70
+ var MAX_COMMENTS_PER_FEEDBACK = 100;
76
71
  var StoreNotFoundError = class extends Error {
77
72
  code = "STORE_NOT_FOUND";
78
73
  constructor(message = "Record not found", options) {
@@ -94,6 +89,13 @@ var StorePersistenceError = class extends Error {
94
89
  this.name = "StorePersistenceError";
95
90
  }
96
91
  };
92
+ var StoreLimitError = class extends Error {
93
+ code = "STORE_LIMIT";
94
+ constructor(message = "Store limit reached", options) {
95
+ super(message, options);
96
+ this.name = "StoreLimitError";
97
+ }
98
+ };
97
99
  function hasErrorCode(error, code) {
98
100
  return hasOwn(error, "code") && error.code === code;
99
101
  }
@@ -129,6 +131,18 @@ function buildAnnotationRecord(input, ctx) {
129
131
  createdAt: ctx.now
130
132
  };
131
133
  }
134
+ function buildCommentRecord(input, ctx) {
135
+ return {
136
+ id: ctx.id,
137
+ feedbackId: ctx.feedbackId,
138
+ body: input.body,
139
+ authorName: input.authorName,
140
+ authorEmail: input.authorEmail,
141
+ authorRole: input.authorRole,
142
+ clientId: input.clientId,
143
+ createdAt: ctx.now ?? /* @__PURE__ */ new Date()
144
+ };
145
+ }
132
146
  function buildFeedbackRecord(input, ctx) {
133
147
  const now = ctx.now ?? /* @__PURE__ */ new Date();
134
148
  return {
@@ -156,23 +170,24 @@ function buildFeedbackRecord(input, ctx) {
156
170
  };
157
171
  }
158
172
  function createCollectionStore(backend) {
159
- let mutationQueueTail = Promise.resolve();
160
- const runSerializedMutation = (mutation) => {
161
- const mutationResult = mutationQueueTail.then(mutation);
162
- mutationQueueTail = mutationResult.then(
163
- () => void 0,
164
- () => void 0
165
- );
166
- return mutationResult;
167
- };
168
- const insertFeedbackIfAbsent = async (data) => {
169
- const feedbacks = await backend.load();
173
+ let tail = Promise.resolve();
174
+ function mutate(mutation) {
175
+ const result = tail.then(() => {
176
+ const loaded = backend.load();
177
+ return Array.isArray(loaded) ? mutation(loaded) : loaded.then(mutation);
178
+ });
179
+ tail = result.catch(() => {
180
+ });
181
+ return result;
182
+ }
183
+ const createFeedbackIfAbsent = (data) => mutate(async (feedbacks) => {
170
184
  const existing = feedbacks.find((f) => f.clientId === data.clientId);
171
185
  if (existing) return { feedback: existing, created: false };
172
186
  const record = buildFeedbackRecord(data, {
173
187
  id: backend.generateId(),
174
188
  annotationId: () => backend.generateId()
175
189
  });
190
+ if (backend.comments) record.comments = [];
176
191
  const next = [record, ...feedbacks];
177
192
  try {
178
193
  await backend.persist(next);
@@ -182,9 +197,8 @@ function createCollectionStore(backend) {
182
197
  await backend.persist(next);
183
198
  }
184
199
  return { feedback: record, created: true };
185
- };
186
- const createFeedbackIfAbsent = (data) => runSerializedMutation(() => insertFeedbackIfAbsent(data));
187
- return {
200
+ });
201
+ const store = {
188
202
  createFeedbackIfAbsent,
189
203
  async createFeedback(data) {
190
204
  return (await createFeedbackIfAbsent(data)).feedback;
@@ -195,39 +209,55 @@ function createCollectionStore(backend) {
195
209
  async findByClientId(clientId) {
196
210
  return (await backend.load()).find((f) => f.clientId === clientId) ?? null;
197
211
  },
198
- updateFeedback(id, data) {
199
- return runSerializedMutation(async () => {
200
- const feedbacks = await backend.load();
201
- const current = feedbacks.find((f) => f.id === id);
202
- if (!current) throw new StoreNotFoundError();
203
- const updated = {
204
- ...current,
205
- status: data.status,
206
- resolvedAt: data.resolvedAt,
207
- updatedAt: /* @__PURE__ */ new Date()
208
- };
209
- await backend.persist(feedbacks.map((f) => f === current ? updated : f));
210
- return updated;
211
- });
212
- },
213
- deleteFeedback(id) {
214
- return runSerializedMutation(async () => {
215
- const feedbacks = await backend.load();
216
- if (!feedbacks.some((f) => f.id === id)) throw new StoreNotFoundError();
217
- await backend.persist(feedbacks.filter((f) => f.id !== id));
218
- });
219
- },
220
- deleteAllFeedbacks(projectName) {
221
- return runSerializedMutation(async () => {
222
- const feedbacks = await backend.load();
223
- await backend.persist(feedbacks.filter((f) => f.projectName !== projectName));
224
- });
225
- },
212
+ updateFeedback: (id, data) => mutate(async (feedbacks) => {
213
+ const current = feedbacks.find((f) => f.id === id);
214
+ if (!current) throw new StoreNotFoundError();
215
+ const updated = {
216
+ ...current,
217
+ status: data.status,
218
+ resolvedAt: data.resolvedAt,
219
+ updatedAt: /* @__PURE__ */ new Date()
220
+ };
221
+ await backend.persist(feedbacks.map((f) => f === current ? updated : f));
222
+ return updated;
223
+ }),
224
+ deleteFeedback: (id) => mutate(async (feedbacks) => {
225
+ if (!feedbacks.some((f) => f.id === id)) throw new StoreNotFoundError();
226
+ await backend.persist(feedbacks.filter((f) => f.id !== id));
227
+ }),
228
+ deleteAllFeedbacks: (projectName) => mutate(async (feedbacks) => {
229
+ await backend.persist(feedbacks.filter((f) => f.projectName !== projectName));
230
+ }),
226
231
  async verifyProjectOwnership(id, projectName) {
227
232
  const fb = (await backend.load()).find((f) => f.id === id);
228
233
  return fb !== void 0 && fb.projectName === projectName;
229
234
  }
230
235
  };
236
+ if (!backend.comments) return store;
237
+ return {
238
+ ...store,
239
+ addComment: (feedbackId, data) => mutate(async (feedbacks) => {
240
+ const existing = feedbacks.flatMap((f) => f.comments ?? []).find((c) => c.clientId === data.clientId);
241
+ if (existing) return existing;
242
+ const current = feedbacks.find((f) => f.id === feedbackId);
243
+ if (!current) throw new StoreNotFoundError();
244
+ const thread = current.comments ?? [];
245
+ if (data.authorRole === "client" && thread.filter((c) => c.authorRole === "client").length >= MAX_COMMENTS_PER_FEEDBACK) {
246
+ throw new StoreLimitError(`A thread holds at most ${MAX_COMMENTS_PER_FEEDBACK} client comments`);
247
+ }
248
+ const comment = buildCommentRecord(data, { id: backend.generateId(), feedbackId });
249
+ const updated = { ...current, comments: [...thread, comment] };
250
+ await backend.persist(feedbacks.map((f) => f === current ? updated : f));
251
+ return comment;
252
+ }),
253
+ deleteComment: (feedbackId, commentId) => mutate(async (feedbacks) => {
254
+ const current = feedbacks.find((f) => f.id === feedbackId);
255
+ const thread = current?.comments ?? [];
256
+ if (!current || !thread.some((c) => c.id === commentId)) throw new StoreNotFoundError();
257
+ const updated = { ...current, comments: thread.filter((c) => c.id !== commentId) };
258
+ await backend.persist(feedbacks.map((f) => f === current ? updated : f));
259
+ })
260
+ };
231
261
  }
232
262
 
233
263
  // src/index.ts
@@ -239,7 +269,9 @@ var MemoryStore = class {
239
269
  persist: (next) => {
240
270
  this.feedbacks = next;
241
271
  },
242
- generateId: () => `mem-${this.idCounter++}-${Date.now().toString(36)}`
272
+ generateId: () => `mem-${this.idCounter++}-${Date.now().toString(36)}`,
273
+ // Records live in memory: comment dates stay Dates.
274
+ comments: true
243
275
  });
244
276
  createFeedback(data) {
245
277
  return this.engine.createFeedback(data);
@@ -265,6 +297,12 @@ var MemoryStore = class {
265
297
  verifyProjectOwnership(id, projectName) {
266
298
  return this.engine.verifyProjectOwnership(id, projectName);
267
299
  }
300
+ addComment(feedbackId, data) {
301
+ return this.engine.addComment(feedbackId, data);
302
+ }
303
+ deleteComment(feedbackId, commentId) {
304
+ return this.engine.deleteComment(feedbackId, commentId);
305
+ }
268
306
  /** Remove all data from this store instance. */
269
307
  clear() {
270
308
  this.feedbacks = [];