@blinkk/root-cms 3.0.1-alpha.0 → 3.0.1-beta.2

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.
@@ -0,0 +1,1180 @@
1
+ import {
2
+ RootCMSClient,
3
+ getCmsPlugin
4
+ } from "./chunk-F4SODS5S.js";
5
+
6
+ // core/cron.ts
7
+ import { Timestamp as Timestamp3 } from "firebase-admin/firestore";
8
+
9
+ // core/search-index.ts
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { Timestamp } from "firebase-admin/firestore";
13
+ import MiniSearch from "minisearch";
14
+ import glob from "tiny-glob";
15
+
16
+ // core/search-extract.ts
17
+ var MAX_FIELD_TEXT_CHARS = 2e3;
18
+ function stripHtml(input) {
19
+ if (!input) {
20
+ return "";
21
+ }
22
+ const dropped = input.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
23
+ const noTags = dropped.replace(/<[^>]+>/g, " ");
24
+ const decoded = noTags.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))).replace(
25
+ /&#x([0-9a-f]+);/gi,
26
+ (_, code) => String.fromCharCode(parseInt(code, 16))
27
+ );
28
+ return decoded.replace(/\s+/g, " ").trim();
29
+ }
30
+ function extractListItems(items) {
31
+ if (!items?.length) {
32
+ return "";
33
+ }
34
+ const parts = [];
35
+ for (const item of items) {
36
+ if (item?.content) {
37
+ parts.push(stripHtml(item.content));
38
+ }
39
+ if (item?.items?.length) {
40
+ parts.push(extractListItems(item.items));
41
+ }
42
+ }
43
+ return parts.filter(Boolean).join(" ");
44
+ }
45
+ function extractRichText(data) {
46
+ if (!data || typeof data !== "object") {
47
+ return "";
48
+ }
49
+ const blocks = data.blocks;
50
+ if (!Array.isArray(blocks)) {
51
+ return "";
52
+ }
53
+ const parts = [];
54
+ for (const block of blocks) {
55
+ if (!block || typeof block !== "object") {
56
+ continue;
57
+ }
58
+ const type = block.type;
59
+ const d = block.data || {};
60
+ switch (type) {
61
+ case "paragraph":
62
+ case "heading":
63
+ case "quote":
64
+ if (typeof d.text === "string" && d.text) {
65
+ parts.push(stripHtml(d.text));
66
+ }
67
+ break;
68
+ case "orderedList":
69
+ case "unorderedList":
70
+ parts.push(extractListItems(d.items));
71
+ break;
72
+ case "table": {
73
+ const rows = Array.isArray(d.rows) ? d.rows : [];
74
+ for (const row of rows) {
75
+ const cells = Array.isArray(row?.cells) ? row.cells : [];
76
+ for (const cell of cells) {
77
+ parts.push(extractRichText({ blocks: cell?.blocks || [] }));
78
+ }
79
+ }
80
+ break;
81
+ }
82
+ case "html":
83
+ if (typeof d.html === "string" && d.html) {
84
+ parts.push(stripHtml(d.html));
85
+ }
86
+ break;
87
+ default:
88
+ break;
89
+ }
90
+ }
91
+ return parts.filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
92
+ }
93
+ function truncate(text) {
94
+ if (text.length <= MAX_FIELD_TEXT_CHARS) {
95
+ return text;
96
+ }
97
+ return text.slice(0, MAX_FIELD_TEXT_CHARS);
98
+ }
99
+ function fieldLabelOrId(field) {
100
+ if (field.label) {
101
+ return field.label;
102
+ }
103
+ if (field.id) {
104
+ return field.id;
105
+ }
106
+ return field.type;
107
+ }
108
+ function walkSchemaFields(fields, data, parentDeepKey, ctx, emit) {
109
+ if (!Array.isArray(fields) || !data || typeof data !== "object") {
110
+ return;
111
+ }
112
+ for (const field of fields) {
113
+ if (!field?.id) {
114
+ continue;
115
+ }
116
+ const deepKey = `${parentDeepKey}.${field.id}`;
117
+ const value = data[field.id];
118
+ walkField(field, value, deepKey, ctx, emit);
119
+ }
120
+ }
121
+ function walkField(field, value, deepKey, ctx, emit) {
122
+ const fieldLabel = fieldLabelOrId(field);
123
+ switch (field.type) {
124
+ case "string": {
125
+ if (typeof value === "string" && value.trim()) {
126
+ emitText(deepKey, fieldLabel, field.type, value, ctx, emit);
127
+ }
128
+ break;
129
+ }
130
+ case "select": {
131
+ if (typeof value === "string" && value.trim()) {
132
+ emitText(deepKey, fieldLabel, field.type, value, ctx, emit);
133
+ }
134
+ break;
135
+ }
136
+ case "multiselect": {
137
+ if (Array.isArray(value)) {
138
+ const text = value.filter((v) => typeof v === "string").join(" ");
139
+ if (text.trim()) {
140
+ emitText(deepKey, fieldLabel, field.type, text, ctx, emit);
141
+ }
142
+ }
143
+ break;
144
+ }
145
+ case "richtext": {
146
+ const text = extractRichText(value);
147
+ if (text) {
148
+ emitText(deepKey, fieldLabel, field.type, text, ctx, emit);
149
+ }
150
+ break;
151
+ }
152
+ case "object": {
153
+ const objectField = field;
154
+ if (value && typeof value === "object") {
155
+ walkSchemaFields(objectField.fields, value, deepKey, ctx, emit);
156
+ }
157
+ break;
158
+ }
159
+ case "array": {
160
+ const arrayField = field;
161
+ walkArray(arrayField, value, deepKey, ctx, emit);
162
+ break;
163
+ }
164
+ case "oneof": {
165
+ const oneOfField = field;
166
+ walkOneOf(oneOfField, value, deepKey, ctx, emit);
167
+ break;
168
+ }
169
+ // Non-text or non-searchable: number, date, datetime, boolean, image,
170
+ // file, reference, references — intentionally skipped.
171
+ default:
172
+ break;
173
+ }
174
+ }
175
+ function walkArray(field, value, deepKey, ctx, emit) {
176
+ if (!value || typeof value !== "object") {
177
+ return;
178
+ }
179
+ const order = Array.isArray(value._array) ? value._array : [];
180
+ if (order.length === 0) {
181
+ return;
182
+ }
183
+ const itemField = field.of;
184
+ for (const autokey of order) {
185
+ if (typeof autokey !== "string" || !autokey) {
186
+ continue;
187
+ }
188
+ const itemValue = value[autokey];
189
+ if (itemValue === void 0 || itemValue === null) {
190
+ continue;
191
+ }
192
+ const itemDeepKey = `${deepKey}.${autokey}`;
193
+ if (itemField?.type === "object") {
194
+ walkSchemaFields(
195
+ itemField.fields,
196
+ itemValue,
197
+ itemDeepKey,
198
+ ctx,
199
+ emit
200
+ );
201
+ } else if (itemField?.type === "oneof") {
202
+ walkOneOf(itemField, itemValue, itemDeepKey, ctx, emit);
203
+ } else {
204
+ }
205
+ }
206
+ }
207
+ function walkOneOf(field, value, deepKey, ctx, emit) {
208
+ if (!value || typeof value !== "object") {
209
+ return;
210
+ }
211
+ const typeName = typeof value._type === "string" ? value._type : "";
212
+ if (!typeName) {
213
+ return;
214
+ }
215
+ const types = field.types;
216
+ let matchedSchema = null;
217
+ if (Array.isArray(types)) {
218
+ for (const candidate of types) {
219
+ if (candidate && typeof candidate === "object") {
220
+ const schema = candidate;
221
+ if (schema.name === typeName) {
222
+ matchedSchema = schema;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ if (!matchedSchema) {
229
+ return;
230
+ }
231
+ walkSchemaFields(matchedSchema.fields, value, deepKey, ctx, emit);
232
+ }
233
+ function emitText(deepKey, fieldLabel, fieldType, rawText, ctx, emit) {
234
+ const text = truncate(rawText.replace(/\s+/g, " ").trim());
235
+ if (!text) {
236
+ return;
237
+ }
238
+ emit({
239
+ id: `${ctx.docId}#${deepKey}`,
240
+ docId: ctx.docId,
241
+ collection: ctx.collection,
242
+ slug: ctx.slug,
243
+ deepKey,
244
+ fieldType,
245
+ fieldLabel,
246
+ text
247
+ });
248
+ }
249
+ function extractDocRecords(schema, options) {
250
+ const records = [];
251
+ const docId = `${options.collection}/${options.slug}`;
252
+ const ctx = {
253
+ docId,
254
+ collection: options.collection,
255
+ slug: options.slug
256
+ };
257
+ walkSchemaFields(
258
+ schema.fields,
259
+ options.fields || {},
260
+ "fields",
261
+ ctx,
262
+ (rec) => records.push(rec)
263
+ );
264
+ return records;
265
+ }
266
+
267
+ // core/search-query.ts
268
+ var SMART_QUOTES = /[“”„«»]/g;
269
+ function parseQuery(input) {
270
+ const out = {
271
+ phrases: [],
272
+ terms: [],
273
+ excluded: [],
274
+ excludedPhrases: []
275
+ };
276
+ if (!input) {
277
+ return out;
278
+ }
279
+ const s = input.replace(SMART_QUOTES, '"');
280
+ let i = 0;
281
+ while (i < s.length) {
282
+ while (i < s.length && isSpace(s[i])) {
283
+ i++;
284
+ }
285
+ if (i >= s.length) {
286
+ break;
287
+ }
288
+ let negative = false;
289
+ if (s[i] === "-" && i + 1 < s.length && !isSpace(s[i + 1])) {
290
+ negative = true;
291
+ i++;
292
+ }
293
+ if (s[i] === '"') {
294
+ i++;
295
+ const start = i;
296
+ while (i < s.length && s[i] !== '"') {
297
+ i++;
298
+ }
299
+ const phrase = s.slice(start, i).trim();
300
+ if (i < s.length) {
301
+ i++;
302
+ }
303
+ if (phrase) {
304
+ if (negative) {
305
+ out.excludedPhrases.push(phrase);
306
+ } else {
307
+ out.phrases.push(phrase);
308
+ }
309
+ }
310
+ } else {
311
+ const start = i;
312
+ while (i < s.length && !isSpace(s[i]) && s[i] !== '"') {
313
+ i++;
314
+ }
315
+ const term = s.slice(start, i);
316
+ if (term && term.replace(/^-+$/, "")) {
317
+ if (negative) {
318
+ out.excluded.push(term);
319
+ } else {
320
+ out.terms.push(term);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ return out;
326
+ }
327
+ function isSpace(ch) {
328
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r";
329
+ }
330
+ function tokenize(s) {
331
+ return s.toLowerCase().split(/[\s\p{P}]+/u).filter(Boolean);
332
+ }
333
+ function containsPhrase(text, phrase) {
334
+ const phraseTokens = tokenize(phrase);
335
+ if (phraseTokens.length === 0) {
336
+ return true;
337
+ }
338
+ const textTokens = tokenize(text);
339
+ if (textTokens.length < phraseTokens.length) {
340
+ return false;
341
+ }
342
+ outer: for (let i = 0; i <= textTokens.length - phraseTokens.length; i++) {
343
+ for (let j = 0; j < phraseTokens.length; j++) {
344
+ if (textTokens[i + j] !== phraseTokens[j]) {
345
+ continue outer;
346
+ }
347
+ }
348
+ return true;
349
+ }
350
+ return false;
351
+ }
352
+ function isEmptyQuery(parsed) {
353
+ return parsed.phrases.length === 0 && parsed.terms.length === 0;
354
+ }
355
+ function executeQuery(index, parsed) {
356
+ if (isEmptyQuery(parsed)) {
357
+ return [];
358
+ }
359
+ let acc = null;
360
+ for (const phrase of parsed.phrases) {
361
+ const candidates = index.search(phrase, {
362
+ combineWith: "AND",
363
+ fuzzy: false,
364
+ prefix: false
365
+ });
366
+ const matched = candidates.filter(
367
+ (r) => containsPhrase(String(r.text || ""), phrase) || containsPhrase(String(r.fieldLabel || ""), phrase)
368
+ );
369
+ acc = mergeAnd(acc, matched);
370
+ if (acc.size === 0) {
371
+ return [];
372
+ }
373
+ }
374
+ if (parsed.terms.length > 0) {
375
+ const q = parsed.terms.join(" ");
376
+ const results = index.search(q, { combineWith: "AND" });
377
+ acc = mergeAnd(acc, results);
378
+ }
379
+ if (!acc || acc.size === 0) {
380
+ return [];
381
+ }
382
+ for (const term of parsed.excluded) {
383
+ for (const r of index.search(term)) {
384
+ acc.delete(String(r.id));
385
+ }
386
+ }
387
+ for (const phrase of parsed.excludedPhrases) {
388
+ const candidates = index.search(phrase, {
389
+ combineWith: "AND",
390
+ fuzzy: false,
391
+ prefix: false
392
+ });
393
+ for (const r of candidates) {
394
+ if (containsPhrase(String(r.text || ""), phrase) || containsPhrase(String(r.fieldLabel || ""), phrase)) {
395
+ acc.delete(String(r.id));
396
+ }
397
+ }
398
+ }
399
+ return Array.from(acc.values()).sort((a, b) => b.score - a.score).map((entry) => ({
400
+ ...entry.record,
401
+ score: entry.score,
402
+ terms: Array.from(entry.terms)
403
+ }));
404
+ }
405
+ function mergeAnd(acc, results) {
406
+ if (acc === null) {
407
+ const next2 = /* @__PURE__ */ new Map();
408
+ for (const r of results) {
409
+ next2.set(String(r.id), {
410
+ score: r.score,
411
+ record: r,
412
+ terms: new Set(Array.isArray(r.terms) ? r.terms : [])
413
+ });
414
+ }
415
+ return next2;
416
+ }
417
+ const byId = /* @__PURE__ */ new Map();
418
+ for (const r of results) {
419
+ byId.set(String(r.id), r);
420
+ }
421
+ const next = /* @__PURE__ */ new Map();
422
+ for (const [id, entry] of acc) {
423
+ const r = byId.get(id);
424
+ if (!r) {
425
+ continue;
426
+ }
427
+ const terms = new Set(entry.terms);
428
+ if (Array.isArray(r.terms)) {
429
+ for (const t of r.terms) {
430
+ terms.add(t);
431
+ }
432
+ }
433
+ next.set(id, {
434
+ score: entry.score + r.score,
435
+ record: entry.record,
436
+ terms
437
+ });
438
+ }
439
+ return next;
440
+ }
441
+
442
+ // core/search-index.ts
443
+ var SHARD_MAX_CHARS = 5e5;
444
+ var MAX_INDEX_SIZE_BYTES = 50 * 1024 * 1024;
445
+ var VACUUM_EVERY_N_RUNS = 25;
446
+ var SEARCH_INDEX_SUBCOLLECTION = "SearchIndex";
447
+ var META_DOC_ID = "_meta";
448
+ var DOC_MAP_DOC_ID = "_docMap";
449
+ var TITLEY_KEYS = /* @__PURE__ */ new Set(["title", "name", "slug", "headline", "label"]);
450
+ var MINISEARCH_OPTIONS = {
451
+ fields: ["text", "fieldLabel"],
452
+ storeFields: [
453
+ "docId",
454
+ "collection",
455
+ "slug",
456
+ "deepKey",
457
+ "fieldLabel",
458
+ "fieldType",
459
+ "text",
460
+ "weight"
461
+ ],
462
+ searchOptions: {
463
+ boost: { fieldLabel: 2 },
464
+ prefix: true,
465
+ fuzzy: 0.2
466
+ }
467
+ };
468
+ function toSetOrNull(values) {
469
+ if (!values || values.length === 0) {
470
+ return null;
471
+ }
472
+ return new Set(values);
473
+ }
474
+ function resolveSearchIndexFilters(config) {
475
+ return {
476
+ includeCollections: toSetOrNull(config?.includeCollections),
477
+ excludeCollections: toSetOrNull(config?.excludeCollections),
478
+ includeDocIds: toSetOrNull(config?.includeDocIds),
479
+ excludeDocIds: toSetOrNull(config?.excludeDocIds)
480
+ };
481
+ }
482
+ function isCollectionIndexable(filters, collectionId) {
483
+ if (filters.includeCollections && !filters.includeCollections.has(collectionId)) {
484
+ return false;
485
+ }
486
+ if (filters.excludeCollections?.has(collectionId)) {
487
+ return false;
488
+ }
489
+ return true;
490
+ }
491
+ function isDocIndexable(filters, docId) {
492
+ const slashIndex = docId.indexOf("/");
493
+ if (slashIndex < 0) {
494
+ return false;
495
+ }
496
+ const collectionId = docId.slice(0, slashIndex);
497
+ if (!isCollectionIndexable(filters, collectionId)) {
498
+ return false;
499
+ }
500
+ if (filters.includeDocIds && !filters.includeDocIds.has(docId)) {
501
+ return false;
502
+ }
503
+ if (filters.excludeDocIds?.has(docId)) {
504
+ return false;
505
+ }
506
+ return true;
507
+ }
508
+ function isTitleyDeepKey(deepKey) {
509
+ const last = deepKey.split(".").pop() || "";
510
+ return TITLEY_KEYS.has(last.toLowerCase());
511
+ }
512
+ function withWeight(rec) {
513
+ return { ...rec, weight: isTitleyDeepKey(rec.deepKey) ? 2 : 1 };
514
+ }
515
+ var SearchIndexService = class {
516
+ constructor(rootConfig, loadSchema, filtersOverride) {
517
+ this.cached = null;
518
+ this.rootConfig = rootConfig;
519
+ const cmsPlugin = getCmsPlugin(rootConfig);
520
+ const cmsPluginOptions = cmsPlugin.getConfig();
521
+ this.projectId = cmsPluginOptions.id || "default";
522
+ this.db = cmsPlugin.getFirestore();
523
+ this.loadSchema = loadSchema || ((id) => this.loadCollectionSchemaFromDist(id));
524
+ this.filters = resolveSearchIndexFilters(
525
+ filtersOverride ?? cmsPluginOptions.searchIndex
526
+ );
527
+ }
528
+ /** Returns true if the given collection passes the include/exclude filter. */
529
+ isCollectionIndexable(collectionId) {
530
+ return isCollectionIndexable(this.filters, collectionId);
531
+ }
532
+ /**
533
+ * Returns true if the given doc id passes the include/exclude filter (and
534
+ * its collection is itself indexable). Doc ids are `<collectionId>/<slug>`.
535
+ */
536
+ isDocIndexable(docId) {
537
+ return isDocIndexable(this.filters, docId);
538
+ }
539
+ /** Returns the MiniSearch options used by both writers and readers. */
540
+ static getMiniSearchOptions() {
541
+ return MINISEARCH_OPTIONS;
542
+ }
543
+ indexDocPath(docId) {
544
+ return `Projects/${this.projectId}/${SEARCH_INDEX_SUBCOLLECTION}/${docId}`;
545
+ }
546
+ indexCollectionPath() {
547
+ return `Projects/${this.projectId}/${SEARCH_INDEX_SUBCOLLECTION}`;
548
+ }
549
+ /**
550
+ * Lists collection ids by globbing `<rootDir>/collections/*.schema.ts`,
551
+ * filtered by the include/exclude config.
552
+ */
553
+ async listCollectionIds() {
554
+ const collectionsDir = path.join(this.rootConfig.rootDir, "collections");
555
+ if (!fs.existsSync(collectionsDir)) {
556
+ return [];
557
+ }
558
+ const fileNames = await glob("*.schema.ts", { cwd: collectionsDir });
559
+ return fileNames.map((f) => f.slice(0, -10)).filter((id) => this.isCollectionIndexable(id));
560
+ }
561
+ /**
562
+ * Default schema loader: reads `dist/collections/<id>.schema.json`. These
563
+ * files are produced by the CMS plugin's `preBuild` hook during a real
564
+ * `root build`. In dev there is no JSON file on disk, so callers should
565
+ * pass a dev-aware `loadSchema` callback to the constructor (the API
566
+ * endpoints do this — they delegate to the same Vite SSR path used by
567
+ * `/cms/api/collection.get`).
568
+ */
569
+ async loadCollectionSchemaFromDist(collectionId) {
570
+ const distPath = path.join(
571
+ this.rootConfig.rootDir,
572
+ "dist",
573
+ "collections",
574
+ `${collectionId}.schema.json`
575
+ );
576
+ if (!fs.existsSync(distPath)) {
577
+ return null;
578
+ }
579
+ const contents = fs.readFileSync(distPath, "utf8");
580
+ return JSON.parse(contents);
581
+ }
582
+ async readMeta() {
583
+ const ref = this.db.doc(this.indexDocPath(META_DOC_ID));
584
+ const snap = await ref.get();
585
+ if (!snap.exists) {
586
+ return null;
587
+ }
588
+ return snap.data() || null;
589
+ }
590
+ async readDocMap() {
591
+ const ref = this.db.doc(this.indexDocPath(DOC_MAP_DOC_ID));
592
+ const snap = await ref.get();
593
+ if (!snap.exists) {
594
+ return {};
595
+ }
596
+ const data = snap.data() || {};
597
+ const records = data.records || {};
598
+ return records;
599
+ }
600
+ /** Reads all shard docs and returns the concatenated MiniSearch JSON string. */
601
+ async readShards(shardCount) {
602
+ if (shardCount <= 0) {
603
+ return "";
604
+ }
605
+ const refs = Array.from(
606
+ { length: shardCount },
607
+ (_, i) => this.db.doc(this.indexDocPath(`shard-${i}`))
608
+ );
609
+ const snaps = await this.db.getAll(...refs);
610
+ const parts = [];
611
+ for (const snap of snaps) {
612
+ if (!snap.exists) {
613
+ throw new Error(`searchIndex: missing shard ${snap.ref.path}`);
614
+ }
615
+ const data = snap.data() || {};
616
+ parts.push(typeof data.data === "string" ? data.data : "");
617
+ }
618
+ return parts.join("");
619
+ }
620
+ /** Writes shards, _docMap, _meta in one batched commit. */
621
+ async writeIndex(serialized, docMap, counts, existingShardCount) {
622
+ if (serialized.length > MAX_INDEX_SIZE_BYTES) {
623
+ throw new Error(
624
+ `searchIndex: serialized index size (${serialized.length} chars) exceeds MAX_INDEX_SIZE_BYTES. Reduce indexed fields or migrate to Cloud Storage.`
625
+ );
626
+ }
627
+ const shards = [];
628
+ for (let i = 0; i < serialized.length; i += SHARD_MAX_CHARS) {
629
+ shards.push(serialized.slice(i, i + SHARD_MAX_CHARS));
630
+ }
631
+ const newShardCount = shards.length;
632
+ const batch = this.db.batch();
633
+ shards.forEach((data, i) => {
634
+ const ref = this.db.doc(this.indexDocPath(`shard-${i}`));
635
+ batch.set(ref, { shardId: i, data });
636
+ });
637
+ for (let i = newShardCount; i < existingShardCount; i++) {
638
+ const ref = this.db.doc(this.indexDocPath(`shard-${i}`));
639
+ batch.delete(ref);
640
+ }
641
+ const docMapRef = this.db.doc(this.indexDocPath(DOC_MAP_DOC_ID));
642
+ batch.set(docMapRef, { records: docMap });
643
+ const metaRef = this.db.doc(this.indexDocPath(META_DOC_ID));
644
+ const meta = {
645
+ lastRun: Timestamp.now(),
646
+ shardCount: newShardCount,
647
+ docCount: counts.docCount,
648
+ fieldCount: counts.fieldCount,
649
+ runsSinceVacuum: counts.runsSinceVacuum
650
+ };
651
+ batch.set(metaRef, meta);
652
+ await batch.commit();
653
+ await this.db.doc(`Projects/${this.projectId}`).set(
654
+ {
655
+ searchIndexLastRun: meta.lastRun,
656
+ searchIndexDocCount: counts.docCount,
657
+ searchIndexFieldCount: counts.fieldCount
658
+ },
659
+ { merge: true }
660
+ );
661
+ return { shardCount: newShardCount };
662
+ }
663
+ /** Drops every doc under the SearchIndex subcollection. */
664
+ async wipeIndex() {
665
+ const snap = await this.db.collection(this.indexCollectionPath()).get();
666
+ if (snap.empty) {
667
+ return;
668
+ }
669
+ const batch = this.db.batch();
670
+ snap.forEach((d) => batch.delete(d.ref));
671
+ await batch.commit();
672
+ }
673
+ /**
674
+ * Lists the full set of live (collection, slug, doc) tuples for `Drafts`,
675
+ * filtered by the include/exclude config.
676
+ */
677
+ async listAllDocs(collectionIds) {
678
+ const all = [];
679
+ for (const collectionId of collectionIds) {
680
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
681
+ const snap = await this.db.collection(path3).get();
682
+ snap.forEach((d) => {
683
+ const docId = `${collectionId}/${d.id}`;
684
+ if (!this.isDocIndexable(docId)) {
685
+ return;
686
+ }
687
+ const data = d.data() || {};
688
+ if (!data.collection) data.collection = collectionId;
689
+ if (!data.slug) data.slug = d.id;
690
+ if (!data.id) data.id = docId;
691
+ all.push(data);
692
+ });
693
+ }
694
+ return all;
695
+ }
696
+ /**
697
+ * Lists docs modified since `lastRun` across every collection, filtered by
698
+ * the include/exclude config.
699
+ */
700
+ async listChangedDocs(collectionIds, lastRun) {
701
+ const all = [];
702
+ for (const collectionId of collectionIds) {
703
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
704
+ const q = this.db.collection(path3).where("sys.modifiedAt", ">=", lastRun);
705
+ const snap = await q.get();
706
+ snap.forEach((d) => {
707
+ const docId = `${collectionId}/${d.id}`;
708
+ if (!this.isDocIndexable(docId)) {
709
+ return;
710
+ }
711
+ const data = d.data() || {};
712
+ if (!data.collection) data.collection = collectionId;
713
+ if (!data.slug) data.slug = d.id;
714
+ if (!data.id) data.id = docId;
715
+ all.push(data);
716
+ });
717
+ }
718
+ return all;
719
+ }
720
+ /**
721
+ * Returns true when no docs have been modified since `lastRun` AND no
722
+ * deletions are pending (i.e. the doc-id set in `_docMap` matches the live
723
+ * doc-id set across all collections).
724
+ */
725
+ async hasChangesSince(lastRun, docMap = null) {
726
+ const collectionIds = await this.listCollectionIds();
727
+ for (const collectionId of collectionIds) {
728
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
729
+ const q = this.db.collection(path3).where("sys.modifiedAt", ">=", lastRun).limit(1);
730
+ const snap = await q.get();
731
+ if (!snap.empty) {
732
+ return true;
733
+ }
734
+ }
735
+ const map = docMap ?? await this.readDocMap();
736
+ const knownIds = new Set(Object.keys(map));
737
+ if (knownIds.size === 0) {
738
+ return false;
739
+ }
740
+ const liveIds = /* @__PURE__ */ new Set();
741
+ for (const collectionId of collectionIds) {
742
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
743
+ const snap = await this.db.collection(path3).select().get();
744
+ snap.forEach((d) => {
745
+ const docId = `${collectionId}/${d.id}`;
746
+ if (this.isDocIndexable(docId)) {
747
+ liveIds.add(docId);
748
+ }
749
+ });
750
+ }
751
+ for (const id of knownIds) {
752
+ if (!liveIds.has(id)) {
753
+ return true;
754
+ }
755
+ }
756
+ return false;
757
+ }
758
+ /** Orchestrates a rebuild (incremental by default; full when `force: true`). */
759
+ async rebuildIndex(opts = {}) {
760
+ const start = Date.now();
761
+ const force = !!opts.force;
762
+ const collectionIds = await this.listCollectionIds();
763
+ if (force) {
764
+ const result2 = await this.fullRebuild(collectionIds);
765
+ return {
766
+ forced: true,
767
+ skipped: false,
768
+ docCount: result2.docCount,
769
+ fieldCount: result2.fieldCount,
770
+ shardCount: result2.shardCount,
771
+ durationMs: Date.now() - start
772
+ };
773
+ }
774
+ const meta = await this.readMeta();
775
+ if (!meta) {
776
+ const result2 = await this.fullRebuild(collectionIds);
777
+ return {
778
+ forced: false,
779
+ skipped: false,
780
+ docCount: result2.docCount,
781
+ fieldCount: result2.fieldCount,
782
+ shardCount: result2.shardCount,
783
+ durationMs: Date.now() - start
784
+ };
785
+ }
786
+ const result = await this.incrementalRebuild(collectionIds, meta);
787
+ return {
788
+ forced: false,
789
+ skipped: result.skipped,
790
+ docCount: result.docCount,
791
+ fieldCount: result.fieldCount,
792
+ shardCount: result.shardCount,
793
+ durationMs: Date.now() - start
794
+ };
795
+ }
796
+ async fullRebuild(collectionIds) {
797
+ await this.wipeIndex();
798
+ const index = new MiniSearch(MINISEARCH_OPTIONS);
799
+ const docMap = {};
800
+ let docCount = 0;
801
+ let fieldCount = 0;
802
+ for (const collectionId of collectionIds) {
803
+ const schema = await this.loadSchema(collectionId);
804
+ if (!schema) {
805
+ console.warn(
806
+ `searchIndex: skipping collection "${collectionId}" \u2014 schema not available. In dev, trigger the rebuild from Settings \u2192 Site Admin (the API endpoint loads schemas via Vite). In prod, ensure dist/collections/${collectionId}.schema.json exists.`
807
+ );
808
+ continue;
809
+ }
810
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
811
+ const snap = await this.db.collection(path3).get();
812
+ snap.forEach((d) => {
813
+ const data = d.data() || {};
814
+ const slug = data.slug || d.id;
815
+ if (!this.isDocIndexable(`${collectionId}/${slug}`)) {
816
+ return;
817
+ }
818
+ const records = extractDocRecords(schema, {
819
+ collection: collectionId,
820
+ slug,
821
+ fields: data.fields || {}
822
+ });
823
+ if (records.length === 0) {
824
+ return;
825
+ }
826
+ const weighted = records.map(withWeight);
827
+ index.addAll(weighted);
828
+ docMap[`${collectionId}/${slug}`] = weighted.map((r) => r.id);
829
+ docCount += 1;
830
+ fieldCount += weighted.length;
831
+ });
832
+ }
833
+ const serialized = JSON.stringify(index.toJSON());
834
+ const { shardCount } = await this.writeIndex(
835
+ serialized,
836
+ docMap,
837
+ { docCount, fieldCount, runsSinceVacuum: 0 },
838
+ 0
839
+ );
840
+ this.cached = null;
841
+ return { docCount, fieldCount, shardCount };
842
+ }
843
+ async incrementalRebuild(collectionIds, meta) {
844
+ const json = await this.readShards(meta.shardCount);
845
+ const index = json ? MiniSearch.loadJSON(json, MINISEARCH_OPTIONS) : new MiniSearch(MINISEARCH_OPTIONS);
846
+ const docMap = await this.readDocMap();
847
+ const lastRun = meta.lastRun;
848
+ const changedDocs = await this.listChangedDocs(collectionIds, lastRun);
849
+ const schemaCache = /* @__PURE__ */ new Map();
850
+ const getSchema = async (collectionId) => {
851
+ if (!schemaCache.has(collectionId)) {
852
+ schemaCache.set(collectionId, await this.loadSchema(collectionId));
853
+ }
854
+ return schemaCache.get(collectionId) || null;
855
+ };
856
+ let touched = 0;
857
+ for (const doc of changedDocs) {
858
+ const collectionId = doc.collection;
859
+ const slug = doc.slug;
860
+ const docId = `${collectionId}/${slug}`;
861
+ const schema = await getSchema(collectionId);
862
+ if (!schema) {
863
+ continue;
864
+ }
865
+ const oldIds = docMap[docId] || [];
866
+ for (const id of oldIds) {
867
+ if (index.has(id)) {
868
+ index.discard(id);
869
+ }
870
+ }
871
+ const records = extractDocRecords(schema, {
872
+ collection: collectionId,
873
+ slug,
874
+ fields: doc.fields || {}
875
+ });
876
+ if (records.length === 0) {
877
+ delete docMap[docId];
878
+ continue;
879
+ }
880
+ const weighted = records.map(withWeight);
881
+ index.addAll(weighted);
882
+ docMap[docId] = weighted.map((r) => r.id);
883
+ touched += 1;
884
+ }
885
+ const liveIds = /* @__PURE__ */ new Set();
886
+ for (const collectionId of collectionIds) {
887
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
888
+ const snap = await this.db.collection(path3).select().get();
889
+ snap.forEach((d) => {
890
+ const docId = `${collectionId}/${d.id}`;
891
+ if (this.isDocIndexable(docId)) {
892
+ liveIds.add(docId);
893
+ }
894
+ });
895
+ }
896
+ let deletions = 0;
897
+ for (const docId of Object.keys(docMap)) {
898
+ if (!liveIds.has(docId)) {
899
+ const ids = docMap[docId] || [];
900
+ for (const id of ids) {
901
+ if (index.has(id)) {
902
+ index.discard(id);
903
+ }
904
+ }
905
+ delete docMap[docId];
906
+ deletions += 1;
907
+ }
908
+ }
909
+ const skipped = touched === 0 && deletions === 0;
910
+ if (skipped) {
911
+ const noopMeta = {
912
+ ...meta,
913
+ lastRun: Timestamp.now()
914
+ };
915
+ await this.db.doc(this.indexDocPath(META_DOC_ID)).set(noopMeta, { merge: true });
916
+ return {
917
+ skipped: true,
918
+ docCount: meta.docCount,
919
+ fieldCount: meta.fieldCount,
920
+ shardCount: meta.shardCount
921
+ };
922
+ }
923
+ let runsSinceVacuum = (meta.runsSinceVacuum || 0) + 1;
924
+ if (runsSinceVacuum >= VACUUM_EVERY_N_RUNS) {
925
+ await index.vacuum();
926
+ runsSinceVacuum = 0;
927
+ }
928
+ let docCount = 0;
929
+ let fieldCount = 0;
930
+ for (const ids of Object.values(docMap)) {
931
+ if (ids.length > 0) {
932
+ docCount += 1;
933
+ fieldCount += ids.length;
934
+ }
935
+ }
936
+ const serialized = JSON.stringify(index.toJSON());
937
+ const { shardCount } = await this.writeIndex(
938
+ serialized,
939
+ docMap,
940
+ { docCount, fieldCount, runsSinceVacuum },
941
+ meta.shardCount
942
+ );
943
+ this.cached = null;
944
+ return { skipped: false, docCount, fieldCount, shardCount };
945
+ }
946
+ /**
947
+ * Returns the cached MiniSearch index, loading it from Firestore if
948
+ * necessary. The cache is keyed off `meta.lastRun` and survives across
949
+ * requests in the same process.
950
+ */
951
+ async getIndex() {
952
+ const meta = await this.readMeta();
953
+ if (!meta) {
954
+ this.cached = null;
955
+ return null;
956
+ }
957
+ if (this.cached && this.cached.meta.lastRun.toMillis() === meta.lastRun.toMillis()) {
958
+ return this.cached;
959
+ }
960
+ const json = await this.readShards(meta.shardCount);
961
+ const index = json ? MiniSearch.loadJSON(json, MINISEARCH_OPTIONS) : new MiniSearch(MINISEARCH_OPTIONS);
962
+ const docMap = await this.readDocMap();
963
+ this.cached = { index, meta, docMap };
964
+ return this.cached;
965
+ }
966
+ /** Runs a query against the loaded index. */
967
+ async search(q, options = {}) {
968
+ const limit = Math.max(1, Math.min(options.limit || 25, 100));
969
+ const cached = await this.getIndex();
970
+ const status = await this.getStatus();
971
+ if (!cached || !q.trim()) {
972
+ return { hits: [], meta: status };
973
+ }
974
+ const raw = executeQuery(cached.index, parseQuery(q));
975
+ const hits = raw.slice(0, limit).map((r) => ({
976
+ id: String(r.id || ""),
977
+ docId: String(r.docId || ""),
978
+ collection: String(r.collection || ""),
979
+ slug: String(r.slug || ""),
980
+ deepKey: String(r.deepKey || ""),
981
+ fieldLabel: String(r.fieldLabel || ""),
982
+ fieldType: String(r.fieldType || ""),
983
+ text: String(r.text || ""),
984
+ score: typeof r.score === "number" ? r.score : 0,
985
+ terms: Array.isArray(r.terms) ? r.terms.map(String) : []
986
+ }));
987
+ return { hits, meta: status };
988
+ }
989
+ /** Lightweight metadata read for status endpoints / settings UI. */
990
+ async getStatus() {
991
+ const meta = await this.readMeta();
992
+ if (!meta) {
993
+ return { lastRun: null, docCount: 0, fieldCount: 0, shardCount: 0 };
994
+ }
995
+ return {
996
+ lastRun: meta.lastRun.toMillis(),
997
+ docCount: meta.docCount,
998
+ fieldCount: meta.fieldCount,
999
+ shardCount: meta.shardCount
1000
+ };
1001
+ }
1002
+ };
1003
+
1004
+ // core/versions.ts
1005
+ import fs2 from "fs";
1006
+ import path2 from "path";
1007
+ import { Timestamp as Timestamp2 } from "firebase-admin/firestore";
1008
+ import glob2 from "tiny-glob";
1009
+ var DOCUMENT_SAVE_OFFSET = 5 * 60 * 1e3;
1010
+ var VersionsService = class {
1011
+ constructor(rootConfig) {
1012
+ this.rootConfig = rootConfig;
1013
+ const cmsPlugin = getCmsPlugin(rootConfig);
1014
+ const cmsPluginOptions = cmsPlugin.getConfig();
1015
+ const projectId = cmsPluginOptions.id || "default";
1016
+ this.projectId = projectId;
1017
+ this.db = cmsPlugin.getFirestore();
1018
+ }
1019
+ /**
1020
+ * Saves a version of all documents that have been edited since the last run.
1021
+ */
1022
+ async saveVersions() {
1023
+ const lastRun = await this.getLastRun();
1024
+ const lastRunWithOffset = lastRun === 0 ? lastRun : lastRun - DOCUMENT_SAVE_OFFSET;
1025
+ const changedDocs = await this.getDocsModifiedAfter(lastRunWithOffset);
1026
+ const now = Timestamp2.now().toMillis();
1027
+ const versions = changedDocs.filter((doc) => {
1028
+ const modifiedAt = doc.sys.modifiedAt.toMillis();
1029
+ if (modifiedAt > now - DOCUMENT_SAVE_OFFSET) {
1030
+ return false;
1031
+ }
1032
+ const publishedAt = doc.sys.publishedAt?.toMillis?.();
1033
+ if (publishedAt && Math.abs(publishedAt - modifiedAt) < 5e3) {
1034
+ return false;
1035
+ }
1036
+ return true;
1037
+ });
1038
+ if (versions.length > 0) {
1039
+ this.saveVersionsToFirestore(versions);
1040
+ }
1041
+ this.saveLastRun(now);
1042
+ }
1043
+ async saveVersionsToFirestore(versions) {
1044
+ const batch = this.db.batch();
1045
+ versions.forEach((version) => {
1046
+ if (!version.collection || !version.slug || !version.sys?.modifiedAt) {
1047
+ return;
1048
+ }
1049
+ const modifiedAtMillis = version.sys.modifiedAt.toMillis();
1050
+ const versionPath = `Projects/${this.projectId}/Collections/${version.collection}/Drafts/${version.slug}/Versions/${modifiedAtMillis}`;
1051
+ console.log(versionPath);
1052
+ const versionRef = this.db.doc(versionPath);
1053
+ batch.set(versionRef, version);
1054
+ });
1055
+ await batch.commit();
1056
+ console.log(`versions: saved ${versions.length} versions`);
1057
+ }
1058
+ /**
1059
+ * Returns the last time (in millis) saveVersions() was run, or 0 if has
1060
+ * never been run.
1061
+ */
1062
+ async getLastRun() {
1063
+ const projectDocRef = this.db.collection("Projects").doc(this.projectId);
1064
+ const projectDoc = await projectDocRef.get();
1065
+ if (projectDoc.exists) {
1066
+ const data = projectDoc.data() || {};
1067
+ const ts = data.versionsLastRun;
1068
+ if (ts) {
1069
+ return ts.toMillis();
1070
+ }
1071
+ }
1072
+ return 0;
1073
+ }
1074
+ /**
1075
+ * Saves {versionLastRun: <timestamp>} to the Projects/<projectId> doc.
1076
+ */
1077
+ async saveLastRun(millis) {
1078
+ const ts = Timestamp2.fromMillis(millis);
1079
+ const projectDocRef = this.db.collection("Projects").doc(this.projectId);
1080
+ await projectDocRef.set({ versionsLastRun: ts }, { merge: true });
1081
+ }
1082
+ /**
1083
+ * Returns a list of all docs that were edited after a certain time.
1084
+ */
1085
+ async getDocsModifiedAfter(millis) {
1086
+ const ts = Timestamp2.fromMillis(millis);
1087
+ const results = [];
1088
+ const collectionIds = await this.listCollections();
1089
+ for (const collectionId of collectionIds) {
1090
+ const collectionPath = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
1091
+ const query = this.db.collection(collectionPath).where("sys.modifiedAt", ">=", ts);
1092
+ const querySnapshot = await query.get();
1093
+ querySnapshot.forEach((doc) => {
1094
+ results.push(doc.data());
1095
+ });
1096
+ }
1097
+ return results;
1098
+ }
1099
+ /**
1100
+ * Returns a list of collection ids for the Root project.
1101
+ */
1102
+ async listCollections() {
1103
+ const collectionsDir = path2.join(this.rootConfig.rootDir, "collections");
1104
+ if (!fs2.existsSync(collectionsDir)) {
1105
+ return [];
1106
+ }
1107
+ const collectionIds = [];
1108
+ const collectionFileNames = await glob2("*.schema.ts", {
1109
+ cwd: collectionsDir
1110
+ });
1111
+ collectionFileNames.forEach((filename) => {
1112
+ collectionIds.push(filename.slice(0, -10));
1113
+ });
1114
+ return collectionIds;
1115
+ }
1116
+ };
1117
+
1118
+ // core/cron.ts
1119
+ var SEARCH_INDEX_MIN_INTERVAL_MS = 5 * 60 * 1e3;
1120
+ async function runCronJobs(rootConfig, options = {}) {
1121
+ await Promise.all([
1122
+ runCronJob("publishScheduledDocs", runPublishScheduledDocs, rootConfig),
1123
+ runCronJob(
1124
+ "syncScheduledDataSources",
1125
+ runSyncScheduledDataSources,
1126
+ rootConfig
1127
+ ),
1128
+ runCronJob("saveVersions", runSaveVersions, rootConfig),
1129
+ runCronJob(
1130
+ "incrementalSearchIndex",
1131
+ runIncrementalSearchIndex,
1132
+ rootConfig,
1133
+ options.loadSchema
1134
+ )
1135
+ ]);
1136
+ }
1137
+ async function runCronJob(name, fn, ...args) {
1138
+ try {
1139
+ await fn(...args);
1140
+ } catch (err) {
1141
+ console.log(`cron failed: ${name}`);
1142
+ console.error(String(err.stack || err));
1143
+ throw err;
1144
+ }
1145
+ }
1146
+ async function runPublishScheduledDocs(rootConfig) {
1147
+ const cmsClient = new RootCMSClient(rootConfig);
1148
+ await cmsClient.publishScheduledDocs();
1149
+ await cmsClient.publishScheduledReleases();
1150
+ }
1151
+ async function runSyncScheduledDataSources(rootConfig) {
1152
+ const cmsClient = new RootCMSClient(rootConfig);
1153
+ await cmsClient.syncScheduledDataSources();
1154
+ }
1155
+ async function runSaveVersions(rootConfig) {
1156
+ const service = new VersionsService(rootConfig);
1157
+ await service.saveVersions();
1158
+ }
1159
+ async function runIncrementalSearchIndex(rootConfig, loadSchema) {
1160
+ const service = new SearchIndexService(rootConfig, loadSchema);
1161
+ const status = await service.getStatus();
1162
+ if (status.lastRun !== null) {
1163
+ const elapsed = Date.now() - status.lastRun;
1164
+ if (elapsed < SEARCH_INDEX_MIN_INTERVAL_MS) {
1165
+ return;
1166
+ }
1167
+ const hasChanges = await service.hasChangesSince(
1168
+ Timestamp3.fromMillis(status.lastRun)
1169
+ );
1170
+ if (!hasChanges) {
1171
+ return;
1172
+ }
1173
+ }
1174
+ await service.rebuildIndex({ force: false });
1175
+ }
1176
+
1177
+ export {
1178
+ SearchIndexService,
1179
+ runCronJobs
1180
+ };