@murumets-ee/yhikas-sync 0.40.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
@@ -30,11 +30,36 @@ declare const YHIKAS_SYNC_ACTOR_ID = "yhikas-sync";
30
30
  */
31
31
  declare const SYNC_JOB_NAME = "yhikas-sync:pull";
32
32
  declare const WATCHDOG_JOB_NAME = "yhikas-sync:staleness-watchdog";
33
- /** The two resources in scope. `site_info` / `site_notice` are Q1, deliberately absent. */
33
+ /**
34
+ * The three resources in scope.
35
+ *
36
+ * `site_info` was deliberately absent until 2026-08-11 (D027). PR 05 left it
37
+ * out on a MEASUREMENT — `site_info`/`site_notice` appear only in
38
+ * yhikas-admin's `src/db/schema.ts` and its public route, with no admin UI, no
39
+ * server action, no seed and no internal consumer — from which the plan
40
+ * concluded that no rows existed and a sync would pull nothing.
41
+ *
42
+ * Measured against production on 2026-08-11 (R028), that premise had expired:
43
+ * the route answers 200 with populated reception hours and two live notices.
44
+ * The rows were written outside the app. Hardcoding them in the rebuilt site
45
+ * would therefore have frozen live data, not stood in for an empty upstream.
46
+ */
34
47
  declare const ROOM_TYPES_RESOURCE = "room_types";
35
48
  declare const LEGAL_DOCUMENTS_RESOURCE = "legal_documents";
36
- declare const SYNC_RESOURCES: readonly ["room_types", "legal_documents"];
49
+ declare const SITE_INFO_RESOURCE = "site_info";
50
+ declare const SYNC_RESOURCES: readonly ["room_types", "legal_documents", "site_info"];
37
51
  type SyncResource = (typeof SYNC_RESOURCES)[number];
52
+ /**
53
+ * The business key of the one `yhikas_site_info` row.
54
+ *
55
+ * Upstream `site_info` is a singleton and its notices carry no id or code of
56
+ * their own — only `{text, isActive, order}` — so there is no natural key to
57
+ * diff N rows on. Modelling it as ONE row with a constant key keeps the whole
58
+ * package on a single identity story (`code`, `type`, and now `key`), and
59
+ * keeps the notices ordered without inventing an identity that upstream would
60
+ * not preserve across an edit.
61
+ */
62
+ declare const SITE_INFO_KEY = "default";
38
63
  /**
39
64
  * Locale codes. Upstream `MultilingualText` is `{ en, et }` and the lumi codes
40
65
  * are identical, so there is no mapping to configure — inventing one would be
@@ -56,6 +81,12 @@ declare const EN_LOCALE = "en";
56
81
  declare const DECLARED_CURRENCY = "EUR";
57
82
  /** `net` = the amount excludes VAT. */
58
83
  declare const DECLARED_VAT_TREATMENT = "net";
84
+ /**
85
+ * Hard ceiling on ticker items. A ticker is a marquee: past a couple of dozen
86
+ * entries nobody reads the tail, and an unbounded array from upstream would
87
+ * ride into a JSONB column and out onto every page of the site.
88
+ */
89
+ declare const MAX_SITE_NOTICES = 25;
59
90
  /**
60
91
  * Env var carrying the sync's OWN bearer credential — deliberately not the
61
92
  * site's `PUBLIC_SITE_API_KEY`. The upstream limiter buckets on
@@ -144,16 +175,49 @@ declare function planDiff<T>(input: PlanDiffInput<T>): DiffPlan<T>;
144
175
  * NOT `.strict()`: unknown keys are stripped rather than rejected, so adding a
145
176
  * third language upstream degrades to "the sync ignores it" instead of "every
146
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.
147
211
  */
148
212
  declare const multilingualTextSchema: z.ZodObject<{
149
- et: z.ZodString;
150
- en: z.ZodString;
213
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
214
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
151
215
  }, "strip", z.ZodTypeAny, {
152
- et: string;
153
- en: string;
216
+ et?: string | null | undefined;
217
+ en?: string | null | undefined;
154
218
  }, {
155
- et: string;
156
- en: string;
219
+ et?: string | null | undefined;
220
+ en?: string | null | undefined;
157
221
  }>;
158
222
  type MultilingualText = z.infer<typeof multilingualTextSchema>;
