@openinc/parse-server-opendash 4.1.1 → 4.1.2

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 (163) hide show
  1. package/dist/featuremap.json +27 -12
  2. package/dist/features/config/helper/customOptions.js +9 -0
  3. package/dist/features/config/helper/isFeatureEnabled.d.ts +1 -1
  4. package/dist/features/config/helper/isFeatureEnabled.js +2 -0
  5. package/dist/features/meta/index.d.ts +1 -0
  6. package/dist/features/meta/index.js +5 -0
  7. package/dist/features/meta/services/registerMetaContext.d.ts +29 -0
  8. package/dist/features/meta/services/registerMetaContext.js +208 -0
  9. package/dist/features/permissions/types/Permissions.d.ts +24 -1
  10. package/dist/features/permissions/types/Permissions.js +34 -0
  11. package/dist/features/ruleset/applyRuleSet.d.ts +41 -0
  12. package/dist/features/ruleset/applyRuleSet.js +81 -0
  13. package/dist/features/schema/helper/defaultHandler.js +13 -0
  14. package/dist/features/schema/helper/migrationMode.d.ts +26 -0
  15. package/dist/features/schema/helper/migrationMode.js +48 -0
  16. package/dist/features/schema/index.d.ts +1 -0
  17. package/dist/features/schema/index.js +3 -1
  18. package/dist/features/schema/services/beforeSaveHook.js +21 -0
  19. package/dist/features/translations/services/initTranslation.js +1 -1
  20. package/dist/hooks/Chat_Message.d.ts +1 -0
  21. package/dist/hooks/Chat_Message.js +179 -0
  22. package/dist/hooks/Meta_Config.js +19 -10
  23. package/dist/hooks/Meta_Context.d.ts +6 -0
  24. package/dist/hooks/Meta_Context.js +16 -0
  25. package/dist/hooks/Meta_Entry.js +20 -10
  26. package/dist/hooks/Service_Equipment.d.ts +1 -0
  27. package/dist/hooks/Service_Equipment.js +38 -0
  28. package/dist/hooks/Service_FormConfig.d.ts +1 -0
  29. package/dist/hooks/Service_FormConfig.js +17 -0
  30. package/dist/hooks/Service_Project.d.ts +1 -0
  31. package/dist/hooks/Service_Project.js +17 -0
  32. package/dist/hooks/Service_Schedule.d.ts +1 -0
  33. package/dist/hooks/Service_Schedule.js +24 -0
  34. package/dist/hooks/Service_Schedule_Execution.d.ts +1 -0
  35. package/dist/hooks/Service_Schedule_Execution.js +17 -0
  36. package/dist/hooks/Service_Schedule_Template.d.ts +1 -0
  37. package/dist/hooks/Service_Schedule_Template.js +17 -0
  38. package/dist/hooks/Service_Status.d.ts +1 -0
  39. package/dist/hooks/Service_Status.js +32 -0
  40. package/dist/hooks/Service_Tag.d.ts +1 -0
  41. package/dist/hooks/Service_Tag.js +168 -0
  42. package/dist/hooks/Service_Ticket.d.ts +1 -0
  43. package/dist/hooks/Service_Ticket.js +433 -0
  44. package/dist/hooks/Service_TriggerState.d.ts +1 -0
  45. package/dist/hooks/Service_TriggerState.js +17 -0
  46. package/dist/hooks/_ChangeLog.js +27 -6
  47. package/dist/i18n/de-DE/chat.json +18 -0
  48. package/dist/i18n/de-DE/openservice.json +14 -0
  49. package/dist/i18n/en-US/chat.json +18 -0
  50. package/dist/i18n/en-US/openservice.json +14 -0
  51. package/dist/scripts/backfill-meta-contexts.d.ts +1 -0
  52. package/dist/scripts/backfill-meta-contexts.js +100 -0
  53. package/dist/scripts/migration/lib/acl.d.ts +52 -0
  54. package/dist/scripts/migration/lib/acl.js +79 -0
  55. package/dist/scripts/migration/lib/articleMeta.d.ts +47 -0
  56. package/dist/scripts/migration/lib/articleMeta.js +132 -0
  57. package/dist/scripts/migration/lib/env.d.ts +56 -0
  58. package/dist/scripts/migration/lib/env.js +106 -0
  59. package/dist/scripts/migration/lib/latest.d.ts +47 -0
  60. package/dist/scripts/migration/lib/latest.js +115 -0
  61. package/dist/scripts/migration/lib/map.d.ts +53 -0
  62. package/dist/scripts/migration/lib/map.js +113 -0
  63. package/dist/scripts/migration/lib/material.d.ts +47 -0
  64. package/dist/scripts/migration/lib/material.js +51 -0
  65. package/dist/scripts/migration/lib/message.d.ts +78 -0
  66. package/dist/scripts/migration/lib/message.js +115 -0
  67. package/dist/scripts/migration/lib/pass.d.ts +67 -0
  68. package/dist/scripts/migration/lib/pass.js +89 -0
  69. package/dist/scripts/migration/lib/ptr.d.ts +32 -0
  70. package/dist/scripts/migration/lib/ptr.js +38 -0
  71. package/dist/scripts/migration/lib/report.d.ts +41 -0
  72. package/dist/scripts/migration/lib/report.js +78 -0
  73. package/dist/scripts/migration/lib/rest.d.ts +47 -0
  74. package/dist/scripts/migration/lib/rest.js +68 -0
  75. package/dist/scripts/migration/lib/ticket.d.ts +72 -0
  76. package/dist/scripts/migration/lib/ticket.js +192 -0
  77. package/dist/scripts/migration/passes/01-statuses.d.ts +12 -0
  78. package/dist/scripts/migration/passes/01-statuses.js +122 -0
  79. package/dist/scripts/migration/passes/02-tags.d.ts +13 -0
  80. package/dist/scripts/migration/passes/02-tags.js +150 -0
  81. package/dist/scripts/migration/passes/03-equipment.d.ts +14 -0
  82. package/dist/scripts/migration/passes/03-equipment.js +176 -0
  83. package/dist/scripts/migration/passes/04-projects.d.ts +27 -0
  84. package/dist/scripts/migration/passes/04-projects.js +241 -0
  85. package/dist/scripts/migration/passes/05-articles.d.ts +25 -0
  86. package/dist/scripts/migration/passes/05-articles.js +203 -0
  87. package/dist/scripts/migration/passes/06-formconfigs.d.ts +17 -0
  88. package/dist/scripts/migration/passes/06-formconfigs.js +166 -0
  89. package/dist/scripts/migration/passes/07-meta-configs.d.ts +46 -0
  90. package/dist/scripts/migration/passes/07-meta-configs.js +185 -0
  91. package/dist/scripts/migration/passes/08-tickets.d.ts +27 -0
  92. package/dist/scripts/migration/passes/08-tickets.js +353 -0
  93. package/dist/scripts/migration/passes/09-ticket-meta.d.ts +12 -0
  94. package/dist/scripts/migration/passes/09-ticket-meta.js +136 -0
  95. package/dist/scripts/migration/passes/10-chat.d.ts +17 -0
  96. package/dist/scripts/migration/passes/10-chat.js +230 -0
  97. package/dist/scripts/migration/passes/11-assets.d.ts +17 -0
  98. package/dist/scripts/migration/passes/11-assets.js +93 -0
  99. package/dist/scripts/migration/passes/12-history.d.ts +24 -0
  100. package/dist/scripts/migration/passes/12-history.js +332 -0
  101. package/dist/scripts/migration/run.d.ts +1 -0
  102. package/dist/scripts/migration/run.js +148 -0
  103. package/dist/scripts/migration/verify.d.ts +1 -0
  104. package/dist/scripts/migration/verify.js +620 -0
  105. package/dist/scripts/seed-v1-testdata.d.ts +41 -0
  106. package/dist/scripts/seed-v1-testdata.js +543 -0
  107. package/dist/types/BDE_Article.d.ts +3 -0
  108. package/dist/types/BDE_Article.js +6 -0
  109. package/dist/types/Chat_Message.d.ts +49 -0
  110. package/dist/types/Chat_Message.js +87 -0
  111. package/dist/types/MES_RessourceOverview.d.ts +38 -0
  112. package/dist/types/MES_RessourceOverview.js +63 -0
  113. package/dist/types/Meta_Context.d.ts +43 -0
  114. package/dist/types/Meta_Context.js +75 -0
  115. package/dist/types/Service_Equipment.d.ts +33 -0
  116. package/dist/types/Service_Equipment.js +57 -0
  117. package/dist/types/Service_FormConfig.d.ts +33 -0
  118. package/dist/types/Service_FormConfig.js +57 -0
  119. package/dist/types/Service_MeterReading.d.ts +35 -0
  120. package/dist/types/Service_MeterReading.js +57 -0
  121. package/dist/types/Service_Project.d.ts +53 -0
  122. package/dist/types/Service_Project.js +93 -0
  123. package/dist/types/Service_Schedule.d.ts +66 -0
  124. package/dist/types/Service_Schedule.js +117 -0
  125. package/dist/types/Service_Schedule_Execution.d.ts +38 -0
  126. package/dist/types/Service_Schedule_Execution.js +63 -0
  127. package/dist/types/Service_Schedule_Template.d.ts +36 -0
  128. package/dist/types/Service_Schedule_Template.js +60 -0
  129. package/dist/types/Service_Status.d.ts +30 -0
  130. package/dist/types/Service_Status.js +51 -0
  131. package/dist/types/Service_Tag.d.ts +30 -0
  132. package/dist/types/Service_Tag.js +51 -0
  133. package/dist/types/Service_Ticket.d.ts +92 -0
  134. package/dist/types/Service_Ticket.js +165 -0
  135. package/dist/types/Service_TicketCounter.d.ts +18 -0
  136. package/dist/types/Service_TicketCounter.js +27 -0
  137. package/dist/types/Service_TriggerState.d.ts +31 -0
  138. package/dist/types/Service_TriggerState.js +51 -0
  139. package/dist/types/_Session.d.ts +0 -1
  140. package/dist/types/_User.d.ts +2 -6
  141. package/dist/types/custom/MetaField.d.ts +9 -0
  142. package/dist/types/custom/TicketMaterial.d.ts +32 -0
  143. package/dist/types/custom/TicketMaterial.js +2 -0
  144. package/dist/types/index.d.ts +30 -0
  145. package/dist/types/index.js +33 -3
  146. package/package.json +6 -3
  147. package/schema/BDE_Article.json +3 -0
  148. package/schema/Changelog.json +3 -6
  149. package/schema/Chat_Message.json +88 -0
  150. package/schema/Meta_Context.json +78 -0
  151. package/schema/Service_Equipment.json +55 -0
  152. package/schema/Service_FormConfig.json +66 -0
  153. package/schema/Service_MeterReading.json +54 -0
  154. package/schema/Service_MigrationMap.json +55 -0
  155. package/schema/Service_Project.json +77 -0
  156. package/schema/Service_Schedule.json +101 -0
  157. package/schema/Service_Schedule_Execution.json +58 -0
  158. package/schema/Service_Schedule_Template.json +73 -0
  159. package/schema/Service_Status.json +50 -0
  160. package/schema/Service_Tag.json +50 -0
  161. package/schema/Service_Ticket.json +114 -0
  162. package/schema/Service_TicketCounter.json +38 -0
  163. package/schema/Service_TriggerState.json +50 -0
