@murumets-ee/yhikas-sync 0.93.0 → 0.94.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.
Files changed (57) hide show
  1. package/README.md +108 -0
  2. package/dist/client-J_w85GUr.mjs +2 -0
  3. package/dist/client-J_w85GUr.mjs.map +1 -0
  4. package/dist/config-DM3zAAhm.d.mts +244 -0
  5. package/dist/config-DM3zAAhm.d.mts.map +1 -0
  6. package/dist/constants-Bmm3P17G.mjs +2 -0
  7. package/dist/constants-Bmm3P17G.mjs.map +1 -0
  8. package/dist/en-BOWwWf-X.mjs +2 -0
  9. package/dist/en-BOWwWf-X.mjs.map +1 -0
  10. package/dist/et-CGDUOj83.mjs +2 -0
  11. package/dist/et-CGDUOj83.mjs.map +1 -0
  12. package/dist/i18n.d.mts +18 -0
  13. package/dist/i18n.d.mts.map +1 -0
  14. package/dist/i18n.mjs +2 -0
  15. package/dist/i18n.mjs.map +1 -0
  16. package/dist/index.d.mts +244 -774
  17. package/dist/index.d.mts.map +1 -1
  18. package/dist/index.mjs +2 -1
  19. package/dist/index.mjs.map +1 -0
  20. package/dist/jobs-Di4Z8go9.mjs +2 -0
  21. package/dist/jobs-Di4Z8go9.mjs.map +1 -0
  22. package/dist/plugin.d.mts +6 -4
  23. package/dist/plugin.d.mts.map +1 -1
  24. package/dist/plugin.mjs +1 -1
  25. package/dist/plugin.mjs.map +1 -1
  26. package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
  27. package/dist/room-page-registry-BFbyGTC7.mjs +2 -0
  28. package/dist/room-page-registry-BFbyGTC7.mjs.map +1 -0
  29. package/dist/room-page-registry-CnqXH_Ls.d.mts +974 -0
  30. package/dist/room-page-registry-CnqXH_Ls.d.mts.map +1 -0
  31. package/dist/room-pages-adapters-DxFWkFFF.mjs +2 -0
  32. package/dist/room-pages-adapters-DxFWkFFF.mjs.map +1 -0
  33. package/dist/room-pages-table-Vd9BqTSl.mjs +2 -0
  34. package/dist/room-pages-table-Vd9BqTSl.mjs.map +1 -0
  35. package/dist/room-type-DtMaQbza.mjs +2 -0
  36. package/dist/room-type-DtMaQbza.mjs.map +1 -0
  37. package/dist/ru-uzvamDhj.mjs +2 -0
  38. package/dist/ru-uzvamDhj.mjs.map +1 -0
  39. package/dist/sync-state-table-CZjRypK0.mjs +2 -0
  40. package/dist/sync-state-table-CZjRypK0.mjs.map +1 -0
  41. package/dist/widgets-client.d.mts +27 -0
  42. package/dist/widgets-client.d.mts.map +1 -0
  43. package/dist/widgets-client.mjs +3 -0
  44. package/dist/widgets-client.mjs.map +1 -0
  45. package/dist/widgets.d.mts +73 -0
  46. package/dist/widgets.d.mts.map +1 -0
  47. package/dist/widgets.mjs +2 -0
  48. package/dist/widgets.mjs.map +1 -0
  49. package/package.json +41 -7
  50. package/dist/config-ClDwmosW.d.mts +0 -102
  51. package/dist/config-ClDwmosW.d.mts.map +0 -1
  52. package/dist/jobs-Vg-eMVGf.mjs +0 -2
  53. package/dist/jobs-Vg-eMVGf.mjs.map +0 -1
  54. package/dist/sync-state-table-Dq_wyKfo.mjs +0 -2
  55. package/dist/sync-state-table-Dq_wyKfo.mjs.map +0 -1
  56. package/dist/watchdog-C_ClM4PH.mjs +0 -2
  57. package/dist/watchdog-C_ClM4PH.mjs.map +0 -1
