@happyvertical/smrt-template-sveltekit 0.38.26 → 0.39.0
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/AGENTS.md +45 -26
- package/README.md +118 -80
- package/index.js +4 -4
- package/package.json +10 -8
- package/template/.env.example +4 -8
- package/template/AGENTS.md +29 -28
- package/template/README.md +232 -398
- package/template/package.json +21 -12
- package/template/smrt.config.ts +1 -16
- package/template/src/app.d.ts +5 -4
- package/template/src/app.html +0 -1
- package/template/src/hooks.server.ts +27 -111
- package/template/src/lib/objects/Item.ts +23 -22
- package/template/src/lib/objects/index.ts +1 -1
- package/template/src/lib/server/smrt.ts +14 -22
- package/template/src/lib/server/tenancy.ts +81 -234
- package/template/src/routes/+layout.server.ts +12 -66
- package/template/src/routes/+layout.svelte +64 -76
- package/template/src/routes/+page.server.ts +67 -70
- package/template/src/routes/+page.svelte +141 -109
- package/template/src/routes/settings/+page.svelte +1 -1
- package/template/vite.config.ts +14 -4
- package/LICENSE +0 -7
package/template/package.json
CHANGED
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smrt-sveltekit-app",
|
|
3
3
|
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
4
5
|
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@10.34.4",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24.18.0",
|
|
9
|
+
"pnpm": "10.34.4"
|
|
10
|
+
},
|
|
5
11
|
"scripts": {
|
|
6
12
|
"dev": "vite dev",
|
|
7
13
|
"build": "vite build",
|
|
8
14
|
"preview": "vite preview",
|
|
9
15
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
16
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
17
|
+
"db:migrate": "vite build && smrt db:migrate"
|
|
12
18
|
},
|
|
13
19
|
"devDependencies": {
|
|
14
20
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
15
|
-
"@sveltejs/kit": "^2.69.
|
|
16
|
-
"@sveltejs/vite-plugin-svelte": "^7.
|
|
21
|
+
"@sveltejs/kit": "^2.69.2",
|
|
22
|
+
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
|
23
|
+
"@types/node": "^24.13.3",
|
|
17
24
|
"svelte": "^5.56.4",
|
|
18
|
-
"svelte-check": "^4.7.
|
|
19
|
-
"typescript": "^
|
|
20
|
-
"vite": "^8.1.
|
|
25
|
+
"svelte-check": "^4.7.2",
|
|
26
|
+
"typescript": "^6.0.3",
|
|
27
|
+
"vite": "^8.1.4",
|
|
28
|
+
"@happyvertical/smrt-cli": "^0.39.0"
|
|
21
29
|
},
|
|
22
30
|
"dependencies": {
|
|
23
|
-
"@happyvertical/smrt-core": "^0.
|
|
24
|
-
"@happyvertical/smrt-
|
|
25
|
-
"@happyvertical/smrt-
|
|
26
|
-
"@happyvertical/smrt-
|
|
27
|
-
"@happyvertical/smrt-
|
|
31
|
+
"@happyvertical/smrt-core": "^0.39.0",
|
|
32
|
+
"@happyvertical/smrt-profiles": "^0.39.0",
|
|
33
|
+
"@happyvertical/smrt-svelte": "^0.39.0",
|
|
34
|
+
"@happyvertical/smrt-tenancy": "^0.39.0",
|
|
35
|
+
"@happyvertical/smrt-ui": "^0.39.0",
|
|
36
|
+
"@happyvertical/smrt-users": "^0.39.0"
|
|
28
37
|
}
|
|
29
38
|
}
|
package/template/smrt.config.ts
CHANGED
|
@@ -6,14 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export default {
|
|
9
|
-
// Global SMRT settings
|
|
10
|
-
smrt: {
|
|
11
|
-
logLevel: 'info',
|
|
12
|
-
schemaMigration: {
|
|
13
|
-
strategy: 'auto-add',
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
|
|
17
9
|
// Agent/developer knowledge artifacts are generated by smrtPlugin().
|
|
18
10
|
// HTTP API routes stay off unless explicitly enabled for a dev/admin-only workflow.
|
|
19
11
|
knowledge: {
|
|
@@ -33,17 +25,10 @@ export default {
|
|
|
33
25
|
cli: {
|
|
34
26
|
database: {
|
|
35
27
|
type: 'sqlite',
|
|
36
|
-
url: process.env.DATABASE_URL || './
|
|
28
|
+
url: process.env.DATABASE_URL || './app.db',
|
|
37
29
|
},
|
|
38
30
|
verbose: false,
|
|
39
31
|
},
|
|
40
32
|
|
|
41
|
-
// AI configuration (optional)
|
|
42
|
-
ai: process.env.OPENAI_API_KEY
|
|
43
|
-
? {
|
|
44
|
-
provider: 'openai',
|
|
45
|
-
apiKey: process.env.OPENAI_API_KEY,
|
|
46
|
-
}
|
|
47
|
-
: undefined,
|
|
48
33
|
},
|
|
49
34
|
};
|
package/template/src/app.d.ts
CHANGED
|
@@ -8,11 +8,12 @@ declare global {
|
|
|
8
8
|
// interface Error {}
|
|
9
9
|
interface Locals extends SessionLocals {
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
* string; this object carries the full context (permissions,
|
|
14
|
-
* superAdminBypass, etc.) when needed.
|
|
11
|
+
* URL-selected tenant candidate. Never use this as authorization; the
|
|
12
|
+
* session-authorized tenant remains `tenantId` from SessionLocals.
|
|
15
13
|
*/
|
|
14
|
+
selectedTenantId: string | null;
|
|
15
|
+
selectedTenantSlug: string | null;
|
|
16
|
+
/** Full context for the authorized session tenant, when present. */
|
|
16
17
|
tenantContext?: import('@happyvertical/smrt-tenancy').MinimalTenantContext;
|
|
17
18
|
}
|
|
18
19
|
// interface PageData {}
|
package/template/src/app.html
CHANGED
|
@@ -1,137 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Request foundation, in order:
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 2. A subdomain-based tenant resolver (`tenancyHandle`) — if the URL has
|
|
10
|
-
* a leading subdomain (e.g. `acme.demo.local`), the request runs inside
|
|
11
|
-
* `withTenant({ tenantId: 'acme' })`.
|
|
12
|
-
* 3. `createSessionHandler({ enterTenantContext: true })` from
|
|
13
|
-
* @happyvertical/smrt-users/sveltekit — populates
|
|
14
|
-
* `event.locals.{user, permissions, tenantId, sessionId}` and pushes
|
|
15
|
-
* the session's tenant into the AsyncLocalStorage context.
|
|
16
|
-
* 4. `reconcileTenantLocals` — final reconciliation step. The session
|
|
17
|
-
* handler initializes `event.locals.tenantId = null` before reading
|
|
18
|
-
* the session, so for unauthenticated public requests it clobbers the
|
|
19
|
-
* subdomain value set by step 2. This handle restores the subdomain
|
|
20
|
-
* tenant on `event.locals.tenantId` when no session tenant set it,
|
|
21
|
-
* so layout/page/action code reads a consistent value regardless of
|
|
22
|
-
* auth state.
|
|
4
|
+
* 1. Resolve a URL tenant candidate into `locals.selectedTenant*`. This is
|
|
5
|
+
* selection only and does not enter AsyncLocalStorage tenant context.
|
|
6
|
+
* 2. Load the signed session. `enterTenantContext: true` establishes the
|
|
7
|
+
* authorized session tenant and its permission set for downstream code.
|
|
8
|
+
* 3. Publish the authorized context on locals when it matches the session.
|
|
23
9
|
*
|
|
24
|
-
*
|
|
10
|
+
* This ordering prevents a spoofed header or hostname from becoming query
|
|
11
|
+
* authority. Membership-gated tenant switching belongs in an explicit action
|
|
12
|
+
* using `switchSessionTenant()` from `@happyvertical/smrt-users/sveltekit`.
|
|
25
13
|
*/
|
|
26
14
|
|
|
27
|
-
import {
|
|
28
|
-
import type { Handle } from '@sveltejs/kit';
|
|
29
|
-
|
|
15
|
+
import { getCurrentTenant, enableTenancy } from '@happyvertical/smrt-tenancy';
|
|
30
16
|
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
enableTenancy,
|
|
34
|
-
getCurrentTenant,
|
|
35
|
-
} from '@happyvertical/smrt-tenancy';
|
|
17
|
+
import type { Handle } from '@sveltejs/kit';
|
|
18
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
36
19
|
|
|
37
|
-
import { resolveTenant } from '$lib/server/tenancy';
|
|
38
20
|
import { getSmrtConfig } from '$lib/server/smrt';
|
|
21
|
+
import { resolveTenant } from '$lib/server/tenancy';
|
|
39
22
|
|
|
40
|
-
// Register the tenancy interceptor globally. Idempotent — `enableTenancy()`
|
|
41
|
-
// guards against duplicate registration internally.
|
|
42
23
|
enableTenancy();
|
|
43
24
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// strict typechecking. The casts below adapt the upstream return types
|
|
51
|
-
// to SvelteKit's `Handle` once. Runtime behaviour is unchanged; the
|
|
52
|
-
// types still flow through `Handle` to the `sequence(...)` composition.
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Subdomain → tenantId handle. Runs BEFORE the session handler so generated
|
|
56
|
-
* routes have a tenant in context even for unauthenticated public requests
|
|
57
|
-
* (e.g. tenant-scoped catalog endpoints). If a session also carries a
|
|
58
|
-
* tenant, the session handler's inner `withTenant()` will override.
|
|
59
|
-
*/
|
|
60
|
-
const tenancyHandle = createSvelteKitHandle({
|
|
61
|
-
resolveTenantId: async (event) => {
|
|
62
|
-
// The createSvelteKitHandle adapter passes a structural event that is
|
|
63
|
-
// directly assignable to the resolver's `TenantResolverEvent` shape
|
|
64
|
-
// (`url` + `request.headers`), so no cast is needed.
|
|
65
|
-
const result = await resolveTenant(event);
|
|
66
|
-
return result.tenantId;
|
|
67
|
-
},
|
|
68
|
-
}) as unknown as Handle;
|
|
25
|
+
const tenantSelectionHandle: Handle = async ({ event, resolve }) => {
|
|
26
|
+
const selection = await resolveTenant(event);
|
|
27
|
+
event.locals.selectedTenantId = selection.tenantId;
|
|
28
|
+
event.locals.selectedTenantSlug = selection.tenantSlug;
|
|
29
|
+
return resolve(event);
|
|
30
|
+
};
|
|
69
31
|
|
|
70
|
-
/**
|
|
71
|
-
* Session handle from @happyvertical/smrt-users. `enterTenantContext: true`
|
|
72
|
-
* makes it call `enterTenantContext()` after loading the session, so
|
|
73
|
-
* downstream code sees the *session's* tenant id when one is present.
|
|
74
|
-
*/
|
|
75
32
|
const sessionHandle = createSessionHandler({
|
|
76
33
|
...getSmrtConfig('Session'),
|
|
77
34
|
enterTenantContext: true,
|
|
78
35
|
}) as unknown as Handle;
|
|
79
36
|
|
|
80
|
-
|
|
81
|
-
* Final reconciliation: read the active AsyncLocalStorage tenant (already
|
|
82
|
-
* set correctly by tenancyHandle + sessionHandle, with session winning
|
|
83
|
-
* over subdomain when both are present) and align BOTH `event.locals.tenantId`
|
|
84
|
-
* AND `event.locals.tenantContext` to it.
|
|
85
|
-
*
|
|
86
|
-
* Two failure modes this guards against:
|
|
87
|
-
*
|
|
88
|
-
* 1. **Public requests with a subdomain.** `createSessionHandler` resets
|
|
89
|
-
* `event.locals.tenantId` to `null` before checking for a session
|
|
90
|
-
* cookie. With no session, the session handler returns without
|
|
91
|
-
* repopulating it, so the subdomain-resolved tenant goes missing
|
|
92
|
-
* from layouts/actions even though the ALS context (set by
|
|
93
|
-
* `tenancyHandle`) still carries it.
|
|
94
|
-
* 2. **Authenticated requests where the session's tenant differs from
|
|
95
|
-
* the subdomain.** The session handler calls `withTenant(sessionCtx, ...)`
|
|
96
|
-
* — switching the ALS context to the session tenant — and updates
|
|
97
|
-
* `event.locals.tenantId` to match. But it does NOT touch
|
|
98
|
-
* `event.locals.tenantContext`, which is still the subdomain context
|
|
99
|
-
* previously written by `tenancyHandle`. Layouts/actions reading
|
|
100
|
-
* `tenantContext` would then see stale permissions/superAdminBypass
|
|
101
|
-
* that don't match the tenant their queries are scoped to.
|
|
102
|
-
*
|
|
103
|
-
* Reading the live ALS context here and writing it back to BOTH
|
|
104
|
-
* `locals.tenantId` and `locals.tenantContext` keeps the three vantage
|
|
105
|
-
* points (locals.tenantId, locals.tenantContext, ALS-scoped queries) in
|
|
106
|
-
* sync regardless of auth state.
|
|
107
|
-
*
|
|
108
|
-
* **ALS-scope dependency:** this handle reads `getCurrentTenant()` from
|
|
109
|
-
* AsyncLocalStorage, which only works because both upstream handles
|
|
110
|
-
* call `resolve(event)` *inside* their own ALS scope:
|
|
111
|
-
* - `createSvelteKitHandle` (smrt-tenancy) wraps in `withTenant(ctx, () => resolve(event))`.
|
|
112
|
-
* - `createSessionHandler` (smrt-users) wraps in `withSessionPermissionContext`,
|
|
113
|
-
* which internally calls `withTenant(sessionCtx, fn)` when the session
|
|
114
|
-
* has a tenantId.
|
|
115
|
-
* If a future refactor moved either handle's `resolve()` call outside
|
|
116
|
-
* its ALS scope, `getCurrentTenant()` here would return `undefined` and
|
|
117
|
-
* the reconciliation would silently no-op. We deliberately do NOT fall
|
|
118
|
-
* back to `event.locals.tenantId` here — that would re-introduce the
|
|
119
|
-
* stale-tenantContext bug from the subdomain handler. If the upstream
|
|
120
|
-
* scoping ever changes, this handle should be updated to read from a
|
|
121
|
-
* dedicated cross-handler carrier (e.g. `event.locals._tenantSource`)
|
|
122
|
-
* rather than guessing.
|
|
123
|
-
*/
|
|
124
|
-
const reconcileTenantLocals: Handle = async ({ event, resolve }) => {
|
|
37
|
+
const authorizedTenantLocalsHandle: Handle = async ({ event, resolve }) => {
|
|
125
38
|
const activeContext = getCurrentTenant();
|
|
126
|
-
if (
|
|
127
|
-
event.locals.
|
|
39
|
+
if (
|
|
40
|
+
event.locals.user &&
|
|
41
|
+
event.locals.tenantId &&
|
|
42
|
+
activeContext?.tenantId === event.locals.tenantId
|
|
43
|
+
) {
|
|
128
44
|
event.locals.tenantContext = activeContext;
|
|
129
45
|
}
|
|
130
46
|
return resolve(event);
|
|
131
47
|
};
|
|
132
48
|
|
|
133
49
|
export const handle: Handle = sequence(
|
|
134
|
-
|
|
50
|
+
tenantSelectionHandle,
|
|
135
51
|
sessionHandle,
|
|
136
|
-
|
|
52
|
+
authorizedTenantLocalsHandle,
|
|
137
53
|
);
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Example
|
|
2
|
+
* Example s-m-r-t object.
|
|
3
3
|
*
|
|
4
4
|
* This is a sample SMRT object to demonstrate the pattern.
|
|
5
5
|
* Rename or replace this with your own objects.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
ObjectRegistry,
|
|
10
|
+
SmrtCollection,
|
|
11
|
+
SmrtObject,
|
|
12
|
+
smrt,
|
|
13
|
+
} from '@happyvertical/smrt-core';
|
|
14
|
+
import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
|
|
9
15
|
|
|
10
16
|
@smrt({
|
|
11
17
|
api: {
|
|
12
|
-
include: ['list', 'get', 'create', 'update', 'delete'
|
|
18
|
+
include: ['list', 'get', 'create', 'update', 'delete'],
|
|
19
|
+
writable: ['title', 'description', 'status'],
|
|
13
20
|
},
|
|
14
|
-
cli: { include: ['list', 'get', '
|
|
21
|
+
cli: { include: ['list', 'get', 'create', 'update', 'delete'] },
|
|
22
|
+
mcp: { include: ['list', 'get', 'create', 'update', 'delete'] },
|
|
15
23
|
})
|
|
24
|
+
@TenantScoped({ mode: 'optional' })
|
|
16
25
|
export class Item extends SmrtObject {
|
|
26
|
+
/** Null rows are global; tenant rows are isolated automatically. */
|
|
27
|
+
@tenantId({ nullable: true })
|
|
28
|
+
tenantId: string | null = null;
|
|
29
|
+
|
|
17
30
|
/** Item title */
|
|
18
31
|
title: string = '';
|
|
19
32
|
|
|
@@ -22,23 +35,11 @@ export class Item extends SmrtObject {
|
|
|
22
35
|
|
|
23
36
|
/** Status of the item */
|
|
24
37
|
status: string = 'draft';
|
|
38
|
+
}
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* AI-powered summarization
|
|
31
|
-
*/
|
|
32
|
-
async summarize(): Promise<string> {
|
|
33
|
-
return await this.do('Create a brief summary of this item');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Mark item as published
|
|
38
|
-
*/
|
|
39
|
-
async publish(): Promise<void> {
|
|
40
|
-
this.status = 'published';
|
|
41
|
-
this.publishedAt = new Date();
|
|
42
|
-
await this.save();
|
|
43
|
-
}
|
|
40
|
+
/** Explicit collection constructor used by generated CLI/MCP/runtime tools. */
|
|
41
|
+
export class ItemCollection extends SmrtCollection<Item> {
|
|
42
|
+
static readonly _itemClass = Item;
|
|
44
43
|
}
|
|
44
|
+
|
|
45
|
+
ObjectRegistry.registerCollection('Item', ItemCollection);
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type SmrtObject,
|
|
15
15
|
} from '@happyvertical/smrt-core';
|
|
16
16
|
import { loadManifestFromPathSync } from '@happyvertical/smrt-core/manifest';
|
|
17
|
+
import { getRequestScopedDatabase } from '@happyvertical/smrt-users';
|
|
17
18
|
|
|
18
19
|
// Import all SMRT objects to register them.
|
|
19
20
|
import '../objects/index.js';
|
|
@@ -25,8 +26,17 @@ import '../objects/index.js';
|
|
|
25
26
|
// fresh checkout that has not run the plugin yet still boots.
|
|
26
27
|
try {
|
|
27
28
|
await import('./smrt-register.js');
|
|
28
|
-
} catch {
|
|
29
|
-
// Not generated yet — the first `vite dev` / `vite build`
|
|
29
|
+
} catch (error) {
|
|
30
|
+
// Not generated yet — the first `vite dev` / `vite build` creates it. Do
|
|
31
|
+
// not hide a real import/runtime error from an existing generated module.
|
|
32
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
33
|
+
const message = error instanceof Error ? error.message : '';
|
|
34
|
+
if (
|
|
35
|
+
code !== 'ERR_MODULE_NOT_FOUND' &&
|
|
36
|
+
!message.includes('smrt-register.js')
|
|
37
|
+
) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
// Hydrate field metadata for this app's own objects from the local manifest
|
|
@@ -40,13 +50,6 @@ if (existsSync(localManifestPath)) {
|
|
|
40
50
|
loadManifestFromPathSync(localManifestPath);
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
declare global {
|
|
44
|
-
// eslint-disable-next-line no-var
|
|
45
|
-
var __smrtGetRequestScopedDatabase:
|
|
46
|
-
| (() => SmrtClassOptions['db'] | undefined)
|
|
47
|
-
| undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
53
|
/**
|
|
51
54
|
* Per-object configuration overrides
|
|
52
55
|
*
|
|
@@ -64,23 +67,12 @@ const objectOverrides: Record<string, Partial<SmrtClassOptions>> = {
|
|
|
64
67
|
function getDefaultConfig(): SmrtClassOptions {
|
|
65
68
|
return {
|
|
66
69
|
db: {
|
|
67
|
-
url: process.env.DATABASE_URL || './
|
|
70
|
+
url: process.env.DATABASE_URL || './app.db',
|
|
68
71
|
type: (process.env.DATABASE_TYPE as 'sqlite' | 'postgres') || 'sqlite',
|
|
69
72
|
},
|
|
70
|
-
ai: process.env.OPENAI_API_KEY
|
|
71
|
-
? {
|
|
72
|
-
type: 'openai',
|
|
73
|
-
apiKey: process.env.OPENAI_API_KEY,
|
|
74
|
-
}
|
|
75
|
-
: undefined,
|
|
76
73
|
};
|
|
77
74
|
}
|
|
78
75
|
|
|
79
|
-
function getRequestScopedDatabase(): SmrtClassOptions['db'] | undefined {
|
|
80
|
-
const getter = globalThis.__smrtGetRequestScopedDatabase;
|
|
81
|
-
return typeof getter === 'function' ? getter() : undefined;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
76
|
/**
|
|
85
77
|
* Get configuration for a specific SMRT class
|
|
86
78
|
*/
|
|
@@ -102,7 +94,7 @@ export async function getCollection<T extends SmrtObject>(className: string) {
|
|
|
102
94
|
const objectOverride = objectOverrides[className];
|
|
103
95
|
const requestScopedDb = objectOverride?.db
|
|
104
96
|
? undefined
|
|
105
|
-
: getRequestScopedDatabase();
|
|
97
|
+
: (getRequestScopedDatabase() as SmrtClassOptions['db'] | undefined);
|
|
106
98
|
|
|
107
99
|
return await ObjectRegistry.getCollection<T>(className, {
|
|
108
100
|
...config,
|