@blinkk/root-cms 3.0.1-alpha.0 → 3.0.1-alpha.1

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,921 @@
1
+ import {
2
+ RootCMSClient,
3
+ getCmsPlugin
4
+ } from "./chunk-SNZ4S4IC.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-index.ts
268
+ var SHARD_MAX_CHARS = 5e5;
269
+ var MAX_INDEX_SIZE_BYTES = 50 * 1024 * 1024;
270
+ var VACUUM_EVERY_N_RUNS = 25;
271
+ var SEARCH_INDEX_SUBCOLLECTION = "SearchIndex";
272
+ var META_DOC_ID = "_meta";
273
+ var DOC_MAP_DOC_ID = "_docMap";
274
+ var TITLEY_KEYS = /* @__PURE__ */ new Set(["title", "name", "slug", "headline", "label"]);
275
+ var MINISEARCH_OPTIONS = {
276
+ fields: ["text", "fieldLabel"],
277
+ storeFields: [
278
+ "docId",
279
+ "collection",
280
+ "slug",
281
+ "deepKey",
282
+ "fieldLabel",
283
+ "fieldType",
284
+ "text",
285
+ "weight"
286
+ ],
287
+ searchOptions: {
288
+ boost: { fieldLabel: 2 },
289
+ prefix: true,
290
+ fuzzy: 0.2
291
+ }
292
+ };
293
+ function isTitleyDeepKey(deepKey) {
294
+ const last = deepKey.split(".").pop() || "";
295
+ return TITLEY_KEYS.has(last.toLowerCase());
296
+ }
297
+ function withWeight(rec) {
298
+ return { ...rec, weight: isTitleyDeepKey(rec.deepKey) ? 2 : 1 };
299
+ }
300
+ var SearchIndexService = class {
301
+ constructor(rootConfig, loadSchema) {
302
+ this.cached = null;
303
+ this.rootConfig = rootConfig;
304
+ const cmsPlugin = getCmsPlugin(rootConfig);
305
+ const cmsPluginOptions = cmsPlugin.getConfig();
306
+ this.projectId = cmsPluginOptions.id || "default";
307
+ this.db = cmsPlugin.getFirestore();
308
+ this.loadSchema = loadSchema || ((id) => this.loadCollectionSchemaFromDist(id));
309
+ }
310
+ /** Returns the MiniSearch options used by both writers and readers. */
311
+ static getMiniSearchOptions() {
312
+ return MINISEARCH_OPTIONS;
313
+ }
314
+ indexDocPath(docId) {
315
+ return `Projects/${this.projectId}/${SEARCH_INDEX_SUBCOLLECTION}/${docId}`;
316
+ }
317
+ indexCollectionPath() {
318
+ return `Projects/${this.projectId}/${SEARCH_INDEX_SUBCOLLECTION}`;
319
+ }
320
+ /** Lists collection ids by globbing `<rootDir>/collections/*.schema.ts`. */
321
+ async listCollectionIds() {
322
+ const collectionsDir = path.join(this.rootConfig.rootDir, "collections");
323
+ if (!fs.existsSync(collectionsDir)) {
324
+ return [];
325
+ }
326
+ const fileNames = await glob("*.schema.ts", { cwd: collectionsDir });
327
+ return fileNames.map((f) => f.slice(0, -10));
328
+ }
329
+ /**
330
+ * Default schema loader: reads `dist/collections/<id>.schema.json`. These
331
+ * files are produced by the CMS plugin's `preBuild` hook during a real
332
+ * `root build`. In dev there is no JSON file on disk, so callers should
333
+ * pass a dev-aware `loadSchema` callback to the constructor (the API
334
+ * endpoints do this — they delegate to the same Vite SSR path used by
335
+ * `/cms/api/collection.get`).
336
+ */
337
+ async loadCollectionSchemaFromDist(collectionId) {
338
+ const distPath = path.join(
339
+ this.rootConfig.rootDir,
340
+ "dist",
341
+ "collections",
342
+ `${collectionId}.schema.json`
343
+ );
344
+ if (!fs.existsSync(distPath)) {
345
+ return null;
346
+ }
347
+ const contents = fs.readFileSync(distPath, "utf8");
348
+ return JSON.parse(contents);
349
+ }
350
+ async readMeta() {
351
+ const ref = this.db.doc(this.indexDocPath(META_DOC_ID));
352
+ const snap = await ref.get();
353
+ if (!snap.exists) {
354
+ return null;
355
+ }
356
+ return snap.data() || null;
357
+ }
358
+ async readDocMap() {
359
+ const ref = this.db.doc(this.indexDocPath(DOC_MAP_DOC_ID));
360
+ const snap = await ref.get();
361
+ if (!snap.exists) {
362
+ return {};
363
+ }
364
+ const data = snap.data() || {};
365
+ const records = data.records || {};
366
+ return records;
367
+ }
368
+ /** Reads all shard docs and returns the concatenated MiniSearch JSON string. */
369
+ async readShards(shardCount) {
370
+ if (shardCount <= 0) {
371
+ return "";
372
+ }
373
+ const refs = Array.from(
374
+ { length: shardCount },
375
+ (_, i) => this.db.doc(this.indexDocPath(`shard-${i}`))
376
+ );
377
+ const snaps = await this.db.getAll(...refs);
378
+ const parts = [];
379
+ for (const snap of snaps) {
380
+ if (!snap.exists) {
381
+ throw new Error(`searchIndex: missing shard ${snap.ref.path}`);
382
+ }
383
+ const data = snap.data() || {};
384
+ parts.push(typeof data.data === "string" ? data.data : "");
385
+ }
386
+ return parts.join("");
387
+ }
388
+ /** Writes shards, _docMap, _meta in one batched commit. */
389
+ async writeIndex(serialized, docMap, counts, existingShardCount) {
390
+ if (serialized.length > MAX_INDEX_SIZE_BYTES) {
391
+ throw new Error(
392
+ `searchIndex: serialized index size (${serialized.length} chars) exceeds MAX_INDEX_SIZE_BYTES. Reduce indexed fields or migrate to Cloud Storage.`
393
+ );
394
+ }
395
+ const shards = [];
396
+ for (let i = 0; i < serialized.length; i += SHARD_MAX_CHARS) {
397
+ shards.push(serialized.slice(i, i + SHARD_MAX_CHARS));
398
+ }
399
+ const newShardCount = shards.length;
400
+ const batch = this.db.batch();
401
+ shards.forEach((data, i) => {
402
+ const ref = this.db.doc(this.indexDocPath(`shard-${i}`));
403
+ batch.set(ref, { shardId: i, data });
404
+ });
405
+ for (let i = newShardCount; i < existingShardCount; i++) {
406
+ const ref = this.db.doc(this.indexDocPath(`shard-${i}`));
407
+ batch.delete(ref);
408
+ }
409
+ const docMapRef = this.db.doc(this.indexDocPath(DOC_MAP_DOC_ID));
410
+ batch.set(docMapRef, { records: docMap });
411
+ const metaRef = this.db.doc(this.indexDocPath(META_DOC_ID));
412
+ const meta = {
413
+ lastRun: Timestamp.now(),
414
+ shardCount: newShardCount,
415
+ docCount: counts.docCount,
416
+ fieldCount: counts.fieldCount,
417
+ runsSinceVacuum: counts.runsSinceVacuum
418
+ };
419
+ batch.set(metaRef, meta);
420
+ await batch.commit();
421
+ await this.db.doc(`Projects/${this.projectId}`).set(
422
+ {
423
+ searchIndexLastRun: meta.lastRun,
424
+ searchIndexDocCount: counts.docCount,
425
+ searchIndexFieldCount: counts.fieldCount
426
+ },
427
+ { merge: true }
428
+ );
429
+ return { shardCount: newShardCount };
430
+ }
431
+ /** Drops every doc under the SearchIndex subcollection. */
432
+ async wipeIndex() {
433
+ const snap = await this.db.collection(this.indexCollectionPath()).get();
434
+ if (snap.empty) {
435
+ return;
436
+ }
437
+ const batch = this.db.batch();
438
+ snap.forEach((d) => batch.delete(d.ref));
439
+ await batch.commit();
440
+ }
441
+ /** Lists the full set of live (collection, slug, doc) tuples for `Drafts`. */
442
+ async listAllDocs(collectionIds) {
443
+ const all = [];
444
+ for (const collectionId of collectionIds) {
445
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
446
+ const snap = await this.db.collection(path3).get();
447
+ snap.forEach((d) => {
448
+ const data = d.data() || {};
449
+ if (!data.collection) data.collection = collectionId;
450
+ if (!data.slug) data.slug = d.id;
451
+ if (!data.id) data.id = `${collectionId}/${d.id}`;
452
+ all.push(data);
453
+ });
454
+ }
455
+ return all;
456
+ }
457
+ /** Lists docs modified since `lastRun` across every collection. */
458
+ async listChangedDocs(collectionIds, lastRun) {
459
+ const all = [];
460
+ for (const collectionId of collectionIds) {
461
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
462
+ const q = this.db.collection(path3).where("sys.modifiedAt", ">=", lastRun);
463
+ const snap = await q.get();
464
+ snap.forEach((d) => {
465
+ const data = d.data() || {};
466
+ if (!data.collection) data.collection = collectionId;
467
+ if (!data.slug) data.slug = d.id;
468
+ if (!data.id) data.id = `${collectionId}/${d.id}`;
469
+ all.push(data);
470
+ });
471
+ }
472
+ return all;
473
+ }
474
+ /**
475
+ * Returns true when no docs have been modified since `lastRun` AND no
476
+ * deletions are pending (i.e. the doc-id set in `_docMap` matches the live
477
+ * doc-id set across all collections).
478
+ */
479
+ async hasChangesSince(lastRun, docMap = null) {
480
+ const collectionIds = await this.listCollectionIds();
481
+ for (const collectionId of collectionIds) {
482
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
483
+ const q = this.db.collection(path3).where("sys.modifiedAt", ">=", lastRun).limit(1);
484
+ const snap = await q.get();
485
+ if (!snap.empty) {
486
+ return true;
487
+ }
488
+ }
489
+ const map = docMap ?? await this.readDocMap();
490
+ const knownIds = new Set(Object.keys(map));
491
+ if (knownIds.size === 0) {
492
+ return false;
493
+ }
494
+ const liveIds = /* @__PURE__ */ new Set();
495
+ for (const collectionId of collectionIds) {
496
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
497
+ const snap = await this.db.collection(path3).select().get();
498
+ snap.forEach((d) => liveIds.add(`${collectionId}/${d.id}`));
499
+ }
500
+ for (const id of knownIds) {
501
+ if (!liveIds.has(id)) {
502
+ return true;
503
+ }
504
+ }
505
+ return false;
506
+ }
507
+ /** Orchestrates a rebuild (incremental by default; full when `force: true`). */
508
+ async rebuildIndex(opts = {}) {
509
+ const start = Date.now();
510
+ const force = !!opts.force;
511
+ const collectionIds = await this.listCollectionIds();
512
+ if (force) {
513
+ const result2 = await this.fullRebuild(collectionIds);
514
+ return {
515
+ forced: true,
516
+ skipped: false,
517
+ docCount: result2.docCount,
518
+ fieldCount: result2.fieldCount,
519
+ shardCount: result2.shardCount,
520
+ durationMs: Date.now() - start
521
+ };
522
+ }
523
+ const meta = await this.readMeta();
524
+ if (!meta) {
525
+ const result2 = await this.fullRebuild(collectionIds);
526
+ return {
527
+ forced: false,
528
+ skipped: false,
529
+ docCount: result2.docCount,
530
+ fieldCount: result2.fieldCount,
531
+ shardCount: result2.shardCount,
532
+ durationMs: Date.now() - start
533
+ };
534
+ }
535
+ const result = await this.incrementalRebuild(collectionIds, meta);
536
+ return {
537
+ forced: false,
538
+ skipped: result.skipped,
539
+ docCount: result.docCount,
540
+ fieldCount: result.fieldCount,
541
+ shardCount: result.shardCount,
542
+ durationMs: Date.now() - start
543
+ };
544
+ }
545
+ async fullRebuild(collectionIds) {
546
+ await this.wipeIndex();
547
+ const index = new MiniSearch(MINISEARCH_OPTIONS);
548
+ const docMap = {};
549
+ let docCount = 0;
550
+ let fieldCount = 0;
551
+ for (const collectionId of collectionIds) {
552
+ const schema = await this.loadSchema(collectionId);
553
+ if (!schema) {
554
+ console.warn(
555
+ `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.`
556
+ );
557
+ continue;
558
+ }
559
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
560
+ const snap = await this.db.collection(path3).get();
561
+ snap.forEach((d) => {
562
+ const data = d.data() || {};
563
+ const slug = data.slug || d.id;
564
+ const records = extractDocRecords(schema, {
565
+ collection: collectionId,
566
+ slug,
567
+ fields: data.fields || {}
568
+ });
569
+ if (records.length === 0) {
570
+ return;
571
+ }
572
+ const weighted = records.map(withWeight);
573
+ index.addAll(weighted);
574
+ docMap[`${collectionId}/${slug}`] = weighted.map((r) => r.id);
575
+ docCount += 1;
576
+ fieldCount += weighted.length;
577
+ });
578
+ }
579
+ const serialized = JSON.stringify(index.toJSON());
580
+ const { shardCount } = await this.writeIndex(
581
+ serialized,
582
+ docMap,
583
+ { docCount, fieldCount, runsSinceVacuum: 0 },
584
+ 0
585
+ );
586
+ this.cached = null;
587
+ return { docCount, fieldCount, shardCount };
588
+ }
589
+ async incrementalRebuild(collectionIds, meta) {
590
+ const json = await this.readShards(meta.shardCount);
591
+ const index = json ? MiniSearch.loadJSON(json, MINISEARCH_OPTIONS) : new MiniSearch(MINISEARCH_OPTIONS);
592
+ const docMap = await this.readDocMap();
593
+ const lastRun = meta.lastRun;
594
+ const changedDocs = await this.listChangedDocs(collectionIds, lastRun);
595
+ const schemaCache = /* @__PURE__ */ new Map();
596
+ const getSchema = async (collectionId) => {
597
+ if (!schemaCache.has(collectionId)) {
598
+ schemaCache.set(collectionId, await this.loadSchema(collectionId));
599
+ }
600
+ return schemaCache.get(collectionId) || null;
601
+ };
602
+ let touched = 0;
603
+ for (const doc of changedDocs) {
604
+ const collectionId = doc.collection;
605
+ const slug = doc.slug;
606
+ const docId = `${collectionId}/${slug}`;
607
+ const schema = await getSchema(collectionId);
608
+ if (!schema) {
609
+ continue;
610
+ }
611
+ const oldIds = docMap[docId] || [];
612
+ for (const id of oldIds) {
613
+ if (index.has(id)) {
614
+ index.discard(id);
615
+ }
616
+ }
617
+ const records = extractDocRecords(schema, {
618
+ collection: collectionId,
619
+ slug,
620
+ fields: doc.fields || {}
621
+ });
622
+ if (records.length === 0) {
623
+ delete docMap[docId];
624
+ continue;
625
+ }
626
+ const weighted = records.map(withWeight);
627
+ index.addAll(weighted);
628
+ docMap[docId] = weighted.map((r) => r.id);
629
+ touched += 1;
630
+ }
631
+ const liveIds = /* @__PURE__ */ new Set();
632
+ for (const collectionId of collectionIds) {
633
+ const path3 = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
634
+ const snap = await this.db.collection(path3).select().get();
635
+ snap.forEach((d) => liveIds.add(`${collectionId}/${d.id}`));
636
+ }
637
+ let deletions = 0;
638
+ for (const docId of Object.keys(docMap)) {
639
+ if (!liveIds.has(docId)) {
640
+ const ids = docMap[docId] || [];
641
+ for (const id of ids) {
642
+ if (index.has(id)) {
643
+ index.discard(id);
644
+ }
645
+ }
646
+ delete docMap[docId];
647
+ deletions += 1;
648
+ }
649
+ }
650
+ const skipped = touched === 0 && deletions === 0;
651
+ if (skipped) {
652
+ const noopMeta = {
653
+ ...meta,
654
+ lastRun: Timestamp.now()
655
+ };
656
+ await this.db.doc(this.indexDocPath(META_DOC_ID)).set(noopMeta, { merge: true });
657
+ return {
658
+ skipped: true,
659
+ docCount: meta.docCount,
660
+ fieldCount: meta.fieldCount,
661
+ shardCount: meta.shardCount
662
+ };
663
+ }
664
+ let runsSinceVacuum = (meta.runsSinceVacuum || 0) + 1;
665
+ if (runsSinceVacuum >= VACUUM_EVERY_N_RUNS) {
666
+ await index.vacuum();
667
+ runsSinceVacuum = 0;
668
+ }
669
+ let docCount = 0;
670
+ let fieldCount = 0;
671
+ for (const ids of Object.values(docMap)) {
672
+ if (ids.length > 0) {
673
+ docCount += 1;
674
+ fieldCount += ids.length;
675
+ }
676
+ }
677
+ const serialized = JSON.stringify(index.toJSON());
678
+ const { shardCount } = await this.writeIndex(
679
+ serialized,
680
+ docMap,
681
+ { docCount, fieldCount, runsSinceVacuum },
682
+ meta.shardCount
683
+ );
684
+ this.cached = null;
685
+ return { skipped: false, docCount, fieldCount, shardCount };
686
+ }
687
+ /**
688
+ * Returns the cached MiniSearch index, loading it from Firestore if
689
+ * necessary. The cache is keyed off `meta.lastRun` and survives across
690
+ * requests in the same process.
691
+ */
692
+ async getIndex() {
693
+ const meta = await this.readMeta();
694
+ if (!meta) {
695
+ this.cached = null;
696
+ return null;
697
+ }
698
+ if (this.cached && this.cached.meta.lastRun.toMillis() === meta.lastRun.toMillis()) {
699
+ return this.cached;
700
+ }
701
+ const json = await this.readShards(meta.shardCount);
702
+ const index = json ? MiniSearch.loadJSON(json, MINISEARCH_OPTIONS) : new MiniSearch(MINISEARCH_OPTIONS);
703
+ const docMap = await this.readDocMap();
704
+ this.cached = { index, meta, docMap };
705
+ return this.cached;
706
+ }
707
+ /** Runs a query against the loaded index. */
708
+ async search(q, options = {}) {
709
+ const limit = Math.max(1, Math.min(options.limit || 25, 100));
710
+ const cached = await this.getIndex();
711
+ const status = await this.getStatus();
712
+ if (!cached || !q.trim()) {
713
+ return { hits: [], meta: status };
714
+ }
715
+ const raw = cached.index.search(q.trim());
716
+ const hits = raw.slice(0, limit).map((r) => ({
717
+ id: String(r.id || ""),
718
+ docId: String(r.docId || ""),
719
+ collection: String(r.collection || ""),
720
+ slug: String(r.slug || ""),
721
+ deepKey: String(r.deepKey || ""),
722
+ fieldLabel: String(r.fieldLabel || ""),
723
+ fieldType: String(r.fieldType || ""),
724
+ text: String(r.text || ""),
725
+ score: typeof r.score === "number" ? r.score : 0,
726
+ terms: Array.isArray(r.terms) ? r.terms.map(String) : []
727
+ }));
728
+ return { hits, meta: status };
729
+ }
730
+ /** Lightweight metadata read for status endpoints / settings UI. */
731
+ async getStatus() {
732
+ const meta = await this.readMeta();
733
+ if (!meta) {
734
+ return { lastRun: null, docCount: 0, fieldCount: 0, shardCount: 0 };
735
+ }
736
+ return {
737
+ lastRun: meta.lastRun.toMillis(),
738
+ docCount: meta.docCount,
739
+ fieldCount: meta.fieldCount,
740
+ shardCount: meta.shardCount
741
+ };
742
+ }
743
+ };
744
+
745
+ // core/versions.ts
746
+ import fs2 from "fs";
747
+ import path2 from "path";
748
+ import { Timestamp as Timestamp2 } from "firebase-admin/firestore";
749
+ import glob2 from "tiny-glob";
750
+ var DOCUMENT_SAVE_OFFSET = 5 * 60 * 1e3;
751
+ var VersionsService = class {
752
+ constructor(rootConfig) {
753
+ this.rootConfig = rootConfig;
754
+ const cmsPlugin = getCmsPlugin(rootConfig);
755
+ const cmsPluginOptions = cmsPlugin.getConfig();
756
+ const projectId = cmsPluginOptions.id || "default";
757
+ this.projectId = projectId;
758
+ this.db = cmsPlugin.getFirestore();
759
+ }
760
+ /**
761
+ * Saves a version of all documents that have been edited since the last run.
762
+ */
763
+ async saveVersions() {
764
+ const lastRun = await this.getLastRun();
765
+ const lastRunWithOffset = lastRun === 0 ? lastRun : lastRun - DOCUMENT_SAVE_OFFSET;
766
+ const changedDocs = await this.getDocsModifiedAfter(lastRunWithOffset);
767
+ const now = Timestamp2.now().toMillis();
768
+ const versions = changedDocs.filter((doc) => {
769
+ const modifiedAt = doc.sys.modifiedAt.toMillis();
770
+ if (modifiedAt > now - DOCUMENT_SAVE_OFFSET) {
771
+ return false;
772
+ }
773
+ const publishedAt = doc.sys.publishedAt?.toMillis?.();
774
+ if (publishedAt && Math.abs(publishedAt - modifiedAt) < 5e3) {
775
+ return false;
776
+ }
777
+ return true;
778
+ });
779
+ if (versions.length > 0) {
780
+ this.saveVersionsToFirestore(versions);
781
+ }
782
+ this.saveLastRun(now);
783
+ }
784
+ async saveVersionsToFirestore(versions) {
785
+ const batch = this.db.batch();
786
+ versions.forEach((version) => {
787
+ if (!version.collection || !version.slug || !version.sys?.modifiedAt) {
788
+ return;
789
+ }
790
+ const modifiedAtMillis = version.sys.modifiedAt.toMillis();
791
+ const versionPath = `Projects/${this.projectId}/Collections/${version.collection}/Drafts/${version.slug}/Versions/${modifiedAtMillis}`;
792
+ console.log(versionPath);
793
+ const versionRef = this.db.doc(versionPath);
794
+ batch.set(versionRef, version);
795
+ });
796
+ await batch.commit();
797
+ console.log(`versions: saved ${versions.length} versions`);
798
+ }
799
+ /**
800
+ * Returns the last time (in millis) saveVersions() was run, or 0 if has
801
+ * never been run.
802
+ */
803
+ async getLastRun() {
804
+ const projectDocRef = this.db.collection("Projects").doc(this.projectId);
805
+ const projectDoc = await projectDocRef.get();
806
+ if (projectDoc.exists) {
807
+ const data = projectDoc.data() || {};
808
+ const ts = data.versionsLastRun;
809
+ if (ts) {
810
+ return ts.toMillis();
811
+ }
812
+ }
813
+ return 0;
814
+ }
815
+ /**
816
+ * Saves {versionLastRun: <timestamp>} to the Projects/<projectId> doc.
817
+ */
818
+ async saveLastRun(millis) {
819
+ const ts = Timestamp2.fromMillis(millis);
820
+ const projectDocRef = this.db.collection("Projects").doc(this.projectId);
821
+ await projectDocRef.set({ versionsLastRun: ts }, { merge: true });
822
+ }
823
+ /**
824
+ * Returns a list of all docs that were edited after a certain time.
825
+ */
826
+ async getDocsModifiedAfter(millis) {
827
+ const ts = Timestamp2.fromMillis(millis);
828
+ const results = [];
829
+ const collectionIds = await this.listCollections();
830
+ for (const collectionId of collectionIds) {
831
+ const collectionPath = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
832
+ const query = this.db.collection(collectionPath).where("sys.modifiedAt", ">=", ts);
833
+ const querySnapshot = await query.get();
834
+ querySnapshot.forEach((doc) => {
835
+ results.push(doc.data());
836
+ });
837
+ }
838
+ return results;
839
+ }
840
+ /**
841
+ * Returns a list of collection ids for the Root project.
842
+ */
843
+ async listCollections() {
844
+ const collectionsDir = path2.join(this.rootConfig.rootDir, "collections");
845
+ if (!fs2.existsSync(collectionsDir)) {
846
+ return [];
847
+ }
848
+ const collectionIds = [];
849
+ const collectionFileNames = await glob2("*.schema.ts", {
850
+ cwd: collectionsDir
851
+ });
852
+ collectionFileNames.forEach((filename) => {
853
+ collectionIds.push(filename.slice(0, -10));
854
+ });
855
+ return collectionIds;
856
+ }
857
+ };
858
+
859
+ // core/cron.ts
860
+ var SEARCH_INDEX_MIN_INTERVAL_MS = 5 * 60 * 1e3;
861
+ async function runCronJobs(rootConfig, options = {}) {
862
+ await Promise.all([
863
+ runCronJob("publishScheduledDocs", runPublishScheduledDocs, rootConfig),
864
+ runCronJob(
865
+ "syncScheduledDataSources",
866
+ runSyncScheduledDataSources,
867
+ rootConfig
868
+ ),
869
+ runCronJob("saveVersions", runSaveVersions, rootConfig),
870
+ runCronJob(
871
+ "incrementalSearchIndex",
872
+ runIncrementalSearchIndex,
873
+ rootConfig,
874
+ options.loadSchema
875
+ )
876
+ ]);
877
+ }
878
+ async function runCronJob(name, fn, ...args) {
879
+ try {
880
+ await fn(...args);
881
+ } catch (err) {
882
+ console.log(`cron failed: ${name}`);
883
+ console.error(String(err.stack || err));
884
+ throw err;
885
+ }
886
+ }
887
+ async function runPublishScheduledDocs(rootConfig) {
888
+ const cmsClient = new RootCMSClient(rootConfig);
889
+ await cmsClient.publishScheduledDocs();
890
+ await cmsClient.publishScheduledReleases();
891
+ }
892
+ async function runSyncScheduledDataSources(rootConfig) {
893
+ const cmsClient = new RootCMSClient(rootConfig);
894
+ await cmsClient.syncScheduledDataSources();
895
+ }
896
+ async function runSaveVersions(rootConfig) {
897
+ const service = new VersionsService(rootConfig);
898
+ await service.saveVersions();
899
+ }
900
+ async function runIncrementalSearchIndex(rootConfig, loadSchema) {
901
+ const service = new SearchIndexService(rootConfig, loadSchema);
902
+ const status = await service.getStatus();
903
+ if (status.lastRun !== null) {
904
+ const elapsed = Date.now() - status.lastRun;
905
+ if (elapsed < SEARCH_INDEX_MIN_INTERVAL_MS) {
906
+ return;
907
+ }
908
+ const hasChanges = await service.hasChangesSince(
909
+ Timestamp3.fromMillis(status.lastRun)
910
+ );
911
+ if (!hasChanges) {
912
+ return;
913
+ }
914
+ }
915
+ await service.rebuildIndex({ force: false });
916
+ }
917
+
918
+ export {
919
+ SearchIndexService,
920
+ runCronJobs
921
+ };