vla 0.1.1
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/README.md +356 -0
- package/dist/index.d.mts +138 -0
- package/dist/index.mjs +231 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# capsel
|
|
2
|
+
|
|
3
|
+
A TypeScript data layer kernel for backend- and fullstack apps. Compatible with whatever framework or library you're using.
|
|
4
|
+
|
|
5
|
+
🚧 WIP, under development. Exports and names will very likely change often.
|
|
6
|
+
|
|
7
|
+
- Dependency Injection without decorators and without reflection
|
|
8
|
+
- Works in any server-side framework
|
|
9
|
+
- Write code that's easy to test without module mocks all over the place
|
|
10
|
+
- Structures code into modules, layers and interfaces
|
|
11
|
+
- Familiar patterns: Actions, Services, Repos, Facades
|
|
12
|
+
- Ensures that Facades are used for cross-module dependencies to prevent messy code dependencies
|
|
13
|
+
- Tree shakeable
|
|
14
|
+
- Memoziation for Repos
|
|
15
|
+
- request-based context with AsyncLocalStorage
|
|
16
|
+
- 🏗️ first-class context injection
|
|
17
|
+
- ...
|
|
18
|
+
|
|
19
|
+
## Why?
|
|
20
|
+
|
|
21
|
+
Many fullstack frameworks lack structure and conventions on the backend side (data layer), but they have lots of good structure and conventions on the frontend side (presentation layer). They are still great frameworks and they all have their own strengths. This is where Capsel comes in. It aims to fill in the missing gap in the data layer, allowing you to write well-structured maintainable, scalable and testable code.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createModule, Kernel } from "capsel"
|
|
27
|
+
|
|
28
|
+
// Users
|
|
29
|
+
const UserModule = createModule("User")
|
|
30
|
+
|
|
31
|
+
class ShowUserSettingsAction extends UserModule.Action {
|
|
32
|
+
users = this.inject(UserService)
|
|
33
|
+
|
|
34
|
+
async handle(userId: string) {
|
|
35
|
+
const settings = await this.users.getSettings(userId)
|
|
36
|
+
return {
|
|
37
|
+
timezone: settings.timezone,
|
|
38
|
+
hasSubscription: settings.hasSubscription
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class UserService extends UserModule.Service {
|
|
44
|
+
repo = this.inject(UserRepo)
|
|
45
|
+
billing = this.inject(BillingFacade)
|
|
46
|
+
ctx = this.inject(Context) // WIP unimplemented
|
|
47
|
+
|
|
48
|
+
async getSettings(userId: string) {
|
|
49
|
+
await canViewProfile(userId)
|
|
50
|
+
|
|
51
|
+
const profile = await this.repo.findById(userId)
|
|
52
|
+
const hasSubscription = await this.billing.hasSubscription(userId)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
...profile,
|
|
56
|
+
hasSubscription
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private canViewProfile(userId: string) {
|
|
61
|
+
const isSameUser = this.ctx.currentUser.id !== userId
|
|
62
|
+
if (!isSameUser) throw new Forbidden()
|
|
63
|
+
|
|
64
|
+
// repo method calls are memoized
|
|
65
|
+
const profile = await this.repo.findById(userId)
|
|
66
|
+
const isTeamAdmin =
|
|
67
|
+
this.ctx.currentUser.role === "admin" &&
|
|
68
|
+
this.ctx.currentUser.teamId === profile.teamId
|
|
69
|
+
|
|
70
|
+
if (!isTeamAdmin) throw new Forbidden()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class UserRepo extends UserModule.Repo {
|
|
75
|
+
findById = this.memo((id: string) => {
|
|
76
|
+
// this method is memoized per request.
|
|
77
|
+
// memoized methods can be called like any normal method, but
|
|
78
|
+
// if it's called multiple times with the same args, it's only
|
|
79
|
+
// executed once and the result is cached
|
|
80
|
+
return db.users.find({ id })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
async create(data: UserValues) {
|
|
84
|
+
const createdUser = await db.users.create({ data })
|
|
85
|
+
|
|
86
|
+
this.findById.prime(createUser.id).value(createdUser)
|
|
87
|
+
// memoized methods support multiple utilities:
|
|
88
|
+
// - .fresh(args) to skip memoized cache and execute the function again
|
|
89
|
+
// - .prime(args).value({ ... }) to set a cached value
|
|
90
|
+
// - .preload(args) to run the method in the background and preload the cache
|
|
91
|
+
// - .bust(args) to bust the cache for the provided args
|
|
92
|
+
// - .bustAll() to bust the cache for all args
|
|
93
|
+
|
|
94
|
+
return createdUser
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Billing
|
|
99
|
+
const BillingModule = createModule("Billing")
|
|
100
|
+
|
|
101
|
+
class BillingFacade extends Billing.Facade {
|
|
102
|
+
repo = this.inject(BillingRepo)
|
|
103
|
+
|
|
104
|
+
async hasSubscription(userId: string) {
|
|
105
|
+
const subscription = await this.repo.findSubscriptionByUser(userId)
|
|
106
|
+
return Boolean(subscription)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class BillingRepo extends BillingModule.Repo {
|
|
111
|
+
async findSubscriptionByUser(userId: string) {
|
|
112
|
+
return db.subscriptions.find({ userId })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const kernel = new Kernel()
|
|
117
|
+
kernel.setGlobal() // global instance
|
|
118
|
+
|
|
119
|
+
const settings = await ShowUserSettingsAction.invoke(userId)
|
|
120
|
+
// -> { timezone: 'GMT+1', hasSubscription: true }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### React usage
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { cache } from 'react'
|
|
127
|
+
import { setCurrentKernelFn } from 'capsel'
|
|
128
|
+
import { kernel } from '@/data/kernel'
|
|
129
|
+
|
|
130
|
+
const kernel = new Kernel()
|
|
131
|
+
|
|
132
|
+
// React's cache() will return a new scoped kernel for each request, giving
|
|
133
|
+
// us a new scoped kernel per request without a middleware
|
|
134
|
+
setCurrentKernelFn(cache(() => kernel.scoped()))
|
|
135
|
+
|
|
136
|
+
async function Layout() {
|
|
137
|
+
const settings = await ShowUserSettingsAction.invoke(userId)
|
|
138
|
+
|
|
139
|
+
return <div><Page /></div>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function Page() {
|
|
143
|
+
const settings = await ShowUserSettingsAction.invoke(userId)
|
|
144
|
+
// it will not query the db twice. it will use the memoized db query
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<ul>
|
|
148
|
+
<li>Timezone: {settings.timezone}</li>
|
|
149
|
+
<li>Subscriber: {settings.hasSubscription ? "yes" : "no"}</li>
|
|
150
|
+
</ul>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### In any middleware-based app
|
|
156
|
+
|
|
157
|
+
e.g. sveltekit
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { runWithScope } from "capsel"
|
|
161
|
+
import type { Handle } from "@sveltejs/kit"
|
|
162
|
+
import { kernel } from '@/data/kernel'
|
|
163
|
+
|
|
164
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
165
|
+
return runWithScope(kernel.scoped(), () => resolve(event))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
import { kernel } from '@/data/kernel';
|
|
169
|
+
import type { PageServerLoad } from './$types';
|
|
170
|
+
|
|
171
|
+
export const load: PageServerLoad = async ({ params }) => {
|
|
172
|
+
return {
|
|
173
|
+
settings: await ShowUserSettingsAction.invoke(userId)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
e.g. express
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { runWithScope } from "capsel"
|
|
182
|
+
import { kernel } from '@/data/kernel'
|
|
183
|
+
|
|
184
|
+
const app = express()
|
|
185
|
+
|
|
186
|
+
express.use((req, res, next) => runWithScope(kernel.scoped(), () => next()))
|
|
187
|
+
|
|
188
|
+
app.get("/users/:id", async (req, res) => {
|
|
189
|
+
const settings = await ShowUserSettingsAction.invoke(req.params.id)
|
|
190
|
+
res.json({ data: settings })
|
|
191
|
+
})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Testing
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
test("returns some user settings", async () => {
|
|
198
|
+
const kernel = new Kernel()
|
|
199
|
+
|
|
200
|
+
// mocks for database calls
|
|
201
|
+
kernel.bind(
|
|
202
|
+
UserRepo,
|
|
203
|
+
vi.fn(
|
|
204
|
+
class {
|
|
205
|
+
findById = vi.fn().mockResolvedValue({ timezone: "faked" })
|
|
206
|
+
},
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
// mocks for cross-module dependencies
|
|
211
|
+
kernel.bind(
|
|
212
|
+
BillingFacade,
|
|
213
|
+
vi.fn(
|
|
214
|
+
class {
|
|
215
|
+
hasSubscription = vi.fn().mockResolvedValue(true)
|
|
216
|
+
},
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await expect(ShowUserSettingsAction.withKernel(kernel).invoke("1")).resolves.toEqual({
|
|
221
|
+
timezone: "faked",
|
|
222
|
+
hasSubscription: true
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Docs
|
|
228
|
+
|
|
229
|
+
There aren't any docs yet. This section is just jotting down some notes for myself.
|
|
230
|
+
|
|
231
|
+
### When to use modules?
|
|
232
|
+
|
|
233
|
+
You don't need to create a module for each separate resource. Modules are meant for domain separation, not necessarily for resources. Smaller apps may just need a single `AppModule`. Start with a single AppModule and grow.
|
|
234
|
+
|
|
235
|
+
A module can have multiple services, repositories and even multiple facades to separate resources from each other.
|
|
236
|
+
|
|
237
|
+
### What's a facade, when to use it?
|
|
238
|
+
|
|
239
|
+
Facades are meant as the internal public API to a module, for other modules. When one module wants to call something from another module, it should do so through a facade, and not by deeply calling a service or repository of a module. Even though this adds a layer of indirection, it lets you better differentiate between internal and external concerns of a module, and prevents code dependencies from becoming messy. If you use facades excessively often, your domain modelling could be improved.
|
|
240
|
+
|
|
241
|
+
### How should I structure files and folders?
|
|
242
|
+
|
|
243
|
+
Roughly follow how capsel structures your code:
|
|
244
|
+
|
|
245
|
+
- Use separate folders for separate modules
|
|
246
|
+
- Create a separate file for each Service, Repo, Facade, Action
|
|
247
|
+
- You could also have all actions of a resource in a single file
|
|
248
|
+
- If you further want to group files inside a module, do it either by resource (`user`, `post`, etc) or by type (`services`, `repos`, `actions`, etc). Start by having a flat hierarchy with all files of a module in a single folder, and separate into multiple folders once you feel it grew too much.
|
|
249
|
+
|
|
250
|
+
If you use multiple modules, it's good to separate them into different folders. This allows you to see: if you're importing unproportionally many files from other folders, you have lots of cross-module dependencies, and your data modelling into modules could be improved.
|
|
251
|
+
|
|
252
|
+
"How to structure code into files and folders" is often a question of how to manage code dependencies in a way that scales well. Capsel's structure already manages your code dependencies, so it makes sense to align your files and folders on capsel's structure.
|
|
253
|
+
|
|
254
|
+
### Class scopes
|
|
255
|
+
|
|
256
|
+
Capsel uses dependency injection to load and use classes in other classes. This means that Capsel creates class instances for you and caches them in different caches for different lifetimes. Whenever you call `inject()`, capsel checks the scope of the class and whether an instance is already cached.
|
|
257
|
+
|
|
258
|
+
There are 3 different scopes:
|
|
259
|
+
|
|
260
|
+
- `transient` creates a new instance every time, it does not use a cached instance
|
|
261
|
+
- `invoke` creates a new instance per request handler and reuses this cached instance within the request.
|
|
262
|
+
- `singleton` creates a new instance only once and caches it forever
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
class DatabasePool extends MyModule.Singleton {
|
|
268
|
+
// singletons are cached globally
|
|
269
|
+
private dbPool: DbPool | null = null
|
|
270
|
+
|
|
271
|
+
get dbPool() {
|
|
272
|
+
return this.dbPool || new DbPool()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
class Repo extends MyModule.Repo {
|
|
277
|
+
// repos are cached per request (`invoke`)
|
|
278
|
+
|
|
279
|
+
// it will only create the db pool once and reuse it
|
|
280
|
+
// through the application's whole lifetime
|
|
281
|
+
dbPool = this.inject(DatabasePool)
|
|
282
|
+
|
|
283
|
+
private cache = Map<string, User>()
|
|
284
|
+
|
|
285
|
+
async findById(id: string) {
|
|
286
|
+
const cached = this.cache.get(id)
|
|
287
|
+
if (cached) return cached
|
|
288
|
+
|
|
289
|
+
const user = await dbPool.user.find({ id })
|
|
290
|
+
this.cache.set(id, user)
|
|
291
|
+
return user
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
class ServiceA extends MyModule.Service {
|
|
296
|
+
// it will use the same instance that ServiceB is also using
|
|
297
|
+
repo = this.inject(Repo)
|
|
298
|
+
|
|
299
|
+
async doSomething() {
|
|
300
|
+
await this.repo.findById("1")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
class ServiceB extends MyModule.Service {
|
|
305
|
+
// it will use the same instance that ServiceA is also using
|
|
306
|
+
repo = this.inject(Repo)
|
|
307
|
+
|
|
308
|
+
async doOtherThing() {
|
|
309
|
+
await this.repo.findById("1")
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
In this example:
|
|
315
|
+
- The Database Pool will only be created once and will be reused forever
|
|
316
|
+
- The Repo is stateful for each request. The Repo cache can be shared across all usages
|
|
317
|
+
- The Services can both call the Repo and use the same instance
|
|
318
|
+
|
|
319
|
+
### Overriding class scopes
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
class FooClass extends MyModule.Class {
|
|
323
|
+
static scope = FooClass.SingletonScope
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
You _can_ override the default scope of a Repo, Service, etc. But it's better to create a new BaseClass to not confuse things.
|
|
328
|
+
|
|
329
|
+
### Defining scope when injecting a class
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
class FooRepo extends MyModule.Repo {
|
|
335
|
+
async foo() {}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
class FooService extends MyModule.Service {
|
|
339
|
+
repo = this.inject(FooRepo, "transient")
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
This will use a transient version of the FooRepo only for the `FooService`. Meaning this service won't share the same cached version that other services might be using.
|
|
344
|
+
|
|
345
|
+
It's not something you'll likely need to use at all. But this shows how the scope defines how long the instance should be cached and shared.
|
|
346
|
+
|
|
347
|
+
### Base classes
|
|
348
|
+
|
|
349
|
+
Capsel gives you multiple base classes to choose from. You can use them as they are or extend them for your own custom base classes.
|
|
350
|
+
|
|
351
|
+
- `Class` is a generic class with scope `transient`
|
|
352
|
+
- `Singleton` is a `Class` with scope `singleton`
|
|
353
|
+
- `Service` is a `Class` with scope `invoke`
|
|
354
|
+
- `Repo` is a `Memoizable(Class)` with scope `invoke`
|
|
355
|
+
- `Facade` is a named alias for `Class`
|
|
356
|
+
- `Action` is a special class with scope `transient`, where you implement a `handle()` function.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type Scope = "singleton" | "invoke" | "transient";
|
|
3
|
+
type InstantiableClass<T> = new () => T;
|
|
4
|
+
//#endregion
|
|
5
|
+
//#region src/kernel.d.ts
|
|
6
|
+
type Token<T = unknown> = InstantiableClass<T> & {
|
|
7
|
+
readonly scope?: Scope;
|
|
8
|
+
};
|
|
9
|
+
type Ctor<T = unknown> = InstantiableClass<T> & {
|
|
10
|
+
readonly scope?: Scope;
|
|
11
|
+
};
|
|
12
|
+
declare class Kernel {
|
|
13
|
+
private readonly singletons;
|
|
14
|
+
private readonly invokeCache;
|
|
15
|
+
private readonly bindings;
|
|
16
|
+
private readonly parent?;
|
|
17
|
+
private readonly root;
|
|
18
|
+
constructor(opts?: {
|
|
19
|
+
parent?: Kernel;
|
|
20
|
+
});
|
|
21
|
+
private fork;
|
|
22
|
+
private getBinding;
|
|
23
|
+
bind<T>(key: Token<T>, impl: Ctor<unknown>, scope?: Scope): void;
|
|
24
|
+
resolve<T>(key: Token<T>, scope?: Scope): T;
|
|
25
|
+
scoped(): Kernel;
|
|
26
|
+
create<T>(cls: Ctor<T>): T;
|
|
27
|
+
private instantiate;
|
|
28
|
+
private injectInto;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/kernel-als.d.ts
|
|
32
|
+
declare function runWithKernel<T>(scopedKernel: Kernel, fn: () => T): T;
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/kernel-current.d.ts
|
|
35
|
+
type CurrentKernelFn = () => Kernel;
|
|
36
|
+
declare function setCurrentKernelFn(fnKernel: CurrentKernelFn): void;
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/kernel-global.d.ts
|
|
39
|
+
declare function setGlobalKernel(kernel: Kernel): void;
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/memo.d.ts
|
|
42
|
+
type ArgsTuple = unknown[];
|
|
43
|
+
type Memoized<Args extends ArgsTuple, R> = ((...args: Args) => R) & {
|
|
44
|
+
memoized: (...args: Args) => R;
|
|
45
|
+
fresh: (...args: Args) => R;
|
|
46
|
+
prime: (...args: Args) => {
|
|
47
|
+
value: (value: R) => void;
|
|
48
|
+
};
|
|
49
|
+
preload: (...args: Args) => R;
|
|
50
|
+
bust: (...args: Args) => void;
|
|
51
|
+
bustAll: () => void;
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/modules.d.ts
|
|
55
|
+
declare const BRAND: unique symbol;
|
|
56
|
+
type Layer = "facade" | "service" | "repo" | "action" | "other";
|
|
57
|
+
type Branded<ModuleName extends string, LayerName extends Layer> = {
|
|
58
|
+
readonly [BRAND]: ClassBrand<ModuleName, LayerName>;
|
|
59
|
+
};
|
|
60
|
+
type Scoped = {
|
|
61
|
+
readonly scope: Scope;
|
|
62
|
+
};
|
|
63
|
+
type ModuleClass<ModuleName extends string, LayerName extends Layer = Layer> = InstantiableClass<unknown> & Branded<ModuleName, LayerName> & Scoped;
|
|
64
|
+
type LayerOf<T> = T extends ModuleClass<string, infer L> ? L : never;
|
|
65
|
+
type ForbiddenCrossModuleClass = ModuleClass<string, Exclude<Layer, "facade">>;
|
|
66
|
+
type AllowedDependency<ModuleName extends string, Key> = Key extends ModuleClass<ModuleName, Layer> ? Key : Key extends ForbiddenCrossModuleClass ? `Cross-module ${Capitalize<LayerOf<Key>>} injection is not allowed. Use a Facade.` : Key;
|
|
67
|
+
declare class ClassBrand<ModuleName extends string, LayerName extends Layer> {
|
|
68
|
+
readonly moduleName: ModuleName;
|
|
69
|
+
readonly layerName: LayerName;
|
|
70
|
+
constructor(moduleName: ModuleName, layerName: LayerName);
|
|
71
|
+
}
|
|
72
|
+
declare function createModule<const ModuleName extends string>(moduleName: ModuleName): {
|
|
73
|
+
Facade: (abstract new () => {
|
|
74
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
75
|
+
}) & {
|
|
76
|
+
scope: Scope;
|
|
77
|
+
readonly [BRAND]: ClassBrand<ModuleName, "facade">;
|
|
78
|
+
InvokeScope: Scope;
|
|
79
|
+
TransientScope: Scope;
|
|
80
|
+
SingletonScope: Scope;
|
|
81
|
+
};
|
|
82
|
+
Service: (abstract new () => {
|
|
83
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
84
|
+
}) & {
|
|
85
|
+
scope: Scope;
|
|
86
|
+
readonly [BRAND]: ClassBrand<ModuleName, "service">;
|
|
87
|
+
InvokeScope: Scope;
|
|
88
|
+
TransientScope: Scope;
|
|
89
|
+
SingletonScope: Scope;
|
|
90
|
+
};
|
|
91
|
+
Repo: (abstract new () => {
|
|
92
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
93
|
+
memo<Args extends unknown[], R>(fn: (...args: Args) => R): Memoized<Args, R>;
|
|
94
|
+
}) & {
|
|
95
|
+
scope: Scope;
|
|
96
|
+
readonly [BRAND]: ClassBrand<ModuleName, "repo">;
|
|
97
|
+
};
|
|
98
|
+
Action: (abstract new () => {
|
|
99
|
+
handle(...args: unknown[]): unknown | Promise<unknown>;
|
|
100
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
101
|
+
}) & {
|
|
102
|
+
scope: Scope;
|
|
103
|
+
invoke<TAction extends {
|
|
104
|
+
handle(...args: unknown[]): unknown | Promise<unknown>;
|
|
105
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
106
|
+
}, TResult = ReturnType<TAction["handle"]>>(this: new () => TAction, ...args: Parameters<TAction["handle"]>): TResult;
|
|
107
|
+
withKernel<TAction extends {
|
|
108
|
+
handle(...args: unknown[]): unknown | Promise<unknown>;
|
|
109
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
110
|
+
}>(this: new () => TAction, kernel: Kernel): {
|
|
111
|
+
invoke<TResult = ReturnType<TAction["handle"]>>(...args: Parameters<TAction["handle"]>): TResult;
|
|
112
|
+
};
|
|
113
|
+
readonly [BRAND]: ClassBrand<ModuleName, "action">;
|
|
114
|
+
InvokeScope: Scope;
|
|
115
|
+
TransientScope: Scope;
|
|
116
|
+
SingletonScope: Scope;
|
|
117
|
+
};
|
|
118
|
+
Singleton: (abstract new () => {
|
|
119
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
120
|
+
}) & {
|
|
121
|
+
scope: Scope;
|
|
122
|
+
readonly [BRAND]: ClassBrand<ModuleName, "other">;
|
|
123
|
+
InvokeScope: Scope;
|
|
124
|
+
TransientScope: Scope;
|
|
125
|
+
SingletonScope: Scope;
|
|
126
|
+
};
|
|
127
|
+
Class: (abstract new () => {
|
|
128
|
+
inject: <TKey extends ModuleClass<string>>(key: AllowedDependency<ModuleName, TKey>, scope?: Scope) => InstanceType<TKey>;
|
|
129
|
+
}) & {
|
|
130
|
+
scope: Scope;
|
|
131
|
+
readonly [BRAND]: ClassBrand<ModuleName, "other">;
|
|
132
|
+
InvokeScope: Scope;
|
|
133
|
+
TransientScope: Scope;
|
|
134
|
+
SingletonScope: Scope;
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
export { Kernel, createModule, runWithKernel, setCurrentKernelFn, setGlobalKernel };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import objectHash from "object-hash";
|
|
3
|
+
|
|
4
|
+
//#region src/dependencies.ts
|
|
5
|
+
var UnresolvedDependency = class {
|
|
6
|
+
constructor(token, scope) {
|
|
7
|
+
this.token = token;
|
|
8
|
+
this.scope = scope;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
function tokenizedDependency(defaultClass, scope) {
|
|
12
|
+
return new UnresolvedDependency(defaultClass, scope);
|
|
13
|
+
}
|
|
14
|
+
function getInjectionPoint(v) {
|
|
15
|
+
if (v instanceof UnresolvedDependency) return v;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/kernel.ts
|
|
21
|
+
var Kernel = class Kernel {
|
|
22
|
+
singletons = /* @__PURE__ */ new Map();
|
|
23
|
+
invokeCache = /* @__PURE__ */ new Map();
|
|
24
|
+
bindings = /* @__PURE__ */ new Map();
|
|
25
|
+
parent;
|
|
26
|
+
root;
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
this.root = opts.parent?.root ?? this;
|
|
29
|
+
this.parent = opts.parent;
|
|
30
|
+
}
|
|
31
|
+
fork() {
|
|
32
|
+
return new Kernel({ parent: this });
|
|
33
|
+
}
|
|
34
|
+
getBinding(key) {
|
|
35
|
+
return this.bindings.get(key) ?? this.parent?.getBinding(key);
|
|
36
|
+
}
|
|
37
|
+
bind(key, impl, scope = "transient") {
|
|
38
|
+
this.singletons.delete(key);
|
|
39
|
+
this.bindings.set(key, {
|
|
40
|
+
impl,
|
|
41
|
+
scope
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
resolve(key, scope) {
|
|
45
|
+
const binding = this.getBinding(key);
|
|
46
|
+
const impl = binding?.impl ?? key;
|
|
47
|
+
const requestedScope = binding?.scope ?? scope ?? impl.scope;
|
|
48
|
+
if (requestedScope === "singleton") {
|
|
49
|
+
if (this.root.singletons.has(key)) return this.root.singletons.get(key);
|
|
50
|
+
const created = this.instantiate(impl);
|
|
51
|
+
this.root.singletons.set(key, created);
|
|
52
|
+
return created;
|
|
53
|
+
}
|
|
54
|
+
if (requestedScope === "invoke" && !!this.parent) {
|
|
55
|
+
if (this.invokeCache.has(key)) return this.invokeCache.get(key);
|
|
56
|
+
const created = this.instantiate(impl);
|
|
57
|
+
this.invokeCache.set(key, created);
|
|
58
|
+
return created;
|
|
59
|
+
}
|
|
60
|
+
return this.instantiate(impl);
|
|
61
|
+
}
|
|
62
|
+
scoped() {
|
|
63
|
+
return this.fork();
|
|
64
|
+
}
|
|
65
|
+
create(cls) {
|
|
66
|
+
return this.instantiate(cls);
|
|
67
|
+
}
|
|
68
|
+
instantiate(cls) {
|
|
69
|
+
const instance = new cls();
|
|
70
|
+
this.injectInto(instance);
|
|
71
|
+
return instance;
|
|
72
|
+
}
|
|
73
|
+
injectInto(instance) {
|
|
74
|
+
const obj = instance;
|
|
75
|
+
Object.entries(obj).map(([key, value]) => {
|
|
76
|
+
const injectionPoint = getInjectionPoint(value);
|
|
77
|
+
if (!injectionPoint) return null;
|
|
78
|
+
return [key, injectionPoint];
|
|
79
|
+
}).filter((entry) => entry !== null).forEach(([key, injectionPoint]) => {
|
|
80
|
+
obj[key] = this.resolve(injectionPoint.token, injectionPoint.scope);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/kernel-als.ts
|
|
87
|
+
const als = new AsyncLocalStorage();
|
|
88
|
+
function runWithKernel(scopedKernel, fn) {
|
|
89
|
+
return als.run(scopedKernel, fn);
|
|
90
|
+
}
|
|
91
|
+
function getAlsKernel() {
|
|
92
|
+
return als.getStore() ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/kernel-current.ts
|
|
97
|
+
let currentKernelFn = null;
|
|
98
|
+
function setCurrentKernelFn(fnKernel) {
|
|
99
|
+
currentKernelFn = fnKernel;
|
|
100
|
+
}
|
|
101
|
+
function getCurrentKernelFromFn() {
|
|
102
|
+
return currentKernelFn?.();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/kernel-global.ts
|
|
107
|
+
let globalKernel = null;
|
|
108
|
+
function setGlobalKernel(kernel) {
|
|
109
|
+
globalKernel = kernel;
|
|
110
|
+
}
|
|
111
|
+
function getGlobalKernel() {
|
|
112
|
+
return globalKernel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/kernel-invoke.ts
|
|
117
|
+
function getInvokeKernel() {
|
|
118
|
+
return getCurrentKernelFromFn() ?? getAlsKernel() ?? getGlobalKernel()?.scoped() ?? new Kernel();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/memo.ts
|
|
123
|
+
function Memoizable(Base) {
|
|
124
|
+
class MemoizableBase extends Base {
|
|
125
|
+
memo(fn) {
|
|
126
|
+
const cache = /* @__PURE__ */ new Map();
|
|
127
|
+
const keyOf = (args) => objectHash(args);
|
|
128
|
+
const memoized = ((...args) => {
|
|
129
|
+
const key = keyOf(args);
|
|
130
|
+
const hit = cache.get(key);
|
|
131
|
+
if (hit !== void 0) return hit;
|
|
132
|
+
const value = fn.apply(this, args);
|
|
133
|
+
if (typeof value.then === "function") value.catch(() => {
|
|
134
|
+
cache.delete(key);
|
|
135
|
+
});
|
|
136
|
+
cache.set(key, value);
|
|
137
|
+
return value;
|
|
138
|
+
});
|
|
139
|
+
memoized.memoized = memoized;
|
|
140
|
+
memoized.fresh = ((...args) => {
|
|
141
|
+
return fn.apply(this, args);
|
|
142
|
+
});
|
|
143
|
+
memoized.preload = ((...args) => {
|
|
144
|
+
return memoized(...args);
|
|
145
|
+
});
|
|
146
|
+
memoized.prime = ((...args) => {
|
|
147
|
+
const key = keyOf(args);
|
|
148
|
+
return { value: (value) => {
|
|
149
|
+
if (typeof value.then === "function") value.catch(() => {
|
|
150
|
+
cache.delete(key);
|
|
151
|
+
});
|
|
152
|
+
cache.set(key, value);
|
|
153
|
+
} };
|
|
154
|
+
});
|
|
155
|
+
memoized.bust = (...args) => {
|
|
156
|
+
cache.delete(keyOf(args));
|
|
157
|
+
};
|
|
158
|
+
memoized.bustAll = () => {
|
|
159
|
+
cache.clear();
|
|
160
|
+
};
|
|
161
|
+
return memoized;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return MemoizableBase;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/modules.ts
|
|
169
|
+
const BRAND = Symbol("_capsel_brand");
|
|
170
|
+
var ClassBrand = class {
|
|
171
|
+
constructor(moduleName, layerName) {
|
|
172
|
+
this.moduleName = moduleName;
|
|
173
|
+
this.layerName = layerName;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
function createModule(moduleName) {
|
|
177
|
+
function inject(key, scope) {
|
|
178
|
+
if (key[BRAND].moduleName !== moduleName && key[BRAND].layerName !== "facade") throw new Error(`Cross-module ${key[BRAND].layerName} dependency is not allowed. Use a Facade. (Tried to inject a ${key[BRAND].layerName} from ${key[BRAND].moduleName} into ${moduleName})`);
|
|
179
|
+
return tokenizedDependency(key, scope ?? key.scope);
|
|
180
|
+
}
|
|
181
|
+
class BaseClass {
|
|
182
|
+
static InvokeScope = "invoke";
|
|
183
|
+
static TransientScope = "transient";
|
|
184
|
+
static SingletonScope = "singleton";
|
|
185
|
+
inject = inject.bind(this);
|
|
186
|
+
}
|
|
187
|
+
class Facade extends BaseClass {
|
|
188
|
+
static [BRAND] = new ClassBrand(moduleName, "facade");
|
|
189
|
+
static scope = "transient";
|
|
190
|
+
}
|
|
191
|
+
class Service extends BaseClass {
|
|
192
|
+
static [BRAND] = new ClassBrand(moduleName, "service");
|
|
193
|
+
static scope = "invoke";
|
|
194
|
+
}
|
|
195
|
+
class Repo extends Memoizable(BaseClass) {
|
|
196
|
+
static [BRAND] = new ClassBrand(moduleName, "repo");
|
|
197
|
+
static scope = "invoke";
|
|
198
|
+
}
|
|
199
|
+
class Action extends BaseClass {
|
|
200
|
+
static [BRAND] = new ClassBrand(moduleName, "action");
|
|
201
|
+
static scope = "transient";
|
|
202
|
+
static invoke(...args) {
|
|
203
|
+
return getInvokeKernel().create(this).handle(...args);
|
|
204
|
+
}
|
|
205
|
+
static withKernel(kernel) {
|
|
206
|
+
const ActionClass = this;
|
|
207
|
+
return { invoke(...args) {
|
|
208
|
+
return kernel.scoped().create(ActionClass).handle(...args);
|
|
209
|
+
} };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
class Singleton extends BaseClass {
|
|
213
|
+
static [BRAND] = new ClassBrand(moduleName, "other");
|
|
214
|
+
static scope = "singleton";
|
|
215
|
+
}
|
|
216
|
+
class Class extends BaseClass {
|
|
217
|
+
static [BRAND] = new ClassBrand(moduleName, "other");
|
|
218
|
+
static scope = "transient";
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
Facade,
|
|
222
|
+
Service,
|
|
223
|
+
Repo,
|
|
224
|
+
Action,
|
|
225
|
+
Singleton,
|
|
226
|
+
Class
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
export { Kernel, createModule, runWithKernel, setCurrentKernelFn, setGlobalKernel };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vla",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"description": "Data layer kernel for backend- and fullstack apps",
|
|
6
|
+
"author": "Timo Mämecke <hello@timo.wtf>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/timomeh/capsel#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/timomeh/capsel.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/timomeh/capsel/issues"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.mjs",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.mjs",
|
|
21
|
+
"module": "./dist/index.mjs",
|
|
22
|
+
"types": "./dist/index.d.mts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"dev": "tsdown --watch",
|
|
29
|
+
"test": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "pnpm run build"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@biomejs/biome": "2.3.10",
|
|
35
|
+
"@types/node": "^25.0.3",
|
|
36
|
+
"@types/object-hash": "^3.0.6",
|
|
37
|
+
"bumpp": "^10.3.2",
|
|
38
|
+
"tsdown": "^0.18.1",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"vitest": "^4.0.16"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"object-hash": "^3.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|