@mohasinac/appkit 4.30.2 → 4.30.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/features/blog/actions/blog-actions.d.ts +7 -7
  2. package/dist/features/blog/api/route.js +12 -7
  3. package/dist/features/blog/repository/blog.repository.d.ts +6 -1
  4. package/dist/features/blog/repository/blog.repository.js +17 -2
  5. package/dist/features/blog/schemas/firestore.d.ts +2 -0
  6. package/dist/features/blog/schemas/firestore.js +2 -0
  7. package/dist/features/blog/schemas/index.d.ts +7 -7
  8. package/dist/features/events/repository/events.repository.d.ts +10 -1
  9. package/dist/features/events/repository/events.repository.js +21 -2
  10. package/dist/features/events/schemas/firestore.d.ts +2 -0
  11. package/dist/features/events/schemas/firestore.js +2 -0
  12. package/dist/features/products/repository/products.repository.js +4 -17
  13. package/dist/features/reviews/repository/reviews.repository.d.ts +11 -1
  14. package/dist/features/reviews/repository/reviews.repository.js +25 -2
  15. package/dist/features/stores/repository/store.repository.d.ts +6 -1
  16. package/dist/features/stores/repository/store.repository.js +16 -2
  17. package/dist/features/stores/schemas/firestore.d.ts +2 -0
  18. package/dist/features/stores/schemas/firestore.js +2 -0
  19. package/dist/features/stores/schemas/index.d.ts +2 -2
  20. package/dist/providers/db-firebase/base.repository.d.ts +2 -1
  21. package/dist/schemas/registry.d.ts +4 -4
  22. package/dist/seed/_helpers/product-search-txt.js +5 -15
  23. package/dist/seed/_helpers/search-txt-wrappers.d.ts +4 -16
  24. package/dist/seed/_helpers/search-txt-wrappers.js +5 -65
  25. package/dist/utils/search-txt-builders.d.ts +23 -0
  26. package/dist/utils/search-txt-builders.js +86 -0
  27. package/dist/utils/search-txt-query.d.ts +42 -0
  28. package/dist/utils/search-txt-query.js +61 -0
  29. package/package.json +1 -1
@@ -102,9 +102,9 @@ export declare const createBlogPostSchema: z.ZodObject<{
102
102
  } | null | undefined;
103
103
  authorId?: string | undefined;
104
104
  authorName?: string | undefined;
105
+ authorAvatar?: string | undefined;
105
106
  metaTitle?: string | undefined;
106
107
  metaDescription?: string | undefined;
