@things-factory/headless-twin 10.0.18 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/canonical-ingest.d.ts +10 -1
- package/dist-server/engine/canonical-ingest.js +110 -33
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/event-time.d.ts +25 -0
- package/dist-server/engine/event-time.js +98 -0
- package/dist-server/engine/event-time.js.map +1 -0
- package/dist-server/engine/index.d.ts +2 -0
- package/dist-server/engine/index.js +23 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/ingest-dedupe.d.ts +18 -6
- package/dist-server/engine/ingest-dedupe.js +201 -15
- package/dist-server/engine/ingest-dedupe.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +60 -1
- package/dist-server/engine/ingest-health.js +43 -8
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +19 -0
- package/dist-server/engine/kpi-fold.js +4 -1
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +23 -3
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +35 -2
- package/dist-server/engine/oee-accumulator.js +56 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -3
- package/dist-server/engine/twin-engine.js +118 -5
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +21 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.d.ts +5 -0
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js +66 -0
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +1 -0
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.d.ts +5 -0
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js +79 -0
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +1 -0
- package/dist-server/migrations/index.js +5 -1
- package/dist-server/migrations/index.js.map +1 -1
- package/dist-server/routes.js +19 -16
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/actuation/actuation-advisor.d.ts +60 -0
- package/dist-server/service/actuation/actuation-advisor.js +53 -0
- package/dist-server/service/actuation/actuation-advisor.js.map +1 -0
- package/dist-server/service/actuation/actuation-outcome.d.ts +52 -0
- package/dist-server/service/actuation/actuation-outcome.js +34 -0
- package/dist-server/service/actuation/actuation-outcome.js.map +1 -0
- package/dist-server/service/actuation/actuation-rule-resolver.d.ts +19 -0
- package/dist-server/service/actuation/actuation-rule-resolver.js +161 -0
- package/dist-server/service/actuation/actuation-rule-resolver.js.map +1 -0
- package/dist-server/service/actuation/actuation-rule.d.ts +55 -0
- package/dist-server/service/actuation/actuation-rule.js +130 -0
- package/dist-server/service/actuation/actuation-rule.js.map +1 -0
- package/dist-server/service/actuation/approval-gateway.d.ts +20 -0
- package/dist-server/service/actuation/approval-gateway.js +14 -0
- package/dist-server/service/actuation/approval-gateway.js.map +1 -0
- package/dist-server/service/actuation/catch-up-effects.d.ts +16 -0
- package/dist-server/service/actuation/catch-up-effects.js +120 -0
- package/dist-server/service/actuation/catch-up-effects.js.map +1 -0
- package/dist-server/service/actuation/command-dispatcher.d.ts +230 -0
- package/dist-server/service/actuation/command-dispatcher.js +269 -0
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
- package/dist-server/service/actuation/command-store.d.ts +11 -0
- package/dist-server/service/actuation/command-store.js +219 -0
- package/dist-server/service/actuation/command-store.js.map +1 -0
- package/dist-server/service/actuation/dispatch-after-approval.d.ts +11 -0
- package/dist-server/service/actuation/dispatch-after-approval.js +73 -0
- package/dist-server/service/actuation/dispatch-after-approval.js.map +1 -0
- package/dist-server/service/actuation/effect-match.d.ts +53 -0
- package/dist-server/service/actuation/effect-match.js +65 -0
- package/dist-server/service/actuation/effect-match.js.map +1 -0
- package/dist-server/service/actuation/index.d.ts +25 -0
- package/dist-server/service/actuation/index.js +30 -0
- package/dist-server/service/actuation/index.js.map +1 -0
- package/dist-server/service/actuation/retry-plan.d.ts +63 -0
- package/dist-server/service/actuation/retry-plan.js +99 -0
- package/dist-server/service/actuation/retry-plan.js.map +1 -0
- package/dist-server/service/actuation/rule-evaluate.d.ts +127 -0
- package/dist-server/service/actuation/rule-evaluate.js +205 -0
- package/dist-server/service/actuation/rule-evaluate.js.map +1 -0
- package/dist-server/service/actuation/rule-loop.d.ts +5 -0
- package/dist-server/service/actuation/rule-loop.js +156 -0
- package/dist-server/service/actuation/rule-loop.js.map +1 -0
- package/dist-server/service/actuation/rule-runner.d.ts +52 -0
- package/dist-server/service/actuation/rule-runner.js +135 -0
- package/dist-server/service/actuation/rule-runner.js.map +1 -0
- package/dist-server/service/actuation/rule-wiring.d.ts +24 -0
- package/dist-server/service/actuation/rule-wiring.js +80 -0
- package/dist-server/service/actuation/rule-wiring.js.map +1 -0
- package/dist-server/service/actuation/settle-effects.d.ts +21 -0
- package/dist-server/service/actuation/settle-effects.js +101 -0
- package/dist-server/service/actuation/settle-effects.js.map +1 -0
- package/dist-server/service/actuation/sweep-dispatch.d.ts +37 -0
- package/dist-server/service/actuation/sweep-dispatch.js +125 -0
- package/dist-server/service/actuation/sweep-dispatch.js.map +1 -0
- package/dist-server/service/actuation/twin-command-resolver.d.ts +120 -0
- package/dist-server/service/actuation/twin-command-resolver.js +406 -0
- package/dist-server/service/actuation/twin-command-resolver.js.map +1 -0
- package/dist-server/service/actuation/twin-command.d.ts +111 -0
- package/dist-server/service/actuation/twin-command.js +170 -0
- package/dist-server/service/actuation/twin-command.js.map +1 -0
- package/dist-server/service/actuation/twin-now.d.ts +41 -0
- package/dist-server/service/actuation/twin-now.js +65 -0
- package/dist-server/service/actuation/twin-now.js.map +1 -0
- package/dist-server/service/index.d.ts +5 -2
- package/dist-server/service/index.js +28 -17
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/actuation-routing.d.ts +60 -0
- package/dist-server/service/reference/actuation-routing.js +126 -0
- package/dist-server/service/reference/actuation-routing.js.map +1 -0
- package/dist-server/service/reference/actuation-target.d.ts +65 -0
- package/dist-server/service/reference/actuation-target.js +94 -0
- package/dist-server/service/reference/actuation-target.js.map +1 -0
- package/dist-server/service/reference/connection-portability-resolver.d.ts +29 -0
- package/dist-server/service/reference/connection-portability-resolver.js +181 -0
- package/dist-server/service/reference/connection-portability-resolver.js.map +1 -0
- package/dist-server/service/reference/connection-portability.d.ts +138 -0
- package/dist-server/service/reference/connection-portability.js +230 -0
- package/dist-server/service/reference/connection-portability.js.map +1 -0
- package/dist-server/service/reference/fill-cadence.d.ts +17 -0
- package/dist-server/service/reference/fill-cadence.js +31 -0
- package/dist-server/service/reference/fill-cadence.js.map +1 -0
- package/dist-server/service/reference/fill-dedupe.d.ts +16 -0
- package/dist-server/service/reference/fill-dedupe.js +63 -0
- package/dist-server/service/reference/fill-dedupe.js.map +1 -0
- package/dist-server/service/reference/fill-loop.d.ts +35 -0
- package/dist-server/service/reference/fill-loop.js +187 -0
- package/dist-server/service/reference/fill-loop.js.map +1 -0
- package/dist-server/service/reference/fill-span.d.ts +10 -0
- package/dist-server/service/reference/fill-span.js +37 -0
- package/dist-server/service/reference/fill-span.js.map +1 -0
- package/dist-server/service/reference/hook-contract.d.ts +149 -22
- package/dist-server/service/reference/hook-contract.js +236 -23
- package/dist-server/service/reference/hook-contract.js.map +1 -1
- package/dist-server/service/reference/hook-rejected.d.ts +25 -0
- package/dist-server/service/reference/hook-rejected.js +48 -0
- package/dist-server/service/reference/hook-rejected.js.map +1 -0
- package/dist-server/service/reference/hook-store.d.ts +3 -0
- package/dist-server/service/reference/hook-store.js +28 -0
- package/dist-server/service/reference/hook-store.js.map +1 -0
- package/dist-server/service/reference/index.d.ts +4 -1
- package/dist-server/service/reference/index.js +5 -1
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/live-cadence.d.ts +68 -0
- package/dist-server/service/reference/live-cadence.js +44 -0
- package/dist-server/service/reference/live-cadence.js.map +1 -0
- package/dist-server/service/reference/live-feed-lease.d.ts +15 -0
- package/dist-server/service/reference/live-feed-lease.js +56 -0
- package/dist-server/service/reference/live-feed-lease.js.map +1 -0
- package/dist-server/service/reference/live-ingest.d.ts +21 -0
- package/dist-server/service/reference/live-ingest.js +66 -0
- package/dist-server/service/reference/live-ingest.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +355 -2
- package/dist-server/service/reference/reference-adapter.js +19 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-fill-run.d.ts +50 -0
- package/dist-server/service/reference/reference-fill-run.js +142 -0
- package/dist-server/service/reference/reference-fill-run.js.map +1 -0
- package/dist-server/service/reference/reference-fill.d.ts +90 -0
- package/dist-server/service/reference/reference-fill.js +141 -0
- package/dist-server/service/reference/reference-fill.js.map +1 -0
- package/dist-server/service/reference/reference-hook.d.ts +64 -6
- package/dist-server/service/reference/reference-hook.js +265 -27
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/dist-server/service/reference/reference-link-groups.d.ts +10 -0
- package/dist-server/service/reference/reference-link-groups.js +15 -0
- package/dist-server/service/reference/reference-link-groups.js.map +1 -0
- package/dist-server/service/reference/reference-links.d.ts +14 -0
- package/dist-server/service/reference/reference-links.js +73 -0
- package/dist-server/service/reference/reference-links.js.map +1 -0
- package/dist-server/service/reference/reference-live.d.ts +1 -0
- package/dist-server/service/reference/reference-live.js +32 -6
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +56 -2
- package/dist-server/service/reference/reference-master.js +33 -3
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +27 -0
- package/dist-server/service/reference/reference-resolver.js +79 -1
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +31 -3
- package/dist-server/service/twin-event/twin-event-keys.js +36 -4
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +1 -0
- package/dist-server/service/twin-event/twin-event.js +11 -2
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-model/choose-recipe.d.ts +15 -0
- package/dist-server/service/twin-model/choose-recipe.js +33 -0
- package/dist-server/service/twin-model/choose-recipe.js.map +1 -0
- package/dist-server/service/twin-model/external-resolver.d.ts +76 -0
- package/dist-server/service/twin-model/external-resolver.js +109 -0
- package/dist-server/service/twin-model/external-resolver.js.map +1 -0
- package/dist-server/service/twin-model/project-structure.js +16 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +17 -0
- package/dist-server/service/twin-model/twin-equipment.js +10 -0
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-lineage-query.js +75 -1
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +94 -5
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +29 -0
- package/dist-server/service/twin-space/twin-space.js +13 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-subject/event-subjects.d.ts +3 -1
- package/dist-server/service/twin-subject/event-subjects.js +8 -1
- package/dist-server/service/twin-subject/event-subjects.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -7
- package/server/engine/canonical-ingest.ts +119 -33
- package/server/engine/event-time.ts +96 -0
- package/server/engine/index.ts +20 -0
- package/server/engine/ingest-dedupe.ts +203 -27
- package/server/engine/ingest-health.ts +91 -6
- package/server/engine/kpi-fold.ts +24 -1
- package/server/engine/kpi-query.ts +23 -4
- package/server/engine/oee-accumulator.ts +80 -8
- package/server/engine/twin-engine.ts +119 -5
- package/server/index.ts +23 -0
- package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +66 -0
- package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +82 -0
- package/server/migrations/index.ts +5 -1
- package/server/routes.ts +19 -20
- package/server/service/actuation/actuation-advisor.ts +124 -0
- package/server/service/actuation/actuation-outcome.ts +97 -0
- package/server/service/actuation/actuation-rule-resolver.ts +147 -0
- package/server/service/actuation/actuation-rule.ts +139 -0
- package/server/service/actuation/approval-gateway.ts +44 -0
- package/server/service/actuation/catch-up-effects.ts +152 -0
- package/server/service/actuation/command-dispatcher.ts +511 -0
- package/server/service/actuation/command-store.ts +236 -0
- package/server/service/actuation/dispatch-after-approval.ts +71 -0
- package/server/service/actuation/effect-match.ts +124 -0
- package/server/service/actuation/index.ts +27 -0
- package/server/service/actuation/retry-plan.ts +145 -0
- package/server/service/actuation/rule-evaluate.ts +352 -0
- package/server/service/actuation/rule-loop.ts +160 -0
- package/server/service/actuation/rule-runner.ts +216 -0
- package/server/service/actuation/rule-wiring.ts +84 -0
- package/server/service/actuation/settle-effects.ts +115 -0
- package/server/service/actuation/sweep-dispatch.ts +179 -0
- package/server/service/actuation/twin-command-resolver.ts +387 -0
- package/server/service/actuation/twin-command.ts +232 -0
- package/server/service/actuation/twin-now.ts +97 -0
- package/server/service/index.ts +11 -0
- package/server/service/reference/actuation-routing.ts +165 -0
- package/server/service/reference/actuation-target.ts +163 -0
- package/server/service/reference/connection-portability-resolver.ts +171 -0
- package/server/service/reference/connection-portability.ts +285 -0
- package/server/service/reference/fill-cadence.ts +29 -0
- package/server/service/reference/fill-dedupe.ts +66 -0
- package/server/service/reference/fill-loop.ts +153 -0
- package/server/service/reference/fill-span.ts +31 -0
- package/server/service/reference/hook-contract.ts +280 -22
- package/server/service/reference/hook-rejected.ts +53 -0
- package/server/service/reference/hook-store.ts +28 -0
- package/server/service/reference/index.ts +5 -1
- package/server/service/reference/live-cadence.ts +129 -0
- package/server/service/reference/live-feed-lease.ts +56 -0
- package/server/service/reference/live-ingest.ts +94 -0
- package/server/service/reference/reference-adapter.ts +392 -5
- package/server/service/reference/reference-fill-run.ts +165 -0
- package/server/service/reference/reference-fill.ts +223 -0
- package/server/service/reference/reference-hook.ts +355 -32
- package/server/service/reference/reference-link-groups.ts +21 -0
- package/server/service/reference/reference-links.ts +77 -0
- package/server/service/reference/reference-live.ts +30 -5
- package/server/service/reference/reference-master.ts +90 -5
- package/server/service/reference/reference-resolver.ts +77 -2
- package/server/service/twin-event/twin-event-keys.ts +37 -4
- package/server/service/twin-event/twin-event.ts +18 -1
- package/server/service/twin-model/choose-recipe.ts +31 -0
- package/server/service/twin-model/external-resolver.ts +161 -0
- package/server/service/twin-model/project-structure.ts +16 -3
- package/server/service/twin-model/twin-equipment.ts +22 -0
- package/server/service/twin-model/twin-lineage-query.ts +82 -1
- package/server/service/twin-model/twin-model-item-query.ts +95 -4
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space.ts +39 -0
- package/server/service/twin-subject/event-subjects.ts +8 -1
- package/shared/entity-delta.ts +1 -1
- package/test/ack-shape-alignment.test.ts +97 -0
- package/test/actuation-approval-door.test.ts +421 -0
- package/test/actuation-dispatch.test.ts +209 -0
- package/test/actuation-effect.test.ts +162 -0
- package/test/actuation-outcome.test.ts +152 -0
- package/test/actuation-rule-runner.test.ts +281 -0
- package/test/actuation-rule.test.ts +266 -0
- package/test/actuation-seam.test.ts +249 -0
- package/test/actuation-settle.test.ts +164 -0
- package/test/actuation-target.test.ts +145 -0
- package/test/canonical-ingest-vocabularies.test.ts +49 -2
- package/test/capability-mapping.test.ts +1 -1
- package/test/choose-recipe.test.ts +117 -0
- package/test/command-store-writes-columns.test.ts +126 -0
- package/test/connection-portability-doors.test.ts +87 -0
- package/test/connector-capability-declaration.test.ts +54 -4
- package/test/declaration-reaches-model.test.ts +43 -0
- package/test/dedupe-transformation.test.ts +112 -0
- package/test/entity-delta.test.ts +1 -1
- package/test/event-subjects.test.ts +2 -1
- package/test/external-resolver.test.ts +171 -0
- package/test/fill-dedupe.test.ts +59 -0
- package/test/fill-loop.test.ts +35 -0
- package/test/first-envelope.test.ts +153 -0
- package/test/fold-shift-dst.test.ts +110 -0
- package/test/home-in-area.test.ts +90 -0
- package/test/hook-rejected-shape.test.ts +86 -0
- package/test/hook-response-sum.test.ts +193 -0
- package/test/hook-sequence.test.ts +333 -0
- package/test/ingest-dedupe.test.ts +56 -0
- package/test/ingest-expected-quiet.test.ts +111 -0
- package/test/ingest-idempotent.test.ts +79 -0
- package/test/ingest-reconcile-four-ways.test.ts +97 -0
- package/test/ingest-rules-header.test.ts +100 -0
- package/test/ingest-window-rolls-unobserved.test.ts +72 -0
- package/test/intake-mapping-parity.test.ts +175 -0
- package/test/live-cadence.test.ts +170 -0
- package/test/live-feed-lease.test.ts +63 -0
- package/test/live-mirror-parity.test.ts +1 -1
- package/test/oee-accumulator.test.ts +3 -3
- package/test/oee-site-calendar.test.ts +146 -0
- package/test/order-and-transaction-split.test.ts +107 -0
- package/test/reference-fill.test.ts +235 -0
- package/test/reference-hook.test.ts +114 -23
- package/test/reference-links.test.ts +56 -0
- package/test/retry-plan.test.ts +139 -0
- package/test/sweep-dispatch.test.ts +191 -0
- package/test/twin-event-keys.test.ts +20 -6
- package/test/twin-model-item-db.test.ts +6 -6
- package/test/twin-model-tree-db.test.ts +6 -6
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **연결을 내보내고 다시 들여옵니다** — 실제 셋업을 파일로 보관하고 되세웁니다.
|
|
3
|
+
*
|
|
4
|
+
* ── 이것은 데모가 아니라 제품 기능입니다 (2026-09-05) ───────────────────────
|
|
5
|
+
* 데모 시드(`seed-demo-twins.ts`)와 **다른 것**입니다. 그쪽은 내장 가상 마스터로 보여 줄 트윈을
|
|
6
|
+
* 만들고 `TWIN_PROVISION_DEMO` 뒤에 숨습니다. 이것은 **실제로 붙인 연결을 옮기는 길**이라
|
|
7
|
+
* 데모 스위치 뒤에 두지 않습니다 — 백업·이관·복구가 데모일 수 없습니다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 특정 공장을 제품 코드에 적지 않습니다. 적으면 배포마다 남의 공장이 하나씩 따라가고,
|
|
10
|
+
* 데모 시드 머리말이 적어 둔 「프로덕션 배포는 **트윈 0** 에서 시작한다」가 깨집니다. 무엇을 세울지는
|
|
11
|
+
* **파일이 말하고 코드는 그 파일을 읽을 줄만 압니다.**
|
|
12
|
+
*
|
|
13
|
+
* ── 왜 필요했나 ────────────────────────────────────────────────────────────
|
|
14
|
+
* 실 MES 두 공장을 트윈에 세웠는데(`mes-line-a` · `mes-miratek`) **그 셋업이 한 데이터베이스
|
|
15
|
+
* 안에만 있었습니다.** 잃으면 다시 손으로 만들어야 하고, 다른 배포에 옮길 길도 없었습니다.
|
|
16
|
+
*
|
|
17
|
+
* ── 담는 것과 안 담는 것 ────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* 담는다 연결 선언 — 주소 · 도메인 · 사이트 · 치수 · 이름공간 · 받는 자리
|
|
20
|
+
* 안 담는다 비밀값 — 토큰 · 웹훅 서명값 · 받는 쪽 비밀값
|
|
21
|
+
* 안 담는다 사실(저널) — 원본이 다시 보내면 된다. 담으면 **가짜 과거가 배포마다 복제된다**
|
|
22
|
+
* 안 담는다 마스터에서 온 트윈 모델 — 연결이 있으면 재동기화가 다시 만든다
|
|
23
|
+
*
|
|
24
|
+
* **비밀값은 이름만 적습니다.** 어느 칸이 채워져 있었는지는 알려 주되 값은 안 나갑니다 — 파일은
|
|
25
|
+
* 주고받는 물건이고, 한 번 나간 비밀값은 되돌릴 수 없습니다. 들여올 때 「이 셋이 없다」고 말하고
|
|
26
|
+
* 사람이 연결 화면에서 채웁니다.
|
|
27
|
+
*
|
|
28
|
+
* ── 왜 사실을 안 담나 ───────────────────────────────────────────────────────
|
|
29
|
+
* 저널은 되풀이 읽히고 미러로 복제되고 구독으로 방송됩니다. 내보낸 사실을 다른 배포에 들여오면
|
|
30
|
+
* **일어나지 않은 일이 그 공장의 지난 기록에 섭니다.** 커서만 옮겨 두면 원본이 그 뒤부터 다시
|
|
31
|
+
* 보내 주므로, 사실은 원본에서 오는 것이 맞습니다.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** 값이 아니라 **채워져 있었다는 사실**만 나가는 칸들. */
|
|
35
|
+
export const SECRET_KEYS = ['token', 'hookSecret', 'intakeSecret', 'password', 'apiKey'] as const
|
|
36
|
+
|
|
37
|
+
export interface PortableConnection {
|
|
38
|
+
source: string
|
|
39
|
+
system?: string
|
|
40
|
+
siteName?: string
|
|
41
|
+
adapterType: string
|
|
42
|
+
/** 비밀값을 뺀 연결 설정. */
|
|
43
|
+
config: Record<string, unknown>
|
|
44
|
+
/** 내보낼 때 채워져 있던 비밀 칸 이름 — 값은 없습니다. */
|
|
45
|
+
secretsRequired: string[]
|
|
46
|
+
/** 이 연결이 무엇을 세우나. */
|
|
47
|
+
scope?: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConnectionBundle {
|
|
51
|
+
kind: 'operato-twin/connections'
|
|
52
|
+
version: 1
|
|
53
|
+
exportedAt: string
|
|
54
|
+
/** 어디서 내보냈나 — 사람이 나중에 「이게 어느 배포 것인가」를 물을 때 답합니다. */
|
|
55
|
+
exportedFrom?: { domain?: string; note?: string }
|
|
56
|
+
connections: PortableConnection[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 무엇을 세우나 **선언만** 남깁니다 — 지난 동기화의 **측정값은 뺍니다.**
|
|
61
|
+
*
|
|
62
|
+
* `scopeSpec` 에는 `produced`(선언) 옆에 `assessments`(그때 몇 개가 섰나) · `lastFailures` 가 함께
|
|
63
|
+
* 삽니다. 그것까지 내보내면 새 배포가 **다른 기계에서 잰 수**를 자기 것으로 보입니다 — 「설비 42」가
|
|
64
|
+
* 아무것도 안 세운 배포의 화면에 서고, 그 수가 어디서 왔는지 아무도 모릅니다.
|
|
65
|
+
*
|
|
66
|
+
* 재는 것은 그 배포에서 다시 재야 합니다. 오늘 「설정을 고쳤다」와 「모델이 다시 섰다」를 가른 것과
|
|
67
|
+
* 같은 구별입니다.
|
|
68
|
+
*/
|
|
69
|
+
function producedOf(scopeSpec: any): { produced: unknown } | undefined {
|
|
70
|
+
const produced = scopeSpec?.produced
|
|
71
|
+
return Array.isArray(produced) && produced.length ? { produced } : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isSecret = (key: string): boolean =>
|
|
75
|
+
(SECRET_KEYS as readonly string[]).includes(key) || /secret|token|password|apikey/i.test(key)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 연결 행들을 옮길 수 있는 모양으로 — **비밀값을 빼고**.
|
|
79
|
+
*
|
|
80
|
+
* 빼는 판정을 이름으로 합니다. 목록으로만 하면 커넥터가 새 비밀 칸을 더할 때 그 값이 조용히
|
|
81
|
+
* 파일로 나갑니다 — 이름 규칙을 함께 두어 **새 칸이 기본으로 안 나가게** 합니다.
|
|
82
|
+
*/
|
|
83
|
+
export function exportConnections(
|
|
84
|
+
rows: readonly { source: string; system?: string; siteName?: string; adapterType: string; connectionConfig?: any; scopeSpec?: any }[],
|
|
85
|
+
meta: { at: string; domain?: string; note?: string }
|
|
86
|
+
): ConnectionBundle {
|
|
87
|
+
return {
|
|
88
|
+
kind: 'operato-twin/connections',
|
|
89
|
+
version: 1,
|
|
90
|
+
exportedAt: meta.at,
|
|
91
|
+
...(meta.domain || meta.note ? { exportedFrom: { ...(meta.domain ? { domain: meta.domain } : {}), ...(meta.note ? { note: meta.note } : {}) } } : {}),
|
|
92
|
+
connections: rows.map(r => {
|
|
93
|
+
const cfg = (r.connectionConfig ?? {}) as Record<string, unknown>
|
|
94
|
+
const kept: Record<string, unknown> = {}
|
|
95
|
+
const secrets: string[] = []
|
|
96
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
97
|
+
if (isSecret(k)) {
|
|
98
|
+
/* 비어 있던 칸은 적지 않습니다 — 「채워야 한다」와 「이 커넥터에 그런 칸이 있다」는 다릅니다. */
|
|
99
|
+
if (String(v ?? '').trim()) secrets.push(k)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
kept[k] = v
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
source: r.source,
|
|
106
|
+
...(r.system ? { system: r.system } : {}),
|
|
107
|
+
...(r.siteName ? { siteName: r.siteName } : {}),
|
|
108
|
+
adapterType: r.adapterType,
|
|
109
|
+
config: kept,
|
|
110
|
+
secretsRequired: secrets.sort(),
|
|
111
|
+
...(producedOf(r.scopeSpec) ? { scope: producedOf(r.scopeSpec) } : {})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type ImportAction =
|
|
118
|
+
| { action: 'create'; connection: PortableConnection; missingSecrets: string[] }
|
|
119
|
+
| { action: 'update'; connection: PortableConnection; missingSecrets: string[]; keptSecrets: string[] }
|
|
120
|
+
| { action: 'skip'; source: string; why: string }
|
|
121
|
+
|
|
122
|
+
export interface ImportPlan {
|
|
123
|
+
actions: ImportAction[]
|
|
124
|
+
/** 사람이 연결 화면에서 채워야 하는 것 — 이것이 비어야 「시드만으로 끝」입니다. */
|
|
125
|
+
/**
|
|
126
|
+
* 사람이 환경에 넣어야 하는 비밀값.
|
|
127
|
+
*
|
|
128
|
+
* `env` 를 함께 냅니다 — **그 이름을 사람이 손으로 적기 때문**입니다. 화면이 이름을 따로 만들면
|
|
129
|
+
* 규칙이 두 곳에 살고, 갈리는 날 「넣었는데 안 읽힌다」가 됩니다. 실제로 났습니다(2026-09-06):
|
|
130
|
+
* 화면이 `TWIN_SECRET__MES-MIRATEK__HOOKSECRET` 을 알려 줬는데 서버가 읽는 이름은
|
|
131
|
+
* `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET` 이었습니다. 하이픈과 낙타 경계 둘 다 달랐습니다.
|
|
132
|
+
*
|
|
133
|
+
* `keys` 와 순서가 같습니다 — `keys[i]` 의 이름이 `env[i]` 입니다.
|
|
134
|
+
*/
|
|
135
|
+
needsHuman: { source: string; keys: string[]; env: string[] }[]
|
|
136
|
+
/** 들여온 **뒤에** 두 연결이 같은 곳에 조치를 내게 되나 — 있으면 들여오면 안 됩니다. */
|
|
137
|
+
collisions: { intake: string; sources: string[] }[]
|
|
138
|
+
errors: string[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 들여오기 **계획**을 먼저 냅니다 — 쓰기 전에 무엇이 일어날지 사람이 봅니다.
|
|
143
|
+
*
|
|
144
|
+
* ── 이미 있는 연결을 덮지 않습니다 ──────────────────────────────────────────
|
|
145
|
+
* 같은 이름이 이미 있으면 **선언만 갱신하고 비밀값은 그대로 둡니다.** 사람이 화면에서 넣은 값을
|
|
146
|
+
* 파일이 지우면, 파일 하나 들여왔다가 도는 연결이 멈춥니다. 데모 시드도 같은 규율을 적어 두었습니다
|
|
147
|
+
* (「옆 설정을 지우지 않는다 — 시드는 자기가 아는 것만 채운다」).
|
|
148
|
+
*
|
|
149
|
+
* ── 없는 비밀값을 지어내지 않습니다 ─────────────────────────────────────────
|
|
150
|
+
* 빈 값으로 채워 두면 연결이 서고 **동기화할 때 처음 실패합니다.** 그때는 무엇이 없어서인지 화면에서
|
|
151
|
+
* 멉니다. 여기서 이름을 대고 멈추는 것이 낫습니다.
|
|
152
|
+
*/
|
|
153
|
+
export function planImport(
|
|
154
|
+
bundle: unknown,
|
|
155
|
+
existing: readonly { source: string; connectionConfig?: any }[],
|
|
156
|
+
/** 지금 환경이 줄 수 있는 비밀값 이름들 — 값은 안 받습니다(이 함수는 값을 만지지 않습니다). */
|
|
157
|
+
availableSecrets: (source: string, key: string) => boolean = () => false
|
|
158
|
+
): ImportPlan {
|
|
159
|
+
const errors: string[] = []
|
|
160
|
+
const b = bundle as ConnectionBundle
|
|
161
|
+
if (!b || typeof b !== 'object') return { actions: [], needsHuman: [], collisions: [], errors: ['들여올 것이 객체가 아닙니다'] }
|
|
162
|
+
if (b.kind !== 'operato-twin/connections') {
|
|
163
|
+
return { actions: [], needsHuman: [], collisions: [], errors: [`이 파일은 연결 꾸러미가 아닙니다(kind: ${JSON.stringify((b as any).kind ?? null)})`] }
|
|
164
|
+
}
|
|
165
|
+
if (b.version !== 1) errors.push(`모르는 판입니다(version: ${JSON.stringify((b as any).version)}) — 읽을 수 있는 것만 읽습니다`)
|
|
166
|
+
if (!Array.isArray(b.connections)) return { actions: [], needsHuman: [], collisions: [], errors: [...errors, 'connections 가 목록이 아닙니다'] }
|
|
167
|
+
|
|
168
|
+
const have = new Map(existing.map(e => [e.source, e]))
|
|
169
|
+
const seen = new Set<string>()
|
|
170
|
+
const actions: ImportAction[] = []
|
|
171
|
+
const needsHuman: { source: string; keys: string[]; env: string[] }[] = []
|
|
172
|
+
|
|
173
|
+
for (const c of b.connections) {
|
|
174
|
+
const source = String(c?.source ?? '').trim()
|
|
175
|
+
if (!source) {
|
|
176
|
+
errors.push('이름 없는 연결이 있습니다 — 건너뜁니다')
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (seen.has(source)) {
|
|
180
|
+
actions.push({ action: 'skip', source, why: '같은 이름이 파일에 두 번 있습니다' })
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
seen.add(source)
|
|
184
|
+
if (!String(c?.adapterType ?? '').trim()) {
|
|
185
|
+
actions.push({ action: 'skip', source, why: '커넥터 종류가 없습니다 — 무엇으로 붙일지 모릅니다' })
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const cur = have.get(source)
|
|
190
|
+
const curCfg = (cur?.connectionConfig ?? {}) as Record<string, unknown>
|
|
191
|
+
const wanted = Array.isArray(c.secretsRequired) ? c.secretsRequired.map(String) : []
|
|
192
|
+
const kept = wanted.filter(k => String(curCfg[k] ?? '').trim())
|
|
193
|
+
const missing = wanted.filter(k => !kept.includes(k) && !availableSecrets(source, k))
|
|
194
|
+
|
|
195
|
+
if (missing.length) needsHuman.push({ source, keys: missing, env: missing.map(k => secretEnvName(source, k)) })
|
|
196
|
+
actions.push(
|
|
197
|
+
cur
|
|
198
|
+
? { action: 'update', connection: c, missingSecrets: missing, keptSecrets: kept }
|
|
199
|
+
: { action: 'create', connection: c, missingSecrets: missing }
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/*
|
|
204
|
+
* **들여온 뒤의 모습으로 봅니다.** 파일 안에서만 보면 「이미 있는 연결과 부딪히는 것」을 못
|
|
205
|
+
* 잡습니다 — 그게 실제로 더 쉽게 나는 쪽입니다(하나는 화면에서, 하나는 파일에서 생깁니다).
|
|
206
|
+
*/
|
|
207
|
+
const after = [
|
|
208
|
+
...existing
|
|
209
|
+
.filter(e => !seen.has(e.source))
|
|
210
|
+
.map(e => ({ source: e.source, config: (e.connectionConfig ?? {}) as Record<string, unknown> })),
|
|
211
|
+
...actions
|
|
212
|
+
.filter((a): a is Extract<ImportAction, { connection: PortableConnection }> => a.action !== 'skip')
|
|
213
|
+
.map(a => ({ source: a.connection.source, config: a.connection.config }))
|
|
214
|
+
]
|
|
215
|
+
return { actions, needsHuman, collisions: intakeCollisions(after), errors }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 사람이 읽는 한 줄 — 화면과 로그가 같은 문장을 쓰게. */
|
|
219
|
+
export function sayPlan(plan: ImportPlan): string {
|
|
220
|
+
const n = (a: ImportAction['action']) => plan.actions.filter(x => x.action === a).length
|
|
221
|
+
const head = `연결 ${plan.actions.length}개 — 새로 ${n('create')} · 갱신 ${n('update')} · 건너뜀 ${n('skip')}`
|
|
222
|
+
/* 부딪힘을 먼저 말합니다 — 비밀값이 없는 것은 안 도는 것이고, 부딪히는 것은 **잘못 도는 것**입니다. */
|
|
223
|
+
if (plan.collisions.length) {
|
|
224
|
+
const who = plan.collisions.map(c => `${c.sources.join(' · ')} → ${c.intake}`).join(', ')
|
|
225
|
+
return `${head}. ⚠ **두 연결이 같은 곳에 조치를 냅니다** — ${who}. 다른 공장의 작업지시가 한 공장에 섭니다`
|
|
226
|
+
}
|
|
227
|
+
if (!plan.needsHuman.length) return `${head}. 비밀값은 다 있습니다`
|
|
228
|
+
const who = plan.needsHuman.map(h => `${h.source}(${h.keys.join(' · ')})`).join(', ')
|
|
229
|
+
return `${head}. **비밀값을 사람이 넣어야 합니다** — ${who}. 연결 화면에서 채우면 그때부터 돕니다`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 비밀값이 사는 **환경 변수 이름** — `mes-miratek` · `hookSecret` → `TWIN_SECRET__MES_MIRATEK__HOOK_SECRET`.
|
|
234
|
+
*
|
|
235
|
+
* 꾸러미에는 비밀 칸의 이름만 있고 값은 환경에서 옵니다. 그 이름을 **사람이 손으로 적으므로** 규칙이
|
|
236
|
+
* 흔들리면 「넣었는데 안 읽힌다」가 됩니다 — 오늘 종일 잡은 「양쪽 다 초록인데 안 간다」의 설정 쪽
|
|
237
|
+
* 판본입니다. 그래서 이 규칙을 한 곳에 두고 시험으로 못 박습니다.
|
|
238
|
+
*
|
|
239
|
+
* 낙타 이름의 경계를 밑줄로 가릅니다 — 안 그러면 `HOOKSECRET` 과 `HOOK_SECRET` 이 갈려 한쪽만 읽힙니다.
|
|
240
|
+
*/
|
|
241
|
+
export function secretEnvName(source: string, key: string): string {
|
|
242
|
+
const up = (x: string) =>
|
|
243
|
+
x
|
|
244
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
245
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
246
|
+
.replace(/^_+|_+$/g, '')
|
|
247
|
+
.toUpperCase()
|
|
248
|
+
return `TWIN_SECRET__${up(source)}__${up(key)}`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* **두 연결이 같은 곳에 조치를 내지 않나** — 같으면 다른 공장의 작업지시가 한 공장에 섭니다.
|
|
253
|
+
*
|
|
254
|
+
* ── 왜 이 검사가 생겼나 (2026-09-05) ────────────────────────────────────────
|
|
255
|
+
* `mes-miratek` 이 서면서 한 커넥터가 처음으로 **공장 둘**을 달았습니다. 조치가 나가는 주소는
|
|
256
|
+
* `intakeSiteId` 가 정하는데(`operato-plant.ts:389`), 그 값이 트윈의 사이트 이름을 **이깁니다** —
|
|
257
|
+
* 트윈에서는 `mes-line-a` 인데 MES 의 도메인은 `system` 이라 그 override 가 필요합니다.
|
|
258
|
+
*
|
|
259
|
+
* 그래서 **연결을 베껴 만들고 그 칸만 안 고치면** 두 트윈의 조치가 같은 공장으로 갑니다. 오류가
|
|
260
|
+
* 안 납니다 — 저쪽은 정상적인 작업지시로 받고 **실제로 만듭니다.**
|
|
261
|
+
*
|
|
262
|
+
* 게이트에 이미 「사이트를 모르면 보내지 않는다」가 있는데(`no-site`), 그 이유가 적혀 있는 문장이
|
|
263
|
+
* **「잘못된 공장이 실제로 만든다」** 입니다. 이것은 그 둘째 길입니다 — 사이트를 아는데 **틀린
|
|
264
|
+
* 사이트를 압니다.**
|
|
265
|
+
*
|
|
266
|
+
* 파일로 연결을 만들 수 있게 되면서 더 쉬워졌습니다: 꾸러미를 베껴 이름만 바꾸면 받는 자리가
|
|
267
|
+
* 그대로 따라옵니다.
|
|
268
|
+
*/
|
|
269
|
+
export function intakeCollisions(
|
|
270
|
+
connections: readonly { source: string; adapterType?: string; config?: Record<string, unknown> }[]
|
|
271
|
+
): { intake: string; sources: string[] }[] {
|
|
272
|
+
const byIntake = new Map<string, string[]>()
|
|
273
|
+
for (const c of connections) {
|
|
274
|
+
const cfg = c.config ?? {}
|
|
275
|
+
/* 받는 자리를 정하는 규칙은 커넥터의 것과 같아야 합니다 — 여기서 다시 지으면 두 벌이 됩니다. */
|
|
276
|
+
const intake = String(cfg.intakeSiteId ?? cfg.siteId ?? '').trim()
|
|
277
|
+
/* 조치를 낼 수 없는 연결은 부딪힐 것이 없습니다 — 주소가 없으면 아무 데도 안 갑니다. */
|
|
278
|
+
if (!intake || !String(cfg.intakeEndpoint ?? '').trim()) continue
|
|
279
|
+
const key = `${String(cfg.intakeEndpoint).trim().replace(/\/$/, '')}::${intake}`
|
|
280
|
+
byIntake.set(key, [...(byIntake.get(key) ?? []), c.source])
|
|
281
|
+
}
|
|
282
|
+
return [...byIntake.entries()]
|
|
283
|
+
.filter(([, sources]) => sources.length > 1)
|
|
284
|
+
.map(([intake, sources]) => ({ intake, sources: sources.sort() }))
|
|
285
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 빠진 구간을 **언제** 다시 받나 — 간격만 여기 둔다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 따로인가 ─────────────────────────────────────────────────────────────
|
|
5
|
+
* `fill-loop.ts` 는 엔진과 TypeORM entity 를 물어서 테스트 프로세스가 적재할 수 없다. 값만 따로 두면
|
|
6
|
+
* 테스트가 가벼운 import 로 확인한다.
|
|
7
|
+
*
|
|
8
|
+
* 오늘 같은 자리를 세 번 밟았다(`reference-links` · `fill-dedupe` · 여기). 이 저장소에서 **판정과
|
|
9
|
+
* 값은 배선과 나누는 것이 규율**이다 — 나누면 테스트가 붙고, 안 나누면 안 붙는다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 그물의 간격 — **10분. 30초가 아니다.**
|
|
14
|
+
*
|
|
15
|
+
* 인티그레이션 레인이 측정으로 준 권고다. backfill 은 원본에 조회를 걸고 저널을 읽는다. 30초마다
|
|
16
|
+
* 돌면 얻는 것 없이 두 쪽을 계속 친다 — 놓친 구간은 10분 뒤에 받아도 사실이 달라지지 않는다.
|
|
17
|
+
*
|
|
18
|
+
* 규칙 주기(`RULE_PERIOD_MS`, 30초)에 얹지 않는 이유이기도 하다. 얹으면 간격이 그쪽에 묶이고,
|
|
19
|
+
* 나중에 규칙 주기를 바꾸면 이쪽이 소리 없이 따라간다.
|
|
20
|
+
*/
|
|
21
|
+
export const FILL_PERIOD_MS = 10 * 60_000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 같은 단위를 이 간격 안에 두 번 부르지 않는다.
|
|
25
|
+
*
|
|
26
|
+
* 구멍을 본 자리에서 부르는 길이 뜨거운 길이다. 밀어 받는 쪽이 구멍 뒤의 봉투를 계속 보내면 그때마다
|
|
27
|
+
* 구멍 판정이 나고, 막지 않으면 **봉투마다 backfill 이 돈다.**
|
|
28
|
+
*/
|
|
29
|
+
export const FILL_DEBOUNCE_MS = 60_000
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **backfill 이 이미 있는 사실을 다시 앉히지 않게 한다** — 저널에서 정체를 읽어 기억을 채운다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 있었나 (2026-09-06 측정) ─────────────────────────────────────────
|
|
5
|
+
* 중복 판정 기억(`FactDeduper`)은 **프로세스 안에만 있다.** 재기동하면 비워지고, 그 뒤 backfill 을
|
|
6
|
+
* 돌리면 이미 저널에 있는 사실도 「처음 본 것」이 된다.
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* mes-line-a 를 재기동 뒤 backfill
|
|
10
|
+
* 저널 44 → 83건 · 서로 다른 것 39가지 · 중복 44가지
|
|
11
|
+
* 같은 프로세스에서 다시 돌리면 applied 0 (기억이 살아 있어서)
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* push 경로에서는 안 보인다. 재전송이 몇 분 안에 오므로 창(20,000) 안에서 걸린다.
|
|
15
|
+
* **backfill 이 그 전제를 깬다** — 몇 달 전 사실을 지금 받으니 재기동 한 번이면 그 구간이 통째로
|
|
16
|
+
* 다시 앉는다.
|
|
17
|
+
*
|
|
18
|
+
* ── 왜 여기서만 하나 ────────────────────────────────────────────────────────
|
|
19
|
+
* push 경로는 뜨거운 길이다. 봉투마다 저널을 조회하면 그 길이 느려지고, 거기서는 재전송이 창 안에
|
|
20
|
+
* 들어와 이미 걸린다.
|
|
21
|
+
*
|
|
22
|
+
* backfill 은 자주 돌지 않고 한 번에 많이 받는다. 그래서 **한 번의 조회로 그 구간을 통째로** 읽어
|
|
23
|
+
* 기억을 채운다 — 레코드마다 묻지 않는다.
|
|
24
|
+
*
|
|
25
|
+
* ── 근본 고침은 따로 있다 ───────────────────────────────────────────────────
|
|
26
|
+
* 저널에 정체 칸과 unique index 를 두는 것이 근본이다. 그런데 저널이 2,484,472행이고, 개발 중에
|
|
27
|
+
* 그 표에 칸을 더하면 sqlite 가 표를 통째로 다시 쓴다. postgres 첫 부팅 직전에 한 번에 하기로 적어
|
|
28
|
+
* 두었다(`design/plans/postgres-column-types.md`).
|
|
29
|
+
*/
|
|
30
|
+
import { Between } from 'typeorm'
|
|
31
|
+
|
|
32
|
+
import { getRepository } from '@things-factory/shell'
|
|
33
|
+
|
|
34
|
+
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
35
|
+
import { factIdentity } from '../../engine/ingest-dedupe.js'
|
|
36
|
+
|
|
37
|
+
/** 구간 판정은 가벼운 파일에 있다 — 부르는 쪽이 한 곳에서 가져가게 다시 내보낸다. */
|
|
38
|
+
export { spanOf } from './fill-span.js'
|
|
39
|
+
|
|
40
|
+
/** 한 번에 읽을 저널 줄의 상한 — 넘으면 읽은 만큼만 채우고 그 사실을 말한다. */
|
|
41
|
+
const SCAN_MAX = 50_000
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 그 구간의 저널을 읽어 **정체 목록**을 만든다.
|
|
45
|
+
*
|
|
46
|
+
* 저장된 payload 가 봉투 그대로라(`eventId` · `eventType` · `eventTime` · `data`) 유입 때와 같은
|
|
47
|
+
* 함수로 정체를 만든다. 여기서 따로 만들면 두 곳이 다른 규칙을 갖고, 어긋나는 날 조용히 다시 앉는다.
|
|
48
|
+
*/
|
|
49
|
+
export async function journalIdentities(
|
|
50
|
+
domainId: string,
|
|
51
|
+
instanceId: string,
|
|
52
|
+
span: { from: Date; to: Date }
|
|
53
|
+
): Promise<{ identities: string[]; scanned: number; truncated: boolean }> {
|
|
54
|
+
const rows = await getRepository(TwinEvent).find({
|
|
55
|
+
where: { domain: { id: domainId } as any, instanceId, eventTime: Between(span.from, span.to) as any },
|
|
56
|
+
select: { id: true, payload: true } as any,
|
|
57
|
+
take: SCAN_MAX
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const identities: string[] = []
|
|
61
|
+
for (const r of rows as any[]) {
|
|
62
|
+
const id = factIdentity(r?.payload)
|
|
63
|
+
if (id) identities.push(id)
|
|
64
|
+
}
|
|
65
|
+
return { identities, scanned: rows.length, truncated: rows.length >= SCAN_MAX }
|
|
66
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **빠진 구간을 언제 다시 받나** — 세 자리에서 부른다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 세 자리인가 (인티그레이션 레인의 측정 권고, 2026-09-05) ──────────────
|
|
5
|
+
* ```
|
|
6
|
+
* 1 재기동 직후 한 번 우리가 내려가 있는 동안 그쪽이 보낸 것을 못 받았다
|
|
7
|
+
* 2 구멍을 본 그 자리 번호가 비었다는 것을 아는 순간이 가장 이르다
|
|
8
|
+
* 3 10분 그물 위 둘이 다 놓치는 경우 — 그쪽이 아예 안 보낸 구간
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* **30초가 아니다.** 그 권고에 근거가 있었다 — backfill 은 원본에 조회를 걸고 저널을 읽는다.
|
|
12
|
+
* 30초마다 돌면 얻는 것 없이 두 쪽을 계속 친다. 놓친 구간은 10분 뒤에 받아도 사실이 달라지지 않는다.
|
|
13
|
+
*
|
|
14
|
+
* 규칙 주기(`rule-loop.ts`, 30초)에 얹지 않는 이유가 그것이다. 얹으면 간격이 그쪽에 묶이고, 나중에
|
|
15
|
+
* 규칙 주기를 바꾸면 이쪽이 소리 없이 따라간다.
|
|
16
|
+
*
|
|
17
|
+
* ── 겹쳐 돌지 않는다 ────────────────────────────────────────────────────────
|
|
18
|
+
* 한 바퀴가 간격보다 오래 걸리면 다음 발화가 겹친다. 겹치면 두 바퀴가 같은 구간을 받고, 그 순간
|
|
19
|
+
* 중복 판정이 두 벌의 기억을 갖는다.
|
|
20
|
+
*
|
|
21
|
+
* ── 도는 트윈만 본다 ────────────────────────────────────────────────────────
|
|
22
|
+
* 멈춘 트윈에는 사실을 넣을 커널이 없다. `runFill` 이 그것을 다시 확인하지만 여기서 미리 걸러
|
|
23
|
+
* 헛조회를 안 만든다.
|
|
24
|
+
*/
|
|
25
|
+
import { twinError, twinLog, twinWarn } from '../../engine/log.js'
|
|
26
|
+
import { TwinEngine } from '../../engine/twin-engine.js'
|
|
27
|
+
|
|
28
|
+
import { runFill, type FillRunResult } from './reference-fill-run.js'
|
|
29
|
+
|
|
30
|
+
/* 간격은 가벼운 파일에 있다 — 테스트가 붙게(§`fill-cadence.ts`). 부르는 쪽이 한 곳에서 가져가게 다시 내보낸다. */
|
|
31
|
+
export { FILL_DEBOUNCE_MS, FILL_PERIOD_MS } from './fill-cadence.js'
|
|
32
|
+
import { FILL_DEBOUNCE_MS, FILL_PERIOD_MS } from './fill-cadence.js'
|
|
33
|
+
|
|
34
|
+
/** 어느 단위를 언제 마지막으로 받았나 — `domainId::instanceId::scope`. */
|
|
35
|
+
const lastRun = new Map<string, number>()
|
|
36
|
+
|
|
37
|
+
let timer: any
|
|
38
|
+
let running = false
|
|
39
|
+
|
|
40
|
+
function recently(key: string, now: number): boolean {
|
|
41
|
+
const last = lastRun.get(key)
|
|
42
|
+
return last !== undefined && now - last < FILL_DEBOUNCE_MS
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 그 단위를 한 번 받는다 — **커서부터 이어서.**
|
|
47
|
+
*
|
|
48
|
+
* `from` 을 주지 않는다. 밑에서부터 다시 받는 것은 사람이 정하는 일이고(`fillTwinScope(from:)`),
|
|
49
|
+
* 주기가 스스로 할 일이 아니다 — 그것을 주기로 돌리면 매번 원본의 전 구간을 훑는다.
|
|
50
|
+
*/
|
|
51
|
+
export async function fillOnce(domainId: string, instanceId: string, scope: string): Promise<FillRunResult | undefined> {
|
|
52
|
+
const key = `${domainId}::${instanceId}::${scope}`
|
|
53
|
+
const now = Date.now()
|
|
54
|
+
if (recently(key, now)) return undefined
|
|
55
|
+
lastRun.set(key, now)
|
|
56
|
+
|
|
57
|
+
const out = await runFill({ domainId, instanceId, scope })
|
|
58
|
+
/*
|
|
59
|
+
* 받은 것이 있을 때만 말한다. 「받을 것이 없었다」를 10분마다 적으면 로그가 그 줄로 차고, 그러면
|
|
60
|
+
* 진짜 받은 날을 못 본다.
|
|
61
|
+
*/
|
|
62
|
+
if (out.applied) twinLog(`[twin-fill] "${instanceId}" · ${scope} — 빠진 구간에서 ${out.applied}건을 받았습니다`)
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* **구멍을 본 자리에서 부른다** — 기다리지 않는다.
|
|
68
|
+
*
|
|
69
|
+
* 훅의 응답이 이것 때문에 늦으면 보내는 쪽이 되풀이하고, 그러면 같은 봉투가 두 번 온다. 조치를
|
|
70
|
+
* 잇는 자리(`settleEffects`)와 같은 규율이다.
|
|
71
|
+
*/
|
|
72
|
+
export function requestFill(domainId: string, instanceId: string, scope: string): void {
|
|
73
|
+
void fillOnce(domainId, instanceId, scope).catch(err =>
|
|
74
|
+
twinWarn(`[twin-fill] "${instanceId}" · ${scope} 를 받지 못했습니다 — ${err?.message ?? err}`)
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 도는 트윈을 한 바퀴 돈다.
|
|
80
|
+
*
|
|
81
|
+
* **어느 단위를 받을지는 커서가 말한다.** 커서에 적힌 흐름이 그동안 받아 온 것들이고, 그것이
|
|
82
|
+
* 없으면 아직 아무것도 안 받은 연결이라 받을 구간도 없다.
|
|
83
|
+
*/
|
|
84
|
+
export async function fillAllOnce(): Promise<{ tried: number; applied: number }> {
|
|
85
|
+
let tried = 0
|
|
86
|
+
let applied = 0
|
|
87
|
+
for (const { domainId, instanceId } of TwinEngine.ownedTwins()) {
|
|
88
|
+
for (const scope of await scopesOf(domainId, instanceId)) {
|
|
89
|
+
tried++
|
|
90
|
+
const out = await fillOnce(domainId, instanceId, scope)
|
|
91
|
+
applied += out?.applied ?? 0
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { tried, applied }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 그 연결이 받아 온 단위들 — **커서에 적힌 것.**
|
|
99
|
+
*
|
|
100
|
+
* 커서 열쇠는 `instanceId::scope` 다(§`pushCursorKey`). 여기서 그 규칙을 다시 적지 않고 앞부분을
|
|
101
|
+
* 떼어 낸다 — 두 곳이 같은 규칙을 가지면 어긋나는 날 이 주기가 엉뚱한 단위를 받는다.
|
|
102
|
+
*/
|
|
103
|
+
async function scopesOf(domainId: string, instanceId: string): Promise<string[]> {
|
|
104
|
+
const { getRepository } = await import('@things-factory/shell')
|
|
105
|
+
const { TwinReference } = await import('./twin-reference.js')
|
|
106
|
+
const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
|
|
107
|
+
const streams = (ref as any)?.liveCursor?.streams
|
|
108
|
+
if (!streams || typeof streams !== 'object') return []
|
|
109
|
+
|
|
110
|
+
const prefix = `${instanceId}::`
|
|
111
|
+
return Object.keys(streams)
|
|
112
|
+
.filter(k => k.startsWith(prefix))
|
|
113
|
+
.map(k => k.slice(prefix.length))
|
|
114
|
+
.filter(s => s)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 주기를 세운다 — 두 번 불러도 하나만 선다.
|
|
119
|
+
*
|
|
120
|
+
* **기동에서 한 번 먼저 돈다**(§1). 우리가 내려가 있는 동안 그쪽이 보낸 것을 그때 받는다. 첫 바퀴를
|
|
121
|
+
* 10분 뒤로 미루면 그 10분 동안 화면이 빈 구간을 그대로 보인다.
|
|
122
|
+
*/
|
|
123
|
+
export function startFillLoop(periodMs = FILL_PERIOD_MS): void {
|
|
124
|
+
if (timer) return
|
|
125
|
+
|
|
126
|
+
const turn = () => {
|
|
127
|
+
if (running) return
|
|
128
|
+
running = true
|
|
129
|
+
fillAllOnce()
|
|
130
|
+
.then(({ tried, applied }) => {
|
|
131
|
+
if (applied) twinLog(`[twin-fill] 한 바퀴 — ${tried}개 단위에서 ${applied}건을 받았습니다`)
|
|
132
|
+
})
|
|
133
|
+
.catch(err => twinError(`[twin-fill] 주기가 실패했습니다 — ${err?.message ?? err}`))
|
|
134
|
+
.finally(() => {
|
|
135
|
+
running = false
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
timer = setInterval(turn, periodMs)
|
|
140
|
+
/*
|
|
141
|
+
* 기동 직후 한 번. 조금 늦춰서 돈다 — 커널이 되살아나기 전에 부르면 도는 트윈이 0개라 아무것도
|
|
142
|
+
* 안 받고, 그러면 그 한 번이 없는 것과 같다.
|
|
143
|
+
*/
|
|
144
|
+
setTimeout(turn, 15_000).unref?.()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 주기를 내린다 — 테스트가 재기동을 넣을 수 있게. */
|
|
148
|
+
export function stopFillLoop(): void {
|
|
149
|
+
if (timer) clearInterval(timer)
|
|
150
|
+
timer = undefined
|
|
151
|
+
running = false
|
|
152
|
+
lastRun.clear()
|
|
153
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* backfill 이 읽을 저널 구간을 정하는 판정.
|
|
3
|
+
*
|
|
4
|
+
* 조회 코드(`fill-dedupe.ts`)와 나눠 둔다 — 그쪽은 TypeORM entity 를 물어서 테스트 프로세스가
|
|
5
|
+
* 적재할 수 없다. 판정만 따로 두면 테스트가 가벼운 import 로 확인할 수 있다.
|
|
6
|
+
*/
|
|
7
|
+
import { factEventTime } from '../../engine/event-time.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 이 레코드들이 덮는 시각 구간 — 하나도 시각을 말하지 않으면 `undefined`.
|
|
11
|
+
*
|
|
12
|
+
* **구간을 못 정하면 채우지 않는다.** 저널 전체를 읽는 쪽으로 넓히지 않는다 — 그것이 필요한
|
|
13
|
+
* 상황이면 조회가 아니라 unique index 로 풀어야 한다.
|
|
14
|
+
*/
|
|
15
|
+
export function spanOf(records: readonly unknown[]): { from: Date; to: Date } | undefined {
|
|
16
|
+
let lo = Number.POSITIVE_INFINITY
|
|
17
|
+
let hi = Number.NEGATIVE_INFINITY
|
|
18
|
+
for (const r of records) {
|
|
19
|
+
const t = Date.parse(String(factEventTime(r) ?? ''))
|
|
20
|
+
if (!Number.isFinite(t)) continue
|
|
21
|
+
if (t < lo) lo = t
|
|
22
|
+
if (t > hi) hi = t
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isFinite(lo)) return undefined
|
|
25
|
+
/*
|
|
26
|
+
* 양쪽을 1초씩 넓힌다. 저장된 시각은 밀리초까지 있고 경계에서 같은 값이 부동소수로 갈릴 수 있다 —
|
|
27
|
+
* 넓게 읽는 것은 기억이 조금 더 차는 것뿐이라 손해가 없고, 좁게 읽으면 그 사실이 다시 앉는다.
|
|
28
|
+
*/
|
|
29
|
+
return { from: new Date(lo - 1000), to: new Date(hi + 1000) }
|
|
30
|
+
}
|
|
31
|
+
|