@neuralinnovations/dataisland-sdk 0.0.1-dev1 → 0.0.1-dev3

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 (58) hide show
  1. package/.editorconfig +4 -1
  2. package/.eslintrc.json +1 -1
  3. package/jest.config.ts +4 -4
  4. package/jest.setup.ts +2 -0
  5. package/package.json +4 -2
  6. package/src/appBuilder.ts +24 -5
  7. package/src/appSdk.ts +31 -12
  8. package/src/commands/startCommandHandler.ts +14 -0
  9. package/src/context.ts +31 -0
  10. package/src/credentials.ts +31 -9
  11. package/src/disposable.ts +2 -2
  12. package/src/dto/accessGroupResponse.ts +35 -0
  13. package/src/dto/chatResponse.ts +104 -0
  14. package/src/dto/userInfoResponse.ts +47 -0
  15. package/src/dto/workspacesResponse.ts +49 -0
  16. package/src/events.ts +1 -5
  17. package/src/index.ts +17 -11
  18. package/src/internal/app.impl.ts +98 -30
  19. package/src/internal/appBuilder.impl.ts +39 -12
  20. package/src/internal/createApp.impl.ts +3 -3
  21. package/src/middleware.ts +1 -1
  22. package/src/services/commandService.ts +44 -0
  23. package/src/services/credentialService.ts +3 -3
  24. package/src/services/middlewareService.ts +7 -5
  25. package/src/services/organizationService.ts +28 -0
  26. package/src/services/requestBuilder.ts +102 -0
  27. package/src/services/responseUtils.ts +32 -0
  28. package/src/services/rpcService.ts +113 -53
  29. package/src/services/service.ts +3 -3
  30. package/src/services/userProfileService.ts +38 -0
  31. package/src/storages/chat.ts +37 -0
  32. package/src/storages/file.impl.ts +68 -0
  33. package/src/storages/files.impl.ts +192 -0
  34. package/src/storages/files.ts +67 -0
  35. package/src/storages/groups.impl.ts +337 -0
  36. package/src/storages/groups.ts +43 -0
  37. package/src/storages/organization.impl.ts +68 -0
  38. package/src/storages/organization.ts +33 -0
  39. package/src/storages/organizations.impl.ts +191 -0
  40. package/src/storages/organizations.ts +56 -0
  41. package/src/storages/userProfile.impl.ts +56 -0
  42. package/src/storages/userProfile.ts +42 -0
  43. package/src/storages/workspace.impl.ts +109 -0
  44. package/src/storages/workspace.ts +43 -0
  45. package/src/storages/workspaces.impl.ts +212 -0
  46. package/src/storages/workspaces.ts +53 -0
  47. package/src/unitTest.ts +42 -0
  48. package/test/commands.test.ts +24 -0
  49. package/test/disposable.test.ts +3 -3
  50. package/test/events.test.ts +4 -4
  51. package/test/index.test.ts +204 -62
  52. package/test/registry.test.ts +8 -8
  53. package/test/services.test.ts +56 -0
  54. package/test/setup.ts +2 -0
  55. package/test/unitTest.test.ts +21 -0
  56. package/test_file.pdf +0 -0
  57. package/src/internal/context.ts +0 -13
  58. package/src/types.ts +0 -110
