@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.
- package/README.md +108 -0
- package/dist/client-J_w85GUr.mjs +2 -0
- package/dist/client-J_w85GUr.mjs.map +1 -0
- package/dist/config-DM3zAAhm.d.mts +244 -0
- package/dist/config-DM3zAAhm.d.mts.map +1 -0
- package/dist/constants-Bmm3P17G.mjs +2 -0
- package/dist/constants-Bmm3P17G.mjs.map +1 -0
- package/dist/en-BOWwWf-X.mjs +2 -0
- package/dist/en-BOWwWf-X.mjs.map +1 -0
- package/dist/et-CGDUOj83.mjs +2 -0
- package/dist/et-CGDUOj83.mjs.map +1 -0
- package/dist/i18n.d.mts +18 -0
- package/dist/i18n.d.mts.map +1 -0
- package/dist/i18n.mjs +2 -0
- package/dist/i18n.mjs.map +1 -0
- package/dist/index.d.mts +244 -774
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -0
- package/dist/jobs-Di4Z8go9.mjs +2 -0
- package/dist/jobs-Di4Z8go9.mjs.map +1 -0
- package/dist/plugin.d.mts +6 -4
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
- package/dist/room-page-registry-BFbyGTC7.mjs +2 -0
- package/dist/room-page-registry-BFbyGTC7.mjs.map +1 -0
- package/dist/room-page-registry-CnqXH_Ls.d.mts +974 -0
- package/dist/room-page-registry-CnqXH_Ls.d.mts.map +1 -0
- package/dist/room-pages-adapters-DxFWkFFF.mjs +2 -0
- package/dist/room-pages-adapters-DxFWkFFF.mjs.map +1 -0
- package/dist/room-pages-table-Vd9BqTSl.mjs +2 -0
- package/dist/room-pages-table-Vd9BqTSl.mjs.map +1 -0
- package/dist/room-type-DtMaQbza.mjs +2 -0
- package/dist/room-type-DtMaQbza.mjs.map +1 -0
- package/dist/ru-uzvamDhj.mjs +2 -0
- package/dist/ru-uzvamDhj.mjs.map +1 -0
- package/dist/sync-state-table-CZjRypK0.mjs +2 -0
- package/dist/sync-state-table-CZjRypK0.mjs.map +1 -0
- package/dist/widgets-client.d.mts +27 -0
- package/dist/widgets-client.d.mts.map +1 -0
- package/dist/widgets-client.mjs +3 -0
- package/dist/widgets-client.mjs.map +1 -0
- package/dist/widgets.d.mts +73 -0
- package/dist/widgets.d.mts.map +1 -0
- package/dist/widgets.mjs +2 -0
- package/dist/widgets.mjs.map +1 -0
- package/package.json +41 -7
- package/dist/config-ClDwmosW.d.mts +0 -102
- package/dist/config-ClDwmosW.d.mts.map +0 -1
- package/dist/jobs-Vg-eMVGf.mjs +0 -2
- package/dist/jobs-Vg-eMVGf.mjs.map +0 -1
- package/dist/sync-state-table-Dq_wyKfo.mjs +0 -2
- package/dist/sync-state-table-Dq_wyKfo.mjs.map +0 -1
- package/dist/watchdog-C_ClM4PH.mjs +0 -2
- 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
|