@murumets-ee/yhikas-sync 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -175,16 +175,49 @@ declare function planDiff<T>(input: PlanDiffInput<T>): DiffPlan<T>;
175
175
  * NOT `.strict()`: unknown keys are stripped rather than rejected, so adding a
176
176
  * third language upstream degrades to "the sync ignores it" instead of "every
177
177
  * run fails".
178
+ *
179
+ * 🔴 **Each locale key is nullable AND optional, and that is the load-bearing
180
+ * clause** (F058, cross-referenced to upstream F031). The column is declared
181
+ * `json(...).notNull()` — and **notNull covers the OBJECT, not its keys**. There
182
+ * is no CHECK constraint, and two of the four public columns
183
+ * (`room_type.name`, `legal_document.title`) have no upstream validation at all:
184
+ * the write is a raw `formData.get(…) as string`, which is `null` the moment a
185
+ * field is absent. So `{"et":"Tuba","en":null}` — and `{"et":"Tuba"}` — are rows
186
+ * yhikas-admin accepts today, through its ordinary admin UI.
187
+ *
188
+ * Requiring both keys would fail the ENTIRE response for one such row, so 32
189
+ * room types would stop syncing because one lacks an English name — every six
190
+ * hours, until somebody edited it upstream. That is exactly the blast-radius
191
+ * mistake `legalDocumentRowSchema.slug` documents below and deliberately
192
+ * avoided; a missing translation belongs at one row, and `projection.ts` is
193
+ * where it is decided (the locale is suppressed, or the row is skipped with a
194
+ * reason).
195
+ *
196
+ * What is NOT relaxed, on purpose:
197
+ *
198
+ * - **The object itself stays required.** A missing translation is DATA; a
199
+ * missing FIELD is a renamed column or a changed envelope. The consequence of
200
+ * tolerating one is caught either way — `assertSomethingSurvived` refuses a
201
+ * snapshot in which no row survived — but refusing here NAMES it
202
+ * (`title: Required`) instead of reporting 32 identical "no 'et' title
203
+ * upstream" skips and leaving the reader to infer the cause.
204
+ * - **A non-string value is still a shape violation.** `{"en":42}` would
205
+ * otherwise be written as a name.
206
+ *
207
+ * Note the asymmetry this leaves, deliberately: a RENAMED key (`et_EE`) is
208
+ * indistinguishable from two absent ones, because unknown keys are stripped by
209
+ * design so a third language degrades gracefully. That case falls through to
210
+ * `assertSomethingSurvived`, which is exactly what it is for.
178
211
  */
179
212
  declare const multilingualTextSchema: z.ZodObject<{
180
- et: z.ZodString;
181
- en: z.ZodString;
213
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
214
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
182
215
  }, "strip", z.ZodTypeAny, {
183
- et: string;
184
- en: string;
216
+ et?: string | null | undefined;
217
+ en?: string | null | undefined;
185
218
  }, {
186
- et: string;
187
- en: string;
219
+ et?: string | null | undefined;
220
+ en?: string | null | undefined;
188
221
  }>;
189
222
  type MultilingualText = z.infer<typeof multilingualTextSchema>;
190
223
  /**
@@ -199,14 +232,14 @@ type MultilingualText = z.infer<typeof multilingualTextSchema>;
199
232
  declare const roomTypeRowSchema: z.ZodObject<{
200
233
  code: z.ZodString;
201
234
  name: z.ZodObject<{
202
- et: z.ZodString;
203
- en: z.ZodString;
235
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
236
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
204
237
  }, "strip", z.ZodTypeAny, {
205
- et: string;
206
- en: string;
238
+ et?: string | null | undefined;
239
+ en?: string | null | undefined;
207
240
  }, {
208
- et: string;
209
- en: string;
241
+ et?: string | null | undefined;
242
+ en?: string | null | undefined;
210
243
  }>;
211
244
  totalArea: z.ZodNullable<z.ZodString>;
212
245
  livingArea: z.ZodNullable<z.ZodString>;
@@ -219,8 +252,8 @@ declare const roomTypeRowSchema: z.ZodObject<{
219
252
  }, "strip", z.ZodTypeAny, {
220
253
  code: string;
221
254
  name: {
222
- et: string;
223
- en: string;
255
+ et?: string | null | undefined;
256
+ en?: string | null | undefined;
224
257
  };
225
258
  totalArea: string | null;
226
259
  livingArea: string | null;
@@ -233,8 +266,8 @@ declare const roomTypeRowSchema: z.ZodObject<{
233
266
  }, {
234
267
  code: string;
235
268
  name: {
236
- et: string;
237
- en: string;
269
+ et?: string | null | undefined;
270
+ en?: string | null | undefined;
238
271
  };
239
272
  totalArea: string | null;
240
273
  livingArea: string | null;
@@ -258,14 +291,14 @@ type RoomTypeRow = z.infer<typeof roomTypeRowSchema>;
258
291
  declare const legalDocumentRowSchema: z.ZodObject<{
259
292
  type: z.ZodString;
260
293
  title: z.ZodObject<{
261
- et: z.ZodString;
262
- en: z.ZodString;
294
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
295
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
263
296
  }, "strip", z.ZodTypeAny, {
264
- et: string;
265
- en: string;
297
+ et?: string | null | undefined;
298
+ en?: string | null | undefined;
266
299
  }, {
267
- et: string;
268
- en: string;
300
+ et?: string | null | undefined;
301
+ en?: string | null | undefined;
269
302
  }>;
270
303
  /**
271
304
  * Accepted as free text HERE, and screened per-row in the projection.
@@ -286,8 +319,8 @@ declare const legalDocumentRowSchema: z.ZodObject<{
286
319
  type: string;
287
320
  slug: string;
288
321
  title: {
289
- et: string;
290
- en: string;
322
+ et?: string | null | undefined;
323
+ en?: string | null | undefined;
291
324
  };
292
325
  order: number;
293
326
  htmlContentEt: string;
@@ -296,8 +329,8 @@ declare const legalDocumentRowSchema: z.ZodObject<{
296
329
  type: string;
297
330
  slug: string;
298
331
  title: {
299
- et: string;
300
- en: string;
332
+ et?: string | null | undefined;
333
+ en?: string | null | undefined;
301
334
  };
302
335
  order: number;
303
336
  htmlContentEt: string;
@@ -319,14 +352,14 @@ declare const roomTypesResponseSchema: z.ZodObject<{
319
352
  roomTypes: z.ZodArray<z.ZodObject<{
320
353
  code: z.ZodString;
321
354
  name: z.ZodObject<{
322
- et: z.ZodString;
323
- en: z.ZodString;
355
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
356
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
324
357
  }, "strip", z.ZodTypeAny, {
325
- et: string;
326
- en: string;
358
+ et?: string | null | undefined;
359
+ en?: string | null | undefined;
327
360
  }, {
328
- et: string;
329
- en: string;
361
+ et?: string | null | undefined;
362
+ en?: string | null | undefined;
330
363
  }>;
331
364
  totalArea: z.ZodNullable<z.ZodString>;
332
365
  livingArea: z.ZodNullable<z.ZodString>;
@@ -339,8 +372,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
339
372
  }, "strip", z.ZodTypeAny, {
340
373
  code: string;
341
374
  name: {
342
- et: string;
343
- en: string;
375
+ et?: string | null | undefined;
376
+ en?: string | null | undefined;
344
377
  };
345
378
  totalArea: string | null;
346
379
  livingArea: string | null;
@@ -353,8 +386,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
353
386
  }, {
354
387
  code: string;
355
388
  name: {
356
- et: string;
357
- en: string;
389
+ et?: string | null | undefined;
390
+ en?: string | null | undefined;
358
391
  };
359
392
  totalArea: string | null;
360
393
  livingArea: string | null;
@@ -370,8 +403,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
370
403
  roomTypes: {
371
404
  code: string;
372
405
  name: {
373
- et: string;
374
- en: string;
406
+ et?: string | null | undefined;
407
+ en?: string | null | undefined;
375
408
  };
376
409
  totalArea: string | null;
377
410
  livingArea: string | null;
@@ -387,8 +420,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
387
420
  roomTypes: {
388
421
  code: string;
389
422
  name: {
390
- et: string;
391
- en: string;
423
+ et?: string | null | undefined;
424
+ en?: string | null | undefined;
392
425
  };
393
426
  totalArea: string | null;
394
427
  livingArea: string | null;
@@ -405,14 +438,14 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
405
438
  documents: z.ZodArray<z.ZodObject<{
406
439
  type: z.ZodString;
407
440
  title: z.ZodObject<{
408
- et: z.ZodString;
409
- en: z.ZodString;
441
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
442
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
410
443
  }, "strip", z.ZodTypeAny, {
411
- et: string;
412
- en: string;
444
+ et?: string | null | undefined;
445
+ en?: string | null | undefined;
413
446
  }, {
414
- et: string;
415
- en: string;
447
+ et?: string | null | undefined;
448
+ en?: string | null | undefined;
416
449
  }>;
417
450
  /**
418
451
  * Accepted as free text HERE, and screened per-row in the projection.
@@ -433,8 +466,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
433
466
  type: string;
434
467
  slug: string;
435
468
  title: {
436
- et: string;
437
- en: string;
469
+ et?: string | null | undefined;
470
+ en?: string | null | undefined;
438
471
  };
439
472
  order: number;
440
473
  htmlContentEt: string;
@@ -443,8 +476,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
443
476
  type: string;
444
477
  slug: string;
445
478
  title: {
446
- et: string;
447
- en: string;
479
+ et?: string | null | undefined;
480
+ en?: string | null | undefined;
448
481
  };
449
482
  order: number;
450
483
  htmlContentEt: string;
@@ -456,8 +489,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
456
489
  type: string;
457
490
  slug: string;
458
491
  title: {
459
- et: string;
460
- en: string;
492
+ et?: string | null | undefined;
493
+ en?: string | null | undefined;
461
494
  };
462
495
  order: number;
463
496
  htmlContentEt: string;
@@ -469,8 +502,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
469
502
  type: string;
470
503
  slug: string;
471
504
  title: {
472
- et: string;
473
- en: string;
505
+ et?: string | null | undefined;
506
+ en?: string | null | undefined;
474
507
  };
475
508
  order: number;
476
509
  htmlContentEt: string;
@@ -491,28 +524,28 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
491
524
  */
492
525
  declare const siteNoticeRowSchema: z.ZodObject<{
493
526
  text: z.ZodObject<{
494
- et: z.ZodString;
495
- en: z.ZodString;
527
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
528
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
496
529
  }, "strip", z.ZodTypeAny, {
497
- et: string;
498
- en: string;
530
+ et?: string | null | undefined;
531
+ en?: string | null | undefined;
499
532
  }, {
500
- et: string;
501
- en: string;
533
+ et?: string | null | undefined;
534
+ en?: string | null | undefined;
502
535
  }>;
503
536
  isActive: z.ZodBoolean;
504
537
  order: z.ZodNumber;
505
538
  }, "strip", z.ZodTypeAny, {
506
539
  text: {
507
- et: string;
508
- en: string;
540
+ et?: string | null | undefined;
541
+ en?: string | null | undefined;
509
542
  };
510
543
  order: number;
511
544
  isActive: boolean;
512
545
  }, {
513
546
  text: {
514
- et: string;
515
- en: string;
547
+ et?: string | null | undefined;
548
+ en?: string | null | undefined;
516
549
  };
517
550
  order: number;
518
551
  isActive: boolean;
@@ -536,52 +569,52 @@ type SiteNoticeRow = z.infer<typeof siteNoticeRowSchema>;
536
569
  declare const siteInfoResponseSchema: z.ZodObject<{
537
570
  success: z.ZodLiteral<true>;
538
571
  receptionHours: z.ZodObject<{
539
- et: z.ZodString;
540
- en: z.ZodString;
572
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
573
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
541
574
  }, "strip", z.ZodTypeAny, {
542
- et: string;
543
- en: string;
575
+ et?: string | null | undefined;
576
+ en?: string | null | undefined;
544
577
  }, {
545
- et: string;
546
- en: string;
578
+ et?: string | null | undefined;
579
+ en?: string | null | undefined;
547
580
  }>;
548
581
  notices: z.ZodArray<z.ZodObject<{
549
582
  text: z.ZodObject<{
550
- et: z.ZodString;
551
- en: z.ZodString;
583
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
584
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
552
585
  }, "strip", z.ZodTypeAny, {
553
- et: string;
554
- en: string;
586
+ et?: string | null | undefined;
587
+ en?: string | null | undefined;
555
588
  }, {
556
- et: string;
557
- en: string;
589
+ et?: string | null | undefined;
590
+ en?: string | null | undefined;
558
591
  }>;
559
592
  isActive: z.ZodBoolean;
560
593
  order: z.ZodNumber;
561
594
  }, "strip", z.ZodTypeAny, {
562
595
  text: {
563
- et: string;
564
- en: string;
596
+ et?: string | null | undefined;
597
+ en?: string | null | undefined;
565
598
  };
566
599
  order: number;
567
600
  isActive: boolean;
568
601
  }, {
569
602
  text: {
570
- et: string;
571
- en: string;
603
+ et?: string | null | undefined;
604
+ en?: string | null | undefined;
572
605
  };
573
606
  order: number;
574
607
  isActive: boolean;
575
608
  }>, "many">;
576
609
  }, "strip", z.ZodTypeAny, {
577
610
  receptionHours: {
578
- et: string;
579
- en: string;
611
+ et?: string | null | undefined;
612
+ en?: string | null | undefined;
580
613
  };
581
614
  notices: {
582
615
  text: {
583
- et: string;
584
- en: string;
616
+ et?: string | null | undefined;
617
+ en?: string | null | undefined;
585
618
  };
586
619
  order: number;
587
620
  isActive: boolean;
@@ -589,13 +622,13 @@ declare const siteInfoResponseSchema: z.ZodObject<{
589
622
  success: true;
590
623
  }, {
591
624
  receptionHours: {
592
- et: string;
593
- en: string;
625
+ et?: string | null | undefined;
626
+ en?: string | null | undefined;
594
627
  };
595
628
  notices: {
596
629
  text: {
597
- et: string;
598
- en: string;
630
+ et?: string | null | undefined;
631
+ en?: string | null | undefined;
599
632
  };
600
633
  order: number;
601
634
  isActive: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/constants.ts","../src/diff.ts","../src/upstream/wire.ts","../src/projection.ts","../src/apply.ts","../src/entities/guard.ts","../src/entities/index.ts","../src/entities/legal-document.ts","../src/entities/room-type.ts","../src/entities/site-info.ts","../src/entity-client.ts","../src/sync-state-table.ts","../src/sync-state.ts","../src/upstream/client.ts","../src/run-sync.ts","../src/upstream/errors.ts","../src/watchdog.ts"],"mappings":";;;;;;;;;;;;AASA;;cAAa,uBAAA;;AAAuB;AAWpC;;;;AAAiC;AAOjC;;cAPa,oBAAA;;AAOa;AAC1B;;;cADa,aAAA;AAAA,cACA,iBAAA;AAgBb;;;;AAAgC;AAChC;;;;AAAqC;AACrC;;;;AAFA,cAAa,mBAAA;AAAA,cACA,wBAAA;AAAA,cACA,kBAAA;AAAA,cAEA,cAAA;AAAA,KAKD,YAAA,WAAuB,cAAc;AADvC;AACV;;;;AAAiD;AAYjD;;;;AAbU,cAaG,aAAA;AAOb;;;;AAAsB;AAAtB,cAAa,SAAA;AAAA,cACA,SAAA;;;AAAS;AAatB;;;;AAA8B;AAE9B;;;cAFa,iBAAA;AAEsB;AAAA,cAAtB,sBAAA;;ACxDe;AAG5B;;;cD4Fa,gBAAA;;;;;;;;cASA,eAAA;AAAA,cACA,gBAAA;;;AArHoB;AAAA,UCAhB,QAAA;EAAA,SACN,EAAA;;WAEA,GAAA;EAAA,SACA,MAAA;EDIE;;;;AAAiB;EAAjB,SCEF,UAAA;EDcqB;EAAA,SCZrB,WAAA,EAAa,IAAI;AAAA;AAAA,UAGX,QAAA;EDUJ;EAAA,SCRF,MAAA,WAAiB,CAAA;;WAEjB,MAAA;IAAA,SAA4B,KAAA,EAAO,QAAA;IAAA,SAAmB,GAAA,EAAK,CAAA;EAAA;;WAE3D,SAAA,WAAoB,QAAA;EDKA;EAAA,SCHpB,MAAA,WAAiB,QAAA;AAAA;AAAA,UAGX,WAAA;EDMP;EAAA,SCJC,OAAA;EDKC;EAAA,SCHD,iBAAA;;WAEA,kBAAA;AAAA;AAAA,UAGM,aAAA;EAAA,SACN,QAAA,EAAU,YAAA;EAAA,SACV,QAAA,WAAmB,CAAA;EAAA,SACnB,KAAA,WAAgB,QAAA;EAAA,SAChB,KAAA,GAAQ,GAAA,EAAK,CAAA;EAAA,SACb,MAAA,GAAS,GAAA,EAAK,CAAA;EAAA,SACd,KAAA,EAAO,WAAA;AAAA;;ADWI;AACtB;;;;AAAsB;iBCFN,UAAA,CAAW,KAAc;;;;ADeX;AAE9B;;;;AAAmC;AAuCnC;;;;AAA6B;AAS7B;iBChCgB,QAAA,GAAA,CAAY,KAAA,EAAO,aAAA,CAAc,CAAA,IAAK,QAAA,CAAS,CAAA;;;AD5E/D;;;;AAA8B;AAgB9B;;;AAhBA,cE2Ba,sBAAA,EAAsB,CAAA,CAAA,SAAA;;;;;;;;;;KAKvB,gBAAA,GAAmB,CAAA,CAAE,KAAK,QAAQ,sBAAA;AFdf;AAE/B;;;;AAIU;AACV;;;AAP+B,cEyBlB,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAalB,WAAA,GAAc,CAAA,CAAE,KAAK,QAAQ,iBAAA;;;;;;;ADpDb;AAG5B;;cC4Da,sBAAA,EAAsB,CAAA,CAAA,SAAA;;;;;;;;;;;;EDxDI;;;;;;;;;AAIH;AAGpC;;;;;;;;;;;;;;;;;;;;;;;;;;KCqEY,gBAAA,GAAmB,CAAA,CAAE,KAAK,QAAQ,sBAAA;;;;;;;;;;ADtDjB;cCkEhB,uBAAA,EAAuB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAKvB,4BAAA,EAA4B,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAiB5B,mBAAA,EAAmB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAMpB,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;;;;;;;;cAiC9B,sBAAA,EAAsB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAMvB,gBAAA,GAAmB,CAAA,CAAE,KAAK,QAAQ,sBAAA;;;;KCvJlC,aAAA,IAAiB,IAAY;AAAA,UAExB,iBAAA;EHDe;EAAA,SGGrB,aAAa;AAAA;AAAA,UAGP,YAAA;EHLJ;EAAA,SGOF,GAAA;;WAEA,IAAA,EAAM,MAAA;EHToB;AACrC;;;;EADqC,SGe1B,SAAA,EAAW,MAAM;EAAA,SACjB,IAAA;AAAA;AAAA,UAGM,UAAA;EAAA,SACN,GAAA;EAAA,SACA,MAAM;AAAA;AAAA,UAGA,gBAAA;EAAA,SACN,SAAA,WAAoB,YAAA;EAAA,SACpB,OAAA,WAAkB,UAAU;AAAA;;iBAgCvB,iBAAA,CAAkB,aAAqB;AAAA,iBAmDvC,gBAAA,CACd,IAAA,WAAe,WAAA,IACf,OAAA,EAAS,iBAAA,GACR,gBAAA;AAAA,iBAmDa,qBAAA,CACd,IAAA,WAAe,gBAAA,IACf,OAAA,EAAS,iBAAA,EACT,QAAA,EAAU,aAAA,GACT,gBAAA;;;AH5HgC;AAuCnC;;;;AAA6B;AAS7B;;;;AAA4B;AAC5B;;;;AAA6B;;;;ACrH7B;;;;;;;;iBEkSgB,eAAA,CACd,QAAA,EAAU,gBAAA,EACV,OAAA,EAAS,iBAAA,GACR,gBAAA;;;;;;AH5QkC;AACrC;;;;UIJiB,gBAAA;EACf,QAAA,CAAS,OAAA;IAAW,KAAA;EAAA,IAAkB,OAAA,CAAQ,MAAA;EAC9C,MAAA,CAAO,IAAA,EAAM,MAAA,oBAA0B,OAAA,CAAQ,MAAA;EAC/C,MAAA,CAAO,EAAA,UAAY,IAAA,EAAM,MAAA,oBAA0B,OAAA,CAAQ,MAAA;EAC3D,eAAA,CACE,EAAA,UACA,IAAA,EAAM,MAAA,mBACN,MAAA,WACC,OAAA,CAAQ,MAAA;EACX,iBAAA,CAAkB,EAAA,UAAY,MAAA,WAAiB,OAAA;AAAA;AAAA,UAGhC,UAAA;EACf,IAAA,CAAK,GAAA,EAAK,MAAA,mBAAyB,GAAA;EACnC,IAAA,CAAK,GAAA,EAAK,MAAA,mBAAyB,GAAA;EACnC,KAAA,CAAM,GAAA,EAAK,MAAA,mBAAyB,GAAA;AAAA;AAAA,UAGrB,WAAA;EACf,OAAA;EACA,OAAA;EACA,OAAA;EACA,SAAA;EACA,MAAA;AAAA;AJQF;AAAA,cIJa,uBAAA,SAAgC,KAAA;EAAA,SAClC,MAAA,EAAQ,WAAA;cACL,QAAA,EAAU,YAAA,EAAc,MAAA,EAAQ,WAAA;AAAA;AAAA,iBAqCxB,aAAA,CACpB,MAAA,EAAQ,gBAAA,EACR,QAAA,UACA,KAAA,WACC,OAAA,CAAQ,QAAA;AAAA,UAWM,cAAA;EAAA,SACN,QAAA,EAAU,YAAA;EAAA,SACV,IAAA,EAAM,QAAA,CAAS,YAAA;EAAA,SACf,MAAA,EAAQ,gBAAA;EJtCN;EAAA,SIwCF,eAAA;EAAA,SACA,MAAA,EAAQ,UAAA;AAAA;AAAA,iBAGG,SAAA,CAAU,KAAA,EAAO,cAAA,GAAiB,OAAA,CAAQ,WAAA;;;AJ9ClC;AAAA,cKZjB,gCAAA,SAAyC,KAAK;cAEvD,UAAA,UACA,MAAA,kGAOA,OAAA;AAAA;;ALI+B;AAuCnC;;;;iBKEgB,cAAA,kBAAA,CACd,UAAA,EAAY,CAAA,GACX,QAAA,CAAS,MAAA,+CAAqD,CAAA;;;;;;;;;AL1HjE;;;;cMMa,eAAA,0CAAe,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ANN5B;;;;AAAoC;AAWpC;;;;AAAiC;AAOjC;;;;AAA0B;AAC1B;;;;cOCa,4BAAA;AAAA,cAEA,mBAAA,gCAAmB,MAAA;;sGAyE9B,MAAA;EP5D8B;AAAA;AAChC;;;;AAAqC;;;;;;;;;;;EAQzB;;;;AAAqC;AAYjD;;;;AAA0B;;;;;KAQb;;;;KAaA;;;;;;EAEsB;;;AAAA;AAuCnC;;;;;;;;EAS4B;AAAA;AAC5B;;;;;;;2QOjCE,MAAA;;;;;;;;;AP/FF;;;;AAAoC;AAWpC;;;;AAAiC;AAOjC;;;;AAA0B;AAC1B;;;;AAA8B;AAgB9B;;;;AAAgC;AAChC;;;;cQLa,uBAAA;ARMb;AAAA,cQHa,cAAA;AAAA,cAEA,cAAA,gCAAc,MAAA;;sGAgFzB,MAAA;ER7EW;;;;AAIH;AACV;;;;;;;;;;;;;;;;;;;;;;;;;;EAmCmC;AAAA;AAuCnC;;;;;;;;;;;;;;;;;;;;;;;;ECjGW;;;;AAEiB;AAG5B;;;;;;EAM+B;;;;;;;;;;sQOoF7B,MAAA;;;;;;;;;ARpHF;;;;AAAoC;AAWpC;;;;AAAiC;AAOjC;;;;AAA0B;AAC1B;;;;AAA8B;AAgB9B;;;;AAAgC;AAChC;;;cSPa,uBAAA;AAAA,cAEA,cAAA,gCAAc,MAAA;;sGA4DzB,MAAA;;;ATtD6B;AAE/B;;;;AAIU;;;;;;;EAagB;;;AAAA;AAO1B;;;;;;;;EACsB;AAAA;AAatB;;;;AAA8B;AAE9B;;;;;EAuCa;;;;;;;KASe;;;;sQSpC1B,MAAA;;;;KC/EU,eAAA,GAAkB,UAAU,CAAC,UAAA;AVMf;AAC1B;;;;AAA8B;AAgB9B;;AAjB0B,iBUIV,kBAAA,CACd,MAAA,EAAQ,eAAA,EACR,aAAA,WACC,gBAAgB;;;;;;;;;AVzBnB;;;;AAAoC;AAWpC;;;;AAAiC;AAOjC;;;;AAA0B;AAC1B;;;;AAA8B;AAgB9B;cWda,oBAAA;;;;;;;;;;;;;;;;;;;;;;;;;;IXmCa;IAAA;AAO1B;;;;AAAsB;IAPI,8FAQJ;IAAA,kGAAA;IAAA;IAaT;;;;AAAiB;IAAjB,8FAEsB;IAAA;;;;;;;;IAgDtB;IAAe;;AAAA;AAC5B;;;IAD4B,8FACC;IAAA;;ICrHZ;;;;;IAAA,8FAIN;IAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UWVM,eAAA;EAAA,SACN,QAAA;EAAA,SACA,WAAA,EAAa,IAAA;EAAA,SACb,aAAA,EAAe,IAAA;EAAA,SACf,aAAA,EAAe,IAAA;EAAA,SACf,SAAA;AAAA;AAAA,UAGM,cAAA;EZKS;EYHxB,MAAA,CAAO,QAAA,EAAU,YAAA,EAAc,GAAA,EAAK,IAAA,GAAO,OAAA;EAC3C,aAAA,CAAc,QAAA,EAAU,YAAA,EAAc,GAAA,EAAK,IAAA,GAAO,OAAA;EAClD,aAAA,CAAc,QAAA,EAAU,YAAA,EAAc,GAAA,EAAK,IAAA,EAAM,MAAA,EAAQ,WAAA,GAAc,OAAA;EACvE,aAAA,CAAc,QAAA,EAAU,YAAA,EAAc,GAAA,EAAK,IAAA,EAAM,KAAA,WAAgB,OAAA;EACjE,OAAA,IAAW,OAAA,CAAQ,eAAA;AAAA;AAAA,KAGhB,eAAA,GAAkB,UAAU,QAAQ,oBAAA,CAAqB,UAAA;AAAA,iBAK9C,aAAA,CAAc,OAAe;AAAA,iBAI7B,oBAAA,CAAqB,MAAA,EAAQ,eAAA,GAAkB,cAAc;;;UCU5D,2BAAA;;EAEf,OAAA;EbzBwB;Ea2BxB,MAAA;Eb1B4B;Ea4B5B,SAAA;Eb5B4B;Ea8B5B,SAAA,UAAmB,KAAK;AAAA;AAAA,cAgEb,oBAAA;EAAA;cAMC,OAAA,EAAS,2BAAA;EbpFS;EasHxB,cAAA,CAAA,GAAkB,OAAA,CAAQ,WAAA;EbrHG;Ea2H7B,mBAAA,CAAA,GAAuB,OAAA,CAAQ,gBAAA;Eb3HF;AAAA;AACrC;;;;AAA+B;AAE/B;EayIQ,aAAA,CAAA,GAAiB,OAAA,CAAQ,gBAAA;AAAA;;;UCnIhB,WAAA;EAAA,SACN,QAAA,EAAU,oBAAA;EAAA,SACV,cAAA,EAAgB,gBAAA;EAAA,SAChB,mBAAA,EAAqB,gBAAA;EAAA,SACrB,cAAA,EAAgB,gBAAA;EAAA,SAChB,KAAA,EAAO,cAAA;EAAA,SACP,YAAA,EAAc,aAAA;EAAA,SACd,MAAA,EAAQ,UAAA;EAAA,SACR,aAAA;EAAA,SACA,KAAA,EAAO,WAAA;EdjBa;EAAA,ScmBpB,GAAA,SAAY,IAAA;AAAA;AAAA,UAGN,eAAA;EAAA,SACN,QAAA,EAAU,YAAA;EAAA,SACV,EAAA;EAAA,SACA,MAAA,EAAQ,WAAW;EAAA,SACnB,OAAA;EAAA,SACA,KAAA;AAAA;AAAA,UAGM,cAAA;EAAA,SACN,QAAA,WAAmB,eAAe;EAAA,SAClC,EAAA;AAAA;;cAIE,kBAAA,SAA2B,KAAA;EAAA,SAC7B,OAAA,EAAS,cAAA;cACN,OAAA,EAAS,cAAA;AAAA;AAAA,iBAWD,aAAA,CAAc,IAAA,EAAM,WAAA,GAAc,OAAA,CAAQ,cAAA;;;KCpFpD,mBAAA,GfFwB,yRAkBpC;AAAA,ceNa,mBAAA,SAA4B,KAAA;EAAA,SAC9B,QAAA,EAAU,YAAA;EAAA,SACV,IAAA,EAAM,mBAAA;EAAA,SACN,MAAA;cAGP,QAAA,EAAU,YAAA,EACV,IAAA,EAAM,mBAAA,EACN,OAAA,UACA,OAAA;IAAY,MAAA;IAAiB,KAAA;EAAA;AAAA;;;;AfcD;AAChC;;ceCa,sBAAA,SAA+B,KAAA;EAAA,SACjC,QAAA,EAAU,YAAA;EAAA,SACV,MAAA;EfFE;;;;AAAkB;AAE/B;;;;AAIU;AACV;;EAPa;ceqBC,QAAA,EAAU,YAAA,EAAc,MAAA,EAAQ,sBAAA,YAAkC,OAAA;AAAA;;;UCrC/D,iBAAA;EAAA,SACN,QAAA,EAAU,YAAA;EhBaW;EAAA,SgBXrB,aAAA,EAAe,IAAI;EhBYO;EAAA,SgBV1B,KAAA;EAAA,SACA,KAAA;EAAA,SACA,SAAA;AAAA;;cAIE,oBAAA,SAA6B,KAAA;EAAA,SAC/B,KAAA,WAAgB,iBAAA;cACb,KAAA,WAAgB,iBAAA,IAAqB,YAAA;AAAA;;;;AhBSzC;AACV;;;;AAAiD;AAYjD;;;iBgBwBgB,eAAA,CACd,OAAA,WAAkB,eAAA,IAClB,GAAA,EAAK,IAAA,EACL,YAAA,WACC,iBAAA;AhB5BuB;AAO1B;;;;AAAsB;AAPI,iBgB4DV,cAAA,CACd,OAAA,WAAkB,eAAA,IAClB,GAAA,EAAK,IAAA,EACL,YAAA,WACC,iBAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/constants.ts","../src/diff.ts","../src/upstream/wire.ts","../src/projection.ts","../src/apply.ts","../src/entities/guard.ts","../src/entities/index.ts","../src/entities/legal-document.ts","../src/entities/room-type.ts","../src/entities/site-info.ts","../src/entity-client.ts","../src/sync-state-table.ts","../src/sync-state.ts","../src/upstream/client.ts","../src/run-sync.ts","../src/upstream/errors.ts","../src/watchdog.ts"],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{C as e,D as t,E as n,O as r,S as i,T as a,_ as o,a as s,b as c,c as l,d as u,f as d,h as f,i as p,l as m,m as h,n as g,o as _,p as v,r as y,s as b,t as x,u as S,v as C,w,x as T,y as E}from"./watchdog-BVf1F4sR.mjs";import{A as D,D as O,M as k,O as A,S as j,T as M,_ as N,a as P,c as F,d as I,f as L,g as R,h as z,i as B,j as V,k as H,l as U,m as W,n as G,o as K,p as q,r as J,s as Y,t as X,u as Z,v as Q,w as $,x as ee,y as te}from"./sync-state-table-CM6eL5W9.mjs";export{W as API_KEY_ENV_VAR,z as BASE_URL_ENV_VAR,R as DECLARED_CURRENCY,N as DECLARED_VAT_TREATMENT,Q as EN_LOCALE,te as ET_LOCALE,ee as LEGAL_DOCUMENTS_RESOURCE,j as MAX_SITE_NOTICES,$ as ROOM_TYPES_RESOURCE,M as SITE_INFO_KEY,O as SITE_INFO_RESOURCE,G as SYNCED_ENTITIES,A as SYNC_JOB_NAME,H as SYNC_RESOURCES,P as VAT_TREATMENTS,D as WATCHDOG_JOB_NAME,F as YHIKAS_LEGAL_DOCUMENT_ENTITY,K as YHIKAS_ROOM_TYPE_ENTITY,J as YHIKAS_SITE_INFO_ENTITY,V as YHIKAS_SYNC_ACTOR_ID,k as YHIKAS_SYNC_PLUGIN_NAME,n as YhikasApplyPartialError,U as YhikasLegalDocument,Y as YhikasRoomType,B as YhikasSiteInfo,L as YhikasSyncConfigError,w as YhikasSyncRefusedError,h as YhikasSyncRunError,x as YhikasSyncStaleError,Z as YhikasSyncedContentReadOnlyError,p as YhikasUpstreamClient,a as YhikasUpstreamError,t as applyPlan,g as assertNotStale,y as assessStaleness,d as createSyncStateStore,s as legalDocumentRowSchema,_ as legalDocumentsResponseSchema,I as machineWritten,b as multilingualTextSchema,i as planDiff,o as projectLegalDocuments,C as projectRoomTypes,E as projectSiteInfo,r as readLocalRows,q as resolveYhikasSyncConfig,l as roomTypeRowSchema,m as roomTypesResponseSchema,f as runYhikasSync,c as secondaryLocaleOf,S as siteInfoResponseSchema,u as siteNoticeRowSchema,e as stableHash,T as toSyncEntityClient,v as truncateError,X as yhikasSyncStateTable};
1
+ import{C as e,D as t,E as n,O as r,S as i,T as a,_ as o,a as s,b as c,c as l,d as u,f as d,h as f,i as p,l as m,m as h,n as g,o as _,p as v,r as y,s as b,t as x,u as S,v as C,w,x as T,y as E}from"./watchdog-dfFi-7re.mjs";import{A as D,D as O,M as k,O as A,S as j,T as M,_ as N,a as P,c as F,d as I,f as L,g as R,h as z,i as B,j as V,k as H,l as U,m as W,n as G,o as K,p as q,r as J,s as Y,t as X,u as Z,v as Q,w as $,x as ee,y as te}from"./sync-state-table-CM6eL5W9.mjs";export{W as API_KEY_ENV_VAR,z as BASE_URL_ENV_VAR,R as DECLARED_CURRENCY,N as DECLARED_VAT_TREATMENT,Q as EN_LOCALE,te as ET_LOCALE,ee as LEGAL_DOCUMENTS_RESOURCE,j as MAX_SITE_NOTICES,$ as ROOM_TYPES_RESOURCE,M as SITE_INFO_KEY,O as SITE_INFO_RESOURCE,G as SYNCED_ENTITIES,A as SYNC_JOB_NAME,H as SYNC_RESOURCES,P as VAT_TREATMENTS,D as WATCHDOG_JOB_NAME,F as YHIKAS_LEGAL_DOCUMENT_ENTITY,K as YHIKAS_ROOM_TYPE_ENTITY,J as YHIKAS_SITE_INFO_ENTITY,V as YHIKAS_SYNC_ACTOR_ID,k as YHIKAS_SYNC_PLUGIN_NAME,n as YhikasApplyPartialError,U as YhikasLegalDocument,Y as YhikasRoomType,B as YhikasSiteInfo,L as YhikasSyncConfigError,w as YhikasSyncRefusedError,h as YhikasSyncRunError,x as YhikasSyncStaleError,Z as YhikasSyncedContentReadOnlyError,p as YhikasUpstreamClient,a as YhikasUpstreamError,t as applyPlan,g as assertNotStale,y as assessStaleness,d as createSyncStateStore,s as legalDocumentRowSchema,_ as legalDocumentsResponseSchema,I as machineWritten,b as multilingualTextSchema,i as planDiff,o as projectLegalDocuments,C as projectRoomTypes,E as projectSiteInfo,r as readLocalRows,q as resolveYhikasSyncConfig,l as roomTypeRowSchema,m as roomTypesResponseSchema,f as runYhikasSync,c as secondaryLocaleOf,S as siteInfoResponseSchema,u as siteNoticeRowSchema,e as stableHash,T as toSyncEntityClient,v as truncateError,X as yhikasSyncStateTable};
@@ -1,2 +1,2 @@
1
- import{b as e,f as t,g as n,h as r,i,n as a,x as o}from"./watchdog-BVf1F4sR.mjs";import{h as s,i as c,j as l,k as u,l as d,m as f,s as p,t as m}from"./sync-state-table-CM6eL5W9.mjs";import{elevateRequestContext as h,runWithContextAsync as g}from"@murumets-ee/core";var _=class extends Error{constructor(e){super(`yhikas-sync is enabled but ${e.join(` and `)} ${e.length===1?`is`:`are`} not set. Failing loudly rather than skipping quietly: an unconfigured sync is a broken sync, and a broken sync must be visibly broken. Set the variable, or pass \`enabled: false\` to yhikasSync() if this install has no yhikas-admin.`),this.name=`YhikasSyncNotConfiguredError`}};function v(){return h({user:{id:l,groups:[`admin`]}})}function y(e){return e.logger}async function b(t){let{resolveI18nConfig:r}=await import(`@murumets-ee/content`),{defaultLocale:i}=await r(t);if(!n.includes(i))throw new x(`The app's default locale is '${i}', but yhikas-admin carries content in 'et' and 'en' only. The sync has nothing to write to the base row, so it refuses rather than publishing one language under another's name.`);return{defaultLocale:i,secondaryLocale:e(i)}}var x=class extends Error{constructor(e){super(e),this.name=`YhikasSyncLocaleError`}};function S(e,t){let n=t[f],r=[];if(e.baseUrl||r.push(s),n||r.push(f),!e.baseUrl||!n)throw new _(r);return{baseUrl:e.baseUrl,apiKey:n}}async function C(e,n,a=process.env){let{baseUrl:s,apiKey:l}=S(n,a),{sanitizeHtml:u}=await import(`@murumets-ee/blocks`);return g(v(),async()=>{let{defaultLocale:a}=await b(e);return r({upstream:new i({baseUrl:s,apiKey:l,timeoutMs:n.requestTimeoutMs}),roomTypeClient:o(e.getClient(p),a),legalDocumentClient:o(e.getClient(d),a),siteInfoClient:o(e.getClient(c),a),state:t(m.makeClient(e.db.readWrite)),sanitizeHtml:u,logger:y(e),defaultLocale:a,floor:{maxRows:n.maxRowsPerResource,maxRetireFraction:n.maxRetireFraction,minRowsForFraction:n.minRowsForRetireFloor}})})}async function w(e,n,r=new Date){let i=t(m.makeClient(e.db.readWrite));await T(e,r);let o=a(await i.readAll(),r,n.staleAfterMs);return e.logger.debug({window:n.staleAfterMs,resources:o.map(e=>({resource:e.resource,lastSuccessAt:e.lastSuccessAt}))},`yhikas-sync: staleness check passed`),o}async function T(e,n=new Date){let r=t(m.makeClient(e.db.readWrite));for(let e of u)await r.ensure(e,n)}export{C as runSyncJob,w as runWatchdogJob};
2
- //# sourceMappingURL=jobs-DrMLTUFm.mjs.map
1
+ import{b as e,f as t,g as n,h as r,i,n as a,x as o}from"./watchdog-dfFi-7re.mjs";import{h as s,i as c,j as l,k as u,l as d,m as f,s as p,t as m}from"./sync-state-table-CM6eL5W9.mjs";import{elevateRequestContext as h,runWithContextAsync as g}from"@murumets-ee/core";var _=class extends Error{constructor(e){super(`yhikas-sync is enabled but ${e.join(` and `)} ${e.length===1?`is`:`are`} not set. Failing loudly rather than skipping quietly: an unconfigured sync is a broken sync, and a broken sync must be visibly broken. Set the variable, or pass \`enabled: false\` to yhikasSync() if this install has no yhikas-admin.`),this.name=`YhikasSyncNotConfiguredError`}};function v(){return h({user:{id:l,groups:[`admin`]}})}function y(e){return e.logger}async function b(t){let{resolveI18nConfig:r}=await import(`@murumets-ee/content`),{defaultLocale:i}=await r(t);if(!n.includes(i))throw new x(`The app's default locale is '${i}', but yhikas-admin carries content in 'et' and 'en' only. The sync has nothing to write to the base row, so it refuses rather than publishing one language under another's name.`);return{defaultLocale:i,secondaryLocale:e(i)}}var x=class extends Error{constructor(e){super(e),this.name=`YhikasSyncLocaleError`}};function S(e,t){let n=t[f],r=[];if(e.baseUrl||r.push(s),n||r.push(f),!e.baseUrl||!n)throw new _(r);return{baseUrl:e.baseUrl,apiKey:n}}async function C(e,n,a=process.env){let{baseUrl:s,apiKey:l}=S(n,a),{sanitizeHtml:u}=await import(`@murumets-ee/blocks`);return g(v(),async()=>{let{defaultLocale:a}=await b(e);return r({upstream:new i({baseUrl:s,apiKey:l,timeoutMs:n.requestTimeoutMs}),roomTypeClient:o(e.getClient(p),a),legalDocumentClient:o(e.getClient(d),a),siteInfoClient:o(e.getClient(c),a),state:t(m.makeClient(e.db.readWrite)),sanitizeHtml:u,logger:y(e),defaultLocale:a,floor:{maxRows:n.maxRowsPerResource,maxRetireFraction:n.maxRetireFraction,minRowsForFraction:n.minRowsForRetireFloor}})})}async function w(e,n,r=new Date){let i=t(m.makeClient(e.db.readWrite));await T(e,r);let o=a(await i.readAll(),r,n.staleAfterMs);return e.logger.debug({window:n.staleAfterMs,resources:o.map(e=>({resource:e.resource,lastSuccessAt:e.lastSuccessAt}))},`yhikas-sync: staleness check passed`),o}async function T(e,n=new Date){let r=t(m.makeClient(e.db.readWrite));for(let e of u)await r.ensure(e,n)}export{C as runSyncJob,w as runWatchdogJob};
2
+ //# sourceMappingURL=jobs-CYy8AaKK.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"jobs-DrMLTUFm.mjs","names":[],"sources":["../src/jobs.ts"],"sourcesContent":["/**\n * The two job handlers, bound to a live `ToolkitApp`.\n *\n * This module is reached ONLY through a dynamic `import()` from the plugin's\n * `init` (see `plugin.ts`). It statically imports `@murumets-ee/core` and\n * `@murumets-ee/blocks`, either of which would be evaluated at\n * `lumi.config.ts`-load time under the CLI's jiti loader if the plugin entry\n * reached it statically — and `@murumets-ee/blocks`' root export evaluates\n * React's `createContext` at module scope.\n */\n\nimport { elevateRequestContext, runWithContextAsync, type ToolkitApp } from '@murumets-ee/core'\nimport type { SyncLogger } from './apply.js'\nimport type { ResolvedYhikasSyncConfig } from './config.js'\nimport {\n API_KEY_ENV_VAR,\n BASE_URL_ENV_VAR,\n EN_LOCALE,\n ET_LOCALE,\n LEGAL_DOCUMENTS_RESOURCE,\n ROOM_TYPES_RESOURCE,\n SYNC_RESOURCES,\n YHIKAS_SYNC_ACTOR_ID,\n} from './constants.js'\nimport { YhikasLegalDocument } from './entities/legal-document.js'\nimport { YhikasRoomType } from './entities/room-type.js'\nimport { YhikasSiteInfo } from './entities/site-info.js'\nimport { toSyncEntityClient } from './entity-client.js'\nimport { SUPPORTED_LOCALES, secondaryLocaleOf } from './projection.js'\nimport { runYhikasSync, type SyncRunSummary } from './run-sync.js'\nimport { createSyncStateStore } from './sync-state.js'\nimport { yhikasSyncStateTable } from './sync-state-table.js'\nimport { YhikasUpstreamClient } from './upstream/client.js'\nimport { assertNotStale, type ResourceStaleness } from './watchdog.js'\n\n/** Thrown when the sync is enabled but has no credential or no origin to talk to. */\nexport class YhikasSyncNotConfiguredError extends Error {\n constructor(missing: readonly string[]) {\n super(\n `yhikas-sync is enabled but ${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} ` +\n `not set. Failing loudly rather than skipping quietly: an unconfigured sync is a broken ` +\n `sync, and a broken sync must be visibly broken. Set the variable, or pass ` +\n `\\`enabled: false\\` to yhikasSync() if this install has no yhikas-admin.`,\n )\n this.name = 'YhikasSyncNotConfiguredError'\n }\n}\n\n/**\n * Every entity write runs as this identity.\n *\n * `elevateRequestContext` rather than `runAsCli`: both replace the permission\n * checker, but this one carries a NAMED actor, so `auditable()` records\n * `yhikas-sync` in `created_by`/`updated_by` instead of a generic `cli`. That\n * makes the sync's writes attributable — worth having precisely because the\n * upstream writes are not (F006) — and it is what the machine-written guard\n * recognises when it refuses every other writer.\n *\n * Built per run rather than at module scope: `elevateRequestContext` reads the\n * ambient context at CALL time, and a module-scope constant would bake in\n * whatever context happened to exist when the module first loaded.\n */\nfunction syncContext() {\n return elevateRequestContext({\n user: { id: YHIKAS_SYNC_ACTOR_ID, groups: ['admin'] },\n })\n}\n\n/** Narrow `app.logger` to the shape the sync uses, without importing a logger type. */\nfunction toSyncLogger(app: ToolkitApp): SyncLogger {\n return app.logger\n}\n\n/**\n * The app's REAL default locale, read from the content plugin rather than\n * configured here.\n *\n * A configured copy could disagree with the app's actual default, and the\n * failure would be silent in both directions: `updateForLocale` would take its\n * `isDefault` branch for what this package thought was the secondary locale\n * and overwrite the base row with the wrong language, while no translation was\n * ever written for the real secondary. `resolveI18nConfig` is the authoritative\n * answer — it merges the runtime `i18n` override over the shipped config — so\n * there is nothing to keep in sync.\n */\nasync function resolveLocales(\n app: ToolkitApp,\n): Promise<{ defaultLocale: string; secondaryLocale: string }> {\n const { resolveI18nConfig } = await import('@murumets-ee/content')\n const { defaultLocale } = await resolveI18nConfig(app)\n\n if (!SUPPORTED_LOCALES.includes(defaultLocale)) {\n throw new YhikasSyncLocaleError(\n `The app's default locale is '${defaultLocale}', but yhikas-admin carries content in ` +\n `'${ET_LOCALE}' and '${EN_LOCALE}' only. The sync has nothing to write to the base row, ` +\n `so it refuses rather than publishing one language under another's name.`,\n )\n }\n // `secondaryLocaleOf` asserts the same thing, so the two can never disagree.\n return { defaultLocale, secondaryLocale: secondaryLocaleOf(defaultLocale) }\n}\n\n/** Thrown when the app's locale configuration cannot carry the synced content. */\nexport class YhikasSyncLocaleError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'YhikasSyncLocaleError'\n }\n}\n\n/**\n * Resolve the bearer credential at RUN time, from the environment.\n *\n * Deliberately not part of the plugin's declared config: resolved config lands\n * in the framework's `PluginConfigRegistry`, a process-global any plugin can\n * read, and a secret does not belong there (mirroring `@murumets-ee/merit`'s\n * D003). Reading it here also means rotating the key needs a restart rather\n * than a rebuild.\n */\nfunction resolveCredentials(\n config: ResolvedYhikasSyncConfig,\n env: NodeJS.ProcessEnv,\n): { baseUrl: string; apiKey: string } {\n const apiKey = env[API_KEY_ENV_VAR]\n const missing: string[] = []\n if (!config.baseUrl) missing.push(BASE_URL_ENV_VAR)\n if (!apiKey) missing.push(API_KEY_ENV_VAR)\n if (!config.baseUrl || !apiKey) throw new YhikasSyncNotConfiguredError(missing)\n return { baseUrl: config.baseUrl, apiKey }\n}\n\n/** One full sync run. Throws on any resource failure, so the queue retries and alerts. */\nexport async function runSyncJob(\n app: ToolkitApp,\n config: ResolvedYhikasSyncConfig,\n env: NodeJS.ProcessEnv = process.env,\n): Promise<SyncRunSummary> {\n const { baseUrl, apiKey } = resolveCredentials(config, env)\n const { sanitizeHtml } = await import('@murumets-ee/blocks')\n\n return runWithContextAsync(syncContext(), async () => {\n const { defaultLocale } = await resolveLocales(app)\n return runYhikasSync({\n upstream: new YhikasUpstreamClient({\n baseUrl,\n apiKey,\n timeoutMs: config.requestTimeoutMs,\n }),\n // The clients are built HERE, inside the job, not in the plugin's\n // `init`. That is load-bearing and easy to \"tidy\" into a bug:\n // `createAdminClient` reads the locale-status writer ONCE at\n // construction, and `@murumets-ee/content` installs that writer from its\n // own `init`. `Plugin.requires` guarantees content is CONFIGURED, not\n // that it initialised first — so a client constructed during init could\n // capture no writer, and `updateForLocale` would then silently assign\n // the requested status to the BASE row. Constructing at job-run time is\n // after every plugin's init, unconditionally.\n roomTypeClient: toSyncEntityClient(app.getClient(YhikasRoomType), defaultLocale),\n legalDocumentClient: toSyncEntityClient(app.getClient(YhikasLegalDocument), defaultLocale),\n siteInfoClient: toSyncEntityClient(app.getClient(YhikasSiteInfo), defaultLocale),\n state: createSyncStateStore(yhikasSyncStateTable.makeClient(app.db.readWrite)),\n sanitizeHtml,\n logger: toSyncLogger(app),\n defaultLocale,\n floor: {\n maxRows: config.maxRowsPerResource,\n maxRetireFraction: config.maxRetireFraction,\n minRowsForFraction: config.minRowsForRetireFloor,\n },\n })\n })\n}\n\n/**\n * The staleness check. Throws when any resource is past the window, which is\n * how it reaches the queue's dead-letter alerting — the detection is new, the\n * delivery is not.\n */\nexport async function runWatchdogJob(\n app: ToolkitApp,\n config: ResolvedYhikasSyncConfig,\n now: Date = new Date(),\n): Promise<ResourceStaleness[]> {\n const store = createSyncStateStore(yhikasSyncStateTable.makeClient(app.db.readWrite))\n // Seed before assessing, so a fresh install gets a `firstSeenAt` to be\n // measured from rather than reading as \"no tracking row at all\" — which is\n // correct but alarming on an app that simply has not reached its first sync\n // tick yet.\n //\n // The cost, stated: `assessStaleness`'s missing-row branch is therefore\n // unreachable from here, so a tracking row DELETED by hand while the sync is\n // broken resets the clock instead of alerting. That needs deliberate DB\n // surgery to reach, and the alternative — alerting on every fresh install's\n // first tick — trains people to ignore the one channel that has to stay\n // trustworthy.\n await ensureSyncState(app, now)\n const records = await store.readAll()\n const assessed = assertNotStale(records, now, config.staleAfterMs)\n app.logger.debug(\n {\n window: config.staleAfterMs,\n resources: assessed.map((entry) => ({\n resource: entry.resource,\n lastSuccessAt: entry.lastSuccessAt,\n })),\n },\n 'yhikas-sync: staleness check passed',\n )\n return assessed\n}\n\n/**\n * Seed a tracking row per resource, so a fresh install has a clock to be\n * measured against before its first sync tick.\n *\n * Called from the WATCHDOG, not from the plugin's `init`. Doing DB I/O during\n * `init` runs it in every process that builds a `ToolkitApp` — web included —\n * and would fail app boot outright on a database blip, or on any boot that\n * precedes `lumi migrate` creating the table, in exchange for two bookkeeping\n * rows.\n */\nexport async function ensureSyncState(app: ToolkitApp, now: Date = new Date()): Promise<void> {\n const store = createSyncStateStore(yhikasSyncStateTable.makeClient(app.db.readWrite))\n for (const resource of SYNC_RESOURCES) {\n await store.ensure(resource, now)\n }\n}\n\nexport { LEGAL_DOCUMENTS_RESOURCE, ROOM_TYPES_RESOURCE }\n"],"mappings":"yQAoCA,IAAa,EAAb,cAAkD,KAAM,CACtD,YAAY,EAA4B,CACtC,MACE,8BAA8B,EAAQ,KAAK,OAAO,EAAE,GAAG,EAAQ,SAAW,EAAI,KAAO,MAAM,0OAI7F,EACA,KAAK,KAAO,8BACd,CACF,EAgBA,SAAS,GAAc,CACrB,OAAO,EAAsB,CAC3B,KAAM,CAAE,GAAI,EAAsB,OAAQ,CAAC,OAAO,CAAE,CACtD,CAAC,CACH,CAGA,SAAS,EAAa,EAA6B,CACjD,OAAO,EAAI,MACb,CAcA,eAAe,EACb,EAC6D,CAC7D,GAAM,CAAE,qBAAsB,MAAM,OAAO,wBACrC,CAAE,iBAAkB,MAAM,EAAkB,CAAG,EAErD,GAAI,CAAC,EAAkB,SAAS,CAAa,EAC3C,MAAM,IAAI,EACR,gCAAgC,EAAc,kLAGhD,EAGF,MAAO,CAAE,gBAAe,gBAAiB,EAAkB,CAAa,CAAE,CAC5E,CAGA,IAAa,EAAb,cAA2C,KAAM,CAC/C,YAAY,EAAiB,CAC3B,MAAM,CAAO,EACb,KAAK,KAAO,uBACd,CACF,EAWA,SAAS,EACP,EACA,EACqC,CACrC,IAAM,EAAS,EAAI,GACb,EAAoB,CAAC,EAG3B,GAFK,EAAO,SAAS,EAAQ,KAAK,CAAgB,EAC7C,GAAQ,EAAQ,KAAK,CAAe,EACrC,CAAC,EAAO,SAAW,CAAC,EAAQ,MAAM,IAAI,EAA6B,CAAO,EAC9E,MAAO,CAAE,QAAS,EAAO,QAAS,QAAO,CAC3C,CAGA,eAAsB,EACpB,EACA,EACA,EAAyB,QAAQ,IACR,CACzB,GAAM,CAAE,UAAS,UAAW,EAAmB,EAAQ,CAAG,EACpD,CAAE,gBAAiB,MAAM,OAAO,uBAEtC,OAAO,EAAoB,EAAY,EAAG,SAAY,CACpD,GAAM,CAAE,iBAAkB,MAAM,EAAe,CAAG,EAClD,OAAO,EAAc,CACnB,SAAU,IAAI,EAAqB,CACjC,UACA,SACA,UAAW,EAAO,gBACpB,CAAC,EAUD,eAAgB,EAAmB,EAAI,UAAU,CAAc,EAAG,CAAa,EAC/E,oBAAqB,EAAmB,EAAI,UAAU,CAAmB,EAAG,CAAa,EACzF,eAAgB,EAAmB,EAAI,UAAU,CAAc,EAAG,CAAa,EAC/E,MAAO,EAAqB,EAAqB,WAAW,EAAI,GAAG,SAAS,CAAC,EAC7E,eACA,OAAQ,EAAa,CAAG,EACxB,gBACA,MAAO,CACL,QAAS,EAAO,mBAChB,kBAAmB,EAAO,kBAC1B,mBAAoB,EAAO,qBAC7B,CACF,CAAC,CACH,CAAC,CACH,CAOA,eAAsB,EACpB,EACA,EACA,EAAY,IAAI,KACc,CAC9B,IAAM,EAAQ,EAAqB,EAAqB,WAAW,EAAI,GAAG,SAAS,CAAC,EAYpF,MAAM,EAAgB,EAAK,CAAG,EAE9B,IAAM,EAAW,EAAe,MADV,EAAM,QAAQ,EACK,EAAK,EAAO,YAAY,EAWjE,OAVA,EAAI,OAAO,MACT,CACE,OAAQ,EAAO,aACf,UAAW,EAAS,IAAK,IAAW,CAClC,SAAU,EAAM,SAChB,cAAe,EAAM,aACvB,EAAE,CACJ,EACA,qCACF,EACO,CACT,CAYA,eAAsB,EAAgB,EAAiB,EAAY,IAAI,KAAuB,CAC5F,IAAM,EAAQ,EAAqB,EAAqB,WAAW,EAAI,GAAG,SAAS,CAAC,EACpF,IAAK,IAAM,KAAY,EACrB,MAAM,EAAM,OAAO,EAAU,CAAG,CAEpC"}
1
+ {"version":3,"file":"jobs-CYy8AaKK.mjs","names":[],"sources":["../src/jobs.ts"],"sourcesContent":["/**\n * The two job handlers, bound to a live `ToolkitApp`.\n *\n * This module is reached ONLY through a dynamic `import()` from the plugin's\n * `init` (see `plugin.ts`). It statically imports `@murumets-ee/core` and\n * `@murumets-ee/blocks`, either of which would be evaluated at\n * `lumi.config.ts`-load time under the CLI's jiti loader if the plugin entry\n * reached it statically — and `@murumets-ee/blocks`' root export evaluates\n * React's `createContext` at module scope.\n */\n\nimport { elevateRequestContext, runWithContextAsync, type ToolkitApp } from '@murumets-ee/core'\nimport type { SyncLogger } from './apply.js'\nimport type { ResolvedYhikasSyncConfig } from './config.js'\nimport {\n API_KEY_ENV_VAR,\n BASE_URL_ENV_VAR,\n EN_LOCALE,\n ET_LOCALE,\n LEGAL_DOCUMENTS_RESOURCE,\n ROOM_TYPES_RESOURCE,\n SYNC_RESOURCES,\n YHIKAS_SYNC_ACTOR_ID,\n} from './constants.js'\nimport { YhikasLegalDocument } from './entities/legal-document.js'\nimport { YhikasRoomType } from './entities/room-type.js'\nimport { YhikasSiteInfo } from './entities/site-info.js'\nimport { toSyncEntityClient } from './entity-client.js'\nimport { SUPPORTED_LOCALES, secondaryLocaleOf } from './projection.js'\nimport { runYhikasSync, type SyncRunSummary } from './run-sync.js'\nimport { createSyncStateStore } from './sync-state.js'\nimport { yhikasSyncStateTable } from './sync-state-table.js'\nimport { YhikasUpstreamClient } from './upstream/client.js'\nimport { assertNotStale, type ResourceStaleness } from './watchdog.js'\n\n/** Thrown when the sync is enabled but has no credential or no origin to talk to. */\nexport class YhikasSyncNotConfiguredError extends Error {\n constructor(missing: readonly string[]) {\n super(\n `yhikas-sync is enabled but ${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} ` +\n `not set. Failing loudly rather than skipping quietly: an unconfigured sync is a broken ` +\n `sync, and a broken sync must be visibly broken. Set the variable, or pass ` +\n `\\`enabled: false\\` to yhikasSync() if this install has no yhikas-admin.`,\n )\n this.name = 'YhikasSyncNotConfiguredError'\n }\n}\n\n/**\n * Every entity write runs as this identity.\n *\n * `elevateRequestContext` rather than `runAsCli`: both replace the permission\n * checker, but this one carries a NAMED actor, so `auditable()` records\n * `yhikas-sync` in `created_by`/`updated_by` instead of a generic `cli`. That\n * makes the sync's writes attributable — worth having precisely because the\n * upstream writes are not (F006) — and it is what the machine-written guard\n * recognises when it refuses every other writer.\n *\n * Built per run rather than at module scope: `elevateRequestContext` reads the\n * ambient context at CALL time, and a module-scope constant would bake in\n * whatever context happened to exist when the module first loaded.\n */\nfunction syncContext() {\n return elevateRequestContext({\n user: { id: YHIKAS_SYNC_ACTOR_ID, groups: ['admin'] },\n })\n}\n\n/** Narrow `app.logger` to the shape the sync uses, without importing a logger type. */\nfunction toSyncLogger(app: ToolkitApp): SyncLogger {\n return app.logger\n}\n\n/**\n * The app's REAL default locale, read from the content plugin rather than\n * configured here.\n *\n * A configured copy could disagree with the app's actual default, and the\n * failure would be silent in both directions: `updateForLocale` would take its\n * `isDefault` branch for what this package thought was the secondary locale\n * and overwrite the base row with the wrong language, while no translation was\n * ever written for the real secondary. `resolveI18nConfig` is the authoritative\n * answer — it merges the runtime `i18n` override over the shipped config — so\n * there is nothing to keep in sync.\n */\nasync function resolveLocales(\n app: ToolkitApp,\n): Promise<{ defaultLocale: string; secondaryLocale: string }> {\n const { resolveI18nConfig } = await import('@murumets-ee/content')\n const { defaultLocale } = await resolveI18nConfig(app)\n\n if (!SUPPORTED_LOCALES.includes(defaultLocale)) {\n throw new YhikasSyncLocaleError(\n `The app's default locale is '${defaultLocale}', but yhikas-admin carries content in ` +\n `'${ET_LOCALE}' and '${EN_LOCALE}' only. The sync has nothing to write to the base row, ` +\n `so it refuses rather than publishing one language under another's name.`,\n )\n }\n // `secondaryLocaleOf` asserts the same thing, so the two can never disagree.\n return { defaultLocale, secondaryLocale: secondaryLocaleOf(defaultLocale) }\n}\n\n/** Thrown when the app's locale configuration cannot carry the synced content. */\nexport class YhikasSyncLocaleError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'YhikasSyncLocaleError'\n }\n}\n\n/**\n * Resolve the bearer credential at RUN time, from the environment.\n *\n * Deliberately not part of the plugin's declared config: resolved config lands\n * in the framework's `PluginConfigRegistry`, a process-global any plugin can\n * read, and a secret does not belong there (mirroring `@murumets-ee/merit`'s\n * D003). Reading it here also means rotating the key needs a restart rather\n * than a rebuild.\n */\nfunction resolveCredentials(\n config: ResolvedYhikasSyncConfig,\n env: NodeJS.ProcessEnv,\n): { baseUrl: string; apiKey: string } {\n const apiKey = env[API_KEY_ENV_VAR]\n const missing: string[] = []\n if (!config.baseUrl) missing.push(BASE_URL_ENV_VAR)\n if (!apiKey) missing.push(API_KEY_ENV_VAR)\n if (!config.baseUrl || !apiKey) throw new YhikasSyncNotConfiguredError(missing)\n return { baseUrl: config.baseUrl, apiKey }\n}\n\n/** One full sync run. Throws on any resource failure, so the queue retries and alerts. */\nexport async function runSyncJob(\n app: ToolkitApp,\n config: ResolvedYhikasSyncConfig,\n env: NodeJS.ProcessEnv = process.env,\n): Promise<SyncRunSummary> {\n const { baseUrl, apiKey } = resolveCredentials(config, env)\n const { sanitizeHtml } = await import('@murumets-ee/blocks')\n\n return runWithContextAsync(syncContext(), async () => {\n const { defaultLocale } = await resolveLocales(app)\n return runYhikasSync({\n upstream: new YhikasUpstreamClient({\n baseUrl,\n apiKey,\n timeoutMs: config.requestTimeoutMs,\n }),\n // The clients are built HERE, inside the job, not in the plugin's\n // `init`. That is load-bearing and easy to \"tidy\" into a bug:\n // `createAdminClient` reads the locale-status writer ONCE at\n // construction, and `@murumets-ee/content` installs that writer from its\n // own `init`. `Plugin.requires` guarantees content is CONFIGURED, not\n // that it initialised first — so a client constructed during init could\n // capture no writer, and `updateForLocale` would then silently assign\n // the requested status to the BASE row. Constructing at job-run time is\n // after every plugin's init, unconditionally.\n roomTypeClient: toSyncEntityClient(app.getClient(YhikasRoomType), defaultLocale),\n legalDocumentClient: toSyncEntityClient(app.getClient(YhikasLegalDocument), defaultLocale),\n siteInfoClient: toSyncEntityClient(app.getClient(YhikasSiteInfo), defaultLocale),\n state: createSyncStateStore(yhikasSyncStateTable.makeClient(app.db.readWrite)),\n sanitizeHtml,\n logger: toSyncLogger(app),\n defaultLocale,\n floor: {\n maxRows: config.maxRowsPerResource,\n maxRetireFraction: config.maxRetireFraction,\n minRowsForFraction: config.minRowsForRetireFloor,\n },\n })\n })\n}\n\n/**\n * The staleness check. Throws when any resource is past the window, which is\n * how it reaches the queue's dead-letter alerting — the detection is new, the\n * delivery is not.\n */\nexport async function runWatchdogJob(\n app: ToolkitApp,\n config: ResolvedYhikasSyncConfig,\n now: Date = new Date(),\n): Promise<ResourceStaleness[]> {\n const store = createSyncStateStore(yhikasSyncStateTable.makeClient(app.db.readWrite))\n // Seed before assessing, so a fresh install gets a `firstSeenAt` to be\n // measured from rather than reading as \"no tracking row at all\" — which is\n // correct but alarming on an app that simply has not reached its first sync\n // tick yet.\n //\n // The cost, stated: `assessStaleness`'s missing-row branch is therefore\n // unreachable from here, so a tracking row DELETED by hand while the sync is\n // broken resets the clock instead of alerting. That needs deliberate DB\n // surgery to reach, and the alternative — alerting on every fresh install's\n // first tick — trains people to ignore the one channel that has to stay\n // trustworthy.\n await ensureSyncState(app, now)\n const records = await store.readAll()\n const assessed = assertNotStale(records, now, config.staleAfterMs)\n app.logger.debug(\n {\n window: config.staleAfterMs,\n resources: assessed.map((entry) => ({\n resource: entry.resource,\n lastSuccessAt: entry.lastSuccessAt,\n })),\n },\n 'yhikas-sync: staleness check passed',\n )\n return assessed\n}\n\n/**\n * Seed a tracking row per resource, so a fresh install has a clock to be\n * measured against before its first sync tick.\n *\n * Called from the WATCHDOG, not from the plugin's `init`. Doing DB I/O during\n * `init` runs it in every process that builds a `ToolkitApp` — web included —\n * and would fail app boot outright on a database blip, or on any boot that\n * precedes `lumi migrate` creating the table, in exchange for two bookkeeping\n * rows.\n */\nexport async function ensureSyncState(app: ToolkitApp, now: Date = new Date()): Promise<void> {\n const store = createSyncStateStore(yhikasSyncStateTable.makeClient(app.db.readWrite))\n for (const resource of SYNC_RESOURCES) {\n await store.ensure(resource, now)\n }\n}\n\nexport { LEGAL_DOCUMENTS_RESOURCE, ROOM_TYPES_RESOURCE }\n"],"mappings":"yQAoCA,IAAa,EAAb,cAAkD,KAAM,CACtD,YAAY,EAA4B,CACtC,MACE,8BAA8B,EAAQ,KAAK,OAAO,EAAE,GAAG,EAAQ,SAAW,EAAI,KAAO,MAAM,0OAI7F,EACA,KAAK,KAAO,8BACd,CACF,EAgBA,SAAS,GAAc,CACrB,OAAO,EAAsB,CAC3B,KAAM,CAAE,GAAI,EAAsB,OAAQ,CAAC,OAAO,CAAE,CACtD,CAAC,CACH,CAGA,SAAS,EAAa,EAA6B,CACjD,OAAO,EAAI,MACb,CAcA,eAAe,EACb,EAC6D,CAC7D,GAAM,CAAE,qBAAsB,MAAM,OAAO,wBACrC,CAAE,iBAAkB,MAAM,EAAkB,CAAG,EAErD,GAAI,CAAC,EAAkB,SAAS,CAAa,EAC3C,MAAM,IAAI,EACR,gCAAgC,EAAc,kLAGhD,EAGF,MAAO,CAAE,gBAAe,gBAAiB,EAAkB,CAAa,CAAE,CAC5E,CAGA,IAAa,EAAb,cAA2C,KAAM,CAC/C,YAAY,EAAiB,CAC3B,MAAM,CAAO,EACb,KAAK,KAAO,uBACd,CACF,EAWA,SAAS,EACP,EACA,EACqC,CACrC,IAAM,EAAS,EAAI,GACb,EAAoB,CAAC,EAG3B,GAFK,EAAO,SAAS,EAAQ,KAAK,CAAgB,EAC7C,GAAQ,EAAQ,KAAK,CAAe,EACrC,CAAC,EAAO,SAAW,CAAC,EAAQ,MAAM,IAAI,EAA6B,CAAO,EAC9E,MAAO,CAAE,QAAS,EAAO,QAAS,QAAO,CAC3C,CAGA,eAAsB,EACpB,EACA,EACA,EAAyB,QAAQ,IACR,CACzB,GAAM,CAAE,UAAS,UAAW,EAAmB,EAAQ,CAAG,EACpD,CAAE,gBAAiB,MAAM,OAAO,uBAEtC,OAAO,EAAoB,EAAY,EAAG,SAAY,CACpD,GAAM,CAAE,iBAAkB,MAAM,EAAe,CAAG,EAClD,OAAO,EAAc,CACnB,SAAU,IAAI,EAAqB,CACjC,UACA,SACA,UAAW,EAAO,gBACpB,CAAC,EAUD,eAAgB,EAAmB,EAAI,UAAU,CAAc,EAAG,CAAa,EAC/E,oBAAqB,EAAmB,EAAI,UAAU,CAAmB,EAAG,CAAa,EACzF,eAAgB,EAAmB,EAAI,UAAU,CAAc,EAAG,CAAa,EAC/E,MAAO,EAAqB,EAAqB,WAAW,EAAI,GAAG,SAAS,CAAC,EAC7E,eACA,OAAQ,EAAa,CAAG,EACxB,gBACA,MAAO,CACL,QAAS,EAAO,mBAChB,kBAAmB,EAAO,kBAC1B,mBAAoB,EAAO,qBAC7B,CACF,CAAC,CACH,CAAC,CACH,CAOA,eAAsB,EACpB,EACA,EACA,EAAY,IAAI,KACc,CAC9B,IAAM,EAAQ,EAAqB,EAAqB,WAAW,EAAI,GAAG,SAAS,CAAC,EAYpF,MAAM,EAAgB,EAAK,CAAG,EAE9B,IAAM,EAAW,EAAe,MADV,EAAM,QAAQ,EACK,EAAK,EAAO,YAAY,EAWjE,OAVA,EAAI,OAAO,MACT,CACE,OAAQ,EAAO,aACf,UAAW,EAAS,IAAK,IAAW,CAClC,SAAU,EAAM,SAChB,cAAe,EAAM,aACvB,EAAE,CACJ,EACA,qCACF,EACO,CACT,CAYA,eAAsB,EAAgB,EAAiB,EAAY,IAAI,KAAuB,CAC5F,IAAM,EAAQ,EAAqB,EAAqB,WAAW,EAAI,GAAG,SAAS,CAAC,EACpF,IAAK,IAAM,KAAY,EACrB,MAAM,EAAM,OAAO,EAAU,CAAG,CAEpC"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{A as e,M as t,O as n,n as r,p as i,t as a}from"./sync-state-table-CM6eL5W9.mjs";import{definePlugin as o}from"@murumets-ee/core";function s(s){let c=i(s);return o({name:t,requires:[`@murumets-ee/content`,`@murumets-ee/queue`],server:{config:c,entities:[...r],tables:{yhikas_sync_state:a.table},init:async r=>{if(!c.enabled){r.logger.warn({plugin:t},`yhikas-sync is disabled by config — no scheduled pull, and no staleness alerting`);return}let{defineJob:i,registerJob:a}=await import(`@murumets-ee/queue/client`);a(i({name:n,description:`Pull room types and legal documents from yhikas-admin and apply a full-snapshot diff.`,schedule:c.schedule}),async()=>{let{runSyncJob:e}=await import(`./jobs-DrMLTUFm.mjs`);await e(r,c)}),a(i({name:e,description:`Alert when the newest successful yhikas-admin sync is older than the staleness window.`,schedule:c.watchdogSchedule}),async()=>{let{runWatchdogJob:e}=await import(`./jobs-DrMLTUFm.mjs`);await e(r,c)})}}})}export{s as yhikasSync};
1
+ import{A as e,M as t,O as n,n as r,p as i,t as a}from"./sync-state-table-CM6eL5W9.mjs";import{definePlugin as o}from"@murumets-ee/core";function s(s){let c=i(s);return o({name:t,requires:[`@murumets-ee/content`,`@murumets-ee/queue`],server:{config:c,entities:[...r],tables:{yhikas_sync_state:a.table},init:async r=>{if(!c.enabled){r.logger.warn({plugin:t},`yhikas-sync is disabled by config — no scheduled pull, and no staleness alerting`);return}let{defineJob:i,registerJob:a}=await import(`@murumets-ee/queue/client`);a(i({name:n,description:`Pull room types and legal documents from yhikas-admin and apply a full-snapshot diff.`,schedule:c.schedule}),async()=>{let{runSyncJob:e}=await import(`./jobs-CYy8AaKK.mjs`);await e(r,c)}),a(i({name:e,description:`Alert when the newest successful yhikas-admin sync is older than the staleness window.`,schedule:c.watchdogSchedule}),async()=>{let{runWatchdogJob:e}=await import(`./jobs-CYy8AaKK.mjs`);await e(r,c)})}}})}export{s as yhikasSync};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -0,0 +1,2 @@
1
+ import{C as e,D as t,E as n,T as r,b as i,k as a,w as o,x as s}from"./sync-state-table-CM6eL5W9.mjs";import{createHash as c}from"node:crypto";import{z as l}from"zod";var u=class extends Error{counts;constructor(e,t){super(`${t.failed} of ${t.created+t.updated+t.retired+t.failed} ${e} writes failed. The run is NOT recorded as successful, so the staleness watchdog stays armed and the next run retries from scratch.`),this.name=`YhikasApplyPartialError`,this.counts=t}};function d(e){if(e instanceof Date)return Number.isNaN(e.getTime())?null:e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function f(e,t,n){return(await e.findMany({limit:n})).map(e=>({id:String(e.id),key:String(e[t]),status:String(e.status),sourceHash:typeof e.sourceHash==`string`?e.sourceHash:null,publishedAt:d(e.publishedAt)}))}async function p(e){let{resource:t,plan:n,client:r,secondaryLocale:i,logger:a}=e,o={created:0,updated:0,retired:0,unchanged:n.unchanged.length,failed:0};for(let e of n.create)try{let t=await r.create({...e.base,status:`draft`});await h(r,String(t.id),e,i),await m(r,String(t.id),e.hash,null),o.created+=1}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to create a synced row`)}for(let{local:e,row:s}of n.update)try{await r.update(e.id,{...s.base}),await h(r,e.id,s,i),await m(r,e.id,s.hash,e.publishedAt),o.updated+=1}catch(e){o.failed+=1,a.error({err:e,resource:t,key:s.key},`yhikas-sync: failed to update a synced row`)}for(let e of n.retire)try{await r.updateForLocale(e.id,{status:`draft`},i),await r.update(e.id,{status:`draft`}),o.retired+=1,a.warn({resource:t,key:e.key},`yhikas-sync: retiring a row that upstream no longer offers as publishable content`)}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to retire a synced row`)}if(o.failed>0)throw new u(t,o);return o}async function m(e,t,n,r){await e.update(t,{status:`published`,publishedAt:r??new Date,sourceHash:n})}async function h(e,t,n,r){if(n.secondary){await e.updateForLocale(t,{...n.secondary},r),await e.updateForLocale(t,{status:`published`},r);return}await e.updateForLocale(t,{status:`draft`},r),await e.deleteTranslation(t,r)}var g=class extends Error{resource;kind;status;constructor(e,t,n,r){super(n,r?.cause===void 0?void 0:{cause:r.cause}),this.name=`YhikasUpstreamError`,this.resource=e,this.kind=t,this.status=r?.status}},_=class extends Error{resource;reason;constructor(e,t,n){super(n),this.name=`YhikasSyncRefusedError`,this.resource=e,this.reason=t}};function v(e){return c(`sha256`).update(y(e)).digest(`hex`)}function y(e){return e===null?`null`:e===void 0?`undefined`:Array.isArray(e)?`[${e.map(y).join(`,`)}]`:typeof e==`object`?`{${Object.entries(e).filter(([,e])=>e!==void 0).sort(([e],[t])=>e<t?-1:+(e>t)).map(([e,t])=>`${JSON.stringify(e)}:${y(t)}`).join(`,`)}}`:JSON.stringify(e)}function b(e){let{resource:t,upstream:n,local:r,keyOf:i,hashOf:a,floor:o}=e;if(n.length>o.maxRows)throw new _(t,`oversized-snapshot`,`yhikas-admin returned ${n.length} ${t} rows, above the ${o.maxRows} ceiling. Refusing the run rather than processing a prefix — a truncated snapshot would read as ${n.length-o.maxRows} deletions.`);let s=new Map;for(let e of n){let n=i(e);if(s.has(n))throw new _(t,`duplicate-key`,`yhikas-admin returned two ${t} rows with the same business key '${n}'. That key is unique upstream, so the response is not a faithful snapshot.`);s.set(n,e)}let c=new Map;for(let e of r){if(c.has(e.key))throw new _(t,`duplicate-key`,`Local ${t} content holds two rows keyed '${e.key}'. Refusing to guess which is authoritative.`);c.set(e.key,e)}let l=[],u=[],d=[];for(let[e,t]of s){let n=c.get(e);if(!n){l.push(t);continue}n.sourceHash===a(t)&&n.status===`published`?d.push(n):u.push({local:n,row:t})}let f=r.filter(e=>e.status===`published`&&!s.has(e.key));return ee(t,n.length,r,f,o),{create:l,update:u,unchanged:d,retire:f}}function ee(e,t,n,r,i){if(r.length===0)return;if(t===0)throw new _(e,`empty-snapshot`,`yhikas-admin returned zero ${e} rows while ${r.length} are published locally. Refusing to retire content on the strength of an empty snapshot — that is how a partial upstream response becomes a wiped price sheet.`);let a=n.filter(e=>e.status===`published`).length;if(a<i.minRowsForFraction)return;let o=r.length/a;if(o>i.maxRetireFraction)throw new _(e,`retire-fraction`,`This run would retire ${r.length} of ${a} published ${e} rows (${Math.round(o*100)}%), above the ${Math.round(i.maxRetireFraction*100)}% floor. At this change rate a run proposing to retire most of the set is far likelier to be a bug than a business event.`)}function te(e,t){if(t.trim().length===0)throw TypeError(`yhikas-sync: defaultLocale must be a non-empty locale code`);return{findMany:t=>e.findMany(t),create:t=>e.create(t),update:(t,n)=>e.update(t,n),updateForLocale:(n,r,i)=>e.updateForLocale(n,r,i,{defaultLocale:t}),deleteTranslation:(t,n)=>e.deleteTranslation(t,n)}}const x=[`et`,`en`];function S(e){if(!x.includes(e))throw TypeError(`yhikas-sync: unsupported locale '${e}' — upstream carries only 'et' and 'en', and projecting one language's text under another locale would publish the wrong language while every write succeeded.`)}function C(e){return S(e),e===`et`?`en`:`et`}function w(e,t){return t===`et`?e.et:e.en}function T(e,t){return t===`et`?e.htmlContentEt:e.htmlContentEn}function E(e){return typeof e==`string`&&e.trim().length>0}function D(e){return E(e)?e.trim():``}const ne=/[\s:/\\<>"'`?#&%]/;function O(e){return!ne.test(e)&&!/[\u0000-\u001f\u007f]/.test(e)}function k(e,t,n,r){if(!(t===0||n>0))throw new _(e,`empty-content`,`yhikas-admin returned ${t} ${e} row(s) and ${n} of them are publishable — every one was skipped. One unusable row is an editorial gap and is skipped on its own; a whole snapshot of them is what a renamed column or a changed envelope looks like from here, and unknown keys are stripped by design so the schema cannot tell the difference. Refusing rather than reporting a successful run that published nothing: local content is unchanged and the staleness watchdog stays armed. Reasons: ${r.slice(0,A).map(e=>`${e.key}: ${e.reason}`).join(`; `)}`)}const A=3;function j(e,t){S(t.defaultLocale);let n=C(t.defaultLocale),r=[],i=[];for(let a of e){if(a.code.length>190){i.push({key:a.code.slice(0,80),reason:`upstream code exceeds the 190-character column`});continue}let e=w(a.name,t.defaultLocale);if(!E(e)){i.push({key:a.code,reason:`no '${t.defaultLocale}' name upstream — a room type with no name in the site's primary language has nothing publishable to render`});continue}let o=w(a.name,n),s={code:a.code,name:e,totalArea:a.totalArea,livingArea:a.livingArea,commonArea:a.commonArea,capacity:a.capacity,placesOccupied:a.placesOccupied,monthlyRent:a.monthlyRent,discountedMonthlyRent:a.discountedRent,dailyRent:a.dailyRent,currency:`EUR`,vatTreatment:`net`,hasEnglish:E(a.name.en)},c=E(o)?{name:o}:null;r.push({key:a.code,base:s,secondary:c,hash:P(s,c)})}return k(o,e.length,r.length,i),{projected:r,skipped:i}}function M(e,t,n){S(t.defaultLocale);let r=C(t.defaultLocale),i=[],a=[];for(let o of e){let e=w(o.title,t.defaultLocale),s=n(T(o,t.defaultLocale));if(!E(e)||!E(s)){a.push({key:o.type,reason:`no publishable '${t.defaultLocale}' title or body upstream`});continue}if(o.type.length>190||o.slug.length>190){a.push({key:o.type.slice(0,80),reason:`upstream type or slug exceeds the 190-character column — refusing this row rather than letting the write fail and hold the whole resource in failure`});continue}if(!O(o.slug)){a.push({key:o.type,reason:`upstream slug ${JSON.stringify(o.slug)} is not usable as an anchor fragment — it carries a scheme, a path separator, whitespace or markup. Refusing this row rather than the whole response: upstream derives the slug from an unvalidated free-text field, so one bad value must not take the resource offline`});continue}let c=w(o.title,r),l=n(T(o,r)),u=n(o.htmlContentEn),d={type:o.type,title:e,sourceSlug:o.slug,body:s,order:o.order,hasEnglish:E(o.title.en)&&E(u)},f=E(c)&&E(l)?{title:c,body:l}:null;i.push({key:o.type,base:d,secondary:f,hash:P(d,f)})}return k(s,e.length,i.length,a),{projected:i,skipped:a}}function N(e,n){S(n.defaultLocale);let i=C(n.defaultLocale),a=e.notices.filter(e=>e.isActive).sort((e,t)=>e.order-t.order),o=a.slice(0,25),s=[];a.length>o.length&&s.push({key:r,reason:`${a.length-o.length} active notice(s) beyond the 25 ticker ceiling were dropped — a marquee past a couple of dozen entries is not read, and an unbounded array from upstream would ride onto every page of the site`});let c=e=>o.map(t=>w(t.text,e)).filter(E),l=w(e.receptionHours,n.defaultLocale),u=c(n.defaultLocale);if(!E(l)&&u.length===0)throw new _(t,`empty-content`,`yhikas-admin returned a well-formed site-info response carrying no usable '${n.defaultLocale}' content: reception hours are empty, and of ${e.notices.length} notice(s) upstream, ${a.length} are active and ${u.length} have '${n.defaultLocale}' text. (Those three counts are reported separately on purpose — "no active notices" and "active notices with no text in this language" send an operator to different places.) That state is exactly what the endpoint returns when no row exists at all, and it is indistinguishable from a deliberate clearing — so it is refused rather than applied. Local content is unchanged and the staleness watchdog stays armed. Note this refusal is TERMINAL, not transient: it will repeat every run until upstream carries something. If the intent really was to clear everything, leave one of the two set.`);let d=w(e.receptionHours,i),f=c(i),p=w(e.receptionHours,`en`),m=c(`en`),h={key:r,receptionHours:D(l),notices:u,hasEnglish:E(p)||m.length>0},g=E(d)||f.length>0?{receptionHours:D(d),notices:f}:null;return{projected:[{key:r,base:h,secondary:g,hash:P(h,g)}],skipped:s}}function P(e,t){return v({base:e,secondary:t})}var F=class extends Error{summary;constructor(e){let t=e.outcomes.filter(e=>!e.ok);super(`yhikas-admin sync failed for ${t.map(e=>e.resource).join(`, `)}: `+t.map(e=>e.error).join(` | `)),this.name=`YhikasSyncRunError`,this.summary=e}};async function re(e){let n=e.now??(()=>new Date),r=C(e.defaultLocale),i=[];i.push(await I({resource:o,keyField:`code`,client:e.roomTypeClient,fetchAndProject:async()=>j(await e.upstream.fetchRoomTypes(),{defaultLocale:e.defaultLocale}),deps:e,secondaryLocale:r,now:n})),i.push(await I({resource:s,keyField:`type`,client:e.legalDocumentClient,fetchAndProject:async()=>M(await e.upstream.fetchLegalDocuments(),{defaultLocale:e.defaultLocale},e.sanitizeHtml),deps:e,secondaryLocale:r,now:n})),i.push(await I({resource:t,keyField:`key`,client:e.siteInfoClient,fetchAndProject:async()=>N(await e.upstream.fetchSiteInfo(),{defaultLocale:e.defaultLocale}),deps:e,secondaryLocale:r,now:n}));let a={outcomes:i,ok:i.every(e=>e.ok)};if(!a.ok)throw new F(a);return a}async function I(e){let{resource:t,keyField:n,client:r,fetchAndProject:i,deps:a,secondaryLocale:o,now:s}=e,{logger:c,state:l,floor:u}=a;try{await l.ensure(t,s()),await l.recordAttempt(t,s());let e=await i();for(let n of e.skipped.slice(0,20))c.warn({resource:t,key:n.key,reason:n.reason},`yhikas-sync: skipping an upstream row, or part of one, that cannot be published`);e.skipped.length>20&&c.warn({resource:t,suppressed:e.skipped.length-20,total:e.skipped.length},`yhikas-sync: further skipped rows not logged individually`);let a=await f(r,n,u.maxRows),d=await p({resource:t,plan:b({resource:t,upstream:e.projected,local:a,keyOf:e=>e.key,hashOf:e=>e.hash,floor:u}),client:r,secondaryLocale:o,logger:c});return await l.recordSuccess(t,s(),d),c.info({resource:t,...d,skipped:e.skipped.length},`yhikas-sync: resource synced`),{resource:t,ok:!0,counts:d,skipped:e.skipped.length,error:null}}catch(e){let n=e instanceof Error?e.message:String(e);c.error({err:e,resource:t},`yhikas-sync: resource sync failed — local content unchanged`);try{await l.recordFailure(t,s(),n)}catch(e){c.error({err:e,resource:t},`yhikas-sync: could not record the failure in yhikas_sync_state`)}return{resource:t,ok:!1,counts:null,skipped:0,error:n}}}const L=1024;function R(e){return e.length<=L?e:`${e.slice(0,L-1)}…`}function ie(e){return{async ensure(t,n){await e.upsert({resource:t,firstSeenAt:n},{target:`resource`,set:{resource:t}})},async recordAttempt(t,n){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n},{target:`resource`,set:{lastAttemptAt:n}})},async recordSuccess(t,n,r){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged},{target:`resource`,set:{lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged}})},async recordFailure(t,n,r){let i=R(r);await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastError:i},{target:`resource`,set:{lastAttemptAt:n,lastError:i}})},async readAll(){return(await e.findMany({where:{resource:{in:[...a]}},limit:a.length})).map(e=>({resource:String(e.resource),firstSeenAt:e.firstSeenAt,lastAttemptAt:e.lastAttemptAt??null,lastSuccessAt:e.lastSuccessAt??null,lastError:e.lastError??null}))}}}const z=2e3,B=512*1024,V=l.string().max(32).regex(/^-?\d+(\.\d+)?$/,`expected a decimal literal, e.g. "180.00"`),H=l.object({et:l.string().max(500).nullable().optional(),en:l.string().max(500).nullable().optional()}),U=l.object({code:l.string().min(1).max(z),name:H,totalArea:V.nullable(),livingArea:V.nullable(),commonArea:V.nullable(),capacity:l.number().int().nullable(),monthlyRent:V.nullable(),discountedRent:V.nullable(),dailyRent:V.nullable(),placesOccupied:l.number().int().nullable()}),W=l.object({type:l.string().min(1).max(z),title:H,slug:l.string().min(1).max(z),htmlContentEt:l.string().max(B),htmlContentEn:l.string().max(B),order:l.number().int()}),G=l.object({success:l.literal(!0),roomTypes:l.array(U)}),K=l.object({success:l.literal(!0),documents:l.array(W)}),q=l.object({text:H,isActive:l.boolean(),order:l.number().int()}),J=l.object({success:l.literal(!0),receptionHours:H,notices:l.array(q).max(1e3)}),Y=8*1024*1024;async function ae(e,t,n){let r=e.body;if(!r)return``;let i=r.getReader(),a=new TextDecoder,o=0,s=``;try{for(;;){let{done:r,value:c}=await i.read();if(r)break;if(o+=c.byteLength,o>Y)throw await i.cancel(),new g(t,`shape`,`yhikas-admin sent more than ${Y} bytes for ${n}, above the ceiling — the read was aborted mid-stream rather than buffered and measured after`,{status:e.status});s+=a.decode(c,{stream:!0})}return s+a.decode()}finally{i.releaseLock()}}async function X(e){try{await e.body?.cancel()}catch{}}var oe=class{#e;#t;#n;#r;constructor(e){let t;try{t=new URL(e.baseUrl)}catch(t){throw TypeError(`yhikas-sync: baseUrl is not a valid URL: ${e.baseUrl}`,{cause:t})}if(t.protocol!==`http:`&&t.protocol!==`https:`)throw TypeError(`yhikas-sync: baseUrl must be http or https, got '${t.protocol}'. A file: or data: origin here would be a way to feed the sync a local snapshot.`);if(t.pathname!==`/`)throw TypeError(`yhikas-sync: baseUrl must be an origin with no path, got '${t.pathname}'. The upstream routes are absolute, so a base path would be silently discarded.`);if(e.apiKey.length===0)throw TypeError(`yhikas-sync: apiKey must not be empty`);this.#e=t,this.#t=e.apiKey,this.#n=e.timeoutMs,this.#r=e.fetchImpl??globalThis.fetch}async fetchRoomTypes(){return(await this.#i(o,e,G)).roomTypes}async fetchLegalDocuments(){return(await this.#i(s,i,K)).documents}async fetchSiteInfo(){return await this.#i(t,n,J)}async#i(e,t,n){let r=new URL(t,this.#e),i=AbortSignal.timeout(this.#n),a;try{a=await this.#r(r,{method:`GET`,headers:{authorization:`Bearer ${this.#t}`,accept:`application/json`},redirect:`error`,signal:i})}catch(n){let r=i.aborted;throw new g(e,r?`timeout`:`network`,r?`yhikas-admin did not answer ${t} within ${this.#n}ms`:`yhikas-admin was unreachable at ${t}`,{cause:n})}if(!a.ok)throw await X(a),new g(e,`http`,`yhikas-admin refused ${t} with HTTP ${a.status}`,{status:a.status});let o=Number(a.headers.get(`content-length`)??NaN);if(Number.isFinite(o)&&o>Y)throw await X(a),new g(e,`shape`,`yhikas-admin returned ${o} bytes for ${t}, above the ${Y}-byte ceiling`,{status:a.status});let s;try{s=await ae(a,e,t)}catch(n){if(n instanceof g)throw n;let r=i.aborted;throw new g(e,r?`timeout`:`shape`,r?`yhikas-admin did not finish sending ${t} within ${this.#n}ms`:`${t} returned a body that could not be read`,{status:a.status,cause:n})}let c;try{c=JSON.parse(s)}catch(n){throw new g(e,`shape`,`${t} returned a body that is not JSON`,{status:a.status,cause:n})}let l=n.safeParse(c);if(!l.success)throw new g(e,`shape`,`${t} returned a body that does not match the expected contract — ${l.error.issues.slice(0,5).map(e=>`${e.path.join(`.`)||`<root>`}: ${e.message}`).join(`; `)}`,{status:a.status});return l.data}},Z=class extends Error{stale;constructor(e,t){let n=e.map(se).join(`; `);super(`yhikas-admin sync is stale beyond the ${Q(t)} window. ${n}. The public site is serving content that old — this alert fires on ABSENCE of success, so there may be no failing job to look at.`),this.name=`YhikasSyncStaleError`,this.stale=e}};function se(e){let t=e.lastError?` — last error: ${e.lastError}`:``;return e.lastSuccessAt?`${e.resource}: last succeeded ${Q(e.ageMs)} ago${t}`:Number.isFinite(e.ageMs)?`${e.resource}: has NEVER succeeded (tracked for ${Q(e.ageMs)})${t}`:`${e.resource}: has NEVER synced — no tracking row exists at all${t}`}function Q(e){if(!Number.isFinite(e))return`an unknown time`;let t=e/36e5;return t<1?`${Math.round(e/6e4)}m`:t<48?`${Math.round(t)}h`:`${Math.round(t/24)}d`}function $(e,t,n){let r=new Map(e.map(e=>[e.resource,e]));return a.map(e=>{let i=r.get(e);if(!i)return{resource:e,lastSuccessAt:null,ageMs:1/0,stale:!0,lastError:null};let a=i.lastSuccessAt??i.firstSeenAt,o=t.getTime()-a.getTime();return{resource:e,lastSuccessAt:i.lastSuccessAt,ageMs:o,stale:o>n,lastError:i.lastError}})}function ce(e,t,n){let r=$(e,t,n),i=r.filter(e=>e.stale);if(i.length>0)throw new Z(i,n);return r}export{v as C,p as D,u as E,f as O,b as S,g as T,M as _,W as a,C as b,U as c,q as d,ie as f,x as g,re as h,oe as i,G as l,F as m,ce as n,K as o,R as p,$ as r,H as s,Z as t,J as u,j as v,_ as w,te as x,N as y};
2
+ //# sourceMappingURL=watchdog-dfFi-7re.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watchdog-dfFi-7re.mjs","names":["#baseUrl","#apiKey","#timeoutMs","#fetch","#get"],"sources":["../src/apply.ts","../src/upstream/errors.ts","../src/diff.ts","../src/entity-client.ts","../src/projection.ts","../src/run-sync.ts","../src/sync-state.ts","../src/upstream/wire.ts","../src/upstream/client.ts","../src/watchdog.ts"],"sourcesContent":["/**\n * Applying a diff plan through `AdminClient`.\n *\n * Writes go through the normal entity path — hooks, validation, audit logging\n * — so the sync's own writes are attributable, which is worth having precisely\n * because the UPSTREAM writes are not (F006: `addRoomType`/`updateRoomType`/\n * `deleteRoomType` skip `requireAdmin()`, with no audit log and no timestamp\n * to reconstruct from).\n *\n * ## Bounded, and bounded at one\n *\n * Rows are written sequentially. CLAUDE.md's fan-out rule wants a concurrency\n * bound and a total cap, and \"it's only N today\" is explicitly not a defence\n * — so rather than a semaphore the shape is simply serial, which is the\n * tightest bound available. At ~25 rows every six hours that costs nothing,\n * keeps the run from adding a burst to a connection pool shared with the whole\n * app, and makes the audit log read in a deterministic order. The total cap\n * lives in the differ, which REFUSES an oversized snapshot rather than\n * truncating it.\n *\n * ## Partial application is tolerated, silent partial application is not\n *\n * A per-row failure is caught, logged and counted, and the remaining rows are\n * still applied — one malformed document should not hold back 24 correct price\n * updates. But the run then THROWS at the end, so `lastSuccessAt` is not\n * advanced and the staleness watchdog stays armed. Retries re-run the whole\n * handler from scratch (the queue has no checkpointing primitive), which is\n * safe here because every write is an upsert against a business key.\n */\n\nimport type { SyncResource } from './constants.js'\nimport type { DiffPlan, LocalRow } from './diff.js'\nimport type { ProjectedRow } from './projection.js'\n\n/**\n * The `AdminClient` surface the sync uses, structurally.\n *\n * Declared rather than imported so the apply logic is unit-testable with a\n * fake — CI runs unit tests only, with no database, so a design that could\n * only be exercised by an integration test would in practice be exercised by\n * nothing.\n */\nexport interface SyncEntityClient {\n findMany(options: { limit: number }): Promise<Record<string, unknown>[]>\n create(data: Record<string, unknown>): Promise<Record<string, unknown>>\n update(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>\n updateForLocale(\n id: string,\n data: Record<string, unknown>,\n locale: string,\n ): Promise<Record<string, unknown>>\n deleteTranslation(id: string, locale: string): Promise<void>\n}\n\nexport interface SyncLogger {\n info(obj: Record<string, unknown>, msg: string): void\n warn(obj: Record<string, unknown>, msg: string): void\n error(obj: Record<string, unknown>, msg: string): void\n}\n\nexport interface ApplyCounts {\n created: number\n updated: number\n retired: number\n unchanged: number\n failed: number\n}\n\n/** Thrown when at least one row failed; carries the counts that were achieved. */\nexport class YhikasApplyPartialError extends Error {\n readonly counts: ApplyCounts\n constructor(resource: SyncResource, counts: ApplyCounts) {\n super(\n `${counts.failed} of ${counts.created + counts.updated + counts.retired + counts.failed} ` +\n `${resource} writes failed. The run is NOT recorded as successful, so the staleness ` +\n `watchdog stays armed and the next run retries from scratch.`,\n )\n this.name = 'YhikasApplyPartialError'\n this.counts = counts\n }\n}\n\n/**\n * Read every local row of one synced entity, reduced to what the differ needs.\n *\n * `limit` is the same ceiling the differ refuses above, so a local set that\n * has somehow outgrown it is truncated HERE — which would present the missing\n * tail as absent-locally and re-create it, hitting the unique index rather\n * than silently duplicating. Not silent, but not pretty either; the ceiling is\n * three orders of magnitude above the real row count.\n */\n/**\n * Normalize a timestamp that may arrive as a `Date` or as an ISO string.\n *\n * Coercing an unrecognised shape to `null` is not neutral here: `commitRow`\n * back-fills `publishedAt` when it is absent, so a string that read as `null`\n * would silently replace the original first-publish date on every single\n * update, turning the column into \"last touched by the sync\".\n */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\nexport async function readLocalRows(\n client: SyncEntityClient,\n keyField: string,\n limit: number,\n): Promise<LocalRow[]> {\n const rows = await client.findMany({ limit })\n return rows.map((row) => ({\n id: String(row.id),\n key: String(row[keyField]),\n status: String(row.status),\n sourceHash: typeof row.sourceHash === 'string' ? row.sourceHash : null,\n publishedAt: toDate(row.publishedAt),\n }))\n}\n\nexport interface ApplyPlanInput {\n readonly resource: SyncResource\n readonly plan: DiffPlan<ProjectedRow>\n readonly client: SyncEntityClient\n /** The locale that is NOT on the base row — the one whose publish state is toggled. */\n readonly secondaryLocale: string\n readonly logger: SyncLogger\n}\n\nexport async function applyPlan(input: ApplyPlanInput): Promise<ApplyCounts> {\n const { resource, plan, client, secondaryLocale, logger } = input\n const counts: ApplyCounts = {\n created: 0,\n updated: 0,\n retired: 0,\n unchanged: plan.unchanged.length,\n failed: 0,\n }\n\n for (const row of plan.create) {\n try {\n // Created as a DRAFT, deliberately. Publishing before the secondary\n // locale is written would expose a window — and, if that write then\n // failed, a permanent state — in which the English URL is live and\n // falls back to the Estonian text, because the public read predicate is\n // `COALESCE(locale_status.status, main.status)` and an absent locale row\n // inherits the base row's `published`. Invisible beats wrong.\n const created = await client.create({ ...row.base, status: 'draft' })\n await writeSecondaryLocale(client, String(created.id), row, secondaryLocale)\n await commitRow(client, String(created.id), row.hash, null)\n counts.created += 1\n } catch (err) {\n counts.failed += 1\n logger.error({ err, resource, key: row.key }, 'yhikas-sync: failed to create a synced row')\n }\n }\n\n for (const { local, row } of plan.update) {\n try {\n // NOTE the absence of `sourceHash` here — see `commitRow`.\n await client.update(local.id, { ...row.base })\n await writeSecondaryLocale(client, local.id, row, secondaryLocale)\n await commitRow(client, local.id, row.hash, local.publishedAt)\n counts.updated += 1\n } catch (err) {\n counts.failed += 1\n logger.error({ err, resource, key: row.key }, 'yhikas-sync: failed to update a synced row')\n }\n }\n\n for (const local of plan.retire) {\n try {\n // Unpublished, NEVER deleted (D016). The row and its audit trail stay,\n // and a wrongly-retired row heals automatically on the next successful\n // run — the differ treats a drafted row with a matching hash as an\n // update precisely so that re-publishing happens without operator action.\n // The LOCALE goes first, for the same reason `writeSecondaryLocale`\n // orders its writes the way it does — and here the cost of getting it\n // wrong is permanent rather than transient. Drafting the base row first\n // and then failing to draft the locale leaves the locale `published`\n // with the base row already `draft`; `planDiff` only ever selects\n // retirement candidates whose `status === 'published'`, so that row\n // never enters `plan.retire` again and the secondary locale serves\n // retired content indefinitely. This order fails safe: an error after\n // the locale unpublish leaves the base row published, so the row is\n // still a candidate on the next run.\n await client.updateForLocale(local.id, { status: 'draft' }, secondaryLocale)\n await client.update(local.id, { status: 'draft' })\n counts.retired += 1\n logger.warn(\n { resource, key: local.key },\n // Not \"no longer present upstream\": a row also lands here when it IS\n // present but was skipped as unpublishable (an empty default-locale\n // name). Naming only the first cause would send an operator looking\n // for a deletion that never happened.\n 'yhikas-sync: retiring a row that upstream no longer offers as publishable content',\n )\n } catch (err) {\n counts.failed += 1\n logger.error({ err, resource, key: local.key }, 'yhikas-sync: failed to retire a synced row')\n }\n }\n\n if (counts.failed > 0) throw new YhikasApplyPartialError(resource, counts)\n return counts\n}\n\n/**\n * Publish the row and stamp its `sourceHash` — the LAST write for a row, and\n * the only one that records \"this row now matches upstream\".\n *\n * Committing the hash alongside the base fields would be a durable lie the\n * moment any later write for the row failed: the hash covers the whole\n * projected payload including the secondary locale, so the differ would\n * classify the row `unchanged` on every subsequent run, the run would report\n * success, and the incomplete row would never be retried. Writing it last\n * makes a partial failure self-healing — the stored hash still describes the\n * previous state, so the next run sees a difference and redoes the row.\n *\n * `publishedAt` is preserved when the row already had one. `publishable()`\n * back-fills it whenever a payload sets `status: 'published'` without it,\n * which would otherwise turn \"first published\" into \"last touched by the\n * sync\" — a value that moves every time a price changes.\n */\nasync function commitRow(\n client: SyncEntityClient,\n id: string,\n hash: string,\n publishedAt: Date | null,\n): Promise<void> {\n await client.update(id, {\n status: 'published',\n publishedAt: publishedAt ?? new Date(),\n sourceHash: hash,\n })\n}\n\n/**\n * Write — or suppress — the non-default locale.\n *\n * When the upstream text is present, its translation row is written and the\n * locale is published in one call: `updateForLocale` splits the payload,\n * routing `status` to `<entity>_locale_status` and the translatable fields to\n * `<entity>_translations`.\n *\n * When it is absent, the locale is set to `draft` FIRST and only then is the\n * previous translation deleted. The order is load-bearing and was originally\n * the other way round: `deleteTranslation` does not touch\n * `<entity>_locale_status`, so deleting first and then failing to unpublish\n * leaves a locale marked `published` with no translation row — and the merged\n * read falls back to the base row, i.e. the English URL serves Estonian text,\n * live. Unpublishing first degrades to stale-but-hidden instead.\n *\n * Deleting at all still matters: leaving stale English text in the\n * translations table, merely unpublished, keeps a copy that any future read\n * path forgetting the publish filter could serve. The unpublish is the\n * control; the delete removes the thing the control is protecting — so the\n * control goes on first.\n */\nasync function writeSecondaryLocale(\n client: SyncEntityClient,\n id: string,\n row: ProjectedRow,\n secondaryLocale: string,\n): Promise<void> {\n if (row.secondary) {\n // Two calls, not one, and in this order for the same reason the branch\n // below is ordered the way it is. `updateForLocale` writes the locale\n // STATUS before the translation when handed both in one payload, so a\n // combined call that failed halfway would mark the locale published with\n // no translation row behind it — and the merged read then falls back to\n // the base row, i.e. the English URL serving Estonian text, live.\n //\n // Writing the translation first and the status second means a failure\n // between them leaves the locale unpublished with correct content waiting\n // — invisible, and healed by the next run.\n await client.updateForLocale(id, { ...row.secondary }, secondaryLocale)\n await client.updateForLocale(id, { status: 'published' }, secondaryLocale)\n return\n }\n await client.updateForLocale(id, { status: 'draft' }, secondaryLocale)\n await client.deleteTranslation(id, secondaryLocale)\n}\n","/**\n * Every way a snapshot fetch can fail to be authoritative.\n *\n * The distinction this file exists to preserve: a refusal, a timeout and a\n * malformed body are all \"we do not know what is upstream\", and none of them\n * is \"upstream is empty\". Collapsing them is how a partial response becomes a\n * wiped price sheet, so they are typed and they all abort the run.\n */\n\nimport type { SyncResource } from '../constants.js'\n\nexport type UpstreamFailureKind =\n /** DNS failure, connection refused, TLS error — the request never completed. */\n | 'network'\n /** The per-request deadline elapsed. The queue has no per-job timeout (R012 §3). */\n | 'timeout'\n /** A completed response the sync will not act on: 401, 429, 5xx, or any non-2xx. */\n | 'http'\n /** A 2xx whose body failed the wire schema — including `success: false`. */\n | 'shape'\n\nexport class YhikasUpstreamError extends Error {\n readonly resource: SyncResource\n readonly kind: UpstreamFailureKind\n readonly status: number | undefined\n\n constructor(\n resource: SyncResource,\n kind: UpstreamFailureKind,\n message: string,\n options?: { status?: number; cause?: unknown },\n ) {\n super(message, options?.cause === undefined ? undefined : { cause: options.cause })\n this.name = 'YhikasUpstreamError'\n this.resource = resource\n this.kind = kind\n this.status = options?.status\n }\n}\n\n/**\n * Thrown when a snapshot IS authoritative but applying it would be reckless —\n * the D016 guards. Separate from {@link YhikasUpstreamError} because the\n * remedies differ: an upstream failure usually resolves itself on the next\n * run, whereas this one wants a human to look at why the source shrank.\n */\nexport class YhikasSyncRefusedError extends Error {\n readonly resource: SyncResource\n readonly reason:\n | 'empty-snapshot'\n | 'retire-fraction'\n | 'oversized-snapshot'\n | 'duplicate-key'\n /**\n * A well-formed 200 whose CONTENT is entirely empty (D027).\n *\n * Distinct from `empty-snapshot`, which is a row-COUNT test and therefore\n * cannot see this: `site_info` always projects exactly one row, so the\n * count is 1 whether or not that row says anything. `/api/public/site-info`\n * answers 200 with empty strings and no notices when no upstream row\n * exists, which is byte-identical to a deliberate clearing (F015) — so the\n * only safe reading of an all-empty payload is \"not authoritative\", and the\n * only safe response is to leave local content alone and let the watchdog\n * arm.\n */\n | 'empty-content'\n\n constructor(resource: SyncResource, reason: YhikasSyncRefusedError['reason'], message: string) {\n super(message)\n this.name = 'YhikasSyncRefusedError'\n this.resource = resource\n this.reason = reason\n }\n}\n","/**\n * The full-snapshot differ — a pure function, so every guard in it is testable\n * without a database, an HTTP server or a queue.\n *\n * Full-snapshot rather than incremental is FORCED, not chosen: `room_type`\n * carries no timestamp of any kind, and `legal_document`'s `updated_at` is not\n * in the API response. There is no cursor, no high-water mark and no change\n * feed, so the only available shape is fetch-everything-and-compare. At ~25\n * room types and a handful of documents that is trivially cheap.\n *\n * Identity is the business key — `room_type.code`, `legal_document.type` —\n * never the serial `id`, which is an implementation detail of the other system\n * and would make this system's content depend on the other's insert order.\n */\n\nimport { createHash } from 'node:crypto'\nimport type { SyncResource } from './constants.js'\nimport { YhikasSyncRefusedError } from './upstream/errors.js'\n\n/** One local row, reduced to what the diff needs. */\nexport interface LocalRow {\n readonly id: string\n /** The business key this row was synced under. */\n readonly key: string\n readonly status: string\n /**\n * `null` on a row whose last sync did not complete — the hash is written\n * LAST, after every other write for the row succeeded, so a null (or stale)\n * hash is exactly the signal that the row needs redoing.\n */\n readonly sourceHash: string | null\n /** Preserved across updates so it keeps meaning \"first published\". */\n readonly publishedAt: Date | null\n}\n\nexport interface DiffPlan<T> {\n /** Upstream rows with no local counterpart. */\n readonly create: readonly T[]\n /** Local rows whose hash differs, OR whose status drifted from `published`. */\n readonly update: readonly { readonly local: LocalRow; readonly row: T }[]\n /** Local rows already identical and already published — no write at all. */\n readonly unchanged: readonly LocalRow[]\n /** Published locally, absent upstream. Unpublished, never deleted (D016). */\n readonly retire: readonly LocalRow[]\n}\n\nexport interface SanityFloor {\n /** Refuse a snapshot larger than this rather than truncating it. */\n readonly maxRows: number\n /** Refuse a run retiring more than this fraction of published rows. */\n readonly maxRetireFraction: number\n /** The fraction rule applies only once at least this many rows are published. */\n readonly minRowsForFraction: number\n}\n\nexport interface PlanDiffInput<T> {\n readonly resource: SyncResource\n readonly upstream: readonly T[]\n readonly local: readonly LocalRow[]\n readonly keyOf: (row: T) => string\n readonly hashOf: (row: T) => string\n readonly floor: SanityFloor\n}\n\n/**\n * Stable content hash of an upstream row.\n *\n * Keys are sorted so the hash does not depend on JSON property order, which no\n * part of the HTTP stack guarantees. `undefined` and `null` are distinguished\n * because a null price is meaningful data here, not an absence.\n */\nexport function stableHash(value: unknown): string {\n return createHash('sha256').update(canonicalize(value)).digest('hex')\n}\n\nfunction canonicalize(value: unknown): string {\n if (value === null) return 'null'\n if (value === undefined) return 'undefined'\n if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`\n if (typeof value === 'object') {\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`)\n return `{${entries.join(',')}}`\n }\n return JSON.stringify(value)\n}\n\n/**\n * Compare an authoritative upstream snapshot against local state.\n *\n * **The caller must not invoke this with a snapshot that did not fully\n * succeed.** That guard lives one level up, in the client: a non-2xx, a\n * timeout or a body failing the wire schema throws before the differ is ever\n * reached, so a partial response can never present as an absence here. This\n * function's own guards are for a snapshot that IS authoritative but whose\n * shape makes acting on it reckless.\n *\n * @throws {YhikasSyncRefusedError} for any of the D016 refusals. Every one of\n * them aborts before a single write, so a refused run leaves local content\n * exactly as it was — which is the first of PR 05's three obligatory\n * negative tests.\n */\nexport function planDiff<T>(input: PlanDiffInput<T>): DiffPlan<T> {\n const { resource, upstream, local, keyOf, hashOf, floor } = input\n\n // An oversized snapshot is REFUSED, not truncated. Capping at N and\n // processing the first N would turn the dropped tail into apparent absences\n // and therefore into mass retirement — the bound CLAUDE.md requires, applied\n // the one way that is not itself a bug.\n if (upstream.length > floor.maxRows) {\n throw new YhikasSyncRefusedError(\n resource,\n 'oversized-snapshot',\n `yhikas-admin returned ${upstream.length} ${resource} rows, above the ${floor.maxRows} ceiling. ` +\n `Refusing the run rather than processing a prefix — a truncated snapshot would read as ` +\n `${upstream.length - floor.maxRows} deletions.`,\n )\n }\n\n const upstreamByKey = new Map<string, T>()\n for (const row of upstream) {\n const key = keyOf(row)\n if (upstreamByKey.has(key)) {\n throw new YhikasSyncRefusedError(\n resource,\n 'duplicate-key',\n `yhikas-admin returned two ${resource} rows with the same business key '${key}'. ` +\n `That key is unique upstream, so the response is not a faithful snapshot.`,\n )\n }\n upstreamByKey.set(key, row)\n }\n\n const localByKey = new Map<string, LocalRow>()\n for (const row of local) {\n if (localByKey.has(row.key)) {\n // A unique index makes this unreachable; if it happens the local state is\n // corrupt and picking one arbitrarily would quietly entrench the damage.\n throw new YhikasSyncRefusedError(\n resource,\n 'duplicate-key',\n `Local ${resource} content holds two rows keyed '${row.key}'. Refusing to guess which is ` +\n `authoritative.`,\n )\n }\n localByKey.set(row.key, row)\n }\n\n const create: T[] = []\n const update: { local: LocalRow; row: T }[] = []\n const unchanged: LocalRow[] = []\n\n for (const [key, row] of upstreamByKey) {\n const existing = localByKey.get(key)\n if (!existing) {\n create.push(row)\n continue\n }\n // Re-publishing a row that drifted to draft is an update even when the\n // content is byte-identical: that is how a wrongly-retired row heals on the\n // next successful run, with no operator action.\n if (existing.sourceHash === hashOf(row) && existing.status === 'published') {\n unchanged.push(existing)\n } else {\n update.push({ local: existing, row })\n }\n }\n\n const retire = local.filter((row) => row.status === 'published' && !upstreamByKey.has(row.key))\n\n assertRetirementIsPlausible(resource, upstream.length, local, retire, floor)\n\n return { create, update, unchanged, retire }\n}\n\n/**\n * The two refusal triggers of D016. They are independent because neither can\n * see the other's case:\n *\n * - The fraction rule is blind at small N — two published rows against an\n * empty snapshot is 100% retirement but never reaches `minRowsForFraction`,\n * and a freshly seeded install lives at exactly that size.\n * - The empty-snapshot rule is blind to a HALF-truncated response, which is\n * the shape a partial upstream failure actually produces.\n */\nfunction assertRetirementIsPlausible(\n resource: SyncResource,\n upstreamCount: number,\n local: readonly LocalRow[],\n retire: readonly LocalRow[],\n floor: SanityFloor,\n): void {\n if (retire.length === 0) return\n\n if (upstreamCount === 0) {\n throw new YhikasSyncRefusedError(\n resource,\n 'empty-snapshot',\n `yhikas-admin returned zero ${resource} rows while ${retire.length} are published locally. ` +\n `Refusing to retire content on the strength of an empty snapshot — that is how a partial ` +\n `upstream response becomes a wiped price sheet.`,\n )\n }\n\n const publishedCount = local.filter((row) => row.status === 'published').length\n if (publishedCount < floor.minRowsForFraction) return\n\n const fraction = retire.length / publishedCount\n if (fraction > floor.maxRetireFraction) {\n throw new YhikasSyncRefusedError(\n resource,\n 'retire-fraction',\n `This run would retire ${retire.length} of ${publishedCount} published ${resource} rows ` +\n `(${Math.round(fraction * 100)}%), above the ${Math.round(floor.maxRetireFraction * 100)}% floor. ` +\n `At this change rate a run proposing to retire most of the set is far likelier to be a bug ` +\n `than a business event.`,\n )\n }\n}\n","/**\n * Adapting a framework `AdminClient` to the structural {@link SyncEntityClient}\n * the sync logic is written against.\n *\n * Its own module, importing nothing at runtime (`import type` only), for one\n * reason: this is the seam where the sync meets the framework, and it is where\n * the worst defect in this package's history lived — `updateForLocale` was\n * called without `options.defaultLocale`, so every secondary-locale write threw\n * while the base write succeeded. The run failed once, the next run saw a\n * matching hash and reported success, and the English site served Estonian\n * text indefinitely. No test could see it because the seam sat inside a module\n * that pulls in `@murumets-ee/core`.\n *\n * Keeping it here makes the seam directly unit-testable with a plain object,\n * with no database and no app.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { SyncEntityClient } from './apply.js'\n\n/** The `AdminClient` methods the adapter forwards, structurally. */\nexport type AdminClientLike = ReturnType<ToolkitApp['getClient']>\n\n/**\n * @param defaultLocale The app's REAL default locale — resolved from\n * `@murumets-ee/content`, never configured. It is passed explicitly on every\n * `updateForLocale` call and is not optional, because\n * `elevateRequestContext` deliberately strips `locale`/`defaultLocale` from\n * the context it builds, and `updateForLocale` THROWS when it can resolve\n * the default locale from neither the options nor the context.\n */\nexport function toSyncEntityClient(\n client: AdminClientLike,\n defaultLocale: string,\n): SyncEntityClient {\n if (defaultLocale.trim().length === 0) {\n // `updateForLocale` resolves `options?.defaultLocale ?? context…`, and `??`\n // treats `''` — and `' '` — as present, so either sails past the throw\n // this module exists to avoid and reaches the write as a locale nobody\n // serves. Trimmed, so whitespace is not a way around the guard.\n throw new TypeError('yhikas-sync: defaultLocale must be a non-empty locale code')\n }\n return {\n findMany: (options) => client.findMany(options) as Promise<Record<string, unknown>[]>,\n // The payload casts are the established in-repo idiom at this exact seam —\n // `packages/blocks/src/server/routes/op-commit.ts` writes\n // `client.updateForLocale(id, data as never, locale, { tx })` for the same\n // reason: `InferUpdateInput<F>` is keyed on one entity's field map, which a\n // caller holding a plain record cannot be proven to satisfy. Threading the\n // entity's field generics through every sync module instead would make the\n // logic untestable without a live client.\n create: (data) => client.create(data as never) as Promise<Record<string, unknown>>,\n update: (id, data) => client.update(id, data as never) as Promise<Record<string, unknown>>,\n updateForLocale: (id, data, locale) =>\n client.updateForLocale(id, data as never, locale, { defaultLocale }) as Promise<\n Record<string, unknown>\n >,\n deleteTranslation: (id, locale) => client.deleteTranslation(id, locale),\n }\n}\n","/**\n * Upstream wire row → local entity payload. Pure, so every semantic decision\n * in here is testable without a database.\n *\n * Three things happen at this boundary and nowhere else:\n *\n * 1. **HTML is sanitized**, via an injected sanitizer. Injected rather than\n * imported so this module stays free of `@murumets-ee/blocks` — whose root\n * export evaluates React's `createContext` at module scope — and so a test\n * can assert that the sanitizer was actually applied rather than trusting\n * that it was.\n * 2. **Semantics the source does not carry are stamped on**: currency and VAT\n * treatment (see `constants.ts` for why the period is not one of them).\n * 3. **An empty locale is decided.** D015: no empty string is ever written as\n * a translation value, and a row whose DEFAULT-locale text is empty is\n * skipped entirely rather than published with a blank title.\n *\n * The hash is computed over the PROJECTED payload, not the raw upstream row.\n * That is deliberate: it means a change in our own sanitizer's allowlist, or\n * in a declared constant, also produces a different hash and therefore a\n * rewrite — so stored content cannot silently diverge from what today's code\n * would produce.\n */\n\nimport {\n DECLARED_CURRENCY,\n DECLARED_VAT_TREATMENT,\n EN_LOCALE,\n ET_LOCALE,\n LEGAL_DOCUMENTS_RESOURCE,\n MAX_SITE_NOTICES,\n ROOM_TYPES_RESOURCE,\n SITE_INFO_KEY,\n SITE_INFO_RESOURCE,\n type SyncResource,\n} from './constants.js'\nimport { stableHash } from './diff.js'\nimport { YhikasSyncRefusedError } from './upstream/errors.js'\nimport type {\n LegalDocumentRow,\n MultilingualText,\n RoomTypeRow,\n SiteInfoResponse,\n} from './upstream/wire.js'\n\n/** Injected at the seam; see the module docblock for why it is not imported. */\nexport type HtmlSanitizer = (html: string) => string\n\nexport interface ProjectionOptions {\n /** The locale whose values live on the base entity row. `et` or `en`. */\n readonly defaultLocale: string\n}\n\nexport interface ProjectedRow {\n /** The business key — `room_type.code` or `legal_document.type`. */\n readonly key: string\n /** Base-row fields, including the default locale's values for translatable fields. */\n readonly base: Record<string, unknown>\n /**\n * Translatable values for the non-default locale, or `null` when that\n * locale's text is empty upstream. `null` means \"unpublish that locale\",\n * never \"write an empty string\".\n */\n readonly secondary: Record<string, unknown> | null\n readonly hash: string\n}\n\nexport interface SkippedRow {\n readonly key: string\n readonly reason: string\n}\n\nexport interface ProjectionResult {\n readonly projected: readonly ProjectedRow[]\n readonly skipped: readonly SkippedRow[]\n}\n\n/** The only locales this projection can express — upstream carries exactly these two. */\nexport const SUPPORTED_LOCALES: readonly string[] = [ET_LOCALE, EN_LOCALE]\n\n/**\n * Refuse a locale this projection cannot express.\n *\n * Both helpers below are implicit-else: `pickLocale` returns `text.en` for\n * every locale that is not `et`, and `secondaryLocaleOf` returns `et` for every\n * locale that is not `et`. So a site reporting `fi` as its default would get\n * the ENGLISH text on the base row under the `fi` locale, Estonian as the\n * secondary, and per-locale publish status wrong for both — silently, with\n * every write succeeding. That is the same publish-the-wrong-language failure\n * the entity docblocks call worse than an absent document.\n *\n * `jobs.ts` already screens the value it reads from the app, but this module is\n * a public export and its functions can be called directly. The check belongs\n * where the assumption lives.\n */\nexport function assertSupportedLocale(locale: string): void {\n if (!SUPPORTED_LOCALES.includes(locale)) {\n throw new TypeError(\n `yhikas-sync: unsupported locale '${locale}' — upstream carries only '${ET_LOCALE}' and ` +\n `'${EN_LOCALE}', and projecting one language's text under another locale would publish ` +\n `the wrong language while every write succeeded.`,\n )\n }\n}\n\n/** The locale that is NOT the base row's. */\nexport function secondaryLocaleOf(defaultLocale: string): string {\n assertSupportedLocale(defaultLocale)\n return defaultLocale === ET_LOCALE ? EN_LOCALE : ET_LOCALE\n}\n\n/**\n * One locale's text, which upstream may not carry at all.\n *\n * `MultilingualText`'s keys are `string | null | undefined` because the column's\n * `notNull` covers the object and not its keys (F058 — see the schema docblock).\n * Every caller therefore goes through `present()` before using the value.\n */\nfunction pickLocale(text: MultilingualText, locale: string): string | null | undefined {\n return locale === ET_LOCALE ? text.et : text.en\n}\n\n/**\n * The document body for one locale.\n *\n * Separate from `pickLocale` because the two really do differ upstream, and the\n * difference is measured rather than assumed: `html_content_et` / `_en` are\n * `text().notNull()` — two ordinary columns that cannot be null — whereas the\n * title beside them is one `json` column whose KEYS are unconstrained. Reading\n * the HTML through the nullable helper would push a `?? ''` into the sanitizer\n * call and imply a hole that does not exist.\n */\nfunction pickHtml(row: LegalDocumentRow, locale: string): string {\n return locale === ET_LOCALE ? row.htmlContentEt : row.htmlContentEn\n}\n\n/**\n * Present = a string, non-empty after trimming.\n *\n * A TYPE GUARD, and both halves of it are load-bearing at this boundary:\n *\n * - **`typeof value === 'string'`** is not dead code against a `string`-typed\n * field. The wire schema admits `null` and `undefined` for a locale key\n * (F058), and this is the check that converts a half-filled upstream row into\n * a dropped VALUE rather than a thrown request — `null.trim()` would take the\n * whole resource down for one missing translation.\n * - **Trimming** matters because nothing upstream validates non-emptiness — the\n * form has no schema and no required check — so `\"\"` and `\" \"` are both\n * expected states of a half-filled row, and only one of them looks empty.\n */\nfunction present(value: string | null | undefined): value is string {\n return typeof value === 'string' && value.trim().length > 0\n}\n\n/**\n * A locale value as it should be STORED when it may be absent: trimmed, or the\n * empty string.\n *\n * Only `projectSiteInfo` needs this, and only because its gate is per ROW (the\n * either-half rule) rather than per field — so a value that failed `present()`\n * can still reach the payload alongside a sibling that passed. Its two callers\n * used to trim unconditionally, which throws on a null.\n */\nfunction trimmedOrEmpty(value: string | null | undefined): string {\n return present(value) ? value.trim() : ''\n}\n\n/**\n * Characters that make a slug unusable — or unsafe — as an anchor fragment.\n *\n * A DENY-list rather than an allow-list, deliberately, and this is the one\n * place in the package where that is the right way round. Upstream derives the\n * slug from an unvalidated free-text field, so an Estonian dormitory produces\n * Estonian slugs (`üldtingimused`); a character-class allow-list would reject\n * legitimate content and — since the slug is not the identity — buy nothing for\n * it. What actually needs excluding is the small set that turns a fragment into\n * a scheme, a path, or markup.\n */\nconst UNSAFE_SLUG_CHARS = /[\\s:/\\\\<>\"'`?#&%]/\n\n/**\n * The `maxLength` both entities declare for their key and slug columns.\n *\n * Checked here rather than left to the write: an overlong value that reaches\n * `client.create` fails at the database, `applyPlan` counts the row failed, and\n * the run throws — so one overlong free-text value upstream would keep the\n * whole resource failing every six hours with the watchdog armed. Skipping the\n * row keeps the blast radius at one document, matching the policy already\n * stated for an unsafe slug.\n */\nconst MAX_COLUMN_LENGTH = 190\n\nfunction isSafeAnchorSlug(slug: string): boolean {\n // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what is being excluded from a URL fragment\n return !UNSAFE_SLUG_CHARS.test(slug) && !/[\\u0000-\\u001f\\u007f]/.test(slug)\n}\n\n/**\n * Refuse a snapshot in which rows arrived and NONE of them survived projection.\n *\n * Skipping is per-row and deliberate — that is the whole point of F058's fix,\n * and one unusable row among 32 must not take the resource down. But a snapshot\n * where nothing at all survived is not a set of independent editorial gaps. It\n * is what a renamed column, a changed envelope, or a systematically half-filled\n * table looks like from here, and the response schema cannot tell the\n * difference: unknown keys are stripped by design (so a third language upstream\n * degrades gracefully), which means a RENAMED one parses as \"both locales\n * absent\" for every row.\n *\n * `diff.ts`'s `empty-snapshot` rule cannot cover it, and the reason is the same\n * shape as the one that forced `projectSiteInfo`'s refusal: that rule fires on\n * RETIREMENT, and it returns early when there is nothing to retire. On a first\n * run — a fresh install, or a resource nobody has published yet — the plan comes\n * back empty, `applyPlan` reports all-zero counts, `recordSuccess` advances\n * `lastSuccessAt`, and the staleness watchdog is disarmed against a site with no\n * content at all. The run reports `ok: true` forever.\n *\n * So the refusal is stated where the information exists: here, where both counts\n * are in hand. Local content is left untouched and the watchdog stays armed —\n * the same posture as an unreachable endpoint, because epistemically it is the\n * same situation.\n */\nfunction assertSomethingSurvived(\n resource: SyncResource,\n upstreamCount: number,\n projectedCount: number,\n skipped: readonly SkippedRow[],\n): void {\n if (upstreamCount === 0 || projectedCount > 0) return\n\n const reasons = skipped\n .slice(0, MAX_REPORTED_SKIP_REASONS)\n .map((row) => `${row.key}: ${row.reason}`)\n .join('; ')\n\n throw new YhikasSyncRefusedError(\n resource,\n 'empty-content',\n `yhikas-admin returned ${upstreamCount} ${resource} row(s) and ${projectedCount} of them are ` +\n `publishable — every one was skipped. One unusable row is an editorial gap and is skipped ` +\n `on its own; a whole snapshot of them is what a renamed column or a changed envelope looks ` +\n `like from here, and unknown keys are stripped by design so the schema cannot tell the ` +\n `difference. Refusing rather than reporting a successful run that published nothing: local ` +\n `content is unchanged and the staleness watchdog stays armed. Reasons: ${reasons}`,\n )\n}\n\n/** Enough skip reasons to diagnose the pattern, not enough to bury the log line. */\nconst MAX_REPORTED_SKIP_REASONS = 3\n\nexport function projectRoomTypes(\n rows: readonly RoomTypeRow[],\n options: ProjectionOptions,\n): ProjectionResult {\n assertSupportedLocale(options.defaultLocale)\n const secondaryLocale = secondaryLocaleOf(options.defaultLocale)\n const projected: ProjectedRow[] = []\n const skipped: SkippedRow[] = []\n\n for (const row of rows) {\n if (row.code.length > MAX_COLUMN_LENGTH) {\n skipped.push({\n key: row.code.slice(0, 80),\n reason: `upstream code exceeds the ${MAX_COLUMN_LENGTH}-character column`,\n })\n continue\n }\n\n const primaryName = pickLocale(row.name, options.defaultLocale)\n if (!present(primaryName)) {\n skipped.push({\n key: row.code,\n reason:\n `no '${options.defaultLocale}' name upstream — a room type with no name in the ` +\n `site's primary language has nothing publishable to render`,\n })\n continue\n }\n\n const secondaryName = pickLocale(row.name, secondaryLocale)\n const base: Record<string, unknown> = {\n code: row.code,\n name: primaryName,\n // Decimals stay STRINGS end to end — see the entity docblock.\n totalArea: row.totalArea,\n livingArea: row.livingArea,\n commonArea: row.commonArea,\n capacity: row.capacity,\n placesOccupied: row.placesOccupied,\n monthlyRent: row.monthlyRent,\n discountedMonthlyRent: row.discountedRent,\n dailyRent: row.dailyRent,\n currency: DECLARED_CURRENCY,\n vatTreatment: DECLARED_VAT_TREATMENT,\n hasEnglish: present(row.name.en),\n }\n const secondary = present(secondaryName) ? { name: secondaryName } : null\n\n projected.push({ key: row.code, base, secondary, hash: hashOf(base, secondary) })\n }\n\n assertSomethingSurvived(ROOM_TYPES_RESOURCE, rows.length, projected.length, skipped)\n return { projected, skipped }\n}\n\nexport function projectLegalDocuments(\n rows: readonly LegalDocumentRow[],\n options: ProjectionOptions,\n sanitize: HtmlSanitizer,\n): ProjectionResult {\n assertSupportedLocale(options.defaultLocale)\n const secondaryLocale = secondaryLocaleOf(options.defaultLocale)\n const projected: ProjectedRow[] = []\n const skipped: SkippedRow[] = []\n\n for (const row of rows) {\n const primaryTitle = pickLocale(row.title, options.defaultLocale)\n // Sanitize BEFORE the emptiness test: markup that reduces to nothing under\n // the allowlist (a lone `<script>`, say) is empty content, and publishing a\n // legal document whose body sanitizes away would be worse than omitting it.\n const primaryBody = sanitize(pickHtml(row, options.defaultLocale))\n\n if (!present(primaryTitle) || !present(primaryBody)) {\n skipped.push({\n key: row.type,\n reason: `no publishable '${options.defaultLocale}' title or body upstream`,\n })\n continue\n }\n\n if (row.type.length > MAX_COLUMN_LENGTH || row.slug.length > MAX_COLUMN_LENGTH) {\n skipped.push({\n key: row.type.slice(0, 80),\n reason:\n `upstream type or slug exceeds the ${MAX_COLUMN_LENGTH}-character column — refusing ` +\n `this row rather than letting the write fail and hold the whole resource in failure`,\n })\n continue\n }\n\n if (!isSafeAnchorSlug(row.slug)) {\n skipped.push({\n key: row.type,\n reason:\n `upstream slug ${JSON.stringify(row.slug)} is not usable as an anchor fragment — it ` +\n `carries a scheme, a path separator, whitespace or markup. Refusing this row rather ` +\n `than the whole response: upstream derives the slug from an unvalidated free-text ` +\n `field, so one bad value must not take the resource offline`,\n })\n continue\n }\n\n const secondaryTitle = pickLocale(row.title, secondaryLocale)\n const secondaryBody = sanitize(pickHtml(row, secondaryLocale))\n const englishBody = sanitize(row.htmlContentEn)\n\n const base: Record<string, unknown> = {\n type: row.type,\n title: primaryTitle,\n sourceSlug: row.slug,\n body: primaryBody,\n order: row.order,\n hasEnglish: present(row.title.en) && present(englishBody),\n }\n // BOTH halves must be present. A title with no body renders an empty page;\n // a body with no title renders an untitled one. Either is the \"empty page\"\n // D015 exists to prevent, so the locale is suppressed unless both survive.\n const secondary =\n present(secondaryTitle) && present(secondaryBody)\n ? { title: secondaryTitle, body: secondaryBody }\n : null\n\n projected.push({ key: row.type, base, secondary, hash: hashOf(base, secondary) })\n }\n\n assertSomethingSurvived(LEGAL_DOCUMENTS_RESOURCE, rows.length, projected.length, skipped)\n return { projected, skipped }\n}\n\n/**\n * Reception hours + the notice ticker → the one `yhikas_site_info` row.\n *\n * ## 🔴 The refusal is the point of this function\n *\n * `/api/public/site-info` answers `200 { success: true, receptionHours:\n * {et:'',en:''}, notices: [] }` when no upstream row exists. That is\n * byte-identical to an operator having deliberately cleared both, and it is the\n * state the endpoint is in whenever nobody has filled it in (F015). The other\n * two resources have no equivalent hole — theirs discriminate on `success` and\n * on array length against a local snapshot.\n *\n * The shipped `empty-snapshot` floor cannot cover it, and the reason is\n * structural rather than an oversight: that rule is a row-COUNT test, and this\n * resource always projects exactly one row. The count is 1 whether the row says\n * anything or not, so the floor never fires and an all-empty payload would be\n * applied as an authoritative blanking — emptying the ticker and the hours on\n * every page of the site, silently, six hours after upstream hiccupped.\n *\n * So emptiness is restated here as a CONTENT test: if the default locale has\n * neither hours nor a single active notice, the payload is refused as\n * unusable. Local content is left exactly as it was and the failure arms the\n * staleness watchdog, which is the same posture as an unreachable endpoint —\n * because epistemically it is the same situation.\n *\n * Note what is NOT refused: hours with no notices, or notices with no hours.\n * Both are ordinary states of a real dormitory, and refusing them would make\n * the guard fire on exactly the operator action it exists to protect.\n */\nexport function projectSiteInfo(\n response: SiteInfoResponse,\n options: ProjectionOptions,\n): ProjectionResult {\n assertSupportedLocale(options.defaultLocale)\n const secondaryLocale = secondaryLocaleOf(options.defaultLocale)\n\n const ordered = response.notices\n .filter((notice) => notice.isActive)\n .sort((a, b) => a.order - b.order)\n const active = ordered.slice(0, MAX_SITE_NOTICES)\n const skipped: SkippedRow[] = []\n if (ordered.length > active.length) {\n // Logged, never silent. A dropped notice nobody hears about reads as\n // \"covered everything\" — the operator adds a 26th notice, it never appears\n // on the site, and nothing anywhere says why. Reported as one row carrying\n // the count rather than N rows, since the tail is interchangeable.\n skipped.push({\n key: SITE_INFO_KEY,\n reason:\n `${ordered.length - active.length} active notice(s) beyond the ${MAX_SITE_NOTICES} ` +\n `ticker ceiling were dropped — a marquee past a couple of dozen entries is not read, ` +\n `and an unbounded array from upstream would ride onto every page of the site`,\n })\n }\n\n const noticesFor = (locale: string): string[] =>\n active.map((notice) => pickLocale(notice.text, locale)).filter(present)\n\n const primaryHours = pickLocale(response.receptionHours, options.defaultLocale)\n const primaryNotices = noticesFor(options.defaultLocale)\n\n if (!present(primaryHours) && primaryNotices.length === 0) {\n throw new YhikasSyncRefusedError(\n SITE_INFO_RESOURCE,\n 'empty-content',\n `yhikas-admin returned a well-formed site-info response carrying no usable ` +\n `'${options.defaultLocale}' content: reception hours are empty, and of ` +\n `${response.notices.length} notice(s) upstream, ${ordered.length} are active and ` +\n `${primaryNotices.length} have '${options.defaultLocale}' text. (Those three counts are ` +\n `reported separately on purpose — \"no active notices\" and \"active notices with no text ` +\n `in this language\" send an operator to different places.) That state is exactly what the ` +\n `endpoint returns when no row exists at all, and it is indistinguishable from a ` +\n `deliberate clearing — so it is refused rather than applied. Local content is unchanged ` +\n `and the staleness watchdog stays armed. Note this refusal is TERMINAL, not transient: ` +\n `it will repeat every run until upstream carries something. If the intent really was to ` +\n `clear everything, leave one of the two set.`,\n )\n }\n\n const secondaryHours = pickLocale(response.receptionHours, secondaryLocale)\n const secondaryNotices = noticesFor(secondaryLocale)\n const englishHours = pickLocale(response.receptionHours, EN_LOCALE)\n const englishNotices = noticesFor(EN_LOCALE)\n\n const base: Record<string, unknown> = {\n key: SITE_INFO_KEY,\n // TRIMMED, not passed through, and NULL-TOLERANT. `present()` trims before\n // testing, so a whitespace-only value passes the either-half gate whenever\n // the other half is set — and would then be stored verbatim, rendering as\n // an empty row rather than an absent one. A null does the same thing one\n // step worse: `.trim()` on it throws, so one unfilled locale would fail the\n // whole run (F058). The sibling projections cannot hit either case because\n // their gates are per field; this one's is not.\n receptionHours: trimmedOrEmpty(primaryHours),\n notices: primaryNotices,\n hasEnglish: present(englishHours) || englishNotices.length > 0,\n }\n\n // The same either-half rule as the primary locale, for the same reason: a\n // secondary locale carrying only notices is publishable, and suppressing it\n // would leave English visitors with the Estonian ticker.\n const secondary =\n present(secondaryHours) || secondaryNotices.length > 0\n ? {\n // 🔴 An explicit EMPTY STRING for a missing half, deliberately — NOT\n // `null`, and this is the one place in the package that diverges from\n // its siblings. Two reviews reached opposite conclusions here, so the\n // reasoning is written down rather than left to the next reader.\n //\n // `null` is \"no override, inherit the base row\", and the merged read\n // is `COALESCE(translation, base)`. For a title or a document body\n // that inheritance is a sane fallback, which is why\n // `projectLegalDocuments` suppresses the whole locale unless BOTH\n // halves are present. For OPENING HOURS it is not: inheriting means\n // an English visitor is shown the Estonian string, presented as\n // English. Showing nothing is better than showing the wrong language.\n //\n // So the two halves here are independent optional content rather than\n // a title/body pair, and each says \"absent in this language\" as an\n // explicit empty value. `notices: []` on the next line already had\n // exactly those semantics; the two now agree instead of contradicting\n // each other inside one object.\n //\n // Trimmed — and null-tolerant — for the same reason the base row is:\n // `present()` trims before testing, so whitespace would otherwise\n // survive the gate and be stored verbatim, and a null locale key\n // would throw here rather than read as absent.\n receptionHours: trimmedOrEmpty(secondaryHours),\n notices: secondaryNotices,\n }\n : null\n\n return {\n projected: [{ key: SITE_INFO_KEY, base, secondary, hash: hashOf(base, secondary) }],\n skipped,\n }\n}\n\nfunction hashOf(base: Record<string, unknown>, secondary: Record<string, unknown> | null): string {\n return stableHash({ base, secondary })\n}\n","/**\n * One sync run: fetch, diff, apply, record — per resource, in that order.\n *\n * Every collaborator is injected, so a run is fully exercisable in a unit test\n * with no Postgres, no HTTP server and no queue worker. That is not a stylistic\n * preference: CI runs unit tests only, so logic reachable only through\n * integration tests is in practice covered by nothing, and PR 05's three\n * obligatory negative tests all live at this level.\n *\n * ## Ordering: fetch BEFORE reading local state, and abort before either write\n *\n * The upstream fetch happens first and its failure aborts the resource\n * immediately — before the differ, and therefore before any write. That is the\n * mechanical form of \"never act on an absence from a response that did not\n * fully succeed\": an unreachable endpoint cannot retire anything, because\n * nothing downstream of the fetch runs.\n *\n * ## Resources are independent\n *\n * A failure syncing room types does not prevent legal documents from syncing,\n * and each records its own success separately, so the watchdog reports per\n * resource. Whichever failed still fails the JOB at the end, so the queue's\n * retry and dead-letter path engages.\n */\n\nimport {\n type ApplyCounts,\n applyPlan,\n readLocalRows,\n type SyncEntityClient,\n type SyncLogger,\n} from './apply.js'\nimport {\n LEGAL_DOCUMENTS_RESOURCE,\n ROOM_TYPES_RESOURCE,\n SITE_INFO_RESOURCE,\n type SyncResource,\n} from './constants.js'\nimport { planDiff, type SanityFloor } from './diff.js'\nimport {\n type HtmlSanitizer,\n type ProjectedRow,\n type ProjectionResult,\n projectLegalDocuments,\n projectRoomTypes,\n projectSiteInfo,\n secondaryLocaleOf,\n} from './projection.js'\nimport type { SyncStateStore } from './sync-state.js'\nimport type { YhikasUpstreamClient } from './upstream/client.js'\n\n/** Per-run ceiling on individual skip warnings; the remainder is reported as a count. */\nconst MAX_LOGGED_SKIPS = 20\n\nexport interface SyncRunDeps {\n readonly upstream: YhikasUpstreamClient\n readonly roomTypeClient: SyncEntityClient\n readonly legalDocumentClient: SyncEntityClient\n readonly siteInfoClient: SyncEntityClient\n readonly state: SyncStateStore\n readonly sanitizeHtml: HtmlSanitizer\n readonly logger: SyncLogger\n readonly defaultLocale: string\n readonly floor: SanityFloor\n /** Injectable so tests are not order-dependent on the wall clock. */\n readonly now?: () => Date\n}\n\nexport interface ResourceOutcome {\n readonly resource: SyncResource\n readonly ok: boolean\n readonly counts: ApplyCounts | null\n readonly skipped: number\n readonly error: string | null\n}\n\nexport interface SyncRunSummary {\n readonly outcomes: readonly ResourceOutcome[]\n readonly ok: boolean\n}\n\n/** Thrown when at least one resource failed, so the queue retries and eventually alerts. */\nexport class YhikasSyncRunError extends Error {\n readonly summary: SyncRunSummary\n constructor(summary: SyncRunSummary) {\n const failed = summary.outcomes.filter((outcome) => !outcome.ok)\n super(\n `yhikas-admin sync failed for ${failed.map((o) => o.resource).join(', ')}: ` +\n failed.map((o) => o.error).join(' | '),\n )\n this.name = 'YhikasSyncRunError'\n this.summary = summary\n }\n}\n\nexport async function runYhikasSync(deps: SyncRunDeps): Promise<SyncRunSummary> {\n const now = deps.now ?? (() => new Date())\n const secondaryLocale = secondaryLocaleOf(deps.defaultLocale)\n\n const outcomes: ResourceOutcome[] = []\n\n outcomes.push(\n await syncOne({\n resource: ROOM_TYPES_RESOURCE,\n keyField: 'code',\n client: deps.roomTypeClient,\n fetchAndProject: async () =>\n projectRoomTypes(await deps.upstream.fetchRoomTypes(), {\n defaultLocale: deps.defaultLocale,\n }),\n deps,\n secondaryLocale,\n now,\n }),\n )\n\n outcomes.push(\n await syncOne({\n resource: LEGAL_DOCUMENTS_RESOURCE,\n keyField: 'type',\n client: deps.legalDocumentClient,\n fetchAndProject: async () =>\n projectLegalDocuments(\n await deps.upstream.fetchLegalDocuments(),\n { defaultLocale: deps.defaultLocale },\n deps.sanitizeHtml,\n ),\n deps,\n secondaryLocale,\n now,\n }),\n )\n\n // Third and last. `fetchAndProject` is where its refusal lives, so an\n // all-empty upstream aborts here — before local state is read and therefore\n // before any write, exactly like a failed fetch (D027).\n outcomes.push(\n await syncOne({\n resource: SITE_INFO_RESOURCE,\n keyField: 'key',\n client: deps.siteInfoClient,\n fetchAndProject: async () =>\n projectSiteInfo(await deps.upstream.fetchSiteInfo(), {\n defaultLocale: deps.defaultLocale,\n }),\n deps,\n secondaryLocale,\n now,\n }),\n )\n\n const summary: SyncRunSummary = { outcomes, ok: outcomes.every((outcome) => outcome.ok) }\n if (!summary.ok) throw new YhikasSyncRunError(summary)\n return summary\n}\n\ninterface SyncOneInput {\n readonly resource: SyncResource\n readonly keyField: string\n readonly client: SyncEntityClient\n readonly fetchAndProject: () => Promise<ProjectionResult>\n readonly deps: SyncRunDeps\n readonly secondaryLocale: string\n readonly now: () => Date\n}\n\nasync function syncOne(input: SyncOneInput): Promise<ResourceOutcome> {\n const { resource, keyField, client, fetchAndProject, deps, secondaryLocale, now } = input\n const { logger, state, floor } = deps\n\n try {\n // Inside the try, deliberately. Outside it, a store failure — Postgres\n // unreachable, table not yet migrated — would reject `syncOne` rather than\n // returning a `ResourceOutcome`, which breaks two stated guarantees at\n // once: the second resource is never attempted (so much for \"resources are\n // independent\"), and the caller gets a raw store error instead of a\n // `YhikasSyncRunError` carrying the per-resource summary the watchdog\n // reports on. The catch below already treats a state write as non-fatal;\n // these two get the same treatment.\n await state.ensure(resource, now())\n await state.recordAttempt(resource, now())\n\n // 1. Fetch + project. Any refusal, timeout or shape failure throws HERE,\n // before local state is even read — so a failed fetch cannot influence\n // what is retired.\n const projection = await fetchAndProject()\n\n // Logged, never silent: a dropped row nobody hears about reads as \"covered\n // everything\". Capped all the same — the snapshot ceiling is in the\n // hundreds, and a systematically malformed upstream would otherwise bury\n // every other line in the run. The suppressed COUNT is reported, so the\n // cap can never itself become a silent truncation.\n for (const skip of projection.skipped.slice(0, MAX_LOGGED_SKIPS)) {\n logger.warn(\n { resource, key: skip.key, reason: skip.reason },\n 'yhikas-sync: skipping an upstream row, or part of one, that cannot be published',\n )\n }\n if (projection.skipped.length > MAX_LOGGED_SKIPS) {\n logger.warn(\n {\n resource,\n suppressed: projection.skipped.length - MAX_LOGGED_SKIPS,\n total: projection.skipped.length,\n },\n 'yhikas-sync: further skipped rows not logged individually',\n )\n }\n\n // 2. Read local state and plan. The differ raises the D016 refusals.\n const local = await readLocalRows(client, keyField, floor.maxRows)\n const plan = planDiff<ProjectedRow>({\n resource,\n upstream: projection.projected,\n local,\n keyOf: (row) => row.key,\n hashOf: (row) => row.hash,\n floor,\n })\n\n // 3. Apply.\n const counts = await applyPlan({ resource, plan, client, secondaryLocale, logger })\n\n await state.recordSuccess(resource, now(), counts)\n logger.info(\n { resource, ...counts, skipped: projection.skipped.length },\n 'yhikas-sync: resource synced',\n )\n return { resource, ok: true, counts, skipped: projection.skipped.length, error: null }\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n logger.error({ err, resource }, 'yhikas-sync: resource sync failed — local content unchanged')\n try {\n // `lastSuccessAt` is untouched, so the staleness watchdog stays armed.\n await state.recordFailure(resource, now(), message)\n } catch (stateErr) {\n // Recording the failure must never REPLACE it. If the database is the\n // thing that is broken, this write fails too, and letting it throw here\n // would report a bookkeeping error while hiding the real cause.\n logger.error(\n { err: stateErr, resource },\n 'yhikas-sync: could not record the failure in yhikas_sync_state',\n )\n }\n return { resource, ok: false, counts: null, skipped: 0, error: message }\n }\n}\n","/**\n * Reading and writing `yhikas_sync_state`.\n *\n * Behind an interface so the run orchestrator and the watchdog are both\n * testable against an in-memory fake — CI runs no integration tests, so\n * anything reachable only through a real Postgres is in practice covered by\n * nothing.\n */\n\nimport type { ApplyCounts } from './apply.js'\nimport type { SyncResource } from './constants.js'\nimport { SYNC_RESOURCES } from './constants.js'\nimport type { yhikasSyncStateTable } from './sync-state-table.js'\n\nexport interface SyncStateRecord {\n readonly resource: string\n readonly firstSeenAt: Date\n readonly lastAttemptAt: Date | null\n readonly lastSuccessAt: Date | null\n readonly lastError: string | null\n}\n\nexport interface SyncStateStore {\n /** Create the row if absent, so the watchdog can measure a never-ran sync from somewhere. */\n ensure(resource: SyncResource, now: Date): Promise<void>\n recordAttempt(resource: SyncResource, now: Date): Promise<void>\n recordSuccess(resource: SyncResource, now: Date, counts: ApplyCounts): Promise<void>\n recordFailure(resource: SyncResource, now: Date, error: string): Promise<void>\n readAll(): Promise<SyncStateRecord[]>\n}\n\ntype SyncStateClient = ReturnType<typeof yhikasSyncStateTable.makeClient>\n\n/** Keep the stored message inside the column and free of anything unbounded. */\nconst MAX_ERROR_LENGTH = 1024\n\nexport function truncateError(message: string): string {\n return message.length <= MAX_ERROR_LENGTH ? message : `${message.slice(0, MAX_ERROR_LENGTH - 1)}…`\n}\n\nexport function createSyncStateStore(client: SyncStateClient): SyncStateStore {\n return {\n async ensure(resource, now) {\n // `set: {}` would be an empty conflict update, so the no-op path re-states\n // `resource`: the point is only to guarantee the row exists, never to\n // move `firstSeenAt` — moving it would reset the never-ran clock on every\n // boot and the staleness alert could then never fire.\n await client.upsert({ resource, firstSeenAt: now }, { target: 'resource', set: { resource } })\n },\n\n async recordAttempt(resource, now) {\n await client.upsert(\n { resource, firstSeenAt: now, lastAttemptAt: now },\n { target: 'resource', set: { lastAttemptAt: now } },\n )\n },\n\n async recordSuccess(resource, now, counts) {\n await client.upsert(\n {\n resource,\n firstSeenAt: now,\n lastAttemptAt: now,\n lastSuccessAt: now,\n lastError: null,\n lastCreated: counts.created,\n lastUpdated: counts.updated,\n lastRetired: counts.retired,\n lastUnchanged: counts.unchanged,\n },\n {\n target: 'resource',\n set: {\n lastAttemptAt: now,\n lastSuccessAt: now,\n // Cleared, so a stale message from a resolved failure cannot read\n // as a current one.\n lastError: null,\n lastCreated: counts.created,\n lastUpdated: counts.updated,\n lastRetired: counts.retired,\n lastUnchanged: counts.unchanged,\n },\n },\n )\n },\n\n async recordFailure(resource, now, error) {\n const lastError = truncateError(error)\n // `lastSuccessAt` is deliberately NOT touched. It is the watchdog's only\n // input, and advancing it on a failed run would silence the alert for\n // exactly the runs that should raise it.\n await client.upsert(\n { resource, firstSeenAt: now, lastAttemptAt: now, lastError },\n { target: 'resource', set: { lastAttemptAt: now, lastError } },\n )\n },\n\n async readAll() {\n // Filtered by resource rather than capped at \"however many resources\n // there are\": an unrelated leftover row — a resource added and later\n // removed, e.g. the Q1 `site_info` — would otherwise be able to fill the\n // limit and push an in-scope resource out of the result, which the\n // watchdog reads as \"no tracking row at all\" and reports as a total\n // outage. A false alarm on the one channel that has to stay trustworthy.\n const rows = await client.findMany({\n where: { resource: { in: [...SYNC_RESOURCES] } },\n limit: SYNC_RESOURCES.length,\n })\n return rows.map((row) => ({\n resource: String(row.resource),\n firstSeenAt: row.firstSeenAt as Date,\n lastAttemptAt: (row.lastAttemptAt as Date | null) ?? null,\n lastSuccessAt: (row.lastSuccessAt as Date | null) ?? null,\n lastError: (row.lastError as string | null) ?? null,\n }))\n },\n }\n}\n","/**\n * The yhikas-admin `/api/public/*` wire contract, as Zod schemas.\n *\n * Measured from that repository's route handlers on 2026-08-06 (R013), not\n * inferred from its `src/db/schema.ts` — a sync is written against the\n * envelope, and the envelope is named (`{ success, roomTypes }`,\n * `{ success, documents }`) rather than bare or `data`-keyed.\n *\n * Two properties of the upstream response are deliberate on that side and must\n * survive the hop:\n *\n * - **Decimals arrive as strings.** `pg` returns `numeric` as a string and the\n * route passes it through. Parsing to a float would introduce rounding into\n * a price, so every money/area field is validated AS a string and stored as\n * one. `z.coerce` is banned in this file for that reason.\n * - **Nulls pass through untouched.** The route's own comment: \"no `?? 0`, no\n * `?? ''` … so the site can decide how to render a missing price rather than\n * displaying a fabricated zero.\" A missing price is data; the sync\n * substitutes no defaults either.\n */\n\nimport { z } from 'zod'\n\n/**\n * A memory bound on a key-ish string, NOT the column width.\n *\n * The entities declare `maxLength: 190`, but enforcing that HERE would fail\n * `legalDocumentsResponseSchema` for the whole payload over one overlong\n * free-text value — the same blast-radius mistake the slug charset check made.\n * The column-width check lives in the projection, per row.\n */\nconst MAX_KEY_LENGTH = 2000\n/** Upper bound on a human-facing label (a room-type name, a document title). */\nconst MAX_LABEL_LENGTH = 500\n/** Upper bound on one document's HTML. Refusing beats storing an unbounded blob. */\nconst MAX_HTML_LENGTH = 512 * 1024\n\n/**\n * A Postgres `numeric` as `pg` serializes it. Anything that is not a plain\n * decimal literal is a shape violation and aborts the run — validating at the\n * boundary, per CLAUDE.md, rather than storing whatever arrived.\n */\nconst decimalString = z\n .string()\n .max(32)\n .regex(/^-?\\d+(\\.\\d+)?$/, 'expected a decimal literal, e.g. \"180.00\"')\n\n/**\n * `MultilingualText` — a Postgres `json` column, so it arrives as a nested\n * object and is never stringified.\n *\n * NOT `.strict()`: unknown keys are stripped rather than rejected, so adding a\n * third language upstream degrades to \"the sync ignores it\" instead of \"every\n * run fails\".\n *\n * 🔴 **Each locale key is nullable AND optional, and that is the load-bearing\n * clause** (F058, cross-referenced to upstream F031). The column is declared\n * `json(...).notNull()` — and **notNull covers the OBJECT, not its keys**. There\n * is no CHECK constraint, and two of the four public columns\n * (`room_type.name`, `legal_document.title`) have no upstream validation at all:\n * the write is a raw `formData.get(…) as string`, which is `null` the moment a\n * field is absent. So `{\"et\":\"Tuba\",\"en\":null}` — and `{\"et\":\"Tuba\"}` — are rows\n * yhikas-admin accepts today, through its ordinary admin UI.\n *\n * Requiring both keys would fail the ENTIRE response for one such row, so 32\n * room types would stop syncing because one lacks an English name — every six\n * hours, until somebody edited it upstream. That is exactly the blast-radius\n * mistake `legalDocumentRowSchema.slug` documents below and deliberately\n * avoided; a missing translation belongs at one row, and `projection.ts` is\n * where it is decided (the locale is suppressed, or the row is skipped with a\n * reason).\n *\n * What is NOT relaxed, on purpose:\n *\n * - **The object itself stays required.** A missing translation is DATA; a\n * missing FIELD is a renamed column or a changed envelope. The consequence of\n * tolerating one is caught either way — `assertSomethingSurvived` refuses a\n * snapshot in which no row survived — but refusing here NAMES it\n * (`title: Required`) instead of reporting 32 identical \"no 'et' title\n * upstream\" skips and leaving the reader to infer the cause.\n * - **A non-string value is still a shape violation.** `{\"en\":42}` would\n * otherwise be written as a name.\n *\n * Note the asymmetry this leaves, deliberately: a RENAMED key (`et_EE`) is\n * indistinguishable from two absent ones, because unknown keys are stripped by\n * design so a third language degrades gracefully. That case falls through to\n * `assertSomethingSurvived`, which is exactly what it is for.\n */\nexport const multilingualTextSchema = z.object({\n et: z.string().max(MAX_LABEL_LENGTH).nullable().optional(),\n en: z.string().max(MAX_LABEL_LENGTH).nullable().optional(),\n})\n\nexport type MultilingualText = z.infer<typeof multilingualTextSchema>\n\n/**\n * One `room_type` row.\n *\n * `depositAmount` / `discountedDepositAmount` are absent by design. Upstream\n * ships them as hardcoded `null` with no backing column; validating them as\n * `z.null()` would turn the day someone adds the column into a hard sync\n * failure. Deposits are unbuilt admin-side work, not something the sync can\n * surface.\n */\nexport const roomTypeRowSchema = z.object({\n code: z.string().min(1).max(MAX_KEY_LENGTH),\n name: multilingualTextSchema,\n totalArea: decimalString.nullable(),\n livingArea: decimalString.nullable(),\n commonArea: decimalString.nullable(),\n capacity: z.number().int().nullable(),\n monthlyRent: decimalString.nullable(),\n discountedRent: decimalString.nullable(),\n dailyRent: decimalString.nullable(),\n placesOccupied: z.number().int().nullable(),\n})\n\nexport type RoomTypeRow = z.infer<typeof roomTypeRowSchema>\n\n/**\n * One active `legal_document` row.\n *\n * `type` is `text().notNull().unique()` upstream — NOT a pgEnum, and there is\n * no TS union anywhere. The five values seeded today are closed by convention\n * only and the admin UI can mint a sixth, so this validates the SHAPE of the\n * business key and never its membership in a list. A whitelist here would turn\n * a new upstream document into a hard sync failure.\n */\nexport const legalDocumentRowSchema = z.object({\n type: z.string().min(1).max(MAX_KEY_LENGTH),\n title: multilingualTextSchema,\n /**\n * Accepted as free text HERE, and screened per-row in the projection.\n *\n * Upstream derives it from an unvalidated free-text form field\n * (`type.toLowerCase().replace(/_/g, '-')`), so an Estonian title yields an\n * Estonian slug — `üldtingimused` — and a title with a space yields a slug\n * with a space. A character-class regex on the RESPONSE schema would fail\n * `legalDocumentsResponseSchema` for the whole payload, so one newly\n * authored document would take the entire resource offline every six hours\n * until someone edited it upstream. The blast radius belongs at one row.\n */\n slug: z.string().min(1).max(MAX_KEY_LENGTH),\n htmlContentEt: z.string().max(MAX_HTML_LENGTH),\n htmlContentEn: z.string().max(MAX_HTML_LENGTH),\n order: z.number().int(),\n})\n\nexport type LegalDocumentRow = z.infer<typeof legalDocumentRowSchema>\n\n/**\n * `success: z.literal(true)` is the load-bearing clause, not decoration.\n *\n * Every upstream failure path sets `success: false` — 401 (missing header,\n * wrong scheme, wrong key, AND an unset server-side key: all four\n * indistinguishable, deny-by-default through one branch), 429, and 500. No\n * route returns 200 with a degraded body. So the discriminator is `success`,\n * never array length, and a snapshot that fails this schema can never be\n * mistaken for an authoritative empty one.\n */\nexport const roomTypesResponseSchema = z.object({\n success: z.literal(true),\n roomTypes: z.array(roomTypeRowSchema),\n})\n\nexport const legalDocumentsResponseSchema = z.object({\n success: z.literal(true),\n documents: z.array(legalDocumentRowSchema),\n})\n\n/**\n * One `site_notice` — a ticker item.\n *\n * No id, no code, no timestamp: `{text, isActive, order}` is the whole row as\n * the route serves it. That absence is why `site_info` is modelled as one\n * local row carrying an ordered list rather than as N diffable rows — there is\n * no key an edit upstream would preserve.\n *\n * `isActive` is honoured HERE rather than assumed: the route returns inactive\n * notices too, and a ticker that shows a retired notice is worse than one that\n * shows nothing.\n */\nexport const siteNoticeRowSchema = z.object({\n text: multilingualTextSchema,\n isActive: z.boolean(),\n order: z.number().int(),\n})\n\nexport type SiteNoticeRow = z.infer<typeof siteNoticeRowSchema>\n\n/**\n * `MAX_NOTICES_ON_THE_WIRE` is a BOUNDARY bound, deliberately larger than the\n * ticker's own ceiling and doing a different job.\n *\n * The 8 MiB streaming cap bounds bytes, but Zod still validates every element\n * before the projection ever gets to truncate — so an upstream bug emitting a\n * hundred thousand notices is parsed in full and only then cut to 25. Refusing\n * at the schema is the cheap half of the same rule the byte cap implements.\n *\n * Kept separate from `MAX_SITE_NOTICES` on purpose: that one is a rendering\n * decision (a marquee past two dozen entries is not read) and truncating to it\n * is reported, not fatal. This one is a \"the response is not credible\" floor.\n */\nconst MAX_NOTICES_ON_THE_WIRE = 1000\n\n/**\n * `/api/public/site-info` — reception hours plus the notice ticker.\n *\n * 🔴 **This envelope cannot express \"not configured yet\".** The route answers\n * `200 { success: true, receptionHours: {et:'',en:''}, notices: [] }` when no\n * row exists, which is byte-identical to an operator having deliberately\n * cleared everything (F015). The other two resources have no such hole —\n * theirs discriminate on `success` and on array length against a local\n * snapshot.\n *\n * The schema is therefore NOT where that is solved, and deliberately so: the\n * shape is valid either way. `projectSiteInfo` refuses an all-empty payload as\n * unusable (D027), which is the only place that has the standing to decide\n * that a well-formed response is not authoritative.\n */\n\nexport const siteInfoResponseSchema = z.object({\n success: z.literal(true),\n receptionHours: multilingualTextSchema,\n notices: z.array(siteNoticeRowSchema).max(MAX_NOTICES_ON_THE_WIRE),\n})\n\nexport type SiteInfoResponse = z.infer<typeof siteInfoResponseSchema>\n","/**\n * The read-only yhikas-admin client.\n *\n * **One-directional by construction, not by convention.** This class exposes\n * three methods and all are GETs. There is no `post`, no `put`, no generic\n * `request(method, …)` — so there is no code path anywhere in the sync that\n * could write upstream, which is a property of the type rather than a rule\n * someone has to keep remembering.\n *\n * It also never touches a database. D014 rules out a direct connection even\n * though the credentials would technically permit one: upstream keeps public\n * and private tables in one database behind one pool, with no read replica and\n * no schema separation, and the isolation that exists is query-level (explicit\n * column lists, no joins). The three narrow endpoints are the boundary, and\n * they are the boundary precisely because someone already drew it.\n */\n\nimport type { z } from 'zod'\nimport {\n LEGAL_DOCUMENTS_PATH,\n LEGAL_DOCUMENTS_RESOURCE,\n ROOM_TYPES_PATH,\n ROOM_TYPES_RESOURCE,\n SITE_INFO_PATH,\n SITE_INFO_RESOURCE,\n type SyncResource,\n} from '../constants.js'\nimport { YhikasUpstreamError } from './errors.js'\nimport {\n type LegalDocumentRow,\n legalDocumentsResponseSchema,\n type RoomTypeRow,\n roomTypesResponseSchema,\n type SiteInfoResponse,\n siteInfoResponseSchema,\n} from './wire.js'\n\n/**\n * Ceiling on one response, in BYTES, enforced WHILE reading the stream.\n *\n * An earlier version measured after `response.text()` had already buffered the\n * whole body, and said so — which made the bound honest but inert against\n * exactly the case it exists for: a chunked response declares no\n * `content-length`, so nothing stopped an unbounded body being read into\n * memory before the check could run. Availability is a security property\n * (CLAUDE.md), and a bound that cannot act until after the damage is a comment,\n * not a control. Now the read aborts mid-stream.\n */\nconst MAX_RESPONSE_BYTES = 8 * 1024 * 1024\n\nexport interface YhikasUpstreamClientOptions {\n /** Origin of the yhikas-admin deployment. Must be http or https. */\n baseUrl: string\n /** The sync's OWN bearer credential — never the site's (see `API_KEY_ENV_VAR`). */\n apiKey: string\n /** Per-request deadline in milliseconds. */\n timeoutMs: number\n /** Injectable for tests. Defaults to the global `fetch`. */\n fetchImpl?: typeof fetch\n}\n\n/**\n * Release an unread body before throwing, so the connection is not pinned\n * waiting for a consumer that will never arrive. Cancellation failures are\n * swallowed on purpose: the caller is already throwing something more\n * informative, and replacing it with a teardown error would hide the cause.\n */\n/**\n * Read a response body as text, aborting once it exceeds the byte ceiling.\n *\n * Throws a `shape` {@link YhikasUpstreamError} on overrun — which the caller\n * re-raises untouched, so an oversized body is refused rather than\n * misclassified as unreadable.\n */\nasync function readBounded(\n response: Response,\n resource: SyncResource,\n path: string,\n): Promise<string> {\n const stream = response.body\n if (!stream) return ''\n // Annotated, not cast: `Response.body` is typed `ReadableStream<any>` under\n // the Node type definitions, but the Fetch spec guarantees its chunks are\n // `Uint8Array`. Stating that here keeps `value.byteLength` honestly typed\n // instead of letting three `any`s leak into the byte accounting.\n const reader: ReadableStreamDefaultReader<Uint8Array> = stream.getReader()\n const decoder = new TextDecoder()\n let seen = 0\n let text = ''\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n seen += value.byteLength\n if (seen > MAX_RESPONSE_BYTES) {\n await reader.cancel()\n throw new YhikasUpstreamError(\n resource,\n 'shape',\n `yhikas-admin sent more than ${MAX_RESPONSE_BYTES} bytes for ${path}, above the ` +\n `ceiling — the read was aborted mid-stream rather than buffered and measured after`,\n { status: response.status },\n )\n }\n // `stream: true` so a multi-byte character split across chunk boundaries\n // is not mangled — Estonian text is full of them.\n text += decoder.decode(value, { stream: true })\n }\n return text + decoder.decode()\n } finally {\n reader.releaseLock()\n }\n}\n\nasync function discardBody(response: Response): Promise<void> {\n try {\n await response.body?.cancel()\n } catch {\n // Nothing useful to do — the original refusal is the interesting error.\n }\n}\n\nexport class YhikasUpstreamClient {\n readonly #baseUrl: URL\n readonly #apiKey: string\n readonly #timeoutMs: number\n readonly #fetch: typeof fetch\n\n constructor(options: YhikasUpstreamClientOptions) {\n let parsed: URL\n try {\n parsed = new URL(options.baseUrl)\n } catch (cause) {\n throw new TypeError(`yhikas-sync: baseUrl is not a valid URL: ${options.baseUrl}`, { cause })\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n throw new TypeError(\n `yhikas-sync: baseUrl must be http or https, got '${parsed.protocol}'. ` +\n `A file: or data: origin here would be a way to feed the sync a local snapshot.`,\n )\n }\n if (parsed.pathname !== '/') {\n // `new URL('/api/public/room-types', 'http://host/yhikas-admin/')` resolves\n // to `http://host/api/public/room-types` — the base path is silently\n // dropped and the sync calls the host root. Refusing beats joining:\n // upstream mounts these routes at the origin root, so a base path here is\n // a misconfiguration rather than a shape to support.\n throw new TypeError(\n `yhikas-sync: baseUrl must be an origin with no path, got '${parsed.pathname}'. ` +\n `The upstream routes are absolute, so a base path would be silently discarded.`,\n )\n }\n if (options.apiKey.length === 0) {\n throw new TypeError('yhikas-sync: apiKey must not be empty')\n }\n this.#baseUrl = parsed\n this.#apiKey = options.apiKey\n this.#timeoutMs = options.timeoutMs\n this.#fetch = options.fetchImpl ?? globalThis.fetch\n }\n\n /** `GET /api/public/room-types`. Ordered by `code` upstream; unpaginated. */\n async fetchRoomTypes(): Promise<RoomTypeRow[]> {\n const body = await this.#get(ROOM_TYPES_RESOURCE, ROOM_TYPES_PATH, roomTypesResponseSchema)\n return body.roomTypes\n }\n\n /** `GET /api/public/legal-documents`. Active only, ordered by `order`; unpaginated. */\n async fetchLegalDocuments(): Promise<LegalDocumentRow[]> {\n const body = await this.#get(\n LEGAL_DOCUMENTS_RESOURCE,\n LEGAL_DOCUMENTS_PATH,\n legalDocumentsResponseSchema,\n )\n return body.documents\n }\n\n /**\n * `GET /api/public/site-info`. Reception hours + notice ticker; a singleton,\n * so there is nothing to order or paginate.\n *\n * Returns the whole envelope rather than one key, because both halves are the\n * payload and neither is meaningful without the other — a caller deciding\n * whether this response is usable at all has to see both (D027).\n */\n async fetchSiteInfo(): Promise<SiteInfoResponse> {\n return await this.#get(SITE_INFO_RESOURCE, SITE_INFO_PATH, siteInfoResponseSchema)\n }\n\n // Generic over the PARSED type rather than over the schema: a bare\n // `S extends z.ZodType` defaults its type parameters to `any`, so\n // `safeParse(...).data` would come back `any` and every caller would silently\n // lose the contract this method exists to enforce. `z.ZodType<T>` is the\n // CLAUDE.md-sanctioned form — the defaults handle the internal parameters.\n async #get<T>(resource: SyncResource, path: string, schema: z.ZodType<T>): Promise<T> {\n // Resolved against the configured origin so a path can never escape it.\n const url = new URL(path, this.#baseUrl)\n\n // The queue has NO per-job timeout (R012 §3): a hung fetch would hold a\n // concurrency slot until `jobLockTimeout` (30 min) let lease recovery\n // re-claim the job, at which point the handler body would run TWICE,\n // concurrently. The deadline is what keeps that from being routine.\n const signal = AbortSignal.timeout(this.#timeoutMs)\n\n let response: Response\n try {\n response = await this.#fetch(url, {\n method: 'GET',\n headers: {\n authorization: `Bearer ${this.#apiKey}`,\n accept: 'application/json',\n },\n redirect: 'error',\n signal,\n })\n } catch (cause) {\n const timedOut = signal.aborted\n throw new YhikasUpstreamError(\n resource,\n timedOut ? 'timeout' : 'network',\n timedOut\n ? `yhikas-admin did not answer ${path} within ${this.#timeoutMs}ms`\n : `yhikas-admin was unreachable at ${path}`,\n { cause },\n )\n }\n\n if (!response.ok) {\n // Never log or echo the body: a refusal envelope is uninteresting and the\n // request carried a credential. Status alone distinguishes the cases that\n // matter — 401 (wrong or unset key), 429 (limiter), 5xx (upstream fault).\n await discardBody(response)\n throw new YhikasUpstreamError(\n resource,\n 'http',\n `yhikas-admin refused ${path} with HTTP ${response.status}`,\n { status: response.status },\n )\n }\n\n const declaredLength = Number(response.headers.get('content-length') ?? Number.NaN)\n if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {\n await discardBody(response)\n throw new YhikasUpstreamError(\n resource,\n 'shape',\n `yhikas-admin returned ${declaredLength} bytes for ${path}, above the ${MAX_RESPONSE_BYTES}-byte ceiling`,\n { status: response.status },\n )\n }\n\n // Read through the stream, counting bytes as they arrive, rather than\n // `response.text()`/`.json()`. The `content-length` check above is inert on\n // a chunked response — which is precisely the shape an unbounded body\n // arrives in — so buffering first and measuring after would apply the\n // ceiling to every case except the one it exists for.\n let body: string\n try {\n body = await readBounded(response, resource, path)\n } catch (cause) {\n if (cause instanceof YhikasUpstreamError) throw cause\n // `AbortSignal.timeout` aborts the body stream too, so a deadline that\n // elapses mid-read surfaces here rather than at the request. Reporting\n // it as a malformed body would point an operator at the wrong system.\n const timedOut = signal.aborted\n throw new YhikasUpstreamError(\n resource,\n timedOut ? 'timeout' : 'shape',\n timedOut\n ? `yhikas-admin did not finish sending ${path} within ${this.#timeoutMs}ms`\n : `${path} returned a body that could not be read`,\n { status: response.status, cause },\n )\n }\n\n let json: unknown\n try {\n json = JSON.parse(body)\n } catch (cause) {\n throw new YhikasUpstreamError(resource, 'shape', `${path} returned a body that is not JSON`, {\n status: response.status,\n cause,\n })\n }\n\n const parsed = schema.safeParse(json)\n if (!parsed.success) {\n // The issue paths are field names from OUR schema, never response values,\n // so this cannot leak content into a log line.\n const where = parsed.error.issues\n .slice(0, 5)\n .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)\n .join('; ')\n throw new YhikasUpstreamError(\n resource,\n 'shape',\n `${path} returned a body that does not match the expected contract — ${where}`,\n { status: response.status },\n )\n }\n return parsed.data\n }\n}\n","/**\n * The staleness watchdog — S7's \"and the staleness is OBSERVABLE\" half.\n *\n * ## Why this is new code\n *\n * The failure being defended against is not a crash. It is a job that silently\n * stops running while the site keeps serving last month's prices, indefinitely\n * and confidently — nothing about the rendered page would look wrong. The\n * queue cannot see that (R012 §1): its heartbeat is per worker PROCESS and\n * answers \"is any worker alive\", and its alerter fires only from `failJob`'s\n * dead-letter branch, i.e. only on a job that RAN and THREW. A job that never\n * starts produces no error at all.\n *\n * So detection is new. Delivery is not: this check runs as its own scheduled\n * job and THROWS, and the throw reaches `failJob` → `QueueAlerter.recordFailure`\n * → the shipped dedupe window, digest and email path. No alert channel is\n * invented.\n *\n * ## The limitation, stated rather than papered over\n *\n * The watchdog is itself a scheduled job, so a worker that is entirely dead\n * runs neither the sync nor the watchdog. That case is exactly what the queue's\n * worker heartbeat DOES see. The two are complementary: the heartbeat covers\n * \"no worker\", this covers \"worker alive, this schedule not firing\" — and\n * neither covers the other.\n */\n\nimport { SYNC_RESOURCES, type SyncResource } from './constants.js'\nimport type { SyncStateRecord } from './sync-state.js'\n\nexport interface ResourceStaleness {\n readonly resource: SyncResource\n /** `null` when this resource has never once synced successfully. */\n readonly lastSuccessAt: Date | null\n /** Age of the last success, or of the tracking row when there has never been one. */\n readonly ageMs: number\n readonly stale: boolean\n readonly lastError: string | null\n}\n\n/** Thrown to reach the queue's dead-letter alerting. */\nexport class YhikasSyncStaleError extends Error {\n readonly stale: readonly ResourceStaleness[]\n constructor(stale: readonly ResourceStaleness[], staleAfterMs: number) {\n const detail = stale.map(describeOne).join('; ')\n super(\n `yhikas-admin sync is stale beyond the ${formatAge(staleAfterMs)} window. ${detail}. ` +\n `The public site is serving content that old — this alert fires on ABSENCE of success, ` +\n `so there may be no failing job to look at.`,\n )\n this.name = 'YhikasSyncStaleError'\n this.stale = stale\n }\n}\n\nfunction describeOne(entry: ResourceStaleness): string {\n const suffix = entry.lastError ? ` — last error: ${entry.lastError}` : ''\n if (entry.lastSuccessAt) {\n return `${entry.resource}: last succeeded ${formatAge(entry.ageMs)} ago${suffix}`\n }\n // An infinite age is the no-tracking-row-at-all case. Rendering it through\n // `formatAge` would print \"Infinityd\", which reads as a bug in the alert\n // rather than as the loudest thing the alert has to say.\n if (!Number.isFinite(entry.ageMs)) {\n return `${entry.resource}: has NEVER synced — no tracking row exists at all${suffix}`\n }\n return `${entry.resource}: has NEVER succeeded (tracked for ${formatAge(entry.ageMs)})${suffix}`\n}\n\nfunction formatAge(ms: number): string {\n if (!Number.isFinite(ms)) return 'an unknown time'\n const hours = ms / 3_600_000\n if (hours < 1) return `${Math.round(ms / 60_000)}m`\n if (hours < 48) return `${Math.round(hours)}h`\n return `${Math.round(hours / 24)}d`\n}\n\n/**\n * Assess every in-scope resource.\n *\n * A resource with no state row at all counts as stale with an age of\n * `Infinity`: \"nothing has ever written this row\" is the loudest possible\n * version of \"this sync has never run\", and treating a missing row as\n * not-yet-stale would make an install that never once synced look healthy\n * forever — the exact unobserved-bound failure S7 names.\n *\n * A resource that HAS a row but no success is measured from `firstSeenAt`, so\n * a freshly installed sync gets one full window to succeed before it alerts.\n */\nexport function assessStaleness(\n records: readonly SyncStateRecord[],\n now: Date,\n staleAfterMs: number,\n): ResourceStaleness[] {\n const byResource = new Map(records.map((record) => [record.resource, record]))\n\n return SYNC_RESOURCES.map((resource) => {\n const record = byResource.get(resource)\n if (!record) {\n return {\n resource,\n lastSuccessAt: null,\n ageMs: Number.POSITIVE_INFINITY,\n stale: true,\n lastError: null,\n }\n }\n const since = record.lastSuccessAt ?? record.firstSeenAt\n const ageMs = now.getTime() - since.getTime()\n return {\n resource,\n lastSuccessAt: record.lastSuccessAt,\n ageMs,\n stale: ageMs > staleAfterMs,\n lastError: record.lastError,\n }\n })\n}\n\n/**\n * Assess, and throw if anything is stale.\n *\n * @throws {YhikasSyncStaleError} which the queue turns into a dead-lettered\n * job and therefore into the shipped alert.\n */\nexport function assertNotStale(\n records: readonly SyncStateRecord[],\n now: Date,\n staleAfterMs: number,\n): ResourceStaleness[] {\n const assessed = assessStaleness(records, now, staleAfterMs)\n const stale = assessed.filter((entry) => entry.stale)\n if (stale.length > 0) throw new YhikasSyncStaleError(stale, staleAfterMs)\n return assessed\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/yhikas-sync",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,12 +19,12 @@
19
19
  "dependencies": {
20
20
  "drizzle-orm": "^0.45.2",
21
21
  "zod": "^3.24.1",
22
- "@murumets-ee/blocks": "0.41.0",
23
- "@murumets-ee/content": "0.41.0",
24
- "@murumets-ee/core": "0.41.0",
25
- "@murumets-ee/db": "0.41.0",
26
- "@murumets-ee/entity": "0.41.0",
27
- "@murumets-ee/queue": "0.41.0"
22
+ "@murumets-ee/blocks": "0.42.0",
23
+ "@murumets-ee/core": "0.42.0",
24
+ "@murumets-ee/content": "0.42.0",
25
+ "@murumets-ee/db": "0.42.0",
26
+ "@murumets-ee/entity": "0.42.0",
27
+ "@murumets-ee/queue": "0.42.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^20.19.42",
@@ -1,2 +0,0 @@
1
- import{C as e,D as t,E as n,T as r,b as i,k as a,w as o,x as s}from"./sync-state-table-CM6eL5W9.mjs";import{createHash as c}from"node:crypto";import{z as l}from"zod";var u=class extends Error{counts;constructor(e,t){super(`${t.failed} of ${t.created+t.updated+t.retired+t.failed} ${e} writes failed. The run is NOT recorded as successful, so the staleness watchdog stays armed and the next run retries from scratch.`),this.name=`YhikasApplyPartialError`,this.counts=t}};function d(e){if(e instanceof Date)return Number.isNaN(e.getTime())?null:e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function f(e,t,n){return(await e.findMany({limit:n})).map(e=>({id:String(e.id),key:String(e[t]),status:String(e.status),sourceHash:typeof e.sourceHash==`string`?e.sourceHash:null,publishedAt:d(e.publishedAt)}))}async function p(e){let{resource:t,plan:n,client:r,secondaryLocale:i,logger:a}=e,o={created:0,updated:0,retired:0,unchanged:n.unchanged.length,failed:0};for(let e of n.create)try{let t=await r.create({...e.base,status:`draft`});await h(r,String(t.id),e,i),await m(r,String(t.id),e.hash,null),o.created+=1}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to create a synced row`)}for(let{local:e,row:s}of n.update)try{await r.update(e.id,{...s.base}),await h(r,e.id,s,i),await m(r,e.id,s.hash,e.publishedAt),o.updated+=1}catch(e){o.failed+=1,a.error({err:e,resource:t,key:s.key},`yhikas-sync: failed to update a synced row`)}for(let e of n.retire)try{await r.updateForLocale(e.id,{status:`draft`},i),await r.update(e.id,{status:`draft`}),o.retired+=1,a.warn({resource:t,key:e.key},`yhikas-sync: retiring a row that upstream no longer offers as publishable content`)}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to retire a synced row`)}if(o.failed>0)throw new u(t,o);return o}async function m(e,t,n,r){await e.update(t,{status:`published`,publishedAt:r??new Date,sourceHash:n})}async function h(e,t,n,r){if(n.secondary){await e.updateForLocale(t,{...n.secondary},r),await e.updateForLocale(t,{status:`published`},r);return}await e.updateForLocale(t,{status:`draft`},r),await e.deleteTranslation(t,r)}var g=class extends Error{resource;kind;status;constructor(e,t,n,r){super(n,r?.cause===void 0?void 0:{cause:r.cause}),this.name=`YhikasUpstreamError`,this.resource=e,this.kind=t,this.status=r?.status}},_=class extends Error{resource;reason;constructor(e,t,n){super(n),this.name=`YhikasSyncRefusedError`,this.resource=e,this.reason=t}};function v(e){return c(`sha256`).update(y(e)).digest(`hex`)}function y(e){return e===null?`null`:e===void 0?`undefined`:Array.isArray(e)?`[${e.map(y).join(`,`)}]`:typeof e==`object`?`{${Object.entries(e).filter(([,e])=>e!==void 0).sort(([e],[t])=>e<t?-1:+(e>t)).map(([e,t])=>`${JSON.stringify(e)}:${y(t)}`).join(`,`)}}`:JSON.stringify(e)}function b(e){let{resource:t,upstream:n,local:r,keyOf:i,hashOf:a,floor:o}=e;if(n.length>o.maxRows)throw new _(t,`oversized-snapshot`,`yhikas-admin returned ${n.length} ${t} rows, above the ${o.maxRows} ceiling. Refusing the run rather than processing a prefix — a truncated snapshot would read as ${n.length-o.maxRows} deletions.`);let s=new Map;for(let e of n){let n=i(e);if(s.has(n))throw new _(t,`duplicate-key`,`yhikas-admin returned two ${t} rows with the same business key '${n}'. That key is unique upstream, so the response is not a faithful snapshot.`);s.set(n,e)}let c=new Map;for(let e of r){if(c.has(e.key))throw new _(t,`duplicate-key`,`Local ${t} content holds two rows keyed '${e.key}'. Refusing to guess which is authoritative.`);c.set(e.key,e)}let l=[],u=[],d=[];for(let[e,t]of s){let n=c.get(e);if(!n){l.push(t);continue}n.sourceHash===a(t)&&n.status===`published`?d.push(n):u.push({local:n,row:t})}let f=r.filter(e=>e.status===`published`&&!s.has(e.key));return x(t,n.length,r,f,o),{create:l,update:u,unchanged:d,retire:f}}function x(e,t,n,r,i){if(r.length===0)return;if(t===0)throw new _(e,`empty-snapshot`,`yhikas-admin returned zero ${e} rows while ${r.length} are published locally. Refusing to retire content on the strength of an empty snapshot — that is how a partial upstream response becomes a wiped price sheet.`);let a=n.filter(e=>e.status===`published`).length;if(a<i.minRowsForFraction)return;let o=r.length/a;if(o>i.maxRetireFraction)throw new _(e,`retire-fraction`,`This run would retire ${r.length} of ${a} published ${e} rows (${Math.round(o*100)}%), above the ${Math.round(i.maxRetireFraction*100)}% floor. At this change rate a run proposing to retire most of the set is far likelier to be a bug than a business event.`)}function S(e,t){if(t.trim().length===0)throw TypeError(`yhikas-sync: defaultLocale must be a non-empty locale code`);return{findMany:t=>e.findMany(t),create:t=>e.create(t),update:(t,n)=>e.update(t,n),updateForLocale:(n,r,i)=>e.updateForLocale(n,r,i,{defaultLocale:t}),deleteTranslation:(t,n)=>e.deleteTranslation(t,n)}}const C=[`et`,`en`];function w(e){if(!C.includes(e))throw TypeError(`yhikas-sync: unsupported locale '${e}' — upstream carries only 'et' and 'en', and projecting one language's text under another locale would publish the wrong language while every write succeeded.`)}function T(e){return w(e),e===`et`?`en`:`et`}function E(e,t){return t===`et`?e.et:e.en}function D(e){return e.trim().length>0}const ee=/[\s:/\\<>"'`?#&%]/;function te(e){return!ee.test(e)&&!/[\u0000-\u001f\u007f]/.test(e)}function O(e,t){w(t.defaultLocale);let n=T(t.defaultLocale),r=[],i=[];for(let a of e){if(a.code.length>190){i.push({key:a.code.slice(0,80),reason:`upstream code exceeds the 190-character column`});continue}let e=E(a.name,t.defaultLocale);if(!D(e)){i.push({key:a.code,reason:`no '${t.defaultLocale}' name upstream — a room type with no name in the site's primary language has nothing publishable to render`});continue}let o=E(a.name,n),s={code:a.code,name:e,totalArea:a.totalArea,livingArea:a.livingArea,commonArea:a.commonArea,capacity:a.capacity,placesOccupied:a.placesOccupied,monthlyRent:a.monthlyRent,discountedMonthlyRent:a.discountedRent,dailyRent:a.dailyRent,currency:`EUR`,vatTreatment:`net`,hasEnglish:D(a.name.en)},c=D(o)?{name:o}:null;r.push({key:a.code,base:s,secondary:c,hash:M(s,c)})}return{projected:r,skipped:i}}function k(e,t,n){w(t.defaultLocale);let r=T(t.defaultLocale),i=[],a=[];for(let o of e){let e=E(o.title,t.defaultLocale),s=n(E(j(o),t.defaultLocale));if(!D(e)||!D(s)){a.push({key:o.type,reason:`no publishable '${t.defaultLocale}' title or body upstream`});continue}if(o.type.length>190||o.slug.length>190){a.push({key:o.type.slice(0,80),reason:`upstream type or slug exceeds the 190-character column — refusing this row rather than letting the write fail and hold the whole resource in failure`});continue}if(!te(o.slug)){a.push({key:o.type,reason:`upstream slug ${JSON.stringify(o.slug)} is not usable as an anchor fragment — it carries a scheme, a path separator, whitespace or markup. Refusing this row rather than the whole response: upstream derives the slug from an unvalidated free-text field, so one bad value must not take the resource offline`});continue}let c=E(o.title,r),l=n(E(j(o),r)),u=n(o.htmlContentEn),d={type:o.type,title:e,sourceSlug:o.slug,body:s,order:o.order,hasEnglish:D(o.title.en)&&D(u)},f=D(c)&&D(l)?{title:c,body:l}:null;i.push({key:o.type,base:d,secondary:f,hash:M(d,f)})}return{projected:i,skipped:a}}function A(e,n){w(n.defaultLocale);let i=T(n.defaultLocale),a=e.notices.filter(e=>e.isActive).sort((e,t)=>e.order-t.order),o=a.slice(0,25),s=[];a.length>o.length&&s.push({key:r,reason:`${a.length-o.length} active notice(s) beyond the 25 ticker ceiling were dropped — a marquee past a couple of dozen entries is not read, and an unbounded array from upstream would ride onto every page of the site`});let c=e=>o.map(t=>E(t.text,e)).filter(D),l=E(e.receptionHours,n.defaultLocale),u=c(n.defaultLocale);if(!D(l)&&u.length===0)throw new _(t,`empty-content`,`yhikas-admin returned a well-formed site-info response carrying no usable '${n.defaultLocale}' content: reception hours are empty, and of ${e.notices.length} notice(s) upstream, ${a.length} are active and ${u.length} have '${n.defaultLocale}' text. (Those three counts are reported separately on purpose — "no active notices" and "active notices with no text in this language" send an operator to different places.) That state is exactly what the endpoint returns when no row exists at all, and it is indistinguishable from a deliberate clearing — so it is refused rather than applied. Local content is unchanged and the staleness watchdog stays armed. Note this refusal is TERMINAL, not transient: it will repeat every run until upstream carries something. If the intent really was to clear everything, leave one of the two set.`);let d=E(e.receptionHours,i),f=c(i),p=E(e.receptionHours,`en`),m=c(`en`),h={key:r,receptionHours:l.trim(),notices:u,hasEnglish:D(p)||m.length>0},g=D(d)||f.length>0?{receptionHours:d.trim(),notices:f}:null;return{projected:[{key:r,base:h,secondary:g,hash:M(h,g)}],skipped:s}}function j(e){return{et:e.htmlContentEt,en:e.htmlContentEn}}function M(e,t){return v({base:e,secondary:t})}var N=class extends Error{summary;constructor(e){let t=e.outcomes.filter(e=>!e.ok);super(`yhikas-admin sync failed for ${t.map(e=>e.resource).join(`, `)}: `+t.map(e=>e.error).join(` | `)),this.name=`YhikasSyncRunError`,this.summary=e}};async function P(e){let n=e.now??(()=>new Date),r=T(e.defaultLocale),i=[];i.push(await F({resource:o,keyField:`code`,client:e.roomTypeClient,fetchAndProject:async()=>O(await e.upstream.fetchRoomTypes(),{defaultLocale:e.defaultLocale}),deps:e,secondaryLocale:r,now:n})),i.push(await F({resource:s,keyField:`type`,client:e.legalDocumentClient,fetchAndProject:async()=>k(await e.upstream.fetchLegalDocuments(),{defaultLocale:e.defaultLocale},e.sanitizeHtml),deps:e,secondaryLocale:r,now:n})),i.push(await F({resource:t,keyField:`key`,client:e.siteInfoClient,fetchAndProject:async()=>A(await e.upstream.fetchSiteInfo(),{defaultLocale:e.defaultLocale}),deps:e,secondaryLocale:r,now:n}));let a={outcomes:i,ok:i.every(e=>e.ok)};if(!a.ok)throw new N(a);return a}async function F(e){let{resource:t,keyField:n,client:r,fetchAndProject:i,deps:a,secondaryLocale:o,now:s}=e,{logger:c,state:l,floor:u}=a;try{await l.ensure(t,s()),await l.recordAttempt(t,s());let e=await i();for(let n of e.skipped.slice(0,20))c.warn({resource:t,key:n.key,reason:n.reason},`yhikas-sync: skipping an upstream row, or part of one, that cannot be published`);e.skipped.length>20&&c.warn({resource:t,suppressed:e.skipped.length-20,total:e.skipped.length},`yhikas-sync: further skipped rows not logged individually`);let a=await f(r,n,u.maxRows),d=await p({resource:t,plan:b({resource:t,upstream:e.projected,local:a,keyOf:e=>e.key,hashOf:e=>e.hash,floor:u}),client:r,secondaryLocale:o,logger:c});return await l.recordSuccess(t,s(),d),c.info({resource:t,...d,skipped:e.skipped.length},`yhikas-sync: resource synced`),{resource:t,ok:!0,counts:d,skipped:e.skipped.length,error:null}}catch(e){let n=e instanceof Error?e.message:String(e);c.error({err:e,resource:t},`yhikas-sync: resource sync failed — local content unchanged`);try{await l.recordFailure(t,s(),n)}catch(e){c.error({err:e,resource:t},`yhikas-sync: could not record the failure in yhikas_sync_state`)}return{resource:t,ok:!1,counts:null,skipped:0,error:n}}}const I=1024;function L(e){return e.length<=I?e:`${e.slice(0,I-1)}…`}function R(e){return{async ensure(t,n){await e.upsert({resource:t,firstSeenAt:n},{target:`resource`,set:{resource:t}})},async recordAttempt(t,n){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n},{target:`resource`,set:{lastAttemptAt:n}})},async recordSuccess(t,n,r){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged},{target:`resource`,set:{lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged}})},async recordFailure(t,n,r){let i=L(r);await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastError:i},{target:`resource`,set:{lastAttemptAt:n,lastError:i}})},async readAll(){return(await e.findMany({where:{resource:{in:[...a]}},limit:a.length})).map(e=>({resource:String(e.resource),firstSeenAt:e.firstSeenAt,lastAttemptAt:e.lastAttemptAt??null,lastSuccessAt:e.lastSuccessAt??null,lastError:e.lastError??null}))}}}const z=2e3,B=512*1024,V=l.string().max(32).regex(/^-?\d+(\.\d+)?$/,`expected a decimal literal, e.g. "180.00"`),H=l.object({et:l.string().max(500),en:l.string().max(500)}),U=l.object({code:l.string().min(1).max(z),name:H,totalArea:V.nullable(),livingArea:V.nullable(),commonArea:V.nullable(),capacity:l.number().int().nullable(),monthlyRent:V.nullable(),discountedRent:V.nullable(),dailyRent:V.nullable(),placesOccupied:l.number().int().nullable()}),W=l.object({type:l.string().min(1).max(z),title:H,slug:l.string().min(1).max(z),htmlContentEt:l.string().max(B),htmlContentEn:l.string().max(B),order:l.number().int()}),G=l.object({success:l.literal(!0),roomTypes:l.array(U)}),K=l.object({success:l.literal(!0),documents:l.array(W)}),q=l.object({text:H,isActive:l.boolean(),order:l.number().int()}),J=l.object({success:l.literal(!0),receptionHours:H,notices:l.array(q).max(1e3)}),Y=8*1024*1024;async function ne(e,t,n){let r=e.body;if(!r)return``;let i=r.getReader(),a=new TextDecoder,o=0,s=``;try{for(;;){let{done:r,value:c}=await i.read();if(r)break;if(o+=c.byteLength,o>Y)throw await i.cancel(),new g(t,`shape`,`yhikas-admin sent more than ${Y} bytes for ${n}, above the ceiling — the read was aborted mid-stream rather than buffered and measured after`,{status:e.status});s+=a.decode(c,{stream:!0})}return s+a.decode()}finally{i.releaseLock()}}async function X(e){try{await e.body?.cancel()}catch{}}var re=class{#e;#t;#n;#r;constructor(e){let t;try{t=new URL(e.baseUrl)}catch(t){throw TypeError(`yhikas-sync: baseUrl is not a valid URL: ${e.baseUrl}`,{cause:t})}if(t.protocol!==`http:`&&t.protocol!==`https:`)throw TypeError(`yhikas-sync: baseUrl must be http or https, got '${t.protocol}'. A file: or data: origin here would be a way to feed the sync a local snapshot.`);if(t.pathname!==`/`)throw TypeError(`yhikas-sync: baseUrl must be an origin with no path, got '${t.pathname}'. The upstream routes are absolute, so a base path would be silently discarded.`);if(e.apiKey.length===0)throw TypeError(`yhikas-sync: apiKey must not be empty`);this.#e=t,this.#t=e.apiKey,this.#n=e.timeoutMs,this.#r=e.fetchImpl??globalThis.fetch}async fetchRoomTypes(){return(await this.#i(o,e,G)).roomTypes}async fetchLegalDocuments(){return(await this.#i(s,i,K)).documents}async fetchSiteInfo(){return await this.#i(t,n,J)}async#i(e,t,n){let r=new URL(t,this.#e),i=AbortSignal.timeout(this.#n),a;try{a=await this.#r(r,{method:`GET`,headers:{authorization:`Bearer ${this.#t}`,accept:`application/json`},redirect:`error`,signal:i})}catch(n){let r=i.aborted;throw new g(e,r?`timeout`:`network`,r?`yhikas-admin did not answer ${t} within ${this.#n}ms`:`yhikas-admin was unreachable at ${t}`,{cause:n})}if(!a.ok)throw await X(a),new g(e,`http`,`yhikas-admin refused ${t} with HTTP ${a.status}`,{status:a.status});let o=Number(a.headers.get(`content-length`)??NaN);if(Number.isFinite(o)&&o>Y)throw await X(a),new g(e,`shape`,`yhikas-admin returned ${o} bytes for ${t}, above the ${Y}-byte ceiling`,{status:a.status});let s;try{s=await ne(a,e,t)}catch(n){if(n instanceof g)throw n;let r=i.aborted;throw new g(e,r?`timeout`:`shape`,r?`yhikas-admin did not finish sending ${t} within ${this.#n}ms`:`${t} returned a body that could not be read`,{status:a.status,cause:n})}let c;try{c=JSON.parse(s)}catch(n){throw new g(e,`shape`,`${t} returned a body that is not JSON`,{status:a.status,cause:n})}let l=n.safeParse(c);if(!l.success)throw new g(e,`shape`,`${t} returned a body that does not match the expected contract — ${l.error.issues.slice(0,5).map(e=>`${e.path.join(`.`)||`<root>`}: ${e.message}`).join(`; `)}`,{status:a.status});return l.data}},Z=class extends Error{stale;constructor(e,t){let n=e.map(ie).join(`; `);super(`yhikas-admin sync is stale beyond the ${Q(t)} window. ${n}. The public site is serving content that old — this alert fires on ABSENCE of success, so there may be no failing job to look at.`),this.name=`YhikasSyncStaleError`,this.stale=e}};function ie(e){let t=e.lastError?` — last error: ${e.lastError}`:``;return e.lastSuccessAt?`${e.resource}: last succeeded ${Q(e.ageMs)} ago${t}`:Number.isFinite(e.ageMs)?`${e.resource}: has NEVER succeeded (tracked for ${Q(e.ageMs)})${t}`:`${e.resource}: has NEVER synced — no tracking row exists at all${t}`}function Q(e){if(!Number.isFinite(e))return`an unknown time`;let t=e/36e5;return t<1?`${Math.round(e/6e4)}m`:t<48?`${Math.round(t)}h`:`${Math.round(t/24)}d`}function $(e,t,n){let r=new Map(e.map(e=>[e.resource,e]));return a.map(e=>{let i=r.get(e);if(!i)return{resource:e,lastSuccessAt:null,ageMs:1/0,stale:!0,lastError:null};let a=i.lastSuccessAt??i.firstSeenAt,o=t.getTime()-a.getTime();return{resource:e,lastSuccessAt:i.lastSuccessAt,ageMs:o,stale:o>n,lastError:i.lastError}})}function ae(e,t,n){let r=$(e,t,n),i=r.filter(e=>e.stale);if(i.length>0)throw new Z(i,n);return r}export{v as C,p as D,u as E,f as O,b as S,g as T,k as _,W as a,T as b,U as c,q as d,R as f,C as g,P as h,re as i,G as l,N as m,ae as n,K as o,L as p,$ as r,H as s,Z as t,J as u,O as v,_ as w,S as x,A as y};
2
- //# sourceMappingURL=watchdog-BVf1F4sR.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"watchdog-BVf1F4sR.mjs","names":["#baseUrl","#apiKey","#timeoutMs","#fetch","#get"],"sources":["../src/apply.ts","../src/upstream/errors.ts","../src/diff.ts","../src/entity-client.ts","../src/projection.ts","../src/run-sync.ts","../src/sync-state.ts","../src/upstream/wire.ts","../src/upstream/client.ts","../src/watchdog.ts"],"sourcesContent":["/**\n * Applying a diff plan through `AdminClient`.\n *\n * Writes go through the normal entity path — hooks, validation, audit logging\n * — so the sync's own writes are attributable, which is worth having precisely\n * because the UPSTREAM writes are not (F006: `addRoomType`/`updateRoomType`/\n * `deleteRoomType` skip `requireAdmin()`, with no audit log and no timestamp\n * to reconstruct from).\n *\n * ## Bounded, and bounded at one\n *\n * Rows are written sequentially. CLAUDE.md's fan-out rule wants a concurrency\n * bound and a total cap, and \"it's only N today\" is explicitly not a defence\n * — so rather than a semaphore the shape is simply serial, which is the\n * tightest bound available. At ~25 rows every six hours that costs nothing,\n * keeps the run from adding a burst to a connection pool shared with the whole\n * app, and makes the audit log read in a deterministic order. The total cap\n * lives in the differ, which REFUSES an oversized snapshot rather than\n * truncating it.\n *\n * ## Partial application is tolerated, silent partial application is not\n *\n * A per-row failure is caught, logged and counted, and the remaining rows are\n * still applied — one malformed document should not hold back 24 correct price\n * updates. But the run then THROWS at the end, so `lastSuccessAt` is not\n * advanced and the staleness watchdog stays armed. Retries re-run the whole\n * handler from scratch (the queue has no checkpointing primitive), which is\n * safe here because every write is an upsert against a business key.\n */\n\nimport type { SyncResource } from './constants.js'\nimport type { DiffPlan, LocalRow } from './diff.js'\nimport type { ProjectedRow } from './projection.js'\n\n/**\n * The `AdminClient` surface the sync uses, structurally.\n *\n * Declared rather than imported so the apply logic is unit-testable with a\n * fake — CI runs unit tests only, with no database, so a design that could\n * only be exercised by an integration test would in practice be exercised by\n * nothing.\n */\nexport interface SyncEntityClient {\n findMany(options: { limit: number }): Promise<Record<string, unknown>[]>\n create(data: Record<string, unknown>): Promise<Record<string, unknown>>\n update(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>\n updateForLocale(\n id: string,\n data: Record<string, unknown>,\n locale: string,\n ): Promise<Record<string, unknown>>\n deleteTranslation(id: string, locale: string): Promise<void>\n}\n\nexport interface SyncLogger {\n info(obj: Record<string, unknown>, msg: string): void\n warn(obj: Record<string, unknown>, msg: string): void\n error(obj: Record<string, unknown>, msg: string): void\n}\n\nexport interface ApplyCounts {\n created: number\n updated: number\n retired: number\n unchanged: number\n failed: number\n}\n\n/** Thrown when at least one row failed; carries the counts that were achieved. */\nexport class YhikasApplyPartialError extends Error {\n readonly counts: ApplyCounts\n constructor(resource: SyncResource, counts: ApplyCounts) {\n super(\n `${counts.failed} of ${counts.created + counts.updated + counts.retired + counts.failed} ` +\n `${resource} writes failed. The run is NOT recorded as successful, so the staleness ` +\n `watchdog stays armed and the next run retries from scratch.`,\n )\n this.name = 'YhikasApplyPartialError'\n this.counts = counts\n }\n}\n\n/**\n * Read every local row of one synced entity, reduced to what the differ needs.\n *\n * `limit` is the same ceiling the differ refuses above, so a local set that\n * has somehow outgrown it is truncated HERE — which would present the missing\n * tail as absent-locally and re-create it, hitting the unique index rather\n * than silently duplicating. Not silent, but not pretty either; the ceiling is\n * three orders of magnitude above the real row count.\n */\n/**\n * Normalize a timestamp that may arrive as a `Date` or as an ISO string.\n *\n * Coercing an unrecognised shape to `null` is not neutral here: `commitRow`\n * back-fills `publishedAt` when it is absent, so a string that read as `null`\n * would silently replace the original first-publish date on every single\n * update, turning the column into \"last touched by the sync\".\n */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\nexport async function readLocalRows(\n client: SyncEntityClient,\n keyField: string,\n limit: number,\n): Promise<LocalRow[]> {\n const rows = await client.findMany({ limit })\n return rows.map((row) => ({\n id: String(row.id),\n key: String(row[keyField]),\n status: String(row.status),\n sourceHash: typeof row.sourceHash === 'string' ? row.sourceHash : null,\n publishedAt: toDate(row.publishedAt),\n }))\n}\n\nexport interface ApplyPlanInput {\n readonly resource: SyncResource\n readonly plan: DiffPlan<ProjectedRow>\n readonly client: SyncEntityClient\n /** The locale that is NOT on the base row — the one whose publish state is toggled. */\n readonly secondaryLocale: string\n readonly logger: SyncLogger\n}\n\nexport async function applyPlan(input: ApplyPlanInput): Promise<ApplyCounts> {\n const { resource, plan, client, secondaryLocale, logger } = input\n const counts: ApplyCounts = {\n created: 0,\n updated: 0,\n retired: 0,\n unchanged: plan.unchanged.length,\n failed: 0,\n }\n\n for (const row of plan.create) {\n try {\n // Created as a DRAFT, deliberately. Publishing before the secondary\n // locale is written would expose a window — and, if that write then\n // failed, a permanent state — in which the English URL is live and\n // falls back to the Estonian text, because the public read predicate is\n // `COALESCE(locale_status.status, main.status)` and an absent locale row\n // inherits the base row's `published`. Invisible beats wrong.\n const created = await client.create({ ...row.base, status: 'draft' })\n await writeSecondaryLocale(client, String(created.id), row, secondaryLocale)\n await commitRow(client, String(created.id), row.hash, null)\n counts.created += 1\n } catch (err) {\n counts.failed += 1\n logger.error({ err, resource, key: row.key }, 'yhikas-sync: failed to create a synced row')\n }\n }\n\n for (const { local, row } of plan.update) {\n try {\n // NOTE the absence of `sourceHash` here — see `commitRow`.\n await client.update(local.id, { ...row.base })\n await writeSecondaryLocale(client, local.id, row, secondaryLocale)\n await commitRow(client, local.id, row.hash, local.publishedAt)\n counts.updated += 1\n } catch (err) {\n counts.failed += 1\n logger.error({ err, resource, key: row.key }, 'yhikas-sync: failed to update a synced row')\n }\n }\n\n for (const local of plan.retire) {\n try {\n // Unpublished, NEVER deleted (D016). The row and its audit trail stay,\n // and a wrongly-retired row heals automatically on the next successful\n // run — the differ treats a drafted row with a matching hash as an\n // update precisely so that re-publishing happens without operator action.\n // The LOCALE goes first, for the same reason `writeSecondaryLocale`\n // orders its writes the way it does — and here the cost of getting it\n // wrong is permanent rather than transient. Drafting the base row first\n // and then failing to draft the locale leaves the locale `published`\n // with the base row already `draft`; `planDiff` only ever selects\n // retirement candidates whose `status === 'published'`, so that row\n // never enters `plan.retire` again and the secondary locale serves\n // retired content indefinitely. This order fails safe: an error after\n // the locale unpublish leaves the base row published, so the row is\n // still a candidate on the next run.\n await client.updateForLocale(local.id, { status: 'draft' }, secondaryLocale)\n await client.update(local.id, { status: 'draft' })\n counts.retired += 1\n logger.warn(\n { resource, key: local.key },\n // Not \"no longer present upstream\": a row also lands here when it IS\n // present but was skipped as unpublishable (an empty default-locale\n // name). Naming only the first cause would send an operator looking\n // for a deletion that never happened.\n 'yhikas-sync: retiring a row that upstream no longer offers as publishable content',\n )\n } catch (err) {\n counts.failed += 1\n logger.error({ err, resource, key: local.key }, 'yhikas-sync: failed to retire a synced row')\n }\n }\n\n if (counts.failed > 0) throw new YhikasApplyPartialError(resource, counts)\n return counts\n}\n\n/**\n * Publish the row and stamp its `sourceHash` — the LAST write for a row, and\n * the only one that records \"this row now matches upstream\".\n *\n * Committing the hash alongside the base fields would be a durable lie the\n * moment any later write for the row failed: the hash covers the whole\n * projected payload including the secondary locale, so the differ would\n * classify the row `unchanged` on every subsequent run, the run would report\n * success, and the incomplete row would never be retried. Writing it last\n * makes a partial failure self-healing — the stored hash still describes the\n * previous state, so the next run sees a difference and redoes the row.\n *\n * `publishedAt` is preserved when the row already had one. `publishable()`\n * back-fills it whenever a payload sets `status: 'published'` without it,\n * which would otherwise turn \"first published\" into \"last touched by the\n * sync\" — a value that moves every time a price changes.\n */\nasync function commitRow(\n client: SyncEntityClient,\n id: string,\n hash: string,\n publishedAt: Date | null,\n): Promise<void> {\n await client.update(id, {\n status: 'published',\n publishedAt: publishedAt ?? new Date(),\n sourceHash: hash,\n })\n}\n\n/**\n * Write — or suppress — the non-default locale.\n *\n * When the upstream text is present, its translation row is written and the\n * locale is published in one call: `updateForLocale` splits the payload,\n * routing `status` to `<entity>_locale_status` and the translatable fields to\n * `<entity>_translations`.\n *\n * When it is absent, the locale is set to `draft` FIRST and only then is the\n * previous translation deleted. The order is load-bearing and was originally\n * the other way round: `deleteTranslation` does not touch\n * `<entity>_locale_status`, so deleting first and then failing to unpublish\n * leaves a locale marked `published` with no translation row — and the merged\n * read falls back to the base row, i.e. the English URL serves Estonian text,\n * live. Unpublishing first degrades to stale-but-hidden instead.\n *\n * Deleting at all still matters: leaving stale English text in the\n * translations table, merely unpublished, keeps a copy that any future read\n * path forgetting the publish filter could serve. The unpublish is the\n * control; the delete removes the thing the control is protecting — so the\n * control goes on first.\n */\nasync function writeSecondaryLocale(\n client: SyncEntityClient,\n id: string,\n row: ProjectedRow,\n secondaryLocale: string,\n): Promise<void> {\n if (row.secondary) {\n // Two calls, not one, and in this order for the same reason the branch\n // below is ordered the way it is. `updateForLocale` writes the locale\n // STATUS before the translation when handed both in one payload, so a\n // combined call that failed halfway would mark the locale published with\n // no translation row behind it — and the merged read then falls back to\n // the base row, i.e. the English URL serving Estonian text, live.\n //\n // Writing the translation first and the status second means a failure\n // between them leaves the locale unpublished with correct content waiting\n // — invisible, and healed by the next run.\n await client.updateForLocale(id, { ...row.secondary }, secondaryLocale)\n await client.updateForLocale(id, { status: 'published' }, secondaryLocale)\n return\n }\n await client.updateForLocale(id, { status: 'draft' }, secondaryLocale)\n await client.deleteTranslation(id, secondaryLocale)\n}\n","/**\n * Every way a snapshot fetch can fail to be authoritative.\n *\n * The distinction this file exists to preserve: a refusal, a timeout and a\n * malformed body are all \"we do not know what is upstream\", and none of them\n * is \"upstream is empty\". Collapsing them is how a partial response becomes a\n * wiped price sheet, so they are typed and they all abort the run.\n */\n\nimport type { SyncResource } from '../constants.js'\n\nexport type UpstreamFailureKind =\n /** DNS failure, connection refused, TLS error — the request never completed. */\n | 'network'\n /** The per-request deadline elapsed. The queue has no per-job timeout (R012 §3). */\n | 'timeout'\n /** A completed response the sync will not act on: 401, 429, 5xx, or any non-2xx. */\n | 'http'\n /** A 2xx whose body failed the wire schema — including `success: false`. */\n | 'shape'\n\nexport class YhikasUpstreamError extends Error {\n readonly resource: SyncResource\n readonly kind: UpstreamFailureKind\n readonly status: number | undefined\n\n constructor(\n resource: SyncResource,\n kind: UpstreamFailureKind,\n message: string,\n options?: { status?: number; cause?: unknown },\n ) {\n super(message, options?.cause === undefined ? undefined : { cause: options.cause })\n this.name = 'YhikasUpstreamError'\n this.resource = resource\n this.kind = kind\n this.status = options?.status\n }\n}\n\n/**\n * Thrown when a snapshot IS authoritative but applying it would be reckless —\n * the D016 guards. Separate from {@link YhikasUpstreamError} because the\n * remedies differ: an upstream failure usually resolves itself on the next\n * run, whereas this one wants a human to look at why the source shrank.\n */\nexport class YhikasSyncRefusedError extends Error {\n readonly resource: SyncResource\n readonly reason:\n | 'empty-snapshot'\n | 'retire-fraction'\n | 'oversized-snapshot'\n | 'duplicate-key'\n /**\n * A well-formed 200 whose CONTENT is entirely empty (D027).\n *\n * Distinct from `empty-snapshot`, which is a row-COUNT test and therefore\n * cannot see this: `site_info` always projects exactly one row, so the\n * count is 1 whether or not that row says anything. `/api/public/site-info`\n * answers 200 with empty strings and no notices when no upstream row\n * exists, which is byte-identical to a deliberate clearing (F015) — so the\n * only safe reading of an all-empty payload is \"not authoritative\", and the\n * only safe response is to leave local content alone and let the watchdog\n * arm.\n */\n | 'empty-content'\n\n constructor(resource: SyncResource, reason: YhikasSyncRefusedError['reason'], message: string) {\n super(message)\n this.name = 'YhikasSyncRefusedError'\n this.resource = resource\n this.reason = reason\n }\n}\n","/**\n * The full-snapshot differ — a pure function, so every guard in it is testable\n * without a database, an HTTP server or a queue.\n *\n * Full-snapshot rather than incremental is FORCED, not chosen: `room_type`\n * carries no timestamp of any kind, and `legal_document`'s `updated_at` is not\n * in the API response. There is no cursor, no high-water mark and no change\n * feed, so the only available shape is fetch-everything-and-compare. At ~25\n * room types and a handful of documents that is trivially cheap.\n *\n * Identity is the business key — `room_type.code`, `legal_document.type` —\n * never the serial `id`, which is an implementation detail of the other system\n * and would make this system's content depend on the other's insert order.\n */\n\nimport { createHash } from 'node:crypto'\nimport type { SyncResource } from './constants.js'\nimport { YhikasSyncRefusedError } from './upstream/errors.js'\n\n/** One local row, reduced to what the diff needs. */\nexport interface LocalRow {\n readonly id: string\n /** The business key this row was synced under. */\n readonly key: string\n readonly status: string\n /**\n * `null` on a row whose last sync did not complete — the hash is written\n * LAST, after every other write for the row succeeded, so a null (or stale)\n * hash is exactly the signal that the row needs redoing.\n */\n readonly sourceHash: string | null\n /** Preserved across updates so it keeps meaning \"first published\". */\n readonly publishedAt: Date | null\n}\n\nexport interface DiffPlan<T> {\n /** Upstream rows with no local counterpart. */\n readonly create: readonly T[]\n /** Local rows whose hash differs, OR whose status drifted from `published`. */\n readonly update: readonly { readonly local: LocalRow; readonly row: T }[]\n /** Local rows already identical and already published — no write at all. */\n readonly unchanged: readonly LocalRow[]\n /** Published locally, absent upstream. Unpublished, never deleted (D016). */\n readonly retire: readonly LocalRow[]\n}\n\nexport interface SanityFloor {\n /** Refuse a snapshot larger than this rather than truncating it. */\n readonly maxRows: number\n /** Refuse a run retiring more than this fraction of published rows. */\n readonly maxRetireFraction: number\n /** The fraction rule applies only once at least this many rows are published. */\n readonly minRowsForFraction: number\n}\n\nexport interface PlanDiffInput<T> {\n readonly resource: SyncResource\n readonly upstream: readonly T[]\n readonly local: readonly LocalRow[]\n readonly keyOf: (row: T) => string\n readonly hashOf: (row: T) => string\n readonly floor: SanityFloor\n}\n\n/**\n * Stable content hash of an upstream row.\n *\n * Keys are sorted so the hash does not depend on JSON property order, which no\n * part of the HTTP stack guarantees. `undefined` and `null` are distinguished\n * because a null price is meaningful data here, not an absence.\n */\nexport function stableHash(value: unknown): string {\n return createHash('sha256').update(canonicalize(value)).digest('hex')\n}\n\nfunction canonicalize(value: unknown): string {\n if (value === null) return 'null'\n if (value === undefined) return 'undefined'\n if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`\n if (typeof value === 'object') {\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`)\n return `{${entries.join(',')}}`\n }\n return JSON.stringify(value)\n}\n\n/**\n * Compare an authoritative upstream snapshot against local state.\n *\n * **The caller must not invoke this with a snapshot that did not fully\n * succeed.** That guard lives one level up, in the client: a non-2xx, a\n * timeout or a body failing the wire schema throws before the differ is ever\n * reached, so a partial response can never present as an absence here. This\n * function's own guards are for a snapshot that IS authoritative but whose\n * shape makes acting on it reckless.\n *\n * @throws {YhikasSyncRefusedError} for any of the D016 refusals. Every one of\n * them aborts before a single write, so a refused run leaves local content\n * exactly as it was — which is the first of PR 05's three obligatory\n * negative tests.\n */\nexport function planDiff<T>(input: PlanDiffInput<T>): DiffPlan<T> {\n const { resource, upstream, local, keyOf, hashOf, floor } = input\n\n // An oversized snapshot is REFUSED, not truncated. Capping at N and\n // processing the first N would turn the dropped tail into apparent absences\n // and therefore into mass retirement — the bound CLAUDE.md requires, applied\n // the one way that is not itself a bug.\n if (upstream.length > floor.maxRows) {\n throw new YhikasSyncRefusedError(\n resource,\n 'oversized-snapshot',\n `yhikas-admin returned ${upstream.length} ${resource} rows, above the ${floor.maxRows} ceiling. ` +\n `Refusing the run rather than processing a prefix — a truncated snapshot would read as ` +\n `${upstream.length - floor.maxRows} deletions.`,\n )\n }\n\n const upstreamByKey = new Map<string, T>()\n for (const row of upstream) {\n const key = keyOf(row)\n if (upstreamByKey.has(key)) {\n throw new YhikasSyncRefusedError(\n resource,\n 'duplicate-key',\n `yhikas-admin returned two ${resource} rows with the same business key '${key}'. ` +\n `That key is unique upstream, so the response is not a faithful snapshot.`,\n )\n }\n upstreamByKey.set(key, row)\n }\n\n const localByKey = new Map<string, LocalRow>()\n for (const row of local) {\n if (localByKey.has(row.key)) {\n // A unique index makes this unreachable; if it happens the local state is\n // corrupt and picking one arbitrarily would quietly entrench the damage.\n throw new YhikasSyncRefusedError(\n resource,\n 'duplicate-key',\n `Local ${resource} content holds two rows keyed '${row.key}'. Refusing to guess which is ` +\n `authoritative.`,\n )\n }\n localByKey.set(row.key, row)\n }\n\n const create: T[] = []\n const update: { local: LocalRow; row: T }[] = []\n const unchanged: LocalRow[] = []\n\n for (const [key, row] of upstreamByKey) {\n const existing = localByKey.get(key)\n if (!existing) {\n create.push(row)\n continue\n }\n // Re-publishing a row that drifted to draft is an update even when the\n // content is byte-identical: that is how a wrongly-retired row heals on the\n // next successful run, with no operator action.\n if (existing.sourceHash === hashOf(row) && existing.status === 'published') {\n unchanged.push(existing)\n } else {\n update.push({ local: existing, row })\n }\n }\n\n const retire = local.filter((row) => row.status === 'published' && !upstreamByKey.has(row.key))\n\n assertRetirementIsPlausible(resource, upstream.length, local, retire, floor)\n\n return { create, update, unchanged, retire }\n}\n\n/**\n * The two refusal triggers of D016. They are independent because neither can\n * see the other's case:\n *\n * - The fraction rule is blind at small N — two published rows against an\n * empty snapshot is 100% retirement but never reaches `minRowsForFraction`,\n * and a freshly seeded install lives at exactly that size.\n * - The empty-snapshot rule is blind to a HALF-truncated response, which is\n * the shape a partial upstream failure actually produces.\n */\nfunction assertRetirementIsPlausible(\n resource: SyncResource,\n upstreamCount: number,\n local: readonly LocalRow[],\n retire: readonly LocalRow[],\n floor: SanityFloor,\n): void {\n if (retire.length === 0) return\n\n if (upstreamCount === 0) {\n throw new YhikasSyncRefusedError(\n resource,\n 'empty-snapshot',\n `yhikas-admin returned zero ${resource} rows while ${retire.length} are published locally. ` +\n `Refusing to retire content on the strength of an empty snapshot — that is how a partial ` +\n `upstream response becomes a wiped price sheet.`,\n )\n }\n\n const publishedCount = local.filter((row) => row.status === 'published').length\n if (publishedCount < floor.minRowsForFraction) return\n\n const fraction = retire.length / publishedCount\n if (fraction > floor.maxRetireFraction) {\n throw new YhikasSyncRefusedError(\n resource,\n 'retire-fraction',\n `This run would retire ${retire.length} of ${publishedCount} published ${resource} rows ` +\n `(${Math.round(fraction * 100)}%), above the ${Math.round(floor.maxRetireFraction * 100)}% floor. ` +\n `At this change rate a run proposing to retire most of the set is far likelier to be a bug ` +\n `than a business event.`,\n )\n }\n}\n","/**\n * Adapting a framework `AdminClient` to the structural {@link SyncEntityClient}\n * the sync logic is written against.\n *\n * Its own module, importing nothing at runtime (`import type` only), for one\n * reason: this is the seam where the sync meets the framework, and it is where\n * the worst defect in this package's history lived — `updateForLocale` was\n * called without `options.defaultLocale`, so every secondary-locale write threw\n * while the base write succeeded. The run failed once, the next run saw a\n * matching hash and reported success, and the English site served Estonian\n * text indefinitely. No test could see it because the seam sat inside a module\n * that pulls in `@murumets-ee/core`.\n *\n * Keeping it here makes the seam directly unit-testable with a plain object,\n * with no database and no app.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { SyncEntityClient } from './apply.js'\n\n/** The `AdminClient` methods the adapter forwards, structurally. */\nexport type AdminClientLike = ReturnType<ToolkitApp['getClient']>\n\n/**\n * @param defaultLocale The app's REAL default locale — resolved from\n * `@murumets-ee/content`, never configured. It is passed explicitly on every\n * `updateForLocale` call and is not optional, because\n * `elevateRequestContext` deliberately strips `locale`/`defaultLocale` from\n * the context it builds, and `updateForLocale` THROWS when it can resolve\n * the default locale from neither the options nor the context.\n */\nexport function toSyncEntityClient(\n client: AdminClientLike,\n defaultLocale: string,\n): SyncEntityClient {\n if (defaultLocale.trim().length === 0) {\n // `updateForLocale` resolves `options?.defaultLocale ?? context…`, and `??`\n // treats `''` — and `' '` — as present, so either sails past the throw\n // this module exists to avoid and reaches the write as a locale nobody\n // serves. Trimmed, so whitespace is not a way around the guard.\n throw new TypeError('yhikas-sync: defaultLocale must be a non-empty locale code')\n }\n return {\n findMany: (options) => client.findMany(options) as Promise<Record<string, unknown>[]>,\n // The payload casts are the established in-repo idiom at this exact seam —\n // `packages/blocks/src/server/routes/op-commit.ts` writes\n // `client.updateForLocale(id, data as never, locale, { tx })` for the same\n // reason: `InferUpdateInput<F>` is keyed on one entity's field map, which a\n // caller holding a plain record cannot be proven to satisfy. Threading the\n // entity's field generics through every sync module instead would make the\n // logic untestable without a live client.\n create: (data) => client.create(data as never) as Promise<Record<string, unknown>>,\n update: (id, data) => client.update(id, data as never) as Promise<Record<string, unknown>>,\n updateForLocale: (id, data, locale) =>\n client.updateForLocale(id, data as never, locale, { defaultLocale }) as Promise<\n Record<string, unknown>\n >,\n deleteTranslation: (id, locale) => client.deleteTranslation(id, locale),\n }\n}\n","/**\n * Upstream wire row → local entity payload. Pure, so every semantic decision\n * in here is testable without a database.\n *\n * Three things happen at this boundary and nowhere else:\n *\n * 1. **HTML is sanitized**, via an injected sanitizer. Injected rather than\n * imported so this module stays free of `@murumets-ee/blocks` — whose root\n * export evaluates React's `createContext` at module scope — and so a test\n * can assert that the sanitizer was actually applied rather than trusting\n * that it was.\n * 2. **Semantics the source does not carry are stamped on**: currency and VAT\n * treatment (see `constants.ts` for why the period is not one of them).\n * 3. **An empty locale is decided.** D015: no empty string is ever written as\n * a translation value, and a row whose DEFAULT-locale text is empty is\n * skipped entirely rather than published with a blank title.\n *\n * The hash is computed over the PROJECTED payload, not the raw upstream row.\n * That is deliberate: it means a change in our own sanitizer's allowlist, or\n * in a declared constant, also produces a different hash and therefore a\n * rewrite — so stored content cannot silently diverge from what today's code\n * would produce.\n */\n\nimport {\n DECLARED_CURRENCY,\n DECLARED_VAT_TREATMENT,\n EN_LOCALE,\n ET_LOCALE,\n MAX_SITE_NOTICES,\n SITE_INFO_KEY,\n SITE_INFO_RESOURCE,\n} from './constants.js'\nimport { stableHash } from './diff.js'\nimport { YhikasSyncRefusedError } from './upstream/errors.js'\nimport type {\n LegalDocumentRow,\n MultilingualText,\n RoomTypeRow,\n SiteInfoResponse,\n} from './upstream/wire.js'\n\n/** Injected at the seam; see the module docblock for why it is not imported. */\nexport type HtmlSanitizer = (html: string) => string\n\nexport interface ProjectionOptions {\n /** The locale whose values live on the base entity row. `et` or `en`. */\n readonly defaultLocale: string\n}\n\nexport interface ProjectedRow {\n /** The business key — `room_type.code` or `legal_document.type`. */\n readonly key: string\n /** Base-row fields, including the default locale's values for translatable fields. */\n readonly base: Record<string, unknown>\n /**\n * Translatable values for the non-default locale, or `null` when that\n * locale's text is empty upstream. `null` means \"unpublish that locale\",\n * never \"write an empty string\".\n */\n readonly secondary: Record<string, unknown> | null\n readonly hash: string\n}\n\nexport interface SkippedRow {\n readonly key: string\n readonly reason: string\n}\n\nexport interface ProjectionResult {\n readonly projected: readonly ProjectedRow[]\n readonly skipped: readonly SkippedRow[]\n}\n\n/** The only locales this projection can express — upstream carries exactly these two. */\nexport const SUPPORTED_LOCALES: readonly string[] = [ET_LOCALE, EN_LOCALE]\n\n/**\n * Refuse a locale this projection cannot express.\n *\n * Both helpers below are implicit-else: `pickLocale` returns `text.en` for\n * every locale that is not `et`, and `secondaryLocaleOf` returns `et` for every\n * locale that is not `et`. So a site reporting `fi` as its default would get\n * the ENGLISH text on the base row under the `fi` locale, Estonian as the\n * secondary, and per-locale publish status wrong for both — silently, with\n * every write succeeding. That is the same publish-the-wrong-language failure\n * the entity docblocks call worse than an absent document.\n *\n * `jobs.ts` already screens the value it reads from the app, but this module is\n * a public export and its functions can be called directly. The check belongs\n * where the assumption lives.\n */\nexport function assertSupportedLocale(locale: string): void {\n if (!SUPPORTED_LOCALES.includes(locale)) {\n throw new TypeError(\n `yhikas-sync: unsupported locale '${locale}' — upstream carries only '${ET_LOCALE}' and ` +\n `'${EN_LOCALE}', and projecting one language's text under another locale would publish ` +\n `the wrong language while every write succeeded.`,\n )\n }\n}\n\n/** The locale that is NOT the base row's. */\nexport function secondaryLocaleOf(defaultLocale: string): string {\n assertSupportedLocale(defaultLocale)\n return defaultLocale === ET_LOCALE ? EN_LOCALE : ET_LOCALE\n}\n\nfunction pickLocale(text: MultilingualText, locale: string): string {\n return locale === ET_LOCALE ? text.et : text.en\n}\n\n/**\n * Non-empty after trimming.\n *\n * Trimming matters: both upstream columns are `notNull`, but nothing validates\n * that either string is non-empty — the form has no schema and no required\n * check — so `\"\"` and `\" \"` are both expected states of a half-filled row,\n * and only one of them looks empty.\n */\nfunction present(value: string): boolean {\n return value.trim().length > 0\n}\n\n/**\n * Characters that make a slug unusable — or unsafe — as an anchor fragment.\n *\n * A DENY-list rather than an allow-list, deliberately, and this is the one\n * place in the package where that is the right way round. Upstream derives the\n * slug from an unvalidated free-text field, so an Estonian dormitory produces\n * Estonian slugs (`üldtingimused`); a character-class allow-list would reject\n * legitimate content and — since the slug is not the identity — buy nothing for\n * it. What actually needs excluding is the small set that turns a fragment into\n * a scheme, a path, or markup.\n */\nconst UNSAFE_SLUG_CHARS = /[\\s:/\\\\<>\"'`?#&%]/\n\n/**\n * The `maxLength` both entities declare for their key and slug columns.\n *\n * Checked here rather than left to the write: an overlong value that reaches\n * `client.create` fails at the database, `applyPlan` counts the row failed, and\n * the run throws — so one overlong free-text value upstream would keep the\n * whole resource failing every six hours with the watchdog armed. Skipping the\n * row keeps the blast radius at one document, matching the policy already\n * stated for an unsafe slug.\n */\nconst MAX_COLUMN_LENGTH = 190\n\nfunction isSafeAnchorSlug(slug: string): boolean {\n // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what is being excluded from a URL fragment\n return !UNSAFE_SLUG_CHARS.test(slug) && !/[\\u0000-\\u001f\\u007f]/.test(slug)\n}\n\nexport function projectRoomTypes(\n rows: readonly RoomTypeRow[],\n options: ProjectionOptions,\n): ProjectionResult {\n assertSupportedLocale(options.defaultLocale)\n const secondaryLocale = secondaryLocaleOf(options.defaultLocale)\n const projected: ProjectedRow[] = []\n const skipped: SkippedRow[] = []\n\n for (const row of rows) {\n if (row.code.length > MAX_COLUMN_LENGTH) {\n skipped.push({\n key: row.code.slice(0, 80),\n reason: `upstream code exceeds the ${MAX_COLUMN_LENGTH}-character column`,\n })\n continue\n }\n\n const primaryName = pickLocale(row.name, options.defaultLocale)\n if (!present(primaryName)) {\n skipped.push({\n key: row.code,\n reason:\n `no '${options.defaultLocale}' name upstream — a room type with no name in the ` +\n `site's primary language has nothing publishable to render`,\n })\n continue\n }\n\n const secondaryName = pickLocale(row.name, secondaryLocale)\n const base: Record<string, unknown> = {\n code: row.code,\n name: primaryName,\n // Decimals stay STRINGS end to end — see the entity docblock.\n totalArea: row.totalArea,\n livingArea: row.livingArea,\n commonArea: row.commonArea,\n capacity: row.capacity,\n placesOccupied: row.placesOccupied,\n monthlyRent: row.monthlyRent,\n discountedMonthlyRent: row.discountedRent,\n dailyRent: row.dailyRent,\n currency: DECLARED_CURRENCY,\n vatTreatment: DECLARED_VAT_TREATMENT,\n hasEnglish: present(row.name.en),\n }\n const secondary = present(secondaryName) ? { name: secondaryName } : null\n\n projected.push({ key: row.code, base, secondary, hash: hashOf(base, secondary) })\n }\n\n return { projected, skipped }\n}\n\nexport function projectLegalDocuments(\n rows: readonly LegalDocumentRow[],\n options: ProjectionOptions,\n sanitize: HtmlSanitizer,\n): ProjectionResult {\n assertSupportedLocale(options.defaultLocale)\n const secondaryLocale = secondaryLocaleOf(options.defaultLocale)\n const projected: ProjectedRow[] = []\n const skipped: SkippedRow[] = []\n\n for (const row of rows) {\n const primaryTitle = pickLocale(row.title, options.defaultLocale)\n // Sanitize BEFORE the emptiness test: markup that reduces to nothing under\n // the allowlist (a lone `<script>`, say) is empty content, and publishing a\n // legal document whose body sanitizes away would be worse than omitting it.\n const primaryBody = sanitize(pickLocale(htmlOf(row), options.defaultLocale))\n\n if (!present(primaryTitle) || !present(primaryBody)) {\n skipped.push({\n key: row.type,\n reason: `no publishable '${options.defaultLocale}' title or body upstream`,\n })\n continue\n }\n\n if (row.type.length > MAX_COLUMN_LENGTH || row.slug.length > MAX_COLUMN_LENGTH) {\n skipped.push({\n key: row.type.slice(0, 80),\n reason:\n `upstream type or slug exceeds the ${MAX_COLUMN_LENGTH}-character column — refusing ` +\n `this row rather than letting the write fail and hold the whole resource in failure`,\n })\n continue\n }\n\n if (!isSafeAnchorSlug(row.slug)) {\n skipped.push({\n key: row.type,\n reason:\n `upstream slug ${JSON.stringify(row.slug)} is not usable as an anchor fragment — it ` +\n `carries a scheme, a path separator, whitespace or markup. Refusing this row rather ` +\n `than the whole response: upstream derives the slug from an unvalidated free-text ` +\n `field, so one bad value must not take the resource offline`,\n })\n continue\n }\n\n const secondaryTitle = pickLocale(row.title, secondaryLocale)\n const secondaryBody = sanitize(pickLocale(htmlOf(row), secondaryLocale))\n const englishBody = sanitize(row.htmlContentEn)\n\n const base: Record<string, unknown> = {\n type: row.type,\n title: primaryTitle,\n sourceSlug: row.slug,\n body: primaryBody,\n order: row.order,\n hasEnglish: present(row.title.en) && present(englishBody),\n }\n // BOTH halves must be present. A title with no body renders an empty page;\n // a body with no title renders an untitled one. Either is the \"empty page\"\n // D015 exists to prevent, so the locale is suppressed unless both survive.\n const secondary =\n present(secondaryTitle) && present(secondaryBody)\n ? { title: secondaryTitle, body: secondaryBody }\n : null\n\n projected.push({ key: row.type, base, secondary, hash: hashOf(base, secondary) })\n }\n\n return { projected, skipped }\n}\n\n/**\n * Reception hours + the notice ticker → the one `yhikas_site_info` row.\n *\n * ## 🔴 The refusal is the point of this function\n *\n * `/api/public/site-info` answers `200 { success: true, receptionHours:\n * {et:'',en:''}, notices: [] }` when no upstream row exists. That is\n * byte-identical to an operator having deliberately cleared both, and it is the\n * state the endpoint is in whenever nobody has filled it in (F015). The other\n * two resources have no equivalent hole — theirs discriminate on `success` and\n * on array length against a local snapshot.\n *\n * The shipped `empty-snapshot` floor cannot cover it, and the reason is\n * structural rather than an oversight: that rule is a row-COUNT test, and this\n * resource always projects exactly one row. The count is 1 whether the row says\n * anything or not, so the floor never fires and an all-empty payload would be\n * applied as an authoritative blanking — emptying the ticker and the hours on\n * every page of the site, silently, six hours after upstream hiccupped.\n *\n * So emptiness is restated here as a CONTENT test: if the default locale has\n * neither hours nor a single active notice, the payload is refused as\n * unusable. Local content is left exactly as it was and the failure arms the\n * staleness watchdog, which is the same posture as an unreachable endpoint —\n * because epistemically it is the same situation.\n *\n * Note what is NOT refused: hours with no notices, or notices with no hours.\n * Both are ordinary states of a real dormitory, and refusing them would make\n * the guard fire on exactly the operator action it exists to protect.\n */\nexport function projectSiteInfo(\n response: SiteInfoResponse,\n options: ProjectionOptions,\n): ProjectionResult {\n assertSupportedLocale(options.defaultLocale)\n const secondaryLocale = secondaryLocaleOf(options.defaultLocale)\n\n const ordered = response.notices\n .filter((notice) => notice.isActive)\n .sort((a, b) => a.order - b.order)\n const active = ordered.slice(0, MAX_SITE_NOTICES)\n const skipped: SkippedRow[] = []\n if (ordered.length > active.length) {\n // Logged, never silent. A dropped notice nobody hears about reads as\n // \"covered everything\" — the operator adds a 26th notice, it never appears\n // on the site, and nothing anywhere says why. Reported as one row carrying\n // the count rather than N rows, since the tail is interchangeable.\n skipped.push({\n key: SITE_INFO_KEY,\n reason:\n `${ordered.length - active.length} active notice(s) beyond the ${MAX_SITE_NOTICES} ` +\n `ticker ceiling were dropped — a marquee past a couple of dozen entries is not read, ` +\n `and an unbounded array from upstream would ride onto every page of the site`,\n })\n }\n\n const noticesFor = (locale: string): string[] =>\n active.map((notice) => pickLocale(notice.text, locale)).filter(present)\n\n const primaryHours = pickLocale(response.receptionHours, options.defaultLocale)\n const primaryNotices = noticesFor(options.defaultLocale)\n\n if (!present(primaryHours) && primaryNotices.length === 0) {\n throw new YhikasSyncRefusedError(\n SITE_INFO_RESOURCE,\n 'empty-content',\n `yhikas-admin returned a well-formed site-info response carrying no usable ` +\n `'${options.defaultLocale}' content: reception hours are empty, and of ` +\n `${response.notices.length} notice(s) upstream, ${ordered.length} are active and ` +\n `${primaryNotices.length} have '${options.defaultLocale}' text. (Those three counts are ` +\n `reported separately on purpose — \"no active notices\" and \"active notices with no text ` +\n `in this language\" send an operator to different places.) That state is exactly what the ` +\n `endpoint returns when no row exists at all, and it is indistinguishable from a ` +\n `deliberate clearing — so it is refused rather than applied. Local content is unchanged ` +\n `and the staleness watchdog stays armed. Note this refusal is TERMINAL, not transient: ` +\n `it will repeat every run until upstream carries something. If the intent really was to ` +\n `clear everything, leave one of the two set.`,\n )\n }\n\n const secondaryHours = pickLocale(response.receptionHours, secondaryLocale)\n const secondaryNotices = noticesFor(secondaryLocale)\n const englishHours = pickLocale(response.receptionHours, EN_LOCALE)\n const englishNotices = noticesFor(EN_LOCALE)\n\n const base: Record<string, unknown> = {\n key: SITE_INFO_KEY,\n // TRIMMED, not passed through. `present()` trims before testing, so a\n // whitespace-only value passes the either-half gate whenever the other\n // half is set — and would then be stored verbatim, rendering as an empty\n // row rather than an absent one. The sibling projections cannot hit this\n // because their gates are per field; this one's is not.\n receptionHours: primaryHours.trim(),\n notices: primaryNotices,\n hasEnglish: present(englishHours) || englishNotices.length > 0,\n }\n\n // The same either-half rule as the primary locale, for the same reason: a\n // secondary locale carrying only notices is publishable, and suppressing it\n // would leave English visitors with the Estonian ticker.\n const secondary =\n present(secondaryHours) || secondaryNotices.length > 0\n ? {\n // 🔴 An explicit EMPTY STRING for a missing half, deliberately — NOT\n // `null`, and this is the one place in the package that diverges from\n // its siblings. Two reviews reached opposite conclusions here, so the\n // reasoning is written down rather than left to the next reader.\n //\n // `null` is \"no override, inherit the base row\", and the merged read\n // is `COALESCE(translation, base)`. For a title or a document body\n // that inheritance is a sane fallback, which is why\n // `projectLegalDocuments` suppresses the whole locale unless BOTH\n // halves are present. For OPENING HOURS it is not: inheriting means\n // an English visitor is shown the Estonian string, presented as\n // English. Showing nothing is better than showing the wrong language.\n //\n // So the two halves here are independent optional content rather than\n // a title/body pair, and each says \"absent in this language\" as an\n // explicit empty value. `notices: []` on the next line already had\n // exactly those semantics; the two now agree instead of contradicting\n // each other inside one object.\n //\n // Trimmed for the same reason the base row is: `present()` trims\n // before testing, so whitespace would otherwise survive the gate and\n // be stored verbatim.\n receptionHours: secondaryHours.trim(),\n notices: secondaryNotices,\n }\n : null\n\n return {\n projected: [{ key: SITE_INFO_KEY, base, secondary, hash: hashOf(base, secondary) }],\n skipped,\n }\n}\n\n/** The two parallel HTML columns, re-shaped as a `MultilingualText` so locale picking is uniform. */\nfunction htmlOf(row: LegalDocumentRow): MultilingualText {\n return { et: row.htmlContentEt, en: row.htmlContentEn }\n}\n\nfunction hashOf(base: Record<string, unknown>, secondary: Record<string, unknown> | null): string {\n return stableHash({ base, secondary })\n}\n","/**\n * One sync run: fetch, diff, apply, record — per resource, in that order.\n *\n * Every collaborator is injected, so a run is fully exercisable in a unit test\n * with no Postgres, no HTTP server and no queue worker. That is not a stylistic\n * preference: CI runs unit tests only, so logic reachable only through\n * integration tests is in practice covered by nothing, and PR 05's three\n * obligatory negative tests all live at this level.\n *\n * ## Ordering: fetch BEFORE reading local state, and abort before either write\n *\n * The upstream fetch happens first and its failure aborts the resource\n * immediately — before the differ, and therefore before any write. That is the\n * mechanical form of \"never act on an absence from a response that did not\n * fully succeed\": an unreachable endpoint cannot retire anything, because\n * nothing downstream of the fetch runs.\n *\n * ## Resources are independent\n *\n * A failure syncing room types does not prevent legal documents from syncing,\n * and each records its own success separately, so the watchdog reports per\n * resource. Whichever failed still fails the JOB at the end, so the queue's\n * retry and dead-letter path engages.\n */\n\nimport {\n type ApplyCounts,\n applyPlan,\n readLocalRows,\n type SyncEntityClient,\n type SyncLogger,\n} from './apply.js'\nimport {\n LEGAL_DOCUMENTS_RESOURCE,\n ROOM_TYPES_RESOURCE,\n SITE_INFO_RESOURCE,\n type SyncResource,\n} from './constants.js'\nimport { planDiff, type SanityFloor } from './diff.js'\nimport {\n type HtmlSanitizer,\n type ProjectedRow,\n type ProjectionResult,\n projectLegalDocuments,\n projectRoomTypes,\n projectSiteInfo,\n secondaryLocaleOf,\n} from './projection.js'\nimport type { SyncStateStore } from './sync-state.js'\nimport type { YhikasUpstreamClient } from './upstream/client.js'\n\n/** Per-run ceiling on individual skip warnings; the remainder is reported as a count. */\nconst MAX_LOGGED_SKIPS = 20\n\nexport interface SyncRunDeps {\n readonly upstream: YhikasUpstreamClient\n readonly roomTypeClient: SyncEntityClient\n readonly legalDocumentClient: SyncEntityClient\n readonly siteInfoClient: SyncEntityClient\n readonly state: SyncStateStore\n readonly sanitizeHtml: HtmlSanitizer\n readonly logger: SyncLogger\n readonly defaultLocale: string\n readonly floor: SanityFloor\n /** Injectable so tests are not order-dependent on the wall clock. */\n readonly now?: () => Date\n}\n\nexport interface ResourceOutcome {\n readonly resource: SyncResource\n readonly ok: boolean\n readonly counts: ApplyCounts | null\n readonly skipped: number\n readonly error: string | null\n}\n\nexport interface SyncRunSummary {\n readonly outcomes: readonly ResourceOutcome[]\n readonly ok: boolean\n}\n\n/** Thrown when at least one resource failed, so the queue retries and eventually alerts. */\nexport class YhikasSyncRunError extends Error {\n readonly summary: SyncRunSummary\n constructor(summary: SyncRunSummary) {\n const failed = summary.outcomes.filter((outcome) => !outcome.ok)\n super(\n `yhikas-admin sync failed for ${failed.map((o) => o.resource).join(', ')}: ` +\n failed.map((o) => o.error).join(' | '),\n )\n this.name = 'YhikasSyncRunError'\n this.summary = summary\n }\n}\n\nexport async function runYhikasSync(deps: SyncRunDeps): Promise<SyncRunSummary> {\n const now = deps.now ?? (() => new Date())\n const secondaryLocale = secondaryLocaleOf(deps.defaultLocale)\n\n const outcomes: ResourceOutcome[] = []\n\n outcomes.push(\n await syncOne({\n resource: ROOM_TYPES_RESOURCE,\n keyField: 'code',\n client: deps.roomTypeClient,\n fetchAndProject: async () =>\n projectRoomTypes(await deps.upstream.fetchRoomTypes(), {\n defaultLocale: deps.defaultLocale,\n }),\n deps,\n secondaryLocale,\n now,\n }),\n )\n\n outcomes.push(\n await syncOne({\n resource: LEGAL_DOCUMENTS_RESOURCE,\n keyField: 'type',\n client: deps.legalDocumentClient,\n fetchAndProject: async () =>\n projectLegalDocuments(\n await deps.upstream.fetchLegalDocuments(),\n { defaultLocale: deps.defaultLocale },\n deps.sanitizeHtml,\n ),\n deps,\n secondaryLocale,\n now,\n }),\n )\n\n // Third and last. `fetchAndProject` is where its refusal lives, so an\n // all-empty upstream aborts here — before local state is read and therefore\n // before any write, exactly like a failed fetch (D027).\n outcomes.push(\n await syncOne({\n resource: SITE_INFO_RESOURCE,\n keyField: 'key',\n client: deps.siteInfoClient,\n fetchAndProject: async () =>\n projectSiteInfo(await deps.upstream.fetchSiteInfo(), {\n defaultLocale: deps.defaultLocale,\n }),\n deps,\n secondaryLocale,\n now,\n }),\n )\n\n const summary: SyncRunSummary = { outcomes, ok: outcomes.every((outcome) => outcome.ok) }\n if (!summary.ok) throw new YhikasSyncRunError(summary)\n return summary\n}\n\ninterface SyncOneInput {\n readonly resource: SyncResource\n readonly keyField: string\n readonly client: SyncEntityClient\n readonly fetchAndProject: () => Promise<ProjectionResult>\n readonly deps: SyncRunDeps\n readonly secondaryLocale: string\n readonly now: () => Date\n}\n\nasync function syncOne(input: SyncOneInput): Promise<ResourceOutcome> {\n const { resource, keyField, client, fetchAndProject, deps, secondaryLocale, now } = input\n const { logger, state, floor } = deps\n\n try {\n // Inside the try, deliberately. Outside it, a store failure — Postgres\n // unreachable, table not yet migrated — would reject `syncOne` rather than\n // returning a `ResourceOutcome`, which breaks two stated guarantees at\n // once: the second resource is never attempted (so much for \"resources are\n // independent\"), and the caller gets a raw store error instead of a\n // `YhikasSyncRunError` carrying the per-resource summary the watchdog\n // reports on. The catch below already treats a state write as non-fatal;\n // these two get the same treatment.\n await state.ensure(resource, now())\n await state.recordAttempt(resource, now())\n\n // 1. Fetch + project. Any refusal, timeout or shape failure throws HERE,\n // before local state is even read — so a failed fetch cannot influence\n // what is retired.\n const projection = await fetchAndProject()\n\n // Logged, never silent: a dropped row nobody hears about reads as \"covered\n // everything\". Capped all the same — the snapshot ceiling is in the\n // hundreds, and a systematically malformed upstream would otherwise bury\n // every other line in the run. The suppressed COUNT is reported, so the\n // cap can never itself become a silent truncation.\n for (const skip of projection.skipped.slice(0, MAX_LOGGED_SKIPS)) {\n logger.warn(\n { resource, key: skip.key, reason: skip.reason },\n 'yhikas-sync: skipping an upstream row, or part of one, that cannot be published',\n )\n }\n if (projection.skipped.length > MAX_LOGGED_SKIPS) {\n logger.warn(\n {\n resource,\n suppressed: projection.skipped.length - MAX_LOGGED_SKIPS,\n total: projection.skipped.length,\n },\n 'yhikas-sync: further skipped rows not logged individually',\n )\n }\n\n // 2. Read local state and plan. The differ raises the D016 refusals.\n const local = await readLocalRows(client, keyField, floor.maxRows)\n const plan = planDiff<ProjectedRow>({\n resource,\n upstream: projection.projected,\n local,\n keyOf: (row) => row.key,\n hashOf: (row) => row.hash,\n floor,\n })\n\n // 3. Apply.\n const counts = await applyPlan({ resource, plan, client, secondaryLocale, logger })\n\n await state.recordSuccess(resource, now(), counts)\n logger.info(\n { resource, ...counts, skipped: projection.skipped.length },\n 'yhikas-sync: resource synced',\n )\n return { resource, ok: true, counts, skipped: projection.skipped.length, error: null }\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n logger.error({ err, resource }, 'yhikas-sync: resource sync failed — local content unchanged')\n try {\n // `lastSuccessAt` is untouched, so the staleness watchdog stays armed.\n await state.recordFailure(resource, now(), message)\n } catch (stateErr) {\n // Recording the failure must never REPLACE it. If the database is the\n // thing that is broken, this write fails too, and letting it throw here\n // would report a bookkeeping error while hiding the real cause.\n logger.error(\n { err: stateErr, resource },\n 'yhikas-sync: could not record the failure in yhikas_sync_state',\n )\n }\n return { resource, ok: false, counts: null, skipped: 0, error: message }\n }\n}\n","/**\n * Reading and writing `yhikas_sync_state`.\n *\n * Behind an interface so the run orchestrator and the watchdog are both\n * testable against an in-memory fake — CI runs no integration tests, so\n * anything reachable only through a real Postgres is in practice covered by\n * nothing.\n */\n\nimport type { ApplyCounts } from './apply.js'\nimport type { SyncResource } from './constants.js'\nimport { SYNC_RESOURCES } from './constants.js'\nimport type { yhikasSyncStateTable } from './sync-state-table.js'\n\nexport interface SyncStateRecord {\n readonly resource: string\n readonly firstSeenAt: Date\n readonly lastAttemptAt: Date | null\n readonly lastSuccessAt: Date | null\n readonly lastError: string | null\n}\n\nexport interface SyncStateStore {\n /** Create the row if absent, so the watchdog can measure a never-ran sync from somewhere. */\n ensure(resource: SyncResource, now: Date): Promise<void>\n recordAttempt(resource: SyncResource, now: Date): Promise<void>\n recordSuccess(resource: SyncResource, now: Date, counts: ApplyCounts): Promise<void>\n recordFailure(resource: SyncResource, now: Date, error: string): Promise<void>\n readAll(): Promise<SyncStateRecord[]>\n}\n\ntype SyncStateClient = ReturnType<typeof yhikasSyncStateTable.makeClient>\n\n/** Keep the stored message inside the column and free of anything unbounded. */\nconst MAX_ERROR_LENGTH = 1024\n\nexport function truncateError(message: string): string {\n return message.length <= MAX_ERROR_LENGTH ? message : `${message.slice(0, MAX_ERROR_LENGTH - 1)}…`\n}\n\nexport function createSyncStateStore(client: SyncStateClient): SyncStateStore {\n return {\n async ensure(resource, now) {\n // `set: {}` would be an empty conflict update, so the no-op path re-states\n // `resource`: the point is only to guarantee the row exists, never to\n // move `firstSeenAt` — moving it would reset the never-ran clock on every\n // boot and the staleness alert could then never fire.\n await client.upsert({ resource, firstSeenAt: now }, { target: 'resource', set: { resource } })\n },\n\n async recordAttempt(resource, now) {\n await client.upsert(\n { resource, firstSeenAt: now, lastAttemptAt: now },\n { target: 'resource', set: { lastAttemptAt: now } },\n )\n },\n\n async recordSuccess(resource, now, counts) {\n await client.upsert(\n {\n resource,\n firstSeenAt: now,\n lastAttemptAt: now,\n lastSuccessAt: now,\n lastError: null,\n lastCreated: counts.created,\n lastUpdated: counts.updated,\n lastRetired: counts.retired,\n lastUnchanged: counts.unchanged,\n },\n {\n target: 'resource',\n set: {\n lastAttemptAt: now,\n lastSuccessAt: now,\n // Cleared, so a stale message from a resolved failure cannot read\n // as a current one.\n lastError: null,\n lastCreated: counts.created,\n lastUpdated: counts.updated,\n lastRetired: counts.retired,\n lastUnchanged: counts.unchanged,\n },\n },\n )\n },\n\n async recordFailure(resource, now, error) {\n const lastError = truncateError(error)\n // `lastSuccessAt` is deliberately NOT touched. It is the watchdog's only\n // input, and advancing it on a failed run would silence the alert for\n // exactly the runs that should raise it.\n await client.upsert(\n { resource, firstSeenAt: now, lastAttemptAt: now, lastError },\n { target: 'resource', set: { lastAttemptAt: now, lastError } },\n )\n },\n\n async readAll() {\n // Filtered by resource rather than capped at \"however many resources\n // there are\": an unrelated leftover row — a resource added and later\n // removed, e.g. the Q1 `site_info` — would otherwise be able to fill the\n // limit and push an in-scope resource out of the result, which the\n // watchdog reads as \"no tracking row at all\" and reports as a total\n // outage. A false alarm on the one channel that has to stay trustworthy.\n const rows = await client.findMany({\n where: { resource: { in: [...SYNC_RESOURCES] } },\n limit: SYNC_RESOURCES.length,\n })\n return rows.map((row) => ({\n resource: String(row.resource),\n firstSeenAt: row.firstSeenAt as Date,\n lastAttemptAt: (row.lastAttemptAt as Date | null) ?? null,\n lastSuccessAt: (row.lastSuccessAt as Date | null) ?? null,\n lastError: (row.lastError as string | null) ?? null,\n }))\n },\n }\n}\n","/**\n * The yhikas-admin `/api/public/*` wire contract, as Zod schemas.\n *\n * Measured from that repository's route handlers on 2026-08-06 (R013), not\n * inferred from its `src/db/schema.ts` — a sync is written against the\n * envelope, and the envelope is named (`{ success, roomTypes }`,\n * `{ success, documents }`) rather than bare or `data`-keyed.\n *\n * Two properties of the upstream response are deliberate on that side and must\n * survive the hop:\n *\n * - **Decimals arrive as strings.** `pg` returns `numeric` as a string and the\n * route passes it through. Parsing to a float would introduce rounding into\n * a price, so every money/area field is validated AS a string and stored as\n * one. `z.coerce` is banned in this file for that reason.\n * - **Nulls pass through untouched.** The route's own comment: \"no `?? 0`, no\n * `?? ''` … so the site can decide how to render a missing price rather than\n * displaying a fabricated zero.\" A missing price is data; the sync\n * substitutes no defaults either.\n */\n\nimport { z } from 'zod'\n\n/**\n * A memory bound on a key-ish string, NOT the column width.\n *\n * The entities declare `maxLength: 190`, but enforcing that HERE would fail\n * `legalDocumentsResponseSchema` for the whole payload over one overlong\n * free-text value — the same blast-radius mistake the slug charset check made.\n * The column-width check lives in the projection, per row.\n */\nconst MAX_KEY_LENGTH = 2000\n/** Upper bound on a human-facing label (a room-type name, a document title). */\nconst MAX_LABEL_LENGTH = 500\n/** Upper bound on one document's HTML. Refusing beats storing an unbounded blob. */\nconst MAX_HTML_LENGTH = 512 * 1024\n\n/**\n * A Postgres `numeric` as `pg` serializes it. Anything that is not a plain\n * decimal literal is a shape violation and aborts the run — validating at the\n * boundary, per CLAUDE.md, rather than storing whatever arrived.\n */\nconst decimalString = z\n .string()\n .max(32)\n .regex(/^-?\\d+(\\.\\d+)?$/, 'expected a decimal literal, e.g. \"180.00\"')\n\n/**\n * `MultilingualText` — a Postgres `json` column, so it arrives as a nested\n * object and is never stringified.\n *\n * NOT `.strict()`: unknown keys are stripped rather than rejected, so adding a\n * third language upstream degrades to \"the sync ignores it\" instead of \"every\n * run fails\".\n */\nexport const multilingualTextSchema = z.object({\n et: z.string().max(MAX_LABEL_LENGTH),\n en: z.string().max(MAX_LABEL_LENGTH),\n})\n\nexport type MultilingualText = z.infer<typeof multilingualTextSchema>\n\n/**\n * One `room_type` row.\n *\n * `depositAmount` / `discountedDepositAmount` are absent by design. Upstream\n * ships them as hardcoded `null` with no backing column; validating them as\n * `z.null()` would turn the day someone adds the column into a hard sync\n * failure. Deposits are unbuilt admin-side work, not something the sync can\n * surface.\n */\nexport const roomTypeRowSchema = z.object({\n code: z.string().min(1).max(MAX_KEY_LENGTH),\n name: multilingualTextSchema,\n totalArea: decimalString.nullable(),\n livingArea: decimalString.nullable(),\n commonArea: decimalString.nullable(),\n capacity: z.number().int().nullable(),\n monthlyRent: decimalString.nullable(),\n discountedRent: decimalString.nullable(),\n dailyRent: decimalString.nullable(),\n placesOccupied: z.number().int().nullable(),\n})\n\nexport type RoomTypeRow = z.infer<typeof roomTypeRowSchema>\n\n/**\n * One active `legal_document` row.\n *\n * `type` is `text().notNull().unique()` upstream — NOT a pgEnum, and there is\n * no TS union anywhere. The five values seeded today are closed by convention\n * only and the admin UI can mint a sixth, so this validates the SHAPE of the\n * business key and never its membership in a list. A whitelist here would turn\n * a new upstream document into a hard sync failure.\n */\nexport const legalDocumentRowSchema = z.object({\n type: z.string().min(1).max(MAX_KEY_LENGTH),\n title: multilingualTextSchema,\n /**\n * Accepted as free text HERE, and screened per-row in the projection.\n *\n * Upstream derives it from an unvalidated free-text form field\n * (`type.toLowerCase().replace(/_/g, '-')`), so an Estonian title yields an\n * Estonian slug — `üldtingimused` — and a title with a space yields a slug\n * with a space. A character-class regex on the RESPONSE schema would fail\n * `legalDocumentsResponseSchema` for the whole payload, so one newly\n * authored document would take the entire resource offline every six hours\n * until someone edited it upstream. The blast radius belongs at one row.\n */\n slug: z.string().min(1).max(MAX_KEY_LENGTH),\n htmlContentEt: z.string().max(MAX_HTML_LENGTH),\n htmlContentEn: z.string().max(MAX_HTML_LENGTH),\n order: z.number().int(),\n})\n\nexport type LegalDocumentRow = z.infer<typeof legalDocumentRowSchema>\n\n/**\n * `success: z.literal(true)` is the load-bearing clause, not decoration.\n *\n * Every upstream failure path sets `success: false` — 401 (missing header,\n * wrong scheme, wrong key, AND an unset server-side key: all four\n * indistinguishable, deny-by-default through one branch), 429, and 500. No\n * route returns 200 with a degraded body. So the discriminator is `success`,\n * never array length, and a snapshot that fails this schema can never be\n * mistaken for an authoritative empty one.\n */\nexport const roomTypesResponseSchema = z.object({\n success: z.literal(true),\n roomTypes: z.array(roomTypeRowSchema),\n})\n\nexport const legalDocumentsResponseSchema = z.object({\n success: z.literal(true),\n documents: z.array(legalDocumentRowSchema),\n})\n\n/**\n * One `site_notice` — a ticker item.\n *\n * No id, no code, no timestamp: `{text, isActive, order}` is the whole row as\n * the route serves it. That absence is why `site_info` is modelled as one\n * local row carrying an ordered list rather than as N diffable rows — there is\n * no key an edit upstream would preserve.\n *\n * `isActive` is honoured HERE rather than assumed: the route returns inactive\n * notices too, and a ticker that shows a retired notice is worse than one that\n * shows nothing.\n */\nexport const siteNoticeRowSchema = z.object({\n text: multilingualTextSchema,\n isActive: z.boolean(),\n order: z.number().int(),\n})\n\nexport type SiteNoticeRow = z.infer<typeof siteNoticeRowSchema>\n\n/**\n * `MAX_NOTICES_ON_THE_WIRE` is a BOUNDARY bound, deliberately larger than the\n * ticker's own ceiling and doing a different job.\n *\n * The 8 MiB streaming cap bounds bytes, but Zod still validates every element\n * before the projection ever gets to truncate — so an upstream bug emitting a\n * hundred thousand notices is parsed in full and only then cut to 25. Refusing\n * at the schema is the cheap half of the same rule the byte cap implements.\n *\n * Kept separate from `MAX_SITE_NOTICES` on purpose: that one is a rendering\n * decision (a marquee past two dozen entries is not read) and truncating to it\n * is reported, not fatal. This one is a \"the response is not credible\" floor.\n */\nconst MAX_NOTICES_ON_THE_WIRE = 1000\n\n/**\n * `/api/public/site-info` — reception hours plus the notice ticker.\n *\n * 🔴 **This envelope cannot express \"not configured yet\".** The route answers\n * `200 { success: true, receptionHours: {et:'',en:''}, notices: [] }` when no\n * row exists, which is byte-identical to an operator having deliberately\n * cleared everything (F015). The other two resources have no such hole —\n * theirs discriminate on `success` and on array length against a local\n * snapshot.\n *\n * The schema is therefore NOT where that is solved, and deliberately so: the\n * shape is valid either way. `projectSiteInfo` refuses an all-empty payload as\n * unusable (D027), which is the only place that has the standing to decide\n * that a well-formed response is not authoritative.\n */\n\nexport const siteInfoResponseSchema = z.object({\n success: z.literal(true),\n receptionHours: multilingualTextSchema,\n notices: z.array(siteNoticeRowSchema).max(MAX_NOTICES_ON_THE_WIRE),\n})\n\nexport type SiteInfoResponse = z.infer<typeof siteInfoResponseSchema>\n","/**\n * The read-only yhikas-admin client.\n *\n * **One-directional by construction, not by convention.** This class exposes\n * three methods and all are GETs. There is no `post`, no `put`, no generic\n * `request(method, …)` — so there is no code path anywhere in the sync that\n * could write upstream, which is a property of the type rather than a rule\n * someone has to keep remembering.\n *\n * It also never touches a database. D014 rules out a direct connection even\n * though the credentials would technically permit one: upstream keeps public\n * and private tables in one database behind one pool, with no read replica and\n * no schema separation, and the isolation that exists is query-level (explicit\n * column lists, no joins). The three narrow endpoints are the boundary, and\n * they are the boundary precisely because someone already drew it.\n */\n\nimport type { z } from 'zod'\nimport {\n LEGAL_DOCUMENTS_PATH,\n LEGAL_DOCUMENTS_RESOURCE,\n ROOM_TYPES_PATH,\n ROOM_TYPES_RESOURCE,\n SITE_INFO_PATH,\n SITE_INFO_RESOURCE,\n type SyncResource,\n} from '../constants.js'\nimport { YhikasUpstreamError } from './errors.js'\nimport {\n type LegalDocumentRow,\n legalDocumentsResponseSchema,\n type RoomTypeRow,\n roomTypesResponseSchema,\n type SiteInfoResponse,\n siteInfoResponseSchema,\n} from './wire.js'\n\n/**\n * Ceiling on one response, in BYTES, enforced WHILE reading the stream.\n *\n * An earlier version measured after `response.text()` had already buffered the\n * whole body, and said so — which made the bound honest but inert against\n * exactly the case it exists for: a chunked response declares no\n * `content-length`, so nothing stopped an unbounded body being read into\n * memory before the check could run. Availability is a security property\n * (CLAUDE.md), and a bound that cannot act until after the damage is a comment,\n * not a control. Now the read aborts mid-stream.\n */\nconst MAX_RESPONSE_BYTES = 8 * 1024 * 1024\n\nexport interface YhikasUpstreamClientOptions {\n /** Origin of the yhikas-admin deployment. Must be http or https. */\n baseUrl: string\n /** The sync's OWN bearer credential — never the site's (see `API_KEY_ENV_VAR`). */\n apiKey: string\n /** Per-request deadline in milliseconds. */\n timeoutMs: number\n /** Injectable for tests. Defaults to the global `fetch`. */\n fetchImpl?: typeof fetch\n}\n\n/**\n * Release an unread body before throwing, so the connection is not pinned\n * waiting for a consumer that will never arrive. Cancellation failures are\n * swallowed on purpose: the caller is already throwing something more\n * informative, and replacing it with a teardown error would hide the cause.\n */\n/**\n * Read a response body as text, aborting once it exceeds the byte ceiling.\n *\n * Throws a `shape` {@link YhikasUpstreamError} on overrun — which the caller\n * re-raises untouched, so an oversized body is refused rather than\n * misclassified as unreadable.\n */\nasync function readBounded(\n response: Response,\n resource: SyncResource,\n path: string,\n): Promise<string> {\n const stream = response.body\n if (!stream) return ''\n // Annotated, not cast: `Response.body` is typed `ReadableStream<any>` under\n // the Node type definitions, but the Fetch spec guarantees its chunks are\n // `Uint8Array`. Stating that here keeps `value.byteLength` honestly typed\n // instead of letting three `any`s leak into the byte accounting.\n const reader: ReadableStreamDefaultReader<Uint8Array> = stream.getReader()\n const decoder = new TextDecoder()\n let seen = 0\n let text = ''\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n seen += value.byteLength\n if (seen > MAX_RESPONSE_BYTES) {\n await reader.cancel()\n throw new YhikasUpstreamError(\n resource,\n 'shape',\n `yhikas-admin sent more than ${MAX_RESPONSE_BYTES} bytes for ${path}, above the ` +\n `ceiling — the read was aborted mid-stream rather than buffered and measured after`,\n { status: response.status },\n )\n }\n // `stream: true` so a multi-byte character split across chunk boundaries\n // is not mangled — Estonian text is full of them.\n text += decoder.decode(value, { stream: true })\n }\n return text + decoder.decode()\n } finally {\n reader.releaseLock()\n }\n}\n\nasync function discardBody(response: Response): Promise<void> {\n try {\n await response.body?.cancel()\n } catch {\n // Nothing useful to do — the original refusal is the interesting error.\n }\n}\n\nexport class YhikasUpstreamClient {\n readonly #baseUrl: URL\n readonly #apiKey: string\n readonly #timeoutMs: number\n readonly #fetch: typeof fetch\n\n constructor(options: YhikasUpstreamClientOptions) {\n let parsed: URL\n try {\n parsed = new URL(options.baseUrl)\n } catch (cause) {\n throw new TypeError(`yhikas-sync: baseUrl is not a valid URL: ${options.baseUrl}`, { cause })\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n throw new TypeError(\n `yhikas-sync: baseUrl must be http or https, got '${parsed.protocol}'. ` +\n `A file: or data: origin here would be a way to feed the sync a local snapshot.`,\n )\n }\n if (parsed.pathname !== '/') {\n // `new URL('/api/public/room-types', 'http://host/yhikas-admin/')` resolves\n // to `http://host/api/public/room-types` — the base path is silently\n // dropped and the sync calls the host root. Refusing beats joining:\n // upstream mounts these routes at the origin root, so a base path here is\n // a misconfiguration rather than a shape to support.\n throw new TypeError(\n `yhikas-sync: baseUrl must be an origin with no path, got '${parsed.pathname}'. ` +\n `The upstream routes are absolute, so a base path would be silently discarded.`,\n )\n }\n if (options.apiKey.length === 0) {\n throw new TypeError('yhikas-sync: apiKey must not be empty')\n }\n this.#baseUrl = parsed\n this.#apiKey = options.apiKey\n this.#timeoutMs = options.timeoutMs\n this.#fetch = options.fetchImpl ?? globalThis.fetch\n }\n\n /** `GET /api/public/room-types`. Ordered by `code` upstream; unpaginated. */\n async fetchRoomTypes(): Promise<RoomTypeRow[]> {\n const body = await this.#get(ROOM_TYPES_RESOURCE, ROOM_TYPES_PATH, roomTypesResponseSchema)\n return body.roomTypes\n }\n\n /** `GET /api/public/legal-documents`. Active only, ordered by `order`; unpaginated. */\n async fetchLegalDocuments(): Promise<LegalDocumentRow[]> {\n const body = await this.#get(\n LEGAL_DOCUMENTS_RESOURCE,\n LEGAL_DOCUMENTS_PATH,\n legalDocumentsResponseSchema,\n )\n return body.documents\n }\n\n /**\n * `GET /api/public/site-info`. Reception hours + notice ticker; a singleton,\n * so there is nothing to order or paginate.\n *\n * Returns the whole envelope rather than one key, because both halves are the\n * payload and neither is meaningful without the other — a caller deciding\n * whether this response is usable at all has to see both (D027).\n */\n async fetchSiteInfo(): Promise<SiteInfoResponse> {\n return await this.#get(SITE_INFO_RESOURCE, SITE_INFO_PATH, siteInfoResponseSchema)\n }\n\n // Generic over the PARSED type rather than over the schema: a bare\n // `S extends z.ZodType` defaults its type parameters to `any`, so\n // `safeParse(...).data` would come back `any` and every caller would silently\n // lose the contract this method exists to enforce. `z.ZodType<T>` is the\n // CLAUDE.md-sanctioned form — the defaults handle the internal parameters.\n async #get<T>(resource: SyncResource, path: string, schema: z.ZodType<T>): Promise<T> {\n // Resolved against the configured origin so a path can never escape it.\n const url = new URL(path, this.#baseUrl)\n\n // The queue has NO per-job timeout (R012 §3): a hung fetch would hold a\n // concurrency slot until `jobLockTimeout` (30 min) let lease recovery\n // re-claim the job, at which point the handler body would run TWICE,\n // concurrently. The deadline is what keeps that from being routine.\n const signal = AbortSignal.timeout(this.#timeoutMs)\n\n let response: Response\n try {\n response = await this.#fetch(url, {\n method: 'GET',\n headers: {\n authorization: `Bearer ${this.#apiKey}`,\n accept: 'application/json',\n },\n redirect: 'error',\n signal,\n })\n } catch (cause) {\n const timedOut = signal.aborted\n throw new YhikasUpstreamError(\n resource,\n timedOut ? 'timeout' : 'network',\n timedOut\n ? `yhikas-admin did not answer ${path} within ${this.#timeoutMs}ms`\n : `yhikas-admin was unreachable at ${path}`,\n { cause },\n )\n }\n\n if (!response.ok) {\n // Never log or echo the body: a refusal envelope is uninteresting and the\n // request carried a credential. Status alone distinguishes the cases that\n // matter — 401 (wrong or unset key), 429 (limiter), 5xx (upstream fault).\n await discardBody(response)\n throw new YhikasUpstreamError(\n resource,\n 'http',\n `yhikas-admin refused ${path} with HTTP ${response.status}`,\n { status: response.status },\n )\n }\n\n const declaredLength = Number(response.headers.get('content-length') ?? Number.NaN)\n if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {\n await discardBody(response)\n throw new YhikasUpstreamError(\n resource,\n 'shape',\n `yhikas-admin returned ${declaredLength} bytes for ${path}, above the ${MAX_RESPONSE_BYTES}-byte ceiling`,\n { status: response.status },\n )\n }\n\n // Read through the stream, counting bytes as they arrive, rather than\n // `response.text()`/`.json()`. The `content-length` check above is inert on\n // a chunked response — which is precisely the shape an unbounded body\n // arrives in — so buffering first and measuring after would apply the\n // ceiling to every case except the one it exists for.\n let body: string\n try {\n body = await readBounded(response, resource, path)\n } catch (cause) {\n if (cause instanceof YhikasUpstreamError) throw cause\n // `AbortSignal.timeout` aborts the body stream too, so a deadline that\n // elapses mid-read surfaces here rather than at the request. Reporting\n // it as a malformed body would point an operator at the wrong system.\n const timedOut = signal.aborted\n throw new YhikasUpstreamError(\n resource,\n timedOut ? 'timeout' : 'shape',\n timedOut\n ? `yhikas-admin did not finish sending ${path} within ${this.#timeoutMs}ms`\n : `${path} returned a body that could not be read`,\n { status: response.status, cause },\n )\n }\n\n let json: unknown\n try {\n json = JSON.parse(body)\n } catch (cause) {\n throw new YhikasUpstreamError(resource, 'shape', `${path} returned a body that is not JSON`, {\n status: response.status,\n cause,\n })\n }\n\n const parsed = schema.safeParse(json)\n if (!parsed.success) {\n // The issue paths are field names from OUR schema, never response values,\n // so this cannot leak content into a log line.\n const where = parsed.error.issues\n .slice(0, 5)\n .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)\n .join('; ')\n throw new YhikasUpstreamError(\n resource,\n 'shape',\n `${path} returned a body that does not match the expected contract — ${where}`,\n { status: response.status },\n )\n }\n return parsed.data\n }\n}\n","/**\n * The staleness watchdog — S7's \"and the staleness is OBSERVABLE\" half.\n *\n * ## Why this is new code\n *\n * The failure being defended against is not a crash. It is a job that silently\n * stops running while the site keeps serving last month's prices, indefinitely\n * and confidently — nothing about the rendered page would look wrong. The\n * queue cannot see that (R012 §1): its heartbeat is per worker PROCESS and\n * answers \"is any worker alive\", and its alerter fires only from `failJob`'s\n * dead-letter branch, i.e. only on a job that RAN and THREW. A job that never\n * starts produces no error at all.\n *\n * So detection is new. Delivery is not: this check runs as its own scheduled\n * job and THROWS, and the throw reaches `failJob` → `QueueAlerter.recordFailure`\n * → the shipped dedupe window, digest and email path. No alert channel is\n * invented.\n *\n * ## The limitation, stated rather than papered over\n *\n * The watchdog is itself a scheduled job, so a worker that is entirely dead\n * runs neither the sync nor the watchdog. That case is exactly what the queue's\n * worker heartbeat DOES see. The two are complementary: the heartbeat covers\n * \"no worker\", this covers \"worker alive, this schedule not firing\" — and\n * neither covers the other.\n */\n\nimport { SYNC_RESOURCES, type SyncResource } from './constants.js'\nimport type { SyncStateRecord } from './sync-state.js'\n\nexport interface ResourceStaleness {\n readonly resource: SyncResource\n /** `null` when this resource has never once synced successfully. */\n readonly lastSuccessAt: Date | null\n /** Age of the last success, or of the tracking row when there has never been one. */\n readonly ageMs: number\n readonly stale: boolean\n readonly lastError: string | null\n}\n\n/** Thrown to reach the queue's dead-letter alerting. */\nexport class YhikasSyncStaleError extends Error {\n readonly stale: readonly ResourceStaleness[]\n constructor(stale: readonly ResourceStaleness[], staleAfterMs: number) {\n const detail = stale.map(describeOne).join('; ')\n super(\n `yhikas-admin sync is stale beyond the ${formatAge(staleAfterMs)} window. ${detail}. ` +\n `The public site is serving content that old — this alert fires on ABSENCE of success, ` +\n `so there may be no failing job to look at.`,\n )\n this.name = 'YhikasSyncStaleError'\n this.stale = stale\n }\n}\n\nfunction describeOne(entry: ResourceStaleness): string {\n const suffix = entry.lastError ? ` — last error: ${entry.lastError}` : ''\n if (entry.lastSuccessAt) {\n return `${entry.resource}: last succeeded ${formatAge(entry.ageMs)} ago${suffix}`\n }\n // An infinite age is the no-tracking-row-at-all case. Rendering it through\n // `formatAge` would print \"Infinityd\", which reads as a bug in the alert\n // rather than as the loudest thing the alert has to say.\n if (!Number.isFinite(entry.ageMs)) {\n return `${entry.resource}: has NEVER synced — no tracking row exists at all${suffix}`\n }\n return `${entry.resource}: has NEVER succeeded (tracked for ${formatAge(entry.ageMs)})${suffix}`\n}\n\nfunction formatAge(ms: number): string {\n if (!Number.isFinite(ms)) return 'an unknown time'\n const hours = ms / 3_600_000\n if (hours < 1) return `${Math.round(ms / 60_000)}m`\n if (hours < 48) return `${Math.round(hours)}h`\n return `${Math.round(hours / 24)}d`\n}\n\n/**\n * Assess every in-scope resource.\n *\n * A resource with no state row at all counts as stale with an age of\n * `Infinity`: \"nothing has ever written this row\" is the loudest possible\n * version of \"this sync has never run\", and treating a missing row as\n * not-yet-stale would make an install that never once synced look healthy\n * forever — the exact unobserved-bound failure S7 names.\n *\n * A resource that HAS a row but no success is measured from `firstSeenAt`, so\n * a freshly installed sync gets one full window to succeed before it alerts.\n */\nexport function assessStaleness(\n records: readonly SyncStateRecord[],\n now: Date,\n staleAfterMs: number,\n): ResourceStaleness[] {\n const byResource = new Map(records.map((record) => [record.resource, record]))\n\n return SYNC_RESOURCES.map((resource) => {\n const record = byResource.get(resource)\n if (!record) {\n return {\n resource,\n lastSuccessAt: null,\n ageMs: Number.POSITIVE_INFINITY,\n stale: true,\n lastError: null,\n }\n }\n const since = record.lastSuccessAt ?? record.firstSeenAt\n const ageMs = now.getTime() - since.getTime()\n return {\n resource,\n lastSuccessAt: record.lastSuccessAt,\n ageMs,\n stale: ageMs > staleAfterMs,\n lastError: record.lastError,\n }\n })\n}\n\n/**\n * Assess, and throw if anything is stale.\n *\n * @throws {YhikasSyncStaleError} which the queue turns into a dead-lettered\n * job and therefore into the shipped alert.\n */\nexport function assertNotStale(\n records: readonly SyncStateRecord[],\n now: Date,\n staleAfterMs: number,\n): ResourceStaleness[] {\n const assessed = assessStaleness(records, now, staleAfterMs)\n const stale = assessed.filter((entry) => entry.stale)\n if (stale.length > 0) throw new YhikasSyncStaleError(stale, staleAfterMs)\n return assessed\n}\n"],"mappings":"sKAqEA,IAAa,EAAb,cAA6C,KAAM,CACjD,OACA,YAAY,EAAwB,EAAqB,CACvD,MACE,GAAG,EAAO,OAAO,MAAM,EAAO,QAAU,EAAO,QAAU,EAAO,QAAU,EAAO,OAAO,GACnF,EAAS,oIAEhB,EACA,KAAK,KAAO,0BACZ,KAAK,OAAS,CAChB,CACF,EAmBA,SAAS,EAAO,EAA6B,CAC3C,GAAI,aAAiB,KAAM,OAAO,OAAO,MAAM,EAAM,QAAQ,CAAC,EAAI,KAAO,EACzE,GAAI,OAAO,GAAU,SAAU,CAC7B,IAAM,EAAS,IAAI,KAAK,CAAK,EAC7B,OAAO,OAAO,MAAM,EAAO,QAAQ,CAAC,EAAI,KAAO,CACjD,CACA,OAAO,IACT,CAEA,eAAsB,EACpB,EACA,EACA,EACqB,CAErB,OAAO,MADY,EAAO,SAAS,CAAE,OAAM,CAAC,EAAA,CAChC,IAAK,IAAS,CACxB,GAAI,OAAO,EAAI,EAAE,EACjB,IAAK,OAAO,EAAI,EAAS,EACzB,OAAQ,OAAO,EAAI,MAAM,EACzB,WAAY,OAAO,EAAI,YAAe,SAAW,EAAI,WAAa,KAClE,YAAa,EAAO,EAAI,WAAW,CACrC,EAAE,CACJ,CAWA,eAAsB,EAAU,EAA6C,CAC3E,GAAM,CAAE,WAAU,OAAM,SAAQ,kBAAiB,UAAW,EACtD,EAAsB,CAC1B,QAAS,EACT,QAAS,EACT,QAAS,EACT,UAAW,EAAK,UAAU,OAC1B,OAAQ,CACV,EAEA,IAAK,IAAM,KAAO,EAAK,OACrB,GAAI,CAOF,IAAM,EAAU,MAAM,EAAO,OAAO,CAAE,GAAG,EAAI,KAAM,OAAQ,OAAQ,CAAC,EACpE,MAAM,EAAqB,EAAQ,OAAO,EAAQ,EAAE,EAAG,EAAK,CAAe,EAC3E,MAAM,EAAU,EAAQ,OAAO,EAAQ,EAAE,EAAG,EAAI,KAAM,IAAI,EAC1D,EAAO,SAAW,CACpB,OAAS,EAAK,CACZ,EAAO,QAAU,EACjB,EAAO,MAAM,CAAE,MAAK,WAAU,IAAK,EAAI,GAAI,EAAG,4CAA4C,CAC5F,CAGF,IAAK,GAAM,CAAE,QAAO,SAAS,EAAK,OAChC,GAAI,CAEF,MAAM,EAAO,OAAO,EAAM,GAAI,CAAE,GAAG,EAAI,IAAK,CAAC,EAC7C,MAAM,EAAqB,EAAQ,EAAM,GAAI,EAAK,CAAe,EACjE,MAAM,EAAU,EAAQ,EAAM,GAAI,EAAI,KAAM,EAAM,WAAW,EAC7D,EAAO,SAAW,CACpB,OAAS,EAAK,CACZ,EAAO,QAAU,EACjB,EAAO,MAAM,CAAE,MAAK,WAAU,IAAK,EAAI,GAAI,EAAG,4CAA4C,CAC5F,CAGF,IAAK,IAAM,KAAS,EAAK,OACvB,GAAI,CAeF,MAAM,EAAO,gBAAgB,EAAM,GAAI,CAAE,OAAQ,OAAQ,EAAG,CAAe,EAC3E,MAAM,EAAO,OAAO,EAAM,GAAI,CAAE,OAAQ,OAAQ,CAAC,EACjD,EAAO,SAAW,EAClB,EAAO,KACL,CAAE,WAAU,IAAK,EAAM,GAAI,EAK3B,mFACF,CACF,OAAS,EAAK,CACZ,EAAO,QAAU,EACjB,EAAO,MAAM,CAAE,MAAK,WAAU,IAAK,EAAM,GAAI,EAAG,4CAA4C,CAC9F,CAGF,GAAI,EAAO,OAAS,EAAG,MAAM,IAAI,EAAwB,EAAU,CAAM,EACzE,OAAO,CACT,CAmBA,eAAe,EACb,EACA,EACA,EACA,EACe,CACf,MAAM,EAAO,OAAO,EAAI,CACtB,OAAQ,YACR,YAAa,GAAe,IAAI,KAChC,WAAY,CACd,CAAC,CACH,CAwBA,eAAe,EACb,EACA,EACA,EACA,EACe,CACf,GAAI,EAAI,UAAW,CAWjB,MAAM,EAAO,gBAAgB,EAAI,CAAE,GAAG,EAAI,SAAU,EAAG,CAAe,EACtE,MAAM,EAAO,gBAAgB,EAAI,CAAE,OAAQ,WAAY,EAAG,CAAe,EACzE,MACF,CACA,MAAM,EAAO,gBAAgB,EAAI,CAAE,OAAQ,OAAQ,EAAG,CAAe,EACrE,MAAM,EAAO,kBAAkB,EAAI,CAAe,CACpD,CCxQA,IAAa,EAAb,cAAyC,KAAM,CAC7C,SACA,KACA,OAEA,YACE,EACA,EACA,EACA,EACA,CACA,MAAM,EAAS,GAAS,QAAU,IAAA,GAAY,IAAA,GAAY,CAAE,MAAO,EAAQ,KAAM,CAAC,EAClF,KAAK,KAAO,sBACZ,KAAK,SAAW,EAChB,KAAK,KAAO,EACZ,KAAK,OAAS,GAAS,MACzB,CACF,EAQa,EAAb,cAA4C,KAAM,CAChD,SACA,OAmBA,YAAY,EAAwB,EAA0C,EAAiB,CAC7F,MAAM,CAAO,EACb,KAAK,KAAO,yBACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,ECFA,SAAgB,EAAW,EAAwB,CACjD,OAAO,EAAW,QAAQ,CAAC,CAAC,OAAO,EAAa,CAAK,CAAC,CAAC,CAAC,OAAO,KAAK,CACtE,CAEA,SAAS,EAAa,EAAwB,CAW5C,OAVI,IAAU,KAAa,OACvB,IAAU,IAAA,GAAkB,YAC5B,MAAM,QAAQ,CAAK,EAAU,IAAI,EAAM,IAAI,CAAY,CAAC,CAAC,KAAK,GAAG,EAAE,GACnE,OAAO,GAAU,SAKZ,IAJS,OAAO,QAAQ,CAAgC,CAAC,CAC7D,QAAQ,EAAG,KAAO,IAAM,IAAA,EAAS,CAAC,CAClC,MAAM,CAAC,GAAI,CAAC,KAAQ,EAAI,EAAI,GAAK,IAAI,EAAU,CAAC,CAChD,KAAK,CAAC,EAAG,KAAO,GAAG,KAAK,UAAU,CAAC,EAAE,GAAG,EAAa,CAAC,GACxC,CAAC,CAAC,KAAK,GAAG,EAAE,GAExB,KAAK,UAAU,CAAK,CAC7B,CAiBA,SAAgB,EAAY,EAAsC,CAChE,GAAM,CAAE,WAAU,WAAU,QAAO,QAAO,SAAQ,SAAU,EAM5D,GAAI,EAAS,OAAS,EAAM,QAC1B,MAAM,IAAI,EACR,EACA,qBACA,yBAAyB,EAAS,OAAO,GAAG,EAAS,mBAAmB,EAAM,QAAQ,kGAEjF,EAAS,OAAS,EAAM,QAAQ,YACvC,EAGF,IAAM,EAAgB,IAAI,IAC1B,IAAK,IAAM,KAAO,EAAU,CAC1B,IAAM,EAAM,EAAM,CAAG,EACrB,GAAI,EAAc,IAAI,CAAG,EACvB,MAAM,IAAI,EACR,EACA,gBACA,6BAA6B,EAAS,oCAAoC,EAAI,4EAEhF,EAEF,EAAc,IAAI,EAAK,CAAG,CAC5B,CAEA,IAAM,EAAa,IAAI,IACvB,IAAK,IAAM,KAAO,EAAO,CACvB,GAAI,EAAW,IAAI,EAAI,GAAG,EAGxB,MAAM,IAAI,EACR,EACA,gBACA,SAAS,EAAS,iCAAiC,EAAI,IAAI,6CAE7D,EAEF,EAAW,IAAI,EAAI,IAAK,CAAG,CAC7B,CAEA,IAAM,EAAc,CAAC,EACf,EAAwC,CAAC,EACzC,EAAwB,CAAC,EAE/B,IAAK,GAAM,CAAC,EAAK,KAAQ,EAAe,CACtC,IAAM,EAAW,EAAW,IAAI,CAAG,EACnC,GAAI,CAAC,EAAU,CACb,EAAO,KAAK,CAAG,EACf,QACF,CAII,EAAS,aAAe,EAAO,CAAG,GAAK,EAAS,SAAW,YAC7D,EAAU,KAAK,CAAQ,EAEvB,EAAO,KAAK,CAAE,MAAO,EAAU,KAAI,CAAC,CAExC,CAEA,IAAM,EAAS,EAAM,OAAQ,GAAQ,EAAI,SAAW,aAAe,CAAC,EAAc,IAAI,EAAI,GAAG,CAAC,EAI9F,OAFA,EAA4B,EAAU,EAAS,OAAQ,EAAO,EAAQ,CAAK,EAEpE,CAAE,SAAQ,SAAQ,YAAW,QAAO,CAC7C,CAYA,SAAS,EACP,EACA,EACA,EACA,EACA,EACM,CACN,GAAI,EAAO,SAAW,EAAG,OAEzB,GAAI,IAAkB,EACpB,MAAM,IAAI,EACR,EACA,iBACA,8BAA8B,EAAS,cAAc,EAAO,OAAO,+JAGrE,EAGF,IAAM,EAAiB,EAAM,OAAQ,GAAQ,EAAI,SAAW,WAAW,CAAC,CAAC,OACzE,GAAI,EAAiB,EAAM,mBAAoB,OAE/C,IAAM,EAAW,EAAO,OAAS,EACjC,GAAI,EAAW,EAAM,kBACnB,MAAM,IAAI,EACR,EACA,kBACA,yBAAyB,EAAO,OAAO,MAAM,EAAe,aAAa,EAAS,SAC5E,KAAK,MAAM,EAAW,GAAG,EAAE,gBAAgB,KAAK,MAAM,EAAM,kBAAoB,GAAG,EAAE,0HAG7F,CAEJ,CC7LA,SAAgB,EACd,EACA,EACkB,CAClB,GAAI,EAAc,KAAK,CAAC,CAAC,SAAW,EAKlC,MAAU,UAAU,4DAA4D,EAElF,MAAO,CACL,SAAW,GAAY,EAAO,SAAS,CAAO,EAQ9C,OAAS,GAAS,EAAO,OAAO,CAAa,EAC7C,QAAS,EAAI,IAAS,EAAO,OAAO,EAAI,CAAa,EACrD,iBAAkB,EAAI,EAAM,IAC1B,EAAO,gBAAgB,EAAI,EAAe,EAAQ,CAAE,eAAc,CAAC,EAGrE,mBAAoB,EAAI,IAAW,EAAO,kBAAkB,EAAI,CAAM,CACxE,CACF,CCgBA,MAAa,EAAuC,CAAA,KAAA,IAAqB,EAiBzE,SAAgB,EAAsB,EAAsB,CAC1D,GAAI,CAAC,EAAkB,SAAS,CAAM,EACpC,MAAU,UACR,oCAAoC,EAAO,+JAG7C,CAEJ,CAGA,SAAgB,EAAkB,EAA+B,CAE/D,OADA,EAAsB,CAAa,EAC5B,IAAA,KAAA,KAAA,IACT,CAEA,SAAS,EAAW,EAAwB,EAAwB,CAClE,OAAO,IAAA,KAAuB,EAAK,GAAK,EAAK,EAC/C,CAUA,SAAS,EAAQ,EAAwB,CACvC,OAAO,EAAM,KAAK,CAAC,CAAC,OAAS,CAC/B,CAaA,MAAM,GAAoB,oBAc1B,SAAS,GAAiB,EAAuB,CAE/C,MAAO,CAAC,GAAkB,KAAK,CAAI,GAAK,CAAC,wBAAwB,KAAK,CAAI,CAC5E,CAEA,SAAgB,EACd,EACA,EACkB,CAClB,EAAsB,EAAQ,aAAa,EAC3C,IAAM,EAAkB,EAAkB,EAAQ,aAAa,EACzD,EAA4B,CAAC,EAC7B,EAAwB,CAAC,EAE/B,IAAK,IAAM,KAAO,EAAM,CACtB,GAAI,EAAI,KAAK,OAAS,IAAmB,CACvC,EAAQ,KAAK,CACX,IAAK,EAAI,KAAK,MAAM,EAAG,EAAE,EACzB,OAAQ,gDACV,CAAC,EACD,QACF,CAEA,IAAM,EAAc,EAAW,EAAI,KAAM,EAAQ,aAAa,EAC9D,GAAI,CAAC,EAAQ,CAAW,EAAG,CACzB,EAAQ,KAAK,CACX,IAAK,EAAI,KACT,OACE,OAAO,EAAQ,cAAc,4GAEjC,CAAC,EACD,QACF,CAEA,IAAM,EAAgB,EAAW,EAAI,KAAM,CAAe,EACpD,EAAgC,CACpC,KAAM,EAAI,KACV,KAAM,EAEN,UAAW,EAAI,UACf,WAAY,EAAI,WAChB,WAAY,EAAI,WAChB,SAAU,EAAI,SACd,eAAgB,EAAI,eACpB,YAAa,EAAI,YACjB,sBAAuB,EAAI,eAC3B,UAAW,EAAI,UACf,SAAA,MACA,aAAA,MACA,WAAY,EAAQ,EAAI,KAAK,EAAE,CACjC,EACM,EAAY,EAAQ,CAAa,EAAI,CAAE,KAAM,CAAc,EAAI,KAErE,EAAU,KAAK,CAAE,IAAK,EAAI,KAAM,OAAM,YAAW,KAAM,EAAO,EAAM,CAAS,CAAE,CAAC,CAClF,CAEA,MAAO,CAAE,YAAW,SAAQ,CAC9B,CAEA,SAAgB,EACd,EACA,EACA,EACkB,CAClB,EAAsB,EAAQ,aAAa,EAC3C,IAAM,EAAkB,EAAkB,EAAQ,aAAa,EACzD,EAA4B,CAAC,EAC7B,EAAwB,CAAC,EAE/B,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAe,EAAW,EAAI,MAAO,EAAQ,aAAa,EAI1D,EAAc,EAAS,EAAW,EAAO,CAAG,EAAG,EAAQ,aAAa,CAAC,EAE3E,GAAI,CAAC,EAAQ,CAAY,GAAK,CAAC,EAAQ,CAAW,EAAG,CACnD,EAAQ,KAAK,CACX,IAAK,EAAI,KACT,OAAQ,mBAAmB,EAAQ,cAAc,yBACnD,CAAC,EACD,QACF,CAEA,GAAI,EAAI,KAAK,OAAS,KAAqB,EAAI,KAAK,OAAS,IAAmB,CAC9E,EAAQ,KAAK,CACX,IAAK,EAAI,KAAK,MAAM,EAAG,EAAE,EACzB,OACE,sJAEJ,CAAC,EACD,QACF,CAEA,GAAI,CAAC,GAAiB,EAAI,IAAI,EAAG,CAC/B,EAAQ,KAAK,CACX,IAAK,EAAI,KACT,OACE,iBAAiB,KAAK,UAAU,EAAI,IAAI,EAAE,yQAI9C,CAAC,EACD,QACF,CAEA,IAAM,EAAiB,EAAW,EAAI,MAAO,CAAe,EACtD,EAAgB,EAAS,EAAW,EAAO,CAAG,EAAG,CAAe,CAAC,EACjE,EAAc,EAAS,EAAI,aAAa,EAExC,EAAgC,CACpC,KAAM,EAAI,KACV,MAAO,EACP,WAAY,EAAI,KAChB,KAAM,EACN,MAAO,EAAI,MACX,WAAY,EAAQ,EAAI,MAAM,EAAE,GAAK,EAAQ,CAAW,CAC1D,EAIM,EACJ,EAAQ,CAAc,GAAK,EAAQ,CAAa,EAC5C,CAAE,MAAO,EAAgB,KAAM,CAAc,EAC7C,KAEN,EAAU,KAAK,CAAE,IAAK,EAAI,KAAM,OAAM,YAAW,KAAM,EAAO,EAAM,CAAS,CAAE,CAAC,CAClF,CAEA,MAAO,CAAE,YAAW,SAAQ,CAC9B,CA+BA,SAAgB,EACd,EACA,EACkB,CAClB,EAAsB,EAAQ,aAAa,EAC3C,IAAM,EAAkB,EAAkB,EAAQ,aAAa,EAEzD,EAAU,EAAS,QACtB,OAAQ,GAAW,EAAO,QAAQ,CAAC,CACnC,MAAM,EAAG,IAAM,EAAE,MAAQ,EAAE,KAAK,EAC7B,EAAS,EAAQ,MAAM,EAAA,EAAmB,EAC1C,EAAwB,CAAC,EAC3B,EAAQ,OAAS,EAAO,QAK1B,EAAQ,KAAK,CACX,IAAK,EACL,OACE,GAAG,EAAQ,OAAS,EAAO,OAAO,gMAGtC,CAAC,EAGH,IAAM,EAAc,GAClB,EAAO,IAAK,GAAW,EAAW,EAAO,KAAM,CAAM,CAAC,CAAC,CAAC,OAAO,CAAO,EAElE,EAAe,EAAW,EAAS,eAAgB,EAAQ,aAAa,EACxE,EAAiB,EAAW,EAAQ,aAAa,EAEvD,GAAI,CAAC,EAAQ,CAAY,GAAK,EAAe,SAAW,EACtD,MAAM,IAAI,EACR,EACA,gBACA,8EACM,EAAQ,cAAc,+CACvB,EAAS,QAAQ,OAAO,uBAAuB,EAAQ,OAAO,kBAC9D,EAAe,OAAO,SAAS,EAAQ,cAAc,6kBAQ5D,EAGF,IAAM,EAAiB,EAAW,EAAS,eAAgB,CAAe,EACpE,EAAmB,EAAW,CAAe,EAC7C,EAAe,EAAW,EAAS,eAAA,IAAyB,EAC5D,EAAiB,EAAA,IAAoB,EAErC,EAAgC,CACpC,IAAK,EAML,eAAgB,EAAa,KAAK,EAClC,QAAS,EACT,WAAY,EAAQ,CAAY,GAAK,EAAe,OAAS,CAC/D,EAKM,EACJ,EAAQ,CAAc,GAAK,EAAiB,OAAS,EACjD,CAuBE,eAAgB,EAAe,KAAK,EACpC,QAAS,CACX,EACA,KAEN,MAAO,CACL,UAAW,CAAC,CAAE,IAAK,EAAe,OAAM,YAAW,KAAM,EAAO,EAAM,CAAS,CAAE,CAAC,EAClF,SACF,CACF,CAGA,SAAS,EAAO,EAAyC,CACvD,MAAO,CAAE,GAAI,EAAI,cAAe,GAAI,EAAI,aAAc,CACxD,CAEA,SAAS,EAAO,EAA+B,EAAmD,CAChG,OAAO,EAAW,CAAE,OAAM,WAAU,CAAC,CACvC,CCrVA,IAAa,EAAb,cAAwC,KAAM,CAC5C,QACA,YAAY,EAAyB,CACnC,IAAM,EAAS,EAAQ,SAAS,OAAQ,GAAY,CAAC,EAAQ,EAAE,EAC/D,MACE,gCAAgC,EAAO,IAAK,GAAM,EAAE,QAAQ,CAAC,CAAC,KAAK,IAAI,EAAE,IACvE,EAAO,IAAK,GAAM,EAAE,KAAK,CAAC,CAAC,KAAK,KAAK,CACzC,EACA,KAAK,KAAO,qBACZ,KAAK,QAAU,CACjB,CACF,EAEA,eAAsB,EAAc,EAA4C,CAC9E,IAAM,EAAM,EAAK,UAAc,IAAI,MAC7B,EAAkB,EAAkB,EAAK,aAAa,EAEtD,EAA8B,CAAC,EAErC,EAAS,KACP,MAAM,EAAQ,CACZ,SAAU,EACV,SAAU,OACV,OAAQ,EAAK,eACb,gBAAiB,SACf,EAAiB,MAAM,EAAK,SAAS,eAAe,EAAG,CACrD,cAAe,EAAK,aACtB,CAAC,EACH,OACA,kBACA,KACF,CAAC,CACH,EAEA,EAAS,KACP,MAAM,EAAQ,CACZ,SAAU,EACV,SAAU,OACV,OAAQ,EAAK,oBACb,gBAAiB,SACf,EACE,MAAM,EAAK,SAAS,oBAAoB,EACxC,CAAE,cAAe,EAAK,aAAc,EACpC,EAAK,YACP,EACF,OACA,kBACA,KACF,CAAC,CACH,EAKA,EAAS,KACP,MAAM,EAAQ,CACZ,SAAU,EACV,SAAU,MACV,OAAQ,EAAK,eACb,gBAAiB,SACf,EAAgB,MAAM,EAAK,SAAS,cAAc,EAAG,CACnD,cAAe,EAAK,aACtB,CAAC,EACH,OACA,kBACA,KACF,CAAC,CACH,EAEA,IAAM,EAA0B,CAAE,WAAU,GAAI,EAAS,MAAO,GAAY,EAAQ,EAAE,CAAE,EACxF,GAAI,CAAC,EAAQ,GAAI,MAAM,IAAI,EAAmB,CAAO,EACrD,OAAO,CACT,CAYA,eAAe,EAAQ,EAA+C,CACpE,GAAM,CAAE,WAAU,WAAU,SAAQ,kBAAiB,OAAM,kBAAiB,OAAQ,EAC9E,CAAE,SAAQ,QAAO,SAAU,EAEjC,GAAI,CASF,MAAM,EAAM,OAAO,EAAU,EAAI,CAAC,EAClC,MAAM,EAAM,cAAc,EAAU,EAAI,CAAC,EAKzC,IAAM,EAAa,MAAM,EAAgB,EAOzC,IAAK,IAAM,KAAQ,EAAW,QAAQ,MAAM,EAAG,EAAgB,EAC7D,EAAO,KACL,CAAE,WAAU,IAAK,EAAK,IAAK,OAAQ,EAAK,MAAO,EAC/C,iFACF,EAEE,EAAW,QAAQ,OAAS,IAC9B,EAAO,KACL,CACE,WACA,WAAY,EAAW,QAAQ,OAAS,GACxC,MAAO,EAAW,QAAQ,MAC5B,EACA,2DACF,EAIF,IAAM,EAAQ,MAAM,EAAc,EAAQ,EAAU,EAAM,OAAO,EAW3D,EAAS,MAAM,EAAU,CAAE,WAAU,KAV9B,EAAuB,CAClC,WACA,SAAU,EAAW,UACrB,QACA,MAAQ,GAAQ,EAAI,IACpB,OAAS,GAAQ,EAAI,KACrB,OACF,CAG8C,EAAG,SAAQ,kBAAiB,QAAO,CAAC,EAOlF,OALA,MAAM,EAAM,cAAc,EAAU,EAAI,EAAG,CAAM,EACjD,EAAO,KACL,CAAE,WAAU,GAAG,EAAQ,QAAS,EAAW,QAAQ,MAAO,EAC1D,8BACF,EACO,CAAE,WAAU,GAAI,GAAM,SAAQ,QAAS,EAAW,QAAQ,OAAQ,MAAO,IAAK,CACvF,OAAS,EAAK,CACZ,IAAM,EAAU,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,EAC/D,EAAO,MAAM,CAAE,MAAK,UAAS,EAAG,6DAA6D,EAC7F,GAAI,CAEF,MAAM,EAAM,cAAc,EAAU,EAAI,EAAG,CAAO,CACpD,OAAS,EAAU,CAIjB,EAAO,MACL,CAAE,IAAK,EAAU,UAAS,EAC1B,gEACF,CACF,CACA,MAAO,CAAE,WAAU,GAAI,GAAO,OAAQ,KAAM,QAAS,EAAG,MAAO,CAAQ,CACzE,CACF,CCpNA,MAAM,EAAmB,KAEzB,SAAgB,EAAc,EAAyB,CACrD,OAAO,EAAQ,QAAU,EAAmB,EAAU,GAAG,EAAQ,MAAM,EAAG,EAAmB,CAAC,EAAE,EAClG,CAEA,SAAgB,EAAqB,EAAyC,CAC5E,MAAO,CACL,MAAM,OAAO,EAAU,EAAK,CAK1B,MAAM,EAAO,OAAO,CAAE,WAAU,YAAa,CAAI,EAAG,CAAE,OAAQ,WAAY,IAAK,CAAE,UAAS,CAAE,CAAC,CAC/F,EAEA,MAAM,cAAc,EAAU,EAAK,CACjC,MAAM,EAAO,OACX,CAAE,WAAU,YAAa,EAAK,cAAe,CAAI,EACjD,CAAE,OAAQ,WAAY,IAAK,CAAE,cAAe,CAAI,CAAE,CACpD,CACF,EAEA,MAAM,cAAc,EAAU,EAAK,EAAQ,CACzC,MAAM,EAAO,OACX,CACE,WACA,YAAa,EACb,cAAe,EACf,cAAe,EACf,UAAW,KACX,YAAa,EAAO,QACpB,YAAa,EAAO,QACpB,YAAa,EAAO,QACpB,cAAe,EAAO,SACxB,EACA,CACE,OAAQ,WACR,IAAK,CACH,cAAe,EACf,cAAe,EAGf,UAAW,KACX,YAAa,EAAO,QACpB,YAAa,EAAO,QACpB,YAAa,EAAO,QACpB,cAAe,EAAO,SACxB,CACF,CACF,CACF,EAEA,MAAM,cAAc,EAAU,EAAK,EAAO,CACxC,IAAM,EAAY,EAAc,CAAK,EAIrC,MAAM,EAAO,OACX,CAAE,WAAU,YAAa,EAAK,cAAe,EAAK,WAAU,EAC5D,CAAE,OAAQ,WAAY,IAAK,CAAE,cAAe,EAAK,WAAU,CAAE,CAC/D,CACF,EAEA,MAAM,SAAU,CAWd,OAAO,MAJY,EAAO,SAAS,CACjC,MAAO,CAAE,SAAU,CAAE,GAAI,CAAC,GAAG,CAAc,CAAE,CAAE,EAC/C,MAAO,EAAe,MACxB,CAAC,EAAA,CACW,IAAK,IAAS,CACxB,SAAU,OAAO,EAAI,QAAQ,EAC7B,YAAa,EAAI,YACjB,cAAgB,EAAI,eAAiC,KACrD,cAAgB,EAAI,eAAiC,KACrD,UAAY,EAAI,WAA+B,IACjD,EAAE,CACJ,CACF,CACF,CCvFA,MAAM,EAAiB,IAIjB,EAAkB,IAAM,KAOxB,EAAgB,EACnB,OAAO,CAAC,CACR,IAAI,EAAE,CAAC,CACP,MAAM,kBAAmB,2CAA2C,EAU1D,EAAyB,EAAE,OAAO,CAC7C,GAAI,EAAE,OAAO,CAAC,CAAC,IAAI,GAAgB,EACnC,GAAI,EAAE,OAAO,CAAC,CAAC,IAAI,GAAgB,CACrC,CAAC,EAaY,EAAoB,EAAE,OAAO,CACxC,KAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAc,EAC1C,KAAM,EACN,UAAW,EAAc,SAAS,EAClC,WAAY,EAAc,SAAS,EACnC,WAAY,EAAc,SAAS,EACnC,SAAU,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EACpC,YAAa,EAAc,SAAS,EACpC,eAAgB,EAAc,SAAS,EACvC,UAAW,EAAc,SAAS,EAClC,eAAgB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAC5C,CAAC,EAaY,EAAyB,EAAE,OAAO,CAC7C,KAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAc,EAC1C,MAAO,EAYP,KAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAc,EAC1C,cAAe,EAAE,OAAO,CAAC,CAAC,IAAI,CAAe,EAC7C,cAAe,EAAE,OAAO,CAAC,CAAC,IAAI,CAAe,EAC7C,MAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CACxB,CAAC,EAcY,EAA0B,EAAE,OAAO,CAC9C,QAAS,EAAE,QAAQ,EAAI,EACvB,UAAW,EAAE,MAAM,CAAiB,CACtC,CAAC,EAEY,EAA+B,EAAE,OAAO,CACnD,QAAS,EAAE,QAAQ,EAAI,EACvB,UAAW,EAAE,MAAM,CAAsB,CAC3C,CAAC,EAcY,EAAsB,EAAE,OAAO,CAC1C,KAAM,EACN,SAAU,EAAE,QAAQ,EACpB,MAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CACxB,CAAC,EAmCY,EAAyB,EAAE,OAAO,CAC7C,QAAS,EAAE,QAAQ,EAAI,EACvB,eAAgB,EAChB,QAAS,EAAE,MAAM,CAAmB,CAAC,CAAC,IAAI,GAAuB,CACnE,CAAC,EChJK,EAAqB,EAAI,KAAO,KA0BtC,eAAe,GACb,EACA,EACA,EACiB,CACjB,IAAM,EAAS,EAAS,KACxB,GAAI,CAAC,EAAQ,MAAO,GAKpB,IAAM,EAAkD,EAAO,UAAU,EACnE,EAAU,IAAI,YAChB,EAAO,EACP,EAAO,GACX,GAAI,CACF,OAAa,CACX,GAAM,CAAE,OAAM,SAAU,MAAM,EAAO,KAAK,EAC1C,GAAI,EAAM,MAEV,GADA,GAAQ,EAAM,WACV,EAAO,EAET,MADA,MAAM,EAAO,OAAO,EACd,IAAI,EACR,EACA,QACA,+BAA+B,EAAmB,aAAa,EAAK,+FAEpE,CAAE,OAAQ,EAAS,MAAO,CAC5B,EAIF,GAAQ,EAAQ,OAAO,EAAO,CAAE,OAAQ,EAAK,CAAC,CAChD,CACA,OAAO,EAAO,EAAQ,OAAO,CAC/B,QAAU,CACR,EAAO,YAAY,CACrB,CACF,CAEA,eAAe,EAAY,EAAmC,CAC5D,GAAI,CACF,MAAM,EAAS,MAAM,OAAO,CAC9B,MAAQ,CAER,CACF,CAEA,IAAa,GAAb,KAAkC,CAChC,GACA,GACA,GACA,GAEA,YAAY,EAAsC,CAChD,IAAI,EACJ,GAAI,CACF,EAAS,IAAI,IAAI,EAAQ,OAAO,CAClC,OAAS,EAAO,CACd,MAAU,UAAU,4CAA4C,EAAQ,UAAW,CAAE,OAAM,CAAC,CAC9F,CACA,GAAI,EAAO,WAAa,SAAW,EAAO,WAAa,SACrD,MAAU,UACR,oDAAoD,EAAO,SAAS,kFAEtE,EAEF,GAAI,EAAO,WAAa,IAMtB,MAAU,UACR,6DAA6D,EAAO,SAAS,iFAE/E,EAEF,GAAI,EAAQ,OAAO,SAAW,EAC5B,MAAU,UAAU,uCAAuC,EAE7D,KAAKA,GAAW,EAChB,KAAKC,GAAU,EAAQ,OACvB,KAAKC,GAAa,EAAQ,UAC1B,KAAKC,GAAS,EAAQ,WAAa,WAAW,KAChD,CAGA,MAAM,gBAAyC,CAE7C,OAAO,MADY,KAAKC,GAAK,EAAqB,EAAiB,CAAuB,EAAA,CAC9E,SACd,CAGA,MAAM,qBAAmD,CAMvD,OAAO,MALY,KAAKA,GACtB,EACA,EACA,CACF,EAAA,CACY,SACd,CAUA,MAAM,eAA2C,CAC/C,OAAO,MAAM,KAAKA,GAAK,EAAoB,EAAgB,CAAsB,CACnF,CAOA,KAAMA,GAAQ,EAAwB,EAAc,EAAkC,CAEpF,IAAM,EAAM,IAAI,IAAI,EAAM,KAAKJ,EAAQ,EAMjC,EAAS,YAAY,QAAQ,KAAKE,EAAU,EAE9C,EACJ,GAAI,CACF,EAAW,MAAM,KAAKC,GAAO,EAAK,CAChC,OAAQ,MACR,QAAS,CACP,cAAe,UAAU,KAAKF,KAC9B,OAAQ,kBACV,EACA,SAAU,QACV,QACF,CAAC,CACH,OAAS,EAAO,CACd,IAAM,EAAW,EAAO,QACxB,MAAM,IAAI,EACR,EACA,EAAW,UAAY,UACvB,EACI,+BAA+B,EAAK,UAAU,KAAKC,GAAW,IAC9D,mCAAmC,IACvC,CAAE,OAAM,CACV,CACF,CAEA,GAAI,CAAC,EAAS,GAKZ,MADA,MAAM,EAAY,CAAQ,EACpB,IAAI,EACR,EACA,OACA,wBAAwB,EAAK,aAAa,EAAS,SACnD,CAAE,OAAQ,EAAS,MAAO,CAC5B,EAGF,IAAM,EAAiB,OAAO,EAAS,QAAQ,IAAI,gBAAgB,GAAK,GAAU,EAClF,GAAI,OAAO,SAAS,CAAc,GAAK,EAAiB,EAEtD,MADA,MAAM,EAAY,CAAQ,EACpB,IAAI,EACR,EACA,QACA,yBAAyB,EAAe,aAAa,EAAK,cAAc,EAAmB,eAC3F,CAAE,OAAQ,EAAS,MAAO,CAC5B,EAQF,IAAI,EACJ,GAAI,CACF,EAAO,MAAM,GAAY,EAAU,EAAU,CAAI,CACnD,OAAS,EAAO,CACd,GAAI,aAAiB,EAAqB,MAAM,EAIhD,IAAM,EAAW,EAAO,QACxB,MAAM,IAAI,EACR,EACA,EAAW,UAAY,QACvB,EACI,uCAAuC,EAAK,UAAU,KAAKA,GAAW,IACtE,GAAG,EAAK,yCACZ,CAAE,OAAQ,EAAS,OAAQ,OAAM,CACnC,CACF,CAEA,IAAI,EACJ,GAAI,CACF,EAAO,KAAK,MAAM,CAAI,CACxB,OAAS,EAAO,CACd,MAAM,IAAI,EAAoB,EAAU,QAAS,GAAG,EAAK,mCAAoC,CAC3F,OAAQ,EAAS,OACjB,OACF,CAAC,CACH,CAEA,IAAM,EAAS,EAAO,UAAU,CAAI,EACpC,GAAI,CAAC,EAAO,QAOV,MAAM,IAAI,EACR,EACA,QACA,GAAG,EAAK,+DAPI,EAAO,MAAM,OACxB,MAAM,EAAG,CAAC,CAAC,CACX,IAAK,GAAU,GAAG,EAAM,KAAK,KAAK,GAAG,GAAK,SAAS,IAAI,EAAM,SAAS,CAAC,CACvE,KAAK,IAIqE,IAC3E,CAAE,OAAQ,EAAS,MAAO,CAC5B,EAEF,OAAO,EAAO,IAChB,CACF,ECrQa,EAAb,cAA0C,KAAM,CAC9C,MACA,YAAY,EAAqC,EAAsB,CACrE,IAAM,EAAS,EAAM,IAAI,EAAW,CAAC,CAAC,KAAK,IAAI,EAC/C,MACE,yCAAyC,EAAU,CAAY,EAAE,WAAW,EAAO,mIAGrF,EACA,KAAK,KAAO,uBACZ,KAAK,MAAQ,CACf,CACF,EAEA,SAAS,GAAY,EAAkC,CACrD,IAAM,EAAS,EAAM,UAAY,kBAAkB,EAAM,YAAc,GAUvE,OATI,EAAM,cACD,GAAG,EAAM,SAAS,mBAAmB,EAAU,EAAM,KAAK,EAAE,MAAM,IAKtE,OAAO,SAAS,EAAM,KAAK,EAGzB,GAAG,EAAM,SAAS,qCAAqC,EAAU,EAAM,KAAK,EAAE,GAAG,IAF/E,GAAG,EAAM,SAAS,oDAAoD,GAGjF,CAEA,SAAS,EAAU,EAAoB,CACrC,GAAI,CAAC,OAAO,SAAS,CAAE,EAAG,MAAO,kBACjC,IAAM,EAAQ,EAAK,KAGnB,OAFI,EAAQ,EAAU,GAAG,KAAK,MAAM,EAAK,GAAM,EAAE,GAC7C,EAAQ,GAAW,GAAG,KAAK,MAAM,CAAK,EAAE,GACrC,GAAG,KAAK,MAAM,EAAQ,EAAE,EAAE,EACnC,CAcA,SAAgB,EACd,EACA,EACA,EACqB,CACrB,IAAM,EAAa,IAAI,IAAI,EAAQ,IAAK,GAAW,CAAC,EAAO,SAAU,CAAM,CAAC,CAAC,EAE7E,OAAO,EAAe,IAAK,GAAa,CACtC,IAAM,EAAS,EAAW,IAAI,CAAQ,EACtC,GAAI,CAAC,EACH,MAAO,CACL,WACA,cAAe,KACf,MAAO,IACP,MAAO,GACP,UAAW,IACb,EAEF,IAAM,EAAQ,EAAO,eAAiB,EAAO,YACvC,EAAQ,EAAI,QAAQ,EAAI,EAAM,QAAQ,EAC5C,MAAO,CACL,WACA,cAAe,EAAO,cACtB,QACA,MAAO,EAAQ,EACf,UAAW,EAAO,SACpB,CACF,CAAC,CACH,CAQA,SAAgB,GACd,EACA,EACA,EACqB,CACrB,IAAM,EAAW,EAAgB,EAAS,EAAK,CAAY,EACrD,EAAQ,EAAS,OAAQ,GAAU,EAAM,KAAK,EACpD,GAAI,EAAM,OAAS,EAAG,MAAM,IAAI,EAAqB,EAAO,CAAY,EACxE,OAAO,CACT"}