mat_views 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/app/assets/images/mat_views/android-chrome-192x192.png +0 -0
  4. data/app/assets/images/mat_views/android-chrome-512x512.png +0 -0
  5. data/app/assets/images/mat_views/apple-touch-icon.png +0 -0
  6. data/app/assets/images/mat_views/favicon-16x16.png +0 -0
  7. data/app/assets/images/mat_views/favicon-32x32.png +0 -0
  8. data/app/assets/images/mat_views/favicon-48x48.png +0 -0
  9. data/app/assets/images/mat_views/favicon.ico +0 -0
  10. data/app/assets/images/mat_views/favicon.svg +18 -0
  11. data/app/assets/images/mat_views/logo.svg +18 -0
  12. data/app/assets/images/mat_views/mask-icon.svg +5 -0
  13. data/app/assets/stylesheets/mat_views/application.css +323 -12
  14. data/app/controllers/mat_views/admin/application_controller.rb +135 -0
  15. data/app/controllers/mat_views/admin/dashboard_controller.rb +32 -0
  16. data/app/controllers/mat_views/admin/mat_view_definitions_controller.rb +248 -0
  17. data/app/controllers/mat_views/admin/preferences_controller.rb +91 -0
  18. data/app/controllers/mat_views/admin/runs_controller.rb +74 -0
  19. data/app/helpers/mat_views/admin/ui_helper.rb +385 -0
  20. data/app/javascript/mat_views/application.js +8 -0
  21. data/app/javascript/mat_views/controllers/application.js +10 -0
  22. data/app/javascript/mat_views/controllers/details_controller.js +122 -0
  23. data/app/javascript/mat_views/controllers/drawer_controller.js +252 -0
  24. data/app/javascript/mat_views/controllers/filter_controller.js +90 -0
  25. data/app/javascript/mat_views/controllers/flash_controller.js +13 -0
  26. data/app/javascript/mat_views/controllers/index.js +10 -0
  27. data/app/javascript/mat_views/controllers/mv_confirm_controller.js +281 -0
  28. data/app/javascript/mat_views/controllers/submitter_controller.js +15 -0
  29. data/app/javascript/mat_views/controllers/tabs_controller.js +67 -0
  30. data/app/javascript/mat_views/controllers/timezone_controller.js +16 -0
  31. data/app/javascript/mat_views/controllers/tooltip_controller.js +328 -0
  32. data/app/javascript/mat_views/controllers/turbo_frame_lifecycle_controller.js +49 -0
  33. data/app/jobs/mat_views/application_job.rb +107 -2
  34. data/app/jobs/mat_views/create_view_job.rb +21 -122
  35. data/app/jobs/mat_views/delete_view_job.rb +22 -129
  36. data/app/jobs/mat_views/refresh_view_job.rb +12 -133
  37. data/app/models/concerns/mat_views_i18n.rb +139 -0
  38. data/app/models/mat_views/application_record.rb +1 -0
  39. data/app/models/mat_views/mat_view_definition.rb +12 -7
  40. data/app/models/mat_views/mat_view_run.rb +34 -16
  41. data/app/views/layouts/mat_views/_footer.html.erb +41 -0
  42. data/app/views/layouts/mat_views/_header.html.erb +25 -0
  43. data/app/views/layouts/mat_views/admin.html.erb +47 -0
  44. data/app/views/layouts/mat_views/turbo_frame.html.erb +3 -0
  45. data/app/views/mat_views/admin/dashboard/index.html.erb +33 -0
  46. data/app/views/mat_views/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  47. data/app/views/mat_views/admin/mat_view_definitions/_table.html.erb +48 -0
  48. data/app/views/mat_views/admin/mat_view_definitions/empty.html.erb +1 -0
  49. data/app/views/mat_views/admin/mat_view_definitions/form.html.erb +79 -0
  50. data/app/views/mat_views/admin/mat_view_definitions/index.html.erb +10 -0
  51. data/app/views/mat_views/admin/mat_view_definitions/show.html.erb +40 -0
  52. data/app/views/mat_views/admin/preferences/show.html.erb +50 -0
  53. data/app/views/mat_views/admin/runs/_table.html.erb +61 -0
  54. data/app/views/mat_views/admin/runs/index.html.erb +38 -0
  55. data/app/views/mat_views/admin/runs/show.html.erb +64 -0
  56. data/app/views/mat_views/admin/ui/_card.html.erb +15 -0
  57. data/app/views/mat_views/admin/ui/_details.html.erb +10 -0
  58. data/app/views/mat_views/admin/ui/_flash.html.erb +6 -0
  59. data/app/views/mat_views/admin/ui/_table.html.erb +8 -0
  60. data/config/importmap.rb +9 -0
  61. data/config/locales/en-AU-ocker.yml +187 -0
  62. data/config/locales/en-AU.yml +187 -0
  63. data/config/locales/en-BB.yml +187 -0
  64. data/config/locales/en-BD.yml +187 -0
  65. data/config/locales/en-BE.yml +187 -0
  66. data/config/locales/en-BORK.yml +187 -0
  67. data/config/locales/en-BS.yml +187 -0
  68. data/config/locales/en-BZ.yml +187 -0
  69. data/config/locales/en-CA.yml +187 -0
  70. data/config/locales/en-CM.yml +187 -0
  71. data/config/locales/en-CY.yml +187 -0
  72. data/config/locales/en-EG.yml +187 -0
  73. data/config/locales/en-FJ.yml +187 -0
  74. data/config/locales/en-GB.yml +187 -0
  75. data/config/locales/en-GH.yml +187 -0
  76. data/config/locales/en-GI.yml +187 -0
  77. data/config/locales/en-GM.yml +187 -0
  78. data/config/locales/en-GY.yml +187 -0
  79. data/config/locales/en-HK.yml +187 -0
  80. data/config/locales/en-IE.yml +187 -0
  81. data/config/locales/en-IN.yml +187 -0
  82. data/config/locales/en-JM.yml +187 -0
  83. data/config/locales/en-KE.yml +187 -0
  84. data/config/locales/en-LK.yml +187 -0
  85. data/config/locales/en-LOL.yml +187 -0
  86. data/config/locales/en-LR.yml +187 -0
  87. data/config/locales/en-MS.yml +187 -0
  88. data/config/locales/en-MT.yml +187 -0
  89. data/config/locales/en-MW.yml +187 -0
  90. data/config/locales/en-MY.yml +187 -0
  91. data/config/locales/en-NG.yml +187 -0
  92. data/config/locales/en-NP.yml +187 -0
  93. data/config/locales/en-NZ.yml +187 -0
  94. data/config/locales/en-PG.yml +187 -0
  95. data/config/locales/en-PH.yml +187 -0
  96. data/config/locales/en-PK.yml +187 -0
  97. data/config/locales/en-RW.yml +187 -0
  98. data/config/locales/en-SCOT.yml +187 -0
  99. data/config/locales/en-SG.yml +187 -0
  100. data/config/locales/en-SHAKESPEARE.yml +187 -0
  101. data/config/locales/en-SL.yml +187 -0
  102. data/config/locales/en-SS.yml +187 -0
  103. data/config/locales/en-TH.yml +187 -0
  104. data/config/locales/en-TT.yml +187 -0
  105. data/config/locales/en-TZ.yml +187 -0
  106. data/config/locales/en-UG.yml +187 -0
  107. data/config/locales/en-US-pirate.yml +187 -0
  108. data/config/locales/en-US.yml +187 -0
  109. data/config/locales/en-YODA.yml +187 -0
  110. data/config/locales/en-ZA.yml +187 -0
  111. data/config/locales/en-ZW.yml +187 -0
  112. data/config/locales/en.yml +187 -0
  113. data/config/routes.rb +27 -3
  114. data/lib/ext/exception.rb +20 -0
  115. data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +7 -7
  116. data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +7 -7
  117. data/lib/mat_views/admin/auth_bridge.rb +93 -0
  118. data/lib/mat_views/admin/default_auth.rb +61 -0
  119. data/lib/mat_views/configuration.rb +9 -0
  120. data/lib/mat_views/engine.rb +50 -2
  121. data/lib/mat_views/helpers/ui_test_ids.rb +43 -0
  122. data/lib/mat_views/jobs/adapter.rb +8 -5
  123. data/lib/mat_views/service_response.rb +30 -15
  124. data/lib/mat_views/services/base_service.rb +204 -41
  125. data/lib/mat_views/services/check_matview_exists.rb +76 -0
  126. data/lib/mat_views/services/concurrent_refresh.rb +38 -121
  127. data/lib/mat_views/services/create_view.rb +72 -55
  128. data/lib/mat_views/services/delete_view.rb +46 -95
  129. data/lib/mat_views/services/regular_refresh.rb +38 -94
  130. data/lib/mat_views/services/swap_refresh.rb +83 -123
  131. data/lib/mat_views/version.rb +1 -1
  132. data/lib/mat_views.rb +13 -6
  133. data/lib/tasks/helpers.rb +27 -27
  134. data/lib/tasks/mat_views_tasks.rake +48 -42
  135. metadata +131 -5
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ static targets = ["link", "panel"];
11
+
12
+ connect() {
13
+ const qs = new URLSearchParams(window.location.search);
14
+ const name = qs.get("tab") || this.linkTargets[0]?.dataset?.name;
15
+ setTimeout(() => {
16
+ if (name) this.showByName(name, false);
17
+ else if (this.panelTargets[0])
18
+ this._ensureFrameLoaded(this.panelTargets[0]);
19
+ });
20
+ }
21
+
22
+ show(e) {
23
+ e.preventDefault();
24
+ const name = e.currentTarget.dataset.name;
25
+ this.showByName(name, true);
26
+ }
27
+
28
+ showByName(name, pushState) {
29
+ this.linkTargets.forEach((a) => {
30
+ const on = a.dataset.name === name;
31
+ a.classList.toggle("mv-tab--on", on);
32
+ a.setAttribute("aria-selected", on ? "true" : "false");
33
+ });
34
+
35
+ this.panelTargets.forEach((p) => {
36
+ p.hidden = p.dataset.name !== name;
37
+ });
38
+
39
+ const active = this.panelTargets.find((p) => p.dataset.name === name);
40
+ if (active) this._ensureFrameLoaded(active);
41
+
42
+ if (pushState) {
43
+ const url = new URL(window.location);
44
+ url.searchParams.set("tab", name);
45
+
46
+ if (name == "definitions") {
47
+ url.searchParams.delete("mat_view_definition_id");
48
+ url.searchParams.delete("operation");
49
+ url.searchParams.delete("status");
50
+ }
51
+
52
+ history.replaceState({}, "", url);
53
+ }
54
+ }
55
+
56
+ _ensureFrameLoaded(panel) {
57
+ const frame = panel.querySelector("turbo-frame");
58
+
59
+ if (!frame) return;
60
+ if (!frame.getAttribute("src")) {
61
+ const src = frame.dataset.src;
62
+ if (src) frame.setAttribute("src", src);
63
+ }
64
+
65
+ frame.reload();
66
+ }
67
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ connect() {
11
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
12
+ if (document.cookie.indexOf(`browser_tz=${tz}`) === -1) {
13
+ document.cookie = `browser_tz=${tz}; path=/`;
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ /**
10
+ * Usage:
11
+ * <button
12
+ * data-controller="tooltip"
13
+ * data-tooltip-text-value="Save"
14
+ * data-tooltip-placement="bottom"
15
+ * >
16
+ * Save
17
+ * </button>
18
+ */
19
+ export default class extends Controller {
20
+ static values = {
21
+ text: String,
22
+ placement: { type: String, default: "top" },
23
+ delay: { type: Number, default: 120 },
24
+ hideDelay: { type: Number, default: 80 },
25
+ disabled: { type: Boolean, default: false },
26
+ gap: { type: Number, default: 8 },
27
+ margin: { type: Number, default: 8 },
28
+ };
29
+
30
+ // ──────────────────────────────────────────────
31
+ // Lifecycle
32
+ // ──────────────────────────────────────────────
33
+ connect() {
34
+ this.tooltipEl = this._ensureTooltipEl();
35
+ this.contentEl = this.tooltipEl.querySelector(".mv-tooltip__content");
36
+ this.element.classList.add("mv-cursor-pointer");
37
+ this._bindHandlers();
38
+ this._addListeners();
39
+ }
40
+
41
+ disconnect() {
42
+ this._clearAllTimers();
43
+ this._removeListeners();
44
+ this._restoreTitle();
45
+ this._clearDescribedBy();
46
+ }
47
+
48
+ // ──────────────────────────────────────────────
49
+ // Public actions
50
+ // ──────────────────────────────────────────────
51
+ show() {
52
+ if (this.disabledValue) return;
53
+ this._clearTimer("_hideTimer");
54
+ this._schedule("_showTimer", () => this._actuallyShow(), this.delayValue);
55
+ }
56
+
57
+ hide() {
58
+ this._clearTimer("_showTimer");
59
+ this._schedule(
60
+ "_hideTimer",
61
+ () => this._actuallyHide(),
62
+ this.hideDelayValue,
63
+ );
64
+ }
65
+
66
+ toggle() {
67
+ this._isVisible() ? this.hide() : this.show();
68
+ }
69
+
70
+ forceHide() {
71
+ this._clearAllTimers();
72
+ this._actuallyHide(true);
73
+ }
74
+
75
+ // ──────────────────────────────────────────────
76
+ // Internals: show/hide
77
+ // ──────────────────────────────────────────────
78
+ _actuallyShow() {
79
+ const text = this._resolveText();
80
+ if (!text) return;
81
+
82
+ // Remove native title to avoid default browser tooltip
83
+ this._saveAndRemoveTitle();
84
+
85
+ // Fill content
86
+ this.contentEl.textContent = text;
87
+
88
+ // Prep, place, show
89
+ this._prepareForMeasure();
90
+ const rect = this.element.getBoundingClientRect();
91
+ const ttRect = this.tooltipEl.getBoundingClientRect();
92
+ const placement = this._resolvePlacement();
93
+ const { x, y } = this._computePosition(
94
+ rect,
95
+ ttRect,
96
+ placement,
97
+ this.gapValue,
98
+ this.marginValue,
99
+ );
100
+
101
+ this._applyPlacementClass(placement);
102
+ this._setTransform(x, y);
103
+ this._setVisible(true);
104
+ this._setDescribedBy();
105
+ }
106
+
107
+ _actuallyHide(immediate = false) {
108
+ this._setVisible(false);
109
+
110
+ if (immediate) {
111
+ this._offscreen();
112
+ } else {
113
+ // Allow CSS transition to finish before moving offscreen (if not reopened)
114
+ setTimeout(() => {
115
+ if (!this._isVisible()) this._offscreen();
116
+ }, 150);
117
+ }
118
+
119
+ this._restoreTitle();
120
+ this._clearDescribedBy();
121
+ }
122
+
123
+ // ──────────────────────────────────────────────
124
+ // Internals: listeners and handlers
125
+ // ──────────────────────────────────────────────
126
+ _bindHandlers() {
127
+ this._onEnter = this.show.bind(this);
128
+ this._onLeave = this.hide.bind(this);
129
+ this._onFocus = this.show.bind(this);
130
+ this._onBlur = this.hide.bind(this);
131
+ this._onKey = (e) => {
132
+ if (e.key === "Escape") this.forceHide();
133
+ };
134
+ this._onTouch = (e) => {
135
+ e.preventDefault();
136
+ this.show();
137
+ this._clearTimer("_touchTimer");
138
+ this._touchTimer = setTimeout(() => this.hide(), 1500);
139
+ };
140
+ }
141
+
142
+ _addListeners() {
143
+ const el = this.element;
144
+ el.addEventListener("mouseenter", this._onEnter);
145
+ el.addEventListener("mouseleave", this._onLeave);
146
+ el.addEventListener("focus", this._onFocus);
147
+ el.addEventListener("blur", this._onBlur);
148
+ el.addEventListener("keydown", this._onKey);
149
+ // preventDefault() requires passive: false
150
+ el.addEventListener("touchstart", this._onTouch, { passive: false });
151
+ }
152
+
153
+ _removeListeners() {
154
+ const el = this.element;
155
+ if (!el) return;
156
+ el.removeEventListener("mouseenter", this._onEnter);
157
+ el.removeEventListener("mouseleave", this._onLeave);
158
+ el.removeEventListener("focus", this._onFocus);
159
+ el.removeEventListener("blur", this._onBlur);
160
+ el.removeEventListener("keydown", this._onKey);
161
+ el.removeEventListener("touchstart", this._onTouch, { passive: false });
162
+ }
163
+
164
+ // ──────────────────────────────────────────────
165
+ // Internals: DOM helpers
166
+ // ──────────────────────────────────────────────
167
+ _ensureTooltipEl() {
168
+ let el = document.getElementById("mv-tooltip");
169
+ if (el) return el;
170
+
171
+ el = document.createElement("div");
172
+ el.id = "mv-tooltip";
173
+ el.setAttribute("role", "tooltip");
174
+ el.className = "mv-tooltip";
175
+ Object.assign(el.style, {
176
+ position: "fixed",
177
+ top: "0",
178
+ left: "0",
179
+ pointerEvents: "none",
180
+ opacity: "0",
181
+ transform: "translate(-9999px,-9999px)",
182
+ });
183
+ el.innerHTML =
184
+ '<div class="mv-tooltip__content"></div><div class="mv-tooltip__arrow" data-arrow></div>';
185
+ document.body.appendChild(el);
186
+ return el;
187
+ }
188
+
189
+ _prepareForMeasure() {
190
+ this.tooltipEl.style.opacity = "0";
191
+ this._offscreen();
192
+ this._removeAllPlacementClasses();
193
+ }
194
+
195
+ _applyPlacementClass(placement) {
196
+ this._removeAllPlacementClasses();
197
+ this.tooltipEl.classList.add(`mv-tooltip--${placement}`);
198
+ }
199
+
200
+ _removeAllPlacementClasses() {
201
+ this.tooltipEl.classList.remove(
202
+ "mv-tooltip--top",
203
+ "mv-tooltip--right",
204
+ "mv-tooltip--bottom",
205
+ "mv-tooltip--left",
206
+ );
207
+ }
208
+
209
+ _setTransform(x, y) {
210
+ this.tooltipEl.style.transform = `translate(${x}px, ${y}px)`;
211
+ }
212
+
213
+ _offscreen() {
214
+ this.tooltipEl.style.transform = "translate(-9999px,-9999px)";
215
+ }
216
+
217
+ _setVisible(visible) {
218
+ if (visible) {
219
+ this.tooltipEl.style.opacity = "1";
220
+ this.tooltipEl.setAttribute("data-show", "true");
221
+ } else {
222
+ this.tooltipEl.removeAttribute("data-show");
223
+ this.tooltipEl.style.opacity = "0";
224
+ }
225
+ }
226
+
227
+ _setDescribedBy() {
228
+ this.element.setAttribute("aria-describedby", "mv-tooltip");
229
+ }
230
+
231
+ _clearDescribedBy() {
232
+ this.element.removeAttribute("aria-describedby");
233
+ }
234
+
235
+ // ──────────────────────────────────────────────
236
+ // Internals: data/text/title
237
+ // ──────────────────────────────────────────────
238
+ _resolveText() {
239
+ return (
240
+ this.textValue ||
241
+ this.element.getAttribute("aria-label") ||
242
+ this.element.getAttribute("title")
243
+ );
244
+ }
245
+
246
+ _resolvePlacement() {
247
+ return (
248
+ this.element.getAttribute("data-tooltip-placement") || this.placementValue
249
+ );
250
+ }
251
+
252
+ _saveAndRemoveTitle() {
253
+ if (this.element.hasAttribute("title")) {
254
+ this._savedTitle = this.element.getAttribute("title");
255
+ this.element.removeAttribute("title");
256
+ }
257
+ }
258
+
259
+ _restoreTitle() {
260
+ if (this._savedTitle) {
261
+ this.element.setAttribute("title", this._savedTitle);
262
+ this._savedTitle = null;
263
+ }
264
+ }
265
+
266
+ // ──────────────────────────────────────────────
267
+ // Internals: timers & state helpers
268
+ // ──────────────────────────────────────────────
269
+ _schedule(name, fn, ms) {
270
+ this._clearTimer(name);
271
+ this[name] = setTimeout(fn, ms);
272
+ }
273
+
274
+ _clearTimer(name) {
275
+ if (this[name]) {
276
+ clearTimeout(this[name]);
277
+ this[name] = null;
278
+ }
279
+ }
280
+
281
+ _clearAllTimers() {
282
+ this._clearTimer("_showTimer");
283
+ this._clearTimer("_hideTimer");
284
+ this._clearTimer("_touchTimer");
285
+ }
286
+
287
+ _isVisible() {
288
+ return this.tooltipEl?.getAttribute("data-show") === "true";
289
+ }
290
+
291
+ // ──────────────────────────────────────────────
292
+ // Geometry
293
+ // ──────────────────────────────────────────────
294
+ _computePosition(targetRect, ttRect, placement, gap, margin) {
295
+ const coords = this._coordsFor(placement, targetRect, ttRect, gap);
296
+ const x = this._clampX(coords.x, ttRect.width, margin);
297
+ const y = this._clampY(coords.y, ttRect.height, margin);
298
+ return { x: Math.round(x), y: Math.round(y) };
299
+ }
300
+
301
+ _coordsFor(placement, r, tt, gap) {
302
+ switch (placement) {
303
+ case "top":
304
+ return {
305
+ x: r.left + (r.width - tt.width) / 2,
306
+ y: r.top - tt.height - gap,
307
+ };
308
+ case "bottom":
309
+ return { x: r.left + (r.width - tt.width) / 2, y: r.bottom + gap };
310
+ case "left":
311
+ return {
312
+ x: r.left - tt.width - gap,
313
+ y: r.top + (r.height - tt.height) / 2,
314
+ };
315
+ case "right":
316
+ default:
317
+ return { x: r.right + gap, y: r.top + (r.height - tt.height) / 2 };
318
+ }
319
+ }
320
+
321
+ _clampX(x, width, margin) {
322
+ return Math.max(margin, Math.min(x, window.innerWidth - width - margin));
323
+ }
324
+
325
+ _clampY(y, height, margin) {
326
+ return Math.max(margin, Math.min(y, window.innerHeight - height - margin));
327
+ }
328
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Copyright Codevedas Inc. 2025-present
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ connect() {
11
+ this.elements = document.querySelectorAll("turbo-frame");
12
+ this.elements.forEach((el) => {
13
+ el.addEventListener("turbo:before-fetch-request", this._addBusyAttribute);
14
+ el.addEventListener("turbo:frame-load", this._removeBusyAttribute);
15
+ el.addEventListener(
16
+ "turbo:fetch-request-error",
17
+ this._removeBusyAttribute,
18
+ );
19
+ });
20
+ }
21
+
22
+ disconnect() {
23
+ this.elements.forEach((el) => {
24
+ el.removeEventListener(
25
+ "turbo:before-fetch-request",
26
+ this._addBusyAttribute,
27
+ );
28
+ el.removeEventListener("turbo:frame-load", this._removeBusyAttribute);
29
+ el.removeEventListener(
30
+ "turbo:fetch-request-error",
31
+ this._removeBusyAttribute,
32
+ );
33
+ });
34
+ }
35
+
36
+ _addBusyAttribute = (event) => {
37
+ document.body.setAttribute("aria-busy", "true");
38
+ document.body.setAttribute("busy", "");
39
+ event.target.setAttribute("aria-busy", "true");
40
+ event.target.setAttribute("busy", "");
41
+ };
42
+
43
+ _removeBusyAttribute = (event) => {
44
+ document.body.removeAttribute("aria-busy");
45
+ document.body.removeAttribute("busy");
46
+ event.target.removeAttribute("aria-busy");
47
+ event.target.removeAttribute("busy");
48
+ };
49
+ }
@@ -8,7 +8,7 @@
8
8
  ##
9
9
  # Top-level namespace for the mat_views engine.
10
10
  #
11
- # All classes, modules, and services for materialized view management
11
+ # All classes, modules, and services for materialised view management
12
12
  # are defined under this namespace.
13
13
  #
14
14
  # @example Accessing a job
@@ -29,11 +29,116 @@ module MatViews
29
29
  #
30
30
  # @example Defining a custom job
31
31
  # class MyCustomJob < MatViews::ApplicationJob
32
- # def perform(definition_id)
32
+ # def perform(mat_view_definition_id)
33
33
  # # custom logic here
34
34
  # end
35
35
  # end
36
36
  #
37
37
  class ApplicationJob < ActiveJob::Base
38
+ private
39
+
40
+ def record_run(definition, operation, &)
41
+ start = monotime
42
+ run = start_run(definition, operation)
43
+ response = yield
44
+ finalize_run(run, response, elapsed_ms(start))
45
+ response.to_h
46
+ rescue StandardError => e
47
+ fail_run(run, e, elapsed_ms(start))
48
+ raise e
49
+ end
50
+
51
+ ##
52
+ # Begin a {MatViews::MatViewRun} row for lifecycle tracking.
53
+ #
54
+ # @api private
55
+ #
56
+ # @return [MatViews::MatViewRun]
57
+ #
58
+ def start_run(definition, operation)
59
+ MatViews::MatViewRun.create!(
60
+ mat_view_definition: definition,
61
+ status: :running,
62
+ started_at: Time.current,
63
+ operation: operation
64
+ )
65
+ end
66
+
67
+ ##
68
+ # Finalize the run with success/failure, timing, and meta from the response.
69
+ #
70
+ # @api private
71
+ #
72
+ # @param run [MatViews::MatViewRun]
73
+ # @param response [MatViews::ServiceResponse, nil] may be nil if exception raised
74
+ # @param duration_ms [Integer]
75
+ # @return [void]
76
+ #
77
+ def finalize_run(run, response, duration_ms)
78
+ base_attrs = {
79
+ finished_at: Time.current,
80
+ duration_ms: duration_ms,
81
+ meta: { request: response.request, response: response.response }.compact
82
+ }
83
+
84
+ if response.success?
85
+ run.update!(base_attrs.merge(status: :success, error: nil))
86
+ else
87
+ run.update!(base_attrs.merge(status: :failed, error: response.error))
88
+ end
89
+ end
90
+
91
+ ##
92
+ # Mark the run failed due to an exception.
93
+ #
94
+ # @api private
95
+ #
96
+ # @param run [MatViews::MatViewRun]
97
+ # @param exception [Exception]
98
+ # @param duration_ms [Integer]
99
+ # @return [void]
100
+ #
101
+ def fail_run(run, exception, duration_ms)
102
+ run&.update!(
103
+ error: exception.mv_serialize_error,
104
+ finished_at: Time.current,
105
+ duration_ms: duration_ms,
106
+ status: :failed
107
+ )
108
+ end
109
+
110
+ ##
111
+ # Monotonic clock getter (for elapsed-time measurement).
112
+ #
113
+ # @api private
114
+ # @return [Float] seconds
115
+ #
116
+ def monotime = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+
118
+ ##
119
+ # Convert monotonic start time to elapsed milliseconds.
120
+ #
121
+ # @api private
122
+ # @param start [Float]
123
+ # @return [Integer] elapsed ms
124
+ #
125
+ def elapsed_ms(start) = ((monotime - start) * 1000).round
126
+
127
+ ##
128
+ # Normalize the strategy argument into a symbol or default.
129
+ #
130
+ # @api private
131
+ #
132
+ # @param arg [Symbol, String, nil]
133
+ # @return [Symbol] One of `:estimated`, `:exact`, or `:none` by default.
134
+ #
135
+ def normalize_strategy(arg)
136
+ case arg
137
+ when String, Symbol
138
+ arg.to_sym
139
+ else
140
+ :none
141
+ end
142
+ end
38
143
  end
39
144
  end