159
223
  /**
@@ -168,14 +232,14 @@ type MultilingualText = z.infer<typeof multilingualTextSchema>;
168
232
  declare const roomTypeRowSchema: z.ZodObject<{
169
233
  code: z.ZodString;
170
234
  name: z.ZodObject<{
171
- et: z.ZodString;
172
- en: z.ZodString;
235
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
236
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
173
237
  }, "strip", z.ZodTypeAny, {
174
- et: string;
175
- en: string;
238
+ et?: string | null | undefined;
239
+ en?: string | null | undefined;
176
240
  }, {
177
- et: string;
178
- en: string;
241
+ et?: string | null | undefined;
242
+ en?: string | null | undefined;
179
243
  }>;
180
244
  totalArea: z.ZodNullable<z.ZodString>;
181
245
  livingArea: z.ZodNullable<z.ZodString>;
@@ -188,8 +252,8 @@ declare const roomTypeRowSchema: z.ZodObject<{
188
252
  }, "strip", z.ZodTypeAny, {
189
253
  code: string;
190
254
  name: {
191
- et: string;
192
- en: string;
255
+ et?: string | null | undefined;
256
+ en?: string | null | undefined;
193
257
  };
194
258
  totalArea: string | null;
195
259
  livingArea: string | null;
@@ -202,8 +266,8 @@ declare const roomTypeRowSchema: z.ZodObject<{
202
266
  }, {
203
267
  code: string;
204
268
  name: {
205
- et: string;
206
- en: string;
269
+ et?: string | null | undefined;
270
+ en?: string | null | undefined;
207
271
  };
208
272
  totalArea: string | null;
209
273
  livingArea: string | null;
@@ -227,14 +291,14 @@ type RoomTypeRow = z.infer<typeof roomTypeRowSchema>;
227
291
  declare const legalDocumentRowSchema: z.ZodObject<{
228
292
  type: z.ZodString;
229
293
  title: z.ZodObject<{
230
- et: z.ZodString;
231
- en: z.ZodString;
294
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
295
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
232
296
  }, "strip", z.ZodTypeAny, {
233
- et: string;
234
- en: string;
297
+ et?: string | null | undefined;
298
+ en?: string | null | undefined;
235
299
  }, {
236
- et: string;
237
- en: string;
300
+ et?: string | null | undefined;
301
+ en?: string | null | undefined;
238
302
  }>;
239
303
  /**
240
304
  * Accepted as free text HERE, and screened per-row in the projection.
@@ -255,8 +319,8 @@ declare const legalDocumentRowSchema: z.ZodObject<{
255
319
  type: string;
256
320
  slug: string;
257
321
  title: {
258
- et: string;
259
- en: string;
322
+ et?: string | null | undefined;
323
+ en?: string | null | undefined;
260
324
  };
261
325
  order: number;
262
326
  htmlContentEt: string;
@@ -265,8 +329,8 @@ declare const legalDocumentRowSchema: z.ZodObject<{
265
329
  type: string;
266
330
  slug: string;
267
331
  title: {
268
- et: string;
269
- en: string;
332
+ et?: string | null | undefined;
333
+ en?: string | null | undefined;
270
334
  };
271
335
  order: number;
272
336
  htmlContentEt: string;
@@ -288,14 +352,14 @@ declare const roomTypesResponseSchema: z.ZodObject<{
288
352
  roomTypes: z.ZodArray<z.ZodObject<{
289
353
  code: z.ZodString;
290
354
  name: z.ZodObject<{
291
- et: z.ZodString;
292
- en: z.ZodString;
355
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
356
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
293
357
  }, "strip", z.ZodTypeAny, {
294
- et: string;
295
- en: string;
358
+ et?: string | null | undefined;
359
+ en?: string | null | undefined;
296
360
  }, {
297
- et: string;
298
- en: string;
361
+ et?: string | null | undefined;
362
+ en?: string | null | undefined;
299
363
  }>;
300
364
  totalArea: z.ZodNullable<z.ZodString>;
301
365
  livingArea: z.ZodNullable<z.ZodString>;
@@ -308,8 +372,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
308
372
  }, "strip", z.ZodTypeAny, {
309
373
  code: string;
310
374
  name: {
311
- et: string;
312
- en: string;
375
+ et?: string | null | undefined;
376
+ en?: string | null | undefined;
313
377
  };
314
378
  totalArea: string | null;
315
379
  livingArea: string | null;
@@ -322,8 +386,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
322
386
  }, {
323
387
  code: string;
324
388
  name: {
325
- et: string;
326
- en: string;
389
+ et?: string | null | undefined;
390
+ en?: string | null | undefined;
327
391
  };
328
392
  totalArea: string | null;
329
393
  livingArea: string | null;
@@ -339,8 +403,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
339
403
  roomTypes: {
340
404
  code: string;
341
405
  name: {
342
- et: string;
343
- en: string;
406
+ et?: string | null | undefined;
407
+ en?: string | null | undefined;
344
408
  };
345
409
  totalArea: string | null;
346
410
  livingArea: string | null;
@@ -356,8 +420,8 @@ declare const roomTypesResponseSchema: z.ZodObject<{
356
420
  roomTypes: {
357
421
  code: string;
358
422
  name: {
359
- et: string;
360
- en: string;
423
+ et?: string | null | undefined;
424
+ en?: string | null | undefined;
361
425
  };
362
426
  totalArea: string | null;
363
427
  livingArea: string | null;
@@ -374,14 +438,14 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
374
438
  documents: z.ZodArray<z.ZodObject<{
375
439
  type: z.ZodString;
376
440
  title: z.ZodObject<{
377
- et: z.ZodString;
378
- en: z.ZodString;
441
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
442
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
379
443
  }, "strip", z.ZodTypeAny, {
380
- et: string;
381
- en: string;
444
+ et?: string | null | undefined;
445
+ en?: string | null | undefined;
382
446
  }, {
383
- et: string;
384
- en: string;
447
+ et?: string | null | undefined;
448
+ en?: string | null | undefined;
385
449
  }>;
386
450
  /**
387
451
  * Accepted as free text HERE, and screened per-row in the projection.
@@ -402,8 +466,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
402
466
  type: string;
403
467
  slug: string;
404
468
  title: {
405
- et: string;
406
- en: string;
469
+ et?: string | null | undefined;
470
+ en?: string | null | undefined;
407
471
  };
408
472
  order: number;
409
473
  htmlContentEt: string;
@@ -412,8 +476,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
412
476
  type: string;
413
477
  slug: string;
414
478
  title: {
415
- et: string;
416
- en: string;
479
+ et?: string | null | undefined;
480
+ en?: string | null | undefined;
417
481
  };
418
482
  order: number;
419
483
  htmlContentEt: string;
@@ -425,8 +489,8 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
425
489
  type: string;
426
490
  slug: string;
427
491
  title: {
428
- et: string;
429
- en: string;
492
+ et?: string | null | undefined;
493
+ en?: string | null | undefined;
430
494
  };
431
495
  order: number;
432
496
  htmlContentEt: string;
@@ -438,14 +502,140 @@ declare const legalDocumentsResponseSchema: z.ZodObject<{
438
502
  type: string;
439
503
  slug: string;
440
504
  title: {
441
- et: string;
442
- en: string;
505
+ et?: string | null | undefined;
506
+ en?: string | null | undefined;
443
507
  };
444
508
  order: number;
445
509
  htmlContentEt: string;
446
510
  htmlContentEn: string;
447
511
  }[];
448
512
  }>;
513
+ /**
514
+ * One `site_notice` — a ticker item.
515
+ *
516
+ * No id, no code, no timestamp: `{text, isActive, order}` is the whole row as
517
+ * the route serves it. That absence is why `site_info` is modelled as one
518
+ * local row carrying an ordered list rather than as N diffable rows — there is
519
+ * no key an edit upstream would preserve.
520
+ *
521
+ * `isActive` is honoured HERE rather than assumed: the route returns inactive
522
+ * notices too, and a ticker that shows a retired notice is worse than one that
523
+ * shows nothing.
524
+ */
525
+ declare const siteNoticeRowSchema: z.ZodObject<{
526
+ text: z.ZodObject<{
527
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
528
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
529
+ }, "strip", z.ZodTypeAny, {
530
+ et?: string | null | undefined;
531
+ en?: string | null | undefined;
532
+ }, {
533
+ et?: string | null | undefined;
534
+ en?: string | null | undefined;
535
+ }>;
536
+ isActive: z.ZodBoolean;
537
+ order: z.ZodNumber;
538
+ }, "strip", z.ZodTypeAny, {
539
+ text: {
540
+ et?: string | null | undefined;
541
+ en?: string | null | undefined;
542
+ };
543
+ order: number;
544
+ isActive: boolean;
545
+ }, {
546
+ text: {
547
+ et?: string | null | undefined;
548
+ en?: string | null | undefined;
549
+ };
550
+ order: number;
551
+ isActive: boolean;
552
+ }>;
553
+ type SiteNoticeRow = z.infer<typeof siteNoticeRowSchema>;
554
+ /**
555
+ * `/api/public/site-info` — reception hours plus the notice ticker.
556
+ *
557
+ * 🔴 **This envelope cannot express "not configured yet".** The route answers
558
+ * `200 { success: true, receptionHours: {et:'',en:''}, notices: [] }` when no
559
+ * row exists, which is byte-identical to an operator having deliberately
560
+ * cleared everything (F015). The other two resources have no such hole —
561
+ * theirs discriminate on `success` and on array length against a local
562
+ * snapshot.
563
+ *
564
+ * The schema is therefore NOT where that is solved, and deliberately so: the
565
+ * shape is valid either way. `projectSiteInfo` refuses an all-empty payload as
566
+ * unusable (D027), which is the only place that has the standing to decide
567
+ * that a well-formed response is not authoritative.
568
+ */
569
+ declare const siteInfoResponseSchema: z.ZodObject<{
570
+ success: z.ZodLiteral<true>;
571
+ receptionHours: z.ZodObject<{
572
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
573
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
574
+ }, "strip", z.ZodTypeAny, {
575
+ et?: string | null | undefined;
576
+ en?: string | null | undefined;
577
+ }, {
578
+ et?: string | null | undefined;
579
+ en?: string | null | undefined;
580
+ }>;
581
+ notices: z.ZodArray<z.ZodObject<{
582
+ text: z.ZodObject<{
583
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
584
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
585
+ }, "strip", z.ZodTypeAny, {
586
+ et?: string | null | undefined;
587
+ en?: string | null | undefined;
588
+ }, {
589
+ et?: string | null | undefined;
590
+ en?: string | null | undefined;
591
+ }>;
592
+ isActive: z.ZodBoolean;
593
+ order: z.ZodNumber;
594
+ }, "strip", z.ZodTypeAny, {
595
+ text: {
596
+ et?: string | null | undefined;
597
+ en?: string | null | undefined;
598
+ };
599
+ order: number;
600
+ isActive: boolean;
601
+ }, {
602
+ text: {
603
+ et?: string | null | undefined;
604
+ en?: string | null | undefined;
605
+ };
606
+ order: number;
607
+ isActive: boolean;
608
+ }>, "many">;
609
+ }, "strip", z.ZodTypeAny, {
610
+ receptionHours: {
611
+ et?: string | null | undefined;
612
+ en?: string | null | undefined;
613
+ };
614
+ notices: {
615
+ text: {
616
+ et?: string | null | undefined;
617
+ en?: string | null | undefined;
618
+ };
619
+ order: number;
620
+ isActive: boolean;
621
+ }[];
622
+ success: true;
623
+ }, {
624
+ receptionHours: {
625
+ et?: string | null | undefined;
626
+ en?: string | null | undefined;
627
+ };
628
+ notices: {
629
+ text: {
630
+ et?: string | null | undefined;
631
+ en?: string | null | undefined;
632
+ };
633
+ order: number;
634
+ isActive: boolean;
635
+ }[];
636
+ success: true;
637
+ }>;
638
+ type SiteInfoResponse = z.infer<typeof siteInfoResponseSchema>;
449
639
  //#endregion