@@ -0,0 +1,56 @@
1
+ import { EventDispatcher } from "../events"
2
+ import { Organization } from "./organization"
3
+
4
+ /**
5
+ * Organization id.
6
+ */
7
+ export type OrganizationId = string
8
+
9
+ /**
10
+ * Organization event.
11
+ */
12
+ export enum OrganizationsEvent {
13
+ ADDED = "added",
14
+ REMOVED = "removed",
15
+ CURRENT_CHANGED = "currentChanged"
16
+ }
17
+
18
+ /**
19
+ * Organizations storage.
20
+ */
21
+ export abstract class Organizations extends EventDispatcher<
22
+ OrganizationsEvent,
23
+ Organization
24
+ > {
25
+ /**
26
+ * User's organizations.
27
+ */
28
+ abstract get collection(): ReadonlyArray<Organization>
29
+
30
+ /**
31
+ * Current organization.
32
+ */
33
+ abstract get current(): OrganizationId
34
+ abstract set current(value: OrganizationId)
35
+
36
+ /**
37
+ * Get organization by id.
38
+ */
39
+ abstract get(id: OrganizationId): Organization
40
+
41
+ /**
42
+ * Try to get organization by id.
43
+ * @param id
44
+ */
45
+ abstract tryGet(id: OrganizationId): Organization | undefined
46
+
47
+ /**
48
+ * Create new organization.
49
+ */
50
+ abstract create(name: string, description: string): Promise<Organization>
51
+
52
+ /**
53
+ * Delete organization.
54
+ */
55
+ abstract delete(id: OrganizationId): Promise<void>
56
+ }
@@ -0,0 +1,56 @@
1
+ import { UserEvent, UserProfile } from "./userProfile"
2
+ import { UserInfoResponse } from "../dto/userInfoResponse"
3
+
4
+ export class UserProfileImpl extends UserProfile {
5
+ private content?: UserInfoResponse
6
+
7
+ get id(): string {
8
+ if (this.content) {
9
+ return this.content.user.id
10
+ }
11
+ throw new Error("The profile is not loaded.")
12
+ }
13
+
14
+ get name(): string {
15
+ if (this.content) {
16
+ return this.content.user.profile.name
17
+ }
18
+ throw new Error("The profile is not loaded.")
19
+ }
20
+
21
+ get email(): string {
22
+ if (this.content) {
23
+ return this.content.user.profile.email
24
+ }
25
+ throw new Error("The profile is not loaded.")
26
+ }
27
+
28
+ get isDeleted(): boolean {
29
+ if (this.content) {
30
+ return this.content.user.isDeleted
31
+ }
32
+ throw new Error("The profile is not loaded.")
33
+ }
34
+
35
+ get createdAt(): Date {
36
+ if (this.content) {
37
+ return new Date(this.content.user.created_at)
38
+ }
39
+ throw new Error("The profile is not loaded.")
40
+ }
41
+
42
+ get modifiedAt(): Date {
43
+ if (this.content) {
44
+ return new Date(this.content.user.modified_at)
45
+ }
46
+ throw new Error("The profile is not loaded.")
47
+ }
48
+
49
+ initFrom(content: UserInfoResponse) {
50
+ this.content = content
51
+ this.dispatch({
52
+ type: UserEvent.CHANGED,
53
+ data: this
54
+ })
55
+ }
56
+ }
@@ -0,0 +1,42 @@
1
+ import { EventDispatcher } from "../events"
2
+
3
+ export type UserId = string
4
+
5
+ export enum UserEvent {
6
+ CHANGED = "changed"
7
+ }
8
+
9
+ export abstract class UserProfile extends EventDispatcher<
10
+ UserEvent,
11
+ UserProfile
12
+ > {
13
+ /**
14
+ * User id.
15
+ */
16
+ abstract get id(): UserId
17
+
18
+ /**
19
+ * User name.
20
+ */
21
+ abstract get name(): string
22
+
23
+ /**
24
+ * User email.
25
+ */
26
+ abstract get email(): string
27
+
28
+ /**
29
+ * Is user deleted.
30
+ */
31
+ abstract get isDeleted(): boolean
32
+
33
+ /**
34
+ * Created at.
35
+ */
36
+ abstract get createdAt(): Date
37
+
38
+ /**
39
+ * Modified at.
40
+ */
41
+ abstract get modifiedAt(): Date
42
+ }
@@ -0,0 +1,109 @@
1
+ import { Context } from "../context"
2
+ import { Files } from "./files"
3
+ import { Workspace, WorkspaceEvent } from "./workspace"
4
+ import { OrganizationImpl } from "./organization.impl"
5
+ import { WorkspaceDto } from "../dto/workspacesResponse"
6
+ import { RpcService } from "../services/rpcService"
7
+ import { FilesImpl } from "./files.impl"
8
+ import { ResponseUtils } from "../services/responseUtils"
9
+
10
+ export class WorkspaceImpl extends Workspace {
11
+ private _isMarkAsDeleted: boolean = false
12
+ private _workspace?: WorkspaceDto
13
+
14
+ private readonly _files: FilesImpl
15
+
16
+ constructor(
17
+ public readonly organization: OrganizationImpl,
18
+ public readonly context: Context
19
+ ) {
20
+ super()
21
+ this._files = new FilesImpl(this, context)
22
+ }
23
+
24
+ get id(): string {
25
+ if (this._workspace) {
26
+ return this._workspace.id
27
+ }
28
+ throw new Error("Workspace is not loaded.")
29
+ }
30
+
31
+ get name(): string {
32
+ if (this._workspace) {
33
+ return this._workspace.profile.name
34
+ }
35
+ throw new Error("Workspace is not loaded.")
36
+ }
37
+
38
+ get description(): string {
39
+ if (this._workspace) {
40
+ return this._workspace.profile.description
41
+ }
42
+ throw new Error("Workspace is not loaded.")
43
+ }
44
+
45
+ get files(): Files {
46
+ return this._files
47
+ }
48
+
49
+ async change(name: string, description: string): Promise<void> {
50
+ if (!this._workspace) {
51
+ throw new Error("Workspace is not loaded.")
52
+ }
53
+ if (this._isMarkAsDeleted) {
54
+ throw new Error("Workspace is marked as deleted.")
55
+ }
56
+ if (name === this.name && description === this.description) {
57
+ return Promise.resolve()
58
+ }
59
+ if (name === undefined || name === null || name.trim() === "") {
60
+ throw new Error("Name is required. Please provide a valid name.")
61
+ }
62
+ if (
63
+ description === undefined ||
64
+ description === null ||
65
+ description.trim() === ""
66
+ ) {
67
+ throw new Error(
68
+ "Description is required. Please provide a valid description."
69
+ )
70
+ }
71
+
72
+ const response = await this.context
73
+ .resolve(RpcService)
74
+ ?.requestBuilder("api/v1/Workspaces")
75
+ .sendPut({
76
+ workspaceId: this.id,
77
+ profile: {
78
+ name,
79
+ description
80
+ }
81
+ })
82
+
83
+ if (ResponseUtils.isFail(response)) {
84
+ await ResponseUtils.throwError("Failed to change workspace", response)
85
+ }
86
+
87
+ if (this._workspace) {
88
+ this._workspace.profile.name = name
89
+ this._workspace.profile.description = description
90
+ }
91
+
92
+ this.dispatch({
93
+ type: WorkspaceEvent.CHANGED,
94
+ data: this
95
+ })
96
+ }
97
+
98
+ async initFrom(workspace: WorkspaceDto) {
99
+ this._workspace = workspace
100
+ }
101
+
102
+ get isMarkAsDeleted(): boolean {
103
+ return this._isMarkAsDeleted
104
+ }
105
+
106
+ markToDelete(): void {
107
+ this._isMarkAsDeleted = true
108
+ }
109
+ }
@@ -0,0 +1,43 @@
1
+ import { EventDispatcher } from "../events"
2
+ import { Files } from "./files"
3
+ import { WorkspaceId } from "./workspaces"
4
+
5
+ /**
6
+ * Workspace event.
7
+ */
8
+ export enum WorkspaceEvent {
9
+ CHANGED = "changed"
10
+ }
11
+
12
+ /**
13
+ * Workspace.
14
+ */
15
+ export abstract class Workspace extends EventDispatcher<
16
+ WorkspaceEvent,
17
+ Workspace
18
+ > {
19
+ /**
20
+ * Workspace id.
21
+ */
22
+ abstract get id(): WorkspaceId
23
+
24
+ /**
25
+ * Workspace name.
26
+ */
27
+ abstract get name(): string
28
+
29
+ /**
30
+ * Workspace description.
31
+ */
32
+ abstract get description(): string
33
+
34
+ /**
35
+ * Workspace files.
36
+ */
37
+ abstract get files(): Files
38
+
39
+ /**
40
+ * Change workspace name and description.
41
+ */
42
+ abstract change(name: string, description: string): Promise<void>
43
+ }
@@ -0,0 +1,212 @@
1
+ import { WorkspaceId, Workspaces, WorkspacesEvent } from "./workspaces"
2
+ import { OrganizationImpl } from "./organization.impl"
3
+ import { Context } from "../context"
4
+ import { Workspace } from "./workspace"
5
+ import { WorkspaceImpl } from "./workspace.impl"
6
+ import { OrganizationId } from "./organizations"
7
+ import { RpcService } from "../services/rpcService"
8
+ import { OrganizationWorkspaces } from "../dto/userInfoResponse"
9
+ import { WorkspaceDto } from "../dto/workspacesResponse"
10
+ import { ResponseUtils } from "../services/responseUtils"
11
+
12
+ export class WorkspacesImpl extends Workspaces {
13
+ private readonly _workspaces: WorkspaceImpl[] = []
14
+
15
+ constructor(
16
+ private readonly organization: OrganizationImpl,
17
+ private readonly context: Context
18
+ ) {
19
+ super()
20
+ }
21
+
22
+ get collection(): readonly Workspace[] {
23
+ return this._workspaces
24
+ }
25
+
26
+ get(id: string): Workspace {
27
+ return <Workspace>this.tryGet(id)
28
+ }
29
+
30
+ tryGet(id: string): Workspace | undefined {
31
+ return this._workspaces.find(workspace => workspace.id === id)
32
+ }
33
+
34
+ contains(id: string): boolean {
35
+ return this._workspaces.find(workspace => workspace.id === id) !== undefined
36
+ }
37
+
38
+ /**
39
+ * Create workspace.
40
+ * @param name
41
+ * @param description
42
+ * @param regulation
43
+ */
44
+ async create(
45
+ name: string,
46
+ description: string,
47
+ regulation?: {
48
+ isCreateNewGroup: boolean
49
+ newGroupName: string
50
+ groupIds: string[]
51
+ }
52
+ ): Promise<Workspace> {
53
+ if (name === undefined || name === null || name.trim() === "") {
54
+ throw new Error("Name is required, must be not empty")
55
+ }
56
+ if (
57
+ description === undefined ||
58
+ description === null ||
59
+ description.trim() === ""
60
+ ) {
61
+ throw new Error("Description is required, must be not empty")
62
+ }
63
+ if (regulation) {
64
+ if (
65
+ regulation.isCreateNewGroup === undefined ||
66
+ regulation.isCreateNewGroup === null
67
+ ) {
68
+ throw new Error("isCreateNewGroup is required, must be not empty")
69
+ }
70
+ if (
71
+ regulation.newGroupName === undefined ||
72
+ regulation.newGroupName === null ||
73
+ regulation.newGroupName.trim() === ""
74
+ ) {
75
+ throw new Error("newGroupName is required, must be not empty")
76
+ }
77
+ if (
78
+ regulation.groupIds === undefined ||
79
+ regulation.groupIds === null ||
80
+ regulation.groupIds.length === 0
81
+ ) {
82
+ throw new Error("groupIds is required, must be not empty")
83
+ }
84
+ }
85
+
86
+ // send create request to the server
87
+ const response = await this.context
88
+ .resolve(RpcService)
89
+ ?.requestBuilder("api/v1/Workspaces")
90
+ .sendPost({
91
+ organizationId: this.organization.id,
92
+ profile: {
93
+ name: name,
94
+ description: description
95
+ },
96
+ regulation: {
97
+ isCreateNewGroup: regulation?.isCreateNewGroup ?? false,
98
+ newGroupName: regulation?.newGroupName ?? "",
99
+ groupIds: regulation?.groupIds ?? []
100
+ }
101
+ })
102
+
103
+ // check response status
104
+ if (ResponseUtils.isFail(response)) {
105
+ await ResponseUtils.throwError("Failed to create workspace", response)
106
+ }
107
+
108
+ // parse workspace from the server's response
109
+ const content = (await response!.json()).workspace as WorkspaceDto
110
+
111
+ // create workspace implementation
112
+ const workspace = new WorkspaceImpl(this.organization, this.context)
113
+ await workspace.initFrom(content)
114
+
115
+ // add workspace to the collection
116
+ this._workspaces.push(workspace)
117
+
118
+ // dispatch event
119
+ this.dispatch({
120
+ type: WorkspacesEvent.ADDED,
121
+ data: workspace
122
+ })
123
+
124
+ return workspace
125
+ }
126
+
127
+ /**
128
+ * Delete workspace.
129
+ * @param id
130
+ */
131
+ async delete(id: WorkspaceId): Promise<void> {
132
+ // get workspace by id
133
+ const workspace = <WorkspaceImpl>this.tryGet(id)
134
+
135
+ // check if workspace is found
136
+ if (!workspace) {
137
+ throw new Error(`Workspace ${id} is not found`)
138
+ }
139
+
140
+ // check if workspace is already marked as deleted
141
+ if (workspace.isMarkAsDeleted) {
142
+ throw new Error(`Workspace ${id} is already marked as deleted`)
143
+ }
144
+
145
+ // mark workspace as deleted
146
+ workspace.markToDelete()
147
+
148
+ // send delete request to the server
149
+ const response = await this.context
150
+ .resolve(RpcService)
151
+ ?.requestBuilder("api/v1/Workspaces")
152
+ .searchParam("id", id)
153
+ .sendDelete()
154
+
155
+ // check response status
156
+ if (ResponseUtils.isFail(response)) {
157
+ await ResponseUtils.throwError(
158
+ `Failed to delete workspace: ${workspace.organization.name}/${workspace.name}:${id}`,
159
+ response
160
+ )
161
+ }
162
+
163
+ // remove workspace from the collection
164
+ const index = this._workspaces.indexOf(<WorkspaceImpl>workspace)
165
+ if (index < 0) {
166
+ throw new Error(`Workspace ${id} is not found`)
167
+ }
168
+ this._workspaces.splice(index, 1)
169
+
170
+ // dispatch event
171
+ this.dispatch({
172
+ type: WorkspacesEvent.REMOVED,
173
+ data: workspace
174
+ })
175
+ }
176
+
177
+ async initFrom(organizationId: OrganizationId): Promise<void> {
178
+ // init workspaces from the server's response
179
+ const response = await this.context
180
+ .resolve(RpcService)
181
+ ?.requestBuilder("api/v1/Organizations")
182
+ .searchParam("id", organizationId)
183
+ .sendGet()
184
+
185
+ // check response status
186
+ if (ResponseUtils.isFail(response)) {
187
+ await ResponseUtils.throwError("Failed to fetch workspaces.", response)
188
+ }
189
+
190
+ // parse workspaces from the server's response
191
+ const workspaces = ((await response!.json()) as OrganizationWorkspaces)
192
+ .workspaces
193
+
194
+ // init workspaces from the server's response
195
+ for (const workspace of workspaces) {
196
+ // create workspace implementation
197
+ const workspaceImpl = new WorkspaceImpl(this.organization, this.context)
198
+
199
+ // init workspace from the server's response
200
+ await workspaceImpl.initFrom(workspace)
201
+
202
+ // add workspace to the collection
203
+ this._workspaces.push(workspaceImpl)
204
+
205
+ // dispatch event
206
+ this.dispatch({
207
+ type: WorkspacesEvent.ADDED,
208
+ data: workspaceImpl
209
+ })
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,53 @@
1
+ import { EventDispatcher } from "../events"
2
+ import { Workspace } from "./workspace"
3
+
4
+ export type WorkspaceId = string
5
+
6
+ /**
7
+ * Workspaces event.
8
+ */
9
+ export enum WorkspacesEvent {
10
+ ADDED = "added",
11
+ REMOVED = "removed"
12
+ }
13
+
14
+ /**
15
+ * Organization's workspaces.
16
+ */
17
+ export abstract class Workspaces extends EventDispatcher<
18
+ WorkspacesEvent,
19
+ Workspace
20
+ > {
21
+ /**
22
+ * Workspaces.
23
+ */
24
+ abstract get collection(): ReadonlyArray<Workspace>
25
+
26
+ /**
27
+ * Get workspace by id.
28
+ * @param id
29
+ */
30
+ abstract get(id: WorkspaceId): Workspace
31
+
32
+ /**
33
+ * Try to get workspace by id.
34
+ * @param id
35
+ */
36
+ abstract tryGet(id: WorkspaceId): Workspace | undefined
37
+
38
+ /**
39
+ * Check if workspace exists.
40
+ * @param id
41
+ */
42
+ abstract contains(id: WorkspaceId): boolean
43
+
44
+ /**
45
+ * Create workspace.
46
+ */
47
+ abstract create(name: string, description: string): Promise<Workspace>
48
+
49
+ /**
50
+ * Delete workspace.
51
+ */
52
+ abstract delete(id: WorkspaceId): Promise<void>
53
+ }
@@ -0,0 +1,42 @@
1
+ export enum UnitTest {
2
+ DO_NOTHING = 0,
3
+ DO_NOT_START = 1 << 0,
4
+ DO_NOT_PRINT_INITIALIZED_LOG = 1 << 1,
5
+
6
+ DEFAULT = DO_NOT_START | DO_NOT_PRINT_INITIALIZED_LOG
7
+ }
8
+
9
+ export type UnitTestProfileSyncAction = () => void
10
+ export type UnitTestProfileAsyncAction = () => Promise<void>
11
+
12
+ export class AppSdkUnitTest {
13
+ private static _stack: UnitTest[] = [UnitTest.DO_NOTHING]
14
+
15
+ public static get current(): UnitTest {
16
+ return this._stack[this._stack.length - 1]
17
+ }
18
+
19
+ public static async test(
20
+ unitTest: UnitTest = UnitTest.DEFAULT,
21
+ func: UnitTestProfileSyncAction | UnitTestProfileAsyncAction
22
+ ): Promise<void> {
23
+ this._stack.push(unitTest)
24
+ if (func) {
25
+ const result = func()
26
+ if (result) {
27
+ await result
28
+ }
29
+ AppSdkUnitTest.end()
30
+ }
31
+ }
32
+
33
+ private static end(): void {
34
+ if (this._stack.length > 1) {
35
+ this._stack.pop()
36
+ }
37
+ }
38
+ }
39
+
40
+ export const isUnitTest = (mask: UnitTest): boolean => {
41
+ return (AppSdkUnitTest.current & mask) == mask
42
+ }
@@ -0,0 +1,24 @@
1
+ import { Command, CommandHandler } from "../src/services/commandService"
2
+ import { appSdk } from "../src"
3
+ import { UnitTest, AppSdkUnitTest } from "../src/unitTest"
4
+
5
+ class Cmd extends Command {
6
+ constructor(public readonly name: string = "test") {
7
+ super()
8
+ }
9
+ }
10
+
11
+ class CmdHandler extends CommandHandler<Cmd> {
12
+ async execute(message: Cmd): Promise<void> {
13
+ expect(message.name).toBe("test-command")
14
+ }
15
+ }
16
+
17
+ test("Commands test", async () => {
18
+ await AppSdkUnitTest.test(UnitTest.DEFAULT, async () => {
19
+ const app = await appSdk("test-commands", async builder => {
20
+ builder.registerCommand(Cmd, context => new CmdHandler(context))
21
+ })
22
+ expect(app.context.execute(new Cmd("test-command"))).toBeDefined()
23
+ })
24
+ })
@@ -1,6 +1,6 @@
1
- import { DisposableContainer } from '../src'
1
+ import { DisposableContainer } from "../src"
2
2
 
3
- test('DisposableContainer', () => {
3
+ test("DisposableContainer", () => {
4
4
  const disposable = new DisposableContainer()
5
5
  expect(disposable.isDisposed).toBe(false)
6
6
  expect(disposable.lifetime.isDisposed).toBe(false)
@@ -9,7 +9,7 @@ test('DisposableContainer', () => {
9
9
  expect(disposable.lifetime.isDisposed).toBe(true)
10
10
  })
11
11
 
12
- test('DisposableContainer, dispose order', () => {
12
+ test("DisposableContainer, dispose order", () => {
13
13
  const indexes: number[] = []
14
14
  const disposable = new DisposableContainer()
15
15
  disposable.addCallback(() => {
@@ -1,6 +1,6 @@
1
- import { EventDispatcher } from '../src'
1
+ import { EventDispatcher } from "../src"
2
2
 
3
- test('Events, test general', () => {
3
+ test("Events, test general", () => {
4
4
  enum ET {
5
5
  A,
6
6
  B
@@ -78,7 +78,7 @@ test('Events, test general', () => {
78
78
  expect(b2).toBe(3)
79
79
  })
80
80
 
81
- test('Events, test this', () => {
81
+ test("Events, test this", () => {
82
82
  enum ET {
83
83
  A,
84
84
  B
@@ -128,7 +128,7 @@ test('Events, test this', () => {
128
128
  expect(b.value).toBe(2)
129
129
  })
130
130
 
131
- test('Events, test unsubscribe', () => {
131
+ test("Events, test unsubscribe", () => {
132
132
  const dispatch = new EventDispatcher<unknown, number>()
133
133
 
134
134
  let index = 0