@@ -0,0 +1,620 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_1 = __importDefault(require("parse/node"));
7
+ const index_js_1 = require("../../types/index.js");
8
+ const env_js_1 = require("./lib/env.js");
9
+ const message_js_1 = require("./lib/message.js");
10
+ const pass_js_1 = require("./lib/pass.js");
11
+ /** Pointer/array-of-ids fields to resolve, and the class they must resolve into. */
12
+ const REFERENCE_FIELDS = [
13
+ { field: "status", className: index_js_1.Service_Status.className },
14
+ { field: "equipment", className: index_js_1.Service_Equipment.className },
15
+ { field: "project", className: index_js_1.Service_Project.className },
16
+ { field: "tags", className: index_js_1.Service_Tag.className, array: true },
17
+ ];
18
+ /**
19
+ * How many objects the migration created for a given v1 class.
20
+ *
21
+ * Comparing raw table counts (`Maintenance_Ticket` vs `Service_Ticket`) looks
22
+ * equivalent but drifts: the aftercare step deliberately *adds* v2 Statuses for the
23
+ * `pending` / `cancelled` categories v1 never had, and any ticket created in v2
24
+ * before this runs inflates the other side. Both would fail a hard check for
25
+ * entirely correct reasons.
26
+ *
27
+ * Counting `OD3_Service_MigrationMap` rows instead asks the question that actually
28
+ * matters — "was every v1 record migrated?" — and is immune to anything added
29
+ * afterwards.
30
+ */
31
+ async function migratedCount(sourceClass, batchSize) {
32
+ return (0, pass_js_1.countAll)(() => new node_1.default.Query("OD3_Service_MigrationMap").equalTo("sourceClass", sourceClass), batchSize);
33
+ }
34
+ /**
35
+ * Render a snapshot value for comparison.
36
+ *
37
+ * The replay check used to compare with `String(...)`, which is fine for
38
+ * scalars and pointers but collapses every array-of-objects column to
39
+ * "[object Object]" — so a `material` mismatch would have compared equal to
40
+ * itself and the check would have passed while proving nothing.
41
+ *
42
+ * Objects are stringified with their keys sorted, because the fold and the
43
+ * replay build their entries independently and key order carries no meaning.
44
+ */
45
+ function comparable(value) {
46
+ const seen = new WeakSet();
47
+ const norm = (v) => {
48
+ if (v === null || typeof v !== "object")
49
+ return v;
50
+ if (seen.has(v))
51
+ return "[circular]";
52
+ seen.add(v);
53
+ if (Array.isArray(v))
54
+ return v.map(norm);
55
+ const obj = v;
56
+ // Parse wire types carry their identity in `__type`; compare pointers by id.
57
+ if (obj.__type === "Pointer")
58
+ return `Pointer:${String(obj.objectId)}`;
59
+ if (obj.__type === "Date")
60
+ return `Date:${String(obj.iso)}`;
61
+ return Object.fromEntries(Object.keys(obj)
62
+ .sort()
63
+ .filter((k) => obj[k] !== undefined)
64
+ .map((k) => [k, norm(obj[k])]));
65
+ };
66
+ return JSON.stringify(norm(value));
67
+ }
68
+ const checks = [
69
+ // ── Counts ───────────────────────────────────────────────────────────────
70
+ {
71
+ name: "every v1 ticket was migrated (disabled ones arrive as deleted)",
72
+ hard: true,
73
+ async run(tenantId, batchSize) {
74
+ const [v1, migrated] = await Promise.all([
75
+ (0, pass_js_1.countAll)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Maintenance_Ticket), tenantId), batchSize),
76
+ migratedCount(index_js_1.Maintenance_Ticket.className, batchSize),
77
+ ]);
78
+ // Note: the map is not tenant-scoped, so this is only exact for a
79
+ // whole-database run. With --tenant it is a lower bound, which still catches
80
+ // wholesale loss (the failure mode that matters).
81
+ return migrated >= v1
82
+ ? undefined
83
+ : `${v1} v1 ticket(s) but only ${migrated} mapping(s) — ${v1 - migrated} not migrated`;
84
+ },
85
+ },
86
+ {
87
+ name: "every v1 kanban state was migrated",
88
+ hard: true,
89
+ async run(tenantId, batchSize) {
90
+ const [v1, migrated] = await Promise.all([
91
+ (0, pass_js_1.countAll)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Maintenance_Kanban_State), tenantId), batchSize),
92
+ migratedCount(index_js_1.Maintenance_Kanban_State.className, batchSize),
93
+ ]);
94
+ return migrated >= v1
95
+ ? undefined
96
+ : `${v1} v1 kanban state(s) but only ${migrated} mapping(s)`;
97
+ },
98
+ },
99
+ {
100
+ name: "comment count matches migrated chat messages",
101
+ hard: false,
102
+ async run(tenantId, batchSize) {
103
+ let comments = 0;
104
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Maintenance_Message), tenantId), batchSize)) {
105
+ // Same predicates pass 10 uses, imported rather than re-inlined — a
106
+ // divergence here would make the check disagree with the pass it verifies.
107
+ for (const m of page) {
108
+ if (!(0, message_js_1.isTodo)(m) && !(0, message_js_1.isSystemGenerated)(m))
109
+ comments += 1;
110
+ }
111
+ }
112
+ const migrated = await (0, pass_js_1.countAll)(() => new node_1.default.Query(index_js_1.Chat_Message).startsWith("context", '["service","ticket"'), batchSize);
113
+ // Not a hard check: v1 messages can reference non-ticket objects, which are
114
+ // legitimately skipped.
115
+ return comments === migrated
116
+ ? undefined
117
+ : `${comments} human v1 comment(s) vs ${migrated} chat message(s) — the ` +
118
+ `difference should equal pass 10's "not a migrated ticket" skips`;
119
+ },
120
+ },
121
+ // ── Required fields ──────────────────────────────────────────────────────
122
+ {
123
+ name: "every ticket has status, tenant, title and number",
124
+ hard: true,
125
+ async run(tenantId, batchSize) {
126
+ const missing = [];
127
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
128
+ for (const t of page) {
129
+ const gaps = [];
130
+ if (!(0, pass_js_1.refId)(t.get("status")))
131
+ gaps.push("status");
132
+ if (!(0, pass_js_1.refId)(t.get("tenant")))
133
+ gaps.push("tenant");
134
+ if (!t.get("title"))
135
+ gaps.push("title");
136
+ if (typeof t.get("number") !== "number")
137
+ gaps.push("number");
138
+ if (gaps.length && missing.length < 10) {
139
+ missing.push(`${t.id} (${gaps.join(", ")})`);
140
+ }
141
+ }
142
+ }
143
+ return missing.length ? `e.g. ${missing.join("; ")}` : undefined;
144
+ },
145
+ },
146
+ {
147
+ name: "project ticketCounter is at least the highest number handed out",
148
+ hard: true,
149
+ async run(tenantId, batchSize) {
150
+ // The migration assigns `number` itself now (beforeSave is suppressed so the
151
+ // historical createdAt survives), so this checks the migration's own
152
+ // bookkeeping: a counter left behind the numbers already used would make the
153
+ // next run reuse them.
154
+ const highest = new Map();
155
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
156
+ for (const t of page) {
157
+ const number = t.get("number");
158
+ const projectId = (0, pass_js_1.refId)(t.get("project"));
159
+ if (typeof number !== "number" || !projectId)
160
+ continue;
161
+ highest.set(projectId, Math.max(highest.get(projectId) ?? 0, number));
162
+ }
163
+ }
164
+ const behind = [];
165
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(index_js_1.Service_Project), batchSize)) {
166
+ for (const project of page) {
167
+ if (!project.id)
168
+ continue;
169
+ const max = highest.get(project.id);
170
+ if (max === undefined)
171
+ continue;
172
+ const counter = project.get("ticketCounter") ?? 0;
173
+ if (counter < max && behind.length < 10) {
174
+ behind.push(`${project.id}: counter ${counter} < highest number ${max}`);
175
+ }
176
+ }
177
+ }
178
+ return behind.length ? behind.join("; ") : undefined;
179
+ },
180
+ },
181
+ {
182
+ name: "ticket numbers are unique per project",
183
+ hard: true,
184
+ async run(tenantId, batchSize) {
185
+ const seen = new Map();
186
+ const dupes = [];
187
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
188
+ for (const t of page) {
189
+ const number = t.get("number");
190
+ if (typeof number !== "number")
191
+ continue;
192
+ // Keyed by tenant *and* project: tickets without a project are numbered
193
+ // from the per-tenant counter, so two tenants legitimately share numbers
194
+ // and a project-only key would report them as duplicates.
195
+ const key = `${(0, pass_js_1.refId)(t.get("tenant")) ?? "-"}/${(0, pass_js_1.refId)(t.get("project")) ?? "(no project)"}`;
196
+ const bucket = seen.get(key) ?? new Set();
197
+ if (bucket.has(number) && dupes.length < 10) {
198
+ dupes.push(`${key} #${number}`);
199
+ }
200
+ bucket.add(number);
201
+ seen.set(key, bucket);
202
+ }
203
+ }
204
+ return dupes.length ? `duplicate number(s): ${dupes.join(", ")}` : undefined;
205
+ },
206
+ },
207
+ {
208
+ name: "every Status has a category in the v2 domain",
209
+ hard: true,
210
+ async run(tenantId, batchSize) {
211
+ const bad = await (0, pass_js_1.countAll)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Status).notContainedIn("category", [
212
+ "inbox",
213
+ "active",
214
+ "pending",
215
+ "done",
216
+ "cancelled",
217
+ ]), tenantId), batchSize);
218
+ return bad > 0 ? `${bad} Status row(s) with an unknown category` : undefined;
219
+ },
220
+ },
221
+ {
222
+ name: "an inbox Status exists",
223
+ hard: true,
224
+ async run(tenantId, batchSize) {
225
+ const n = await (0, pass_js_1.countAll)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Status).equalTo("category", "inbox"), tenantId), batchSize);
226
+ return n > 0 ? undefined : "no Status with category 'inbox'";
227
+ },
228
+ },
229
+ // ── Referential integrity ────────────────────────────────────────────────
230
+ {
231
+ name: "every ticket reference resolves to an existing object",
232
+ hard: true,
233
+ async run(tenantId, batchSize) {
234
+ // Load the id sets once rather than probing per ticket.
235
+ const known = new Map();
236
+ for (const { className } of REFERENCE_FIELDS) {
237
+ if (known.has(className))
238
+ continue;
239
+ const ids = new Set();
240
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(className), batchSize)) {
241
+ for (const row of page)
242
+ if (row.id)
243
+ ids.add(row.id);
244
+ }
245
+ known.set(className, ids);
246
+ }
247
+ const broken = [];
248
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
249
+ for (const t of page) {
250
+ for (const { field, className, array } of REFERENCE_FIELDS) {
251
+ const ids = known.get(className);
252
+ const value = t.get(field);
253
+ if (!value)
254
+ continue;
255
+ const candidates = array
256
+ ? (value ?? []).map((v) => (0, pass_js_1.refId)(v))
257
+ : [(0, pass_js_1.refId)(value)];
258
+ for (const id of candidates) {
259
+ if (id && !ids.has(id) && broken.length < 10) {
260
+ broken.push(`${t.id}.${field} → ${id}`);
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ return broken.length ? `e.g. ${broken.join("; ")}` : undefined;
267
+ },
268
+ },
269
+ {
270
+ name: "every ticket reference belongs to the ticket's own tenant",
271
+ hard: true,
272
+ async run(tenantId, batchSize) {
273
+ // Pass 08 resolved the required-status fallback once per run and only
274
+ // scoped it when --tenant was given, so a whole-database run put one
275
+ // tenant's inbox Status on every other tenant's unstatused tickets. The
276
+ // reference resolved fine (the row exists), so the integrity check above
277
+ // passed — it just belonged to a tenant that could not read it, and the
278
+ // column rendered empty in the UI. This is the check that catches it.
279
+ const tenantOf = new Map();
280
+ for (const { className } of REFERENCE_FIELDS) {
281
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(className), batchSize)) {
282
+ for (const row of page) {
283
+ if (row.id)
284
+ tenantOf.set(`${className}|${row.id}`, (0, pass_js_1.refId)(row.get("tenant")));
285
+ }
286
+ }
287
+ }
288
+ const crossed = [];
289
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
290
+ for (const t of page) {
291
+ const own = (0, pass_js_1.refId)(t.get("tenant"));
292
+ if (!own)
293
+ continue;
294
+ for (const { field, className, array } of REFERENCE_FIELDS) {
295
+ const value = t.get(field);
296
+ if (!value)
297
+ continue;
298
+ const ids = array
299
+ ? (value ?? []).map((v) => (0, pass_js_1.refId)(v))
300
+ : [(0, pass_js_1.refId)(value)];
301
+ for (const id of ids) {
302
+ if (!id)
303
+ continue;
304
+ const other = tenantOf.get(`${className}|${id}`);
305
+ // undefined = the row has no tenant at all, which is a different
306
+ // (and legitimate) case — only a *mismatch* is a leak.
307
+ if (other !== undefined && other !== own && crossed.length < 10) {
308
+ crossed.push(`${t.id}.${field} -> ${id} (tenant ${other}, ticket is ${own})`);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ return crossed.length ? `e.g. ${crossed.join("; ")}` : undefined;
315
+ },
316
+ },
317
+ {
318
+ name: "every ticket's tags array is a complete ancestor closure",
319
+ hard: true,
320
+ async run(tenantId, batchSize) {
321
+ const parentOf = new Map();
322
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(index_js_1.Service_Tag), batchSize)) {
323
+ for (const tag of page) {
324
+ if (tag.id)
325
+ parentOf.set(tag.id, (0, pass_js_1.refId)(tag.get("parent")));
326
+ }
327
+ }
328
+ const bad = [];
329
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
330
+ for (const t of page) {
331
+ const tags = t.get("tags") ?? [];
332
+ if (tags.length === 0)
333
+ continue;
334
+ const present = new Set(tags.map((v) => (0, pass_js_1.refId)(v)).filter(Boolean));
335
+ for (const id of present) {
336
+ let cursor = parentOf.get(id);
337
+ const guard = new Set();
338
+ while (cursor && !guard.has(cursor)) {
339
+ guard.add(cursor);
340
+ if (!present.has(cursor) && bad.length < 10) {
341
+ bad.push(`${t.id} has ${id} but not its ancestor ${cursor}`);
342
+ break;
343
+ }
344
+ cursor = parentOf.get(cursor);
345
+ }
346
+ }
347
+ }
348
+ }
349
+ return bad.length ? `e.g. ${bad.join("; ")}` : undefined;
350
+ },
351
+ },
352
+ {
353
+ name: "no equipment parent chain contains a cycle",
354
+ hard: true,
355
+ async run(_tenantId, batchSize) {
356
+ const parentOf = new Map();
357
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(index_js_1.Service_Equipment), batchSize)) {
358
+ for (const e of page) {
359
+ if (e.id)
360
+ parentOf.set(e.id, (0, pass_js_1.refId)(e.get("parent")));
361
+ }
362
+ }
363
+ const cyclic = [];
364
+ for (const start of parentOf.keys()) {
365
+ const guard = new Set([start]);
366
+ let cursor = parentOf.get(start);
367
+ while (cursor) {
368
+ if (guard.has(cursor)) {
369
+ if (cyclic.length < 10)
370
+ cyclic.push(start);
371
+ break;
372
+ }
373
+ guard.add(cursor);
374
+ cursor = parentOf.get(cursor);
375
+ }
376
+ }
377
+ return cyclic.length ? `equipment in a cycle: ${cyclic.join(", ")}` : undefined;
378
+ },
379
+ },
380
+ // ── Track F: the reconstructed history ───────────────────────────────────
381
+ {
382
+ name: "replaying each ticket's changelog reproduces the migrated ticket",
383
+ hard: true,
384
+ async run(tenantId, batchSize) {
385
+ // The strongest check in the migration: the final snapshot of the replay IS
386
+ // the flat ticket by construction, so any disagreement means either the fold
387
+ // (pass 08) or the replay (pass 12) is wrong.
388
+ const latestSnapshot = new Map();
389
+ const latestAt = new Map();
390
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(index_js_1.Changelog).equalTo("nameOfClass", index_js_1.Service_Ticket.className), batchSize)) {
391
+ for (const row of page) {
392
+ const context = row.get("context");
393
+ if (!context?.migrated)
394
+ continue;
395
+ const ticketId = row.get("changedObject");
396
+ const at = row.createdAt?.getTime() ?? 0;
397
+ if (at >= (latestAt.get(ticketId) ?? -1)) {
398
+ latestAt.set(ticketId, at);
399
+ latestSnapshot.set(ticketId, row.get("value") ?? {});
400
+ }
401
+ }
402
+ }
403
+ if (latestSnapshot.size === 0) {
404
+ return "no reconstructed history found (has pass 12 run?)";
405
+ }
406
+ // Compare only the fields the replay actually tracks; anything the fold set
407
+ // from a source the replay has no events for is out of scope here.
408
+ const tracked = [
409
+ "title",
410
+ "priority",
411
+ "downtime",
412
+ "frequency",
413
+ "restriction",
414
+ "status",
415
+ "equipment",
416
+ "project",
417
+ "material",
418
+ ];
419
+ const mismatches = [];
420
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
421
+ for (const t of page) {
422
+ if (!t.id)
423
+ continue;
424
+ const snapshot = latestSnapshot.get(t.id);
425
+ if (!snapshot) {
426
+ if (mismatches.length < 10) {
427
+ mismatches.push(`${t.id} has no reconstructed history`);
428
+ }
429
+ continue;
430
+ }
431
+ for (const field of tracked) {
432
+ if (!(field in snapshot))
433
+ continue; // never edited → nothing to compare
434
+ const fromReplay = (0, pass_js_1.refId)(snapshot[field]) ?? snapshot[field];
435
+ const onTicket = (0, pass_js_1.refId)(t.get(field)) ?? t.get(field);
436
+ if (fromReplay !== undefined &&
437
+ onTicket !== undefined &&
438
+ comparable(fromReplay) !== comparable(onTicket) &&
439
+ mismatches.length < 10) {
440
+ mismatches.push(`${t.id}.${field}: replay ${comparable(fromReplay)} vs ` +
441
+ `ticket ${comparable(onTicket)}`);
442
+ }
443
+ }
444
+ }
445
+ }
446
+ return mismatches.length ? `e.g. ${mismatches.join("; ")}` : undefined;
447
+ },
448
+ },
449
+ {
450
+ name: "reconstructed history kept its historical timestamps",
451
+ hard: true,
452
+ async run(_tenantId, batchSize) {
453
+ // Under the master key Parse silently stamps `createdAt = now`, which would
454
+ // collapse the whole trail onto the migration date. This is the check that
455
+ // catches a run that used the wrong key.
456
+ const cutoff = Date.now() - 60 * 60 * 1000;
457
+ let total = 0;
458
+ let recent = 0;
459
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(index_js_1.Changelog).equalTo("nameOfClass", index_js_1.Service_Ticket.className), batchSize)) {
460
+ for (const row of page) {
461
+ const context = row.get("context");
462
+ if (!context?.migrated)
463
+ continue;
464
+ total += 1;
465
+ if ((row.createdAt?.getTime() ?? 0) > cutoff)
466
+ recent += 1;
467
+ }
468
+ }
469
+ if (total === 0)
470
+ return undefined;
471
+ return recent === total
472
+ ? `all ${total} reconstructed entries were created within the last hour — ` +
473
+ `timestamps were NOT preserved (was the maintenance key actually used?)`
474
+ : undefined;
475
+ },
476
+ },
477
+ {
478
+ name: "no auto-generated changelog entries for migrated objects",
479
+ hard: true,
480
+ async run(_tenantId, batchSize) {
481
+ // Every migration write passes `context: { skipChangelog: true }`. A pass
482
+ // that forgets it leaves a fingerprint nothing in normal operation
483
+ // produces: `_ChangeLog`'s afterSave stamps `masterkey: request.master`,
484
+ // and a maintenance-key write is **not** master — `Auth.maintenance()`
485
+ // leaves `isMaster: false` — nor does it carry a user. So the tell is an
486
+ // entry with no acting user that is not master-key.
487
+ //
488
+ // Explicitly NOT `masterkey === true`, which is what this check looked for
489
+ // originally and could therefore never find: hook-written entries under the
490
+ // master key are legitimate v2 activity, and the migration's own writes
491
+ // never set that flag.
492
+ //
493
+ // Pass 12's entries match the same shape whenever the v1 row had no `user`,
494
+ // so they are excluded by their `context.migrated` marker rather than by
495
+ // their author. Both filters run in memory so an absent `actingUser` and a
496
+ // null one are treated alike.
497
+ const offenders = [];
498
+ let total = 0;
499
+ for await (const page of (0, pass_js_1.pages)(() => new node_1.default.Query(index_js_1.Changelog)
500
+ .containedIn("nameOfClass", [
501
+ index_js_1.Service_Ticket.className,
502
+ index_js_1.Service_Status.className,
503
+ index_js_1.Service_Tag.className,
504
+ index_js_1.Service_Equipment.className,
505
+ index_js_1.Service_Project.className,
506
+ ])
507
+ .notEqualTo("masterkey", true), batchSize)) {
508
+ for (const row of page) {
509
+ const context = row.get("context");
510
+ if (context?.migrated)
511
+ continue; // written by pass 12, on purpose
512
+ if (row.get("actingUser"))
513
+ continue; // a real edit by a real user
514
+ total += 1;
515
+ if (offenders.length < 10) {
516
+ offenders.push(`${row.get("nameOfClass")} ${row.get("changedObject")}`);
517
+ }
518
+ }
519
+ }
520
+ return total > 0
521
+ ? `${total} auto-generated changelog entr(ies) — a pass is missing ` +
522
+ `skipChangelog. e.g. ${offenders.join("; ")}`
523
+ : undefined;
524
+ },
525
+ },
526
+ // ── Soft: distribution sanity ────────────────────────────────────────────
527
+ {
528
+ name: "ticket status distribution looks plausible",
529
+ hard: false,
530
+ async run(tenantId, batchSize) {
531
+ const byCategory = new Map();
532
+ const categoryOf = new Map();
533
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Status), tenantId), batchSize)) {
534
+ for (const s of page) {
535
+ if (s.id)
536
+ categoryOf.set(s.id, s.get("category"));
537
+ }
538
+ }
539
+ for await (const page of (0, pass_js_1.pages)(() => (0, pass_js_1.scopeToTenant)(new node_1.default.Query(index_js_1.Service_Ticket), tenantId), batchSize)) {
540
+ for (const t of page) {
541
+ const category = categoryOf.get((0, pass_js_1.refId)(t.get("status")) ?? "") ?? "(unknown)";
542
+ byCategory.set(category, (byCategory.get(category) ?? 0) + 1);
543
+ }
544
+ }
545
+ const summary = [...byCategory.entries()]
546
+ .sort((a, b) => b[1] - a[1])
547
+ .map(([k, v]) => `${k}=${v}`)
548
+ .join(" ");
549
+ // Informational: always reported so the numbers land in the run log.
550
+ return `distribution: ${summary}`;
551
+ },
552
+ },
553
+ ];
554
+ async function main() {
555
+ let config;
556
+ try {
557
+ config = (0, env_js_1.readConfig)(process.argv.slice(2));
558
+ }
559
+ catch (error) {
560
+ if (error instanceof env_js_1.MigrationConfigError) {
561
+ console.error(`\n✗ ${error.message}\n`);
562
+ return 2;
563
+ }
564
+ throw error;
565
+ }
566
+ (0, env_js_1.initParse)(config);
567
+ // Probe once before running the checks. Without this, an unreachable server or a
568
+ // rejected key makes every check throw, and the summary reads "N hard invariants
569
+ // failed — do not cut over" — which looks like N data problems rather than one
570
+ // connection problem.
571
+ try {
572
+ await new node_1.default.Query(index_js_1.Service_Ticket).limit(1).find(env_js_1.MK);
573
+ }
574
+ catch (error) {
575
+ console.error(`\n✗ Could not query ${config.parseUrl}.\n` +
576
+ ` ${error.message}\n` +
577
+ ` Check that Parse Server is reachable there, that PARSE_APP_ID matches, ` +
578
+ `that PARSE_MAINTENANCE_KEY is the server's maintenanceKey, and that this ` +
579
+ `host is allowed by maintenanceKeyIps (default: localhost only).\n` +
580
+ ` Nothing was verified — this is not a data problem.\n`);
581
+ return 2;
582
+ }
583
+ console.log(`\nverifying — tenant ${config.tenantId ?? "(all)"}\n`);
584
+ let hardFailures = 0;
585
+ let softFailures = 0;
586
+ for (const check of checks) {
587
+ let failure;
588
+ try {
589
+ failure = await check.run(config.tenantId, config.batchSize);
590
+ }
591
+ catch (error) {
592
+ failure = `check threw: ${error.message}`;
593
+ }
594
+ if (!failure) {
595
+ console.log(` ✓ ${check.name}`);
596
+ continue;
597
+ }
598
+ if (check.hard) {
599
+ hardFailures++;
600
+ console.error(` ✗ ${check.name}\n ${failure}`);
601
+ }
602
+ else {
603
+ softFailures++;
604
+ console.warn(` ! ${check.name}\n ${failure}`);
605
+ }
606
+ }
607
+ console.log("");
608
+ if (hardFailures > 0) {
609
+ console.error(`✗ ${hardFailures} hard invariant(s) failed — do not cut over.\n`);
610
+ return 1;
611
+ }
612
+ console.log(softFailures > 0
613
+ ? `✓ hard invariants pass; ${softFailures} soft check(s) want a look.\n`
614
+ : `✓ all checks pass.\n`);
615
+ return 0;
616
+ }
617
+ main().then((code) => process.exit(code), (error) => {
618
+ console.error(error);
619
+ process.exit(1);
620
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Seed **v1** (`OD3_Maintenance_*`) test data, for exercising the v1 → v2 migration.
3
+ *
4
+ * DEV ONLY. Never run this against production.
5
+ *
6
+ * OD_MIGRATION_MODE=1 PARSE_APP_ID=… PARSE_MAINTENANCE_KEY=… \
7
+ * node dist/scripts/seed-v1-testdata.js [--purge]
8
+ *
9
+ * ## Why the maintenance key
10
+ *
11
+ * Realistic timestamps are the whole point. The migration's fold picks the newest
12
+ * side-table row and its replay builds a timeline from them — neither can be tested
13
+ * against data where every row was created in the same second. Historical
14
+ * `createdAt` / `updatedAt` require the maintenance key, so this seeder needs the
15
+ * same setup as the migration. That makes it a useful smoke test of the write path
16
+ * in its own right: if this runs, the migration's transport works.
17
+ *
18
+ * ## Why OD_MIGRATION_MODE
19
+ *
20
+ * All 29 v1 hooks call `defaultHandler`, which throws on a request with no user —
21
+ * and maintenance-key requests have none. The flag gates that off, so this seeder
22
+ * sets `tenant`, `user` and ACLs itself, exactly as the migration does.
23
+ *
24
+ * ## What it deliberately includes
25
+ *
26
+ * Every case the migration audits turned up, so a dry run has something to say:
27
+ *
28
+ * - two tenants, each with their own kanban states and meta fields
29
+ * - a ticket with no kanban state at all (inbox fallback)
30
+ * - a ticket with `enabled: false`, and one with `enabled` *unset*
31
+ * - side-table rows where `updatedAt` disagrees with `createdAt` ordering
32
+ * - a completed todo, and a todo whose title is an i18n key
33
+ * - a comment with an i18n-key title, and one with no author but an attachment
34
+ * - a source with no tag, and a source tree needing ancestors
35
+ * - an item with a German-decimal price and one with an unparseable price
36
+ * - a two-level issue-category tree (closure expansion)
37
+ * - an assignment batch (three rows, one `batchStamp`)
38
+ * - a message referencing a non-ticket object (orphan skip)
39
+ * - assets on a real and on a `temp-` media context
40
+ */
41
+ export {};