450
640
  //#region src/projection.d.ts
451
641
  /** Injected at the seam; see the module docblock for why it is not imported. */
@@ -479,6 +669,36 @@ interface ProjectionResult {
479
669
  declare function secondaryLocaleOf(defaultLocale: string): string;
480
670
  declare function projectRoomTypes(rows: readonly RoomTypeRow[], options: ProjectionOptions): ProjectionResult;
481
671
  declare function projectLegalDocuments(rows: readonly LegalDocumentRow[], options: ProjectionOptions, sanitize: HtmlSanitizer): ProjectionResult;
672
+ /**
673
+ * Reception hours + the notice ticker → the one `yhikas_site_info` row.
674
+ *
675
+ * ## 🔴 The refusal is the point of this function
676
+ *
677
+ * `/api/public/site-info` answers `200 { success: true, receptionHours:
678
+ * {et:'',en:''}, notices: [] }` when no upstream row exists. That is
679
+ * byte-identical to an operator having deliberately cleared both, and it is the
680
+ * state the endpoint is in whenever nobody has filled it in (F015). The other
681
+ * two resources have no equivalent hole — theirs discriminate on `success` and
682
+ * on array length against a local snapshot.
683
+ *
684
+ * The shipped `empty-snapshot` floor cannot cover it, and the reason is
685
+ * structural rather than an oversight: that rule is a row-COUNT test, and this
686
+ * resource always projects exactly one row. The count is 1 whether the row says
687
+ * anything or not, so the floor never fires and an all-empty payload would be
688
+ * applied as an authoritative blanking — emptying the ticker and the hours on
689
+ * every page of the site, silently, six hours after upstream hiccupped.
690
+ *
691
+ * So emptiness is restated here as a CONTENT test: if the default locale has
692
+ * neither hours nor a single active notice, the payload is refused as
693
+ * unusable. Local content is left exactly as it was and the failure arms the
694
+ * staleness watchdog, which is the same posture as an unreachable endpoint —
695
+ * because epistemically it is the same situation.
696
+ *
697
+ * Note what is NOT refused: hours with no notices, or notices with no hours.
698
+ * Both are ordinary states of a real dormitory, and refusing them would make
699
+ * the guard fire on exactly the operator action it exists to protect.
700
+ */
701
+ declare function projectSiteInfo(response: SiteInfoResponse, options: ProjectionOptions): ProjectionResult;
482
702
  //#endregion
483
703
  //#region src/apply.d.ts
484
704
  /**
@@ -529,7 +749,7 @@ declare function applyPlan(input: ApplyPlanInput): Promise<ApplyCounts>;
529
749
  //#region src/entities/guard.d.ts
530
750
  /** Thrown when a writer other than the sync tries to mutate synced content. */
531
751
  declare class YhikasSyncedContentReadOnlyError extends Error {
532
- constructor(entityName: string, action: 'create' | 'update' | 'delete' | 'bulk update', actorId: string);
752
+ constructor(entityName: string, action: 'create' | 'update' | 'delete' | 'bulk update' | 'translation write' | 'locale publish change', actorId: string);
533
753
  }
534
754
  /**
535
755
  * Refuse every create/update/delete not performed by the sync actor.
@@ -539,6 +759,132 @@ declare class YhikasSyncedContentReadOnlyError extends Error {
539
759
  */
540
760
  declare function machineWritten<N extends string>(entityName: N): Behavior<Record<never, never>, `yhikas-sync:machine-written:${N}`>;
541
761
  //#endregion
762
+ //#region src/entities/index.d.ts
763
+ /**
764
+ * The entities this plugin syncs — ONE list, consumed by the plugin's
765
+ * declaration and by the contract tests.
766
+ *
767
+ * Adding `site_info` turned twelve tests red across two files, every one of
768
+ * them because a resource or entity had been re-listed by hand somewhere. This
769
+ * export exists so the next one cannot: `plugin.ts` spreads it into
770
+ * `server.entities`, and `contract.test.ts` iterates it, so a fourth entity is
771
+ * covered by both the moment it is added here.
772
+ */
773
+ declare const SYNCED_ENTITIES: readonly [import("@murumets-ee/entity").Entity<{
774
+ id: import("@murumets-ee/entity").IdField;
775
+ } & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
776
+ code: import("@murumets-ee/entity").TextField & {
777
+ readonly required: true;
778
+ readonly unique: true;
779
+ readonly indexed: true;
780
+ readonly maxLength: 190;
781
+ };
782
+ name: import("@murumets-ee/entity").TextField & {
783
+ readonly required: true;
784
+ readonly translatable: true;
785
+ };
786
+ totalArea: import("@murumets-ee/entity").TextField & {
787
+ readonly maxLength: 32;
788
+ };
789
+ livingArea: import("@murumets-ee/entity").TextField & {
790
+ readonly maxLength: 32;
791
+ };
792
+ commonArea: import("@murumets-ee/entity").TextField & {
793
+ readonly maxLength: 32;
794
+ };
795
+ capacity: import("@murumets-ee/entity").NumberField & {
796
+ readonly integer: true;
797
+ };
798
+ placesOccupied: import("@murumets-ee/entity").NumberField & {
799
+ readonly integer: true;
800
+ };
801
+ monthlyRent: import("@murumets-ee/entity").TextField & {
802
+ readonly maxLength: 32;
803
+ };
804
+ discountedMonthlyRent: import("@murumets-ee/entity").TextField & {
805
+ readonly maxLength: 32;
806
+ };
807
+ dailyRent: import("@murumets-ee/entity").TextField & {
808
+ readonly maxLength: 32;
809
+ };
810
+ currency: import("@murumets-ee/entity").TextField & {
811
+ readonly required: true;
812
+ readonly maxLength: 3;
813
+ readonly default: "EUR";
814
+ };
815
+ vatTreatment: import("@murumets-ee/entity").SelectField & {
816
+ options: readonly ["net", "gross"];
817
+ } & {
818
+ readonly options: readonly ["net", "gross"];
819
+ readonly required: true;
820
+ readonly default: "net";
821
+ };
822
+ hasEnglish: import("@murumets-ee/entity").BooleanField & {
823
+ readonly required: true;
824
+ readonly default: false;
825
+ };
826
+ sourceHash: import("@murumets-ee/entity").TextField & {
827
+ readonly maxLength: 64;
828
+ };
829
+ }, "yhikas_room_type", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_room_type">]>, import("@murumets-ee/entity").Entity<{
830
+ id: import("@murumets-ee/entity").IdField;
831
+ } & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
832
+ type: import("@murumets-ee/entity").TextField & {
833
+ readonly required: true;
834
+ readonly unique: true;
835
+ readonly indexed: true;
836
+ readonly maxLength: 190;
837
+ };
838
+ title: import("@murumets-ee/entity").TextField & {
839
+ readonly required: true;
840
+ readonly translatable: true;
841
+ };
842
+ sourceSlug: import("@murumets-ee/entity").TextField & {
843
+ readonly required: true;
844
+ readonly indexed: true;
845
+ readonly maxLength: 190;
846
+ };
847
+ body: import("@murumets-ee/entity").RichTextField & {
848
+ readonly required: true;
849
+ readonly translatable: true;
850
+ };
851
+ order: import("@murumets-ee/entity").NumberField & {
852
+ readonly integer: true;
853
+ readonly required: true;
854
+ readonly default: 0;
855
+ };
856
+ hasEnglish: import("@murumets-ee/entity").BooleanField & {
857
+ readonly required: true;
858
+ readonly default: false;
859
+ };
860
+ sourceHash: import("@murumets-ee/entity").TextField & {
861
+ readonly maxLength: 64;
862
+ };
863
+ }, "yhikas_legal_document", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_legal_document">]>, import("@murumets-ee/entity").Entity<{
864
+ id: import("@murumets-ee/entity").IdField;
865
+ } & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
866
+ key: import("@murumets-ee/entity").TextField & {
867
+ readonly required: true;
868
+ readonly unique: true;
869
+ readonly indexed: true;
870
+ readonly maxLength: 32;
871
+ };
872
+ receptionHours: import("@murumets-ee/entity").TextField & {
873
+ readonly translatable: true;
874
+ readonly maxLength: 500;
875
+ };
876
+ notices: import("@murumets-ee/entity").JsonField & {
877
+ readonly translatable: true;
878
+ };
879
+ hasEnglish: import("@murumets-ee/entity").BooleanField & {
880
+ readonly default: false;
881
+ readonly required: true;
882
+ };
883
+ sourceHash: import("@murumets-ee/entity").TextField & {
884
+ readonly maxLength: 64;
885
+ };
886
+ }, "yhikas_site_info", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_site_info">]>];
887
+ //#endregion
542
888
  //#region src/entities/legal-document.d.ts
