@murumets-ee/yhikas-sync 0.38.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/LICENSE +94 -0
- package/README.md +192 -0
- package/dist/config-CHVLiXZf.d.mts +77 -0
- package/dist/config-CHVLiXZf.d.mts.map +1 -0
- package/dist/index.d.mts +1026 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1 -0
- package/dist/jobs-D2perJoW.mjs +2 -0
- package/dist/jobs-D2perJoW.mjs.map +1 -0
- package/dist/plugin.d.mts +25 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/sync-state-table-CYl_DVX9.mjs +2 -0
- package/dist/sync-state-table-CYl_DVX9.mjs.map +1 -0
- package/dist/watchdog-CNsiJMUR.mjs +2 -0
- package/dist/watchdog-CNsiJMUR.mjs.map +1 -0
- package/package.json +44 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
import { i as resolveYhikasSyncConfig, n as YhikasSyncConfig, r as YhikasSyncConfigError, t as ResolvedYhikasSyncConfig } from "./config-CHVLiXZf.mjs";
|
|
2
|
+
import { Behavior } from "@murumets-ee/entity";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ToolkitApp } from "@murumets-ee/core";
|
|
5
|
+
|
|
6
|
+
//#region src/constants.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* Names, identifiers and stated numbers for the yhikas-admin sync.
|
|
9
|
+
*
|
|
10
|
+
* Pure module — no imports, no side effects. Both the jiti-loaded plugin entry
|
|
11
|
+
* and the worker-side job modules read from here, so nothing in this file may
|
|
12
|
+
* reach a `server-only` module.
|
|
13
|
+
*/
|
|
14
|
+
/** Plugin name, as it appears in `app.plugins` and `Plugin.requires`. */
|
|
15
|
+
declare const YHIKAS_SYNC_PLUGIN_NAME = "@murumets-ee/yhikas-sync";
|
|
16
|
+
/**
|
|
17
|
+
* The synthetic actor every sync write is attributed to.
|
|
18
|
+
*
|
|
19
|
+
* Not `'cli'`. `auditable()` records this in `created_by`/`updated_by`, so a
|
|
20
|
+
* human reading the audit log can tell a sync write from an operator running a
|
|
21
|
+
* `lumi` command — and the machine-written guard (see `entities/guard.ts`) can
|
|
22
|
+
* refuse every other writer by name. Fits `varchar(255)`; user ids in this
|
|
23
|
+
* codebase are varchar, never uuid.
|
|
24
|
+
*/
|
|
25
|
+
declare const YHIKAS_SYNC_ACTOR_ID = "yhikas-sync";
|
|
26
|
+
/**
|
|
27
|
+
* Job names. `JOB_NAME_RE` in `@murumets-ee/queue` is
|
|
28
|
+
* `/^[a-zA-Z0-9:_-]{1,128}$/` — colons are the `plugin:action` separator and
|
|
29
|
+
* dots are rejected outright.
|
|
30
|
+
*/
|
|
31
|
+
declare const SYNC_JOB_NAME = "yhikas-sync:pull";
|
|
32
|
+
declare const WATCHDOG_JOB_NAME = "yhikas-sync:staleness-watchdog";
|
|
33
|
+
/** The two resources in scope. `site_info` / `site_notice` are Q1, deliberately absent. */
|
|
34
|
+
declare const ROOM_TYPES_RESOURCE = "room_types";
|
|
35
|
+
declare const LEGAL_DOCUMENTS_RESOURCE = "legal_documents";
|
|
36
|
+
declare const SYNC_RESOURCES: readonly ["room_types", "legal_documents"];
|
|
37
|
+
type SyncResource = (typeof SYNC_RESOURCES)[number];
|
|
38
|
+
/**
|
|
39
|
+
* Locale codes. Upstream `MultilingualText` is `{ en, et }` and the lumi codes
|
|
40
|
+
* are identical, so there is no mapping to configure — inventing one would be
|
|
41
|
+
* a seam for a second customer this package does not have (N1).
|
|
42
|
+
*/
|
|
43
|
+
declare const ET_LOCALE = "et";
|
|
44
|
+
declare const EN_LOCALE = "en";
|
|
45
|
+
/**
|
|
46
|
+
* Currency and VAT treatment, DECLARED here because they are not data upstream
|
|
47
|
+
* (R009 §6 / R013): `room_type` has no currency column and no tax column, and
|
|
48
|
+
* VAT is computed at invoice time against Merit article codes, never stored.
|
|
49
|
+
* Stating them makes a mis-entered source value render as a visibly wrong
|
|
50
|
+
* number against a declared unit rather than as a plausible one.
|
|
51
|
+
*
|
|
52
|
+
* The third semantic — the period — stays encoded in the field NAME
|
|
53
|
+
* (`monthlyRent` / `dailyRent`), because one row carries two different periods
|
|
54
|
+
* and a single `period` column could only be wrong about one of them.
|
|
55
|
+
*/
|
|
56
|
+
declare const DECLARED_CURRENCY = "EUR";
|
|
57
|
+
/** `net` = the amount excludes VAT. */
|
|
58
|
+
declare const DECLARED_VAT_TREATMENT = "net";
|
|
59
|
+
/**
|
|
60
|
+
* Env var carrying the sync's OWN bearer credential — deliberately not the
|
|
61
|
+
* site's `PUBLIC_SITE_API_KEY`. The upstream limiter buckets on
|
|
62
|
+
* `sha256(key).slice(0,16)` rather than on the caller IP (there is no
|
|
63
|
+
* `x-forwarded-for` over the Docker-internal path), so sharing a key means
|
|
64
|
+
* sharing one 60/min bucket, and revoking one credential would blind both.
|
|
65
|
+
*/
|
|
66
|
+
declare const API_KEY_ENV_VAR = "YHIKAS_SYNC_API_KEY";
|
|
67
|
+
declare const BASE_URL_ENV_VAR = "YHIKAS_ADMIN_BASE_URL";
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/diff.d.ts
|
|
70
|
+
/** One local row, reduced to what the diff needs. */
|
|
71
|
+
interface LocalRow {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
/** The business key this row was synced under. */
|
|
74
|
+
readonly key: string;
|
|
75
|
+
readonly status: string;
|
|
76
|
+
/**
|
|
77
|
+
* `null` on a row whose last sync did not complete — the hash is written
|
|
78
|
+
* LAST, after every other write for the row succeeded, so a null (or stale)
|
|
79
|
+
* hash is exactly the signal that the row needs redoing.
|
|
80
|
+
*/
|
|
81
|
+
readonly sourceHash: string | null;
|
|
82
|
+
/** Preserved across updates so it keeps meaning "first published". */
|
|
83
|
+
readonly publishedAt: Date | null;
|
|
84
|
+
}
|
|
85
|
+
interface DiffPlan<T> {
|
|
86
|
+
/** Upstream rows with no local counterpart. */
|
|
87
|
+
readonly create: readonly T[];
|
|
88
|
+
/** Local rows whose hash differs, OR whose status drifted from `published`. */
|
|
89
|
+
readonly update: readonly {
|
|
90
|
+
readonly local: LocalRow;
|
|
91
|
+
readonly row: T;
|
|
92
|
+
}[];
|
|
93
|
+
/** Local rows already identical and already published — no write at all. */
|
|
94
|
+
readonly unchanged: readonly LocalRow[];
|
|
95
|
+
/** Published locally, absent upstream. Unpublished, never deleted (D016). */
|
|
96
|
+
readonly retire: readonly LocalRow[];
|
|
97
|
+
}
|
|
98
|
+
interface SanityFloor {
|
|
99
|
+
/** Refuse a snapshot larger than this rather than truncating it. */
|
|
100
|
+
readonly maxRows: number;
|
|
101
|
+
/** Refuse a run retiring more than this fraction of published rows. */
|
|
102
|
+
readonly maxRetireFraction: number;
|
|
103
|
+
/** The fraction rule applies only once at least this many rows are published. */
|
|
104
|
+
readonly minRowsForFraction: number;
|
|
105
|
+
}
|
|
106
|
+
interface PlanDiffInput<T> {
|
|
107
|
+
readonly resource: SyncResource;
|
|
108
|
+
readonly upstream: readonly T[];
|
|
109
|
+
readonly local: readonly LocalRow[];
|
|
110
|
+
readonly keyOf: (row: T) => string;
|
|
111
|
+
readonly hashOf: (row: T) => string;
|
|
112
|
+
readonly floor: SanityFloor;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Stable content hash of an upstream row.
|
|
116
|
+
*
|
|
117
|
+
* Keys are sorted so the hash does not depend on JSON property order, which no
|
|
118
|
+
* part of the HTTP stack guarantees. `undefined` and `null` are distinguished
|
|
119
|
+
* because a null price is meaningful data here, not an absence.
|
|
120
|
+
*/
|
|
121
|
+
declare function stableHash(value: unknown): string;
|
|
122
|
+
/**
|
|
123
|
+
* Compare an authoritative upstream snapshot against local state.
|
|
124
|
+
*
|
|
125
|
+
* **The caller must not invoke this with a snapshot that did not fully
|
|
126
|
+
* succeed.** That guard lives one level up, in the client: a non-2xx, a
|
|
127
|
+
* timeout or a body failing the wire schema throws before the differ is ever
|
|
128
|
+
* reached, so a partial response can never present as an absence here. This
|
|
129
|
+
* function's own guards are for a snapshot that IS authoritative but whose
|
|
130
|
+
* shape makes acting on it reckless.
|
|
131
|
+
*
|
|
132
|
+
* @throws {YhikasSyncRefusedError} for any of the D016 refusals. Every one of
|
|
133
|
+
* them aborts before a single write, so a refused run leaves local content
|
|
134
|
+
* exactly as it was — which is the first of PR 05's three obligatory
|
|
135
|
+
* negative tests.
|
|
136
|
+
*/
|
|
137
|
+
declare function planDiff<T>(input: PlanDiffInput<T>): DiffPlan<T>;
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/upstream/wire.d.ts
|
|
140
|
+
/**
|
|
141
|
+
* `MultilingualText` — a Postgres `json` column, so it arrives as a nested
|
|
142
|
+
* object and is never stringified.
|
|
143
|
+
*
|
|
144
|
+
* NOT `.strict()`: unknown keys are stripped rather than rejected, so adding a
|
|
145
|
+
* third language upstream degrades to "the sync ignores it" instead of "every
|
|
146
|
+
* run fails".
|
|
147
|
+
*/
|
|
148
|
+
declare const multilingualTextSchema: z.ZodObject<{
|
|
149
|
+
et: z.ZodString;
|
|
150
|
+
en: z.ZodString;
|
|
151
|
+
}, "strip", z.ZodTypeAny, {
|
|
152
|
+
et: string;
|
|
153
|
+
en: string;
|
|
154
|
+
}, {
|
|
155
|
+
et: string;
|
|
156
|
+
en: string;
|
|
157
|
+
}>;
|
|
158
|
+
type MultilingualText = z.infer<typeof multilingualTextSchema>;
|
|
159
|
+
/**
|
|
160
|
+
* One `room_type` row.
|
|
161
|
+
*
|
|
162
|
+
* `depositAmount` / `discountedDepositAmount` are absent by design. Upstream
|
|
163
|
+
* ships them as hardcoded `null` with no backing column; validating them as
|
|
164
|
+
* `z.null()` would turn the day someone adds the column into a hard sync
|
|
165
|
+
* failure. Deposits are unbuilt admin-side work, not something the sync can
|
|
166
|
+
* surface.
|
|
167
|
+
*/
|
|
168
|
+
declare const roomTypeRowSchema: z.ZodObject<{
|
|
169
|
+
code: z.ZodString;
|
|
170
|
+
name: z.ZodObject<{
|
|
171
|
+
et: z.ZodString;
|
|
172
|
+
en: z.ZodString;
|
|
173
|
+
}, "strip", z.ZodTypeAny, {
|
|
174
|
+
et: string;
|
|
175
|
+
en: string;
|
|
176
|
+
}, {
|
|
177
|
+
et: string;
|
|
178
|
+
en: string;
|
|
179
|
+
}>;
|
|
180
|
+
totalArea: z.ZodNullable<z.ZodString>;
|
|
181
|
+
livingArea: z.ZodNullable<z.ZodString>;
|
|
182
|
+
commonArea: z.ZodNullable<z.ZodString>;
|
|
183
|
+
capacity: z.ZodNullable<z.ZodNumber>;
|
|
184
|
+
monthlyRent: z.ZodNullable<z.ZodString>;
|
|
185
|
+
discountedRent: z.ZodNullable<z.ZodString>;
|
|
186
|
+
dailyRent: z.ZodNullable<z.ZodString>;
|
|
187
|
+
placesOccupied: z.ZodNullable<z.ZodNumber>;
|
|
188
|
+
}, "strip", z.ZodTypeAny, {
|
|
189
|
+
code: string;
|
|
190
|
+
name: {
|
|
191
|
+
et: string;
|
|
192
|
+
en: string;
|
|
193
|
+
};
|
|
194
|
+
totalArea: string | null;
|
|
195
|
+
livingArea: string | null;
|
|
196
|
+
commonArea: string | null;
|
|
197
|
+
capacity: number | null;
|
|
198
|
+
placesOccupied: number | null;
|
|
199
|
+
monthlyRent: string | null;
|
|
200
|
+
dailyRent: string | null;
|
|
201
|
+
discountedRent: string | null;
|
|
202
|
+
}, {
|
|
203
|
+
code: string;
|
|
204
|
+
name: {
|
|
205
|
+
et: string;
|
|
206
|
+
en: string;
|
|
207
|
+
};
|
|
208
|
+
totalArea: string | null;
|
|
209
|
+
livingArea: string | null;
|
|
210
|
+
commonArea: string | null;
|
|
211
|
+
capacity: number | null;
|
|
212
|
+
placesOccupied: number | null;
|
|
213
|
+
monthlyRent: string | null;
|
|
214
|
+
dailyRent: string | null;
|
|
215
|
+
discountedRent: string | null;
|
|
216
|
+
}>;
|
|
217
|
+
type RoomTypeRow = z.infer<typeof roomTypeRowSchema>;
|
|
218
|
+
/**
|
|
219
|
+
* One active `legal_document` row.
|
|
220
|
+
*
|
|
221
|
+
* `type` is `text().notNull().unique()` upstream — NOT a pgEnum, and there is
|
|
222
|
+
* no TS union anywhere. The five values seeded today are closed by convention
|
|
223
|
+
* only and the admin UI can mint a sixth, so this validates the SHAPE of the
|
|
224
|
+
* business key and never its membership in a list. A whitelist here would turn
|
|
225
|
+
* a new upstream document into a hard sync failure.
|
|
226
|
+
*/
|
|
227
|
+
declare const legalDocumentRowSchema: z.ZodObject<{
|
|
228
|
+
type: z.ZodString;
|
|
229
|
+
title: z.ZodObject<{
|
|
230
|
+
et: z.ZodString;
|
|
231
|
+
en: z.ZodString;
|
|
232
|
+
}, "strip", z.ZodTypeAny, {
|
|
233
|
+
et: string;
|
|
234
|
+
en: string;
|
|
235
|
+
}, {
|
|
236
|
+
et: string;
|
|
237
|
+
en: string;
|
|
238
|
+
}>;
|
|
239
|
+
/**
|
|
240
|
+
* Accepted as free text HERE, and screened per-row in the projection.
|
|
241
|
+
*
|
|
242
|
+
* Upstream derives it from an unvalidated free-text form field
|
|
243
|
+
* (`type.toLowerCase().replace(/_/g, '-')`), so an Estonian title yields an
|
|
244
|
+
* Estonian slug — `üldtingimused` — and a title with a space yields a slug
|
|
245
|
+
* with a space. A character-class regex on the RESPONSE schema would fail
|
|
246
|
+
* `legalDocumentsResponseSchema` for the whole payload, so one newly
|
|
247
|
+
* authored document would take the entire resource offline every six hours
|
|
248
|
+
* until someone edited it upstream. The blast radius belongs at one row.
|
|
249
|
+
*/
|
|
250
|
+
slug: z.ZodString;
|
|
251
|
+
htmlContentEt: z.ZodString;
|
|
252
|
+
htmlContentEn: z.ZodString;
|
|
253
|
+
order: z.ZodNumber;
|
|
254
|
+
}, "strip", z.ZodTypeAny, {
|
|
255
|
+
type: string;
|
|
256
|
+
slug: string;
|
|
257
|
+
title: {
|
|
258
|
+
et: string;
|
|
259
|
+
en: string;
|
|
260
|
+
};
|
|
261
|
+
order: number;
|
|
262
|
+
htmlContentEt: string;
|
|
263
|
+
htmlContentEn: string;
|
|
264
|
+
}, {
|
|
265
|
+
type: string;
|
|
266
|
+
slug: string;
|
|
267
|
+
title: {
|
|
268
|
+
et: string;
|
|
269
|
+
en: string;
|
|
270
|
+
};
|
|
271
|
+
order: number;
|
|
272
|
+
htmlContentEt: string;
|
|
273
|
+
htmlContentEn: string;
|
|
274
|
+
}>;
|
|
275
|
+
type LegalDocumentRow = z.infer<typeof legalDocumentRowSchema>;
|
|
276
|
+
/**
|
|
277
|
+
* `success: z.literal(true)` is the load-bearing clause, not decoration.
|
|
278
|
+
*
|
|
279
|
+
* Every upstream failure path sets `success: false` — 401 (missing header,
|
|
280
|
+
* wrong scheme, wrong key, AND an unset server-side key: all four
|
|
281
|
+
* indistinguishable, deny-by-default through one branch), 429, and 500. No
|
|
282
|
+
* route returns 200 with a degraded body. So the discriminator is `success`,
|
|
283
|
+
* never array length, and a snapshot that fails this schema can never be
|
|
284
|
+
* mistaken for an authoritative empty one.
|
|
285
|
+
*/
|
|
286
|
+
declare const roomTypesResponseSchema: z.ZodObject<{
|
|
287
|
+
success: z.ZodLiteral<true>;
|
|
288
|
+
roomTypes: z.ZodArray<z.ZodObject<{
|
|
289
|
+
code: z.ZodString;
|
|
290
|
+
name: z.ZodObject<{
|
|
291
|
+
et: z.ZodString;
|
|
292
|
+
en: z.ZodString;
|
|
293
|
+
}, "strip", z.ZodTypeAny, {
|
|
294
|
+
et: string;
|
|
295
|
+
en: string;
|
|
296
|
+
}, {
|
|
297
|
+
et: string;
|
|
298
|
+
en: string;
|
|
299
|
+
}>;
|
|
300
|
+
totalArea: z.ZodNullable<z.ZodString>;
|
|
301
|
+
livingArea: z.ZodNullable<z.ZodString>;
|
|
302
|
+
commonArea: z.ZodNullable<z.ZodString>;
|
|
303
|
+
capacity: z.ZodNullable<z.ZodNumber>;
|
|
304
|
+
monthlyRent: z.ZodNullable<z.ZodString>;
|
|
305
|
+
discountedRent: z.ZodNullable<z.ZodString>;
|
|
306
|
+
dailyRent: z.ZodNullable<z.ZodString>;
|
|
307
|
+
placesOccupied: z.ZodNullable<z.ZodNumber>;
|
|
308
|
+
}, "strip", z.ZodTypeAny, {
|
|
309
|
+
code: string;
|
|
310
|
+
name: {
|
|
311
|
+
et: string;
|
|
312
|
+
en: string;
|
|
313
|
+
};
|
|
314
|
+
totalArea: string | null;
|
|
315
|
+
livingArea: string | null;
|
|
316
|
+
commonArea: string | null;
|
|
317
|
+
capacity: number | null;
|
|
318
|
+
placesOccupied: number | null;
|
|
319
|
+
monthlyRent: string | null;
|
|
320
|
+
dailyRent: string | null;
|
|
321
|
+
discountedRent: string | null;
|
|
322
|
+
}, {
|
|
323
|
+
code: string;
|
|
324
|
+
name: {
|
|
325
|
+
et: string;
|
|
326
|
+
en: string;
|
|
327
|
+
};
|
|
328
|
+
totalArea: string | null;
|
|
329
|
+
livingArea: string | null;
|
|
330
|
+
commonArea: string | null;
|
|
331
|
+
capacity: number | null;
|
|
332
|
+
placesOccupied: number | null;
|
|
333
|
+
monthlyRent: string | null;
|
|
334
|
+
dailyRent: string | null;
|
|
335
|
+
discountedRent: string | null;
|
|
336
|
+
}>, "many">;
|
|
337
|
+
}, "strip", z.ZodTypeAny, {
|
|
338
|
+
success: true;
|
|
339
|
+
roomTypes: {
|
|
340
|
+
code: string;
|
|
341
|
+
name: {
|
|
342
|
+
et: string;
|
|
343
|
+
en: string;
|
|
344
|
+
};
|
|
345
|
+
totalArea: string | null;
|
|
346
|
+
livingArea: string | null;
|
|
347
|
+
commonArea: string | null;
|
|
348
|
+
capacity: number | null;
|
|
349
|
+
placesOccupied: number | null;
|
|
350
|
+
monthlyRent: string | null;
|
|
351
|
+
dailyRent: string | null;
|
|
352
|
+
discountedRent: string | null;
|
|
353
|
+
}[];
|
|
354
|
+
}, {
|
|
355
|
+
success: true;
|
|
356
|
+
roomTypes: {
|
|
357
|
+
code: string;
|
|
358
|
+
name: {
|
|
359
|
+
et: string;
|
|
360
|
+
en: string;
|
|
361
|
+
};
|
|
362
|
+
totalArea: string | null;
|
|
363
|
+
livingArea: string | null;
|
|
364
|
+
commonArea: string | null;
|
|
365
|
+
capacity: number | null;
|
|
366
|
+
placesOccupied: number | null;
|
|
367
|
+
monthlyRent: string | null;
|
|
368
|
+
dailyRent: string | null;
|
|
369
|
+
discountedRent: string | null;
|
|
370
|
+
}[];
|
|
371
|
+
}>;
|
|
372
|
+
declare const legalDocumentsResponseSchema: z.ZodObject<{
|
|
373
|
+
success: z.ZodLiteral<true>;
|
|
374
|
+
documents: z.ZodArray<z.ZodObject<{
|
|
375
|
+
type: z.ZodString;
|
|
376
|
+
title: z.ZodObject<{
|
|
377
|
+
et: z.ZodString;
|
|
378
|
+
en: z.ZodString;
|
|
379
|
+
}, "strip", z.ZodTypeAny, {
|
|
380
|
+
et: string;
|
|
381
|
+
en: string;
|
|
382
|
+
}, {
|
|
383
|
+
et: string;
|
|
384
|
+
en: string;
|
|
385
|
+
}>;
|
|
386
|
+
/**
|
|
387
|
+
* Accepted as free text HERE, and screened per-row in the projection.
|
|
388
|
+
*
|
|
389
|
+
* Upstream derives it from an unvalidated free-text form field
|
|
390
|
+
* (`type.toLowerCase().replace(/_/g, '-')`), so an Estonian title yields an
|
|
391
|
+
* Estonian slug — `üldtingimused` — and a title with a space yields a slug
|
|
392
|
+
* with a space. A character-class regex on the RESPONSE schema would fail
|
|
393
|
+
* `legalDocumentsResponseSchema` for the whole payload, so one newly
|
|
394
|
+
* authored document would take the entire resource offline every six hours
|
|
395
|
+
* until someone edited it upstream. The blast radius belongs at one row.
|
|
396
|
+
*/
|
|
397
|
+
slug: z.ZodString;
|
|
398
|
+
htmlContentEt: z.ZodString;
|
|
399
|
+
htmlContentEn: z.ZodString;
|
|
400
|
+
order: z.ZodNumber;
|
|
401
|
+
}, "strip", z.ZodTypeAny, {
|
|
402
|
+
type: string;
|
|
403
|
+
slug: string;
|
|
404
|
+
title: {
|
|
405
|
+
et: string;
|
|
406
|
+
en: string;
|
|
407
|
+
};
|
|
408
|
+
order: number;
|
|
409
|
+
htmlContentEt: string;
|
|
410
|
+
htmlContentEn: string;
|
|
411
|
+
}, {
|
|
412
|
+
type: string;
|
|
413
|
+
slug: string;
|
|
414
|
+
title: {
|
|
415
|
+
et: string;
|
|
416
|
+
en: string;
|
|
417
|
+
};
|
|
418
|
+
order: number;
|
|
419
|
+
htmlContentEt: string;
|
|
420
|
+
htmlContentEn: string;
|
|
421
|
+
}>, "many">;
|
|
422
|
+
}, "strip", z.ZodTypeAny, {
|
|
423
|
+
success: true;
|
|
424
|
+
documents: {
|
|
425
|
+
type: string;
|
|
426
|
+
slug: string;
|
|
427
|
+
title: {
|
|
428
|
+
et: string;
|
|
429
|
+
en: string;
|
|
430
|
+
};
|
|
431
|
+
order: number;
|
|
432
|
+
htmlContentEt: string;
|
|
433
|
+
htmlContentEn: string;
|
|
434
|
+
}[];
|
|
435
|
+
}, {
|
|
436
|
+
success: true;
|
|
437
|
+
documents: {
|
|
438
|
+
type: string;
|
|
439
|
+
slug: string;
|
|
440
|
+
title: {
|
|
441
|
+
et: string;
|
|
442
|
+
en: string;
|
|
443
|
+
};
|
|
444
|
+
order: number;
|
|
445
|
+
htmlContentEt: string;
|
|
446
|
+
htmlContentEn: string;
|
|
447
|
+
}[];
|
|
448
|
+
}>;
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/projection.d.ts
|
|
451
|
+
/** Injected at the seam; see the module docblock for why it is not imported. */
|
|
452
|
+
type HtmlSanitizer = (html: string) => string;
|
|
453
|
+
interface ProjectionOptions {
|
|
454
|
+
/** The locale whose values live on the base entity row. `et` or `en`. */
|
|
455
|
+
readonly defaultLocale: string;
|
|
456
|
+
}
|
|
457
|
+
interface ProjectedRow {
|
|
458
|
+
/** The business key — `room_type.code` or `legal_document.type`. */
|
|
459
|
+
readonly key: string;
|
|
460
|
+
/** Base-row fields, including the default locale's values for translatable fields. */
|
|
461
|
+
readonly base: Record<string, unknown>;
|
|
462
|
+
/**
|
|
463
|
+
* Translatable values for the non-default locale, or `null` when that
|
|
464
|
+
* locale's text is empty upstream. `null` means "unpublish that locale",
|
|
465
|
+
* never "write an empty string".
|
|
466
|
+
*/
|
|
467
|
+
readonly secondary: Record<string, unknown> | null;
|
|
468
|
+
readonly hash: string;
|
|
469
|
+
}
|
|
470
|
+
interface SkippedRow {
|
|
471
|
+
readonly key: string;
|
|
472
|
+
readonly reason: string;
|
|
473
|
+
}
|
|
474
|
+
interface ProjectionResult {
|
|
475
|
+
readonly projected: readonly ProjectedRow[];
|
|
476
|
+
readonly skipped: readonly SkippedRow[];
|
|
477
|
+
}
|
|
478
|
+
/** The locale that is NOT the base row's. */
|
|
479
|
+
declare function secondaryLocaleOf(defaultLocale: string): string;
|
|
480
|
+
declare function projectRoomTypes(rows: readonly RoomTypeRow[], options: ProjectionOptions): ProjectionResult;
|
|
481
|
+
declare function projectLegalDocuments(rows: readonly LegalDocumentRow[], options: ProjectionOptions, sanitize: HtmlSanitizer): ProjectionResult;
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/apply.d.ts
|
|
484
|
+
/**
|
|
485
|
+
* The `AdminClient` surface the sync uses, structurally.
|
|
486
|
+
*
|
|
487
|
+
* Declared rather than imported so the apply logic is unit-testable with a
|
|
488
|
+
* fake — CI runs unit tests only, with no database, so a design that could
|
|
489
|
+
* only be exercised by an integration test would in practice be exercised by
|
|
490
|
+
* nothing.
|
|
491
|
+
*/
|
|
492
|
+
interface SyncEntityClient {
|
|
493
|
+
findMany(options: {
|
|
494
|
+
limit: number;
|
|
495
|
+
}): Promise<Record<string, unknown>[]>;
|
|
496
|
+
create(data: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
497
|
+
update(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
498
|
+
updateForLocale(id: string, data: Record<string, unknown>, locale: string): Promise<Record<string, unknown>>;
|
|
499
|
+
deleteTranslation(id: string, locale: string): Promise<void>;
|
|
500
|
+
}
|
|
501
|
+
interface SyncLogger {
|
|
502
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
503
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
504
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
505
|
+
}
|
|
506
|
+
interface ApplyCounts {
|
|
507
|
+
created: number;
|
|
508
|
+
updated: number;
|
|
509
|
+
retired: number;
|
|
510
|
+
unchanged: number;
|
|
511
|
+
failed: number;
|
|
512
|
+
}
|
|
513
|
+
/** Thrown when at least one row failed; carries the counts that were achieved. */
|
|
514
|
+
declare class YhikasApplyPartialError extends Error {
|
|
515
|
+
readonly counts: ApplyCounts;
|
|
516
|
+
constructor(resource: SyncResource, counts: ApplyCounts);
|
|
517
|
+
}
|
|
518
|
+
declare function readLocalRows(client: SyncEntityClient, keyField: string, limit: number): Promise<LocalRow[]>;
|
|
519
|
+
interface ApplyPlanInput {
|
|
520
|
+
readonly resource: SyncResource;
|
|
521
|
+
readonly plan: DiffPlan<ProjectedRow>;
|
|
522
|
+
readonly client: SyncEntityClient;
|
|
523
|
+
/** The locale that is NOT on the base row — the one whose publish state is toggled. */
|
|
524
|
+
readonly secondaryLocale: string;
|
|
525
|
+
readonly logger: SyncLogger;
|
|
526
|
+
}
|
|
527
|
+
declare function applyPlan(input: ApplyPlanInput): Promise<ApplyCounts>;
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/entities/guard.d.ts
|
|
530
|
+
/** Thrown when a writer other than the sync tries to mutate synced content. */
|
|
531
|
+
declare class YhikasSyncedContentReadOnlyError extends Error {
|
|
532
|
+
constructor(entityName: string, action: 'create' | 'update' | 'delete' | 'bulk update', actorId: string);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Refuse every create/update/delete not performed by the sync actor.
|
|
536
|
+
*
|
|
537
|
+
* @param entityName Used only in the refusal message — the hook context does
|
|
538
|
+
* not carry the entity name in a shape this can rely on.
|
|
539
|
+
*/
|
|
540
|
+
declare function machineWritten<N extends string>(entityName: N): Behavior<Record<never, never>, `yhikas-sync:machine-written:${N}`>;
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/entities/legal-document.d.ts
|
|
543
|
+
/**
|
|
544
|
+
* `yhikas_legal_document` — the local projection of yhikas-admin's
|
|
545
|
+
* `legal_document` (active rows only; the upstream route filters
|
|
546
|
+
* `is_active = true` server-side so an unpublished draft cannot leak).
|
|
547
|
+
*
|
|
548
|
+
* ## The body is HTML, and it is sanitized on the way in
|
|
549
|
+
*
|
|
550
|
+
* Upstream stores raw TipTap-authored HTML in parallel `html_content_et` /
|
|
551
|
+
* `html_content_en` columns, so this side stores and renders trusted HTML
|
|
552
|
+
* rather than markdown. Every value passes through `@murumets-ee/blocks`'
|
|
553
|
+
* allowlist-based `sanitizeHtml()` at INGEST, not at render: sanitizing at the
|
|
554
|
+
* boundary means the stored bytes are already safe, so a future reader that
|
|
555
|
+
* forgets to sanitize is not a stored-XSS hole.
|
|
556
|
+
*
|
|
557
|
+
* "It comes from our own admin" is not a reason to skip it — F006 is precisely
|
|
558
|
+
* why. The upstream price mutations are unauthenticated, which means the
|
|
559
|
+
* premise that this content is trusted is weaker than it looks, and the sync
|
|
560
|
+
* would propagate whatever it was handed with complete fidelity.
|
|
561
|
+
*
|
|
562
|
+
* ## Security
|
|
563
|
+
*
|
|
564
|
+
* Same two controls as `yhikas_room_type`: deny-by-default for every non-admin
|
|
565
|
+
* role, plus `machineWritten` refusing any writer that is not the sync actor.
|
|
566
|
+
* `access` is advisory metadata only.
|
|
567
|
+
*/
|
|
568
|
+
declare const YHIKAS_LEGAL_DOCUMENT_ENTITY = "yhikas_legal_document";
|
|
569
|
+
declare const YhikasLegalDocument: import("@murumets-ee/entity").Entity<{
|
|
570
|
+
id: import("@murumets-ee/entity").IdField;
|
|
571
|
+
} & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
|
|
572
|
+
/**
|
|
573
|
+
* The business key. `legal_document.type` is `text().notNull().unique()`
|
|
574
|
+
* upstream — NOT a pgEnum, and there is no TS union anywhere. The five
|
|
575
|
+
* values seeded today are closed by convention only, so nothing here
|
|
576
|
+
* validates membership in a list: doing so would turn a newly authored
|
|
577
|
+
* upstream document into a hard sync failure.
|
|
578
|
+
*/
|
|
579
|
+
type: import("@murumets-ee/entity").TextField & {
|
|
580
|
+
readonly required: true;
|
|
581
|
+
readonly unique: true;
|
|
582
|
+
readonly indexed: true;
|
|
583
|
+
readonly maxLength: 190;
|
|
584
|
+
};
|
|
585
|
+
title: import("@murumets-ee/entity").TextField & {
|
|
586
|
+
readonly required: true;
|
|
587
|
+
readonly translatable: true;
|
|
588
|
+
};
|
|
589
|
+
/**
|
|
590
|
+
* The upstream slug, verbatim — deliberately `field.text` and not
|
|
591
|
+
* `field.slug`, which would regenerate it from a source field and silently
|
|
592
|
+
* diverge from the anchor links yhikas-admin already publishes.
|
|
593
|
+
*
|
|
594
|
+
* Indexed but NOT unique: it is unique upstream, but the sync writes rows
|
|
595
|
+
* one at a time, so a run in which two documents exchange slugs would hit
|
|
596
|
+
* a transient collision mid-run and fail a sync that is not actually
|
|
597
|
+
* invalid. `type` is the identity and carries the uniqueness guarantee.
|
|
598
|
+
*/
|
|
599
|
+
sourceSlug: import("@murumets-ee/entity").TextField & {
|
|
600
|
+
readonly required: true;
|
|
601
|
+
readonly indexed: true;
|
|
602
|
+
readonly maxLength: 190;
|
|
603
|
+
}; /** Sanitized HTML. Translatable: `et` on the base row, `en` in the translations table. */
|
|
604
|
+
body: import("@murumets-ee/entity").RichTextField & {
|
|
605
|
+
readonly required: true;
|
|
606
|
+
readonly translatable: true;
|
|
607
|
+
}; /** Upstream display order. */
|
|
608
|
+
order: import("@murumets-ee/entity").NumberField & {
|
|
609
|
+
readonly integer: true;
|
|
610
|
+
readonly required: true;
|
|
611
|
+
readonly default: 0;
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* Whether the upstream row carried non-empty English content. A
|
|
615
|
+
* convenience for the renderer — the control is per-locale publish status
|
|
616
|
+
* (D015). Serving Estonian legal text under an English URL is the failure
|
|
617
|
+
* this exists to prevent, and it is worse than the document being absent:
|
|
618
|
+
* rental terms and fire-safety instructions read as correct English-locale
|
|
619
|
+
* content to every automated check.
|
|
620
|
+
*/
|
|
621
|
+
hasEnglish: import("@murumets-ee/entity").BooleanField & {
|
|
622
|
+
readonly required: true;
|
|
623
|
+
readonly default: false;
|
|
624
|
+
};
|
|
625
|
+
/**
|
|
626
|
+
* Stable hash of the normalized upstream row. `legal_document` DOES carry
|
|
627
|
+
* an `updated_at` — set by hand rather than by `$onUpdate` — but the public
|
|
628
|
+
* route deliberately excludes it from the projection, so it is not
|
|
629
|
+
* available as a change signal on this side.
|
|
630
|
+
*/
|
|
631
|
+
sourceHash: import("@murumets-ee/entity").TextField & {
|
|
632
|
+
readonly maxLength: 64;
|
|
633
|
+
};
|
|
634
|
+
}, "yhikas_legal_document", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_legal_document">]>;
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/entities/room-type.d.ts
|
|
637
|
+
/**
|
|
638
|
+
* `yhikas_room_type` — the local projection of yhikas-admin's `room_type`.
|
|
639
|
+
*
|
|
640
|
+
* ## Decimals are text, and that is not a style choice
|
|
641
|
+
*
|
|
642
|
+
* `field.number()` maps to Postgres `doublePrecision` unless `integer: true`
|
|
643
|
+
* — a float8. Every money and area value here arrives from `pg` as a STRING
|
|
644
|
+
* (`numeric` serialization) and is stored as one, because routing a price
|
|
645
|
+
* through a binary float introduces rounding that nothing downstream can undo.
|
|
646
|
+
* `capacity` and `placesOccupied` are genuine integers and use `field.number`.
|
|
647
|
+
*
|
|
648
|
+
* ## Nulls are data
|
|
649
|
+
*
|
|
650
|
+
* Upstream passes nulls through untouched and says why: "so the site can
|
|
651
|
+
* decide how to render a missing price rather than displaying a fabricated
|
|
652
|
+
* zero." None of the price fields is `required`, and the sync substitutes no
|
|
653
|
+
* defaults — a missing price stays missing all the way to the renderer.
|
|
654
|
+
*
|
|
655
|
+
* ## What this side declares
|
|
656
|
+
*
|
|
657
|
+
* There is no currency column, no VAT column and no period column upstream;
|
|
658
|
+
* EUR-net-per-month is a convention held in column NAMING. `currency` and
|
|
659
|
+
* `vatTreatment` are therefore real columns written with constants, so a
|
|
660
|
+
* mis-entered source value renders as a visibly wrong number against a
|
|
661
|
+
* declared unit rather than as a plausible one.
|
|
662
|
+
*
|
|
663
|
+
* ## Security
|
|
664
|
+
*
|
|
665
|
+
* `access` below is advisory metadata — the firewall does not read it and the
|
|
666
|
+
* `admin` role bypasses it unconditionally. The real controls are (a)
|
|
667
|
+
* deny-by-default: `buildInitialRoleDefinitions` grants no non-admin role any
|
|
668
|
+
* permission on this entity, and (b) `machineWritten`, which refuses every
|
|
669
|
+
* writer that is not the sync actor, including an administrator in the CRUD
|
|
670
|
+
* UI. `admin.disableCreate` is a UI affordance, never a gate.
|
|
671
|
+
*/
|
|
672
|
+
declare const YHIKAS_ROOM_TYPE_ENTITY = "yhikas_room_type";
|
|
673
|
+
/** VAT treatments this projection can express. `net` = the amount excludes VAT. */
|
|
674
|
+
declare const VAT_TREATMENTS: readonly ["net", "gross"];
|
|
675
|
+
declare const YhikasRoomType: import("@murumets-ee/entity").Entity<{
|
|
676
|
+
id: import("@murumets-ee/entity").IdField;
|
|
677
|
+
} & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
|
|
678
|
+
/**
|
|
679
|
+
* The business key. `room_type.code` is `notNull().unique()` upstream and
|
|
680
|
+
* is what identity is keyed on — never the serial `id`, which is an
|
|
681
|
+
* implementation detail of the other system and would make this system's
|
|
682
|
+
* content depend on the other's insert order.
|
|
683
|
+
*/
|
|
684
|
+
code: import("@murumets-ee/entity").TextField & {
|
|
685
|
+
readonly required: true;
|
|
686
|
+
readonly unique: true;
|
|
687
|
+
readonly indexed: true;
|
|
688
|
+
readonly maxLength: 190;
|
|
689
|
+
}; /** Translatable: `et` on the base row, `en` in `yhikas_room_type_translations`. */
|
|
690
|
+
name: import("@murumets-ee/entity").TextField & {
|
|
691
|
+
readonly required: true;
|
|
692
|
+
readonly translatable: true;
|
|
693
|
+
}; /** Areas in m², as strings. See the note on decimals above. */
|
|
694
|
+
totalArea: import("@murumets-ee/entity").TextField & {
|
|
695
|
+
readonly maxLength: 32;
|
|
696
|
+
};
|
|
697
|
+
livingArea: import("@murumets-ee/entity").TextField & {
|
|
698
|
+
readonly maxLength: 32;
|
|
699
|
+
};
|
|
700
|
+
commonArea: import("@murumets-ee/entity").TextField & {
|
|
701
|
+
readonly maxLength: 32;
|
|
702
|
+
};
|
|
703
|
+
capacity: import("@murumets-ee/entity").NumberField & {
|
|
704
|
+
readonly integer: true;
|
|
705
|
+
};
|
|
706
|
+
placesOccupied: import("@murumets-ee/entity").NumberField & {
|
|
707
|
+
readonly integer: true;
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* Period is carried in the field NAME rather than in a `period` column,
|
|
711
|
+
* deliberately: one row holds both a monthly and a daily rate, so a single
|
|
712
|
+
* period column could only ever be wrong about one of them.
|
|
713
|
+
*/
|
|
714
|
+
monthlyRent: import("@murumets-ee/entity").TextField & {
|
|
715
|
+
readonly maxLength: 32;
|
|
716
|
+
};
|
|
717
|
+
discountedMonthlyRent: import("@murumets-ee/entity").TextField & {
|
|
718
|
+
readonly maxLength: 32;
|
|
719
|
+
};
|
|
720
|
+
dailyRent: import("@murumets-ee/entity").TextField & {
|
|
721
|
+
readonly maxLength: 32;
|
|
722
|
+
}; /** Declared by this side; not data upstream. */
|
|
723
|
+
currency: import("@murumets-ee/entity").TextField & {
|
|
724
|
+
readonly required: true;
|
|
725
|
+
readonly maxLength: 3;
|
|
726
|
+
readonly default: "EUR";
|
|
727
|
+
};
|
|
728
|
+
vatTreatment: import("@murumets-ee/entity").SelectField & {
|
|
729
|
+
options: readonly ["net", "gross"];
|
|
730
|
+
} & {
|
|
731
|
+
readonly options: readonly ["net", "gross"];
|
|
732
|
+
readonly required: true;
|
|
733
|
+
readonly default: "net";
|
|
734
|
+
};
|
|
735
|
+
/**
|
|
736
|
+
* Whether the upstream row carried a non-empty English name. A convenience
|
|
737
|
+
* for a renderer building an English price sheet — NOT the control. The
|
|
738
|
+
* control is per-locale publish status (D015): when this is `false` the
|
|
739
|
+
* `en` locale is unpublished, so the public read path cannot serve it
|
|
740
|
+
* whether or not anyone reads this flag.
|
|
741
|
+
*/
|
|
742
|
+
hasEnglish: import("@murumets-ee/entity").BooleanField & {
|
|
743
|
+
readonly required: true;
|
|
744
|
+
readonly default: false;
|
|
745
|
+
};
|
|
746
|
+
/**
|
|
747
|
+
* Stable hash of the normalized upstream row. `room_type` carries no
|
|
748
|
+
* timestamp of any kind — no `created_at`, no `updated_at`, no version —
|
|
749
|
+
* so this is the only available change signal, and without it every run
|
|
750
|
+
* would rewrite all ~25 rows and fill the audit log with ~100 no-op
|
|
751
|
+
* updates a day.
|
|
752
|
+
*/
|
|
753
|
+
sourceHash: import("@murumets-ee/entity").TextField & {
|
|
754
|
+
readonly maxLength: 64;
|
|
755
|
+
};
|
|
756
|
+
}, "yhikas_room_type", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "yhikas-sync:machine-written:yhikas_room_type">]>;
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/entity-client.d.ts
|
|
759
|
+
/** The `AdminClient` methods the adapter forwards, structurally. */
|
|
760
|
+
type AdminClientLike = ReturnType<ToolkitApp['getClient']>;
|
|
761
|
+
/**
|
|
762
|
+
* @param defaultLocale The app's REAL default locale — resolved from
|
|
763
|
+
* `@murumets-ee/content`, never configured. It is passed explicitly on every
|
|
764
|
+
* `updateForLocale` call and is not optional, because
|
|
765
|
+
* `elevateRequestContext` deliberately strips `locale`/`defaultLocale` from
|
|
766
|
+
* the context it builds, and `updateForLocale` THROWS when it can resolve
|
|
767
|
+
* the default locale from neither the options nor the context.
|
|
768
|
+
*/
|
|
769
|
+
declare function toSyncEntityClient(client: AdminClientLike, defaultLocale: string): SyncEntityClient;
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/sync-state-table.d.ts
|
|
772
|
+
/**
|
|
773
|
+
* `yhikas_sync_state` — one row per synced resource, recording when that
|
|
774
|
+
* resource last synced successfully.
|
|
775
|
+
*
|
|
776
|
+
* ## Why this table exists at all
|
|
777
|
+
*
|
|
778
|
+
* Phase 05 hoped the job could record success through
|
|
779
|
+
* `@murumets-ee/queue`'s `heartbeats-table.ts` "rather than inventing a status
|
|
780
|
+
* field". Measured (R012 §1), that is not available:
|
|
781
|
+
*
|
|
782
|
+
* - `toolkit_worker_heartbeats` is keyed on `workerId` — one row per worker
|
|
783
|
+
* PROCESS. It answers "is any worker alive", never "did this schedule fire".
|
|
784
|
+
* - `QueueAlerter.recordFailure` is reached from exactly one place,
|
|
785
|
+
* `failJob`'s dead-letter branch, so it fires only on a job that RAN and
|
|
786
|
+
* THREW. A job that never starts produces no error and therefore no alert.
|
|
787
|
+
*
|
|
788
|
+
* The failure S7 requires be observable — a job that silently stops running
|
|
789
|
+
* while the site keeps serving last month's prices — is precisely the one
|
|
790
|
+
* neither can see. So the detection is new; only the alert channel is
|
|
791
|
+
* inherited (the watchdog throws, and the throw reaches the shipped
|
|
792
|
+
* dedupe/digest/email path).
|
|
793
|
+
*
|
|
794
|
+
* This is infrastructure, not content: `defineTable` + `TableClient`, per
|
|
795
|
+
* CLAUDE.md's two-tier rule. It is also the surface a human or a monitor reads
|
|
796
|
+
* to answer "how stale is the site's price sheet right now", which is what
|
|
797
|
+
* makes the staleness observable rather than merely bounded.
|
|
798
|
+
*/
|
|
799
|
+
declare const yhikasSyncStateTable: {
|
|
800
|
+
table: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
801
|
+
name: string;
|
|
802
|
+
schema: undefined;
|
|
803
|
+
columns: {
|
|
804
|
+
[x: string]: import("drizzle-orm/pg-core").PgColumn<{
|
|
805
|
+
name: string;
|
|
806
|
+
tableName: string;
|
|
807
|
+
dataType: import("drizzle-orm").ColumnDataType;
|
|
808
|
+
columnType: string;
|
|
809
|
+
data: unknown;
|
|
810
|
+
driverParam: unknown;
|
|
811
|
+
notNull: false;
|
|
812
|
+
hasDefault: false;
|
|
813
|
+
isPrimaryKey: false;
|
|
814
|
+
isAutoincrement: false;
|
|
815
|
+
hasRuntimeDefault: false;
|
|
816
|
+
enumValues: string[] | undefined;
|
|
817
|
+
baseColumn: never;
|
|
818
|
+
identity: undefined;
|
|
819
|
+
generated: undefined;
|
|
820
|
+
}, {}, {}>;
|
|
821
|
+
};
|
|
822
|
+
dialect: "pg";
|
|
823
|
+
}>;
|
|
824
|
+
schema: import("@murumets-ee/db").TableDefinition<{
|
|
825
|
+
/** `room_types` | `legal_documents` — see `SyncResource`. */readonly resource: import("@murumets-ee/db").ColumnFactory<string, "varchar", true, false>;
|
|
826
|
+
/**
|
|
827
|
+
* When this resource was first tracked. The watchdog measures staleness
|
|
828
|
+
* from `lastSuccessAt ?? firstSeenAt`, so a sync that has NEVER once
|
|
829
|
+
* succeeded eventually alerts instead of looking indistinguishable from
|
|
830
|
+
* one that is merely young.
|
|
831
|
+
*/
|
|
832
|
+
readonly firstSeenAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", true, true>; /** Updated on every run, successful or not. */
|
|
833
|
+
readonly lastAttemptAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>; /** Updated ONLY on a fully applied run. This is what the watchdog reads. */
|
|
834
|
+
readonly lastSuccessAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>;
|
|
835
|
+
/**
|
|
836
|
+
* The last failure's message, cleared on the next success. Bounded, and
|
|
837
|
+
* carries no credential: the client never puts a response body or a key
|
|
838
|
+
* into an error message.
|
|
839
|
+
*/
|
|
840
|
+
readonly lastError: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>; /** Counts from the last successful run — what a human wants to see first. */
|
|
841
|
+
readonly lastCreated: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
842
|
+
readonly lastUpdated: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
843
|
+
readonly lastRetired: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
844
|
+
readonly lastUnchanged: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
845
|
+
}>;
|
|
846
|
+
columnKinds: Readonly<Record<string, import("@murumets-ee/db").ColumnKind>>;
|
|
847
|
+
primaryKeyColumns: readonly string[];
|
|
848
|
+
makeClient: (database: import("drizzle-orm/postgres-js").PostgresJsDatabase) => import("@murumets-ee/db").TableClient<{
|
|
849
|
+
/** `room_types` | `legal_documents` — see `SyncResource`. */readonly resource: import("@murumets-ee/db").ColumnFactory<string, "varchar", true, false>;
|
|
850
|
+
/**
|
|
851
|
+
* When this resource was first tracked. The watchdog measures staleness
|
|
852
|
+
* from `lastSuccessAt ?? firstSeenAt`, so a sync that has NEVER once
|
|
853
|
+
* succeeded eventually alerts instead of looking indistinguishable from
|
|
854
|
+
* one that is merely young.
|
|
855
|
+
*/
|
|
856
|
+
readonly firstSeenAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", true, true>; /** Updated on every run, successful or not. */
|
|
857
|
+
readonly lastAttemptAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>; /** Updated ONLY on a fully applied run. This is what the watchdog reads. */
|
|
858
|
+
readonly lastSuccessAt: import("@murumets-ee/db").ColumnFactory<Date, "timestamp", false, false>;
|
|
859
|
+
/**
|
|
860
|
+
* The last failure's message, cleared on the next success. Bounded, and
|
|
861
|
+
* carries no credential: the client never puts a response body or a key
|
|
862
|
+
* into an error message.
|
|
863
|
+
*/
|
|
864
|
+
readonly lastError: import("@murumets-ee/db").ColumnFactory<string, "varchar", false, false>; /** Counts from the last successful run — what a human wants to see first. */
|
|
865
|
+
readonly lastCreated: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
866
|
+
readonly lastUpdated: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
867
|
+
readonly lastRetired: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
868
|
+
readonly lastUnchanged: import("@murumets-ee/db").ColumnFactory<number, "integer", true, true>;
|
|
869
|
+
}, import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
870
|
+
name: string;
|
|
871
|
+
schema: undefined;
|
|
872
|
+
columns: {
|
|
873
|
+
[x: string]: import("drizzle-orm/pg-core").PgColumn<{
|
|
874
|
+
name: string;
|
|
875
|
+
tableName: string;
|
|
876
|
+
dataType: import("drizzle-orm").ColumnDataType;
|
|
877
|
+
columnType: string;
|
|
878
|
+
data: unknown;
|
|
879
|
+
driverParam: unknown;
|
|
880
|
+
notNull: false;
|
|
881
|
+
hasDefault: false;
|
|
882
|
+
isPrimaryKey: false;
|
|
883
|
+
isAutoincrement: false;
|
|
884
|
+
hasRuntimeDefault: false;
|
|
885
|
+
enumValues: string[] | undefined;
|
|
886
|
+
baseColumn: never;
|
|
887
|
+
identity: undefined;
|
|
888
|
+
generated: undefined;
|
|
889
|
+
}, {}, {}>;
|
|
890
|
+
};
|
|
891
|
+
dialect: "pg";
|
|
892
|
+
}>>;
|
|
893
|
+
};
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/sync-state.d.ts
|
|
896
|
+
interface SyncStateRecord {
|
|
897
|
+
readonly resource: string;
|
|
898
|
+
readonly firstSeenAt: Date;
|
|
899
|
+
readonly lastAttemptAt: Date | null;
|
|
900
|
+
readonly lastSuccessAt: Date | null;
|
|
901
|
+
readonly lastError: string | null;
|
|
902
|
+
}
|
|
903
|
+
interface SyncStateStore {
|
|
904
|
+
/** Create the row if absent, so the watchdog can measure a never-ran sync from somewhere. */
|
|
905
|
+
ensure(resource: SyncResource, now: Date): Promise<void>;
|
|
906
|
+
recordAttempt(resource: SyncResource, now: Date): Promise<void>;
|
|
907
|
+
recordSuccess(resource: SyncResource, now: Date, counts: ApplyCounts): Promise<void>;
|
|
908
|
+
recordFailure(resource: SyncResource, now: Date, error: string): Promise<void>;
|
|
909
|
+
readAll(): Promise<SyncStateRecord[]>;
|
|
910
|
+
}
|
|
911
|
+
type SyncStateClient = ReturnType<typeof yhikasSyncStateTable.makeClient>;
|
|
912
|
+
declare function truncateError(message: string): string;
|
|
913
|
+
declare function createSyncStateStore(client: SyncStateClient): SyncStateStore;
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/upstream/client.d.ts
|
|
916
|
+
interface YhikasUpstreamClientOptions {
|
|
917
|
+
/** Origin of the yhikas-admin deployment. Must be http or https. */
|
|
918
|
+
baseUrl: string;
|
|
919
|
+
/** The sync's OWN bearer credential — never the site's (see `API_KEY_ENV_VAR`). */
|
|
920
|
+
apiKey: string;
|
|
921
|
+
/** Per-request deadline in milliseconds. */
|
|
922
|
+
timeoutMs: number;
|
|
923
|
+
/** Injectable for tests. Defaults to the global `fetch`. */
|
|
924
|
+
fetchImpl?: typeof fetch;
|
|
925
|
+
}
|
|
926
|
+
declare class YhikasUpstreamClient {
|
|
927
|
+
#private;
|
|
928
|
+
constructor(options: YhikasUpstreamClientOptions);
|
|
929
|
+
/** `GET /api/public/room-types`. Ordered by `code` upstream; unpaginated. */
|
|
930
|
+
fetchRoomTypes(): Promise<RoomTypeRow[]>;
|
|
931
|
+
/** `GET /api/public/legal-documents`. Active only, ordered by `order`; unpaginated. */
|
|
932
|
+
fetchLegalDocuments(): Promise<LegalDocumentRow[]>;
|
|
933
|
+
}
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region src/run-sync.d.ts
|
|
936
|
+
interface SyncRunDeps {
|
|
937
|
+
readonly upstream: YhikasUpstreamClient;
|
|
938
|
+
readonly roomTypeClient: SyncEntityClient;
|
|
939
|
+
readonly legalDocumentClient: SyncEntityClient;
|
|
940
|
+
readonly state: SyncStateStore;
|
|
941
|
+
readonly sanitizeHtml: HtmlSanitizer;
|
|
942
|
+
readonly logger: SyncLogger;
|
|
943
|
+
readonly defaultLocale: string;
|
|
944
|
+
readonly floor: SanityFloor;
|
|
945
|
+
/** Injectable so tests are not order-dependent on the wall clock. */
|
|
946
|
+
readonly now?: () => Date;
|
|
947
|
+
}
|
|
948
|
+
interface ResourceOutcome {
|
|
949
|
+
readonly resource: SyncResource;
|
|
950
|
+
readonly ok: boolean;
|
|
951
|
+
readonly counts: ApplyCounts | null;
|
|
952
|
+
readonly skipped: number;
|
|
953
|
+
readonly error: string | null;
|
|
954
|
+
}
|
|
955
|
+
interface SyncRunSummary {
|
|
956
|
+
readonly outcomes: readonly ResourceOutcome[];
|
|
957
|
+
readonly ok: boolean;
|
|
958
|
+
}
|
|
959
|
+
/** Thrown when at least one resource failed, so the queue retries and eventually alerts. */
|
|
960
|
+
declare class YhikasSyncRunError extends Error {
|
|
961
|
+
readonly summary: SyncRunSummary;
|
|
962
|
+
constructor(summary: SyncRunSummary);
|
|
963
|
+
}
|
|
964
|
+
declare function runYhikasSync(deps: SyncRunDeps): Promise<SyncRunSummary>;
|
|
965
|
+
//#endregion
|
|
966
|
+
//#region src/upstream/errors.d.ts
|
|
967
|
+
type UpstreamFailureKind = /** DNS failure, connection refused, TLS error — the request never completed. */'network' /** The per-request deadline elapsed. The queue has no per-job timeout (R012 §3). */ | 'timeout' /** A completed response the sync will not act on: 401, 429, 5xx, or any non-2xx. */ | 'http' /** A 2xx whose body failed the wire schema — including `success: false`. */ | 'shape';
|
|
968
|
+
declare class YhikasUpstreamError extends Error {
|
|
969
|
+
readonly resource: SyncResource;
|
|
970
|
+
readonly kind: UpstreamFailureKind;
|
|
971
|
+
readonly status: number | undefined;
|
|
972
|
+
constructor(resource: SyncResource, kind: UpstreamFailureKind, message: string, options?: {
|
|
973
|
+
status?: number;
|
|
974
|
+
cause?: unknown;
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Thrown when a snapshot IS authoritative but applying it would be reckless —
|
|
979
|
+
* the D016 guards. Separate from {@link YhikasUpstreamError} because the
|
|
980
|
+
* remedies differ: an upstream failure usually resolves itself on the next
|
|
981
|
+
* run, whereas this one wants a human to look at why the source shrank.
|
|
982
|
+
*/
|
|
983
|
+
declare class YhikasSyncRefusedError extends Error {
|
|
984
|
+
readonly resource: SyncResource;
|
|
985
|
+
readonly reason: 'empty-snapshot' | 'retire-fraction' | 'oversized-snapshot' | 'duplicate-key';
|
|
986
|
+
constructor(resource: SyncResource, reason: YhikasSyncRefusedError['reason'], message: string);
|
|
987
|
+
}
|
|
988
|
+
//#endregion
|
|
989
|
+
//#region src/watchdog.d.ts
|
|
990
|
+
interface ResourceStaleness {
|
|
991
|
+
readonly resource: SyncResource;
|
|
992
|
+
/** `null` when this resource has never once synced successfully. */
|
|
993
|
+
readonly lastSuccessAt: Date | null;
|
|
994
|
+
/** Age of the last success, or of the tracking row when there has never been one. */
|
|
995
|
+
readonly ageMs: number;
|
|
996
|
+
readonly stale: boolean;
|
|
997
|
+
readonly lastError: string | null;
|
|
998
|
+
}
|
|
999
|
+
/** Thrown to reach the queue's dead-letter alerting. */
|
|
1000
|
+
declare class YhikasSyncStaleError extends Error {
|
|
1001
|
+
readonly stale: readonly ResourceStaleness[];
|
|
1002
|
+
constructor(stale: readonly ResourceStaleness[], staleAfterMs: number);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Assess every in-scope resource.
|
|
1006
|
+
*
|
|
1007
|
+
* A resource with no state row at all counts as stale with an age of
|
|
1008
|
+
* `Infinity`: "nothing has ever written this row" is the loudest possible
|
|
1009
|
+
* version of "this sync has never run", and treating a missing row as
|
|
1010
|
+
* not-yet-stale would make an install that never once synced look healthy
|
|
1011
|
+
* forever — the exact unobserved-bound failure S7 names.
|
|
1012
|
+
*
|
|
1013
|
+
* A resource that HAS a row but no success is measured from `firstSeenAt`, so
|
|
1014
|
+
* a freshly installed sync gets one full window to succeed before it alerts.
|
|
1015
|
+
*/
|
|
1016
|
+
declare function assessStaleness(records: readonly SyncStateRecord[], now: Date, staleAfterMs: number): ResourceStaleness[];
|
|
1017
|
+
/**
|
|
1018
|
+
* Assess, and throw if anything is stale.
|
|
1019
|
+
*
|
|
1020
|
+
* @throws {YhikasSyncStaleError} which the queue turns into a dead-lettered
|
|
1021
|
+
* job and therefore into the shipped alert.
|
|
1022
|
+
*/
|
|
1023
|
+
declare function assertNotStale(records: readonly SyncStateRecord[], now: Date, staleAfterMs: number): ResourceStaleness[];
|
|
1024
|
+
//#endregion
|
|
1025
|
+
export { API_KEY_ENV_VAR, type AdminClientLike, type ApplyCounts, type ApplyPlanInput, BASE_URL_ENV_VAR, DECLARED_CURRENCY, DECLARED_VAT_TREATMENT, type DiffPlan, EN_LOCALE, ET_LOCALE, type HtmlSanitizer, LEGAL_DOCUMENTS_RESOURCE, type LegalDocumentRow, type LocalRow, type MultilingualText, type PlanDiffInput, type ProjectedRow, type ProjectionOptions, type ProjectionResult, ROOM_TYPES_RESOURCE, type ResolvedYhikasSyncConfig, type ResourceOutcome, type ResourceStaleness, type RoomTypeRow, SYNC_JOB_NAME, SYNC_RESOURCES, type SanityFloor, type SkippedRow, type SyncEntityClient, type SyncLogger, type SyncResource, type SyncRunDeps, type SyncRunSummary, type SyncStateRecord, type SyncStateStore, type UpstreamFailureKind, VAT_TREATMENTS, WATCHDOG_JOB_NAME, YHIKAS_LEGAL_DOCUMENT_ENTITY, YHIKAS_ROOM_TYPE_ENTITY, YHIKAS_SYNC_ACTOR_ID, YHIKAS_SYNC_PLUGIN_NAME, YhikasApplyPartialError, YhikasLegalDocument, YhikasRoomType, type YhikasSyncConfig, YhikasSyncConfigError, YhikasSyncRefusedError, YhikasSyncRunError, YhikasSyncStaleError, YhikasSyncedContentReadOnlyError, YhikasUpstreamClient, type YhikasUpstreamClientOptions, YhikasUpstreamError, applyPlan, assertNotStale, assessStaleness, createSyncStateStore, legalDocumentRowSchema, legalDocumentsResponseSchema, machineWritten, multilingualTextSchema, planDiff, projectLegalDocuments, projectRoomTypes, readLocalRows, resolveYhikasSyncConfig, roomTypeRowSchema, roomTypesResponseSchema, runYhikasSync, secondaryLocaleOf, stableHash, toSyncEntityClient, truncateError, yhikasSyncStateTable };
|
|
1026
|
+
//# sourceMappingURL=index.d.mts.map
|