@oneuptime/common 12.0.10 → 12.0.11

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.
Files changed (81) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  6. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  7. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  8. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  9. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  10. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  11. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  12. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  13. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  14. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  15. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  16. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  17. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  18. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  19. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  20. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  21. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  22. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  23. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  24. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  25. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  26. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  27. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  28. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  29. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  30. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  31. package/Types/CustomField/CustomFieldType.ts +28 -0
  32. package/Types/Monitor/MonitorType.ts +11 -0
  33. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  34. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  35. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  36. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  37. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  38. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  39. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  40. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  41. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  42. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  43. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  44. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  45. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  46. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  51. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  52. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  53. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  54. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  55. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  56. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  58. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  59. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  60. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  61. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  62. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  63. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  64. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  68. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  70. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  72. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  74. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  75. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  76. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  77. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  78. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  79. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  80. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  81. package/package.json +1 -1
@@ -0,0 +1,312 @@
1
+ import CommonModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/CommonModel";
2
+ import AnalyticsTableColumn from "../../../Types/AnalyticsDatabase/TableColumn";
3
+ import TableColumnType from "../../../Types/AnalyticsDatabase/TableColumnType";
4
+ import BadDataException from "../../../Types/Exception/BadDataException";
5
+ import ObjectID from "../../../Types/ObjectID";
6
+
7
+ /*
8
+ * Unit tests for CommonModel — the base every analytics (ClickHouse) model
9
+ * extends. It owns the column-aware value coercion (setColumnValue) and the
10
+ * JSON (de)serialization that the analytics query/ingest layer relies on.
11
+ *
12
+ * Nothing imports CommonModel directly in the existing test suite, so its
13
+ * per-type string coercion and its static/instance JSON helpers were
14
+ * uncovered. The logic is pure: it reads column metadata off the instance,
15
+ * so no ClickHouse connection is required.
16
+ */
17
+
18
+ // Build a column of a given type. Only key + type matter for these tests.
19
+ const column: (key: string, type: TableColumnType) => AnalyticsTableColumn = (
20
+ key: string,
21
+ type: TableColumnType,
22
+ ): AnalyticsTableColumn => {
23
+ return new AnalyticsTableColumn({
24
+ key,
25
+ title: key,
26
+ description: `${key} column`,
27
+ required: false,
28
+ type,
29
+ });
30
+ };
31
+
32
+ const COLUMNS: Array<AnalyticsTableColumn> = [
33
+ column("name", TableColumnType.Text),
34
+ column("resourceId", TableColumnType.ObjectID),
35
+ column("startsAt", TableColumnType.Date),
36
+ column("observedAt", TableColumnType.DateTime64),
37
+ column("attributes", TableColumnType.JSON),
38
+ column("tags", TableColumnType.JSONArray),
39
+ column("count", TableColumnType.Number),
40
+ column("ratio", TableColumnType.Decimal),
41
+ column("bigCount", TableColumnType.LongNumber),
42
+ ];
43
+
44
+ /*
45
+ * A concrete model with a no-arg constructor so the static new type() paths
46
+ * (fromJSON / fromJSONArray) work.
47
+ */
48
+ class TestModel extends CommonModel {
49
+ public constructor() {
50
+ super({ tableColumns: COLUMNS });
51
+ }
52
+ }
53
+
54
+ const newModel: () => TestModel = (): TestModel => {
55
+ return new TestModel();
56
+ };
57
+
58
+ describe("CommonModel.getTableColumn", () => {
59
+ test("returns the column when it exists", () => {
60
+ const model: TestModel = newModel();
61
+ const col: AnalyticsTableColumn | null = model.getTableColumn("name");
62
+ expect(col).not.toBeNull();
63
+ expect(col!.key).toBe("name");
64
+ expect(col!.type).toBe(TableColumnType.Text);
65
+ });
66
+
67
+ test("returns null for an unknown column", () => {
68
+ expect(newModel().getTableColumn("doesNotExist")).toBeNull();
69
+ });
70
+
71
+ test("getTableColumns returns the full column set", () => {
72
+ expect(newModel().getTableColumns()).toHaveLength(COLUMNS.length);
73
+ });
74
+ });
75
+
76
+ describe("CommonModel.setColumnValue coercion by column type", () => {
77
+ test("stores a plain text value unchanged", () => {
78
+ const model: TestModel = newModel();
79
+ model.setColumnValue("name", "hello");
80
+ expect(model.getColumnValue("name")).toBe("hello");
81
+ });
82
+
83
+ test("wraps a string into an ObjectID for an ObjectID column", () => {
84
+ const model: TestModel = newModel();
85
+ const id: ObjectID = ObjectID.generate();
86
+ model.setColumnValue("resourceId", id.toString());
87
+ const stored: unknown = model.getColumnValue("resourceId");
88
+ expect(stored).toBeInstanceOf(ObjectID);
89
+ expect((stored as ObjectID).toString()).toBe(id.toString());
90
+ });
91
+
92
+ test("parses a string into a Date for Date and DateTime64 columns", () => {
93
+ const model: TestModel = newModel();
94
+ model.setColumnValue("startsAt", "2023-01-15T12:30:00.000Z");
95
+ model.setColumnValue("observedAt", "2023-06-01T00:00:00.000Z");
96
+ expect(model.getColumnValue("startsAt")).toBeInstanceOf(Date);
97
+ expect(model.getColumnValue("observedAt")).toBeInstanceOf(Date);
98
+ });
99
+
100
+ test("parses a JSON string for a JSON column", () => {
101
+ const model: TestModel = newModel();
102
+ model.setColumnValue("attributes", '{"env":"prod","n":5}');
103
+ expect(model.getColumnValue("attributes")).toEqual({
104
+ env: "prod",
105
+ n: 5,
106
+ });
107
+ });
108
+
109
+ test("falls back to an empty object when the JSON string is invalid", () => {
110
+ const model: TestModel = newModel();
111
+ model.setColumnValue("attributes", "not json {");
112
+ expect(model.getColumnValue("attributes")).toEqual({});
113
+ });
114
+
115
+ test("parses a JSON array string for a JSONArray column", () => {
116
+ const model: TestModel = newModel();
117
+ model.setColumnValue("tags", "[1, 2, 3]");
118
+ expect(model.getColumnValue("tags")).toEqual([1, 2, 3]);
119
+ });
120
+
121
+ test("falls back to an empty array when the JSONArray string is invalid", () => {
122
+ const model: TestModel = newModel();
123
+ model.setColumnValue("tags", "definitely not an array");
124
+ expect(model.getColumnValue("tags")).toEqual([]);
125
+ });
126
+
127
+ test("parses a string into an integer for a Number column", () => {
128
+ const model: TestModel = newModel();
129
+ model.setColumnValue("count", "42");
130
+ expect(model.getColumnValue("count")).toBe(42);
131
+ });
132
+
133
+ test("parses a string into a float for a Decimal column", () => {
134
+ const model: TestModel = newModel();
135
+ model.setColumnValue("ratio", "3.14");
136
+ expect(model.getColumnValue("ratio")).toBe(3.14);
137
+ });
138
+
139
+ test("parses a string into an integer for a LongNumber column", () => {
140
+ const model: TestModel = newModel();
141
+ model.setColumnValue("bigCount", "1000");
142
+ expect(model.getColumnValue("bigCount")).toBe(1000);
143
+ });
144
+
145
+ test("leaves an already-numeric value on a Number column unchanged", () => {
146
+ const model: TestModel = newModel();
147
+ model.setColumnValue("count", 7);
148
+ expect(model.getColumnValue("count")).toBe(7);
149
+ });
150
+
151
+ test("throws BadDataException when setting an unknown column", () => {
152
+ const model: TestModel = newModel();
153
+ expect(() => {
154
+ model.setColumnValue("nope", "x");
155
+ }).toThrow(BadDataException);
156
+ });
157
+ });
158
+
159
+ describe("CommonModel.getColumnValue", () => {
160
+ test("returns undefined for an unknown column", () => {
161
+ expect(newModel().getColumnValue("nope")).toBeUndefined();
162
+ });
163
+
164
+ test("returns undefined for a known-but-unset column", () => {
165
+ expect(newModel().getColumnValue("name")).toBeUndefined();
166
+ });
167
+ });
168
+
169
+ describe("CommonModel.fromJSON (instance) and toJSON", () => {
170
+ test("fromJSON populates every provided column with coercion", () => {
171
+ const model: CommonModel = newModel().fromJSON({
172
+ name: "widget",
173
+ count: "10",
174
+ ratio: "2.5",
175
+ });
176
+
177
+ expect(model.getColumnValue("name")).toBe("widget");
178
+ expect(model.getColumnValue("count")).toBe(10);
179
+ expect(model.getColumnValue("ratio")).toBe(2.5);
180
+ });
181
+
182
+ test("toJSON emits set columns and omits unset ones", () => {
183
+ const model: TestModel = newModel();
184
+ model.setColumnValue("name", "widget");
185
+ model.setColumnValue("count", 3);
186
+
187
+ const json: Record<string, unknown> = model.toJSON();
188
+
189
+ expect(json["name"]).toBe("widget");
190
+ expect(json["count"]).toBe(3);
191
+ // Unset columns serialize to undefined, which JSONFunctions.serialize drops.
192
+ expect(Object.prototype.hasOwnProperty.call(json, "ratio")).toBe(false);
193
+ expect(json["ratio"]).toBeUndefined();
194
+ });
195
+
196
+ test("toJSON recursively serializes a nested CommonModel value", () => {
197
+ const parent: TestModel = newModel();
198
+ const child: TestModel = newModel();
199
+ child.setColumnValue("name", "child");
200
+
201
+ /*
202
+ * A non-string value on a column is stored as-is, so a nested model
203
+ * survives to toJSON where it is recursively serialized.
204
+ */
205
+ parent.setColumnValue("attributes", child as unknown as never);
206
+
207
+ const json: Record<string, unknown> = parent.toJSON();
208
+ expect((json["attributes"] as Record<string, unknown>)["name"]).toBe(
209
+ "child",
210
+ );
211
+ });
212
+ });
213
+
214
+ describe("CommonModel static fromJSON / toJSON", () => {
215
+ test("builds a model instance from a plain JSON object", () => {
216
+ const model: CommonModel = CommonModel.fromJSON(
217
+ { name: "from-json" },
218
+ TestModel,
219
+ ) as CommonModel;
220
+
221
+ expect(model).toBeInstanceOf(TestModel);
222
+ expect(model.getColumnValue("name")).toBe("from-json");
223
+ });
224
+
225
+ test("returns an already-constructed model unchanged", () => {
226
+ const existing: TestModel = newModel();
227
+ existing.setColumnValue("name", "existing");
228
+
229
+ const result: CommonModel = CommonModel.fromJSON(
230
+ existing,
231
+ TestModel,
232
+ ) as CommonModel;
233
+
234
+ expect(result).toBe(existing);
235
+ });
236
+
237
+ test("builds an array of models, passing through existing instances", () => {
238
+ const existing: TestModel = newModel();
239
+ existing.setColumnValue("name", "kept");
240
+
241
+ const result: Array<CommonModel> = CommonModel.fromJSON(
242
+ [{ name: "a" }, existing, { name: "b" }] as never,
243
+ TestModel,
244
+ ) as Array<CommonModel>;
245
+
246
+ expect(result).toHaveLength(3);
247
+ expect(result[0]!.getColumnValue("name")).toBe("a");
248
+ expect(result[1]).toBe(existing);
249
+ expect(result[2]!.getColumnValue("name")).toBe("b");
250
+ });
251
+
252
+ test("static toJSON delegates to the instance toJSON", () => {
253
+ const model: TestModel = newModel();
254
+ model.setColumnValue("name", "delegated");
255
+
256
+ const json: Record<string, unknown> = CommonModel.toJSON(model, TestModel);
257
+ expect(json["name"]).toBe("delegated");
258
+ });
259
+ });
260
+
261
+ describe("CommonModel static fromJSONArray / toJSONArray", () => {
262
+ test("fromJSONArray builds instances and passes through existing ones", () => {
263
+ const existing: TestModel = newModel();
264
+ existing.setColumnValue("name", "kept");
265
+
266
+ const models: Array<CommonModel> = CommonModel.fromJSONArray(
267
+ [{ name: "first" }, existing],
268
+ TestModel,
269
+ );
270
+
271
+ expect(models).toHaveLength(2);
272
+ expect(models[0]!.getColumnValue("name")).toBe("first");
273
+ expect(models[1]).toBe(existing);
274
+ });
275
+
276
+ test("toJSONArray round-trips a list of models to JSON objects", () => {
277
+ const a: TestModel = newModel();
278
+ a.setColumnValue("name", "a");
279
+ const b: TestModel = newModel();
280
+ b.setColumnValue("name", "b");
281
+
282
+ const jsonArray: Array<Record<string, unknown>> = CommonModel.toJSONArray(
283
+ [a, b],
284
+ TestModel,
285
+ );
286
+
287
+ expect(jsonArray).toHaveLength(2);
288
+ expect(jsonArray[0]!["name"]).toBe("a");
289
+ expect(jsonArray[1]!["name"]).toBe("b");
290
+ });
291
+
292
+ test("fromJSONArray then toJSONArray preserves scalar values", () => {
293
+ const input: Array<Record<string, unknown>> = [
294
+ { name: "x", count: 1 },
295
+ { name: "y", count: 2 },
296
+ ];
297
+
298
+ const models: Array<CommonModel> = CommonModel.fromJSONArray(
299
+ input as never,
300
+ TestModel,
301
+ );
302
+ const output: Array<Record<string, unknown>> = CommonModel.toJSONArray(
303
+ models,
304
+ TestModel,
305
+ );
306
+
307
+ expect(output[0]!["name"]).toBe("x");
308
+ expect(output[0]!["count"]).toBe(1);
309
+ expect(output[1]!["name"]).toBe("y");
310
+ expect(output[1]!["count"]).toBe(2);
311
+ });
312
+ });
@@ -0,0 +1,250 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import SchemaMigrations from "../../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
5
+ import { AddDeviceRoleAndDeclaredLinkParent1787400000000 } from "../../../../Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent";
6
+
7
+ /*
8
+ * The migration behind #3192: NetworkDevice.deviceRole and
9
+ * NetworkDeviceLink.parentDeviceId.
10
+ *
11
+ * Two things are worth pinning about it, and neither is the SQL — the
12
+ * statements were generated rather than written, and the schema-drift job
13
+ * already proves they match the entities.
14
+ *
15
+ * The first is NULLABILITY, because it carries meaning here rather than
16
+ * just permissiveness. NULL is not "unset pending a backfill": on
17
+ * deviceRole it means "no override, let the classifier decide", and on
18
+ * parentDeviceId it means "peers, infer the direction". They are the
19
+ * values every existing row should hold, and a DEFAULT or a NOT NULL on
20
+ * either would silently restate every device and every link in every
21
+ * project as something an operator had chosen.
22
+ *
23
+ * The second is ORDERING. This is the newest registered migration, so it
24
+ * inherits the guard that used to live in
25
+ * EpisodeMemberNotifyIndexesMigration.test.ts: its timestamp must sort
26
+ * above every other. A migration generated with `generate-postgres-
27
+ * migration` carries a WALL-CLOCK timestamp, and the recent migrations in
28
+ * this repo use hand-picked round numbers that run ahead of it — so the
29
+ * generated file lands below several already-registered migrations unless
30
+ * somebody renumbers it. That is exactly what happened to this one.
31
+ * Whoever adds the next migration should move this test's ordering block
32
+ * to theirs.
33
+ */
34
+
35
+ const MIGRATION_PATH: string = path.join(
36
+ __dirname,
37
+ "../../../../Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts",
38
+ );
39
+
40
+ const SOURCE: string = fs.readFileSync(MIGRATION_PATH, "utf8");
41
+
42
+ interface MigrationClass {
43
+ name: string;
44
+ }
45
+
46
+ function timestampOf(migrationClass: MigrationClass): number | null {
47
+ const match: RegExpMatchArray | null = migrationClass.name.match(/(\d+)$/);
48
+ return match ? Number(match[1]) : null;
49
+ }
50
+
51
+ describe("the columns it adds", () => {
52
+ test("NetworkDevice gains deviceRole", () => {
53
+ expect(SOURCE).toContain(
54
+ `ALTER TABLE "NetworkDevice" ADD "deviceRole" character varying(100)`,
55
+ );
56
+ });
57
+
58
+ test("NetworkDeviceLink gains parentDeviceId", () => {
59
+ expect(SOURCE).toContain(
60
+ `ALTER TABLE "NetworkDeviceLink" ADD "parentDeviceId" uuid`,
61
+ );
62
+ });
63
+
64
+ /*
65
+ * See the header: NULL is a meaningful value on both, so neither may
66
+ * arrive with a DEFAULT or a NOT NULL.
67
+ */
68
+ test("neither column is given a default", () => {
69
+ const addStatements: Array<string> = [
70
+ ...SOURCE.matchAll(/`(ALTER TABLE [^`]*ADD "[^"]+"[^`]*)`/g),
71
+ ].map((match: RegExpMatchArray): string => {
72
+ return match[1] as string;
73
+ });
74
+
75
+ expect(addStatements.length).toBe(2);
76
+ for (const statement of addStatements) {
77
+ expect(statement).not.toContain("DEFAULT");
78
+ expect(statement).not.toContain("NOT NULL");
79
+ }
80
+ });
81
+
82
+ test("the parent is indexed and cascades with the device it names", () => {
83
+ expect(SOURCE).toContain(
84
+ `CREATE INDEX "IDX_01752cd47b773df38b4a54ae53" ON "NetworkDeviceLink" ("parentDeviceId")`,
85
+ );
86
+ expect(SOURCE).toContain(
87
+ `FOREIGN KEY ("parentDeviceId") REFERENCES "NetworkDevice"("_id") ON DELETE CASCADE`,
88
+ );
89
+ });
90
+
91
+ /*
92
+ * The parent is always one of the link's own two ends, and both of those
93
+ * already cascade — so this cascade only ever fires alongside one of
94
+ * them. Anything weaker would leave a link pointing at a deleted device.
95
+ */
96
+ test("it touches nothing beyond those two tables", () => {
97
+ const touchedTables: Set<string> = new Set<string>(
98
+ [...SOURCE.matchAll(/ALTER TABLE "(\w+)"/g)].map(
99
+ (match: RegExpMatchArray): string => {
100
+ return match[1] as string;
101
+ },
102
+ ),
103
+ );
104
+
105
+ expect([...touchedTables].sort()).toEqual([
106
+ "NetworkDevice",
107
+ "NetworkDeviceLink",
108
+ ]);
109
+ });
110
+ });
111
+
112
+ describe("up and down are symmetric", () => {
113
+ test("down drops everything up adds, and nothing else", () => {
114
+ const added: Array<string> = [...SOURCE.matchAll(/ADD "(\w+)"/g)].map(
115
+ (match: RegExpMatchArray): string => {
116
+ return match[1] as string;
117
+ },
118
+ );
119
+ const dropped: Array<string> = [
120
+ ...SOURCE.matchAll(/DROP COLUMN "(\w+)"/g),
121
+ ].map((match: RegExpMatchArray): string => {
122
+ return match[1] as string;
123
+ });
124
+
125
+ expect(added.sort()).toEqual(dropped.sort());
126
+ });
127
+
128
+ test("the index and the constraint are dropped too", () => {
129
+ expect(SOURCE).toContain(
130
+ `DROP INDEX "public"."IDX_01752cd47b773df38b4a54ae53"`,
131
+ );
132
+ expect(SOURCE).toContain(
133
+ `DROP CONSTRAINT "FK_01752cd47b773df38b4a54ae539"`,
134
+ );
135
+ });
136
+
137
+ /*
138
+ * A stray semicolon would split one queryRunner.query() into two.
139
+ *
140
+ * Trailing whitespace is tolerated on purpose: TypeORM's generator ends
141
+ * a CREATE INDEX with no WHERE clause in a single space, and this file
142
+ * is generated. Normalising it here would mean the checked-in SQL no
143
+ * longer matched what `npm run generate-postgres-migration` produces,
144
+ * and the next person to regenerate would see a spurious diff. Newlines
145
+ * are a different matter — one inside a statement means the template
146
+ * literal wrapped, and that is worth catching.
147
+ */
148
+ test("every statement is a complete, single statement", () => {
149
+ for (const statement of [
150
+ ...SOURCE.matchAll(/`((?:ALTER TABLE|CREATE INDEX|DROP INDEX)[^`]+)`/g),
151
+ ]) {
152
+ expect(statement[1]).not.toContain(";");
153
+ expect(statement[1]).not.toContain("\n");
154
+ expect(statement[1]!.trimStart()).toBe(statement[1]);
155
+ }
156
+ });
157
+ });
158
+
159
+ describe("the migration runs", () => {
160
+ test("it is registered", () => {
161
+ const index: string = fs.readFileSync(
162
+ path.join(path.dirname(MIGRATION_PATH), "Index.ts"),
163
+ "utf8",
164
+ );
165
+
166
+ expect(index).toContain(
167
+ 'from "./1787400000000-AddDeviceRoleAndDeclaredLinkParent"',
168
+ );
169
+ expect(index).toContain("AddDeviceRoleAndDeclaredLinkParent1787400000000,");
170
+ });
171
+
172
+ /*
173
+ * ...and registered for real, not just mentioned in the file: an import
174
+ * that never reaches the default-export array leaves the migration
175
+ * unregistered, and it silently never runs.
176
+ */
177
+ test("it is in the exported migration list", () => {
178
+ expect(SchemaMigrations).toContain(
179
+ AddDeviceRoleAndDeclaredLinkParent1787400000000,
180
+ );
181
+ });
182
+
183
+ /*
184
+ * TypeORM records applied migrations by the `name` property, not the file
185
+ * name. A mismatch re-runs the migration on every boot.
186
+ */
187
+ test("its declared name matches its class name", () => {
188
+ expect(new AddDeviceRoleAndDeclaredLinkParent1787400000000().name).toBe(
189
+ "AddDeviceRoleAndDeclaredLinkParent1787400000000",
190
+ );
191
+ });
192
+
193
+ // The guard described in the header. Hand it to the next migration.
194
+ test("its timestamp sorts after every other registered migration", () => {
195
+ const ourTimestamp: number | null = timestampOf(
196
+ AddDeviceRoleAndDeclaredLinkParent1787400000000,
197
+ );
198
+
199
+ expect(ourTimestamp).not.toBeNull();
200
+
201
+ const otherTimestamps: Array<number> = [];
202
+
203
+ for (const migrationClass of SchemaMigrations) {
204
+ if (migrationClass === AddDeviceRoleAndDeclaredLinkParent1787400000000) {
205
+ continue;
206
+ }
207
+
208
+ const timestamp: number | null = timestampOf(
209
+ migrationClass as MigrationClass,
210
+ );
211
+
212
+ if (timestamp !== null) {
213
+ otherTimestamps.push(timestamp);
214
+ }
215
+ }
216
+
217
+ /*
218
+ * Math.max() of an empty list is -Infinity, which every timestamp beats.
219
+ * Prove the list was actually enumerated before leaning on the maximum.
220
+ */
221
+ expect(otherTimestamps.length).toBeGreaterThan(100);
222
+
223
+ expect(ourTimestamp).toBeGreaterThan(Math.max(...otherTimestamps));
224
+ });
225
+
226
+ test("it is registered last, matching that timestamp", () => {
227
+ expect(SchemaMigrations[SchemaMigrations.length - 1]).toBe(
228
+ AddDeviceRoleAndDeclaredLinkParent1787400000000,
229
+ );
230
+ });
231
+
232
+ /*
233
+ * ...and the timestamp in the class name is the one on disk. A class
234
+ * renamed without renaming its file (or two migrations landing on the
235
+ * same timestamp) leaves the ordering above asserting something that is
236
+ * not what actually runs.
237
+ */
238
+ test("exactly one file on disk carries its timestamp, and it is this one", () => {
239
+ const directory: string = path.dirname(MIGRATION_PATH);
240
+ const matching: Array<string> = fs
241
+ .readdirSync(directory)
242
+ .filter((file: string): boolean => {
243
+ return file.startsWith("1787400000000-");
244
+ });
245
+
246
+ expect(matching).toEqual([
247
+ "1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts",
248
+ ]);
249
+ });
250
+ });
@@ -444,10 +444,31 @@ describe("the migration runs", () => {
444
444
  );
445
445
  });
446
446
 
447
- test("it is appended last, matching its timestamp", () => {
448
- expect(SchemaMigrations[SchemaMigrations.length - 1]).toBe(
447
+ /*
448
+ * Registered in timestamp order relative to whatever follows it.
449
+ *
450
+ * This asserted "it is last in the array" until a second migration was
451
+ * added behind it, which is a thing that happens to every migration
452
+ * exactly once and is not what the test was ever about. The invariant
453
+ * worth holding is the one the "matching its timestamp" half named:
454
+ * nothing registered AFTER this may carry an earlier timestamp, because
455
+ * the array is how a reader works out what runs when. A newly generated
456
+ * migration carries a wall-clock timestamp, which can easily fall below
457
+ * the hand-picked round numbers the recent ones use — appending it
458
+ * without renumbering is how the two orders drift apart.
459
+ */
460
+ test("nothing registered after it carries an earlier timestamp", () => {
461
+ const position: number = SchemaMigrations.indexOf(
449
462
  AddEpisodeMemberNotifyIndexes1787300000000,
450
463
  );
464
+ expect(position).toBeGreaterThan(-1);
465
+
466
+ for (const migration of SchemaMigrations.slice(position + 1)) {
467
+ const timestamp: number = Number(
468
+ (migration.name.match(/(\d+)$/) || [])[1],
469
+ );
470
+ expect(timestamp).toBeGreaterThan(1787300000000);
471
+ }
451
472
  });
452
473
 
453
474
  /*
@@ -461,34 +482,41 @@ describe("the migration runs", () => {
461
482
  });
462
483
 
463
484
  /*
464
- * Migrations execute in timestamp order, and TypeORM will not re-order an
465
- * already-applied set: a migration added with a timestamp below one that
466
- * has already run is simply skipped on every existing deployment, so the
467
- * index never appears in production and only ever shows up on fresh
468
- * installs. Both sides of the comparison are therefore read off the
469
- * registered classes themselves rename this migration to a lower
470
- * timestamp, or land another one above it, and this fails.
485
+ * Migrations execute in timestamp order, so one landing BELOW a migration
486
+ * that has already run applies out of order — and on a fresh install runs
487
+ * in a different order than it did in production, which is how a schema
488
+ * that passes drift locally still diverges between deployments.
489
+ *
490
+ * This used to read "above every OTHER registered migration", i.e. that
491
+ * this was the newest. That guard belongs to whichever migration is
492
+ * currently newest — see
493
+ * DeviceRoleAndDeclaredLinkParentMigration.test.ts, which now carries it
494
+ * — and it cannot live here, because it fails by design the moment
495
+ * anything lands above it. What is permanently true of this migration,
496
+ * and is the half that protects an existing deployment, is that nothing
497
+ * registered BEFORE it carries a later timestamp.
471
498
  */
472
- test("its timestamp sorts after every other registered migration", () => {
499
+ test("its timestamp sorts after every migration registered before it", () => {
473
500
  const ourTimestamp: number | null = timestampOf(
474
501
  AddEpisodeMemberNotifyIndexes1787300000000,
475
502
  );
476
503
 
477
504
  expect(ourTimestamp).not.toBeNull();
478
505
 
479
- const otherTimestamps: Array<number> = [];
506
+ const position: number = SchemaMigrations.indexOf(
507
+ AddEpisodeMemberNotifyIndexes1787300000000,
508
+ );
509
+ expect(position).toBeGreaterThan(-1);
480
510
 
481
- for (const migrationClass of SchemaMigrations) {
482
- if (migrationClass === AddEpisodeMemberNotifyIndexes1787300000000) {
483
- continue;
484
- }
511
+ const earlierTimestamps: Array<number> = [];
485
512
 
513
+ for (const migrationClass of SchemaMigrations.slice(0, position)) {
486
514
  const timestamp: number | null = timestampOf(
487
515
  migrationClass as MigrationClass,
488
516
  );
489
517
 
490
518
  if (timestamp !== null) {
491
- otherTimestamps.push(timestamp);
519
+ earlierTimestamps.push(timestamp);
492
520
  }
493
521
  }
494
522
 
@@ -497,9 +525,9 @@ describe("the migration runs", () => {
497
525
  * Prove the list was actually enumerated before leaning on the maximum,
498
526
  * or the assertion below is vacuous again.
499
527
  */
500
- expect(otherTimestamps.length).toBeGreaterThan(100);
528
+ expect(earlierTimestamps.length).toBeGreaterThan(100);
501
529
 
502
- expect(ourTimestamp).toBeGreaterThan(Math.max(...otherTimestamps));
530
+ expect(ourTimestamp).toBeGreaterThan(Math.max(...earlierTimestamps));
503
531
  });
504
532
 
505
533
  /*