@oneuptime/common 13.0.3 → 13.0.4

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.
@@ -0,0 +1,711 @@
1
+ import GlobalCache from "../../../../Server/Infrastructure/GlobalCache";
2
+ import InventoryItemService from "../../../../Server/Services/InventoryItemService";
3
+ import InventoryItemRelationshipService from "../../../../Server/Services/InventoryItemRelationshipService";
4
+ import logger from "../../../../Server/Utils/Logger";
5
+ import {
6
+ DEFAULT_ENTITY_BUDGET,
7
+ FALLBACK_ENTITY_BUDGET,
8
+ REGISTRY_PROMOTED_TYPES,
9
+ getEntityBudget,
10
+ reconcileByNaturalKey,
11
+ reconcileEntityRegistryThrottled,
12
+ shouldWarnEntityBudgetOnce,
13
+ } from "../../../../Server/Utils/Telemetry/EntityRegistry";
14
+ import { ExtractedEntity } from "../../../../Server/Utils/Telemetry/TelemetryEntity";
15
+ import InventoryItem from "../../../../Models/DatabaseModels/InventoryItem";
16
+ import ObjectID from "../../../../Types/ObjectID";
17
+ import EntityType from "../../../../Types/Telemetry/EntityType";
18
+ import EntityRelationshipType from "../../../../Types/Telemetry/EntityRelationshipType";
19
+ import { EntityRelationshipEdge } from "../../../../Utils/Telemetry/EntityRelationship";
20
+ import {
21
+ afterEach,
22
+ beforeEach,
23
+ describe,
24
+ expect,
25
+ jest,
26
+ test,
27
+ } from "@jest/globals";
28
+
29
+ /*
30
+ * The row fence (EntityRegistryRowFence) and the legacy Host retirement
31
+ * (EntityRegistryRetirement) have their own suites. This one pins the rest of
32
+ * the registry's decision logic: which entity types are promoted, the per-type
33
+ * budgets, the budget-warning fence, the set-level fence and promotion gate in
34
+ * reconcileEntityRegistryThrottled, and the select / bump composition in
35
+ * reconcileByNaturalKey. Postgres and Redis are mocked throughout.
36
+ */
37
+
38
+ const PROJECT_ID: ObjectID = ObjectID.generate();
39
+
40
+ function entity(
41
+ entityType: EntityType,
42
+ entityKey: string,
43
+ extra: Partial<ExtractedEntity> = {},
44
+ ): ExtractedEntity {
45
+ return {
46
+ entityType,
47
+ entityKey,
48
+ identifyingAttributes: { id: entityKey },
49
+ ...extra,
50
+ };
51
+ }
52
+
53
+ beforeEach(() => {
54
+ jest.spyOn(GlobalCache, "setStringIfNotExists").mockResolvedValue(true);
55
+ jest.spyOn(GlobalCache, "deleteKey").mockResolvedValue(undefined);
56
+ jest.spyOn(GlobalCache, "getString").mockResolvedValue(null);
57
+ jest.spyOn(GlobalCache, "setString").mockResolvedValue(undefined);
58
+ jest
59
+ .spyOn(InventoryItemService, "reconcileEntities")
60
+ .mockResolvedValue(undefined);
61
+ jest
62
+ .spyOn(InventoryItemRelationshipService, "reconcileRelationships")
63
+ .mockResolvedValue(undefined);
64
+ jest.spyOn(InventoryItemService, "hardDeleteBy").mockResolvedValue(0);
65
+ jest.spyOn(logger, "error").mockImplementation(() => {});
66
+ jest.spyOn(logger, "warn").mockImplementation(() => {});
67
+ jest.spyOn(logger, "debug").mockImplementation(() => {});
68
+ });
69
+
70
+ afterEach(() => {
71
+ jest.restoreAllMocks();
72
+ });
73
+
74
+ describe("REGISTRY_PROMOTED_TYPES", () => {
75
+ test.each([
76
+ EntityType.Container,
77
+ EntityType.Process,
78
+ EntityType.ServiceInstance,
79
+ EntityType.TelemetrySdk,
80
+ ])("high-churn type %s is membership-only", (entityType: EntityType) => {
81
+ expect(REGISTRY_PROMOTED_TYPES.has(entityType)).toBe(false);
82
+ });
83
+
84
+ test("every other entity type is promoted", () => {
85
+ const membershipOnly: Array<EntityType> = [
86
+ EntityType.Container,
87
+ EntityType.Process,
88
+ EntityType.ServiceInstance,
89
+ EntityType.TelemetrySdk,
90
+ ];
91
+
92
+ for (const entityType of Object.values(EntityType)) {
93
+ expect(REGISTRY_PROMOTED_TYPES.has(entityType)).toBe(
94
+ !membershipOnly.includes(entityType),
95
+ );
96
+ }
97
+
98
+ expect(REGISTRY_PROMOTED_TYPES.size).toBe(
99
+ Object.values(EntityType).length - membershipOnly.length,
100
+ );
101
+ });
102
+ });
103
+
104
+ describe("getEntityBudget", () => {
105
+ test("uses the declared budget for each configured type", () => {
106
+ for (const [entityType, budget] of DEFAULT_ENTITY_BUDGET) {
107
+ expect(getEntityBudget(entityType)).toBe(budget);
108
+ }
109
+ });
110
+
111
+ test.each([
112
+ [EntityType.Service, 10000],
113
+ [EntityType.KubernetesNode, 1000],
114
+ [EntityType.KubernetesPod, 5000],
115
+ [EntityType.VMwareHost, 2000],
116
+ ])("%s has a budget of %d", (entityType: EntityType, budget: number) => {
117
+ expect(getEntityBudget(entityType)).toBe(budget);
118
+ });
119
+
120
+ test("types without a declared budget use the fallback", () => {
121
+ expect(DEFAULT_ENTITY_BUDGET.has(EntityType.DockerSwarmTask)).toBe(false);
122
+ expect(getEntityBudget(EntityType.DockerSwarmTask)).toBe(
123
+ FALLBACK_ENTITY_BUDGET,
124
+ );
125
+ expect(FALLBACK_ENTITY_BUDGET).toBe(5000);
126
+ });
127
+
128
+ test("every declared budget is a positive integer on a promoted type", () => {
129
+ for (const [entityType, budget] of DEFAULT_ENTITY_BUDGET) {
130
+ expect(Number.isInteger(budget)).toBe(true);
131
+ expect(budget).toBeGreaterThan(0);
132
+ expect(REGISTRY_PROMOTED_TYPES.has(entityType)).toBe(true);
133
+ }
134
+ });
135
+ });
136
+
137
+ describe("shouldWarnEntityBudgetOnce", () => {
138
+ test("claims a fence scoped to project and entity type", async () => {
139
+ await expect(
140
+ shouldWarnEntityBudgetOnce({
141
+ projectId: PROJECT_ID,
142
+ entityType: EntityType.KubernetesPod,
143
+ }),
144
+ ).resolves.toBe(true);
145
+
146
+ const call: Array<unknown> = (GlobalCache.setStringIfNotExists as jest.Mock)
147
+ .mock.calls[0]!;
148
+ expect(call[0]).toBe("otel-maintenance-fence");
149
+ expect(call[1]).toBe(
150
+ `entity-budget-warn:${PROJECT_ID.toString()}:${EntityType.KubernetesPod}`,
151
+ );
152
+ expect(call[2]).toBe("1");
153
+ const ttl: number = (call[3] as { expiresInSeconds: number })
154
+ .expiresInSeconds;
155
+ // 5 minutes, with up to 25% jitter.
156
+ expect(ttl).toBeGreaterThanOrEqual(300);
157
+ expect(ttl).toBeLessThanOrEqual(375);
158
+ });
159
+
160
+ test("returns false once the window is already claimed", async () => {
161
+ (GlobalCache.setStringIfNotExists as jest.Mock).mockResolvedValueOnce(
162
+ false as never,
163
+ );
164
+
165
+ await expect(
166
+ shouldWarnEntityBudgetOnce({
167
+ projectId: PROJECT_ID,
168
+ entityType: EntityType.Host,
169
+ }),
170
+ ).resolves.toBe(false);
171
+ });
172
+
173
+ test("a cache outage defaults to warning", async () => {
174
+ (GlobalCache.setStringIfNotExists as jest.Mock).mockRejectedValueOnce(
175
+ new Error("redis down") as never,
176
+ );
177
+
178
+ await expect(
179
+ shouldWarnEntityBudgetOnce({
180
+ projectId: PROJECT_ID,
181
+ entityType: EntityType.Host,
182
+ }),
183
+ ).resolves.toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("reconcileEntityRegistryThrottled — promotion gate", () => {
188
+ test("nothing to promote means no fence claim and no writes", async () => {
189
+ await reconcileEntityRegistryThrottled({
190
+ projectId: PROJECT_ID,
191
+ entities: [
192
+ entity(EntityType.Container, "c1"),
193
+ entity(EntityType.Process, "p1"),
194
+ entity(EntityType.Host, "h1", { membershipOnly: true }),
195
+ ],
196
+ });
197
+
198
+ expect(GlobalCache.setStringIfNotExists).not.toHaveBeenCalled();
199
+ expect(InventoryItemService.reconcileEntities).not.toHaveBeenCalled();
200
+ expect(
201
+ InventoryItemRelationshipService.reconcileRelationships,
202
+ ).not.toHaveBeenCalled();
203
+ });
204
+
205
+ test("an empty batch is a no-op", async () => {
206
+ await reconcileEntityRegistryThrottled({
207
+ projectId: PROJECT_ID,
208
+ entities: [],
209
+ });
210
+
211
+ expect(GlobalCache.setStringIfNotExists).not.toHaveBeenCalled();
212
+ });
213
+
214
+ test("only promoted, non-membership-only entities are reconciled", async () => {
215
+ const pod: ExtractedEntity = entity(EntityType.KubernetesPod, "pod");
216
+ const duplicatePod: ExtractedEntity = entity(
217
+ EntityType.KubernetesPod,
218
+ "pod-dup",
219
+ { membershipOnly: true },
220
+ );
221
+ const host: ExtractedEntity = entity(EntityType.Host, "host", {
222
+ membershipOnly: false,
223
+ });
224
+
225
+ await reconcileEntityRegistryThrottled({
226
+ projectId: PROJECT_ID,
227
+ entities: [
228
+ entity(EntityType.Container, "container"),
229
+ pod,
230
+ duplicatePod,
231
+ host,
232
+ entity(EntityType.TelemetrySdk, "sdk"),
233
+ ],
234
+ });
235
+
236
+ expect(InventoryItemService.reconcileEntities).toHaveBeenCalledTimes(1);
237
+ expect(InventoryItemService.reconcileEntities).toHaveBeenCalledWith({
238
+ projectId: PROJECT_ID,
239
+ entities: [pod, host],
240
+ });
241
+ });
242
+
243
+ test("non-Host retirements are ignored entirely", async () => {
244
+ await reconcileEntityRegistryThrottled({
245
+ projectId: PROJECT_ID,
246
+ entities: [],
247
+ retiredEntities: [
248
+ {
249
+ entityType: EntityType.KubernetesPod,
250
+ entityKey: "pod",
251
+ identifyingAttributes: { "host.name": "x" },
252
+ },
253
+ ],
254
+ });
255
+
256
+ expect(GlobalCache.setStringIfNotExists).not.toHaveBeenCalled();
257
+ expect(InventoryItemService.hardDeleteBy).not.toHaveBeenCalled();
258
+ });
259
+ });
260
+
261
+ describe("reconcileEntityRegistryThrottled — set-level fence", () => {
262
+ function fenceKeyOfCall(index: number): string {
263
+ return (GlobalCache.setStringIfNotExists as jest.Mock).mock.calls[
264
+ index
265
+ ]![1] as string;
266
+ }
267
+
268
+ test("a claimed fence skips all writes", async () => {
269
+ (GlobalCache.setStringIfNotExists as jest.Mock).mockResolvedValue(
270
+ false as never,
271
+ );
272
+
273
+ await reconcileEntityRegistryThrottled({
274
+ projectId: PROJECT_ID,
275
+ entities: [
276
+ entity(EntityType.KubernetesPod, "pod"),
277
+ entity(EntityType.KubernetesNode, "node"),
278
+ ],
279
+ });
280
+
281
+ expect(InventoryItemService.reconcileEntities).not.toHaveBeenCalled();
282
+ expect(
283
+ InventoryItemRelationshipService.reconcileRelationships,
284
+ ).not.toHaveBeenCalled();
285
+ });
286
+
287
+ test("a cache outage fails open and reconciles", async () => {
288
+ (GlobalCache.setStringIfNotExists as jest.Mock).mockRejectedValue(
289
+ new Error("redis down") as never,
290
+ );
291
+
292
+ await reconcileEntityRegistryThrottled({
293
+ projectId: PROJECT_ID,
294
+ entities: [entity(EntityType.Host, "host")],
295
+ });
296
+
297
+ expect(InventoryItemService.reconcileEntities).toHaveBeenCalledTimes(1);
298
+ });
299
+
300
+ test("the fence key is a fixed-width hash in the shared namespace", async () => {
301
+ await reconcileEntityRegistryThrottled({
302
+ projectId: PROJECT_ID,
303
+ entities: [entity(EntityType.Host, "k".repeat(4000))],
304
+ });
305
+
306
+ const call: Array<unknown> = (GlobalCache.setStringIfNotExists as jest.Mock)
307
+ .mock.calls[0]!;
308
+ expect(call[0]).toBe("otel-maintenance-fence");
309
+ expect(call[1]).toMatch(/^entity-reconcile:[0-9a-f]{40}$/);
310
+ });
311
+
312
+ test("entity order does not change the fence", async () => {
313
+ const a: ExtractedEntity = entity(EntityType.Host, "aaa");
314
+ const b: ExtractedEntity = entity(EntityType.KubernetesNode, "bbb");
315
+
316
+ await reconcileEntityRegistryThrottled({
317
+ projectId: PROJECT_ID,
318
+ entities: [a, b],
319
+ });
320
+ await reconcileEntityRegistryThrottled({
321
+ projectId: PROJECT_ID,
322
+ entities: [b, a],
323
+ });
324
+
325
+ expect(fenceKeyOfCall(0)).toBe(fenceKeyOfCall(1));
326
+ });
327
+
328
+ test("membership-only churn does not change the fence", async () => {
329
+ const pod: ExtractedEntity = entity(EntityType.KubernetesPod, "pod");
330
+
331
+ await reconcileEntityRegistryThrottled({
332
+ projectId: PROJECT_ID,
333
+ entities: [pod, entity(EntityType.Container, "container-run-1")],
334
+ });
335
+ await reconcileEntityRegistryThrottled({
336
+ projectId: PROJECT_ID,
337
+ entities: [pod, entity(EntityType.Container, "container-run-2")],
338
+ });
339
+
340
+ expect(fenceKeyOfCall(0)).toBe(fenceKeyOfCall(1));
341
+ });
342
+
343
+ test("a changed promoted set changes the fence", async () => {
344
+ await reconcileEntityRegistryThrottled({
345
+ projectId: PROJECT_ID,
346
+ entities: [entity(EntityType.KubernetesPod, "pod-1")],
347
+ });
348
+ await reconcileEntityRegistryThrottled({
349
+ projectId: PROJECT_ID,
350
+ entities: [entity(EntityType.KubernetesPod, "pod-2")],
351
+ });
352
+
353
+ expect(fenceKeyOfCall(0)).not.toBe(fenceKeyOfCall(1));
354
+ });
355
+
356
+ test("the same set in another project has its own fence", async () => {
357
+ const pod: ExtractedEntity = entity(EntityType.KubernetesPod, "pod");
358
+
359
+ await reconcileEntityRegistryThrottled({
360
+ projectId: PROJECT_ID,
361
+ entities: [pod],
362
+ });
363
+ await reconcileEntityRegistryThrottled({
364
+ projectId: ObjectID.generate(),
365
+ entities: [pod],
366
+ });
367
+
368
+ expect(fenceKeyOfCall(0)).not.toBe(fenceKeyOfCall(1));
369
+ });
370
+ });
371
+
372
+ describe("reconcileEntityRegistryThrottled — topology edges", () => {
373
+ test("derives co-occurrence edges among promoted entities only", async () => {
374
+ await reconcileEntityRegistryThrottled({
375
+ projectId: PROJECT_ID,
376
+ entities: [
377
+ entity(EntityType.KubernetesPod, "pod"),
378
+ entity(EntityType.KubernetesNode, "node"),
379
+ entity(EntityType.KubernetesCluster, "cluster"),
380
+ // Container → pod would be a PartOf edge, but containers never promote.
381
+ entity(EntityType.Container, "container"),
382
+ ],
383
+ });
384
+
385
+ expect(
386
+ InventoryItemRelationshipService.reconcileRelationships,
387
+ ).toHaveBeenCalledTimes(1);
388
+
389
+ const edges: Array<EntityRelationshipEdge> = (
390
+ (InventoryItemRelationshipService.reconcileRelationships as jest.Mock)
391
+ .mock.calls[0]![0] as { edges: Array<EntityRelationshipEdge> }
392
+ ).edges;
393
+
394
+ const summary: Array<string> = edges
395
+ .map((edge: EntityRelationshipEdge): string => {
396
+ return `${edge.fromEntityKey}-${edge.relationshipType}->${edge.toEntityKey}`;
397
+ })
398
+ .sort();
399
+
400
+ expect(summary).toEqual(
401
+ [
402
+ `node-${EntityRelationshipType.MemberOf}->cluster`,
403
+ `pod-${EntityRelationshipType.MemberOf}->cluster`,
404
+ `pod-${EntityRelationshipType.RunsOn}->node`,
405
+ ].sort(),
406
+ );
407
+ for (const edge of edges) {
408
+ expect(edge.fromEntityKey).not.toBe("container");
409
+ expect(edge.toEntityKey).not.toBe("container");
410
+ }
411
+ });
412
+
413
+ test("a set with no related type pairs writes no relationships", async () => {
414
+ await reconcileEntityRegistryThrottled({
415
+ projectId: PROJECT_ID,
416
+ entities: [
417
+ entity(EntityType.Host, "host"),
418
+ entity(EntityType.CephCluster, "ceph"),
419
+ ],
420
+ });
421
+
422
+ expect(InventoryItemService.reconcileEntities).toHaveBeenCalledTimes(1);
423
+ expect(
424
+ InventoryItemRelationshipService.reconcileRelationships,
425
+ ).not.toHaveBeenCalled();
426
+ });
427
+
428
+ test("edges are reconciled after the entities they reference", async () => {
429
+ const order: Array<string> = [];
430
+ (InventoryItemService.reconcileEntities as jest.Mock).mockImplementation(
431
+ async (): Promise<void> => {
432
+ order.push("entities");
433
+ },
434
+ );
435
+ (
436
+ InventoryItemRelationshipService.reconcileRelationships as jest.Mock
437
+ ).mockImplementation(async (): Promise<void> => {
438
+ order.push("edges");
439
+ });
440
+
441
+ await reconcileEntityRegistryThrottled({
442
+ projectId: PROJECT_ID,
443
+ entities: [
444
+ entity(EntityType.Service, "svc"),
445
+ entity(EntityType.Host, "host"),
446
+ ],
447
+ });
448
+
449
+ expect(order).toEqual(["entities", "edges"]);
450
+ });
451
+
452
+ test("a registry failure is swallowed and logged, and skips the edges", async () => {
453
+ (InventoryItemService.reconcileEntities as jest.Mock).mockRejectedValue(
454
+ new Error("postgres down") as never,
455
+ );
456
+
457
+ await expect(
458
+ reconcileEntityRegistryThrottled({
459
+ projectId: PROJECT_ID,
460
+ entities: [
461
+ entity(EntityType.Service, "svc"),
462
+ entity(EntityType.Host, "host"),
463
+ ],
464
+ }),
465
+ ).resolves.toBeUndefined();
466
+
467
+ expect(logger.error).toHaveBeenCalled();
468
+ expect(
469
+ InventoryItemRelationshipService.reconcileRelationships,
470
+ ).not.toHaveBeenCalled();
471
+ });
472
+
473
+ test("a relationship failure is swallowed too", async () => {
474
+ (
475
+ InventoryItemRelationshipService.reconcileRelationships as jest.Mock
476
+ ).mockRejectedValue(new Error("unique violation") as never);
477
+
478
+ await expect(
479
+ reconcileEntityRegistryThrottled({
480
+ projectId: PROJECT_ID,
481
+ entities: [
482
+ entity(EntityType.Service, "svc"),
483
+ entity(EntityType.Host, "host"),
484
+ ],
485
+ }),
486
+ ).resolves.toBeUndefined();
487
+
488
+ expect(logger.error).toHaveBeenCalled();
489
+ });
490
+ });
491
+
492
+ describe("reconcileByNaturalKey — select and bump composition", () => {
493
+ const ROW_ID: ObjectID = ObjectID.generate();
494
+
495
+ function row(): InventoryItem {
496
+ const item: InventoryItem = new InventoryItem();
497
+ item.id = ROW_ID;
498
+ item._id = ROW_ID.toString();
499
+ item.displayName = "old";
500
+ return item;
501
+ }
502
+
503
+ beforeEach(() => {
504
+ jest
505
+ .spyOn(InventoryItemService, "findOneBy")
506
+ .mockResolvedValue(row() as never);
507
+ jest
508
+ .spyOn(InventoryItemService, "updateColumnsByIdIfUnlockedWithoutHooks")
509
+ .mockResolvedValue(true as never);
510
+ jest
511
+ .spyOn(InventoryItemService, "create")
512
+ .mockImplementation(async (input: any): Promise<any> => {
513
+ return input.data;
514
+ });
515
+ });
516
+
517
+ test("always selects _id and merges caller-supplied columns", async () => {
518
+ await reconcileByNaturalKey<InventoryItem>({
519
+ service: InventoryItemService,
520
+ query: { projectId: PROJECT_ID, entityKey: "k" },
521
+ lastSeenAt: new Date(),
522
+ describe: "entity k",
523
+ rowFenceId: "p:k",
524
+ select: { displayName: true, descriptiveAttributes: true },
525
+ buildModel: row,
526
+ });
527
+
528
+ expect(InventoryItemService.findOneBy).toHaveBeenCalledWith({
529
+ query: { projectId: PROJECT_ID, entityKey: "k" },
530
+ select: { _id: true, displayName: true, descriptiveAttributes: true },
531
+ props: { isRoot: true },
532
+ });
533
+ });
534
+
535
+ test("without buildUpdate the bump is lastSeenAt only", async () => {
536
+ const lastSeenAt: Date = new Date("2026-09-13T12:00:00.000Z");
537
+
538
+ await reconcileByNaturalKey<InventoryItem>({
539
+ service: InventoryItemService,
540
+ query: { projectId: PROJECT_ID, entityKey: "k" },
541
+ lastSeenAt,
542
+ describe: "entity k",
543
+ rowFenceId: "p:k",
544
+ buildModel: row,
545
+ });
546
+
547
+ expect(
548
+ InventoryItemService.updateColumnsByIdIfUnlockedWithoutHooks,
549
+ ).toHaveBeenCalledWith({ id: ROW_ID, data: { lastSeenAt } });
550
+ });
551
+
552
+ test("buildUpdate receives the existing row and its fields join the bump", async () => {
553
+ const lastSeenAt: Date = new Date("2026-09-13T12:00:00.000Z");
554
+ const seen: Array<string | undefined> = [];
555
+
556
+ await reconcileByNaturalKey<InventoryItem>({
557
+ service: InventoryItemService,
558
+ query: { projectId: PROJECT_ID, entityKey: "k" },
559
+ lastSeenAt,
560
+ describe: "entity k",
561
+ rowFenceId: "p:k",
562
+ buildModel: row,
563
+ buildUpdate: (existing: InventoryItem): any => {
564
+ seen.push(existing.displayName);
565
+ return { displayName: "new" };
566
+ },
567
+ });
568
+
569
+ expect(seen).toEqual(["old"]);
570
+ expect(
571
+ InventoryItemService.updateColumnsByIdIfUnlockedWithoutHooks,
572
+ ).toHaveBeenCalledWith({
573
+ id: ROW_ID,
574
+ data: { lastSeenAt, displayName: "new" },
575
+ });
576
+ });
577
+
578
+ test("beforeCreate is never consulted for an existing row", async () => {
579
+ let gateCalls: number = 0;
580
+ const beforeCreate: () => Promise<boolean> = async (): Promise<boolean> => {
581
+ gateCalls++;
582
+ return false;
583
+ };
584
+
585
+ await reconcileByNaturalKey<InventoryItem>({
586
+ service: InventoryItemService,
587
+ query: { projectId: PROJECT_ID, entityKey: "k" },
588
+ lastSeenAt: new Date(),
589
+ describe: "entity k",
590
+ rowFenceId: "p:k",
591
+ buildModel: row,
592
+ beforeCreate,
593
+ });
594
+
595
+ expect(gateCalls).toBe(0);
596
+ expect(InventoryItemService.create).not.toHaveBeenCalled();
597
+ });
598
+
599
+ test("a missing row is created from buildModel with root props once the gate passes", async () => {
600
+ (InventoryItemService.findOneBy as jest.Mock).mockResolvedValue(
601
+ null as never,
602
+ );
603
+ const model: InventoryItem = row();
604
+
605
+ await reconcileByNaturalKey<InventoryItem>({
606
+ service: InventoryItemService,
607
+ query: { projectId: PROJECT_ID, entityKey: "k" },
608
+ lastSeenAt: new Date(),
609
+ describe: "entity k",
610
+ rowFenceId: "p:k",
611
+ buildModel: (): InventoryItem => {
612
+ return model;
613
+ },
614
+ beforeCreate: async (): Promise<boolean> => {
615
+ return true;
616
+ },
617
+ });
618
+
619
+ expect(InventoryItemService.create).toHaveBeenCalledWith({
620
+ data: model,
621
+ props: { isRoot: true },
622
+ });
623
+ expect(
624
+ InventoryItemService.updateColumnsByIdIfUnlockedWithoutHooks,
625
+ ).not.toHaveBeenCalled();
626
+ expect(GlobalCache.deleteKey).not.toHaveBeenCalled();
627
+ });
628
+
629
+ test("an over-budget gate creates nothing", async () => {
630
+ (InventoryItemService.findOneBy as jest.Mock).mockResolvedValue(
631
+ null as never,
632
+ );
633
+ let builds: number = 0;
634
+ const buildModel: () => InventoryItem = (): InventoryItem => {
635
+ builds++;
636
+ return row();
637
+ };
638
+
639
+ await reconcileByNaturalKey<InventoryItem>({
640
+ service: InventoryItemService,
641
+ query: { projectId: PROJECT_ID, entityKey: "k" },
642
+ lastSeenAt: new Date(),
643
+ describe: "entity k",
644
+ rowFenceId: "p:k",
645
+ buildModel,
646
+ beforeCreate: async (): Promise<boolean> => {
647
+ return false;
648
+ },
649
+ });
650
+
651
+ expect(builds).toBe(0);
652
+ expect(InventoryItemService.create).not.toHaveBeenCalled();
653
+ });
654
+
655
+ test("an invalid create is logged at warn with the row description", async () => {
656
+ (InventoryItemService.findOneBy as jest.Mock).mockResolvedValue(
657
+ null as never,
658
+ );
659
+ (InventoryItemService.create as jest.Mock).mockRejectedValue(
660
+ new Error("not-null violation") as never,
661
+ );
662
+
663
+ await expect(
664
+ reconcileByNaturalKey<InventoryItem>({
665
+ service: InventoryItemService,
666
+ query: { projectId: PROJECT_ID, entityKey: "k" },
667
+ lastSeenAt: new Date(),
668
+ describe: "entity host/abc",
669
+ rowFenceId: "p:k",
670
+ buildModel: row,
671
+ }),
672
+ ).resolves.toBeUndefined();
673
+
674
+ expect(logger.warn).toHaveBeenCalledWith(
675
+ "EntityRegistry: create failed for entity host/abc:",
676
+ );
677
+ });
678
+
679
+ test("a lost create race applies buildUpdate to the winning row", async () => {
680
+ const lastSeenAt: Date = new Date("2026-09-13T12:00:00.000Z");
681
+ const winner: InventoryItem = row();
682
+ winner.displayName = "winner";
683
+
684
+ (InventoryItemService.findOneBy as jest.Mock)
685
+ .mockResolvedValueOnce(null as never)
686
+ .mockResolvedValueOnce(winner as never);
687
+ (InventoryItemService.create as jest.Mock).mockRejectedValue(
688
+ new Error("duplicate key") as never,
689
+ );
690
+
691
+ await reconcileByNaturalKey<InventoryItem>({
692
+ service: InventoryItemService,
693
+ query: { projectId: PROJECT_ID, entityKey: "k" },
694
+ lastSeenAt,
695
+ describe: "entity k",
696
+ rowFenceId: "p:k",
697
+ buildModel: row,
698
+ buildUpdate: (existing: InventoryItem): any => {
699
+ return { displayName: `${existing.displayName}!` };
700
+ },
701
+ });
702
+
703
+ expect(
704
+ InventoryItemService.updateColumnsByIdIfUnlockedWithoutHooks,
705
+ ).toHaveBeenCalledWith({
706
+ id: ROW_ID,
707
+ data: { lastSeenAt, displayName: "winner!" },
708
+ });
709
+ expect(logger.warn).not.toHaveBeenCalled();
710
+ });
711
+ });