@teamblind/axeda-testing 0.0.0-stage → 0.1.0-alpha.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/CHANGELOG.md +37 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +14 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/memory-persistence.d.ts +11 -0
- package/dist/memory-persistence.js +231 -0
- package/dist/memory-tenants.d.ts +24 -0
- package/dist/memory-tenants.js +248 -0
- package/dist/memory-users.d.ts +17 -0
- package/dist/memory-users.js +111 -0
- package/dist/persistence-contract.d.ts +13 -0
- package/dist/persistence-contract.js +426 -0
- package/dist/tenant-contract.d.ts +3 -0
- package/dist/tenant-contract.js +388 -0
- package/package.json +51 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { AxedaError, businessKey, companyKey, scopeKey, withoutSecret, } from '@teamblind/axeda-core';
|
|
2
|
+
export const encodeCursor = (value) => Buffer.from(value, 'utf8').toString('base64url');
|
|
3
|
+
export const decodeCursor = (cursor) => Buffer.from(cursor, 'base64url').toString('utf8');
|
|
4
|
+
/** Pagina una lista ya ordenada por `key` con un cursor opaco. */
|
|
5
|
+
export function paginate(sorted, key, page) {
|
|
6
|
+
const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
|
|
7
|
+
const remaining = after === null ? sorted : sorted.filter((item) => key(item) > after);
|
|
8
|
+
const items = remaining.slice(0, page.limit);
|
|
9
|
+
const last = items.at(-1);
|
|
10
|
+
return {
|
|
11
|
+
items,
|
|
12
|
+
nextCursor: remaining.length > items.length && last ? encodeCursor(key(last)) : null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const matches = (entity, filter) => {
|
|
16
|
+
if (filter.ids !== undefined && !filter.ids.includes(entity.id))
|
|
17
|
+
return false;
|
|
18
|
+
const deleted = filter.deleted ?? 'exclude';
|
|
19
|
+
if (deleted === 'exclude')
|
|
20
|
+
return entity.deletedAt === null;
|
|
21
|
+
if (deleted === 'only')
|
|
22
|
+
return entity.deletedAt !== null;
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
const conflict = (kind, id, found, expected) => new AxedaError('AXEDA_VERSION_CONFLICT', `${kind} "${id}" is at version ${found}, not ${expected}.`);
|
|
26
|
+
/** Quita asignaciones e invitaciones de los alcances indicados (claves de `scopeKey`). */
|
|
27
|
+
function dropScopes(state, keys) {
|
|
28
|
+
for (const [id, assignment] of state.assignments) {
|
|
29
|
+
if (keys.has(scopeKey(assignment.scope)))
|
|
30
|
+
state.assignments.delete(id);
|
|
31
|
+
}
|
|
32
|
+
for (const [id, invitation] of state.invitations) {
|
|
33
|
+
if (keys.has(scopeKey(invitation.scope)))
|
|
34
|
+
state.invitations.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function createCompanyStore(context) {
|
|
38
|
+
const { state, run, now, nextId } = context;
|
|
39
|
+
const need = (id, expectedVersion) => {
|
|
40
|
+
const current = state.companies.get(id);
|
|
41
|
+
if (!current)
|
|
42
|
+
throw new AxedaError('AXEDA_COMPANY_NOT_FOUND', `Company "${id}" does not exist.`);
|
|
43
|
+
if (current.version !== expectedVersion)
|
|
44
|
+
throw conflict('Company', id, current.version, expectedVersion);
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
const save = (company, patch) => {
|
|
48
|
+
const next = { ...company, ...patch, version: company.version + 1, updatedAt: now() };
|
|
49
|
+
state.companies.set(company.id, next);
|
|
50
|
+
return next;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
get: (id) => run(async () => state.companies.get(id) ?? null),
|
|
54
|
+
getMany: (ids) => run(async () => ids.flatMap((id) => state.companies.get(id) ?? [])),
|
|
55
|
+
getBySlug: (slug) => run(async () => [...state.companies.values()].find((company) => company.slug === slug) ?? null),
|
|
56
|
+
list: (filter, page) => run(async () => {
|
|
57
|
+
const sorted = [...state.companies.values()]
|
|
58
|
+
.filter((company) => matches(company, filter))
|
|
59
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
60
|
+
return paginate(sorted, (company) => company.slug, page);
|
|
61
|
+
}),
|
|
62
|
+
create: (input) => run(async () => {
|
|
63
|
+
if ([...state.companies.values()].some((company) => company.slug === input.slug)) {
|
|
64
|
+
throw new AxedaError('AXEDA_SLUG_TAKEN', `A company with slug "${input.slug}" already exists.`);
|
|
65
|
+
}
|
|
66
|
+
const at = now();
|
|
67
|
+
const company = {
|
|
68
|
+
id: nextId(),
|
|
69
|
+
slug: input.slug,
|
|
70
|
+
name: input.name,
|
|
71
|
+
legalName: input.legalName ?? null,
|
|
72
|
+
logoUrl: input.logoUrl ?? null,
|
|
73
|
+
faviconUrl: input.faviconUrl ?? null,
|
|
74
|
+
status: 'active',
|
|
75
|
+
metadata: input.metadata ?? {},
|
|
76
|
+
version: 1,
|
|
77
|
+
deletedAt: null,
|
|
78
|
+
createdAt: at,
|
|
79
|
+
updatedAt: at,
|
|
80
|
+
};
|
|
81
|
+
state.companies.set(company.id, company);
|
|
82
|
+
return company;
|
|
83
|
+
}),
|
|
84
|
+
update: (id, expectedVersion, changes) => run(async () => {
|
|
85
|
+
const current = need(id, expectedVersion);
|
|
86
|
+
return save(current, {
|
|
87
|
+
name: changes.name ?? current.name,
|
|
88
|
+
legalName: changes.legalName === undefined ? current.legalName : changes.legalName,
|
|
89
|
+
logoUrl: changes.logoUrl === undefined ? current.logoUrl : changes.logoUrl,
|
|
90
|
+
faviconUrl: changes.faviconUrl === undefined ? current.faviconUrl : changes.faviconUrl,
|
|
91
|
+
metadata: changes.metadata ?? current.metadata,
|
|
92
|
+
});
|
|
93
|
+
}),
|
|
94
|
+
setStatus: (id, expectedVersion, status) => run(async () => save(need(id, expectedVersion), { status })),
|
|
95
|
+
setDeleted: (id, expectedVersion, at) => run(async () => save(need(id, expectedVersion), { deletedAt: at })),
|
|
96
|
+
listDeletedBefore: (before, limit) => run(async () => [...state.companies.values()]
|
|
97
|
+
.filter((company) => company.deletedAt !== null && company.deletedAt < before)
|
|
98
|
+
.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
99
|
+
.slice(0, limit)),
|
|
100
|
+
purge: (ids) => run(async () => {
|
|
101
|
+
const doomed = new Set(ids.filter((id) => state.companies.has(id)));
|
|
102
|
+
const keys = new Set([...doomed].map(companyKey));
|
|
103
|
+
for (const [businessId, business] of state.businesses) {
|
|
104
|
+
if (doomed.has(business.companyId)) {
|
|
105
|
+
keys.add(businessKey(businessId));
|
|
106
|
+
state.businesses.delete(businessId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const id of doomed)
|
|
110
|
+
state.companies.delete(id);
|
|
111
|
+
dropScopes(state, keys);
|
|
112
|
+
return doomed.size;
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function createBusinessStore(context) {
|
|
117
|
+
const { state, run, now, nextId } = context;
|
|
118
|
+
const need = (id, expectedVersion) => {
|
|
119
|
+
const current = state.businesses.get(id);
|
|
120
|
+
if (!current)
|
|
121
|
+
throw new AxedaError('AXEDA_BUSINESS_NOT_FOUND', `Business "${id}" does not exist.`);
|
|
122
|
+
if (current.version !== expectedVersion)
|
|
123
|
+
throw conflict('Business', id, current.version, expectedVersion);
|
|
124
|
+
return current;
|
|
125
|
+
};
|
|
126
|
+
const save = (business, patch) => {
|
|
127
|
+
const next = {
|
|
128
|
+
...business,
|
|
129
|
+
...patch,
|
|
130
|
+
version: business.version + 1,
|
|
131
|
+
updatedAt: now(),
|
|
132
|
+
};
|
|
133
|
+
state.businesses.set(business.id, next);
|
|
134
|
+
return next;
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
get: (id) => run(async () => state.businesses.get(id) ?? null),
|
|
138
|
+
getMany: (ids) => run(async () => ids.flatMap((id) => state.businesses.get(id) ?? [])),
|
|
139
|
+
getBySlug: (companyId, slug) => run(async () => [...state.businesses.values()].find((business) => business.companyId === companyId && business.slug === slug) ?? null),
|
|
140
|
+
listForCompany: (companyId, filter, page) => run(async () => {
|
|
141
|
+
const sorted = [...state.businesses.values()]
|
|
142
|
+
.filter((business) => business.companyId === companyId && matches(business, filter))
|
|
143
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
144
|
+
return paginate(sorted, (business) => business.slug, page);
|
|
145
|
+
}),
|
|
146
|
+
create: (input) => run(async () => {
|
|
147
|
+
if (!state.companies.has(input.companyId)) {
|
|
148
|
+
throw new AxedaError('AXEDA_COMPANY_NOT_FOUND', `Company "${input.companyId}" does not exist.`);
|
|
149
|
+
}
|
|
150
|
+
const taken = [...state.businesses.values()].some((business) => business.companyId === input.companyId && business.slug === input.slug);
|
|
151
|
+
if (taken) {
|
|
152
|
+
throw new AxedaError('AXEDA_SLUG_TAKEN', `A business with slug "${input.slug}" already exists in that company.`);
|
|
153
|
+
}
|
|
154
|
+
const at = now();
|
|
155
|
+
const business = {
|
|
156
|
+
id: nextId(),
|
|
157
|
+
companyId: input.companyId,
|
|
158
|
+
slug: input.slug,
|
|
159
|
+
name: input.name,
|
|
160
|
+
logoUrl: input.logoUrl ?? null,
|
|
161
|
+
faviconUrl: input.faviconUrl ?? null,
|
|
162
|
+
status: 'active',
|
|
163
|
+
metadata: input.metadata ?? {},
|
|
164
|
+
version: 1,
|
|
165
|
+
deletedAt: null,
|
|
166
|
+
createdAt: at,
|
|
167
|
+
updatedAt: at,
|
|
168
|
+
};
|
|
169
|
+
state.businesses.set(business.id, business);
|
|
170
|
+
return business;
|
|
171
|
+
}),
|
|
172
|
+
update: (id, expectedVersion, changes) => run(async () => {
|
|
173
|
+
const current = need(id, expectedVersion);
|
|
174
|
+
return save(current, {
|
|
175
|
+
name: changes.name ?? current.name,
|
|
176
|
+
logoUrl: changes.logoUrl === undefined ? current.logoUrl : changes.logoUrl,
|
|
177
|
+
faviconUrl: changes.faviconUrl === undefined ? current.faviconUrl : changes.faviconUrl,
|
|
178
|
+
metadata: changes.metadata ?? current.metadata,
|
|
179
|
+
});
|
|
180
|
+
}),
|
|
181
|
+
setStatus: (id, expectedVersion, status) => run(async () => save(need(id, expectedVersion), { status })),
|
|
182
|
+
setDeleted: (id, expectedVersion, at) => run(async () => save(need(id, expectedVersion), { deletedAt: at })),
|
|
183
|
+
listDeletedBefore: (before, limit) => run(async () => [...state.businesses.values()]
|
|
184
|
+
.filter((business) => business.deletedAt !== null && business.deletedAt < before)
|
|
185
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
186
|
+
.slice(0, limit)),
|
|
187
|
+
purge: (ids) => run(async () => {
|
|
188
|
+
const doomed = ids.filter((id) => state.businesses.has(id));
|
|
189
|
+
for (const id of doomed)
|
|
190
|
+
state.businesses.delete(id);
|
|
191
|
+
dropScopes(state, new Set(doomed.map(businessKey)));
|
|
192
|
+
return doomed.length;
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const isPending = (invitation) => invitation.acceptedAt === null && invitation.revokedAt === null;
|
|
197
|
+
export function createInvitationStore(context) {
|
|
198
|
+
const { state, run, nextId, now } = context;
|
|
199
|
+
return {
|
|
200
|
+
get: (id) => run(async () => state.invitations.get(id) ?? null),
|
|
201
|
+
findPending: (email, scope) => run(async () => [...state.invitations.values()].find((invitation) => isPending(invitation) &&
|
|
202
|
+
invitation.email === email &&
|
|
203
|
+
scopeKey(invitation.scope) === scopeKey(scope)) ?? null),
|
|
204
|
+
create: (input) => run(async () => {
|
|
205
|
+
const exists = [...state.invitations.values()].some((invitation) => isPending(invitation) &&
|
|
206
|
+
invitation.email === input.email &&
|
|
207
|
+
scopeKey(invitation.scope) === scopeKey(input.scope));
|
|
208
|
+
if (exists) {
|
|
209
|
+
throw new AxedaError('AXEDA_INVITATION_EXISTS', 'There is already a pending invitation for that email and scope.');
|
|
210
|
+
}
|
|
211
|
+
const invitation = {
|
|
212
|
+
id: nextId(),
|
|
213
|
+
email: input.email,
|
|
214
|
+
scope: input.scope,
|
|
215
|
+
roleIds: [...input.roleIds],
|
|
216
|
+
invitedBy: input.invitedBy,
|
|
217
|
+
expiresAt: input.expiresAt,
|
|
218
|
+
secretHash: input.secretHash,
|
|
219
|
+
createdAt: now(),
|
|
220
|
+
acceptedAt: null,
|
|
221
|
+
revokedAt: null,
|
|
222
|
+
};
|
|
223
|
+
state.invitations.set(invitation.id, invitation);
|
|
224
|
+
return invitation;
|
|
225
|
+
}),
|
|
226
|
+
close: (id, outcome, at) => run(async () => {
|
|
227
|
+
const current = state.invitations.get(id);
|
|
228
|
+
if (!current || !isPending(current))
|
|
229
|
+
return false;
|
|
230
|
+
state.invitations.set(id, {
|
|
231
|
+
...current,
|
|
232
|
+
...(outcome === 'accepted' ? { acceptedAt: at } : { revokedAt: at }),
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}),
|
|
236
|
+
list: (scope, options, page) => run(async () => {
|
|
237
|
+
const sorted = [...state.invitations.values()]
|
|
238
|
+
.filter((invitation) => scopeKey(invitation.scope) === scopeKey(scope))
|
|
239
|
+
.filter((invitation) => !options.pendingOnly || (isPending(invitation) && invitation.expiresAt > options.now))
|
|
240
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
241
|
+
const { items, nextCursor } = paginate(sorted, (invitation) => invitation.id, page);
|
|
242
|
+
return {
|
|
243
|
+
items: items.map(withoutSecret),
|
|
244
|
+
nextCursor,
|
|
245
|
+
};
|
|
246
|
+
}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ManagedUser, type UserAdmin, type UserDirectory } from '@teamblind/axeda-core';
|
|
2
|
+
export type MemoryUserInput = Partial<Omit<ManagedUser, 'id'>> & {
|
|
3
|
+
id: string;
|
|
4
|
+
};
|
|
5
|
+
export interface MemoryUserDirectory extends UserDirectory, UserAdmin {
|
|
6
|
+
/** Añade o reemplaza un usuario. */
|
|
7
|
+
add(user: MemoryUserInput): ManagedUser;
|
|
8
|
+
remove(userId: string): void;
|
|
9
|
+
/** Simula `count` sesiones abiertas del usuario. */
|
|
10
|
+
addSessions(userId: string, count: number): void;
|
|
11
|
+
sessionsOf(userId: string): number;
|
|
12
|
+
}
|
|
13
|
+
export interface MemoryUsersOptions {
|
|
14
|
+
readonly now?: () => Date;
|
|
15
|
+
}
|
|
16
|
+
/** Directorio y administración de usuarios en memoria para las pruebas del núcleo. */
|
|
17
|
+
export declare function createMemoryUserDirectory(initial?: readonly MemoryUserInput[], options?: MemoryUsersOptions): MemoryUserDirectory;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { AxedaError, isBanned, } from '@teamblind/axeda-core';
|
|
2
|
+
import { decodeCursor, encodeCursor } from './memory-tenants.js';
|
|
3
|
+
/** Directorio y administración de usuarios en memoria para las pruebas del núcleo. */
|
|
4
|
+
export function createMemoryUserDirectory(initial = [], options = {}) {
|
|
5
|
+
const now = options.now ?? (() => new Date());
|
|
6
|
+
const users = new Map();
|
|
7
|
+
const add = (user) => {
|
|
8
|
+
const full = {
|
|
9
|
+
email: `${user.id}@test.dev`,
|
|
10
|
+
name: user.id,
|
|
11
|
+
emailVerified: true,
|
|
12
|
+
twoFactorEnabled: false,
|
|
13
|
+
banned: false,
|
|
14
|
+
banReason: null,
|
|
15
|
+
banExpires: null,
|
|
16
|
+
createdAt: now(),
|
|
17
|
+
sessions: 0,
|
|
18
|
+
...user,
|
|
19
|
+
};
|
|
20
|
+
users.set(full.id, full);
|
|
21
|
+
return full;
|
|
22
|
+
};
|
|
23
|
+
for (const user of initial)
|
|
24
|
+
add(user);
|
|
25
|
+
/** El estado de bloqueo se calcula siempre con la hora actual. */
|
|
26
|
+
const view = (user) => ({
|
|
27
|
+
id: user.id,
|
|
28
|
+
email: user.email,
|
|
29
|
+
name: user.name,
|
|
30
|
+
emailVerified: user.emailVerified,
|
|
31
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
32
|
+
banned: isBanned(user, now()),
|
|
33
|
+
banReason: user.banReason,
|
|
34
|
+
banExpires: user.banExpires,
|
|
35
|
+
createdAt: user.createdAt,
|
|
36
|
+
});
|
|
37
|
+
const need = (userId) => {
|
|
38
|
+
const user = users.get(userId);
|
|
39
|
+
if (user === undefined) {
|
|
40
|
+
throw new AxedaError('AXEDA_USER_NOT_FOUND', `User "${userId}" does not exist.`);
|
|
41
|
+
}
|
|
42
|
+
return user;
|
|
43
|
+
};
|
|
44
|
+
const toDirectory = (user) => ({
|
|
45
|
+
id: user.id,
|
|
46
|
+
email: user.email,
|
|
47
|
+
emailVerified: user.emailVerified,
|
|
48
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
49
|
+
banned: isBanned(user, now()),
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
add,
|
|
53
|
+
remove: (userId) => void users.delete(userId),
|
|
54
|
+
addSessions: (userId, count) => void (need(userId).sessions += count),
|
|
55
|
+
sessionsOf: (userId) => users.get(userId)?.sessions ?? 0,
|
|
56
|
+
find: async (userId) => {
|
|
57
|
+
const user = users.get(userId);
|
|
58
|
+
return user === undefined ? null : toDirectory(user);
|
|
59
|
+
},
|
|
60
|
+
findByEmail: async (email) => {
|
|
61
|
+
const user = [...users.values()].find((u) => u.email.toLowerCase() === email.toLowerCase());
|
|
62
|
+
return user === undefined ? null : toDirectory(user);
|
|
63
|
+
},
|
|
64
|
+
get: async (userId) => {
|
|
65
|
+
const user = users.get(userId);
|
|
66
|
+
return user === undefined ? null : view(user);
|
|
67
|
+
},
|
|
68
|
+
list: async (filter, page) => {
|
|
69
|
+
const prefix = filter.emailPrefix?.toLowerCase();
|
|
70
|
+
const after = page.cursor === undefined ? null : decodeCursor(page.cursor);
|
|
71
|
+
const sorted = [...users.values()]
|
|
72
|
+
.filter((u) => prefix === undefined || u.email.toLowerCase().startsWith(prefix))
|
|
73
|
+
.filter((u) => filter.banned === undefined || isBanned(u, now()) === filter.banned)
|
|
74
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
75
|
+
.filter((u) => after === null || u.id > after);
|
|
76
|
+
const items = sorted.slice(0, page.limit);
|
|
77
|
+
const last = items.at(-1);
|
|
78
|
+
return {
|
|
79
|
+
items: items.map(view),
|
|
80
|
+
nextCursor: sorted.length > items.length && last ? encodeCursor(last.id) : null,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
create: async (input) => {
|
|
84
|
+
const email = input.email.toLowerCase();
|
|
85
|
+
if ([...users.values()].some((u) => u.email.toLowerCase() === email)) {
|
|
86
|
+
throw new AxedaError('AXEDA_EMAIL_TAKEN', 'That email already has an account.');
|
|
87
|
+
}
|
|
88
|
+
return view(add({
|
|
89
|
+
id: globalThis.crypto.randomUUID(),
|
|
90
|
+
email,
|
|
91
|
+
name: input.name,
|
|
92
|
+
emailVerified: false,
|
|
93
|
+
}));
|
|
94
|
+
},
|
|
95
|
+
setBan: async (userId, ban) => {
|
|
96
|
+
const user = need(userId);
|
|
97
|
+
Object.assign(user, {
|
|
98
|
+
banned: ban !== null,
|
|
99
|
+
banReason: ban?.reason ?? null,
|
|
100
|
+
banExpires: ban?.expiresAt ?? null,
|
|
101
|
+
});
|
|
102
|
+
return view(user);
|
|
103
|
+
},
|
|
104
|
+
revokeSessions: async (userId) => {
|
|
105
|
+
const user = need(userId);
|
|
106
|
+
const closed = user.sessions;
|
|
107
|
+
user.sessions = 0;
|
|
108
|
+
return closed;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type AxedaPersistence, type NewRole } from '@teamblind/axeda-core';
|
|
2
|
+
/**
|
|
3
|
+
* Usuarios que la suite usa. Las persistencias respaldadas por una base con claves foráneas
|
|
4
|
+
* (Better Auth) deben tenerlos creados antes de empezar; la de memoria los ignora.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CONTRACT_USERS: readonly string[];
|
|
7
|
+
export type PersistenceFactory = (users: readonly string[]) => AxedaPersistence | Promise<AxedaPersistence>;
|
|
8
|
+
export declare const roleInput: (overrides?: Partial<NewRole>) => NewRole;
|
|
9
|
+
/**
|
|
10
|
+
* Suite de contrato de los puertos de persistencia de Axeda. Toda implementación (memoria,
|
|
11
|
+
* Better Auth sobre SQLite o PostgreSQL, …) debe pasarla entera.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runPersistenceContract(name: string, create: PersistenceFactory): void;
|