underpost 3.2.8 → 3.2.10

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 (92) hide show
  1. package/.github/workflows/npmpkg.ci.yml +1 -0
  2. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  3. package/.github/workflows/release.cd.yml +1 -0
  4. package/.vscode/settings.json +10 -5
  5. package/CHANGELOG.md +223 -2
  6. package/CLI-HELP.md +36 -7
  7. package/README.md +38 -9
  8. package/bin/build.js +27 -11
  9. package/bin/deploy.js +20 -21
  10. package/bin/file.js +32 -13
  11. package/bin/index.js +2 -1
  12. package/bin/vs.js +1 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -4
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +2 -2
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
  19. package/manifests/kind-config-dev.yaml +8 -0
  20. package/manifests/mongodb/pv-pvc.yaml +44 -8
  21. package/manifests/mongodb/statefulset.yaml +55 -68
  22. package/package.json +40 -25
  23. package/scripts/k3s-node-setup.sh +30 -11
  24. package/scripts/nat-iptables.sh +103 -18
  25. package/src/api/core/core.router.js +19 -14
  26. package/src/api/core/core.service.js +5 -5
  27. package/src/api/default/default.router.js +22 -18
  28. package/src/api/default/default.service.js +5 -5
  29. package/src/api/document/document.router.js +28 -23
  30. package/src/api/document/document.service.js +100 -23
  31. package/src/api/file/file.router.js +19 -13
  32. package/src/api/file/file.service.js +9 -7
  33. package/src/api/test/test.router.js +17 -12
  34. package/src/api/types.js +24 -0
  35. package/src/api/user/guest.service.js +5 -4
  36. package/src/api/user/user.router.js +297 -288
  37. package/src/api/user/user.service.js +100 -35
  38. package/src/cli/baremetal.js +20 -11
  39. package/src/cli/cluster.js +243 -55
  40. package/src/cli/db.js +106 -62
  41. package/src/cli/deploy.js +297 -154
  42. package/src/cli/fs.js +19 -3
  43. package/src/cli/index.js +37 -9
  44. package/src/cli/ipfs.js +4 -6
  45. package/src/cli/kubectl.js +4 -1
  46. package/src/cli/lxd.js +217 -135
  47. package/src/cli/release.js +289 -131
  48. package/src/cli/repository.js +91 -34
  49. package/src/cli/run.js +297 -56
  50. package/src/cli/test.js +9 -3
  51. package/src/client/Default.index.js +9 -3
  52. package/src/client/components/core/Auth.js +19 -5
  53. package/src/client/components/core/Docs.js +6 -34
  54. package/src/client/components/core/FileExplorer.js +6 -6
  55. package/src/client/components/core/Modal.js +65 -2
  56. package/src/client/components/core/PanelForm.js +56 -52
  57. package/src/client/components/core/Recover.js +4 -4
  58. package/src/client/components/core/Worker.js +170 -350
  59. package/src/client/services/default/default.management.js +20 -25
  60. package/src/client/services/user/guest.service.js +10 -3
  61. package/src/client/sw/core.sw.js +174 -112
  62. package/src/db/DataBaseProvider.js +120 -20
  63. package/src/db/mongo/MongoBootstrap.js +587 -0
  64. package/src/db/mongo/MongooseDB.js +126 -22
  65. package/src/index.js +1 -1
  66. package/src/runtime/express/Express.js +2 -2
  67. package/src/runtime/wp/Wp.js +8 -5
  68. package/src/server/auth.js +2 -2
  69. package/src/server/client-build-docs.js +1 -1
  70. package/src/server/client-build.js +94 -129
  71. package/src/server/conf.js +20 -65
  72. package/src/server/data-query.js +32 -20
  73. package/src/server/dns.js +22 -0
  74. package/src/server/process.js +180 -19
  75. package/src/server/runtime.js +1 -1
  76. package/src/server/start.js +26 -7
  77. package/src/server/valkey.js +9 -2
  78. package/src/ws/IoInterface.js +16 -16
  79. package/src/ws/core/channels/core.ws.chat.js +11 -11
  80. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  81. package/src/ws/core/channels/core.ws.stream.js +19 -19
  82. package/src/ws/core/core.ws.connection.js +8 -8
  83. package/src/ws/core/core.ws.server.js +6 -5
  84. package/src/ws/default/channels/default.ws.main.js +10 -10
  85. package/src/ws/default/default.ws.connection.js +4 -4
  86. package/src/ws/default/default.ws.server.js +4 -3
  87. package/typedoc.json +10 -1
  88. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  89. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  90. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  91. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  92. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -675,28 +675,27 @@ class DefaultManagement {
675
675
  EventsUI.onClick(`.management-table-btn-clear-filter-${id}`, async () => {
676
676
  try {
677
677
  const gridApi = AgGrid.grids[gridId];
678
- // Clear all filters
679
- DefaultManagement.clearIdFilter(id);
680
- if (gridApi) {
681
- gridApi.setFilterModel({});
682
- gridApi.applyColumnState({ defaultState: { sort: null } });
683
- }
684
- // Clear token state
685
- if (DefaultManagement.Tokens[id]) {
686
- DefaultManagement.Tokens[id].filterModel = {};
687
- DefaultManagement.Tokens[id].sortModel = [];
688
- }
689
- // Update URL - keep only page and limit
690
- const queryParams = getQueryParams();
691
- setQueryParams({
692
- page: queryParams.page || 1,
693
- limit: queryParams.limit || DefaultManagement.Tokens[id]?.limit || 10,
694
- filterModel: null,
695
- sortModel: null,
696
- id: null,
678
+ await DefaultManagement.runIsolated(id, async () => {
679
+ // Clear all filters without letting grid/query listeners trigger their own reloads.
680
+ DefaultManagement.clearIdFilter(id);
681
+ if (gridApi) {
682
+ gridApi.setFilterModel({});
683
+ gridApi.applyColumnState({ defaultState: { sort: null } });
684
+ }
685
+ if (DefaultManagement.Tokens[id]) {
686
+ DefaultManagement.Tokens[id].filterModel = {};
687
+ DefaultManagement.Tokens[id].sortModel = [];
688
+ }
689
+ const queryParams = getQueryParams();
690
+ setQueryParams({
691
+ page: queryParams.page || 1,
692
+ limit: queryParams.limit || DefaultManagement.Tokens[id]?.limit || 10,
693
+ filterModel: null,
694
+ sortModel: null,
695
+ id: null,
696
+ });
697
+ await DefaultManagement.loadTable(id, { force: true, reload: true, skipUrlUpdate: true });
697
698
  });
698
- // Reload table
699
- await DefaultManagement.loadTable(id, { force: true, reload: true });
700
699
  NotificationManager.Push({
701
700
  html: Translate.instance('success-clear-filter') || 'Filters cleared',
702
701
  status: 'success',
@@ -729,15 +728,11 @@ class DefaultManagement {
729
728
  s(`#ag-pagination-${gridId}`).addEventListener('page-change', async (event) => {
730
729
  const token = DefaultManagement.Tokens[id];
731
730
  token.page = event.detail.page;
732
- // Skip URL update since Pagination component already updated it
733
- await DefaultManagement.loadTable(id, { skipUrlUpdate: true });
734
731
  });
735
732
  s(`#ag-pagination-${gridId}`).addEventListener('limit-change', async (event) => {
736
733
  const token = DefaultManagement.Tokens[id];
737
734
  token.limit = event.detail.limit;
738
735
  token.page = 1; // Reset to first page
739
- // Skip URL update since Pagination component already updated it
740
- await DefaultManagement.loadTable(id, { skipUrlUpdate: true });
741
736
  });
742
737
  RouterEvents[id] = async (...args) => {
743
738
  const queryParams = getQueryParams();
@@ -15,7 +15,14 @@ class SessionMetaDb extends Dexie {
15
15
  }
16
16
  }
17
17
 
18
- const db = new SessionMetaDb();
18
+ // Lazy singleton avoids opening IndexedDB at module-load time.
19
+ // Firefox is measurably slower at IDB open than Chromium; deferring the
20
+ // open until first actual read/write eliminates that latency from page startup.
21
+ let _db = null;
22
+ const getDb = () => {
23
+ if (!_db) _db = new SessionMetaDb();
24
+ return _db;
25
+ };
19
26
 
20
27
  class GuestService {
21
28
  static setUserToken(value = '') {
@@ -59,7 +66,7 @@ class GuestService {
59
66
 
60
67
  static async setMeta(key, value) {
61
68
  try {
62
- await db.meta.put({ key, value, updatedAt: Date.now() });
69
+ await getDb().meta.put({ key, value, updatedAt: Date.now() });
63
70
  } catch (error) {
64
71
  logger.warn('session meta write failed', { key, error: error?.message });
65
72
  }
@@ -67,7 +74,7 @@ class GuestService {
67
74
 
68
75
  static async getMeta(key) {
69
76
  try {
70
- const row = await db.meta.get(key);
77
+ const row = await getDb().meta.get(key);
71
78
  return row ? row.value : null;
72
79
  } catch (error) {
73
80
  logger.warn('session meta read failed', { key, error: error?.message });
@@ -8,48 +8,108 @@ import { ExpirationPlugin } from 'workbox-expiration';
8
8
  import { BackgroundSyncPlugin } from 'workbox-background-sync';
9
9
 
10
10
  // ─── Runtime config injected by client-build.js ───────────────────────────────
11
- const CACHE_PREFIX = self.renderPayload?.CACHE_PREFIX || 'engine-core-v3';
12
- const PRE_CACHED_RESOURCES = Array.isArray(self.renderPayload?.PRE_CACHED_RESOURCES)
13
- ? self.renderPayload.PRE_CACHED_RESOURCES
14
- : [];
15
- const PROXY_PATH = self.renderPayload?.PROXY_PATH || '/';
16
- const OFFLINE_PATH = self.renderPayload?.OFFLINE_PATH || '/offline';
17
- const MAINTENANCE_PATH = self.renderPayload?.MAINTENANCE_PATH || '/maintenance';
18
- const proxyBase = PROXY_PATH === '/' ? '' : PROXY_PATH;
19
- const normalizeRoutePath = (candidatePath, fallbackPath) => {
20
- const routePath = typeof candidatePath === 'string' && candidatePath.length > 0 ? candidatePath : fallbackPath;
21
- const withLeadingSlash = routePath.startsWith('/') ? routePath : `/${routePath}`;
22
- const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, '');
23
- return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : '/';
11
+ // OFFLINE_URL and MAINTENANCE_URL are absolute (proxy-prefixed) index.html paths
12
+ // resolved at build time from the SSR config's `fallbacks` map.
13
+ const payload = self.renderPayload || {};
14
+ const CACHE_PREFIX = payload.CACHE_PREFIX || 'engine-core';
15
+ const PRE_CACHED_RESOURCES = Array.isArray(payload.PRE_CACHED_RESOURCES) ? payload.PRE_CACHED_RESOURCES : [];
16
+ const OFFLINE_URL = payload.OFFLINE_URL || '/offline/index.html';
17
+ const MAINTENANCE_URL = payload.MAINTENANCE_URL || '/maintenance/index.html';
18
+
19
+ // Dedicated cache for fallback pages so they're available even if precacheAndRoute
20
+ // fails (e.g. a precache manifest entry returns non-200 during install).
21
+ const FALLBACK_CACHE = `${CACHE_PREFIX}-fallbacks`;
22
+
23
+ /**
24
+ * Ultimate inline HTML fallback used ONLY when the network is unavailable and
25
+ * the custom SSR pages (NoNetworkConnection / Maintenance views from config)
26
+ * have not yet been cached. Once cached on first successful visit, the custom
27
+ * pages take over.
28
+ * @param {'offline'|'maintenance'} kind
29
+ * @returns {Response}
30
+ */
31
+ const inlineFallback = (kind) => {
32
+ const title = kind === 'offline' ? 'Offline' : 'Maintenance';
33
+ const icon = kind === 'offline' ? '🌐' : '🔧';
34
+ const msg =
35
+ kind === 'offline'
36
+ ? 'You are offline. Please check your internet connection and try again.'
37
+ : 'The server is under maintenance. We\'ll be back shortly.';
38
+ const html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>${title}</title><style>body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f5f5;color:#333;padding:1rem;text-align:center}h1{font-size:2rem;margin-bottom:.5rem}p{font-size:1rem;color:#666;max-width:24rem}</style></head><body><h1>${icon} ${title}</h1><p>${msg}</p></body></html>`;
39
+ return new Response(html, {
40
+ status: 200,
41
+ statusText: 'OK',
42
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
43
+ });
24
44
  };
25
- const offlinePath = normalizeRoutePath(OFFLINE_PATH, '/offline');
26
- const maintenancePath = normalizeRoutePath(MAINTENANCE_PATH, '/maintenance');
27
- const toRouteIndexUrl = (routePath) => `${proxyBase}${routePath === '/' ? '' : routePath}/index.html`;
28
- const offlineUrl = toRouteIndexUrl(offlinePath);
29
- const maintenanceUrl = toRouteIndexUrl(maintenancePath);
30
-
31
- // Dedicated cache for fallback pages populated independently from precacheAndRoute
32
- // so offline/maintenance pages are always available even if the main precache install fails.
33
- const FALLBACK_CACHE_NAME = `${CACHE_PREFIX}-fallbacks`;
34
-
35
- const getFallbackResponse = async (preferMaintenance) => {
36
- const cache = await caches.open(FALLBACK_CACHE_NAME);
37
- if (preferMaintenance) {
38
- return (
39
- (await cache.match(maintenanceUrl)) ||
40
- (await matchPrecache(maintenanceUrl)) ||
41
- (await cache.match(offlineUrl)) ||
42
- (await matchPrecache(offlineUrl)) ||
43
- Response.error()
44
- );
45
+
46
+ /**
47
+ * Returns a Response for the cached fallback page.
48
+ * Tries (in order):
49
+ * 1. Dedicated FALLBACK_CACHE (primary & secondary URL)
50
+ * 2. Workbox precache (primary & secondary URL)
51
+ * 3. If online, fetches the custom SSR page from the server (caches it for next time)
52
+ * 4. Ultimate inline HTML string (never a browser error page)
53
+ *
54
+ * @param {'offline'|'maintenance'} kind
55
+ * @returns {Promise<Response>}
56
+ */
57
+ const getFallback = async (kind) => {
58
+ const primary = kind === 'maintenance' ? MAINTENANCE_URL : OFFLINE_URL;
59
+ const secondary = kind === 'maintenance' ? OFFLINE_URL : MAINTENANCE_URL;
60
+ const cache = await caches.open(FALLBACK_CACHE);
61
+
62
+ // ── 1. Try cache (own + precache) ──────────────────────────────────────
63
+ const cached =
64
+ (await cache.match(primary)) ||
65
+ (await matchPrecache(primary)) ||
66
+ (await cache.match(secondary)) ||
67
+ (await matchPrecache(secondary));
68
+ if (cached) return cached;
69
+
70
+ // ── 2. If we appear online, try to fetch the custom SSR page from server ─
71
+ // This handles the case where the install-time caching didn't happen yet
72
+ // (e.g. first install while server was up, but fallback pages weren't cached).
73
+ if (typeof navigator === 'undefined' || navigator.onLine !== false) {
74
+ try {
75
+ const fetchResp = await fetch(primary, { credentials: 'same-origin' });
76
+ if (fetchResp.ok) {
77
+ cache.put(primary, fetchResp.clone()).catch(() => { });
78
+ return fetchResp;
79
+ }
80
+ } catch (_) { }
81
+ try {
82
+ const fetchResp = await fetch(secondary, { credentials: 'same-origin' });
83
+ if (fetchResp.ok) {
84
+ cache.put(secondary, fetchResp.clone()).catch(() => { });
85
+ return fetchResp;
86
+ }
87
+ } catch (_) { }
88
+ }
89
+
90
+ // ── 3. Ultimate inline fallback ────────────────────────────────────────
91
+ return inlineFallback(kind);
92
+ };
93
+
94
+ /**
95
+ * Background-caches the offline and maintenance SSR pages for future use.
96
+ * Called after each successful navigation so the custom views are available
97
+ * the next time the network is unreachable.
98
+ */
99
+ const cacheFallbackPages = async () => {
100
+ const cache = await caches.open(FALLBACK_CACHE);
101
+ for (const url of [OFFLINE_URL, MAINTENANCE_URL]) {
102
+ try {
103
+ if (!(await cache.match(url))) {
104
+ const response = await fetch(url, { credentials: 'same-origin' });
105
+ if (response.ok) {
106
+ await cache.put(url, response);
107
+ }
108
+ }
109
+ } catch (_) {
110
+ // Network unavailable now — will try again on next navigation.
111
+ }
45
112
  }
46
- return (
47
- (await cache.match(offlineUrl)) ||
48
- (await matchPrecache(offlineUrl)) ||
49
- (await cache.match(maintenanceUrl)) ||
50
- (await matchPrecache(maintenanceUrl)) ||
51
- Response.error()
52
- );
53
113
  };
54
114
 
55
115
  // ─── Core setup ───────────────────────────────────────────────────────────────
@@ -58,17 +118,10 @@ clientsClaim();
58
118
 
59
119
  self.addEventListener('install', (event) => {
60
120
  self.skipWaiting();
61
- // Cache fallback pages in a dedicated cache so they are available even if
62
- // precacheAndRoute fails (e.g. some asset in the manifest returns non-200).
63
- event.waitUntil(
64
- caches.open(FALLBACK_CACHE_NAME).then((cache) =>
65
- // Try together first; fall back to individual adds so a single failure
66
- // does not prevent the other page from being cached.
67
- cache
68
- .addAll([offlineUrl, maintenanceUrl])
69
- .catch(() => Promise.all([cache.add(offlineUrl).catch(() => {}), cache.add(maintenanceUrl).catch(() => {})])),
70
- ),
71
- );
121
+ // Note: install-time caching of fallback pages is unreliable in some browsers.
122
+ // We rely on the post-navigation eager caching in the navigation handler.
123
+ // The inline HTML fallback ensures the user never sees a browser error page
124
+ // before the custom SSR pages are cached.
72
125
  });
73
126
 
74
127
  self.addEventListener('activate', (event) => {
@@ -82,21 +135,17 @@ self.addEventListener('activate', (event) => {
82
135
  });
83
136
 
84
137
  self.addEventListener('message', (event) => {
85
- const payload = event.data || {};
86
-
87
- if (payload.status === 'skipWaiting') {
138
+ const { status } = event.data || {};
139
+ if (status === 'skipWaiting') {
88
140
  self.skipWaiting();
89
141
  return;
90
142
  }
91
-
92
- if (payload.status === 'workbox-reset') {
143
+ if (status === 'workbox-reset') {
93
144
  event.waitUntil(
94
145
  (async () => {
95
- const cacheNames = await caches.keys();
96
- await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
97
- if (event.ports && event.ports[0]) {
98
- event.ports[0].postMessage({ status: 'workbox-reset-done', deleted: cacheNames.length });
99
- }
146
+ const names = await caches.keys();
147
+ await Promise.all(names.map((name) => caches.delete(name)));
148
+ event.ports?.[0]?.postMessage({ status: 'workbox-reset-done', deleted: names.length });
100
149
  })(),
101
150
  );
102
151
  }
@@ -115,13 +164,8 @@ registerRoute(
115
164
  new StaleWhileRevalidate({
116
165
  cacheName: `${CACHE_PREFIX}-assets`,
117
166
  plugins: [
118
- new CacheableResponsePlugin({
119
- statuses: [0, 200],
120
- }),
121
- new ExpirationPlugin({
122
- maxEntries: 350,
123
- maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
124
- }),
167
+ new CacheableResponsePlugin({ statuses: [0, 200] }),
168
+ new ExpirationPlugin({ maxEntries: 350, maxAgeSeconds: 30 * 24 * 60 * 60 }),
125
169
  ],
126
170
  }),
127
171
  );
@@ -133,13 +177,8 @@ registerRoute(
133
177
  cacheName: `${CACHE_PREFIX}-api-get`,
134
178
  networkTimeoutSeconds: 5,
135
179
  plugins: [
136
- new CacheableResponsePlugin({
137
- statuses: [0, 200],
138
- }),
139
- new ExpirationPlugin({
140
- maxEntries: 120,
141
- maxAgeSeconds: 5 * 60, // 5 minutes
142
- }),
180
+ new CacheableResponsePlugin({ statuses: [0, 200] }),
181
+ new ExpirationPlugin({ maxEntries: 120, maxAgeSeconds: 5 * 60 }),
143
182
  ],
144
183
  }),
145
184
  );
@@ -148,65 +187,88 @@ registerRoute(
148
187
  registerRoute(
149
188
  ({ request, url }) => request.method !== 'GET' && url.pathname.includes('/api/'),
150
189
  new NetworkOnly({
151
- plugins: [
152
- new BackgroundSyncPlugin('api-mutation-queue', {
153
- maxRetentionTime: 24 * 60, // 24 hours
154
- }),
155
- ],
190
+ plugins: [new BackgroundSyncPlugin('api-mutation-queue', { maxRetentionTime: 24 * 60 })],
156
191
  }),
157
192
  );
158
193
 
159
- // ─── Navigation: NetworkFirst with offline fallback ───────────────────────────
194
+ // ─── Navigation with offline/maintenance dispatcher ─────────────────────────
195
+ //
196
+ // CRITICAL: We use direct fetch() instead of NetworkFirst for navigation so
197
+ // that when the server is unreachable:
198
+ // - NetworkFirst would silently return a STALE cached real page (status 200)
199
+ // making it look like the app still works when it doesn't
200
+ // - fetch() throws on network failure → catch → fallback page
201
+ // - fetch() returns 5xx → maintenance fallback
202
+ //
203
+ // After each successful fetch, we eagerly cache the custom offline/maintenance
204
+ // SSR pages so they're available when the network goes down.
205
+ //
206
+ // The dispatcher logic:
207
+ // 1. If navigator.onLine === false → offline fallback immediately
208
+ // 2. Try navigation preload — if it succeeds (< 500) use it, else fall through
209
+ // 3. Try direct fetch() — if it succeeds (< 500) cache and return it
210
+ // 4. If fetch returned 5xx → maintenance fallback
211
+ // 5. If fetch threw (network down) → fallback based on connectivity
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+
160
214
  registerRoute(
161
215
  ({ request }) => request.mode === 'navigate',
162
216
  async ({ event }) => {
163
- const navigationStrategy = new NetworkFirst({
164
- cacheName: `${CACHE_PREFIX}-pages`,
165
- networkTimeoutSeconds: 4,
166
- plugins: [
167
- new CacheableResponsePlugin({
168
- statuses: [0, 200],
169
- }),
170
- new ExpirationPlugin({
171
- maxEntries: 60,
172
- maxAgeSeconds: 12 * 60 * 60, // 12 hours
173
- }),
174
- ],
175
- });
176
-
177
- // Distinguish server-down (online but unreachable) from no-network (offline).
178
- // navigator.onLine is false only when the device has no network at all.
179
- const isOnline = () => typeof navigator !== 'undefined' && navigator.onLine !== false;
217
+ // ── 1. Check browser offline state ────────────────────────────────────
218
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
219
+ return getFallback('offline');
220
+ }
180
221
 
222
+ // ── 2. Try navigation preload ────────────────────────────────────────
181
223
  try {
182
224
  const preload = await event.preloadResponse;
183
- if (preload) {
184
- if (preload.status >= 500) {
185
- return getFallbackResponse(true);
186
- }
225
+ if (preload && preload.status < 500) {
226
+ // Fire-and-forget: eagerly cache fallback pages in the background
227
+ // after a successful navigation so they're available when offline.
228
+ cacheFallbackPages().catch(() => { });
187
229
  return preload;
188
230
  }
231
+ } catch (_) {
232
+ // Preload threw — fall through to direct fetch
233
+ }
234
+
235
+ // ── 3. Try direct fetch to the server ─────────────────────────────────
236
+ try {
237
+ const response = await fetch(event.request);
238
+
239
+ if (response.status >= 500) {
240
+ return getFallback('maintenance');
241
+ }
242
+
243
+ if (response.ok) {
244
+ // Cache the successful response for future navigations
245
+ const cache = await caches.open(`${CACHE_PREFIX}-pages`);
246
+ cache.put(event.request, response.clone()).catch(() => { });
247
+
248
+ // Eagerly cache fallback pages in the background
249
+ cacheFallbackPages().catch(() => { });
189
250
 
190
- const networkResponse = await navigationStrategy.handle({ event, request: event.request });
191
- if (networkResponse && networkResponse.status >= 500) {
192
- return getFallbackResponse(true);
251
+ return response;
193
252
  }
194
- return networkResponse;
253
+
254
+ return response;
195
255
  } catch (_) {
196
- // If device reports it has network but the request failed, it means the
197
- // server is unreachable/down show maintenance. True offline → show offline.
198
- return getFallbackResponse(isOnline());
256
+ const reallyOffline =
257
+ typeof navigator !== 'undefined' && navigator.onLine === false;
258
+ return getFallback(reallyOffline ? 'offline' : 'maintenance');
199
259
  }
200
260
  },
201
261
  );
202
262
 
203
- // ─── Global catch handler ─────────────────────────────────────────────────────
263
+ // ─── Global catch handler (non-navigation failures) ──────────────────────────
204
264
  setCatchHandler(async ({ request }) => {
205
265
  if (request.mode === 'navigate') {
206
- return getFallbackResponse(typeof navigator !== 'undefined' && navigator.onLine !== false);
266
+ const reallyOffline =
267
+ typeof navigator !== 'undefined' && navigator.onLine === false;
268
+ return getFallback(reallyOffline ? 'offline' : 'maintenance');
207
269
  }
208
270
  return new Response(JSON.stringify({ status: 'error', message: 'request failed' }), {
209
271
  status: 503,
210
272
  headers: { 'Content-Type': 'application/json' },
211
273
  });
212
- });
274
+ });
@@ -1,5 +1,7 @@
1
1
  import { MongooseDB } from './mongo/MongooseDB.js';
2
2
  import { loggerFactory } from '../server/logger.js';
3
+ import { getCapVariableName } from '../client/components/core/CommonJs.js';
4
+ import { resolveHostKeyContext } from '../server/conf.js';
3
5
 
4
6
  /**
5
7
  * Module for managing and loading various database connections (e.g., Mongoose, MariaDB).
@@ -22,16 +24,108 @@ class DataBaseProviderService {
22
24
  * @type {object.<string, object>}
23
25
  * @method
24
26
  */
25
- #instance = {};
27
+ static #instance = {};
26
28
 
27
29
  /**
28
30
  * Retrieves the internal instance storage for direct access (used for backward compatibility).
29
31
  * @returns {object.<string, object>} The internal connection instance map.
30
32
  */
31
- get instance() {
33
+ static get instance() {
32
34
  return this.#instance;
33
35
  }
34
36
 
37
+
38
+ /**
39
+ * Retrieves a loaded provider bucket for a context.
40
+ * @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or key.
41
+ * @param {string} [provider='mongoose'] - Provider name.
42
+ * @returns {{models: object, connection: object, close: Function, dbSignature?: string}} Provider bucket.
43
+ * @throws {Error} When the provider is not loaded for the context.
44
+ */
45
+ static getProvider(context = { host: '', path: '' }, provider = 'mongoose') {
46
+ const key = resolveHostKeyContext(context);
47
+ const entry = this.#instance[key]?.[provider];
48
+
49
+ if (!entry) throw new Error(`Database provider not loaded for context "${key}" (${provider})`);
50
+ return entry;
51
+ }
52
+
53
+ /**
54
+ * Returns the raw DB connection object for a context/provider.
55
+ * @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or key.
56
+ * @param {string} [provider='mongoose'] - Provider name.
57
+ * @returns {object} Provider connection object.
58
+ */
59
+ static getConnection(context = { host: '', path: '' }, provider = 'mongoose') {
60
+ return this.getProvider(context, provider).connection;
61
+ }
62
+
63
+ /**
64
+ * Resolves a loaded model by name for a given context/provider.
65
+ * @param {string} modelName - API/model identifier.
66
+ * @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or key.
67
+ * @param {string} [provider='mongoose'] - Provider name.
68
+ * @returns {object} Loaded model instance.
69
+ * @throws {Error} When the model is not loaded for the context.
70
+ */
71
+ static getModel(modelName, context = { host: '', path: '' }, provider = 'mongoose') {
72
+ const models = this.getProvider(context, provider).models || {};
73
+ const normalizedModelName = getCapVariableName(modelName);
74
+
75
+ // First try direct key (supports callers passing exact model names).
76
+ let model = models?.[modelName];
77
+ if (!model) {
78
+ model = models?.[normalizedModelName];
79
+ }
80
+
81
+ if (!model) {
82
+ // Final fallback: case-insensitive comparison without separators.
83
+ const target = String(modelName || '')
84
+ .replaceAll('-', '')
85
+ .replaceAll('_', '')
86
+ .replaceAll(' ', '')
87
+ .toLowerCase();
88
+ const resolvedModelName = Object.keys(models).find(
89
+ (key) => key.replaceAll('_', '').replaceAll(' ', '').toLowerCase() === target,
90
+ );
91
+ if (resolvedModelName) model = models[resolvedModelName];
92
+ }
93
+
94
+ if (!model) {
95
+ throw new Error(`Model not loaded for context "${resolveHostKeyContext(context)}": ${normalizedModelName}`);
96
+ }
97
+
98
+ return model;
99
+ }
100
+
101
+ /**
102
+ * Builds a minimal dispatcher bound to a specific context/provider.
103
+ * @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or key.
104
+ * @param {string} [provider='mongoose'] - Provider name.
105
+ * @returns {{getConnection: () => object, getModel: (modelName: string) => object}} Bound accessor helpers.
106
+ */
107
+ static getDispatcher(context = { host: '', path: '' }, provider = 'mongoose') {
108
+ return {
109
+ getConnection: () => this.getConnection(context, provider),
110
+ getModel: (modelName) => this.getModel(modelName, context, provider),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Builds a stable signature used to detect provider configuration changes.
116
+ * @param {object} [db={}] - Database configuration object.
117
+ * @returns {string} Stringified signature for change detection.
118
+ */
119
+ static buildDbSignature(db = {}) {
120
+ return JSON.stringify({
121
+ authSource: db.authSource || '',
122
+ host: db.host || '',
123
+ name: db.name || '',
124
+ provider: db.provider || '',
125
+ replicaSet: db.replicaSet || '',
126
+ });
127
+ }
128
+
35
129
  /**
36
130
  * Loads and initializes a database provider based on the configuration.
37
131
  * If the connection is already loaded for the given host/path, it returns the existing instance.
@@ -48,21 +142,30 @@ class DataBaseProviderService {
48
142
  * @returns {Promise<object|undefined>} A promise that resolves to the initialized provider object
49
143
  * or `undefined` on error or if the provider is already loaded.
50
144
  */
51
- async load(options = { apis: [], host: '', path: '', db: {} }) {
145
+ static async load(options = { apis: [], host: '', path: '', db: {} }) {
52
146
  try {
53
147
  const { apis, host, path, db } = options;
54
- const key = `${host}${path}`;
148
+ const key = resolveHostKeyContext({ host, path });
149
+ const dbSignature = DataBaseProviderService.buildDbSignature(db);
55
150
 
56
151
  if (!this.#instance[key]) this.#instance[key] = {};
152
+ if (!db) return undefined;
57
153
 
58
- if (!db || this.#instance[key][db.provider]) return this.#instance[key][db.provider];
154
+ const currentProvider = this.#instance[key][db.provider];
155
+ if (currentProvider && currentProvider.dbSignature === dbSignature) return currentProvider;
156
+
157
+ if (currentProvider && currentProvider.close) {
158
+ await currentProvider.close();
159
+ delete this.#instance[key][db.provider];
160
+ }
59
161
 
60
162
  // logger.info(`Load ${db.provider} provider`, key);
61
163
  switch (db.provider) {
62
164
  case 'mongoose':
63
165
  {
64
- const conn = await MongooseDB.connect(db.host, db.name);
166
+ const conn = await MongooseDB.connect(db);
65
167
  this.#instance[key][db.provider] = {
168
+ dbSignature,
66
169
  models: await MongooseDB.loadModels({ conn, apis }),
67
170
  connection: conn,
68
171
  close: async () => {
@@ -88,12 +191,12 @@ class DataBaseProviderService {
88
191
  path: options.path,
89
192
  db: options.db
90
193
  ? {
91
- provider: options.db.provider,
92
- name: options.db.name ? '***' : undefined,
93
- host: options.db.host ? '***' : undefined,
94
- user: options.db.user ? '***' : undefined,
95
- password: options.db.password ? '***' : undefined,
96
- }
194
+ provider: options.db.provider,
195
+ name: options.db.name ? '***' : undefined,
196
+ host: options.db.host ? '***' : undefined,
197
+ user: options.db.user ? '***' : undefined,
198
+ password: options.db.password ? '***' : undefined,
199
+ }
97
200
  : {},
98
201
  };
99
202
  logger.error(error.message, { safeOptions });
@@ -102,12 +205,9 @@ class DataBaseProviderService {
102
205
  }
103
206
  }
104
207
 
105
- /**
106
- * Singleton instance of the DataBaseProviderService class for backward compatibility.
107
- * @alias DataBaseProvider
108
- * @memberof DataBaseProviderService
109
- * @type {DataBaseProviderService}
110
- */
111
- const DataBaseProvider = new DataBaseProviderService();
112
208
 
113
- export { DataBaseProvider, DataBaseProviderService as DataBaseProviderClass };
209
+ export {
210
+ DataBaseProviderService as DataBaseProviderClass,
211
+ DataBaseProviderService as default,
212
+ DataBaseProviderService
213
+ };