@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 ADDED
@@ -0,0 +1,94 @@
1
+ Elastic License 2.0 (ELv2)
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject
14
+ to the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set
20
+ of the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license
39
+ for the software granted under these terms ends immediately. If your company
40
+ makes such a claim, your patent license ends immediately for work on behalf
41
+ of your company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from
46
+ you also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license
61
+ no later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your
64
+ licenses to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is
76
+ the software the licensor makes available under these terms, including any
77
+ portion of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that organization.
84
+ **control** means ownership of substantially all the assets of an entity, or
85
+ the power to direct the management and policies of an entity (for example, by
86
+ voting right, contract, or otherwise). Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your
92
+ licenses.
93
+
94
+ **trademark** means trademarks, service marks, and similar rights.
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # `@murumets-ee/yhikas-sync`
2
+
3
+ A scheduled, one-directional pull of yhikas-admin's **room types** and **legal
4
+ documents** into lumi-cms content, so the yhikas public site reads exactly one
5
+ origin — its own content-api — and never learns that yhikas-admin exists.
6
+
7
+ Part of the `frontend-delivery` plan, phase 05 (D014).
8
+
9
+ ## Why a pull and not a push
10
+
11
+ Three independent facts, each of which would decide it alone:
12
+
13
+ - **There is nothing to push with.** yhikas-admin has no webhook dispatch, no
14
+ outbound content HTTP, no content queue and no DB triggers. Its ~30
15
+ `revalidatePath`/`revalidateTag` calls all target admin pages — in-process
16
+ Next cache invalidation that never crosses a process boundary.
17
+ - **Its own rules discourage building one.** That app's CLAUDE.md rule 5 is
18
+ "NEVER create API routes for internal data operations", with rule 6 carving
19
+ out third-party integrations needing HTTP endpoints. A pull-only reader of
20
+ `/api/public/*` sits in the carve-out; a push dispatcher does not.
21
+ - **Nothing needs pushing.** `git log -S "monthlyRent" -- src/db/schema.ts`
22
+ returns one commit — the initial one, 2025-05-14 — against a repo whose HEAD
23
+ is 2026-08-03. This is a fee schedule revised at most yearly.
24
+
25
+ The diff is **full-snapshot**, and that is forced rather than chosen:
26
+ `room_type` carries no timestamp of any kind, and `legal_document`'s
27
+ `updated_at` is not in the API response. There is no cursor and no change feed.
28
+
29
+ ## Wiring
30
+
31
+ ```ts
32
+ // lumi.config.ts
33
+ import { content } from '@murumets-ee/content/plugin'
34
+ import { queue } from '@murumets-ee/queue/plugin'
35
+ import { yhikasSync } from '@murumets-ee/yhikas-sync/plugin'
36
+
37
+ export default defineLumiConfig({
38
+ plugins: [content({ /* … */ }), queue(), yhikasSync()],
39
+ })
40
+ ```
41
+
42
+ Both `@murumets-ee/content` and `@murumets-ee/queue` are declared in
43
+ `Plugin.requires`, so a misconfigured install fails at boot rather than at the
44
+ first sync — see "Per-locale publish" below for why content in particular is a
45
+ hard requirement rather than a nicety.
46
+
47
+ ### Environment
48
+
49
+ | Variable | Purpose |
50
+ |---|---|
51
+ | `YHIKAS_ADMIN_BASE_URL` | Origin of the yhikas-admin deployment, e.g. the Docker-internal `http://yhikas-admin:3000`. |
52
+ | `YHIKAS_SYNC_API_KEY` | The sync's **own** bearer credential. |
53
+
54
+ The credential is deliberately **not** the site's `PUBLIC_SITE_API_KEY`.
55
+ Upstream's rate limiter buckets on `sha256(key).slice(0,16)` rather than on the
56
+ caller IP — over the Docker-internal path there is no `x-forwarded-for` and
57
+ every caller resolves to `'unknown'` — so a shared key means a shared 60/min
58
+ bucket, and revoking one credential would blind both consumers.
59
+
60
+ The key is never part of the plugin's declared config: resolved config lands in
61
+ the framework's `PluginConfigRegistry`, a process-global any plugin can read,
62
+ and a secret does not belong there. It is read from the environment at job-run
63
+ time.
64
+
65
+ ## What it does, and what it refuses to do
66
+
67
+ ### Identity is the business key
68
+
69
+ `room_type.code` and `legal_document.type` — never the serial `id`, which is an
70
+ implementation detail of the other system and would make this system's content
71
+ depend on the other's insert order.
72
+
73
+ ### Semantics this side declares
74
+
75
+ There is no currency column, no VAT column and no period column upstream;
76
+ EUR-net-per-month is a convention held in column *naming*. So `currency` and
77
+ `vatTreatment` are real columns here, written with constants, which means a
78
+ mis-entered source value renders as a **visibly wrong number against a declared
79
+ unit** rather than as a plausible one. Period stays in the field name
80
+ (`monthlyRent` / `dailyRent`) because one row carries two different periods.
81
+
82
+ Decimals stay **strings** end to end. `pg` returns `numeric` as a string and
83
+ upstream passes it through; parsing to a float would put rounding into a price.
84
+ Nulls likewise pass through untouched — a missing price is data, not a zero.
85
+
86
+ ### Per-locale publish, and why `content` is required
87
+
88
+ One upstream row becomes one entity row with a translation per locale. When the
89
+ English text is empty — both columns are `notNull` upstream but nothing
90
+ validates non-emptiness, so `""` is the expected state of a half-filled row —
91
+ the English locale is set to `draft` and its translation row deleted. No empty
92
+ string is ever written as a translation value, and the English page cannot
93
+ render rather than rendering Estonian text under an English URL.
94
+
95
+ That mechanism is `AdminClient.updateForLocale`, which routes a non-default
96
+ locale's `status` into `<entity>_locale_status`. Two of its preconditions are
97
+ load-bearing, and both fail SILENTLY rather than loudly:
98
+
99
+ - **The locale-status writer must exist.** Without it `updateForLocale` falls
100
+ back to the base row's `status` column, so a request to unpublish English
101
+ would unpublish Estonian. The writer is installed by
102
+ `@murumets-ee/content`'s `init`, for entities that are `publishable()` with
103
+ at least one translatable field — which both entities here satisfy by
104
+ construction. `Plugin.requires` guarantees content is *configured*; the
105
+ *ordering* half is bought by constructing the AdminClients inside the job
106
+ handler rather than in `init`, because `createAdminClient` reads the writer
107
+ once, at construction. Hoisting that into `init` would reintroduce the hazard
108
+ while leaving the declaration green.
109
+ - **The default locale must be resolvable.** `updateForLocale` throws when it
110
+ can find one in neither its options nor the request context — and
111
+ `elevateRequestContext`, which the sync runs under, deliberately strips
112
+ `locale`/`defaultLocale`. So the adapter passes it explicitly on every call,
113
+ and it is read from `resolveI18nConfig(app)` rather than configured: a
114
+ configured copy could disagree with the app's real default, and that
115
+ disagreement writes the wrong language to the base row without failing.
116
+
117
+ ### Deletion is a decision
118
+
119
+ A row that is published locally and absent upstream is **unpublished, never
120
+ deleted**. The row and its audit trail survive, and a wrongly-retired row heals
121
+ automatically on the next successful run. Two guards, both of which abort the
122
+ whole run before a single write:
123
+
124
+ 1. **Nothing acts on an absence from a response that did not fully succeed.**
125
+ Every upstream failure path sets `success: false` (401, 429, 500), and the
126
+ wire schema pins `success: z.literal(true)` — so a refusal can never be
127
+ mistaken for an authoritative empty snapshot.
128
+ 2. **A sanity floor.** An empty snapshot against non-empty local content is
129
+ refused at any size; a run retiring more than half of at least three
130
+ published rows is refused. Both are needed: the fraction rule is blind at the
131
+ two-row size a freshly seeded install lives at, and the empty-snapshot rule
132
+ is blind to the half-truncated response a partial failure actually produces.
133
+
134
+ An **oversized** snapshot is also refused rather than truncated — capping at N
135
+ and processing the first N would turn the dropped tail into apparent absences,
136
+ and therefore into mass retirement.
137
+
138
+ ### Synced content is not editor-writable
139
+
140
+ An editable field the next run silently overwrites is a worse affordance than a
141
+ read-only one, because it looks like it worked. A `machineWritten` behavior
142
+ refuses every create/update/delete on the BASE row whose actor is not
143
+ `yhikas-sync`, including an administrator in the generic CRUD UI. (`access`
144
+ metadata and `admin.hidden` cannot do this — neither is read by the firewall.)
145
+
146
+ **The boundary, stated because a guard documented as total but actually partial
147
+ is worse than one whose limit is written down:** `saveTranslation` and
148
+ `deleteTranslation` run no behaviour hooks, so an edit made through the admin
149
+ *locale switcher* is not refused — and, because it does not change
150
+ `sourceHash`, the sync will not revert it either. Closing that needs a hook
151
+ seam on the translation write path, which is a framework change; tracked as
152
+ finding 12 on the plan.
153
+
154
+ ## Observability
155
+
156
+ `yhikas_sync_state` holds one row per resource: when it last attempted, when it
157
+ last **succeeded**, the last error, and the counts from the last successful run.
158
+
159
+ A second scheduled job — the watchdog — alerts when the newest success is older
160
+ than the window, including the case where a sync has never succeeded at all.
161
+ This is new code because nothing shipped can detect it: the queue's heartbeat is
162
+ keyed on `workerId` and answers "is any worker alive", and its alerter fires
163
+ only from `failJob`'s dead-letter branch, i.e. only on a job that ran and
164
+ *threw*. A job that never starts produces no error at all. The watchdog throws,
165
+ and the throw reaches the shipped dedupe/digest/email path — so the detection is
166
+ new, the delivery is not.
167
+
168
+ Known limit, stated rather than papered over: the watchdog is itself a scheduled
169
+ job, so a worker that is entirely dead runs neither it nor the sync. That case
170
+ is what the queue's worker heartbeat *does* see. The two are complementary.
171
+
172
+ ## Defaults
173
+
174
+ | Setting | Default | Why |
175
+ |---|---|---|
176
+ | `schedule` | `@every 6h` | Four successes a day against an annually-revised fee schedule. The interval form rather than cron: the queue's cron parser is UTC-only with no DST awareness, so a wall-clock schedule would shift twice a year. |
177
+ | `watchdogSchedule` | `@every 1h` | |
178
+ | `staleAfterMs` | 24h | Four consecutive failures before anyone is woken. |
179
+ | `requestTimeoutMs` | 15s | The queue has **no** per-job timeout; without a deadline a hung fetch holds a slot until lease recovery re-claims it and runs the handler twice, concurrently. |
180
+ | `maxRowsPerResource` | 500 | ~25 room types today. |
181
+ | `maxRetireFraction` | 0.5 | |
182
+
183
+ ## Scope
184
+
185
+ **In:** room types and legal documents.
186
+
187
+ **Out:** `site_info` / `site_notice` — they have no write path anywhere in
188
+ yhikas-admin (no admin UI, no server action, no seed), so a sync would pull the
189
+ route's designed-empty fallback. Recorded as Q1, not dropped. Deposits, which
190
+ ship upstream as hardcoded `null` with no backing column. And any write path
191
+ back into yhikas-admin: the client exposes two methods and both are GETs, so
192
+ one-directionality is a property of the type rather than a rule to remember.
@@ -0,0 +1,77 @@
1
+ //#region src/config.d.ts
2
+ /**
3
+ * Plugin configuration — pure resolution, no I/O, no `server-only`.
4
+ *
5
+ * There is deliberately no `defaultLocale` knob either. The default locale is
6
+ * read from `@murumets-ee/content`'s `resolveI18nConfig(app)` at job-run time,
7
+ * because a configured copy could silently disagree with the app's actual
8
+ * default — and the consequences run both ways: `updateForLocale` would take
9
+ * its default-locale branch for what this package believed was the secondary
10
+ * locale and overwrite the base row with the wrong language, while no
11
+ * translation was written for the real one. Neither write fails. A knob whose
12
+ * only reachable states are "correct but redundant" and "wrong and silent" is a
13
+ * trap, not a setting.
14
+ *
15
+ * The bearer credential is deliberately NOT part of this object. Resolved
16
+ * config is stored in the framework's `PluginConfigRegistry`, which is a
17
+ * process-global any plugin can read; a secret belongs in the environment and
18
+ * is read at job-run time instead (`resolveCredential`). This mirrors
19
+ * `@murumets-ee/merit` D003 — the key never lands in a registry or the DB.
20
+ */
21
+ interface YhikasSyncConfig {
22
+ /**
23
+ * Origin of the yhikas-admin deployment, e.g. `http://yhikas-admin:3000`
24
+ * over the Docker-internal network. Falls back to `YHIKAS_ADMIN_BASE_URL`.
25
+ */
26
+ baseUrl?: string;
27
+ /** Cron/interval expression for the pull. Default `@every 6h` (D017). */
28
+ schedule?: string;
29
+ /** Cron/interval expression for the staleness watchdog. Default `@every 1h`. */
30
+ watchdogSchedule?: string;
31
+ /** Alert when the newest successful sync is older than this. Default 24h. */
32
+ staleAfterMs?: number;
33
+ /** Per-request deadline. The queue has NO per-job timeout (R012 §3). Default 15s. */
34
+ requestTimeoutMs?: number;
35
+ /** Refuse a snapshot larger than this rather than truncating it (D016). */
36
+ maxRowsPerResource?: number;
37
+ /** Refuse a run retiring more than this fraction of published rows. Default 0.5. */
38
+ maxRetireFraction?: number;
39
+ /** The fraction rule only applies once at least this many rows are published. Default 3. */
40
+ minRowsForRetireFloor?: number;
41
+ /**
42
+ * Explicit opt-out. An unconfigured sync is a BROKEN sync, and a broken sync
43
+ * must be visibly broken (S7) — so an absent credential fails the job loudly
44
+ * rather than quietly skipping registration. Set this to `false` when the
45
+ * install genuinely has no yhikas-admin to talk to.
46
+ */
47
+ enabled?: boolean;
48
+ }
49
+ interface ResolvedYhikasSyncConfig {
50
+ readonly baseUrl: string | undefined;
51
+ readonly schedule: string;
52
+ readonly watchdogSchedule: string;
53
+ readonly staleAfterMs: number;
54
+ readonly requestTimeoutMs: number;
55
+ readonly maxRowsPerResource: number;
56
+ readonly maxRetireFraction: number;
57
+ readonly minRowsForRetireFloor: number;
58
+ readonly enabled: boolean;
59
+ }
60
+ /** Thrown at plugin construction for a structurally invalid config. */
61
+ declare class YhikasSyncConfigError extends Error {
62
+ constructor(message: string);
63
+ }
64
+ /**
65
+ * Resolve config + environment into the frozen object the plugin declares.
66
+ *
67
+ * Throws only on a STRUCTURALLY invalid config — a nonsensical number. It never
68
+ * throws for a missing credential or a missing base URL: `lumi.config.ts` is
69
+ * loaded at build time by the CLI, and throwing there breaks every `lumi`
70
+ * command on a machine that simply has no secrets (see
71
+ * `gotcha_lumi_config_throw_at_build_time`). A missing credential surfaces at
72
+ * job-run time, where it becomes an alert.
73
+ */
74
+ declare function resolveYhikasSyncConfig(config?: YhikasSyncConfig, env?: NodeJS.ProcessEnv): ResolvedYhikasSyncConfig;
75
+ //#endregion
76
+ export { resolveYhikasSyncConfig as i, YhikasSyncConfig as n, YhikasSyncConfigError as r, ResolvedYhikasSyncConfig as t };
77
+ //# sourceMappingURL=config-CHVLiXZf.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-CHVLiXZf.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AA+BA;;;;;;;;;;;;;;AA0BS;AAGT;;;UA7BiB,gBAAA;EA8BN;;;;EAzBT,OAAA;EA8BS;EA5BT,QAAA;EA8BS;EA5BT,gBAAA;EA6BgB;EA3BhB,YAAA;EA+BW;EA7BX,gBAAA;;EAEA,kBAAA;EA2ByC;EAzBzC,iBAAA;EA0BY;EAxBZ,qBAAA;EAwB2B;AAoC7B;;;;;EArDE,OAAA;AAAA;AAAA,UAGe,wBAAA;EAAA,SACN,OAAA;EAAA,SACA,QAAA;EAAA,SACA,gBAAA;EAAA,SACA,YAAA;EAAA,SACA,gBAAA;EAAA,SACA,kBAAA;EAAA,SACA,iBAAA;EAAA,SACA,qBAAA;EAAA,SACA,OAAA;AAAA;;cAIE,qBAAA,SAA8B,KAAK;cAClC,OAAA;AAAA;;;;;;;;;;;iBAoCE,uBAAA,CACd,MAAA,GAAQ,gBAAA,EACR,GAAA,GAAK,MAAA,CAAO,UAAA,GACX,wBAAA"}