107
- authorAvatar?: string | undefined;
108
108
  }, {
109
109
  title: string;
110
110
  content: string;
@@ -130,8 +130,6 @@ export declare const createBlogPostSchema: z.ZodObject<{
130
130
  alt?: string | undefined;
131
131
  thumbnailUrl?: string | undefined;
132
132
  }[] | undefined;
133
- metaTitle?: string | undefined;
134
- metaDescription?: string | undefined;
135
133
  contentImages?: {
136
134
  type: "video" | "image" | "file";
137
135
  url: string;
@@ -139,6 +137,8 @@ export declare const createBlogPostSchema: z.ZodObject<{
139
137
  thumbnailUrl?: string | undefined;
140
138
  }[] | undefined;
141
139
  authorAvatar?: string | undefined;
140
+ metaTitle?: string | undefined;
141
+ metaDescription?: string | undefined;
142
142
  }>;
143
143
  export declare const updateBlogPostSchema: z.ZodObject<{
144
144
  title: z.ZodOptional<z.ZodString>;
@@ -235,8 +235,6 @@ export declare const updateBlogPostSchema: z.ZodObject<{
235
235
  alt?: string | undefined;
236
236
  thumbnailUrl?: string | undefined;
237
237
  }[] | undefined;
238
- metaTitle?: string | undefined;
239
- metaDescription?: string | undefined;
240
238
  contentImages?: {
241
239
  type: "video" | "image" | "file";
242
240
  url: string;
@@ -244,6 +242,8 @@ export declare const updateBlogPostSchema: z.ZodObject<{
244
242
  thumbnailUrl?: string | undefined;
245
243
  }[] | undefined;
246
244
  authorAvatar?: string | undefined;
245
+ metaTitle?: string | undefined;
246
+ metaDescription?: string | undefined;
247
247
  }, {
248
248
  status?: "draft" | "published" | "archived" | undefined;
249
249
  title?: string | undefined;
@@ -269,8 +269,6 @@ export declare const updateBlogPostSchema: z.ZodObject<{
269
269
  alt?: string | undefined;
270
270
  thumbnailUrl?: string | undefined;
271
271
  }[] | undefined;
272
- metaTitle?: string | undefined;
273
- metaDescription?: string | undefined;
274
272
  contentImages?: {
275
273
  type: "video" | "image" | "file";
276
274
  url: string;
@@ -278,6 +276,8 @@ export declare const updateBlogPostSchema: z.ZodObject<{
278
276
  thumbnailUrl?: string | undefined;
279
277
  }[] | undefined;
280
278
  authorAvatar?: string | undefined;
279
+ metaTitle?: string | undefined;
280
+ metaDescription?: string | undefined;
281
281
  }>;
282
282
  export type CreateBlogPostInput = z.infer<typeof createBlogPostSchema>;
283
283
  export type UpdateBlogPostInput = z.infer<typeof updateBlogPostSchema>;
@@ -8,6 +8,7 @@
8
8
  * export { GET } from "@mohasinac/feat-blog";
9
9
  * ```
10
10
  */
11
+ import { blogRepository } from "../repository/blog.repository";
11
12
  import { NextResponse } from "next/server.js";
12
13
  import { getProviders } from "../../../contracts";
13
14
  import { normalizeError } from "../../../errors/normalize";
@@ -33,9 +34,11 @@ export async function GET(request) {
33
34
  const tags = param(url, "tags");
34
35
  if (tags)
35
36
  parts.push(`tags@=${tags}`);
37
+ // Token search rides OUTSIDE `filters` — array-contains is not expressible
38
+ // in Sieve, and as `title@=*` this was a case-SENSITIVE prefix match on the
39
+ // generic repository path (`base.ts`), so "Beyblade" matched and "beyblade"
40
+ // did not. searchTxt is normalised, so case and accents stop mattering.
36
41
  const q = param(url, "q");
37
- if (q)
38
- parts.push(`title@=*${q}`);
39
42
  const featured = param(url, "featured");
40
43
  if (featured === "true")
41
44
  parts.push("isFeatured==true");
@@ -46,17 +49,19 @@ export async function GET(request) {
46
49
  if (!db) {
47
50
  return NextResponse.json({ success: false, error: "Database provider not registered" }, { status: 503 });
48
51
  }
49
- const repo = db.getRepository("blogPosts");
50
- const result = await repo.findAll({ filters, sort, page, perPage });
52
+ // `blogRepository.listAll` rather than the generic `db.getRepository(...)`:
53
+ // only the feature repository knows how to push a searchTxt clause down and
54
+ // AND-refine the remaining terms.
55
+ const result = await blogRepository.listAll({ filters, sorts: sort, page, pageSize: perPage }, q ? { search: q } : undefined);
51
56
  const meta = {
52
57
  total: result.total,
53
58
  page: result.page,
54
- pageSize: result.perPage,
59
+ pageSize: result.pageSize,
55
60
  totalPages: result.totalPages,
56
- hasMore: result.page < result.totalPages,
61
+ hasMore: result.hasMore,
57
62
  };
58
63
  const body = {
59
- posts: result.data,
64
+ posts: result.items,
60
65
  meta,
61
66
  };
62
67
  return NextResponse.json({ success: true, data: body });
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from "../../../schemas/types";
1
2
  import type { FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
2
3
  import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
3
4
  import { type BlogPostCreateInput, type BlogPostDocument, type BlogPostUpdateInput } from "../schemas";
@@ -86,7 +87,11 @@ declare class BlogRepository extends BaseRepository<BlogPostDocument> {
86
87
  category?: BlogPostCategory;
87
88
  featuredOnly?: boolean;
88
89
  }, model: SieveModel): Promise<FirebaseSieveResult<BlogPostDocument>>;
89
- listAll(model: SieveModel): Promise<FirebaseSieveResult<BlogPostDocument>>;
90
+ /** Derived on every write path via `applyWriteHooks`. */
91
+ protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
92
+ listAll(model: SieveModel, opts?: {
93
+ search?: string;
94
+ }): Promise<FirebaseSieveResult<BlogPostDocument>>;
90
95
  }
91
96
  declare const blogRepository: BlogRepository;
92
97
  export { BlogRepository, blogRepository };
@@ -4,6 +4,8 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
4
4
  import { BLOG_POST_FIELDS, BLOG_POSTS_COLLECTION, } from "../schemas";
5
5
  import { createBlogPostId } from "../schemas";
6
6
  import { increment } from "../../../contracts/field-ops";
7
+ import { buildBlogSearchTxt } from "../../../utils/search-txt-builders";
8
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
7
9
  class BlogRepository extends BaseRepository {
8
10
  constructor() {
9
11
  super(BLOG_POSTS_COLLECTION);
@@ -153,11 +155,24 @@ class BlogRepository extends BaseRepository {
153
155
  maxPageSize: 50,
154
156
  });
155
157
  }
156
- async listAll(model) {
157
- return this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
158
+ /** Derived on every write path via `applyWriteHooks`. */
159
+ buildSearchTxtFor(data) {
160
+ return buildBlogSearchTxt(data);
161
+ }
162
+ async listAll(model, opts) {
163
+ const plan = planSearchTxt(opts?.search);
164
+ if (plan.empty)
165
+ return emptySearchResult();
166
+ let baseQuery = this.getCollection();
167
+ if (plan.head) {
168
+ baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
169
+ }
170
+ const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
171
+ baseQuery,
158
172
  defaultPageSize: 50,
159
173
  maxPageSize: 200,
160
174
  });
175
+ return refineSearchTxt(result, plan.rest);
161
176
  }
162
177
  }
163
178
  BlogRepository.SIEVE_FIELDS = {
@@ -42,6 +42,8 @@ export interface BlogPostDocument extends BaseDocument {
42
42
  export declare const BLOG_POSTS_COLLECTION: "blogPosts";
43
43
  export declare const BLOG_POSTS_INDEXED_FIELDS: readonly ["searchTxt", "status", "publishedAt", "isFeatured", "category", "slug"];
44
44
  export declare const BLOG_POST_FIELDS: {
45
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
46
+ readonly SEARCH_TXT: "searchTxt";
45
47
  readonly ID: "id";
46
48
  readonly TITLE: "title";
47
49
  readonly SLUG: "slug";
@@ -18,6 +18,8 @@ export const BLOG_POSTS_INDEXED_FIELDS = [
18
18
  "slug",
19
19
  ];
20
20
  export const BLOG_POST_FIELDS = {
21
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
22
+ SEARCH_TXT: "searchTxt",
21
23
  ID: "id",
22
24
  TITLE: "title",
23
25
  SLUG: "slug",
@@ -155,8 +155,6 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
155
155
  youtubeId?: string | undefined;
156
156
  duration?: number | undefined;
157
157
  }[] | undefined;
158
- metaTitle?: string | undefined;
159
- metaDescription?: string | undefined;
160
158
  contentImages?: {
161
159
  type: "video" | "image" | "file";
162
160
  url: string;
@@ -167,6 +165,8 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
167
165
  duration?: number | undefined;
168
166
  }[] | undefined;
169
167
  authorAvatar?: string | undefined;
168
+ metaTitle?: string | undefined;
169
+ metaDescription?: string | undefined;
170
170
  }, {
171
171
  status: "draft" | "published" | "archived";
172
172
  id: string;
@@ -208,8 +208,6 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
208
208
  youtubeId?: string | undefined;
209
209
  duration?: number | undefined;
210
210
  }[] | undefined;
211
- metaTitle?: string | undefined;
212
- metaDescription?: string | undefined;
213
211
  contentImages?: {
214
212
  type: "video" | "image" | "file";
215
213
  url: string;
@@ -220,6 +218,8 @@ export declare const blogPostFirestoreSchema: z.ZodObject<{
220
218
  duration?: number | undefined;
221
219
  }[] | undefined;
222
220
  authorAvatar?: string | undefined;
221
+ metaTitle?: string | undefined;
222
+ metaDescription?: string | undefined;
223
223
  }>;
224
224
  export type BlogPostFromSchema = z.infer<typeof blogPostFirestoreSchema>;
225
225
  export declare const blogPostCategorySchema: z.ZodEnum<["news", "tips", "guides", "updates", "community"]>;
@@ -401,9 +401,9 @@ export declare const blogPostSchema: z.ZodObject<{
401
401
  authorId?: string | undefined;
402
402
  authorName?: string | undefined;
403
403
  readTimeMinutes?: number | undefined;
404
+ authorAvatar?: string | undefined;
404
405
  metaTitle?: string | undefined;
405
406
  metaDescription?: string | undefined;
406
- authorAvatar?: string | undefined;
407
407
  }, {
408
408
  status: "draft" | "published" | "archived";
409
409
  id: string;
@@ -439,8 +439,6 @@ export declare const blogPostSchema: z.ZodObject<{
439
439
  youtubeId?: string | undefined;
440
440
  duration?: number | undefined;
441
441
  }[] | undefined;
442
- metaTitle?: string | undefined;
443
- metaDescription?: string | undefined;
444
442
  contentImages?: {
445
443
  type: "video" | "image" | "file";
446
444
  url: string;
@@ -451,6 +449,8 @@ export declare const blogPostSchema: z.ZodObject<{
451
449
  duration?: number | undefined;
452
450
  }[] | undefined;
453
451
  authorAvatar?: string | undefined;
452
+ metaTitle?: string | undefined;
453
+ metaDescription?: string | undefined;
454
454
  }>;
455
455
  /** Base Zod schema for list-query parameters. */
456
456
  export declare const blogListParamsSchema: z.ZodObject<{
@@ -1,10 +1,19 @@
1
+ import type { JsonValue } from "../../../schemas/types";
1
2
  import type { FirebaseSieveFields, FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
2
3
  import { BaseRepository } from "../../../providers/db-firebase";
3
4
  import { type EventCreateInput, type EventDocument, type EventUpdateInput } from "../schemas";
4
5
  declare class EventRepository extends BaseRepository<EventDocument> {
5
6
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
6
7
  constructor();
7
- list(model: SieveModel): Promise<FirebaseSieveResult<EventDocument>>;
8
+ /**
9
+ * Derived on every write path via `applyWriteHooks`. The field, its index and
10
+ * its seed tokens all existed before this — but nothing wrote it for a
11
+ * document created through the app, so only seeded events were searchable.
12
+ */
13
+ protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
14
+ list(model: SieveModel, opts?: {
15
+ search?: string;
16
+ }): Promise<FirebaseSieveResult<EventDocument>>;
8
17
  /**
9
18
  * How many events exist per `type` and per `status` — the numbers behind the
10
19
  * facet checkboxes, and what lets a type with no events disappear from the
@@ -6,12 +6,31 @@ import { normalizeError } from "../../../errors/normalize";
6
6
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
7
7
  import { EVENT_FIELDS, EVENTS_COLLECTION, } from "../schemas";
8
8
  import { increment } from "../../../contracts/field-ops";
9
+ import { buildEventSearchTxt } from "../../../utils/search-txt-builders";
10
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
9
11
  class EventRepository extends BaseRepository {
10
12
  constructor() {
11
13
  super(EVENTS_COLLECTION);
12
14
  }
13
- async list(model) {
14
- return this.sieveQuery(model, EventRepository.SIEVE_FIELDS);
15
+ /**
16
+ * Derived on every write path via `applyWriteHooks`. The field, its index and
17
+ * its seed tokens all existed before this — but nothing wrote it for a
18
+ * document created through the app, so only seeded events were searchable.
19
+ */
20
+ buildSearchTxtFor(data) {
21
+ return buildEventSearchTxt(data);
22
+ }
23
+ async list(model, opts) {
24
+ const plan = planSearchTxt(opts?.search);
25
+ // A search that yielded no usable term must return NOTHING, not everything.
26
+ if (plan.empty)
27
+ return emptySearchResult();
28
+ let baseQuery = this.getCollection();
29
+ if (plan.head) {
30
+ baseQuery = baseQuery.where(EVENT_FIELDS.SEARCH_TXT, "array-contains", plan.head);
31
+ }
32
+ const result = await this.sieveQuery(model, EventRepository.SIEVE_FIELDS, { baseQuery });
33
+ return refineSearchTxt(result, plan.rest);
15
34
  }
16
35
  /**
17
36
  * How many events exist per `type` and per `status` — the numbers behind the
@@ -105,6 +105,8 @@ export type EventEntryDocument = {
105
105
  export declare const EVENT_INDEXED_FIELDS: readonly ["searchTxt", "type", "status", "startsAt", "endsAt", "createdAt"];
106
106
  export declare const EVENT_ENTRY_INDEXED_FIELDS: readonly ["eventId", "userId", "guestIpHash", "reviewStatus", "status", "submittedAt", "points", "spinWonAt"];
107
107
  export declare const EVENT_FIELDS: {
108
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
109
+ readonly SEARCH_TXT: "searchTxt";
108
110
  readonly ID: "id";
109
111
  readonly SLUG: "slug";
110
112
  readonly TYPE: "type";
@@ -22,6 +22,8 @@ export const EVENT_ENTRY_INDEXED_FIELDS = [
22
22
  "spinWonAt",
23
23
  ];
24
24
  export const EVENT_FIELDS = {
25
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
26
+ SEARCH_TXT: "searchTxt",
25
27
  ID: "id",
26
28
  SLUG: "slug",
27
29
  TYPE: "type",
@@ -5,7 +5,10 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
5
5
  import { cacheManager } from "../../../core";
6
6
  import { serverLogger } from "../../../monitoring";
7
7
  import { generateUniqueId, slugify, generateBarcodeId } from "../../../utils";
8
- import { buildSearchTxt, matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
8
+ import { matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
9
+ // One definition of "which fields feed searchTxt", shared with the seed
10
+ // wrapper. Three copies existed and two were missing the same three fields.
11
+ import { buildProductSearchTxt } from "../../../utils/search-txt-builders";
9
12
  import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
10
13
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
11
14
  /**
@@ -46,22 +49,6 @@ function buildListingKindClause(kind, inverted) {
46
49
  const op = inverted ? "!=" : "==";
47
50
  return `${PRODUCT_FIELDS.LISTING_TYPE}${op}${canonical}`;
48
51
  }
49
- function buildProductSearchTxt(p) {
50
- return buildSearchTxt([
51
- p.title,
52
- p.description,
53
- p.brand,
54
- p.brandSlug,
55
- p.categoryNames,
56
- p.tags,
57
- p.features,
58
- p.condition,
59
- p.card?.setName,
60
- p.card?.cardNumber,
61
- p.grading?.service,
62
- p.specifications?.map((s) => `${s.name} ${s.value}`),
63
- ]);
64
- }
65
52
  export class ProductRepository extends BaseRepository {
66
53
  constructor() {
67
54
  super(PRODUCT_COLLECTION);
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from "../../../schemas/types";
1
2
  import type { FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
2
3
  import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
3
4
  import { type ReviewCreateInput, type ReviewDocument } from "../schemas";
@@ -173,7 +174,16 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
173
174
  };
174
175
  listForProduct(productId: string, model: SieveModel): Promise<FirebaseSieveResult<ReviewDocument>>;
175
176
  listForStore(storeId: string, model: SieveModel): Promise<FirebaseSieveResult<ReviewDocument>>;
176
- listAll(model: SieveModel): Promise<FirebaseSieveResult<ReviewDocument>>;
177
+ /**
178
+ * Derived on every write path via `applyWriteHooks`.
179
+ *
180
+ * `userName` is PII and is deliberately absent from the token source — a
181
+ * searchTxt for a person's name IS that name, re-encoded (D1).
182
+ */
183
+ protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
184
+ listAll(model: SieveModel, opts?: {
185
+ search?: string;
186
+ }): Promise<FirebaseSieveResult<ReviewDocument>>;
177
187
  }
178
188
  declare const reviewRepository: ReviewRepository;
179
189
  export { ReviewRepository, reviewRepository };
@@ -3,7 +3,11 @@ import { NotFoundError } from "../../../errors";
3
3
  import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
4
4
  import { decryptPiiFields, encryptPiiFields, piiIndicesFor, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
5
5
  import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
6
+ import { buildReviewSearchTxt } from "../../../utils/search-txt-builders";
7
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
6
8
  const REVIEW_FIELDS = {
9
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
10
+ SEARCH_TXT: "searchTxt",
7
11
  PRODUCT_ID: "productId",
8
12
  USER_ID: "userId",
9
13
  STORE_ID: "storeId",
@@ -309,12 +313,31 @@ class ReviewRepository extends BaseRepository {
309
313
  });
310
314
  return this.decryptSieveResult(result);
311
315
  }
312
- async listAll(model) {
316
+ /**
317
+ * Derived on every write path via `applyWriteHooks`.
318
+ *
319
+ * `userName` is PII and is deliberately absent from the token source — a
320
+ * searchTxt for a person's name IS that name, re-encoded (D1).
321
+ */
322
+ buildSearchTxtFor(data) {
323
+ return buildReviewSearchTxt(data);
324
+ }
325
+ async listAll(model, opts) {
326
+ const plan = planSearchTxt(opts?.search);
327
+ if (plan.empty)
328
+ return emptySearchResult();
329
+ let baseQuery = this.getCollection();
330
+ if (plan.head) {
331
+ baseQuery = baseQuery.where(REVIEW_FIELDS.SEARCH_TXT, "array-contains", plan.head);
332
+ }
313
333
  const result = await this.sieveQuery(model, ReviewRepository.SIEVE_FIELDS, {
334
+ baseQuery,
314
335
  defaultPageSize: 50,
315
336
  maxPageSize: 200,
316
337
  });
317
- return this.decryptSieveResult(result);
338
+ // Refine BEFORE decrypting: the refine reads `searchTxt`, which is never
339
+ // encrypted, and decrypting rows that are about to be dropped is waste.
340
+ return this.decryptSieveResult(refineSearchTxt(result, plan.rest));
318
341
  }
319
342
  }
320
343
  ReviewRepository.SIEVE_FIELDS = {
@@ -7,6 +7,7 @@
7
7
  import { BaseRepository, type SieveModel, type FirebaseSieveResult, type DocumentSnapshot } from "../../../providers/db-firebase";
8
8
  import { StoreDocument } from "../schemas";
9
9
  import { type HistoryActor } from "../../../_internal/shared/history/index";
10
+ import type { JsonValue } from "../../../schemas/types";
10
11
  /** Who/why for a store write that lands in `statusHistory`. */
11
12
  export interface StoreWriteContext {
12
13
  actor?: HistoryActor;
@@ -89,7 +90,11 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
89
90
  * Paginated list of stores with Sieve filtering/sorting.
90
91
  * Filters to active + public stores for the public listing.
91
92
  */
92
- listStores(model: SieveModel, activeOnly?: boolean): Promise<FirebaseSieveResult<StoreDocument>>;
93
+ /** Derived on every write path via `applyWriteHooks`. */
94
+ protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
95
+ listStores(model: SieveModel, activeOnly?: boolean, opts?: {
96
+ search?: string;
97
+ }): Promise<FirebaseSieveResult<StoreDocument>>;
93
98
  /**
94
99
  * Admin: list all stores regardless of status.
95
100
  */
@@ -11,6 +11,8 @@ import { DatabaseError } from "../../../errors";
11
11
  import { normalizeError } from "../../../errors/normalize";
12
12
  import { encryptSecret, decryptSecret, } from "../../../security/settings-encryption";
13
13
  import { withHistory } from "../../../_internal/shared/history/index";
14
+ import { buildStoreSearchTxt } from "../../../utils/search-txt-builders";
15
+ import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
14
16
  export class StoreRepository extends BaseRepository {
15
17
  constructor() {
16
18
  super(STORE_COLLECTION);
@@ -178,7 +180,11 @@ export class StoreRepository extends BaseRepository {
178
180
  * Paginated list of stores with Sieve filtering/sorting.
179
181
  * Filters to active + public stores for the public listing.
180
182
  */
181
- async listStores(model, activeOnly = true) {
183
+ /** Derived on every write path via `applyWriteHooks`. */
184
+ buildSearchTxtFor(data) {
185
+ return buildStoreSearchTxt(data);
186
+ }
187
+ async listStores(model, activeOnly = true, opts) {
182
188
  const sieveFields = {
183
189
  [STORE_FIELDS.STORE_NAME]: { canFilter: true, canSort: true },
184
190
  searchTxt: { canFilter: true, canSort: false },
@@ -196,13 +202,20 @@ export class StoreRepository extends BaseRepository {
196
202
  "stats.averageRating": { canFilter: false, canSort: true },
197
203
  "stats.totalProducts": { canFilter: true, canSort: false },
198
204
  };
205
+ const plan = planSearchTxt(opts?.search);
206
+ // A search that yielded no usable term must return NOTHING, not everything.
207
+ if (plan.empty)
208
+ return emptySearchResult();
199
209
  let baseQuery = this.getCollection();
200
210
  if (activeOnly) {
201
211
  baseQuery = baseQuery
202
212
  .where(STORE_FIELDS.STATUS, "==", "active")
203
213
  .where(STORE_FIELDS.IS_PUBLIC, "==", true);
204
214
  }
205
- return applySieveToFirestore({
215
+ if (plan.head) {
216
+ baseQuery = baseQuery.where(STORE_FIELDS.SEARCH_TXT, "array-contains", plan.head);
217
+ }
218
+ const result = await applySieveToFirestore({
206
219
  baseQuery,
207
220
  model,
208
221
  fields: sieveFields,
@@ -212,6 +225,7 @@ export class StoreRepository extends BaseRepository {
212
225
  // WhatsApp bearer token and a list has no use for it.
213
226
  mapDoc: (snap) => this.mapDocForList(snap),
214
227
  });
228
+ return refineSearchTxt(result, plan.rest);
215
229
  }
216
230
  /**
217
231
  * Admin: list all stores regardless of status.
@@ -174,6 +174,8 @@ export declare const STORE_HISTORY_PII_FIELDS: readonly ["accessToken", "payoutD
174
174
  export type { StoreCapability } from "../../auth/permissions/constants";
175
175
  export declare const STORE_COLLECTION: "stores";
176
176
  export declare const STORE_FIELDS: {
177
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
178
+ readonly SEARCH_TXT: "searchTxt";
177
179
  readonly ID: "id";
178
180
  readonly STORE_SLUG: "storeSlug";
179
181
  readonly OWNER_ID: "ownerId";
@@ -45,6 +45,8 @@ export const STORE_HISTORY_PII_FIELDS = [
45
45
  ];
46
46
  export const STORE_COLLECTION = "stores";
47
47
  export const STORE_FIELDS = {
48
+ /** Word-prefix search tokens. Derived on write by `buildSearchTxtFor`. */
49
+ SEARCH_TXT: "searchTxt",
48
50
  ID: "id",
49
51
  STORE_SLUG: "storeSlug",
50
52
  OWNER_ID: "ownerId",
@@ -617,9 +617,9 @@ export declare const storeListItemSchema: z.ZodObject<{
617
617
  storeLogoURL?: string | undefined;
618
618
  storeBannerURL?: string | undefined;
619
619
  itemsSold?: number | undefined;
620
- averageRating?: number | undefined;
621
620
  totalProducts?: number | undefined;
622
621
  totalReviews?: number | undefined;
622
+ averageRating?: number | undefined;
623
623
  }, {
624
624
  status: string;
625
625
  id: string;
@@ -633,9 +633,9 @@ export declare const storeListItemSchema: z.ZodObject<{
633
633
  storeLogoURL?: string | undefined;
634
634
  storeBannerURL?: string | undefined;
635
635
  itemsSold?: number | undefined;
636
- averageRating?: number | undefined;
637
636
  totalProducts?: number | undefined;
638
637
  totalReviews?: number | undefined;
638
+ averageRating?: number | undefined;
639
639
  }>;
640
640
  export declare const storeListParamsSchema: z.ZodObject<{
641
641
  category: z.ZodOptional<z.ZodString>;
@@ -1,6 +1,7 @@
1
1
  import type { FirestoreDocument } from "@mohasinac/appkit";
2
2
  import type { CollectionReference, DocumentData, DocumentReference, DocumentSnapshot, Firestore, Query, Transaction, WriteBatch } from "firebase-admin/firestore";
3
3
  import { type FirebaseSieveFields, type FirebaseSieveOptions, type FirebaseSieveResult, type SieveModel } from "./sieve";
4
+ import type { JsonValue } from "../../schemas/types";
4
5
  export declare abstract class BaseRepository<T extends DocumentData> {
5
6
  protected collection: string;
6
7
  constructor(collectionName: string);
@@ -46,7 +47,7 @@ export declare abstract class BaseRepository<T extends DocumentData> {
46
47
  * MUST NOT include PII: `searchTxt` stores readable fragments of the source
47
48
  * text, so indexing an encrypted field would undo the encryption.
48
49
  */
49
- protected buildSearchTxtFor(_data: Record<string, unknown>): string[] | null;
50
+ protected buildSearchTxtFor(_data: Record<string, JsonValue>): string[] | null;
50
51
  /**
51
52
  * The one place writes are transformed. Every write path routes through here
52
53
  * so a new method cannot silently skip encryption.
@@ -226,8 +226,6 @@ export declare const SCHEMAS: {
226
226
  youtubeId?: string | undefined;
227
227
  duration?: number | undefined;
228
228
  }[] | undefined;
229
- metaTitle?: string | undefined;
230
- metaDescription?: string | undefined;
231
229
  contentImages?: {
232
230
  type: "video" | "image" | "file";
233
231
  url: string;
@@ -238,6 +236,8 @@ export declare const SCHEMAS: {
238
236
  duration?: number | undefined;
239
237
  }[] | undefined;
240
238
  authorAvatar?: string | undefined;
239
+ metaTitle?: string | undefined;
240
+ metaDescription?: string | undefined;
241
241
  }, {
242
242
  status: "draft" | "published" | "archived";
243
243
  id: string;
@@ -279,8 +279,6 @@ export declare const SCHEMAS: {
279
279
  youtubeId?: string | undefined;
280
280
  duration?: number | undefined;
281
281
  }[] | undefined;
282
- metaTitle?: string | undefined;
283
- metaDescription?: string | undefined;
284
282
  contentImages?: {
285
283
  type: "video" | "image" | "file";
286
284
  url: string;
@@ -291,6 +289,8 @@ export declare const SCHEMAS: {
291
289
  duration?: number | undefined;
292
290
  }[] | undefined;
293
291
  authorAvatar?: string | undefined;
292
+ metaTitle?: string | undefined;
293
+ metaDescription?: string | undefined;
294
294
  }>;
295
295
  readonly carousels: import("zod").ZodObject<{
296
296
  createdAt: import("zod").ZodUnion<[import("zod").ZodDate, import("zod").ZodString, import("zod").ZodObject<{
@@ -1,4 +1,4 @@
1
- import { buildSearchTxt } from "../../utils/search-txt";
1
+ import { buildProductSearchTxt } from "../../utils/search-txt-builders";
2
2
  /**
3
3
  * Derive `searchTxt` for a seeded product from its own content.
4
4
  *
@@ -16,18 +16,8 @@ import { buildSearchTxt } from "../../utils/search-txt";
16
16
  * reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
17
17
  */
18
18
  export function withProductSearchTxt(p) {
19
- return {
20
- ...p,
21
- searchTxt: buildSearchTxt([
22
- p.title,
23
- p.description,
24
- p.brand,
25
- p.brandSlug,
26
- p.categoryNames,
27
- p.tags,
28
- p.features,
29
- p.condition,
30
- p.specifications?.map((s) => `${s.name} ${s.value}`),
31
- ]),
32
- };
19
+ // Delegates rather than re-deriving. This wrapper listed 9 sources while the
20
+ // write path indexed 12, so a seeded product was findable by fewer terms than
21
+ // the identical product created through the app — with no error on either side.
22
+ return { ...p, searchTxt: buildProductSearchTxt(p) };
33
23
  }
@@ -1,20 +1,8 @@
1
- import type { StoreDocument } from "../../features/stores/schemas";
2
- import type { EventDocument } from "../../features/events/schemas";
3
- import type { BlogPostDocument } from "../../features/blog/schemas";
4
- import type { ReviewDocument } from "../../features/reviews/schemas";
5
- /** stores — name, description, category. No payout details, no contact. */
1
+ import type { StoreDocument } from "../../features/stores/schemas/firestore";
2
+ import type { EventDocument } from "../../features/events/schemas/firestore";
3
+ import type { BlogPostDocument } from "../../features/blog/schemas/firestore";
4
+ import type { ReviewDocument } from "../../features/reviews/schemas/firestore";
6
5
  export declare function withStoreSearchTxt<T extends Partial<StoreDocument>>(p: T): T;
7
- /** events — title, description, type, tags. */
8
6
  export declare function withEventSearchTxt<T extends Partial<EventDocument>>(p: T): T;
9
- /**
10
- * blogPosts — title, excerpt, category, tags.
11
- *
12
- * The full `content` body is deliberately EXCLUDED. FAQ answers are short
13
- * (measured: max 449 tokens, avg 200, nothing near the 600 cap), but blog bodies
14
- * are an order of magnitude larger and would blow the cap — at which point
15
- * `buildSearchTxt` truncates silently and the tail of every post becomes
16
- * unsearchable with no error anywhere. The excerpt is the searchable summary.
17
- */
18
7
  export declare function withBlogSearchTxt<T extends Partial<BlogPostDocument>>(p: T): T;
19
- /** reviews — title, comment, productTitle. `userName` is PII and excluded. */
20
8
  export declare function withReviewSearchTxt<T extends Partial<ReviewDocument>>(p: T): T;
@@ -1,73 +1,13 @@
1
- import { buildSearchTxt } from "../../utils/search-txt";
2
- /**
3
- * Per-collection `searchTxt` derivation for seed data.
4
- *
5
- * Applied to the WHOLE array at each file's export, never per record. Writing
6
- * the field inside object literals is how five product seed files shipped their
7
- * last fixture — the sold/depleted row — with no tokens at all: the field was
8
- * added to every record except the one appended at the bottom. The `...p` spread
9
- * comes FIRST here so a record cannot override or omit it.
10
- *
11
- * All of these are pure functions of the record's own content, so they do not
12
- * reintroduce the `appkit-seed` idempotency problem of Root Cause #25.
13
- *
14
- * 🛑 **No PII in any of these.** `searchTxt` stores readable fragments of the
15
- * source text, so feeding it an encrypted field would undo the encryption.
16
- * Decision D1: names stay searchable, emails/phones/addresses do not.
17
- *
18
- * Each wrapper is typed against its own document rather than a shared
19
- * `Record<string, unknown>`: that shape trips `audit-unknown-leakage`, and more
20
- * importantly it would silently accept a field that no longer exists after a
21
- * rename, which is exactly the class of drift this migration is fixing.
22
- */
23
- const stripHtml = (s) => (s ?? "").replace(/<[^>]+>/g, " ");
24
- /** stores — name, description, category. No payout details, no contact. */
1
+ import { buildStoreSearchTxt, buildEventSearchTxt, buildBlogSearchTxt, buildReviewSearchTxt, } from "../../utils/search-txt-builders";
25
2
  export function withStoreSearchTxt(p) {
26
- return {
27
- ...p,
28
- searchTxt: buildSearchTxt([
29
- p.storeName,
30
- stripHtml(p.storeDescription),
31
- p.storeCategory,
32
- ]),
33
- };
3
+ return { ...p, searchTxt: buildStoreSearchTxt(p) };
34
4
  }
35
- /** events — title, description, type, tags. */
36
5
  export function withEventSearchTxt(p) {
37
- return {
38
- ...p,
39
- searchTxt: buildSearchTxt([
40
- p.title,
41
- stripHtml(p.description),
42
- p.type,
43
- p.tags ?? [],
44
- ]),
45
- };
6
+ return { ...p, searchTxt: buildEventSearchTxt(p) };
46
7
  }
47
- /**
48
- * blogPosts — title, excerpt, category, tags.
49
- *
50
- * The full `content` body is deliberately EXCLUDED. FAQ answers are short
51
- * (measured: max 449 tokens, avg 200, nothing near the 600 cap), but blog bodies
52
- * are an order of magnitude larger and would blow the cap — at which point
53
- * `buildSearchTxt` truncates silently and the tail of every post becomes
54
- * unsearchable with no error anywhere. The excerpt is the searchable summary.
55
- */
56
8
  export function withBlogSearchTxt(p) {
57
- return {
58
- ...p,
59
- searchTxt: buildSearchTxt([
60
- p.title,
61
- stripHtml(p.excerpt),
62
- p.category,
63
- p.tags ?? [],
64
- ]),
65
- };
9
+ return { ...p, searchTxt: buildBlogSearchTxt(p) };
66
10
  }
67
- /** reviews — title, comment, productTitle. `userName` is PII and excluded. */
68
11
  export function withReviewSearchTxt(p) {
69
- return {
70
- ...p,
71
- searchTxt: buildSearchTxt([p.title, stripHtml(p.comment), p.productTitle]),
72
- };
12
+ return { ...p, searchTxt: buildReviewSearchTxt(p) };
73
13
  }
@@ -0,0 +1,23 @@
1
+ import type { ProductDocument } from "../features/products/schemas/firestore";
2
+ import type { StoreDocument } from "../features/stores/schemas/firestore";
3
+ import type { EventDocument } from "../features/events/schemas/firestore";
4
+ import type { BlogPostDocument } from "../features/blog/schemas/firestore";
5
+ import type { ReviewDocument } from "../features/reviews/schemas/firestore";
6
+ /** products — title, description, brand, taxonomy, condition, card/grading, specs. */
7
+ export declare function buildProductSearchTxt(p: Partial<ProductDocument>): string[];
8
+ /** stores — name, description, category. No payout details, no contact. */
9
+ export declare function buildStoreSearchTxt(s: Partial<StoreDocument>): string[];
10
+ /** events — title, description, type, tags. */
11
+ export declare function buildEventSearchTxt(e: Partial<EventDocument>): string[];
12
+ /**
13
+ * blogPosts — title, excerpt, category, tags.
14
+ *
15
+ * The full `content` body is deliberately EXCLUDED. FAQ answers are short
16
+ * (measured: max 449 tokens against a 600 cap), but blog bodies are an order of
17
+ * magnitude larger and would blow it — at which point `buildSearchTxt`
18
+ * truncates SILENTLY and the tail of every post becomes unsearchable with no
19
+ * error anywhere. The excerpt is the searchable summary.
20
+ */
21
+ export declare function buildBlogSearchTxt(b: Partial<BlogPostDocument>): string[];
22
+ /** reviews — title, comment, productTitle. `userName` is PII and excluded. */
23
+ export declare function buildReviewSearchTxt(r: Partial<ReviewDocument>): string[];
@@ -0,0 +1,86 @@
1
+ /*
2
+ * WHY: One definition per collection of "which fields feed searchTxt".
3
+ *
4
+ * There were THREE copies of the products derivation — the repository's
5
+ * `buildProductSearchTxt` (12 sources), the seed wrapper
6
+ * `withProductSearchTxt` (9), and `backfill-search-txt.mjs` (9) — and two
7
+ * of them were missing the same three fields. Nothing could catch it:
8
+ * each copy is valid TypeScript producing a valid token array, and a
9
+ * document indexed under fewer tokens is not an error, just unfindable by
10
+ * the terms nobody derived.
11
+ *
12
+ * This module is the write-path definition. The repository calls it, the
13
+ * seed wrapper calls it, and `backfill-search-txt.mjs` MIRRORS it — the
14
+ * script must keep its own copy because it has to run against a checkout
15
+ * whose appkit/dist is stale, so `audit-search-parity` cross-checks the
16
+ * two rather than trusting them to agree.
17
+ *
18
+ * WHAT: Pure functions, document-shape in, token-array out. No Firestore, no
19
+ * repository, no seed import — so every layer can share them without
20
+ * dragging firebase-admin into a bundle that must not have it.
21
+ *
22
+ * 🛑 NEVER feed PII in here. `searchTxt` stores readable fragments of the source
23
+ * text, so indexing an encrypted field would undo the encryption (D1).
24
+ * `reviews.userName` is encrypted and is deliberately absent below.
25
+ *
26
+ * @tag domain:search
27
+ * @tag layer:util
28
+ * @tag access:isomorphic
29
+ */
30
+ import { buildSearchTxt } from "./search-txt";
31
+ const stripHtml = (s) => (s ?? "").replace(/<[^>]+>/g, " ");
32
+ /** products — title, description, brand, taxonomy, condition, card/grading, specs. */
33
+ export function buildProductSearchTxt(p) {
34
+ return buildSearchTxt([
35
+ p.title,
36
+ p.description,
37
+ p.brand,
38
+ p.brandSlug,
39
+ p.categoryNames,
40
+ p.tags,
41
+ p.features,
42
+ p.condition,
43
+ p.card?.setName,
44
+ p.card?.cardNumber,
45
+ p.grading?.service,
46
+ p.specifications?.map((s) => `${s.name} ${s.value}`),
47
+ ]);
48
+ }
49
+ /** stores — name, description, category. No payout details, no contact. */
50
+ export function buildStoreSearchTxt(s) {
51
+ return buildSearchTxt([
52
+ s.storeName,
53
+ stripHtml(s.storeDescription),
54
+ s.storeCategory,
55
+ ]);
56
+ }
57
+ /** events — title, description, type, tags. */
58
+ export function buildEventSearchTxt(e) {
59
+ return buildSearchTxt([
60
+ e.title,
61
+ stripHtml(e.description),
62
+ e.type,
63
+ e.tags ?? [],
64
+ ]);
65
+ }
66
+ /**
67
+ * blogPosts — title, excerpt, category, tags.
68
+ *
69
+ * The full `content` body is deliberately EXCLUDED. FAQ answers are short
70
+ * (measured: max 449 tokens against a 600 cap), but blog bodies are an order of
71
+ * magnitude larger and would blow it — at which point `buildSearchTxt`
72
+ * truncates SILENTLY and the tail of every post becomes unsearchable with no
73
+ * error anywhere. The excerpt is the searchable summary.
74
+ */
75
+ export function buildBlogSearchTxt(b) {
76
+ return buildSearchTxt([
77
+ b.title,
78
+ stripHtml(b.excerpt),
79
+ b.category,
80
+ b.tags ?? [],
81
+ ]);
82
+ }
83
+ /** reviews — title, comment, productTitle. `userName` is PII and excluded. */
84
+ export function buildReviewSearchTxt(r) {
85
+ return buildSearchTxt([r.title, stripHtml(r.comment), r.productTitle]);
86
+ }
@@ -0,0 +1,42 @@
1
+ export interface SearchTxtPlan {
2
+ /** The single term to push down as `array-contains`, or null for no search. */
3
+ head: string | null;
4
+ /** Terms to AND-refine in memory after the query returns. */
5
+ rest: string[];
6
+ /**
7
+ * True when the caller supplied a search string that yielded no usable term.
8
+ * The caller MUST return an empty page rather than an unfiltered one.
9
+ */
10
+ empty: boolean;
11
+ }
12
+ /**
13
+ * Split a raw search string into the pushdown term and the refine terms.
14
+ * `parseSearchTxtQuery` already orders longest-first — the cheapest proxy for
15
+ * "narrows the most" without cardinality stats.
16
+ */
17
+ export declare function planSearchTxt(search: string | undefined | null): SearchTxtPlan;
18
+ /** An empty page, shaped like a Sieve result. */
19
+ export declare function emptySearchResult<T>(): {
20
+ items: T[];
21
+ total: number;
22
+ page: number;
23
+ pageSize: number;
24
+ totalPages: number;
25
+ hasMore: boolean;
26
+ };
27
+ /**
28
+ * AND-refine the remaining terms over an already-fetched page.
29
+ *
30
+ * `total` becomes a FLOOR once rows are dropped after the page was cut — it
31
+ * counts this page only. That is the known post-pagination-filter debt, not a
32
+ * new invention: reporting the pre-filter total would promise pages that render
33
+ * empty.
34
+ */
35
+ export declare function refineSearchTxt<T extends {
36
+ searchTxt?: string[];
37
+ }, R extends {
38
+ items: T[];
39
+ total: number;
40
+ totalPages: number;
41
+ hasMore: boolean;
42
+ }>(result: R, rest: string[]): R;
@@ -0,0 +1,61 @@
1
+ /*
2
+ * WHY: The READ half of searchTxt, in one place.
3
+ *
4
+ * Firestore permits ONE array operator per query, so token search is
5
+ * always the same two-step shape: the most selective term becomes an
6
+ * `array-contains` clause, and the remaining terms are AND-refined over
7
+ * the returned page. faqs and products each hand-rolled it, and the
8
+ * products copy carried a comment promising the refine for a long time
9
+ * while nothing refined anything — so "red dranzer" returned everything
10
+ * matching "dranzer".
11
+ *
12
+ * Two rules are easy to get wrong independently, and both fail silently:
13
+ *
14
+ * - `array-contains-any` instead of `array-contains` makes a multi-word
15
+ * search an OR, which reads as "search does nothing".
16
+ * - a query that produced NO usable term must return NOTHING. Falling
17
+ * through to an unfiltered list means a 1-character search returns the
18
+ * whole collection, which also reads as "search does nothing".
19
+ *
20
+ * @tag domain:search
21
+ * @tag layer:util
22
+ * @tag access:isomorphic
23
+ */
24
+ import { matchesAllSearchTerms, parseSearchTxtQuery } from "./search-txt";
25
+ /**
26
+ * Split a raw search string into the pushdown term and the refine terms.
27
+ * `parseSearchTxtQuery` already orders longest-first — the cheapest proxy for
28
+ * "narrows the most" without cardinality stats.
29
+ */
30
+ export function planSearchTxt(search) {
31
+ const raw = (search ?? "").trim();
32
+ const terms = parseSearchTxtQuery(raw);
33
+ if (terms.length === 0) {
34
+ return { head: null, rest: [], empty: raw.length > 0 };
35
+ }
36
+ return { head: terms[0], rest: terms.slice(1), empty: false };
37
+ }
38
+ /** An empty page, shaped like a Sieve result. */
39
+ export function emptySearchResult() {
40
+ return { items: [], total: 0, page: 1, pageSize: 0, totalPages: 0, hasMore: false };
41
+ }
42
+ /**
43
+ * AND-refine the remaining terms over an already-fetched page.
44
+ *
45
+ * `total` becomes a FLOOR once rows are dropped after the page was cut — it
46
+ * counts this page only. That is the known post-pagination-filter debt, not a
47
+ * new invention: reporting the pre-filter total would promise pages that render
48
+ * empty.
49
+ */
50
+ export function refineSearchTxt(result, rest) {
51
+ if (rest.length === 0)
52
+ return result;
53
+ const items = result.items.filter((row) => matchesAllSearchTerms(row.searchTxt, rest));
54
+ return {
55
+ ...result,
56
+ items,
57
+ total: items.length,
58
+ totalPages: items.length === 0 ? 0 : 1,
59
+ hasMore: false,
60
+ };
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.30.2",
3
+ "version": "4.30.4",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"