@myko/ui-vue 4.2.0-canary.3
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/.prettierrc +6 -0
- package/README.md +88 -0
- package/package.json +44 -0
- package/src/lib/composables/index.ts +3 -0
- package/src/lib/composables/useConnection.ts +57 -0
- package/src/lib/composables/useQuery.ts +58 -0
- package/src/lib/composables/useReport.ts +46 -0
- package/src/lib/index.ts +24 -0
- package/src/lib/services/vue-client.ts +345 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +6 -0
package/.prettierrc
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @myko/ui-vue
|
|
2
|
+
|
|
3
|
+
Vue 3 reactive wrappers for the Myko client.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @myko/ui-vue
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Using Composables (Recommended)
|
|
14
|
+
|
|
15
|
+
```vue
|
|
16
|
+
<script setup>
|
|
17
|
+
import { useQuery, useReport, useConnection } from '@myko/ui-vue'
|
|
18
|
+
import { queries, reports } from '@rship/entities'
|
|
19
|
+
|
|
20
|
+
// Connection management
|
|
21
|
+
const { status, isConnected, connect, disconnect } = useConnection()
|
|
22
|
+
|
|
23
|
+
// Query data with automatic cleanup
|
|
24
|
+
const { items, itemsArray, resolved } = useQuery(queries.GetAllTargets({}))
|
|
25
|
+
|
|
26
|
+
// Report data with automatic cleanup
|
|
27
|
+
const { value: count } = useReport(reports.CountAllTargets({}))
|
|
28
|
+
|
|
29
|
+
// Connect on mount
|
|
30
|
+
connect('ws://localhost:5155')
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div>Status: {{ status }}</div>
|
|
35
|
+
<div v-if="!resolved">Loading...</div>
|
|
36
|
+
<div v-for="target in itemsArray" :key="target.id">
|
|
37
|
+
{{ target.name }}
|
|
38
|
+
</div>
|
|
39
|
+
<div>Total: {{ count?.count ?? 0 }}</div>
|
|
40
|
+
</template>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Using the Client Directly
|
|
44
|
+
|
|
45
|
+
```vue
|
|
46
|
+
<script setup>
|
|
47
|
+
import { onUnmounted } from 'vue'
|
|
48
|
+
import { getMykoClient } from '@myko/ui-vue'
|
|
49
|
+
import { queries, commands } from '@rship/entities'
|
|
50
|
+
|
|
51
|
+
const client = getMykoClient()
|
|
52
|
+
|
|
53
|
+
// Manual query subscription
|
|
54
|
+
const { items, release } = client.query(queries.GetAllTargets({}))
|
|
55
|
+
onUnmounted(release)
|
|
56
|
+
|
|
57
|
+
// Send commands
|
|
58
|
+
async function deleteTarget(id: string) {
|
|
59
|
+
await client.sendCommand(commands.DeleteTarget({ targetId: id }))
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
### Composables
|
|
67
|
+
|
|
68
|
+
- `useQuery(queryFactory)` - Watch a query with reactive Map updates
|
|
69
|
+
- `useReport(reportFactory)` - Watch a report with reactive value
|
|
70
|
+
- `useConnection()` - Manage connection status
|
|
71
|
+
|
|
72
|
+
### Client
|
|
73
|
+
|
|
74
|
+
- `getMykoClient()` - Get the global singleton client
|
|
75
|
+
- `createMykoClient()` - Create a new client instance
|
|
76
|
+
- `myko` - Direct access to the global client
|
|
77
|
+
|
|
78
|
+
## Types
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import type {
|
|
82
|
+
ReactiveQuery,
|
|
83
|
+
ReactiveReport,
|
|
84
|
+
UseQueryReturn,
|
|
85
|
+
UseReportReturn,
|
|
86
|
+
UseConnectionReturn
|
|
87
|
+
} from '@myko/ui-vue'
|
|
88
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@myko/core": "workspace:*"
|
|
4
|
+
},
|
|
5
|
+
"devDependencies": {
|
|
6
|
+
"@types/luxon": "^3.4.2",
|
|
7
|
+
"@vitejs/plugin-vue": "^5.0.0",
|
|
8
|
+
"luxon": "^3.5.0",
|
|
9
|
+
"prettier": "^3.3.2",
|
|
10
|
+
"rxjs": "^7.8.1",
|
|
11
|
+
"typescript": "^5.0.0",
|
|
12
|
+
"vite": "^5.0.11",
|
|
13
|
+
"vite-plugin-dts": "^3.7.0",
|
|
14
|
+
"vue": "^3.4.0",
|
|
15
|
+
"vue-tsc": "^2.0.0"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"default": "./src/lib/index.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"flux": {
|
|
23
|
+
"tasks": [
|
|
24
|
+
"build",
|
|
25
|
+
"check",
|
|
26
|
+
"publish"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"main": "./src/lib/index.ts",
|
|
30
|
+
"name": "@myko/ui-vue",
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"vue": "^3.4.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "vue-tsc --noEmit",
|
|
36
|
+
"check": "vue-tsc --noEmit",
|
|
37
|
+
"dev": "vite build --watch",
|
|
38
|
+
"format": "prettier --write .",
|
|
39
|
+
"lint": "prettier --check ."
|
|
40
|
+
},
|
|
41
|
+
"type": "module",
|
|
42
|
+
"types": "./src/lib/index.ts",
|
|
43
|
+
"version": "4.2.0-canary.3"
|
|
44
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from 'vue';
|
|
2
|
+
import { ConnectionStatus } from '@myko/core';
|
|
3
|
+
import { getMykoClient } from '../services/vue-client';
|
|
4
|
+
|
|
5
|
+
export interface UseConnectionReturn {
|
|
6
|
+
/** Current connection status (reactive) */
|
|
7
|
+
status: Ref<ConnectionStatus>;
|
|
8
|
+
/** Whether currently connected (computed) */
|
|
9
|
+
isConnected: ComputedRef<boolean>;
|
|
10
|
+
/** Whether currently connecting (computed) */
|
|
11
|
+
isConnecting: ComputedRef<boolean>;
|
|
12
|
+
/** Whether currently disconnected (computed) */
|
|
13
|
+
isDisconnected: ComputedRef<boolean>;
|
|
14
|
+
/** Connect to a server address */
|
|
15
|
+
connect: (address: string) => void;
|
|
16
|
+
/** Disconnect from the server */
|
|
17
|
+
disconnect: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Vue composable for managing Myko connection status.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```vue
|
|
25
|
+
* <script setup>
|
|
26
|
+
* import { useConnection } from '@myko/ui-vue'
|
|
27
|
+
*
|
|
28
|
+
* const { status, isConnected, connect, disconnect } = useConnection()
|
|
29
|
+
*
|
|
30
|
+
* function handleConnect() {
|
|
31
|
+
* connect('ws://localhost:5155')
|
|
32
|
+
* }
|
|
33
|
+
* </script>
|
|
34
|
+
*
|
|
35
|
+
* <template>
|
|
36
|
+
* <div>Status: {{ status }}</div>
|
|
37
|
+
* <button v-if="!isConnected" @click="handleConnect">Connect</button>
|
|
38
|
+
* <button v-else @click="disconnect">Disconnect</button>
|
|
39
|
+
* </template>
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function useConnection(): UseConnectionReturn {
|
|
43
|
+
const client = getMykoClient();
|
|
44
|
+
|
|
45
|
+
const isConnected = computed(() => client.connectionStatus.value === ConnectionStatus.Connected);
|
|
46
|
+
const isConnecting = computed(() => client.connectionStatus.value === ConnectionStatus.Connecting);
|
|
47
|
+
const isDisconnected = computed(() => client.connectionStatus.value === ConnectionStatus.Disconnected);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
status: client.connectionStatus,
|
|
51
|
+
isConnected,
|
|
52
|
+
isConnecting,
|
|
53
|
+
isDisconnected,
|
|
54
|
+
connect: (address: string) => client.connect(address),
|
|
55
|
+
disconnect: () => client.disconnect()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { onUnmounted, computed, type ComputedRef, type Ref } from 'vue';
|
|
2
|
+
import type { Query, QueryItem } from '@myko/core';
|
|
3
|
+
import { getMykoClient, type ReactiveQuery } from '../services/vue-client';
|
|
4
|
+
|
|
5
|
+
export interface UseQueryReturn<Q extends Query<unknown>> {
|
|
6
|
+
/** Reactive map of items by ID */
|
|
7
|
+
items: Map<string, QueryItem<Q> & { id: string }>;
|
|
8
|
+
/** Array of all items (computed for convenience) */
|
|
9
|
+
itemsArray: ComputedRef<(QueryItem<Q> & { id: string })[]>;
|
|
10
|
+
/** Whether the query has received its first response */
|
|
11
|
+
resolved: Ref<boolean>;
|
|
12
|
+
/** Manually release the subscription (also called on unmount) */
|
|
13
|
+
release: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Vue composable for watching a Myko query.
|
|
18
|
+
*
|
|
19
|
+
* Automatically releases the subscription when the component is unmounted.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```vue
|
|
23
|
+
* <script setup>
|
|
24
|
+
* import { useQuery } from '@myko/ui-vue'
|
|
25
|
+
* import { queries } from '@rship/entities'
|
|
26
|
+
*
|
|
27
|
+
* const { items, itemsArray, resolved } = useQuery(queries.GetAllTargets({}))
|
|
28
|
+
* </script>
|
|
29
|
+
*
|
|
30
|
+
* <template>
|
|
31
|
+
* <div v-if="!resolved">Loading...</div>
|
|
32
|
+
* <div v-for="target in itemsArray" :key="target.id">
|
|
33
|
+
* {{ target.name }}
|
|
34
|
+
* </div>
|
|
35
|
+
* </template>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function useQuery<Q extends Query<unknown>>(
|
|
39
|
+
queryFactory: Q
|
|
40
|
+
): UseQueryReturn<Q> {
|
|
41
|
+
const client = getMykoClient();
|
|
42
|
+
const result = client.query(queryFactory);
|
|
43
|
+
|
|
44
|
+
// Auto-release on unmount
|
|
45
|
+
onUnmounted(() => {
|
|
46
|
+
result.release();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Convenience computed for array iteration
|
|
50
|
+
const itemsArray = computed(() => Array.from(result.items.values()));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
items: result.items,
|
|
54
|
+
itemsArray,
|
|
55
|
+
resolved: result.resolved,
|
|
56
|
+
release: result.release
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { onUnmounted, type ShallowRef } from 'vue';
|
|
2
|
+
import type { Report, ReportResult } from '@myko/core';
|
|
3
|
+
import { getMykoClient, type ReactiveReport } from '../services/vue-client';
|
|
4
|
+
|
|
5
|
+
export interface UseReportReturn<R extends Report<unknown>> {
|
|
6
|
+
/** Current value (reactive via ref) */
|
|
7
|
+
value: ShallowRef<ReportResult<R> | undefined>;
|
|
8
|
+
/** Manually release the subscription (also called on unmount) */
|
|
9
|
+
release: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Vue composable for watching a Myko report.
|
|
14
|
+
*
|
|
15
|
+
* Automatically releases the subscription when the component is unmounted.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```vue
|
|
19
|
+
* <script setup>
|
|
20
|
+
* import { useReport } from '@myko/ui-vue'
|
|
21
|
+
* import { reports } from '@rship/entities'
|
|
22
|
+
*
|
|
23
|
+
* const { value } = useReport(reports.CountAllTargets({}))
|
|
24
|
+
* </script>
|
|
25
|
+
*
|
|
26
|
+
* <template>
|
|
27
|
+
* <div>Count: {{ value?.count ?? 'loading...' }}</div>
|
|
28
|
+
* </template>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function useReport<R extends Report<unknown>>(
|
|
32
|
+
reportFactory: R
|
|
33
|
+
): UseReportReturn<R> {
|
|
34
|
+
const client = getMykoClient();
|
|
35
|
+
const result = client.report(reportFactory);
|
|
36
|
+
|
|
37
|
+
// Auto-release on unmount
|
|
38
|
+
onUnmounted(() => {
|
|
39
|
+
result.release();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
value: result.value,
|
|
44
|
+
release: result.release
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Vue-friendly Myko client exports
|
|
2
|
+
export {
|
|
3
|
+
createMykoClient,
|
|
4
|
+
getMykoClient,
|
|
5
|
+
myko,
|
|
6
|
+
VueMykoClient,
|
|
7
|
+
type CommandError,
|
|
8
|
+
type CommandSuccess,
|
|
9
|
+
type ReactiveQuery,
|
|
10
|
+
type ReactiveReport
|
|
11
|
+
} from './services/vue-client';
|
|
12
|
+
|
|
13
|
+
// Vue composables
|
|
14
|
+
export {
|
|
15
|
+
useQuery,
|
|
16
|
+
useReport,
|
|
17
|
+
useConnection,
|
|
18
|
+
type UseQueryReturn,
|
|
19
|
+
type UseReportReturn,
|
|
20
|
+
type UseConnectionReturn
|
|
21
|
+
} from './composables';
|
|
22
|
+
|
|
23
|
+
// Re-export useful types from @myko/ts
|
|
24
|
+
export { ConnectionStatus } from '@myko/core';
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue-friendly Myko client wrapper
|
|
3
|
+
*
|
|
4
|
+
* Provides reactive state using Vue 3 reactivity and reactive Map for efficient updates.
|
|
5
|
+
* Queries and reports are deduplicated - multiple calls with the same args share
|
|
6
|
+
* the same subscription and are only cancelled when all consumers unsubscribe.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ConnectionStatus,
|
|
11
|
+
MykoClient,
|
|
12
|
+
type Command,
|
|
13
|
+
type CommandResult,
|
|
14
|
+
type QueryDiff,
|
|
15
|
+
type QueryItem,
|
|
16
|
+
type Query,
|
|
17
|
+
type ReportResult,
|
|
18
|
+
type Report
|
|
19
|
+
} from '@myko/core';
|
|
20
|
+
import { ref, shallowRef, shallowReactive, type Ref, type ShallowRef } from 'vue';
|
|
21
|
+
import { Subject, type Observable, type Subscription } from 'rxjs';
|
|
22
|
+
|
|
23
|
+
/** Command success event */
|
|
24
|
+
export type CommandSuccess = {
|
|
25
|
+
commandId: string;
|
|
26
|
+
response: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Command error event */
|
|
30
|
+
export type CommandError = {
|
|
31
|
+
commandId: string;
|
|
32
|
+
error: Error;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Reactive query result using reactive Map - generic over the Query factory type */
|
|
36
|
+
export type ReactiveQuery<Q extends Query<unknown>> = {
|
|
37
|
+
/** Reactive map of items by ID */
|
|
38
|
+
readonly items: Map<string, QueryItem<Q> & { id: string }>;
|
|
39
|
+
/** Whether the query has received its first response */
|
|
40
|
+
readonly resolved: Ref<boolean>;
|
|
41
|
+
/** Release this consumer's reference (unsubscribes when last consumer releases) */
|
|
42
|
+
release: () => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Reactive report result - generic over the Report factory type */
|
|
46
|
+
export type ReactiveReport<R extends Report<unknown>> = {
|
|
47
|
+
/** Current value (reactive via ref) */
|
|
48
|
+
readonly value: ShallowRef<ReportResult<R> | undefined>;
|
|
49
|
+
/** Release this consumer's reference (unsubscribes when last consumer releases) */
|
|
50
|
+
release: () => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Internal state for a shared query */
|
|
54
|
+
type SharedQuery<T extends { id: string }> = {
|
|
55
|
+
items: Map<string, T>;
|
|
56
|
+
resolved: Ref<boolean>;
|
|
57
|
+
subscription: Subscription;
|
|
58
|
+
refCount: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Internal state for a shared report */
|
|
62
|
+
type SharedReport<T> = {
|
|
63
|
+
value: ShallowRef<T | undefined>;
|
|
64
|
+
subscription: Subscription;
|
|
65
|
+
refCount: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Vue-friendly Myko client
|
|
70
|
+
*
|
|
71
|
+
* Wraps MykoClient to provide reactive Vue state with automatic deduplication.
|
|
72
|
+
*/
|
|
73
|
+
export class VueMykoClient {
|
|
74
|
+
private client: MykoClient;
|
|
75
|
+
|
|
76
|
+
// Shared queries by cache key
|
|
77
|
+
private sharedQueries = new Map<string, SharedQuery<{ id: string }>>();
|
|
78
|
+
|
|
79
|
+
// Shared reports by cache key
|
|
80
|
+
private sharedReports = new Map<string, SharedReport<unknown>>();
|
|
81
|
+
|
|
82
|
+
// Command outcome subjects
|
|
83
|
+
private commandSuccessSubject = new Subject<CommandSuccess>();
|
|
84
|
+
private commandErrorSubject = new Subject<CommandError>();
|
|
85
|
+
|
|
86
|
+
/** Observable of all command successes */
|
|
87
|
+
readonly commandSuccess$: Observable<CommandSuccess> = this.commandSuccessSubject.asObservable();
|
|
88
|
+
|
|
89
|
+
/** Observable of all command errors */
|
|
90
|
+
readonly commandError$: Observable<CommandError> = this.commandErrorSubject.asObservable();
|
|
91
|
+
|
|
92
|
+
// Reactive connection status
|
|
93
|
+
private _connectionStatus = ref<ConnectionStatus>(ConnectionStatus.Disconnected);
|
|
94
|
+
|
|
95
|
+
constructor() {
|
|
96
|
+
this.client = new MykoClient();
|
|
97
|
+
|
|
98
|
+
// Sync connection status to reactive state
|
|
99
|
+
this.client.connectionStatus$.subscribe((status: ConnectionStatus) => {
|
|
100
|
+
this._connectionStatus.value = status;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Create a stable cache key from a query/report factory */
|
|
105
|
+
private getCacheKey(
|
|
106
|
+
type: 'query' | 'report',
|
|
107
|
+
factory: {
|
|
108
|
+
query?: Record<string, unknown>;
|
|
109
|
+
report?: Record<string, unknown>;
|
|
110
|
+
queryId?: string;
|
|
111
|
+
reportId?: string;
|
|
112
|
+
}
|
|
113
|
+
): string {
|
|
114
|
+
const id = type === 'query' ? factory.queryId : factory.reportId;
|
|
115
|
+
const args = type === 'query' ? factory.query : factory.report;
|
|
116
|
+
return `${type}:${id}:${JSON.stringify(args)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Current connection status (reactive) */
|
|
120
|
+
get connectionStatus(): Ref<ConnectionStatus> {
|
|
121
|
+
return this._connectionStatus;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether currently connected (reactive) - use .value to access */
|
|
125
|
+
get isConnected(): boolean {
|
|
126
|
+
return this._connectionStatus.value === ConnectionStatus.Connected;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Set the server address and connect */
|
|
130
|
+
connect(address: string): void {
|
|
131
|
+
this.client.setAddress(address);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Disconnect from the server */
|
|
135
|
+
disconnect(): void {
|
|
136
|
+
// Unsubscribe all shared queries
|
|
137
|
+
for (const shared of this.sharedQueries.values()) {
|
|
138
|
+
shared.subscription.unsubscribe();
|
|
139
|
+
}
|
|
140
|
+
this.sharedQueries.clear();
|
|
141
|
+
|
|
142
|
+
// Unsubscribe all shared reports
|
|
143
|
+
for (const shared of this.sharedReports.values()) {
|
|
144
|
+
shared.subscription.unsubscribe();
|
|
145
|
+
}
|
|
146
|
+
this.sharedReports.clear();
|
|
147
|
+
|
|
148
|
+
this.client.disconnect();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Watch a query with reactive Map updates.
|
|
153
|
+
*
|
|
154
|
+
* Multiple calls with the same query args share the same Map,
|
|
155
|
+
* and the subscription is only cancelled when all consumers release.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```vue
|
|
159
|
+
* <script setup>
|
|
160
|
+
* import { onUnmounted } from 'vue'
|
|
161
|
+
* const { items, resolved, release } = client.query(queries.GetAllTargets({}))
|
|
162
|
+
* onUnmounted(release)
|
|
163
|
+
* </script>
|
|
164
|
+
*
|
|
165
|
+
* <template>
|
|
166
|
+
* <div v-for="[id, target] in items" :key="id">{{ target.name }}</div>
|
|
167
|
+
* </template>
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
query<Q extends Query<unknown>>(queryFactory: Q): ReactiveQuery<Q> {
|
|
171
|
+
type Item = QueryItem<Q> & { id: string };
|
|
172
|
+
const cacheKey = this.getCacheKey('query', queryFactory);
|
|
173
|
+
|
|
174
|
+
// Return existing shared query if available
|
|
175
|
+
let shared = this.sharedQueries.get(cacheKey) as SharedQuery<Item> | undefined;
|
|
176
|
+
|
|
177
|
+
if (!shared) {
|
|
178
|
+
// Create new shared query with reactive Map
|
|
179
|
+
const items = shallowReactive(new Map<string, Item>());
|
|
180
|
+
const resolved = ref(false);
|
|
181
|
+
|
|
182
|
+
const subscription = this.client.watchQueryDiff(queryFactory).subscribe({
|
|
183
|
+
next: (diff) => {
|
|
184
|
+
if (diff.sequence === 0n) {
|
|
185
|
+
items.clear();
|
|
186
|
+
}
|
|
187
|
+
for (const id of diff.deletes) {
|
|
188
|
+
items.delete(id);
|
|
189
|
+
}
|
|
190
|
+
for (const item of diff.upserts as Item[]) {
|
|
191
|
+
items.set(item.id, item);
|
|
192
|
+
}
|
|
193
|
+
resolved.value = true;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
shared = { items, resolved, subscription, refCount: 0 };
|
|
198
|
+
this.sharedQueries.set(cacheKey, shared as SharedQuery<{ id: string }>);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Increment reference count
|
|
202
|
+
shared.refCount++;
|
|
203
|
+
|
|
204
|
+
let released = false;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
items: shared.items,
|
|
208
|
+
resolved: shared.resolved,
|
|
209
|
+
release: () => {
|
|
210
|
+
if (released) return;
|
|
211
|
+
released = true;
|
|
212
|
+
|
|
213
|
+
const s = this.sharedQueries.get(cacheKey);
|
|
214
|
+
if (s) {
|
|
215
|
+
s.refCount--;
|
|
216
|
+
if (s.refCount <= 0) {
|
|
217
|
+
s.subscription.unsubscribe();
|
|
218
|
+
this.sharedQueries.delete(cacheKey);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Watch a report with reactive value updates.
|
|
227
|
+
*
|
|
228
|
+
* Multiple calls with the same report args share the same subscription,
|
|
229
|
+
* and the subscription is only cancelled when all consumers release.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```vue
|
|
233
|
+
* <script setup>
|
|
234
|
+
* import { onUnmounted } from 'vue'
|
|
235
|
+
* const { value, release } = client.report(reports.CountAllTargets({}))
|
|
236
|
+
* onUnmounted(release)
|
|
237
|
+
* </script>
|
|
238
|
+
*
|
|
239
|
+
* <template>
|
|
240
|
+
* <div>Count: {{ value?.count ?? 'loading...' }}</div>
|
|
241
|
+
* </template>
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
report<R extends Report<unknown>>(reportFactory: R): ReactiveReport<R> {
|
|
245
|
+
type Result = ReportResult<R>;
|
|
246
|
+
const cacheKey = this.getCacheKey('report', reportFactory);
|
|
247
|
+
|
|
248
|
+
// Return existing shared report if available
|
|
249
|
+
let shared = this.sharedReports.get(cacheKey) as SharedReport<Result> | undefined;
|
|
250
|
+
|
|
251
|
+
if (!shared) {
|
|
252
|
+
// Create new shared report with reactive state
|
|
253
|
+
const value = shallowRef<Result | undefined>(undefined);
|
|
254
|
+
const subscription = this.client.watchReport(reportFactory).subscribe({
|
|
255
|
+
next: (result) => {
|
|
256
|
+
value.value = result;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
shared = {
|
|
261
|
+
value: value as ShallowRef<Result | undefined>,
|
|
262
|
+
subscription,
|
|
263
|
+
refCount: 0
|
|
264
|
+
};
|
|
265
|
+
this.sharedReports.set(cacheKey, shared as SharedReport<unknown>);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Increment reference count (shared is guaranteed to be defined here)
|
|
269
|
+
shared!.refCount++;
|
|
270
|
+
|
|
271
|
+
let released = false;
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
value: shared!.value as ShallowRef<Result | undefined>,
|
|
275
|
+
release: () => {
|
|
276
|
+
if (released) return;
|
|
277
|
+
released = true;
|
|
278
|
+
|
|
279
|
+
const s = this.sharedReports.get(cacheKey);
|
|
280
|
+
if (s) {
|
|
281
|
+
s.refCount--;
|
|
282
|
+
if (s.refCount <= 0) {
|
|
283
|
+
s.subscription.unsubscribe();
|
|
284
|
+
this.sharedReports.delete(cacheKey);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Send a command and wait for the response.
|
|
293
|
+
*
|
|
294
|
+
* Emits to commandSuccess$ or commandError$ observables for generic handling.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```vue
|
|
298
|
+
* <script setup>
|
|
299
|
+
* async function deleteMachine(id: string) {
|
|
300
|
+
* const result = await myko.sendCommand(commands.DeleteMachine({ machineId: id }))
|
|
301
|
+
* console.log('Deleted:', result)
|
|
302
|
+
* }
|
|
303
|
+
* </script>
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
async sendCommand<C extends Command<unknown>>(
|
|
307
|
+
commandFactory: C
|
|
308
|
+
): Promise<CommandResult<C>> {
|
|
309
|
+
const commandId = commandFactory.commandId;
|
|
310
|
+
try {
|
|
311
|
+
const response = await this.client.sendCommand(commandFactory);
|
|
312
|
+
this.commandSuccessSubject.next({ commandId, response });
|
|
313
|
+
return response;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
316
|
+
this.commandErrorSubject.next({ commandId, error });
|
|
317
|
+
throw e;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Access the underlying MykoClient for advanced use cases */
|
|
322
|
+
get raw(): MykoClient {
|
|
323
|
+
return this.client;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Global singleton client instance (auto-initialized) */
|
|
328
|
+
export const myko = new VueMykoClient();
|
|
329
|
+
|
|
330
|
+
// HMR cleanup - disconnect old client when module is hot-reloaded
|
|
331
|
+
if (import.meta.hot) {
|
|
332
|
+
import.meta.hot.dispose(() => {
|
|
333
|
+
myko.disconnect();
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Get the global MykoClient instance */
|
|
338
|
+
export function getMykoClient(): VueMykoClient {
|
|
339
|
+
return myko;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Create a new VueMykoClient instance (non-singleton, for advanced use) */
|
|
343
|
+
export function createMykoClient(): VueMykoClient {
|
|
344
|
+
return new VueMykoClient();
|
|
345
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"jsx": "preserve",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"lib": ["ESNext", "DOM"],
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"types": ["vite/client"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts", "src/**/*.vue"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|