543
889
  /**
544
890
  * `yhikas_legal_document` — the local projection of yhikas-admin's
@@ -755,6 +1101,96 @@ declare const YhikasRoomType: import("@murumets-ee/entity").Entity<{
755
1101
  };
756
1102
  }, "yhikas_room_type", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_room_type">]>;
757
1103
  //#endregion
1104
+ //#region src/entities/site-info.d.ts
1105
+ /**
1106
+ * `yhikas_site_info` — reception hours and the notice ticker, as ONE row.
1107
+ *
1108
+ * ## Why a singleton and not N notice rows
1109
+ *
1110
+ * Upstream `site_notice` carries `{text, isActive, order}` and nothing else —
1111
+ * no id, no code, no timestamp. So there is no business key an edit upstream
1112
+ * would preserve, and a diff keyed on `order` would read "notice 2 was
1113
+ * reworded" and "notices 2 and 3 swapped places" as the same event. Modelling
1114
+ * the resource as one row carrying an ordered list keeps identity honest: the
1115
+ * whole list is the value, and it is replaced as a value.
1116
+ *
1117
+ * That also matches what the site does with it. A ticker renders the list in
1118
+ * order; nothing links to an individual notice, so nothing needs one to have a
1119
+ * URL, a version or a publish state of its own.
1120
+ *
1121
+ * ## Both halves are optional, and that is the point
1122
+ *
1123
+ * `receptionHours` and `notices` can each legitimately be empty — a dormitory
1124
+ * with no current notices is an ordinary state. What is NOT ordinary is BOTH
1125
+ * being empty, because that is exactly what `/api/public/site-info` returns
1126
+ * when no row exists at all (F015), and it is indistinguishable at the wire
1127
+ * from a deliberate clearing. `projectSiteInfo` refuses that payload rather
1128
+ * than writing it (D027) — the refusal lives there because emptiness is a
1129
+ * content judgement, not a schema one.
1130
+ *
1131
+ * ## Security
1132
+ *
1133
+ * As with the other two synced entities: `access` is advisory metadata, the
1134
+ * firewall does not read it, and `admin` bypasses it unconditionally. The real
1135
+ * controls are deny-by-default (no non-admin role is granted anything here) and
1136
+ * `machineWritten`, which since PR 12 refuses non-sync writers on the base row
1137
+ * AND on the translation path.
1138
+ */
1139
+ declare const YHIKAS_SITE_INFO_ENTITY = "yhikas_site_info";
1140
+ declare const YhikasSiteInfo: import("@murumets-ee/entity").Entity<{
1141
+ id: import("@murumets-ee/entity").IdField;
1142
+ } & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
1143
+ /**
1144
+ * The business key, constant at {@link SITE_INFO_KEY}.
1145
+ *
1146
+ * A singleton still needs a key, for the same reason the other two do: the
1147
+ * differ matches local to upstream by key, and `applyPlan` upserts on it.
1148
+ * A constant key makes the singleton a one-element case of the same
1149
+ * machinery rather than a second code path.
1150
+ */
1151
+ key: import("@murumets-ee/entity").TextField & {
1152
+ readonly required: true;
1153
+ readonly unique: true;
1154
+ readonly indexed: true;
1155
+ readonly maxLength: 32;
1156
+ };
1157
+ /**
1158
+ * Reception hours as free text — `E–R 9–17`, or whatever the office writes.
1159
+ *
1160
+ * Deliberately NOT structured. Upstream stores one multilingual string and
1161
+ * the site renders it verbatim; parsing it into open/close pairs here would
1162
+ * invent a schema neither system has, and would fail the first time someone
1163
+ * writes "suletud 24.06".
1164
+ */
1165
+ receptionHours: import("@murumets-ee/entity").TextField & {
1166
+ readonly translatable: true;
1167
+ readonly maxLength: 500;
1168
+ };
1169
+ /**
1170
+ * The ticker, as an ordered array of strings for ONE locale.
1171
+ *
1172
+ * Already filtered to `isActive` and already sorted by `order` at
1173
+ * projection time, so a renderer maps over it and nothing more. `isActive`
1174
+ * and `order` are upstream's editing affordances, not content — carrying
1175
+ * them across would oblige every consumer to re-implement the same filter
1176
+ * and sort, and one of them would eventually get it wrong.
1177
+ */
1178
+ notices: import("@murumets-ee/entity").JsonField & {
1179
+ readonly translatable: true;
1180
+ };
1181
+ /**
1182
+ * Whether the secondary locale has anything publishable. A convenience
1183
+ * flag for the admin list, never the control — per-locale publish status is.
1184
+ */
1185
+ hasEnglish: import("@murumets-ee/entity").BooleanField & {
1186
+ readonly default: false;
1187
+ readonly required: true;
1188
+ }; /** The change signal. Written LAST, so a stale hash means "redo this row". */
1189
+ sourceHash: import("@murumets-ee/entity").TextField & {
1190
+ readonly maxLength: 64;
1191
+ };
1192
+ }, "yhikas_site_info", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_site_info">]>;
1193
+ //#endregion
758
1194
  //#region src/entity-client.d.ts
