@objectstack/client 4.0.2 → 4.0.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/client",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Official Client SDK for ObjectStack Protocol",
6
6
  "main": "dist/index.js",
@@ -13,20 +13,20 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "4.0.2",
17
- "@objectstack/spec": "4.0.2"
16
+ "@objectstack/core": "4.0.4",
17
+ "@objectstack/spec": "4.0.4"
18
18
  },
19
19
  "devDependencies": {
20
- "@hono/node-server": "^1.19.12",
21
- "msw": "^2.12.14",
20
+ "@hono/node-server": "^1.19.14",
21
+ "msw": "^2.13.2",
22
22
  "typescript": "^6.0.2",
23
- "vitest": "^4.1.2",
24
- "@objectstack/driver-memory": "4.0.2",
25
- "@objectstack/hono": "4.0.2",
26
- "@objectstack/objectql": "4.0.2",
27
- "@objectstack/plugin-hono-server": "4.0.2",
28
- "@objectstack/plugin-msw": "4.0.2",
29
- "@objectstack/runtime": "4.0.2"
23
+ "vitest": "^4.1.4",
24
+ "@objectstack/driver-memory": "4.0.4",
25
+ "@objectstack/hono": "4.0.4",
26
+ "@objectstack/objectql": "4.0.4",
27
+ "@objectstack/plugin-hono-server": "4.0.4",
28
+ "@objectstack/plugin-msw": "4.0.4",
29
+ "@objectstack/runtime": "4.0.4"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsup --config ../../tsup.config.ts",
@@ -107,11 +107,19 @@ describe('ObjectStackClient (with Hono Server)', () => {
107
107
  baseUrl = `http://localhost:${port}`;
108
108
 
109
109
  console.log(`Test server running at ${baseUrl}`);
110
- });
110
+ }, 30_000);
111
111
 
112
112
  afterAll(async () => {
113
- if (kernel) await kernel.shutdown();
114
- });
113
+ if (kernel) {
114
+ // Race shutdown against a hard deadline.
115
+ // kernel.shutdown() can hang when pino's flush callback never fires
116
+ // in CI (worker-thread transport timing issues), so cap the wait.
117
+ await Promise.race([
118
+ kernel.shutdown(),
119
+ new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
120
+ ]);
121
+ }
122
+ }, 30_000);
115
123
 