@@ -0,0 +1,974 @@
1
+ import { ToolkitApp } from "@murumets-ee/core";
2
+ import { z } from "zod";
3
+
4
+ //#region src/constants.d.ts
5
+ /**
6
+ * Names, identifiers and stated numbers for the yhikas-admin sync.
7
+ *
8
+ * Pure module — no imports, no side effects. Both the jiti-loaded plugin entry
9
+ * and the worker-side job modules read from here, so nothing in this file may
10
+ * reach a `server-only` module.
11
+ */
12
+ /** Plugin name, as it appears in `app.plugins` and `Plugin.requires`. */
13
+ declare const YHIKAS_SYNC_PLUGIN_NAME = "@murumets-ee/yhikas-sync";
14
+ /**
15
+ * The synthetic actor every sync write is attributed to.
16
+ *
17
+ * Not `'cli'`. `auditable()` records this in `created_by`/`updated_by`, so a
18
+ * human reading the audit log can tell a sync write from an operator running a
19
+ * `lumi` command — and the machine-written guard (see `entities/guard.ts`) can
20
+ * refuse every other writer by name. Fits `varchar(255)`; user ids in this
21
+ * codebase are varchar, never uuid.
22
+ */
23
+ declare const YHIKAS_SYNC_ACTOR_ID = "yhikas-sync";
24
+ /**
25
+ * Job names. `JOB_NAME_RE` in `@murumets-ee/queue` is
26
+ * `/^[a-zA-Z0-9:_-]{1,128}$/` — colons are the `plugin:action` separator and
27
+ * dots are rejected outright.
28
+ */
29
+ declare const SYNC_JOB_NAME = "yhikas-sync:pull";
30
+ declare const WATCHDOG_JOB_NAME = "yhikas-sync:staleness-watchdog";
31
+ /**
32
+ * The three resources in scope.
33
+ *
34
+ * `site_info` was deliberately absent until 2026-08-11 (D027). PR 05 left it
35
+ * out on a MEASUREMENT — `site_info`/`site_notice` appear only in
36
+ * yhikas-admin's `src/db/schema.ts` and its public route, with no admin UI, no
37
+ * server action, no seed and no internal consumer — from which the plan
38
+ * concluded that no rows existed and a sync would pull nothing.
39
+ *
40
+ * Measured against production on 2026-08-11 (R028), that premise had expired:
41
+ * the route answers 200 with populated reception hours and two live notices.
42
+ * The rows were written outside the app. Hardcoding them in the rebuilt site
43
+ * would therefore have frozen live data, not stood in for an empty upstream.
44
+ */
45
+ declare const ROOM_TYPES_RESOURCE = "room_types";
46
+ declare const LEGAL_DOCUMENTS_RESOURCE = "legal_documents";
47
+ declare const SITE_INFO_RESOURCE = "site_info";
48
+ declare const SYNC_RESOURCES: readonly ["room_types", "legal_documents", "site_info"];
49
+ type SyncResource = (typeof SYNC_RESOURCES)[number];
50
+ /**
51
+ * The business key of the one `yhikas_site_info` row.
52
+ *
53
+ * Upstream `site_info` is a singleton and its notices carry no id or code of
54
+ * their own — only `{text, isActive, order}` — so there is no natural key to
55
+ * diff N rows on. Modelling it as ONE row with a constant key keeps the whole
56
+ * package on a single identity story (`code`, `type`, and now `key`), and
57
+ * keeps the notices ordered without inventing an identity that upstream would
58
+ * not preserve across an edit.
59
+ */
60
+ declare const SITE_INFO_KEY = "default";
61
+ /**
62
+ * Locale codes. Upstream `MultilingualText` is `{ en, et }` and the lumi codes
63
+ * are identical, so there is no mapping to configure — inventing one would be
64
+ * a seam for a second customer this package does not have (N1).
65
+ */
66
+ declare const ET_LOCALE = "et";
67
+ declare const EN_LOCALE = "en";
68
+ /**
69
+ * Currency and VAT treatment, DECLARED here because they are not data upstream
70
+ * (R009 §6 / R013): `room_type` has no currency column and no tax column, and
71
+ * VAT is computed at invoice time against Merit article codes, never stored.
72
+ * Stating them makes a mis-entered source value render as a visibly wrong
73
+ * number against a declared unit rather than as a plausible one.
74
+ *
75
+ * The third semantic — the period — stays encoded in the field NAME
76
+ * (`monthlyRent` / `dailyRent`), because one row carries two different periods
77
+ * and a single `period` column could only be wrong about one of them.
78
+ */
79
+ declare const DECLARED_CURRENCY = "EUR";
80
+ /** `net` = the amount excludes VAT. */
81
+ declare const DECLARED_VAT_TREATMENT = "net";
82
+ /**
83
+ * Hard ceiling on ticker items. A ticker is a marquee: past a couple of dozen
84
+ * entries nobody reads the tail, and an unbounded array from upstream would
85
+ * ride into a JSONB column and out onto every page of the site.
86
+ */
87
+ declare const MAX_SITE_NOTICES = 25;
88
+ /**
89
+ * Env var carrying the sync's OWN bearer credential — deliberately not the
90
+ * site's `PUBLIC_SITE_API_KEY`. The upstream limiter buckets on
91
+ * `sha256(key).slice(0,16)` rather than on the caller IP (there is no
92
+ * `x-forwarded-for` over the Docker-internal path), so sharing a key means
93
+ * sharing one 60/min bucket, and revoking one credential would blind both.
94
+ */
95
+ declare const API_KEY_ENV_VAR = "YHIKAS_SYNC_API_KEY";
96
+ declare const BASE_URL_ENV_VAR = "YHIKAS_ADMIN_BASE_URL";
97
+ //#endregion
98
+ //#region src/diff.d.ts
99
+ /** One local row, reduced to what the diff needs. */
100
+ interface LocalRow {
101
+ readonly id: string;
102
+ /** The business key this row was synced under. */
103
+ readonly key: string;
104
+ readonly status: string;
105
+ /**
106
+ * `null` on a row whose last sync did not complete — the hash is written
107
+ * LAST, after every other write for the row succeeded, so a null (or stale)
108
+ * hash is exactly the signal that the row needs redoing.
109
+ */
110
+ readonly sourceHash: string | null;
111
+ /** Preserved across updates so it keeps meaning "first published". */
112
+ readonly publishedAt: Date | null;
113
+ }
114
+ interface DiffPlan<T> {
115
+ /** Upstream rows with no local counterpart. */
116
+ readonly create: readonly T[];
117
+ /** Local rows whose hash differs, OR whose status drifted from `published`. */
118
+ readonly update: readonly {
119
+ readonly local: LocalRow;
120
+ readonly row: T;
121
+ }[];
122
+ /** Local rows already identical and already published — no write at all. */
123
+ readonly unchanged: readonly LocalRow[];
124
+ /** Published locally, absent upstream. Unpublished, never deleted (D016). */
125
+ readonly retire: readonly LocalRow[];
126
+ }
127
+ interface SanityFloor {
128
+ /** Refuse a snapshot larger than this rather than truncating it. */
129
+ readonly maxRows: number;
130
+ /** Refuse a run retiring more than this fraction of published rows. */
131
+ readonly maxRetireFraction: number;
132
+ /** The fraction rule applies only once at least this many rows are published. */
133
+ readonly minRowsForFraction: number;
134
+ }
135
+ interface PlanDiffInput<T> {
136
+ readonly resource: SyncResource;
137
+ readonly upstream: readonly T[];
138
+ readonly local: readonly LocalRow[];
139
+ readonly keyOf: (row: T) => string;
140
+ readonly hashOf: (row: T) => string;
141
+ readonly floor: SanityFloor;
142
+ }
143
+ /**
144
+ * Stable content hash of an upstream row.
145
+ *
146
+ * Keys are sorted so the hash does not depend on JSON property order, which no
147
+ * part of the HTTP stack guarantees. `undefined` and `null` are distinguished
148
+ * because a null price is meaningful data here, not an absence.
149
+ */
150
+ declare function stableHash(value: unknown): string;
151
+ /**
152
+ * Compare an authoritative upstream snapshot against local state.
153
+ *
154
+ * **The caller must not invoke this with a snapshot that did not fully
155
+ * succeed.** That guard lives one level up, in the client: a non-2xx, a
156
+ * timeout or a body failing the wire schema throws before the differ is ever
157
+ * reached, so a partial response can never present as an absence here. This
158
+ * function's own guards are for a snapshot that IS authoritative but whose
159
+ * shape makes acting on it reckless.
160
+ *
161
+ * @throws {YhikasSyncRefusedError} for any of the D016 refusals. Every one of
162
+ * them aborts before a single write, so a refused run leaves local content
163
+ * exactly as it was — which is the first of PR 05's three obligatory
164
+ * negative tests.
165
+ */
166
+ declare function planDiff<T>(input: PlanDiffInput<T>): DiffPlan<T>;
167
+ //#endregion
168
+ //#region src/upstream/wire.d.ts
169
+ /**
170
+ * `MultilingualText` — a Postgres `json` column, so it arrives as a nested
171
+ * object and is never stringified.
172
+ *
173
+ * NOT `.strict()`: unknown keys are stripped rather than rejected, so adding a
174
+ * third language upstream degrades to "the sync ignores it" instead of "every
175
+ * run fails".
176
+ *
177
+ * 🔴 **Each locale key is nullable AND optional, and that is the load-bearing
178
+ * clause** (F058, cross-referenced to upstream F031). The column is declared
179
+ * `json(...).notNull()` — and **notNull covers the OBJECT, not its keys**. There
180
+ * is no CHECK constraint, and two of the four public columns
181
+ * (`room_type.name`, `legal_document.title`) have no upstream validation at all:
182
+ * the write is a raw `formData.get(…) as string`, which is `null` the moment a
183
+ * field is absent. So `{"et":"Tuba","en":null}` — and `{"et":"Tuba"}` — are rows
184
+ * yhikas-admin accepts today, through its ordinary admin UI.
185
+ *
186
+ * Requiring both keys would fail the ENTIRE response for one such row, so 32
187
+ * room types would stop syncing because one lacks an English name — every six
188
+ * hours, until somebody edited it upstream. That is exactly the blast-radius
189
+ * mistake `legalDocumentRowSchema.slug` documents below and deliberately
190
+ * avoided; a missing translation belongs at one row, and `projection.ts` is
191
+ * where it is decided (the locale is suppressed, or the row is skipped with a
192
+ * reason).
193
+ *
194
+ * What is NOT relaxed, on purpose:
195
+ *
196
+ * - **The object itself stays required.** A missing translation is DATA; a
197
+ * missing FIELD is a renamed column or a changed envelope. The consequence of
198
+ * tolerating one is caught either way — `assertSomethingSurvived` refuses a
199
+ * snapshot in which no row survived — but refusing here NAMES it
200
+ * (`title: Required`) instead of reporting 32 identical "no 'et' title
201
+ * upstream" skips and leaving the reader to infer the cause.
202
+ * - **A non-string value is still a shape violation.** `{"en":42}` would
203
+ * otherwise be written as a name.
204
+ *
205
+ * Note the asymmetry this leaves, deliberately: a RENAMED key (`et_EE`) is
206
+ * indistinguishable from two absent ones, because unknown keys are stripped by
207
+ * design so a third language degrades gracefully. That case falls through to
208
+ * `assertSomethingSurvived`, which is exactly what it is for.
209
+ */
210
+ declare const multilingualTextSchema: z.ZodObject<{
211
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
212
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
213
+ }, "strip", z.ZodTypeAny, {
214
+ et?: string | null | undefined;
215
+ en?: string | null | undefined;
216
+ }, {
217
+ et?: string | null | undefined;
218
+ en?: string | null | undefined;
219
+ }>;
220
+ type MultilingualText = z.infer<typeof multilingualTextSchema>;
221
+ /**
222
+ * One `room_type` row.
223
+ *
224
+ * `depositAmount` / `discountedDepositAmount` are absent by design. Upstream
225
+ * ships them as hardcoded `null` with no backing column; validating them as
226
+ * `z.null()` would turn the day someone adds the column into a hard sync
227
+ * failure. Deposits are unbuilt admin-side work, not something the sync can
228
+ * surface.
229
+ */
230
+ declare const roomTypeRowSchema: z.ZodObject<{
231
+ code: z.ZodString;
232
+ name: z.ZodObject<{
233
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
234
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
235
+ }, "strip", z.ZodTypeAny, {
236
+ et?: string | null | undefined;
237
+ en?: string | null | undefined;
238
+ }, {
239
+ et?: string | null | undefined;
240
+ en?: string | null | undefined;
241
+ }>;
242
+ totalArea: z.ZodNullable<z.ZodString>;
243
+ livingArea: z.ZodNullable<z.ZodString>;
244
+ commonArea: z.ZodNullable<z.ZodString>;
245
+ capacity: z.ZodNullable<z.ZodNumber>;
246
+ monthlyRent: z.ZodNullable<z.ZodString>;
247
+ discountedRent: z.ZodNullable<z.ZodString>;
248
+ dailyRent: z.ZodNullable<z.ZodString>;
249
+ placesOccupied: z.ZodNullable<z.ZodNumber>;
250
+ }, "strip", z.ZodTypeAny, {
251
+ code: string;
252
+ name: {
253
+ et?: string | null | undefined;
254
+ en?: string | null | undefined;
255
+ };
256
+ totalArea: string | null;
257
+ livingArea: string | null;
258
+ commonArea: string | null;
259
+ capacity: number | null;
260
+ placesOccupied: number | null;
261
+ monthlyRent: string | null;
262
+ dailyRent: string | null;
263
+ discountedRent: string | null;
264
+ }, {
265
+ code: string;
266
+ name: {
267
+ et?: string | null | undefined;
268
+ en?: string | null | undefined;
269
+ };
270
+ totalArea: string | null;
271
+ livingArea: string | null;
272
+ commonArea: string | null;
273
+ capacity: number | null;
274
+ placesOccupied: number | null;
275
+ monthlyRent: string | null;
276
+ dailyRent: string | null;
277
+ discountedRent: string | null;
278
+ }>;
279
+ type RoomTypeRow = z.infer<typeof roomTypeRowSchema>;
280
+ /**
281
+ * One active `legal_document` row.
282
+ *
283
+ * `type` is `text().notNull().unique()` upstream — NOT a pgEnum, and there is
284
+ * no TS union anywhere. The five values seeded today are closed by convention
285
+ * only and the admin UI can mint a sixth, so this validates the SHAPE of the
286
+ * business key and never its membership in a list. A whitelist here would turn
287
+ * a new upstream document into a hard sync failure.
288
+ */
289
+ declare const legalDocumentRowSchema: z.ZodObject<{
290
+ type: z.ZodString;
291
+ title: z.ZodObject<{
292
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
293
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
294
+ }, "strip", z.ZodTypeAny, {
295
+ et?: string | null | undefined;
296
+ en?: string | null | undefined;
297
+ }, {
298
+ et?: string | null | undefined;
299
+ en?: string | null | undefined;
300
+ }>;
301
+ /**
302
+ * Accepted as free text HERE, and screened per-row in the projection.
303
+ *
304
+ * Upstream derives it from an unvalidated free-text form field
305
+ * (`type.toLowerCase().replace(/_/g, '-')`), so an Estonian title yields an
306
+ * Estonian slug — `üldtingimused` — and a title with a space yields a slug
307
+ * with a space. A character-class regex on the RESPONSE schema would fail
308
+ * `legalDocumentsResponseSchema` for the whole payload, so one newly
309
+ * authored document would take the entire resource offline every six hours
310
+ * until someone edited it upstream. The blast radius belongs at one row.
311
+ */
312
+ slug: z.ZodString;
313
+ htmlContentEt: z.ZodString;
314
+ htmlContentEn: z.ZodString;
315
+ order: z.ZodNumber;
316
+ }, "strip", z.ZodTypeAny, {
317
+ type: string;
318
+ slug: string;
319
+ title: {
320
+ et?: string | null | undefined;
321
+ en?: string | null | undefined;
322
+ };
323
+ order: number;
324
+ htmlContentEt: string;
325
+ htmlContentEn: string;
326
+ }, {
327
+ type: string;
328
+ slug: string;
329
+ title: {
330
+ et?: string | null | undefined;
331
+ en?: string | null | undefined;
332
+ };
333
+ order: number;
334
+ htmlContentEt: string;
335
+ htmlContentEn: string;
336
+ }>;
337
+ type LegalDocumentRow = z.infer<typeof legalDocumentRowSchema>;
338
+ /**
339
+ * `success: z.literal(true)` is the load-bearing clause, not decoration.
340
+ *
341
+ * Every upstream failure path sets `success: false` — 401 (missing header,
342
+ * wrong scheme, wrong key, AND an unset server-side key: all four
343
+ * indistinguishable, deny-by-default through one branch), 429, and 500. No
344
+ * route returns 200 with a degraded body. So the discriminator is `success`,
345
+ * never array length, and a snapshot that fails this schema can never be
346
+ * mistaken for an authoritative empty one.
347
+ */
348
+ declare const roomTypesResponseSchema: z.ZodObject<{
349
+ success: z.ZodLiteral<true>;
350
+ roomTypes: z.ZodArray<z.ZodObject<{
351
+ code: z.ZodString;
352
+ name: z.ZodObject<{
353
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
354
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
355
+ }, "strip", z.ZodTypeAny, {
356
+ et?: string | null | undefined;
357
+ en?: string | null | undefined;
358
+ }, {
359
+ et?: string | null | undefined;
360
+ en?: string | null | undefined;
361
+ }>;
362
+ totalArea: z.ZodNullable<z.ZodString>;
363
+ livingArea: z.ZodNullable<z.ZodString>;
364
+ commonArea: z.ZodNullable<z.ZodString>;
365
+ capacity: z.ZodNullable<z.ZodNumber>;
366
+ monthlyRent: z.ZodNullable<z.ZodString>;
367
+ discountedRent: z.ZodNullable<z.ZodString>;
368
+ dailyRent: z.ZodNullable<z.ZodString>;
369
+ placesOccupied: z.ZodNullable<z.ZodNumber>;
370
+ }, "strip", z.ZodTypeAny, {
371
+ code: string;
372
+ name: {
373
+ et?: string | null | undefined;
374
+ en?: string | null | undefined;
375
+ };
376
+ totalArea: string | null;
377
+ livingArea: string | null;
378
+ commonArea: string | null;
379
+ capacity: number | null;
380
+ placesOccupied: number | null;
381
+ monthlyRent: string | null;
382
+ dailyRent: string | null;
383
+ discountedRent: string | null;
384
+ }, {
385
+ code: string;
386
+ name: {
387
+ et?: string | null | undefined;
388
+ en?: string | null | undefined;
389
+ };
390
+ totalArea: string | null;
391
+ livingArea: string | null;
392
+ commonArea: string | null;
393
+ capacity: number | null;
394
+ placesOccupied: number | null;
395
+ monthlyRent: string | null;
396
+ dailyRent: string | null;
397
+ discountedRent: string | null;
398
+ }>, "many">;
399
+ }, "strip", z.ZodTypeAny, {
400
+ success: true;
401
+ roomTypes: {
402
+ code: string;
403
+ name: {
404
+ et?: string | null | undefined;
405
+ en?: string | null | undefined;
406
+ };
407
+ totalArea: string | null;
408
+ livingArea: string | null;
409
+ commonArea: string | null;
410
+ capacity: number | null;
411
+ placesOccupied: number | null;
412
+ monthlyRent: string | null;
413
+ dailyRent: string | null;
414
+ discountedRent: string | null;
415
+ }[];
416
+ }, {
417
+ success: true;
418
+ roomTypes: {
419
+ code: string;
420
+ name: {
421
+ et?: string | null | undefined;
422
+ en?: string | null | undefined;
423
+ };
424
+ totalArea: string | null;
425
+ livingArea: string | null;
426
+ commonArea: string | null;
427
+ capacity: number | null;
428
+ placesOccupied: number | null;
429
+ monthlyRent: string | null;
430
+ dailyRent: string | null;
431
+ discountedRent: string | null;
432
+ }[];
433
+ }>;
434
+ declare const legalDocumentsResponseSchema: z.ZodObject<{
435
+ success: z.ZodLiteral<true>;
436
+ documents: z.ZodArray<z.ZodObject<{
437
+ type: z.ZodString;
438
+ title: z.ZodObject<{
439
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
440
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
441
+ }, "strip", z.ZodTypeAny, {
442
+ et?: string | null | undefined;
443
+ en?: string | null | undefined;
444
+ }, {
445
+ et?: string | null | undefined;
446
+ en?: string | null | undefined;
447
+ }>;
448
+ /**
449
+ * Accepted as free text HERE, and screened per-row in the projection.
450
+ *
451
+ * Upstream derives it from an unvalidated free-text form field
452
+ * (`type.toLowerCase().replace(/_/g, '-')`), so an Estonian title yields an
453
+ * Estonian slug — `üldtingimused` — and a title with a space yields a slug
454
+ * with a space. A character-class regex on the RESPONSE schema would fail
455
+ * `legalDocumentsResponseSchema` for the whole payload, so one newly
456
+ * authored document would take the entire resource offline every six hours
457
+ * until someone edited it upstream. The blast radius belongs at one row.
458
+ */
459
+ slug: z.ZodString;
460
+ htmlContentEt: z.ZodString;
461
+ htmlContentEn: z.ZodString;
462
+ order: z.ZodNumber;
463
+ }, "strip", z.ZodTypeAny, {
464
+ type: string;
465
+ slug: string;
466
+ title: {
467
+ et?: string | null | undefined;
468
+ en?: string | null | undefined;
469
+ };
470
+ order: number;
471
+ htmlContentEt: string;
472
+ htmlContentEn: string;
473
+ }, {
474
+ type: string;
475
+ slug: string;
476
+ title: {
477
+ et?: string | null | undefined;
478
+ en?: string | null | undefined;
479
+ };
480
+ order: number;
481
+ htmlContentEt: string;
482
+ htmlContentEn: string;
483
+ }>, "many">;
484
+ }, "strip", z.ZodTypeAny, {
485
+ success: true;
486
+ documents: {
487
+ type: string;
488
+ slug: string;
489
+ title: {
490
+ et?: string | null | undefined;
491
+ en?: string | null | undefined;
492
+ };
493
+ order: number;
494
+ htmlContentEt: string;
495
+ htmlContentEn: string;
496
+ }[];
497
+ }, {
498
+ success: true;
499
+ documents: {
500
+ type: string;
501
+ slug: string;
502
+ title: {
503
+ et?: string | null | undefined;
504
+ en?: string | null | undefined;
505
+ };
506
+ order: number;
507
+ htmlContentEt: string;
508
+ htmlContentEn: string;
509
+ }[];
510
+ }>;
511
+ /**
512
+ * One `site_notice` — a ticker item.
513
+ *
514
+ * No id, no code, no timestamp: `{text, isActive, order}` is the whole row as
515
+ * the route serves it. That absence is why `site_info` is modelled as one
516
+ * local row carrying an ordered list rather than as N diffable rows — there is
517
+ * no key an edit upstream would preserve.
518
+ *
519
+ * `isActive` is honoured HERE rather than assumed: the route returns inactive
520
+ * notices too, and a ticker that shows a retired notice is worse than one that
521
+ * shows nothing.
522
+ */
523
+ declare const siteNoticeRowSchema: z.ZodObject<{
524
+ text: z.ZodObject<{
525
+ et: z.ZodOptional<z.ZodNullable<z.ZodString>>;
526
+ en: z.ZodOptional<z.ZodNullable<z.ZodString>>;
527
+ }, "strip", z.ZodTypeAny, {
528
+ et?: string | null | undefined;
529
+ en?: string | null | undefined;
530
+ }, {
531
+ et?: string | null | undefined;
532
+ en?: string | null | undefined;
533
+ }>;
534
+ isActive: z.ZodBoolean;
535
+ order: z.ZodNumber;
536
+ }, "strip", z.ZodTypeAny, {
537
+ text: {
538
+ et?: string | null | undefined;
539
+ en?: string | null | undefined;
540
+ };
541
+ order: number;
542
+ isActive: boolean;
543
+ }, {
544
+ text: {
545
+ et?: string | null | undefined;
546
+ en?: string | null | undefined;
547
+ };
548
+ order: number;
549
+ isActive: boolean;
550
+ }>;
551
+ type SiteNoticeRow = z.infer<typeof siteNoticeRowSchema>;
552
+ /**
553
+ * `/api/public/site-info` — reception hours plus the notice ticker.
554
+ *
555
+ * 🔴 **This envelope cannot express "not configured yet".** The route answers
556
+ * `200 { success: true, receptionHours: {et:'',en:''}, notices: [] }` when no
557
+ * row exists, which is byte-identical to an operator having deliberately
558
+ * cleared everything (F015). The other two resources have no such hole —
559
+ * theirs discriminate on `success` and on array length against a local
560
+ * snapshot.
561
+ *
562
+ * The schema is therefore NOT where that is solved, and deliberately so: the
563
+ * shape is valid either way. `projectSiteInfo` refuses an all-empty payload as
564
+ * unusable (D027), which is the only place that has the standing to decide
565
+ * that a well-formed response is not authoritative. Upstream's later
566
+ * `updatedAt` / `noticeCount` are not declared here, so zod strips them; why
567
+ * they are not needed is on `projectSiteInfo`.
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>;
639
+ //#endregion
640
+ //#region src/projection.d.ts
641
+ /** Injected at the seam; see the module docblock for why it is not imported. */
642
+ type HtmlSanitizer = (html: string) => string;
643
+ interface ProjectionOptions {
644
+ /** The locale whose values live on the base entity row. `et` or `en`. */
645
+ readonly defaultLocale: string;
646
+ }
647
+ interface ProjectedRow {
648
+ /** The business key — `room_type.code` or `legal_document.type`. */
649
+ readonly key: string;
650
+ /** Base-row fields, including the default locale's values for translatable fields. */
651
+ readonly base: Record<string, unknown>;
652
+ /**
653
+ * Translatable values for the non-default locale, or `null` when that
654
+ * locale's text is empty upstream. `null` means "unpublish that locale",
655
+ * never "write an empty string".
656
+ */
657
+ readonly secondary: Record<string, unknown> | null;
658
+ readonly hash: string;
659
+ }
660
+ interface SkippedRow {
661
+ readonly key: string;
662
+ readonly reason: string;
663
+ }
664
+ interface ProjectionResult {
665
+ readonly projected: readonly ProjectedRow[];
666
+ readonly skipped: readonly SkippedRow[];
667
+ }
668
+ /** The locale that is NOT the base row's. */
669
+ declare function secondaryLocaleOf(defaultLocale: string): string;
670
+ declare function projectRoomTypes(rows: readonly RoomTypeRow[], options: ProjectionOptions): ProjectionResult;
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
+ * ## Why upstream's `updatedAt: null` is not read (F015, decided 2026-09-29)
702
+ *
703
+ * yhikas-admin now answers `updatedAt: null` when both of its tables are
704
+ * empty, which looks like the missing "never configured" signal. It adds
705
+ * nothing this refusal does not already know. Upstream enforces a database
706
+ * CHECK that every `site_info.receptionHours` from a row has both locales
707
+ * non-blank; the empty `{et:'',en:''}` is a value the route FABRICATES when no
708
+ * row exists (its `PUBLIC_API.md`, "The one exception"). So once the
709
+ * constraints are deployed, "deliberately cleared" is not a state an operator
710
+ * can produce: empty hours can only mean no row, and every all-empty response
711
+ * is already "never configured". Reading the null would split one case into
712
+ * one case. It would also make the guard depend on a field an older
713
+ * deployment omits — an absent key, not an error. So the content test stays
714
+ * the single rule, and it holds whether or not the field is present.
715
+ */
716
+ declare function projectSiteInfo(response: SiteInfoResponse, options: ProjectionOptions): ProjectionResult;
717
+ //#endregion
718
+ //#region src/apply.d.ts
719
+ /**
720
+ * The `AdminClient` surface the sync uses, structurally.
721
+ *
722
+ * Declared rather than imported so the apply logic is unit-testable with a
723
+ * fake — CI runs unit tests only, with no database, so a design that could
724
+ * only be exercised by an integration test would in practice be exercised by
725
+ * nothing.
726
+ */
727
+ interface SyncEntityClient {
728
+ findMany(options: {
729
+ limit: number;
730
+ }): Promise<Record<string, unknown>[]>;
731
+ create(data: Record<string, unknown>): Promise<Record<string, unknown>>;
732
+ update(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
733
+ updateForLocale(id: string, data: Record<string, unknown>, locale: string): Promise<Record<string, unknown>>;
734
+ deleteTranslation(id: string, locale: string): Promise<void>;
735
+ }
736
+ interface SyncLogger {
737
+ info(obj: Record<string, unknown>, msg: string): void;
738
+ warn(obj: Record<string, unknown>, msg: string): void;
739
+ error(obj: Record<string, unknown>, msg: string): void;
740
+ }
741
+ interface ApplyCounts {
742
+ created: number;
743
+ updated: number;
744
+ retired: number;
745
+ unchanged: number;
746
+ failed: number;
747
+ }
748
+ /** Thrown when at least one row failed; carries the counts that were achieved. */
749
+ declare class YhikasApplyPartialError extends Error {
750
+ readonly counts: ApplyCounts;
751
+ constructor(resource: SyncResource, counts: ApplyCounts);
752
+ }
753
+ declare function readLocalRows(client: SyncEntityClient, keyField: string, limit: number): Promise<LocalRow[]>;
754
+ interface ApplyPlanInput {
755
+ readonly resource: SyncResource;
756
+ readonly plan: DiffPlan<ProjectedRow>;
757
+ readonly client: SyncEntityClient;
758
+ /** The locale that is NOT on the base row — the one whose publish state is toggled. */
759
+ readonly secondaryLocale: string;
760
+ readonly logger: SyncLogger;
761
+ }
762
+ declare function applyPlan(input: ApplyPlanInput): Promise<ApplyCounts>;
763
+ //#endregion
764
+ //#region src/entity-client.d.ts
765
+ /** The `AdminClient` methods the adapter forwards, structurally. */
766
+ type AdminClientLike = ReturnType<ToolkitApp['getClient']>;
767
+ /**
768
+ * @param defaultLocale The app's REAL default locale — resolved from
769
+ * `@murumets-ee/content`, never configured. It is passed explicitly on every
770
+ * `updateForLocale` call and is not optional, because
771
+ * `elevateRequestContext` deliberately strips `locale`/`defaultLocale` from
772
+ * the context it builds, and `updateForLocale` THROWS when it can resolve
773
+ * the default locale from neither the options nor the context.
774
+ */
775
+ declare function toSyncEntityClient(client: AdminClientLike, defaultLocale: string): SyncEntityClient;
776
+ //#endregion
777
+ //#region src/room-pages-table.d.ts
778
+ /**
779
+ * `yhikas_room_pages` — one row per room type code the sync has given a page
780
+ * (block-library PR21, D010).
781
+ *
782
+ * ## Why a table, and not "does a child page with this slug exist?"
783
+ *
784
+ * The owner's rule is that a room page is created ONCE, ever: never
785
+ * overwritten, never re-created. A slug lookup cannot hold that — an editor
786
+ * who deletes the page, renames its slug, or moves it would get a fresh
787
+ * draft on the next six-hourly run, undoing a decision a person made on
788
+ * purpose. So the decision "this code has had its page" is recorded here, and
789
+ * a code in this table is never acted on again, whatever has since happened
790
+ * to the page.
791
+ *
792
+ * ## A row is CLAIMED before its page is created
793
+ *
794
+ * Two runs can overlap — a manual run-now beside the schedule, two workers,
795
+ * a reclaimed stale lease. So a run first inserts the code with no page
796
+ * (`page_id` NULL, `claim_token` its own, `claimed_at` now) in ONE
797
+ * `INSERT … ON CONFLICT … WHERE` statement (`TableClient.tryClaim`); only
798
+ * the run whose insert wins creates the page, and then fills in `page_id`.
799
+ * A claim that never got its page — the run crashed, or the page was refused
800
+ * — lapses after a lease, and the next run takes it over, re-checking by slug
801
+ * first. A row WITH a `page_id` is final.
802
+ *
803
+ * The slug lookup is kept as well, for the other direction: a child page the
804
+ * site seeded by hand (or a migration wrote) before this table knew about it
805
+ * is FOUND and recorded, not duplicated.
806
+ *
807
+ * Infrastructure, not content: `defineTable` + `TableClient`, per CLAUDE.md's
808
+ * two-tier rule, declared on the plugin's `server.tables` so `lumi migrate`
809
+ * sees it.
810
+ */
811
+ /** How a code came to have its row. */
812
+ declare const ROOM_PAGE_ORIGINS: readonly ["created", "found", "registered"];
813
+ type RoomPageOrigin = (typeof ROOM_PAGE_ORIGINS)[number];
814
+ /**
815
+ * Why the last attempt for a code got no page — shown on the dashboard.
816
+ * `room-type-path`: the page's address is still held by the synced room
817
+ * type's old URL row; `path`: held by another document; `refused`: anything
818
+ * else the page entity refused.
819
+ */
820
+ declare const ROOM_PAGE_REFUSAL_KINDS: readonly ["room-type-path", "path", "refused"];
821
+ type RoomPageRefusalKind = (typeof ROOM_PAGE_REFUSAL_KINDS)[number];
822
+ /** How long a claim without a page is honoured before another run may take it over. */
823
+ declare const ROOM_PAGE_CLAIM_LEASE_MS: number;
824
+ declare const yhikasRoomPagesTable: {
825
+ table: import("drizzle-orm/pg-core").PgTableWithColumns<{
826
+ name: string;
827
+ schema: undefined;
828
+ columns: {
829
+ [x: string]: import("drizzle-orm/pg-core").PgColumn<{
830
+ name: string;
831
+ tableName: string;
832
+ dataType: import("drizzle-orm").ColumnDataType;
833
+ columnType: string;
834
+ data: unknown;
835
+ driverParam: unknown;
836
+ notNull: false;
837
+ hasDefault: false;
838
+ isPrimaryKey: false;
839
+ isAutoincrement: false;
840
+ hasRuntimeDefault: false;
841
+ enumValues: string[] | undefined;
842
+ baseColumn: never;
843
+ identity: undefined;
844
+ generated: undefined;
845
+ }, {}, {}>;
846
+ };
847
+ dialect: "pg";
848
+ }>;
849
+ schema: import("@murumets-ee/db").TableDefinition<{
850
+ /** The room type's business key (`yhikas_room_type.code`, max 190). */readonly code: import("@murumets-ee/db").ColumnFactory<string, "varchar", true, false>;
851
+ /**
852
+ * The page row the code's page is — NULL while the code is only CLAIMED.
853
+ * Not a foreign key: the page may be deleted, and the row must outlive it.
854
+ */
855
+ readonly pageId: import("@murumets-ee/db").ColumnFactory<string, "uuid", false, false>;
856
+ /**
857
+ * `created` — the sync created it as a draft; `found` — a child page with
858
+ * the code's slug already existed under the room list page; `registered` —
859
+ * a site's own migration recorded it (`recordRoomPage`). Only `created`
860
+ * pages are listed as "waiting for text and photos".
861
+ */
862
+ readonly origin: import("@murumets-ee/db").ColumnFactory<string, "varchar", true, true>; /** The run holding the claim (a random UUID per run); NULL once the row has its page. */
863
+ readonly claimToken: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>; /** When the claim was taken. A claim without a page older than the lease lapses. */
864
+ readonly claimedAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>; /** The last refusal for this code, bounded — see `ROOM_PAGE_REFUSAL_KINDS`. Cleared with the page. */
865
+ readonly lastError: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>;
866
+ readonly lastErrorKind: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>;
867
+ readonly lastErrorAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>;
868
+ readonly createdAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", true, true>;
869
+ }>;
870
+ columnKinds: Readonly<Record<string, import("@murumets-ee/db").ColumnKind>>;
871
+ primaryKeyColumns: readonly string[];
872
+ makeClient: (database: import("drizzle-orm/postgres-js").PostgresJsDatabase, partitionValue?: string) => import("@murumets-ee/db").TableClient<{
873
+ /** The room type's business key (`yhikas_room_type.code`, max 190). */readonly code: import("@murumets-ee/db").ColumnFactory<string, "varchar", true, false>;
874
+ /**
875
+ * The page row the code's page is — NULL while the code is only CLAIMED.
876
+ * Not a foreign key: the page may be deleted, and the row must outlive it.
877
+ */
878
+ readonly pageId: import("@murumets-ee/db").ColumnFactory<string, "uuid", false, false>;
879
+ /**
880
+ * `created` — the sync created it as a draft; `found` — a child page with
881
+ * the code's slug already existed under the room list page; `registered` —
882
+ * a site's own migration recorded it (`recordRoomPage`). Only `created`
883
+ * pages are listed as "waiting for text and photos".
884
+ */
885
+ readonly origin: import("@murumets-ee/db").ColumnFactory<string, "varchar", true, true>; /** The run holding the claim (a random UUID per run); NULL once the row has its page. */
886
+ readonly claimToken: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>; /** When the claim was taken. A claim without a page older than the lease lapses. */
887
+ readonly claimedAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>; /** The last refusal for this code, bounded — see `ROOM_PAGE_REFUSAL_KINDS`. Cleared with the page. */
888
+ readonly lastError: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>;
889
+ readonly lastErrorKind: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>;
890
+ readonly lastErrorAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>;
891
+ readonly createdAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", true, true>;
892
+ }, import("drizzle-orm/pg-core").PgTableWithColumns<{
893
+ name: string;
894
+ schema: undefined;
895
+ columns: {
896
+ [x: string]: import("drizzle-orm/pg-core").PgColumn<{
897
+ name: string;
898
+ tableName: string;
899
+ dataType: import("drizzle-orm").ColumnDataType;
900
+ columnType: string;
901
+ data: unknown;
902
+ driverParam: unknown;
903
+ notNull: false;
904
+ hasDefault: false;
905
+ isPrimaryKey: false;
906
+ isAutoincrement: false;
907
+ hasRuntimeDefault: false;
908
+ enumValues: string[] | undefined;
909
+ baseColumn: never;
910
+ identity: undefined;
911
+ generated: undefined;
912
+ }, {}, {}>;
913
+ };
914
+ dialect: "pg";
915
+ }>>;
916
+ partitionColumn: "code" | "createdAt" | "lastError" | "pageId" | "origin" | "claimToken" | "claimedAt" | "lastErrorKind" | "lastErrorAt" | undefined;
917
+ };
918
+ //#endregion
919
+ //#region src/room-page-registry.d.ts
920
+ interface RoomPageRecord {
921
+ readonly code: string;
922
+ /** `null` while the code is only claimed (or its last attempt was refused). */
923
+ readonly pageId: string | null;
924
+ readonly origin: RoomPageOrigin;
925
+ readonly createdAt: Date;
926
+ readonly lastError: string | null;
927
+ readonly lastErrorKind: RoomPageRefusalKind | null;
928
+ readonly lastErrorAt: Date | null;
929
+ }
930
+ /** What a run needs to know about a code before acting on it. */
931
+ interface RoomPageState {
932
+ /** Set once the code has its page — final. */
933
+ readonly pageId: string | null;
934
+ /** A run's claim, still on the row (that run is creating the page, or crashed doing so). */
935
+ readonly claimToken: string | null;
936
+ /** When the last attempt was refused — drives the back-off and the ordering. */
937
+ readonly lastErrorAt: Date | null;
938
+ }
939
+ interface RoomPageRefusal {
940
+ readonly kind: RoomPageRefusalKind;
941
+ readonly message: string;
942
+ }
943
+ interface RoomPageRegistry {
944
+ /** The rows of `codes` that exist, by code. Read in chunks — nothing dropped. */
945
+ states(codes: readonly string[]): Promise<ReadonlyMap<string, RoomPageState>>;
946
+ /**
947
+ * Atomically claim `code` for creating its page. `true` only for the one
948
+ * run that may create it: a new code, or a lapsed claim with no page.
949
+ */
950
+ claim(code: string, token: string, now: Date): Promise<boolean>;
951
+ /** Give a claim held by `token` its page. `false` when the claim is no longer this run's. */
952
+ complete(code: string, token: string, pageId: string, now: Date): Promise<boolean>;
953
+ /** Keep why a claimed code got no page, and release the claim so the next run retries. */
954
+ refuse(code: string, token: string, refusal: RoomPageRefusal, now: Date): Promise<void>;
955
+ /**
956
+ * Record a page that already exists (found by slug, or written by a site's
957
+ * migration). Never moves a page already recorded, and never overrides a
958
+ * FRESH claim of another run. `true` when this call recorded it.
959
+ */
960
+ record(code: string, pageId: string, origin: RoomPageOrigin, now: Date): Promise<boolean>;
961
+ /** The newest rows first, at most `limit`. */
962
+ list(limit: number): Promise<readonly RoomPageRecord[]>;
963
+ }
964
+ type RoomPagesClient = ReturnType<typeof yhikasRoomPagesTable.makeClient>;
965
+ /** Codes per `IN (…)` read — the whole input is read, in chunks of this size. */
966
+ declare const ROOM_PAGE_READ_CHUNK = 100;
967
+ /** Thrown for a code or page id that cannot be recorded. */
968
+ declare class YhikasRoomPageRecordError extends Error {
969
+ constructor(message: string);
970
+ }
971
+ declare function createRoomPageRegistry(client: RoomPagesClient): RoomPageRegistry;
972
+ //#endregion
973
+ export { EN_LOCALE as $, secondaryLocaleOf as A, roomTypesResponseSchema as B, ProjectedRow as C, projectLegalDocuments as D, SkippedRow as E, SiteNoticeRow as F, PlanDiffInput as G, siteNoticeRowSchema as H, legalDocumentRowSchema as I, stableHash as J, SanityFloor as K, legalDocumentsResponseSchema as L, MultilingualText as M, RoomTypeRow as N, projectRoomTypes as O, SiteInfoResponse as P, DECLARED_VAT_TREATMENT as Q, multilingualTextSchema as R, HtmlSanitizer as S, ProjectionResult as T, DiffPlan as U, siteInfoResponseSchema as V, LocalRow as W, BASE_URL_ENV_VAR as X, API_KEY_ENV_VAR as Y, DECLARED_CURRENCY as Z, SyncEntityClient as _, YhikasRoomPageRecordError as a, SITE_INFO_RESOURCE as at, applyPlan as b, ROOM_PAGE_ORIGINS as c, SyncResource as ct, RoomPageRefusalKind as d, YHIKAS_SYNC_PLUGIN_NAME as dt, ET_LOCALE as et, yhikasRoomPagesTable as f, ApplyPlanInput as g, ApplyCounts as h, RoomPageRegistry as i, SITE_INFO_KEY as it, LegalDocumentRow as j, projectSiteInfo as k, ROOM_PAGE_REFUSAL_KINDS as l, WATCHDOG_JOB_NAME as lt, toSyncEntityClient as m, RoomPageRecord as n, MAX_SITE_NOTICES as nt, createRoomPageRegistry as o, SYNC_JOB_NAME as ot, AdminClientLike as p, planDiff as q, RoomPageRefusal as r, ROOM_TYPES_RESOURCE as rt, ROOM_PAGE_CLAIM_LEASE_MS as s, SYNC_RESOURCES as st, ROOM_PAGE_READ_CHUNK as t, LEGAL_DOCUMENTS_RESOURCE as tt, RoomPageOrigin as u, YHIKAS_SYNC_ACTOR_ID as ut, SyncLogger as v, ProjectionOptions as w, readLocalRows as x, YhikasApplyPartialError as y, roomTypeRowSchema as z };
974
+ //# sourceMappingURL=room-page-registry-CnqXH_Ls.d.mts.map