759
1195
  /** The `AdminClient` methods the adapter forwards, structurally. */
760
1196
  type AdminClientLike = ReturnType<ToolkitApp['getClient']>;
@@ -930,6 +1366,15 @@ declare class YhikasUpstreamClient {
930
1366
  fetchRoomTypes(): Promise<RoomTypeRow[]>;
931
1367
  /** `GET /api/public/legal-documents`. Active only, ordered by `order`; unpaginated. */
932
1368
  fetchLegalDocuments(): Promise<LegalDocumentRow[]>;
1369
+ /**
1370
+ * `GET /api/public/site-info`. Reception hours + notice ticker; a singleton,
1371
+ * so there is nothing to order or paginate.
1372
+ *
1373
+ * Returns the whole envelope rather than one key, because both halves are the
1374
+ * payload and neither is meaningful without the other — a caller deciding
1375
+ * whether this response is usable at all has to see both (D027).
1376
+ */
1377
+ fetchSiteInfo(): Promise<SiteInfoResponse>;
933
1378
  }
934
1379
  //#endregion
935
1380
  //#region src/run-sync.d.ts
@@ -937,6 +1382,7 @@ interface SyncRunDeps {
937
1382
  readonly upstream: YhikasUpstreamClient;
938
1383
  readonly roomTypeClient: SyncEntityClient;
939
1384
  readonly legalDocumentClient: SyncEntityClient;
1385
+ readonly siteInfoClient: SyncEntityClient;
940
1386
  readonly state: SyncStateStore;
941
1387
  readonly sanitizeHtml: HtmlSanitizer;
942
1388
  readonly logger: SyncLogger;
@@ -982,7 +1428,20 @@ declare class YhikasUpstreamError extends Error {
982
1428
  */
983
1429
  declare class YhikasSyncRefusedError extends Error {
984
1430
  readonly resource: SyncResource;
985
- readonly reason: 'empty-snapshot' | 'retire-fraction' | 'oversized-snapshot' | 'duplicate-key';
1431
+ readonly reason: 'empty-snapshot' | 'retire-fraction' | 'oversized-snapshot' | 'duplicate-key'
1432
+ /**
1433
+ * A well-formed 200 whose CONTENT is entirely empty (D027).
1434
+ *
1435
+ * Distinct from `empty-snapshot`, which is a row-COUNT test and therefore
1436
+ * cannot see this: `site_info` always projects exactly one row, so the
1437
+ * count is 1 whether or not that row says anything. `/api/public/site-info`
1438
+ * answers 200 with empty strings and no notices when no upstream row
1439
+ * exists, which is byte-identical to a deliberate clearing (F015) — so the
1440
+ * only safe reading of an all-empty payload is "not authoritative", and the
1441
+ * only safe response is to leave local content alone and let the watchdog
1442
+ * arm.
1443
+ */
1444
+ | 'empty-content';
986
1445
  constructor(resource: SyncResource, reason: YhikasSyncRefusedError['reason'], message: string);
987
1446
  }
988
1447
  //#endregion
@@ -1022,5 +1481,5 @@ declare function assessStaleness(records: readonly SyncStateRecord[], now: Date,
1022
1481
  */
1023
1482
  declare function assertNotStale(records: readonly SyncStateRecord[], now: Date, staleAfterMs: number): ResourceStaleness[];
1024
1483
  //#endregion
1025
- export { API_KEY_ENV_VAR, type AdminClientLike, type ApplyCounts, type ApplyPlanInput, BASE_URL_ENV_VAR, DECLARED_CURRENCY, DECLARED_VAT_TREATMENT, type DiffPlan, EN_LOCALE, ET_LOCALE, type HtmlSanitizer, LEGAL_DOCUMENTS_RESOURCE, type LegalDocumentRow, type LocalRow, type MultilingualText, type PlanDiffInput, type ProjectedRow, type ProjectionOptions, type ProjectionResult, ROOM_TYPES_RESOURCE, type ResolvedYhikasSyncConfig, type ResourceOutcome, type ResourceStaleness, type RoomTypeRow, SYNC_JOB_NAME, SYNC_RESOURCES, type SanityFloor, type SkippedRow, type SyncEntityClient, type SyncLogger, type SyncResource, type SyncRunDeps, type SyncRunSummary, type SyncStateRecord, type SyncStateStore, type UpstreamFailureKind, VAT_TREATMENTS, WATCHDOG_JOB_NAME, YHIKAS_LEGAL_DOCUMENT_ENTITY, YHIKAS_ROOM_TYPE_ENTITY, YHIKAS_SYNC_ACTOR_ID, YHIKAS_SYNC_PLUGIN_NAME, YhikasApplyPartialError, YhikasLegalDocument, YhikasRoomType, type YhikasSyncConfig, YhikasSyncConfigError, YhikasSyncRefusedError, YhikasSyncRunError, YhikasSyncStaleError, YhikasSyncedContentReadOnlyError, YhikasUpstreamClient, type YhikasUpstreamClientOptions, YhikasUpstreamError, applyPlan, assertNotStale, assessStaleness, createSyncStateStore, legalDocumentRowSchema, legalDocumentsResponseSchema, machineWritten, multilingualTextSchema, planDiff, projectLegalDocuments, projectRoomTypes, readLocalRows, resolveYhikasSyncConfig, roomTypeRowSchema, roomTypesResponseSchema, runYhikasSync, secondaryLocaleOf, stableHash, toSyncEntityClient, truncateError, yhikasSyncStateTable };
1484
+ export { API_KEY_ENV_VAR, type AdminClientLike, type ApplyCounts, type ApplyPlanInput, BASE_URL_ENV_VAR, DECLARED_CURRENCY, DECLARED_VAT_TREATMENT, type DiffPlan, EN_LOCALE, ET_LOCALE, type HtmlSanitizer, LEGAL_DOCUMENTS_RESOURCE, type LegalDocumentRow, type LocalRow, MAX_SITE_NOTICES, type MultilingualText, type PlanDiffInput, type ProjectedRow, type ProjectionOptions, type ProjectionResult, ROOM_TYPES_RESOURCE, type ResolvedYhikasSyncConfig, type ResourceOutcome, type ResourceStaleness, type RoomTypeRow, SITE_INFO_KEY, SITE_INFO_RESOURCE, SYNCED_ENTITIES, SYNC_JOB_NAME, SYNC_RESOURCES, type SanityFloor, type SiteInfoResponse, type SiteNoticeRow, type SkippedRow, type SyncEntityClient, type SyncLogger, type SyncResource, type SyncRunDeps, type SyncRunSummary, type SyncStateRecord, type SyncStateStore, type UpstreamFailureKind, VAT_TREATMENTS, WATCHDOG_JOB_NAME, YHIKAS_LEGAL_DOCUMENT_ENTITY, YHIKAS_ROOM_TYPE_ENTITY, YHIKAS_SITE_INFO_ENTITY, YHIKAS_SYNC_ACTOR_ID, YHIKAS_SYNC_PLUGIN_NAME, YhikasApplyPartialError, YhikasLegalDocument, YhikasRoomType, YhikasSiteInfo, type YhikasSyncConfig, YhikasSyncConfigError, YhikasSyncRefusedError, YhikasSyncRunError, YhikasSyncStaleError, YhikasSyncedContentReadOnlyError, YhikasUpstreamClient, type YhikasUpstreamClientOptions, YhikasUpstreamError, applyPlan, assertNotStale, assessStaleness, createSyncStateStore, legalDocumentRowSchema, legalDocumentsResponseSchema, machineWritten, multilingualTextSchema, planDiff, projectLegalDocuments, projectRoomTypes, projectSiteInfo, readLocalRows, resolveYhikasSyncConfig, roomTypeRowSchema, roomTypesResponseSchema, runYhikasSync, secondaryLocaleOf, siteInfoResponseSchema, siteNoticeRowSchema, stableHash, toSyncEntityClient, truncateError, yhikasSyncStateTable };
1026
1485
  //# sourceMappingURL=index.d.mts.map