116
124
  it('should connect to hono server and discover endpoints', async () => {
117
125
  const client = new ObjectStackClient({ baseUrl });
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import { QueryAST, SortNode, AggregationNode, isFilterAST } from '@objectstack/spec/data';
4
- import {
5
- BatchUpdateRequest,
6
- BatchUpdateResponse,
4
+ import {
5
+ BatchUpdateRequest,
6
+ BatchUpdateResponse,
7
7
  UpdateManyRequest,
8
8
  DeleteManyRequest,
9
9
  BatchOptions,
@@ -88,6 +88,7 @@ import {
88
88
  ApiRoutes,
89
89
  } from '@objectstack/spec/api';
90
90
  import { Logger, createLogger } from '@objectstack/core';
91
+ import { RealtimeAPI } from './realtime-api';
91
92
 
92
93
  /**
93
94
  * Route types that the client can resolve.
@@ -228,18 +229,22 @@ export class ObjectStackClient {
228
229
  private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
229
230
  private discoveryInfo?: DiscoveryResult;
230
231
  private logger: Logger;
232
+ private realtimeAPI: RealtimeAPI;
231
233
 
232
234
  constructor(config: ClientConfig) {
233
235
  this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
234
236
  this.token = config.token;
235
237
  this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
236
-
238
+
237
239
  // Initialize logger
238
- this.logger = config.logger || createLogger({
240
+ this.logger = config.logger || createLogger({
239
241
  level: config.debug ? 'debug' : 'info',
240
242
  format: 'pretty'
241
243
  });
242
-
244
+
245
+ // Initialize realtime API
246
+ this.realtimeAPI = new RealtimeAPI(this.baseUrl, this.token);
247
+
243
248
  this.logger.debug('ObjectStack client created', { baseUrl: this.baseUrl });
244
249
  }
245
250
 
@@ -248,12 +253,26 @@ export class ObjectStackClient {
248
253
  */
249
254
  async connect() {
250
255
  this.logger.debug('Connecting to ObjectStack server', { baseUrl: this.baseUrl });
251
-
256
+
252
257
  try {
253
258
  let data: DiscoveryResult | undefined;
254
259
 
255
- // 1. Try Standard Discovery (.well-known)
260
+ // 1. Try Protocol-standard Discovery Path /api/v1/discovery (primary)
256
261
  try {
262
+ const discoveryUrl = `${this.baseUrl}/api/v1/discovery`;
263
+ this.logger.debug('Probing protocol-standard discovery endpoint', { url: discoveryUrl });
264
+ const res = await this.fetchImpl(discoveryUrl);
265
+ if (res.ok) {
266
+ const body = await res.json();
267
+ data = body.data || body;
268
+ this.logger.debug('Discovered via /api/v1/discovery');
269
+ }
270
+ } catch (e) {
271
+ this.logger.debug('Protocol-standard discovery probe failed', { error: (e as Error).message });
272
+ }
273
+
274
+ // 2. Fallback to Standard Discovery (.well-known)
275
+ if (!data) {
257
276
  let wellKnownUrl: string;
258
277
  try {
259
278
  // If baseUrl is absolute, get origin
@@ -264,24 +283,10 @@ export class ObjectStackClient {
264
283
  wellKnownUrl = '/.well-known/objectstack';
265
284
  }
266
285
 
267
- this.logger.debug('Probing .well-known discovery', { url: wellKnownUrl });
286
+ this.logger.debug('Falling back to .well-known discovery', { url: wellKnownUrl });
268
287
  const res = await this.fetchImpl(wellKnownUrl);
269
- if (res.ok) {
270
- const body = await res.json();
271
- data = body.data || body;
272
- this.logger.debug('Discovered via .well-known');
273
- }
274
- } catch (e) {
275
- this.logger.debug('Standard discovery probe failed', { error: (e as Error).message });
276
- }
277
-
278
- // 2. Fallback to Protocol-standard Discovery Path /api/v1/discovery
279
- if (!data) {
280
- const fallbackUrl = `${this.baseUrl}/api/v1/discovery`;
281
- this.logger.debug('Falling back to standard discovery endpoint', { url: fallbackUrl });
282
- const res = await this.fetchImpl(fallbackUrl);
283
288
  if (!res.ok) {
284
- throw new Error(`Failed to connect to ${fallbackUrl}: ${res.statusText}`);
289
+ throw new Error(`Failed to connect to ${wellKnownUrl}: ${res.statusText}`);
285
290
  }
286
291
  const body = await res.json();
287
292
  data = body.data || body;
@@ -292,13 +297,13 @@ export class ObjectStackClient {
292
297
  }
293
298
 
294
299
  this.discoveryInfo = data;
295
-
296
- this.logger.info('Connected to ObjectStack server', {
300
+
301
+ this.logger.info('Connected to ObjectStack server', {
297
302
  version: data.version,
298
303
  apiName: data.apiName,
299
- services: data.services
304
+ services: data.services
300
305
  });
301
-
306
+
302
307
  return data as DiscoveryResult;
303
308
  } catch (e) {
304
309
  this.logger.error('Failed to connect to ObjectStack server', e as Error, { baseUrl: this.baseUrl });
@@ -573,6 +578,16 @@ export class ObjectStackClient {
573
578
  * Authentication Services
574
579
  */
575
580
  auth = {
581
+ /**
582
+ * Get authentication configuration
583
+ * Returns available auth providers and features
584
+ */
585
+ getConfig: async () => {
586
+ const route = this.getRoute('auth');
587
+ const res = await this.fetch(`${this.baseUrl}${route}/config`);
588
+ return this.unwrapResponse(res);
589
+ },
590
+
576
591
  /**
577
592
  * Login with email and password
578
593
  * Uses better-auth endpoint: POST /sign-in/email
@@ -887,6 +902,14 @@ export class ObjectStackClient {
887
902
  },
888
903
  };
889
904
 
905
+ /**
906
+ * Event Subscription API
907
+ * Provides real-time event subscriptions for metadata and data changes
908
+ */
909
+ get events() {
910
+ return this.realtimeAPI;
911
+ }
912
+
890
913
  /**
891
914
  * Permissions Services
892
915
  */
@@ -1789,6 +1812,9 @@ export class ObjectStackClient {
1789
1812
  // Re-export type-safe query builder
1790
1813
  export { QueryBuilder, FilterBuilder, createQuery, createFilter } from './query-builder';
1791
1814
 
1815
+ // Re-export realtime API types
1816
+ export { RealtimeAPI, RealtimeSubscriptionFilter, RealtimeEventHandler } from './realtime-api';
1817
+
1792
1818
  // Re-export commonly used types from @objectstack/spec/api for convenience
1793
1819
  export type {
1794
1820
  BatchUpdateRequest,
@@ -1856,4 +1882,8 @@ export type {
1856
1882
  SubscribeResponse,
1857
1883
  UnsubscribeResponse,
1858
1884
  WellKnownCapabilities,
1885
+ GetAuthConfigResponse,
1886
+ AuthProviderInfo,
1887
+ EmailPasswordConfigPublic,
1888
+ AuthFeaturesConfig,
1859
1889
  } from '@objectstack/spec/api';
@@ -0,0 +1,208 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Realtime API Module for ObjectStackClient
5
+ *
6
+ * Provides real-time event subscription capabilities using long-polling.
7
+ * For production WebSocket/SSE support, extend with transport adapters.
8
+ */
9
+
10
+ import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
11
+ import type { MetadataEvent, DataEvent } from '@objectstack/spec/api';
12
+
13
+ export interface RealtimeSubscriptionFilter {
14
+ /** Metadata/object type filter */
15
+ type?: string;
16
+ /** Package ID filter */
17
+ packageId?: string;
18
+ /** Event types to listen for */
19
+ eventTypes?: string[];
20
+ /** Record ID filter (for data events) */
21
+ recordId?: string;
22
+ }
23
+
24
+ export type RealtimeEventHandler = (event: RealtimeEventPayload) => void;
25
+
26
+ /**
27
+ * Realtime API for subscribing to server events
28
+ *
29
+ * Note: Currently uses in-memory adapter. WebSocket/SSE transport planned for future.
30
+ */
31
+ export class RealtimeAPI {
32
+ // @ts-expect-error - Reserved for future WebSocket/SSE implementation
33
+ private _baseUrl: string;
34
+ // @ts-expect-error - Reserved for future WebSocket/SSE implementation
35
+ private _token?: string;
36
+ private subscriptions = new Map<string, {
37
+ filter: RealtimeSubscriptionFilter;
38
+ handler: RealtimeEventHandler;
39
+ }>();
40
+ private pollInterval?: ReturnType<typeof setInterval>;
41
+ private eventBuffer: RealtimeEventPayload[] = [];
42
+
43
+ constructor(baseUrl: string, token?: string) {
44
+ this._baseUrl = baseUrl;
45
+ this._token = token;
46
+ }
47
+
48
+ /**
49
+ * Subscribe to metadata events
50
+ * Returns an unsubscribe function
51
+ */
52
+ subscribeMetadata(
53
+ type: string,
54
+ callback: (event: MetadataEvent) => void,
55
+ options?: { packageId?: string }
56
+ ): () => void {
57
+ const subscriptionId = `metadata-${type}-${Date.now()}`;
58
+
59
+ this.subscriptions.set(subscriptionId, {
60
+ filter: {
61
+ type,
62
+ packageId: options?.packageId,
63
+ eventTypes: [
64
+ `metadata.${type}.created`,
65
+ `metadata.${type}.updated`,
66
+ `metadata.${type}.deleted`
67
+ ]
68
+ },
69
+ handler: (event) => {
70
+ // Type guard and filter
71
+ if (event.type.startsWith('metadata.')) {
72
+ callback(event as any as MetadataEvent);
73
+ }
74
+ }
75
+ });
76
+
77
+ // Start polling if not already started
78
+ this.startPolling();
79
+
80
+ // Return unsubscribe function
81
+ return () => {
82
+ this.subscriptions.delete(subscriptionId);
83
+ if (this.subscriptions.size === 0) {
84
+ this.stopPolling();
85
+ }
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Subscribe to data record events
91
+ * Returns an unsubscribe function
92
+ */
93
+ subscribeData(
94
+ object: string,
95
+ callback: (event: DataEvent) => void,
96
+ options?: { recordId?: string }
97
+ ): () => void {
98
+ const subscriptionId = `data-${object}-${Date.now()}`;
99
+
100
+ this.subscriptions.set(subscriptionId, {
101
+ filter: {
102
+ type: object,
103
+ recordId: options?.recordId,
104
+ eventTypes: [
105
+ 'data.record.created',
106
+ 'data.record.updated',
107
+ 'data.record.deleted'
108
+ ]
109
+ },
110
+ handler: (event) => {
111
+ // Type guard and filter
112
+ if (event.type.startsWith('data.') && event.object === object) {
113
+ if (!options?.recordId || (event.payload as any)?.recordId === options.recordId) {
114
+ callback(event as any as DataEvent);
115
+ }
116
+ }
117
+ }
118
+ });
119
+
120
+ // Start polling if not already started
121
+ this.startPolling();
122
+
123
+ // Return unsubscribe function
124
+ return () => {
125
+ this.subscriptions.delete(subscriptionId);
126
+ if (this.subscriptions.size === 0) {
127
+ this.stopPolling();
128
+ }
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Emit an event to all matching subscriptions (client-side only)
134
+ * This is used for in-process event delivery
135
+ */
136
+ private emitEvent(event: RealtimeEventPayload): void {
137
+ for (const sub of this.subscriptions.values()) {
138
+ // Check if event matches subscription filters
139
+ const matchesType = !sub.filter.type ||
140
+ event.type.includes(sub.filter.type) ||
141
+ event.object === sub.filter.type;
142
+
143
+ const matchesEventType = !sub.filter.eventTypes?.length ||
144
+ sub.filter.eventTypes.includes(event.type);
145
+
146
+ const matchesPackage = !sub.filter.packageId ||
147
+ (event.payload as any)?.packageId === sub.filter.packageId;
148
+
149
+ if (matchesType && matchesEventType && matchesPackage) {
150
+ try {
151
+ sub.handler(event);
152
+ } catch (error) {
153
+ console.error('Error in realtime event handler:', error);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Start polling for events (fallback mechanism)
161
+ * In production, this would be replaced with WebSocket/SSE
162
+ */
163
+ private startPolling(): void {
164
+ if (this.pollInterval) return;
165
+
166
+ // For now, we rely on the in-memory adapter within the same process
167
+ // Events are delivered synchronously via the IRealtimeService
168
+ // This polling is a placeholder for future WebSocket/SSE implementation
169
+
170
+ // Poll every 2 seconds for buffered events
171
+ this.pollInterval = setInterval(() => {
172
+ // Process any buffered events
173
+ while (this.eventBuffer.length > 0) {
174
+ const event = this.eventBuffer.shift();
175
+ if (event) {
176
+ this.emitEvent(event);
177
+ }
178
+ }
179
+ }, 2000);
180
+ }
181
+
182
+ /**
183
+ * Stop polling for events
184
+ */
185
+ private stopPolling(): void {
186
+ if (this.pollInterval) {
187
+ clearInterval(this.pollInterval);
188
+ this.pollInterval = undefined;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Internal method to buffer events from server
194
+ * This would be called by WebSocket/SSE handlers in production
195
+ */
196
+ _bufferEvent(event: RealtimeEventPayload): void {
197
+ this.eventBuffer.push(event);
198
+ }
199
+
200
+ /**
201
+ * Disconnect and clean up all subscriptions
202
+ */
203
+ disconnect(): void {
204
+ this.stopPolling();
205
+ this.subscriptions.clear();
206
+ this.eventBuffer = [];
207
+ }
208
+ }
@@ -13,15 +13,15 @@ import { ObjectStackClient } from '../../src/index';
13
13
  const TEST_SERVER_URL = process.env.TEST_SERVER_URL || 'http://localhost:3000';
14
14
 
15
15
  describe('Discovery & Connection', () => {
16
- describe('TC-DISC-001: Standard Discovery via .well-known', () => {
17
- test('should discover API from .well-known/objectstack', async () => {
18
- const client = new ObjectStackClient({
16
+ describe('TC-DISC-001: Protocol-standard Discovery via /api/v1/discovery', () => {
17
+ test('should discover API from /api/v1/discovery', async () => {
18
+ const client = new ObjectStackClient({
19
19
  baseUrl: TEST_SERVER_URL,
20
20
  debug: true
21
21
  });
22
-
22
+
23
23
  const discovery = await client.connect();
24
-
24
+
25
25
  expect(discovery.version).toBeDefined();
26
26
  expect(discovery.apiName).toBeDefined();
27
27
  expect(discovery.routes).toBeDefined();