@objectstack/runtime 3.2.5 → 3.2.7

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.
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import { ObjectKernel, getEnv } from '@objectstack/core';
3
+ import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
4
4
  import { CoreServiceName } from '@objectstack/spec/system';
5
5
 
6
6
  /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
@@ -67,27 +67,52 @@ export class HttpDispatcher {
67
67
  }
68
68
 
69
69
  /**
70
- * Generates the discovery JSON response for the API root
70
+ * Generates the discovery JSON response for the API root.
71
+ *
72
+ * Uses the same async `resolveService()` fallback chain that request
73
+ * handlers use, so the reported service status is always consistent
74
+ * with the actual runtime availability.
71
75
  */
72
- getDiscoveryInfo(prefix: string) {
73
- const services = this.getServicesMap();
74
-
75
- // All services are plugin-provided — check if a plugin has registered them
76
- const hasAuth = !!services[CoreServiceName.enum.auth];
77
- const hasGraphQL = !!(services[CoreServiceName.enum.graphql] || this.kernel.graphql);
78
- const hasSearch = !!services[CoreServiceName.enum.search];
79
- const hasWebSockets = !!services[CoreServiceName.enum.realtime];
80
- const hasFiles = !!(services[CoreServiceName.enum['file-storage']] || services['storage']?.supportsFiles);
81
- const hasAnalytics = !!services[CoreServiceName.enum.analytics];
82
- const hasWorkflow = !!services[CoreServiceName.enum.workflow];
83
- const hasAi = !!services[CoreServiceName.enum.ai];
84
- const hasNotification = !!services[CoreServiceName.enum.notification];
85
- const hasI18n = !!services[CoreServiceName.enum.i18n];
86
- const hasUi = !!services[CoreServiceName.enum.ui];
87
- const hasAutomation = !!services[CoreServiceName.enum.automation];
88
- const hasCache = !!services[CoreServiceName.enum.cache];
89
- const hasQueue = !!services[CoreServiceName.enum.queue];
90
- const hasJob = !!services[CoreServiceName.enum.job];
76
+ async getDiscoveryInfo(prefix: string) {
77
+ // Resolve all services through the same async fallback chain
78
+ // that request handlers (handleI18n, handleAuth, …) use.
79
+ const [
80
+ authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc,
81
+ analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc,
82
+ uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc,
83
+ ] = await Promise.all([
84
+ this.resolveService(CoreServiceName.enum.auth),
85
+ this.resolveService(CoreServiceName.enum.graphql),
86
+ this.resolveService(CoreServiceName.enum.search),
87
+ this.resolveService(CoreServiceName.enum.realtime),
88
+ this.resolveService(CoreServiceName.enum['file-storage']),
89
+ this.resolveService(CoreServiceName.enum.analytics),
90
+ this.resolveService(CoreServiceName.enum.workflow),
91
+ this.resolveService(CoreServiceName.enum.ai),
92
+ this.resolveService(CoreServiceName.enum.notification),
93
+ this.resolveService(CoreServiceName.enum.i18n),
94
+ this.resolveService(CoreServiceName.enum.ui),
95
+ this.resolveService(CoreServiceName.enum.automation),
96
+ this.resolveService(CoreServiceName.enum.cache),
97
+ this.resolveService(CoreServiceName.enum.queue),
98
+ this.resolveService(CoreServiceName.enum.job),
99
+ ]);
100
+
101
+ const hasAuth = !!authSvc;
102
+ const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
103
+ const hasSearch = !!searchSvc;
104
+ const hasWebSockets = !!realtimeSvc;
105
+ const hasFiles = !!filesSvc;
106
+ const hasAnalytics = !!analyticsSvc;
107
+ const hasWorkflow = !!workflowSvc;
108
+ const hasAi = !!aiSvc;
109
+ const hasNotification = !!notificationSvc;
110
+ const hasI18n = !!i18nSvc;
111
+ const hasUi = !!uiSvc;
112
+ const hasAutomation = !!automationSvc;
113
+ const hasCache = !!cacheSvc;
114
+ const hasQueue = !!queueSvc;
115
+ const hasJob = !!jobSvc;
91
116
 
92
117
  // Routes are only exposed when a plugin provides the service
93
118
  const routes = {
@@ -116,6 +141,20 @@ export class HttpDispatcher {
116
141
  message: `Install a ${name} plugin to enable`,
117
142
  });
118
143
 
144
+ // Derive locale info from actual i18n service when available
145
+ let locale = { default: 'en', supported: ['en'], timezone: 'UTC' };
146
+ if (hasI18n && i18nSvc) {
147
+ const defaultLocale = typeof i18nSvc.getDefaultLocale === 'function'
148
+ ? i18nSvc.getDefaultLocale() : 'en';
149
+ const locales = typeof i18nSvc.getLocales === 'function'
150
+ ? i18nSvc.getLocales() : [];
151
+ locale = {
152
+ default: defaultLocale,
153
+ supported: locales.length > 0 ? locales : [defaultLocale],
154
+ timezone: 'UTC',
155
+ };
156
+ }
157
+
119
158
  return {
120
159
  name: 'ObjectOS',
121
160
  version: '1.0.0',
@@ -154,11 +193,7 @@ export class HttpDispatcher {
154
193
  'file-storage': hasFiles ? svcAvailable(routes.storage) : svcUnavailable('file-storage'),
155
194
  search: hasSearch ? svcAvailable() : svcUnavailable('search'),
156
195
  },
157
- locale: {
158
- default: 'en',
159
- supported: ['en', 'zh-CN'],
160
- timezone: 'UTC'
161
- }
196
+ locale,
162
197
  };
163
198
  }
164
199
 
@@ -585,15 +620,36 @@ export class HttpDispatcher {
585
620
  if (parts[0] === 'translations') {
586
621
  const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
587
622
  if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
588
- const translations = i18nService.getTranslations(locale);
623
+
624
+ let translations = i18nService.getTranslations(locale);
625
+
626
+ // Locale fallback: try resolving to an available locale when
627
+ // the exact code yields empty translations (e.g. zh → zh-CN).
628
+ if (Object.keys(translations).length === 0) {
629
+ const availableLocales = typeof i18nService.getLocales === 'function'
630
+ ? i18nService.getLocales() : [];
631
+ const resolved = resolveLocale(locale, availableLocales);
632
+ if (resolved && resolved !== locale) {
633
+ translations = i18nService.getTranslations(resolved);
634
+ return { handled: true, response: this.success({ locale: resolved, requestedLocale: locale, translations }) };
635
+ }
636
+ }
637
+
589
638
  return { handled: true, response: this.success({ locale, translations }) };
590
639
  }
591
640
 
592
641
  // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
593
642
  if (parts[0] === 'labels' && parts.length >= 2) {
594
643
  const objectName = decodeURIComponent(parts[1]);
595
- const locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
644
+ let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
596
645
  if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
646
+
647
+ // Locale fallback for labels endpoint
648
+ const availableLocales = typeof i18nService.getLocales === 'function'
649
+ ? i18nService.getLocales() : [];
650
+ const resolved = resolveLocale(locale, availableLocales);
651
+ if (resolved) locale = resolved;
652
+
597
653
  if (typeof i18nService.getFieldLabels === 'function') {
598
654
  const labels = i18nService.getFieldLabels(objectName, locale);
599
655
  return { handled: true, response: this.success({ object: objectName, locale, labels }) };
@@ -1056,7 +1112,7 @@ export class HttpDispatcher {
1056
1112
  // Handles request to base URL (e.g. /api/v1) which MSW strips to empty string
1057
1113
  if (cleanPath === '' && method === 'GET') {
1058
1114
  // We use '' as prefix since we are internal dispatcher
1059
- const info = this.getDiscoveryInfo('');
1115
+ const info = await this.getDiscoveryInfo('');
1060
1116
  return {
1061
1117
  handled: true,
1062
1118
  response: this.success(info)
@@ -13,6 +13,7 @@ import type {
13
13
  DatasetLoadResult,
14
14
  Dataset,
15
15
  } from '@objectstack/spec/data';
16
+ import { SeedLoaderConfigSchema } from '@objectstack/spec/data';
16
17
 
17
18
  interface Logger {
18
19
  info(message: string, meta?: Record<string, any>): void;
@@ -152,7 +153,6 @@ export class SeedLoaderService implements ISeedLoaderService {
152
153
  }
153
154
 
154
155
  async validate(datasets: Dataset[], config?: SeedLoaderConfigInput): Promise<SeedLoaderResult> {
155
- const { SeedLoaderConfigSchema } = await import('@objectstack/spec/data');
156
156
  const parsedConfig = SeedLoaderConfigSchema.parse({ ...config, dryRun: true });
157
157
  return this.load({ datasets, config: parsedConfig });
158
158
  }