@kindgi/platform 0.1.3 → 0.1.4-rc.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 +31 -0
- package/dist/in-memory/index.d.ts +1 -0
- package/dist/in-memory/index.d.ts.map +1 -1
- package/dist/in-memory/org-binding.d.ts.map +1 -1
- package/dist/in-memory/org-binding.js +33 -16
- package/dist/in-memory/org-binding.js.map +1 -1
- package/dist/in-memory/project-binding.d.ts +26 -1
- package/dist/in-memory/project-binding.d.ts.map +1 -1
- package/dist/in-memory/project-binding.js +47 -8
- package/dist/in-memory/project-binding.js.map +1 -1
- package/dist/in-memory/team-binding.d.ts +2 -1
- package/dist/in-memory/team-binding.d.ts.map +1 -1
- package/dist/in-memory/team-binding.js +31 -7
- package/dist/in-memory/team-binding.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/org-binding.d.ts +56 -12
- package/dist/org-binding.d.ts.map +1 -1
- package/dist/project-binding.d.ts +76 -12
- package/dist/project-binding.d.ts.map +1 -1
- package/dist/team-binding.d.ts +70 -12
- package/dist/team-binding.d.ts.map +1 -1
- package/dist/tenant-hierarchy-binding.d.ts +31 -5
- package/dist/tenant-hierarchy-binding.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/in-memory/index.ts +1 -0
- package/src/in-memory/org-binding.ts +41 -19
- package/src/in-memory/project-binding.ts +85 -16
- package/src/in-memory/team-binding.ts +48 -12
- package/src/index.ts +14 -1
- package/src/org-binding.ts +60 -12
- package/src/project-binding.ts +88 -12
- package/src/team-binding.ts +82 -12
- package/src/tenant-hierarchy-binding.ts +21 -6
- package/tests/in-memory-project-binding.test.ts +13 -1
- package/tests/in-memory-team-binding.test.ts +8 -1
- package/tests/org-binding.conformance.ts +93 -18
- package/tests/project-binding.conformance.ts +221 -37
- package/tests/team-binding.conformance.ts +120 -28
|
@@ -13,16 +13,22 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `create` enforces the `Project.isDefault = true` uniqueness
|
|
15
15
|
* guardrail at write time: creating a second Default in the same
|
|
16
|
-
* tenant
|
|
16
|
+
* tenant resolves to `project-default-already-exists`. A project's
|
|
17
|
+
* slug is unique within its org, and a project without an org's among
|
|
18
|
+
* the tenant's projects without one (`slug-conflict`).
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
|
-
import type { Filter, Page, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
21
|
+
import type { Filter, OrgId, Page, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
20
22
|
|
|
21
23
|
import type {
|
|
22
24
|
ProjectBinding,
|
|
25
|
+
ProjectCreateOutcome,
|
|
23
26
|
ProjectListFilter,
|
|
24
27
|
ProjectMembershipAddInput,
|
|
28
|
+
ProjectMembershipAddOutcome,
|
|
25
29
|
ProjectMembershipBinding,
|
|
30
|
+
ProjectMembershipUpdateRoleOutcome,
|
|
31
|
+
ProjectUpdateOutcome,
|
|
26
32
|
} from '../project-binding.js';
|
|
27
33
|
import type {
|
|
28
34
|
TeamProjectGrant,
|
|
@@ -53,12 +59,21 @@ function grantKey(teamId: TeamId, projectId: ProjectId): string {
|
|
|
53
59
|
return `${teamId}::${projectId}`;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/**
|
|
63
|
+
* `orgExists`: whether an org is the tenant's (live). Given, a project or
|
|
64
|
+
* team created in or moved to another org is `org-not-found`, as the
|
|
65
|
+
* durable bindings answer; absent, any `orgId` is taken as it is.
|
|
66
|
+
*/
|
|
67
|
+
export interface InMemoryHierarchyOptions {
|
|
68
|
+
readonly orgExists?: (tenantId: TenantId, orgId: OrgId) => boolean | Promise<boolean>;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
/**
|
|
57
72
|
* Combined factory — returns the three project-related bindings that
|
|
58
73
|
* share underlying storage. Any of them can be plucked out and passed
|
|
59
74
|
* individually to consumers that need just one.
|
|
60
75
|
*/
|
|
61
|
-
export function makeInMemoryProjectBinding(): {
|
|
76
|
+
export function makeInMemoryProjectBinding(options: InMemoryHierarchyOptions = {}): {
|
|
62
77
|
readonly projects: ProjectBinding;
|
|
63
78
|
readonly memberships: ProjectMembershipBinding;
|
|
64
79
|
readonly grants: TeamProjectGrantBinding;
|
|
@@ -82,13 +97,43 @@ export function makeInMemoryProjectBinding(): {
|
|
|
82
97
|
return false;
|
|
83
98
|
}
|
|
84
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Is `slug` held by another project (not `exceptId`) where a project of
|
|
102
|
+
* `orgId` lives: in that org, or, without an org, among the tenant's
|
|
103
|
+
* projects without one?
|
|
104
|
+
*/
|
|
105
|
+
function slugTaken(
|
|
106
|
+
tenantId: TenantId,
|
|
107
|
+
orgId: Project['orgId'],
|
|
108
|
+
slug: string,
|
|
109
|
+
exceptId?: ProjectId,
|
|
110
|
+
): boolean {
|
|
111
|
+
for (const row of projectRows.values()) {
|
|
112
|
+
if (
|
|
113
|
+
row.tenantId === tenantId &&
|
|
114
|
+
row.orgId === orgId &&
|
|
115
|
+
row.slug === slug &&
|
|
116
|
+
row.id !== exceptId
|
|
117
|
+
) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
const projects: ProjectBinding = {
|
|
86
|
-
async create(tenantId, spec: ProjectSpec): Promise<
|
|
125
|
+
async create(tenantId, spec: ProjectSpec): Promise<ProjectCreateOutcome> {
|
|
87
126
|
const isDefault = spec.isDefault === true;
|
|
127
|
+
// The Default guardrail first: a caller asking for the Default
|
|
128
|
+
// wants to hear that one exists, whatever its slug.
|
|
88
129
|
if (isDefault && tenantAlreadyHasDefault(tenantId)) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
130
|
+
return { kind: 'project-default-already-exists' };
|
|
131
|
+
}
|
|
132
|
+
if (spec.orgId !== undefined && (await options.orgExists?.(tenantId, spec.orgId)) === false) {
|
|
133
|
+
return { kind: 'org-not-found', orgId: spec.orgId };
|
|
134
|
+
}
|
|
135
|
+
if (slugTaken(tenantId, spec.orgId, spec.slug)) {
|
|
136
|
+
return { kind: 'slug-conflict', slug: spec.slug };
|
|
92
137
|
}
|
|
93
138
|
const id = nextProjectId();
|
|
94
139
|
const now = nowTimestamp();
|
|
@@ -104,7 +149,7 @@ export function makeInMemoryProjectBinding(): {
|
|
|
104
149
|
...(spec.description !== undefined ? { description: spec.description } : {}),
|
|
105
150
|
};
|
|
106
151
|
projectRows.set(id, row);
|
|
107
|
-
return id;
|
|
152
|
+
return { kind: 'ok', projectId: id };
|
|
108
153
|
},
|
|
109
154
|
|
|
110
155
|
async get(tenantId, projectId): Promise<Project | undefined> {
|
|
@@ -135,15 +180,31 @@ export function makeInMemoryProjectBinding(): {
|
|
|
135
180
|
return undefined;
|
|
136
181
|
},
|
|
137
182
|
|
|
138
|
-
async update(tenantId, projectId, patch: ProjectPatch): Promise<
|
|
183
|
+
async update(tenantId, projectId, patch: ProjectPatch): Promise<ProjectUpdateOutcome> {
|
|
139
184
|
const row = findProjectInTenant(tenantId, projectId);
|
|
140
185
|
if (row === undefined) {
|
|
141
|
-
|
|
186
|
+
return { kind: 'project-not-found' };
|
|
142
187
|
}
|
|
143
188
|
let nextOrgId = row.orgId;
|
|
144
189
|
if (patch.orgId !== undefined) {
|
|
145
190
|
nextOrgId = patch.orgId === null ? undefined : patch.orgId;
|
|
146
191
|
}
|
|
192
|
+
if (
|
|
193
|
+
patch.orgId !== undefined &&
|
|
194
|
+
patch.orgId !== null &&
|
|
195
|
+
(await options.orgExists?.(tenantId, patch.orgId)) === false
|
|
196
|
+
) {
|
|
197
|
+
return { kind: 'org-not-found', orgId: patch.orgId };
|
|
198
|
+
}
|
|
199
|
+
// The slug it ends up with, where it ends up: a move to another org
|
|
200
|
+
// (or out of one) can meet a project with the same slug there.
|
|
201
|
+
const nextSlug = patch.slug ?? row.slug;
|
|
202
|
+
if (
|
|
203
|
+
(patch.slug !== undefined || patch.orgId !== undefined) &&
|
|
204
|
+
slugTaken(tenantId, nextOrgId, nextSlug, projectId)
|
|
205
|
+
) {
|
|
206
|
+
return { kind: 'slug-conflict', slug: nextSlug };
|
|
207
|
+
}
|
|
147
208
|
const nextDescription = patch.description ?? row.description;
|
|
148
209
|
const next: Project = {
|
|
149
210
|
id: row.id,
|
|
@@ -157,6 +218,7 @@ export function makeInMemoryProjectBinding(): {
|
|
|
157
218
|
...(nextDescription !== undefined ? { description: nextDescription } : {}),
|
|
158
219
|
};
|
|
159
220
|
projectRows.set(projectId, next);
|
|
221
|
+
return { kind: 'ok' };
|
|
160
222
|
},
|
|
161
223
|
|
|
162
224
|
async delete(tenantId, projectId): Promise<void> {
|
|
@@ -181,13 +243,13 @@ export function makeInMemoryProjectBinding(): {
|
|
|
181
243
|
};
|
|
182
244
|
|
|
183
245
|
const memberships: ProjectMembershipBinding = {
|
|
184
|
-
async add(tenantId, input: ProjectMembershipAddInput): Promise<
|
|
246
|
+
async add(tenantId, input: ProjectMembershipAddInput): Promise<ProjectMembershipAddOutcome> {
|
|
185
247
|
const proj = findProjectInTenant(tenantId, input.projectId);
|
|
186
248
|
if (proj === undefined) {
|
|
187
|
-
|
|
249
|
+
return { kind: 'project-not-found' };
|
|
188
250
|
}
|
|
189
251
|
const key = projMembershipKey(input.projectId, input.userId);
|
|
190
|
-
if (membershipRows.has(key)) return;
|
|
252
|
+
if (membershipRows.has(key)) return { kind: 'ok' };
|
|
191
253
|
const row: ProjectMembership = {
|
|
192
254
|
projectId: input.projectId,
|
|
193
255
|
userId: input.userId,
|
|
@@ -195,6 +257,7 @@ export function makeInMemoryProjectBinding(): {
|
|
|
195
257
|
joinedAt: nowTimestamp(),
|
|
196
258
|
};
|
|
197
259
|
membershipRows.set(key, row);
|
|
260
|
+
return { kind: 'ok' };
|
|
198
261
|
},
|
|
199
262
|
|
|
200
263
|
async remove(tenantId, projectId, userId): Promise<void> {
|
|
@@ -227,17 +290,23 @@ export function makeInMemoryProjectBinding(): {
|
|
|
227
290
|
return paginate(all, filter.limit, filter.cursor);
|
|
228
291
|
},
|
|
229
292
|
|
|
230
|
-
async updateRole(
|
|
293
|
+
async updateRole(
|
|
294
|
+
tenantId,
|
|
295
|
+
projectId,
|
|
296
|
+
userId,
|
|
297
|
+
role: ProjectRole,
|
|
298
|
+
): Promise<ProjectMembershipUpdateRoleOutcome> {
|
|
231
299
|
const proj = findProjectInTenant(tenantId, projectId);
|
|
232
300
|
if (proj === undefined) {
|
|
233
|
-
|
|
301
|
+
return { kind: 'project-not-found' };
|
|
234
302
|
}
|
|
235
303
|
const key = projMembershipKey(projectId, userId);
|
|
236
304
|
const row = membershipRows.get(key);
|
|
237
305
|
if (row === undefined) {
|
|
238
|
-
|
|
306
|
+
return { kind: 'project-membership-not-found' };
|
|
239
307
|
}
|
|
240
308
|
membershipRows.set(key, { ...row, role });
|
|
309
|
+
return { kind: 'ok' };
|
|
241
310
|
},
|
|
242
311
|
};
|
|
243
312
|
|
|
@@ -15,18 +15,24 @@
|
|
|
15
15
|
* Both factories are returned together so the two adapters share the
|
|
16
16
|
* same team map (a membership add must fail cleanly for a
|
|
17
17
|
* non-existent team; splitting into two disjoint factories would
|
|
18
|
-
* require an extra cross-binding contract).
|
|
18
|
+
* require an extra cross-binding contract). Team slugs are unique
|
|
19
|
+
* within a tenant.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type { Filter, Page, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
22
23
|
|
|
23
24
|
import type {
|
|
24
25
|
TeamBinding,
|
|
26
|
+
TeamCreateOutcome,
|
|
25
27
|
TeamListFilter,
|
|
26
28
|
TeamMembershipAddInput,
|
|
29
|
+
TeamMembershipAddOutcome,
|
|
27
30
|
TeamMembershipBinding,
|
|
31
|
+
TeamMembershipUpdateRoleOutcome,
|
|
32
|
+
TeamUpdateOutcome,
|
|
28
33
|
} from '../team-binding.js';
|
|
29
34
|
import type { Team, TeamMembership, TeamPatch, TeamRole, TeamSpec } from '../types.js';
|
|
35
|
+
import type { InMemoryHierarchyOptions } from './project-binding.js';
|
|
30
36
|
|
|
31
37
|
import { nowTimestamp, paginate } from './util.js';
|
|
32
38
|
|
|
@@ -46,7 +52,7 @@ function membershipKey(teamId: TeamId, userId: UserId): string {
|
|
|
46
52
|
* (memberships reference teams; the two must agree on tenant
|
|
47
53
|
* membership at every op).
|
|
48
54
|
*/
|
|
49
|
-
export function makeInMemoryTeamBinding(): {
|
|
55
|
+
export function makeInMemoryTeamBinding(options: InMemoryHierarchyOptions = {}): {
|
|
50
56
|
readonly teams: TeamBinding;
|
|
51
57
|
readonly memberships: TeamMembershipBinding;
|
|
52
58
|
} {
|
|
@@ -59,8 +65,20 @@ export function makeInMemoryTeamBinding(): {
|
|
|
59
65
|
return t;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/** Is `slug` held by a team in the tenant other than `exceptId`? */
|
|
69
|
+
function slugTaken(tenantId: TenantId, slug: string, exceptId?: TeamId): boolean {
|
|
70
|
+
for (const row of teamRows.values()) {
|
|
71
|
+
if (row.tenantId === tenantId && row.slug === slug && row.id !== exceptId) return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
62
76
|
const teams: TeamBinding = {
|
|
63
|
-
async create(tenantId, spec: TeamSpec): Promise<
|
|
77
|
+
async create(tenantId, spec: TeamSpec): Promise<TeamCreateOutcome> {
|
|
78
|
+
if (spec.orgId !== undefined && (await options.orgExists?.(tenantId, spec.orgId)) === false) {
|
|
79
|
+
return { kind: 'org-not-found', orgId: spec.orgId };
|
|
80
|
+
}
|
|
81
|
+
if (slugTaken(tenantId, spec.slug)) return { kind: 'slug-conflict', slug: spec.slug };
|
|
64
82
|
const id = nextTeamId();
|
|
65
83
|
const now = nowTimestamp();
|
|
66
84
|
const row: Team = {
|
|
@@ -74,7 +92,7 @@ export function makeInMemoryTeamBinding(): {
|
|
|
74
92
|
...(spec.description !== undefined ? { description: spec.description } : {}),
|
|
75
93
|
};
|
|
76
94
|
teamRows.set(id, row);
|
|
77
|
-
return id;
|
|
95
|
+
return { kind: 'ok', teamId: id };
|
|
78
96
|
},
|
|
79
97
|
|
|
80
98
|
async get(tenantId, teamId): Promise<Team | undefined> {
|
|
@@ -98,10 +116,20 @@ export function makeInMemoryTeamBinding(): {
|
|
|
98
116
|
return paginate(all, filter.limit, filter.cursor);
|
|
99
117
|
},
|
|
100
118
|
|
|
101
|
-
async update(tenantId, teamId, patch: TeamPatch): Promise<
|
|
119
|
+
async update(tenantId, teamId, patch: TeamPatch): Promise<TeamUpdateOutcome> {
|
|
102
120
|
const row = findTeamInTenant(tenantId, teamId);
|
|
103
121
|
if (row === undefined) {
|
|
104
|
-
|
|
122
|
+
return { kind: 'team-not-found' };
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
patch.orgId !== undefined &&
|
|
126
|
+
patch.orgId !== null &&
|
|
127
|
+
(await options.orgExists?.(tenantId, patch.orgId)) === false
|
|
128
|
+
) {
|
|
129
|
+
return { kind: 'org-not-found', orgId: patch.orgId };
|
|
130
|
+
}
|
|
131
|
+
if (patch.slug !== undefined && slugTaken(tenantId, patch.slug, teamId)) {
|
|
132
|
+
return { kind: 'slug-conflict', slug: patch.slug };
|
|
105
133
|
}
|
|
106
134
|
// orgId patch tri-state: undefined = untouched; null = clear;
|
|
107
135
|
// OrgId value = set.
|
|
@@ -121,6 +149,7 @@ export function makeInMemoryTeamBinding(): {
|
|
|
121
149
|
...(nextDescription !== undefined ? { description: nextDescription } : {}),
|
|
122
150
|
};
|
|
123
151
|
teamRows.set(teamId, next);
|
|
152
|
+
return { kind: 'ok' };
|
|
124
153
|
},
|
|
125
154
|
|
|
126
155
|
async delete(tenantId, teamId): Promise<void> {
|
|
@@ -137,15 +166,15 @@ export function makeInMemoryTeamBinding(): {
|
|
|
137
166
|
};
|
|
138
167
|
|
|
139
168
|
const memberships: TeamMembershipBinding = {
|
|
140
|
-
async add(tenantId, input: TeamMembershipAddInput): Promise<
|
|
169
|
+
async add(tenantId, input: TeamMembershipAddInput): Promise<TeamMembershipAddOutcome> {
|
|
141
170
|
const team = findTeamInTenant(tenantId, input.teamId);
|
|
142
171
|
if (team === undefined) {
|
|
143
|
-
|
|
172
|
+
return { kind: 'team-not-found' };
|
|
144
173
|
}
|
|
145
174
|
const key = membershipKey(input.teamId, input.userId);
|
|
146
175
|
if (membershipRows.has(key)) {
|
|
147
176
|
// Idempotent — role mutation goes through updateRole.
|
|
148
|
-
return;
|
|
177
|
+
return { kind: 'ok' };
|
|
149
178
|
}
|
|
150
179
|
const row: TeamMembership = {
|
|
151
180
|
teamId: input.teamId,
|
|
@@ -154,6 +183,7 @@ export function makeInMemoryTeamBinding(): {
|
|
|
154
183
|
joinedAt: nowTimestamp(),
|
|
155
184
|
};
|
|
156
185
|
membershipRows.set(key, row);
|
|
186
|
+
return { kind: 'ok' };
|
|
157
187
|
},
|
|
158
188
|
|
|
159
189
|
async remove(tenantId, teamId, userId): Promise<void> {
|
|
@@ -187,17 +217,23 @@ export function makeInMemoryTeamBinding(): {
|
|
|
187
217
|
return paginate(all, filter.limit, filter.cursor);
|
|
188
218
|
},
|
|
189
219
|
|
|
190
|
-
async updateRole(
|
|
220
|
+
async updateRole(
|
|
221
|
+
tenantId,
|
|
222
|
+
teamId,
|
|
223
|
+
userId,
|
|
224
|
+
role: TeamRole,
|
|
225
|
+
): Promise<TeamMembershipUpdateRoleOutcome> {
|
|
191
226
|
const team = findTeamInTenant(tenantId, teamId);
|
|
192
227
|
if (team === undefined) {
|
|
193
|
-
|
|
228
|
+
return { kind: 'team-not-found' };
|
|
194
229
|
}
|
|
195
230
|
const key = membershipKey(teamId, userId);
|
|
196
231
|
const row = membershipRows.get(key);
|
|
197
232
|
if (row === undefined) {
|
|
198
|
-
|
|
233
|
+
return { kind: 'team-membership-not-found' };
|
|
199
234
|
}
|
|
200
235
|
membershipRows.set(key, { ...row, role });
|
|
236
|
+
return { kind: 'ok' };
|
|
201
237
|
},
|
|
202
238
|
};
|
|
203
239
|
|
package/src/index.ts
CHANGED
|
@@ -15,19 +15,30 @@ export { scopeKey } from './scope.js';
|
|
|
15
15
|
|
|
16
16
|
export type {
|
|
17
17
|
OrgBinding,
|
|
18
|
+
OrgCreateOutcome,
|
|
18
19
|
OrgListFilter,
|
|
20
|
+
OrgDeleteConflict,
|
|
21
|
+
OrgUpdateOutcome,
|
|
19
22
|
} from './org-binding.js';
|
|
20
23
|
export type {
|
|
21
24
|
TeamBinding,
|
|
25
|
+
TeamCreateOutcome,
|
|
22
26
|
TeamListFilter,
|
|
23
27
|
TeamMembershipAddInput,
|
|
28
|
+
TeamMembershipAddOutcome,
|
|
24
29
|
TeamMembershipBinding,
|
|
30
|
+
TeamMembershipUpdateRoleOutcome,
|
|
31
|
+
TeamUpdateOutcome,
|
|
25
32
|
} from './team-binding.js';
|
|
26
33
|
export type {
|
|
27
34
|
ProjectBinding,
|
|
35
|
+
ProjectCreateOutcome,
|
|
28
36
|
ProjectListFilter,
|
|
29
37
|
ProjectMembershipAddInput,
|
|
38
|
+
ProjectMembershipAddOutcome,
|
|
30
39
|
ProjectMembershipBinding,
|
|
40
|
+
ProjectMembershipUpdateRoleOutcome,
|
|
41
|
+
ProjectUpdateOutcome,
|
|
31
42
|
} from './project-binding.js';
|
|
32
43
|
export type {
|
|
33
44
|
TeamProjectGrant,
|
|
@@ -40,9 +51,12 @@ export {
|
|
|
40
51
|
makeInMemoryTeamBinding,
|
|
41
52
|
makeInMemoryProjectBinding,
|
|
42
53
|
} from './in-memory/index.js';
|
|
54
|
+
export type { InMemoryHierarchyOptions } from './in-memory/index.js';
|
|
43
55
|
|
|
44
56
|
export type {
|
|
57
|
+
AddProjectMemberError,
|
|
45
58
|
AddProjectMemberParams,
|
|
59
|
+
AddTeamMemberError,
|
|
46
60
|
AddTeamMemberParams,
|
|
47
61
|
CreateOrgError,
|
|
48
62
|
CreateOrgParams,
|
|
@@ -50,7 +64,6 @@ export type {
|
|
|
50
64
|
CreateProjectParams,
|
|
51
65
|
CreateTeamError,
|
|
52
66
|
CreateTeamParams,
|
|
53
|
-
MembershipMutationError,
|
|
54
67
|
TenantHierarchyBinding,
|
|
55
68
|
TenantSummary,
|
|
56
69
|
} from './tenant-hierarchy-binding.js';
|
package/src/org-binding.ts
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* `@kindgi/types` (`items` + `nextCursor`).
|
|
13
13
|
* - `list` filters extend the shared `Filter` shape so every list call
|
|
14
14
|
* shares the same pagination fields.
|
|
15
|
+
* - A write the caller can get wrong (a slug already taken, a row that
|
|
16
|
+
* doesn't exist) resolves to an outcome — a union discriminated on
|
|
17
|
+
* `kind`, `'ok'` on success — and never rejects for it. Each
|
|
18
|
+
* non-`ok` `kind` is also the API error code the routes answer with
|
|
19
|
+
* (`slug-conflict` is a 409, `org-not-found` a 404). A rejected
|
|
20
|
+
* promise means the backend failed.
|
|
15
21
|
*
|
|
16
22
|
* This package ships the interface + reference in-memory adapter
|
|
17
23
|
* (in `./in-memory/org-binding.ts`). Any adapter can be checked with
|
|
@@ -43,11 +49,13 @@ export interface OrgListFilter extends Filter {
|
|
|
43
49
|
*/
|
|
44
50
|
export interface OrgBinding {
|
|
45
51
|
/**
|
|
46
|
-
* Create a new `Org` in the given tenant.
|
|
47
|
-
* assigned `OrgId
|
|
48
|
-
*
|
|
52
|
+
* Create a new `Org` in the given tenant. Resolves to `ok` with the
|
|
53
|
+
* freshly-assigned `OrgId`, or to `slug-conflict` when another org in
|
|
54
|
+
* the tenant has `spec.slug`. The binding is responsible for
|
|
55
|
+
* allocating IDs, timestamps, and slug-uniqueness enforcement within
|
|
56
|
+
* the tenant.
|
|
49
57
|
*/
|
|
50
|
-
create(tenantId: TenantId, spec: OrgSpec): Promise<
|
|
58
|
+
create(tenantId: TenantId, spec: OrgSpec): Promise<OrgCreateOutcome>;
|
|
51
59
|
/**
|
|
52
60
|
* Look up an `Org` by id. Returns `undefined` when no row exists in
|
|
53
61
|
* the given tenant, or when the row exists in a different tenant
|
|
@@ -62,15 +70,55 @@ export interface OrgBinding {
|
|
|
62
70
|
list(tenantId: TenantId, filter: OrgListFilter): Promise<Page<Org>>;
|
|
63
71
|
/**
|
|
64
72
|
* Partially update an `Org`. Fields absent from `patch` are left
|
|
65
|
-
* unchanged; fields present are set to the new value.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
73
|
+
* unchanged; fields present are set to the new value. Resolves to
|
|
74
|
+
* `org-not-found` when no org has `orgId` in the tenant, and to
|
|
75
|
+
* `slug-conflict` when `patch.slug` is another org's slug.
|
|
68
76
|
*/
|
|
69
|
-
update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<
|
|
77
|
+
update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<OrgUpdateOutcome>;
|
|
70
78
|
/**
|
|
71
|
-
* Delete an `Org
|
|
72
|
-
*
|
|
73
|
-
*
|
|
79
|
+
* Delete an `Org`: a tombstone, not an erase. From then on `get`,
|
|
80
|
+
* `list` and `update` treat it as unknown, and its slug is free for a
|
|
81
|
+
* new org; a retention policy on the `org` domain purges the row.
|
|
82
|
+
* What happens to dependents (teams, projects, org-scoped config) is
|
|
83
|
+
* the storage's concern; the in-memory adapter has none. A storage
|
|
84
|
+
* whose projects leave a deleted org (the Kindgi runtime's: they
|
|
85
|
+
* become projects without an org) answers `slug-conflict` instead
|
|
86
|
+
* when one of them has the slug of a project that has no org, and
|
|
87
|
+
* deletes nothing. Nothing (`void`): the org is deleted, or wasn't
|
|
88
|
+
* there (or was already deleted).
|
|
74
89
|
*/
|
|
75
|
-
|
|
90
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: `void`, not `undefined`, so a binding whose `delete` returns `Promise<void>` still conforms.
|
|
91
|
+
delete(tenantId: TenantId, orgId: OrgId): Promise<void | OrgDeleteConflict>;
|
|
76
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why `OrgBinding.delete` deleted nothing: the org's projects would leave
|
|
96
|
+
* it with `slugs` that projects without an org already have. Rename or
|
|
97
|
+
* move them first.
|
|
98
|
+
*/
|
|
99
|
+
export interface OrgDeleteConflict {
|
|
100
|
+
readonly kind: 'slug-conflict';
|
|
101
|
+
readonly slugs: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What `OrgBinding.create` did. */
|
|
105
|
+
export type OrgCreateOutcome =
|
|
106
|
+
| { readonly kind: 'ok'; readonly orgId: OrgId }
|
|
107
|
+
| {
|
|
108
|
+
/** Another org in the tenant already has this slug. */
|
|
109
|
+
readonly kind: 'slug-conflict';
|
|
110
|
+
readonly slug: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** What `OrgBinding.update` did. */
|
|
114
|
+
export type OrgUpdateOutcome =
|
|
115
|
+
| { readonly kind: 'ok' }
|
|
116
|
+
| {
|
|
117
|
+
/** No org with this id in the tenant. */
|
|
118
|
+
readonly kind: 'org-not-found';
|
|
119
|
+
}
|
|
120
|
+
| {
|
|
121
|
+
/** Another org in the tenant already has the patched slug. */
|
|
122
|
+
readonly kind: 'slug-conflict';
|
|
123
|
+
readonly slug: string;
|
|
124
|
+
};
|
package/src/project-binding.ts
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* `AgentPublishInput.projectId` in `@kindgi/api`).
|
|
21
21
|
*
|
|
22
22
|
* Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
|
|
23
|
-
* return, `Filter`-extending filter shapes
|
|
23
|
+
* return, `Filter`-extending filter shapes, and writes the caller can
|
|
24
|
+
* get wrong resolve to an outcome discriminated on `kind` instead of
|
|
25
|
+
* rejecting.
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
import type { Filter, OrgId, Page, ProjectId, TenantId, UserId } from '@kindgi/types';
|
|
@@ -58,12 +60,14 @@ export interface ProjectMembershipAddInput {
|
|
|
58
60
|
*/
|
|
59
61
|
export interface ProjectBinding {
|
|
60
62
|
/**
|
|
61
|
-
* Create a `Project` within the tenant. `spec.orgId` optional.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* Create a `Project` within the tenant. `spec.orgId` optional.
|
|
64
|
+
* Resolves to `ok` with the assigned `ProjectId`; to `slug-conflict`
|
|
65
|
+
* when another project in the tenant has `spec.slug`; and, when
|
|
66
|
+
* `spec.isDefault === true` and the tenant already has a Default, to
|
|
67
|
+
* `project-default-already-exists` — the isDefault-uniqueness
|
|
68
|
+
* guardrail is enforced at write time.
|
|
65
69
|
*/
|
|
66
|
-
create(tenantId: TenantId, spec: ProjectSpec): Promise<
|
|
70
|
+
create(tenantId: TenantId, spec: ProjectSpec): Promise<ProjectCreateOutcome>;
|
|
67
71
|
/**
|
|
68
72
|
* Look up a `Project` by id. `undefined` when unknown or in a
|
|
69
73
|
* different tenant.
|
|
@@ -81,9 +85,15 @@ export interface ProjectBinding {
|
|
|
81
85
|
getDefault(tenantId: TenantId): Promise<Project | undefined>;
|
|
82
86
|
/**
|
|
83
87
|
* Partially update a `Project`. `ProjectPatch.orgId` supports
|
|
84
|
-
* `null` to re-assign the project out of any org.
|
|
88
|
+
* `null` to re-assign the project out of any org. Resolves to
|
|
89
|
+
* `project-not-found` when no project has `projectId` in the tenant,
|
|
90
|
+
* and to `slug-conflict` when `patch.slug` is another project's slug.
|
|
85
91
|
*/
|
|
86
|
-
update(
|
|
92
|
+
update(
|
|
93
|
+
tenantId: TenantId,
|
|
94
|
+
projectId: ProjectId,
|
|
95
|
+
patch: ProjectPatch,
|
|
96
|
+
): Promise<ProjectUpdateOutcome>;
|
|
87
97
|
/**
|
|
88
98
|
* Delete a `Project`. What happens to content rows that still
|
|
89
99
|
* reference the project is the storage layer's concern; the
|
|
@@ -104,8 +114,10 @@ export interface ProjectMembershipBinding {
|
|
|
104
114
|
* Add a user directly to a project. Idempotent on
|
|
105
115
|
* `(projectId, userId)` — re-adding the same user does not mutate
|
|
106
116
|
* a stored differing role. Use `updateRole` to change roles.
|
|
117
|
+
* Resolves to `project-not-found` when no project has
|
|
118
|
+
* `input.projectId` in the tenant.
|
|
107
119
|
*/
|
|
108
|
-
add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<
|
|
120
|
+
add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<ProjectMembershipAddOutcome>;
|
|
109
121
|
/**
|
|
110
122
|
* Remove the direct project grant. No-op when absent.
|
|
111
123
|
*/
|
|
@@ -122,13 +134,77 @@ export interface ProjectMembershipBinding {
|
|
|
122
134
|
*/
|
|
123
135
|
listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<ProjectMembership>>;
|
|
124
136
|
/**
|
|
125
|
-
* Update an existing direct-membership's role.
|
|
126
|
-
*
|
|
137
|
+
* Update an existing direct-membership's role. Resolves to
|
|
138
|
+
* `project-not-found` when the project isn't in the tenant, and to
|
|
139
|
+
* `project-membership-not-found` when the user has no direct
|
|
140
|
+
* membership.
|
|
127
141
|
*/
|
|
128
142
|
updateRole(
|
|
129
143
|
tenantId: TenantId,
|
|
130
144
|
projectId: ProjectId,
|
|
131
145
|
userId: UserId,
|
|
132
146
|
role: ProjectRole,
|
|
133
|
-
): Promise<
|
|
147
|
+
): Promise<ProjectMembershipUpdateRoleOutcome>;
|
|
134
148
|
}
|
|
149
|
+
|
|
150
|
+
/** What `ProjectBinding.create` did. */
|
|
151
|
+
export type ProjectCreateOutcome =
|
|
152
|
+
| { readonly kind: 'ok'; readonly projectId: ProjectId }
|
|
153
|
+
| {
|
|
154
|
+
/** Another project in the tenant already has this slug. */
|
|
155
|
+
readonly kind: 'slug-conflict';
|
|
156
|
+
readonly slug: string;
|
|
157
|
+
}
|
|
158
|
+
| {
|
|
159
|
+
/** `spec.isDefault` was `true` and the tenant already has a Default project. */
|
|
160
|
+
readonly kind: 'project-default-already-exists';
|
|
161
|
+
}
|
|
162
|
+
| {
|
|
163
|
+
/**
|
|
164
|
+
* The org the project would be in isn't one of the tenant's: it never
|
|
165
|
+
* existed, or it was deleted. Nothing changed.
|
|
166
|
+
*/
|
|
167
|
+
readonly kind: 'org-not-found';
|
|
168
|
+
readonly orgId: OrgId;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** What `ProjectBinding.update` did. */
|
|
172
|
+
export type ProjectUpdateOutcome =
|
|
173
|
+
| { readonly kind: 'ok' }
|
|
174
|
+
| {
|
|
175
|
+
/** No project with this id in the tenant. */
|
|
176
|
+
readonly kind: 'project-not-found';
|
|
177
|
+
}
|
|
178
|
+
| {
|
|
179
|
+
/** Another project in the tenant already has the patched slug. */
|
|
180
|
+
readonly kind: 'slug-conflict';
|
|
181
|
+
readonly slug: string;
|
|
182
|
+
}
|
|
183
|
+
| {
|
|
184
|
+
/**
|
|
185
|
+
* The org the project would be in isn't one of the tenant's: it never
|
|
186
|
+
* existed, or it was deleted. Nothing changed.
|
|
187
|
+
*/
|
|
188
|
+
readonly kind: 'org-not-found';
|
|
189
|
+
readonly orgId: OrgId;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** What `ProjectMembershipBinding.add` did (`ok` when already a member, too). */
|
|
193
|
+
export type ProjectMembershipAddOutcome =
|
|
194
|
+
| { readonly kind: 'ok' }
|
|
195
|
+
| {
|
|
196
|
+
/** No project with this id in the tenant. */
|
|
197
|
+
readonly kind: 'project-not-found';
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** What `ProjectMembershipBinding.updateRole` did. */
|
|
201
|
+
export type ProjectMembershipUpdateRoleOutcome =
|
|
202
|
+
| { readonly kind: 'ok' }
|
|
203
|
+
| {
|
|
204
|
+
/** No project with this id in the tenant. */
|
|
205
|
+
readonly kind: 'project-not-found';
|
|
206
|
+
}
|
|
207
|
+
| {
|
|
208
|
+
/** The user has no direct membership on the project. */
|
|
209
|
+
readonly kind: 'project-membership-not-found';
|
|
210
|
+
};
|