@jskit-ai/workspaces-core 0.1.163 → 0.1.164
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/package.json +9 -9
- package/src/server/WorkspacesFeature.js +6 -14
- package/src/server/common/repositories/repositoryUtils.js +2 -4
- package/src/server/common/repositories/workspaceInvitesRepository.js +42 -77
- package/src/server/common/repositories/workspaceMembershipsRepository.js +41 -78
- package/src/server/common/repositories/workspacesRepository.js +22 -45
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +15 -28
- package/test/featureRuntime.test.js +0 -1
- package/test/repositories.integration.test.js +323 -0
- package/test/repositoryContracts.test.js +43 -19
- package/test/workspaceInvitesRepository.test.js +41 -44
- package/test/workspaceMembershipsRepository.test.js +34 -37
- package/test/workspaceSettingsRepository.test.js +51 -61
- package/test/workspacesRepository.test.js +46 -46
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
normalizeRecordId,
|
|
3
|
-
isDuplicateEntryError
|
|
4
|
-
createWithTransaction
|
|
3
|
+
isDuplicateEntryError
|
|
5
4
|
} from "../common/repositories/repositoryUtils.js";
|
|
6
5
|
import {
|
|
7
|
-
createJsonApiInputRecord,
|
|
8
6
|
createJsonRestContext,
|
|
9
7
|
extractJsonRestCollectionRows
|
|
10
8
|
} from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
11
9
|
import { resolveWorkspaceThemePalettes } from "../../shared/settings.js";
|
|
12
10
|
|
|
13
|
-
const RESOURCE_TYPE = "workspaceSettings";
|
|
14
11
|
const WORKSPACE_SETTINGS_PATCH_FIELDS = Object.freeze([
|
|
15
12
|
"lightPrimaryColor",
|
|
16
13
|
"lightSecondaryColor",
|
|
@@ -52,14 +49,11 @@ function createDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvites
|
|
|
52
49
|
};
|
|
53
50
|
}
|
|
54
51
|
|
|
55
|
-
function createRepository({ api,
|
|
52
|
+
function createRepository({ api, defaultInvitesEnabled = true } = {}) {
|
|
56
53
|
if (!api?.resources?.workspaceSettings) {
|
|
57
54
|
throw new TypeError("workspaceSettingsRepository requires json-rest-api workspaceSettings resource.");
|
|
58
55
|
}
|
|
59
|
-
|
|
60
|
-
throw new TypeError("workspaceSettingsRepository requires knex.");
|
|
61
|
-
}
|
|
62
|
-
const withTransaction = createWithTransaction(knex);
|
|
56
|
+
const withTransaction = (work) => api.transaction(work);
|
|
63
57
|
|
|
64
58
|
async function queryFirst(filters = {}, options = {}) {
|
|
65
59
|
const rows = extractJsonRestCollectionRows(
|
|
@@ -69,7 +63,7 @@ function createRepository({ api, knex, defaultInvitesEnabled = true } = {}) {
|
|
|
69
63
|
filters
|
|
70
64
|
},
|
|
71
65
|
transaction: options?.trx || null,
|
|
72
|
-
|
|
66
|
+
format: "plain"
|
|
73
67
|
},
|
|
74
68
|
createJsonRestContext(options?.context || null)
|
|
75
69
|
)
|
|
@@ -101,19 +95,15 @@ function createRepository({ api, knex, defaultInvitesEnabled = true } = {}) {
|
|
|
101
95
|
try {
|
|
102
96
|
await api.resources.workspaceSettings.post(
|
|
103
97
|
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{
|
|
108
|
-
id: normalizedWorkspaceId
|
|
109
|
-
}
|
|
110
|
-
),
|
|
98
|
+
data: createDefaultWorkspaceSettingsCreatePayload(normalizedWorkspaceId, defaultInvitesEnabled === true),
|
|
99
|
+
format: "plain",
|
|
100
|
+
returning: "full",
|
|
111
101
|
transaction: options?.trx || null
|
|
112
102
|
},
|
|
113
103
|
createJsonRestContext(options?.context || null)
|
|
114
104
|
);
|
|
115
105
|
} catch (error) {
|
|
116
|
-
if (!isDuplicateEntryError(error)) {
|
|
106
|
+
if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
|
|
117
107
|
throw error;
|
|
118
108
|
}
|
|
119
109
|
}
|
|
@@ -137,16 +127,13 @@ function createRepository({ api, knex, defaultInvitesEnabled = true } = {}) {
|
|
|
137
127
|
|
|
138
128
|
await api.resources.workspaceSettings.patch(
|
|
139
129
|
{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
id: normalizedWorkspaceId
|
|
148
|
-
}
|
|
149
|
-
),
|
|
130
|
+
id: normalizedWorkspaceId,
|
|
131
|
+
data: {
|
|
132
|
+
...updatePayload,
|
|
133
|
+
updatedAt: new Date().toISOString()
|
|
134
|
+
},
|
|
135
|
+
format: "plain",
|
|
136
|
+
returning: "full",
|
|
150
137
|
transaction: options?.trx || null
|
|
151
138
|
},
|
|
152
139
|
createJsonRestContext(options?.context || null)
|
|
@@ -73,7 +73,6 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
|
|
|
73
73
|
"auth.extensions": authExtensions,
|
|
74
74
|
"runtime.bootstrap": bootstrap,
|
|
75
75
|
"runtime.config": runtimeConfig(),
|
|
76
|
-
"runtime.database": { knex() {} },
|
|
77
76
|
"runtime.env": {},
|
|
78
77
|
"runtime.http": {
|
|
79
78
|
router: {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { before, beforeEach, after, describe, test } from "node:test";
|
|
3
|
+
import knexLib from "knex";
|
|
4
|
+
import { createAuthExtensions } from "@jskit-ai/auth-core/server/authExtensions";
|
|
5
|
+
import { findDuplicateEntryError } from "@jskit-ai/database-runtime/shared";
|
|
6
|
+
import { createJsonRestApiHost } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
7
|
+
import { UsersIdentityProvider } from "@jskit-ai/users-core/server/UsersIdentityProvider";
|
|
8
|
+
import { createUsersExtensions } from "@jskit-ai/users-core/server/usersExtensions";
|
|
9
|
+
import usersInitial from "../../users-core/migrations/users_core_generic_initial.cjs";
|
|
10
|
+
import usersUsername from "../../users-core/migrations/users_core_profile_username.cjs";
|
|
11
|
+
import usersUpdatedAt from "../../users-core/migrations/users_core_profile_updated_at.cjs";
|
|
12
|
+
import workspacesInitial from "../migrations/workspaces_core_initial.cjs";
|
|
13
|
+
import workspaceSettingsMigration from "../migrations/workspaces_core_workspace_settings_single_name_source.cjs";
|
|
14
|
+
import workspaceColorMigration from "../migrations/workspaces_core_workspaces_drop_color.cjs";
|
|
15
|
+
import { installWorkspaceResources } from "../src/server/WorkspacesFeature.js";
|
|
16
|
+
import { createRepository as createWorkspacesRepository } from "../src/server/common/repositories/workspacesRepository.js";
|
|
17
|
+
import { createRepository as createMembershipsRepository } from "../src/server/common/repositories/workspaceMembershipsRepository.js";
|
|
18
|
+
import { createRepository as createInvitesRepository } from "../src/server/common/repositories/workspaceInvitesRepository.js";
|
|
19
|
+
import { createRepository as createSettingsRepository } from "../src/server/workspaceSettings/workspaceSettingsRepository.js";
|
|
20
|
+
|
|
21
|
+
const TABLES = [
|
|
22
|
+
"workspace_invites", "workspace_settings", "workspace_memberships", "workspaces", "user_settings", "users"
|
|
23
|
+
];
|
|
24
|
+
const databaseUrl = process.env.JSKIT_REPOSITORY_TEST_DATABASE_URL;
|
|
25
|
+
|
|
26
|
+
function databaseConfig() {
|
|
27
|
+
if (!databaseUrl) {
|
|
28
|
+
return {
|
|
29
|
+
client: "better-sqlite3",
|
|
30
|
+
connection: { filename: ":memory:" },
|
|
31
|
+
useNullAsDefault: true,
|
|
32
|
+
pool: { min: 1, max: 1 }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const url = new URL(databaseUrl);
|
|
36
|
+
assert.match(url.protocol, /^(?:mysql2?|postgres(?:ql)?):$/u);
|
|
37
|
+
assert.match(url.pathname, /^\/jskit_repository_test_[a-z0-9_]+$/u);
|
|
38
|
+
if (url.protocol.startsWith("postgres")) {
|
|
39
|
+
return {
|
|
40
|
+
client: "pg",
|
|
41
|
+
connection: {
|
|
42
|
+
host: url.searchParams.get("host") || url.hostname,
|
|
43
|
+
port: Number(url.port || 5432),
|
|
44
|
+
user: decodeURIComponent(url.username),
|
|
45
|
+
password: decodeURIComponent(url.password),
|
|
46
|
+
database: url.pathname.slice(1)
|
|
47
|
+
},
|
|
48
|
+
pool: { min: 1, max: 2 }
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
client: "mysql2",
|
|
53
|
+
connection: {
|
|
54
|
+
host: url.hostname,
|
|
55
|
+
port: Number(url.port || 3306),
|
|
56
|
+
user: decodeURIComponent(url.username),
|
|
57
|
+
password: decodeURIComponent(url.password),
|
|
58
|
+
database: url.pathname.slice(1),
|
|
59
|
+
...(url.searchParams.has("socketPath") ? { socketPath: url.searchParams.get("socketPath") } : {}),
|
|
60
|
+
timezone: "Z",
|
|
61
|
+
supportBigNumbers: true,
|
|
62
|
+
bigNumberStrings: true
|
|
63
|
+
},
|
|
64
|
+
pool: { min: 1, max: 2 }
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const configuration = databaseConfig();
|
|
69
|
+
const databaseLabel = { pg: "PostgreSQL", mysql2: "MySQL", "better-sqlite3": "SQLite" }[configuration.client];
|
|
70
|
+
|
|
71
|
+
describe(`users and workspaces repositories on migrated ${databaseLabel}`, () => {
|
|
72
|
+
let knex;
|
|
73
|
+
let api;
|
|
74
|
+
let profiles;
|
|
75
|
+
let userSettings;
|
|
76
|
+
let workspaces;
|
|
77
|
+
let memberships;
|
|
78
|
+
let invites;
|
|
79
|
+
let settings;
|
|
80
|
+
let ownsTables = false;
|
|
81
|
+
|
|
82
|
+
before(async () => {
|
|
83
|
+
knex = knexLib(configuration);
|
|
84
|
+
if (databaseUrl) {
|
|
85
|
+
const tables = configuration.client === "pg"
|
|
86
|
+
? await knex("information_schema.tables").where({ table_schema: "public" }).select("table_name")
|
|
87
|
+
: (await knex.raw("SHOW TABLES"))[0];
|
|
88
|
+
assert.equal(tables.length, 0, "Native repository tests require an empty disposable database");
|
|
89
|
+
} else {
|
|
90
|
+
await knex.raw("PRAGMA foreign_keys = ON");
|
|
91
|
+
}
|
|
92
|
+
ownsTables = true;
|
|
93
|
+
for (const migration of [
|
|
94
|
+
usersInitial, usersUsername, usersUpdatedAt, workspacesInitial, workspaceSettingsMigration, workspaceColorMigration
|
|
95
|
+
]) {
|
|
96
|
+
await migration.up(knex);
|
|
97
|
+
}
|
|
98
|
+
api = await createJsonRestApiHost({ knex, logger: { error() {}, warn() {}, info() {}, debug() {} } });
|
|
99
|
+
const { identity } = await UsersIdentityProvider.setup({
|
|
100
|
+
authExtensions: createAuthExtensions(),
|
|
101
|
+
extensions: createUsersExtensions(),
|
|
102
|
+
jsonRestApi: api
|
|
103
|
+
});
|
|
104
|
+
({ userProfiles: profiles, userSettings } = identity.repositories);
|
|
105
|
+
await installWorkspaceResources(api);
|
|
106
|
+
workspaces = createWorkspacesRepository({ api });
|
|
107
|
+
memberships = createMembershipsRepository({ api });
|
|
108
|
+
invites = createInvitesRepository({ api });
|
|
109
|
+
settings = createSettingsRepository({ api });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
beforeEach(async () => {
|
|
113
|
+
for (const table of TABLES) await knex(table).delete();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
after(async () => {
|
|
117
|
+
if (!knex) return;
|
|
118
|
+
try {
|
|
119
|
+
if (ownsTables) {
|
|
120
|
+
for (const table of TABLES) await knex.schema.dropTableIfExists(table);
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
await knex.destroy();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const createProfile = (name, options) => profiles.upsert({
|
|
128
|
+
authProvider: "local",
|
|
129
|
+
authProviderUserSid: `identity-${name}`,
|
|
130
|
+
email: `${name}@example.com`,
|
|
131
|
+
displayName: name
|
|
132
|
+
}, options);
|
|
133
|
+
|
|
134
|
+
test("profile and mapped user settings writes persist normalized values and UTC timestamps", async () => {
|
|
135
|
+
const profile = await profiles.upsert({
|
|
136
|
+
provider: " LOCAL ", providerUserId: " ada-identity ",
|
|
137
|
+
email: " ADA@EXAMPLE.COM ", username: " Ada Lovelace ", displayName: " Ada Lovelace "
|
|
138
|
+
});
|
|
139
|
+
assert.equal(typeof profile.id, "string");
|
|
140
|
+
assert.equal(profile.authProvider, "local");
|
|
141
|
+
assert.equal(profile.authProviderUserSid, "ada-identity");
|
|
142
|
+
assert.equal(profile.email, "ada@example.com");
|
|
143
|
+
assert.equal(profile.username, "ada-lovelace");
|
|
144
|
+
assert.equal(profile.displayName, "Ada Lovelace");
|
|
145
|
+
assert.match(profile.createdAt, /Z$/u);
|
|
146
|
+
assert.match(profile.updatedAt, /Z$/u);
|
|
147
|
+
assert.equal((await profiles.findByEmail(" ADA@EXAMPLE.COM ")).id, profile.id);
|
|
148
|
+
|
|
149
|
+
const updated = await profiles.upsert({
|
|
150
|
+
provider: "local", providerUserId: "ada-identity", email: " ADA.NEW@EXAMPLE.COM ",
|
|
151
|
+
username: "ignored-new-name", displayName: " Ada Updated "
|
|
152
|
+
});
|
|
153
|
+
assert.equal(updated.id, profile.id);
|
|
154
|
+
assert.equal(updated.username, "ada-lovelace");
|
|
155
|
+
assert.equal(updated.displayName, "Ada Updated");
|
|
156
|
+
await profiles.updateAvatarById(profile.id, {
|
|
157
|
+
avatarStorageKey: " avatars/ada.png ", avatarVersion: "v1",
|
|
158
|
+
avatarUpdatedAt: new Date("2026-09-12T10:30:00.000Z")
|
|
159
|
+
});
|
|
160
|
+
const avatarProfile = await profiles.findById(profile.id);
|
|
161
|
+
assert.equal(avatarProfile.avatarStorageKey, "avatars/ada.png");
|
|
162
|
+
assert.equal(avatarProfile.avatarUpdatedAt, "2026-09-12T10:30:00.000Z");
|
|
163
|
+
await profiles.clearAvatarById(profile.id);
|
|
164
|
+
assert.equal((await profiles.findById(profile.id)).avatarUpdatedAt, null);
|
|
165
|
+
|
|
166
|
+
const defaults = await userSettings.ensureForUserId(profile.id);
|
|
167
|
+
assert.equal(defaults.id, profile.id);
|
|
168
|
+
assert.equal(defaults.passwordSignInEnabled, true);
|
|
169
|
+
const preferences = await userSettings.updatePreferences(profile.id, {
|
|
170
|
+
locale: " EN-AU ", currencyCode: "aud", timeZone: " Australia/Perth ",
|
|
171
|
+
avatarSize: 96, theme: " dark ", ignoredField: "must not reach storage"
|
|
172
|
+
});
|
|
173
|
+
assert.equal(preferences.locale, "en-au");
|
|
174
|
+
assert.equal(preferences.currencyCode, "AUD");
|
|
175
|
+
assert.equal(preferences.timeZone, "Australia/Perth");
|
|
176
|
+
assert.equal(preferences.avatarSize, 96);
|
|
177
|
+
assert.equal(preferences.theme, "dark");
|
|
178
|
+
await userSettings.updateNotifications(profile.id, { productUpdates: false, securityAlerts: true });
|
|
179
|
+
const disabled = await userSettings.updatePasswordSignInEnabled(profile.id, false, { passwordSetupRequired: true });
|
|
180
|
+
assert.equal(disabled.passwordSignInEnabled, false);
|
|
181
|
+
assert.equal(disabled.passwordSetupRequired, true);
|
|
182
|
+
assert.equal(disabled.productUpdates, false);
|
|
183
|
+
const stored = await knex("user_settings").where({ user_id: profile.id }).first();
|
|
184
|
+
assert.equal(stored.currency_code, "AUD");
|
|
185
|
+
assert.equal(Number(stored.notify_product_updates), 0);
|
|
186
|
+
assert.equal((await knex("users").where({ id: profile.id }).first()).email, "ada.new@example.com");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("workspace and membership repositories read mapped relationships through includes", async () => {
|
|
190
|
+
const owner = await createProfile("Ada");
|
|
191
|
+
const member = await createProfile("Grace");
|
|
192
|
+
const workspace = await workspaces.insert({
|
|
193
|
+
slug: " TEAM-ONE ", name: " Team One ", ownerUserId: owner.id, isPersonal: true
|
|
194
|
+
});
|
|
195
|
+
assert.equal(workspace.slug, "team-one");
|
|
196
|
+
assert.equal(workspace.name, "Team One");
|
|
197
|
+
assert.equal(workspace.ownerUserId, owner.id);
|
|
198
|
+
assert.equal(workspace.isPersonal, true);
|
|
199
|
+
assert.equal((await workspaces.findPersonalByOwnerUserId(owner.id)).id, workspace.id);
|
|
200
|
+
|
|
201
|
+
const ownerMembership = await memberships.ensureOwnerMembership(workspace.id, owner.id);
|
|
202
|
+
assert.equal(ownerMembership.workspaceId, workspace.id);
|
|
203
|
+
assert.equal(ownerMembership.userId, owner.id);
|
|
204
|
+
assert.equal(ownerMembership.roleSid, "owner");
|
|
205
|
+
await memberships.upsertMembership(workspace.id, member.id, { roleSid: " MEMBER ", status: " ACTIVE " });
|
|
206
|
+
const listed = await memberships.listActiveByWorkspaceId(workspace.id);
|
|
207
|
+
assert.deepEqual(listed.map(({ displayName, email }) => ({ displayName, email })), [
|
|
208
|
+
{ displayName: "Ada", email: "ada@example.com" },
|
|
209
|
+
{ displayName: "Grace", email: "grace@example.com" }
|
|
210
|
+
]);
|
|
211
|
+
const visible = await workspaces.listForUserId(member.id);
|
|
212
|
+
assert.equal(visible.length, 1);
|
|
213
|
+
assert.equal(visible[0].ownerUserId, owner.id);
|
|
214
|
+
assert.equal(visible[0].roleSid, "member");
|
|
215
|
+
assert.deepEqual(await memberships.listActiveWorkspaceIdsByUserId(member.id), [workspace.id]);
|
|
216
|
+
const changed = await workspaces.updateById(workspace.id, { ownerUserId: member.id, name: " Renamed " });
|
|
217
|
+
assert.equal(changed.ownerUserId, member.id);
|
|
218
|
+
assert.equal((await knex("workspaces").where({ id: workspace.id }).first()).name, "Renamed");
|
|
219
|
+
await memberships.upsertMembership(workspace.id, member.id, { status: "inactive" });
|
|
220
|
+
assert.deepEqual(await workspaces.listForUserId(member.id), []);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("workspace settings and invitation lifecycle preserve logical IDs and included workspaces", async () => {
|
|
224
|
+
const owner = await createProfile("Ada");
|
|
225
|
+
const workspace = await workspaces.insert({ slug: "invitations", name: "Invitations", ownerUserId: owner.id });
|
|
226
|
+
const initial = await settings.ensureForWorkspaceId(workspace.id);
|
|
227
|
+
assert.equal(initial.id, workspace.id);
|
|
228
|
+
const changed = await settings.updateSettingsByWorkspaceId(workspace.id, {
|
|
229
|
+
lightPrimaryColor: "#abcdef", invitesEnabled: false, name: "Ignored legacy name"
|
|
230
|
+
});
|
|
231
|
+
assert.equal(changed.lightPrimaryColor, "#ABCDEF");
|
|
232
|
+
assert.equal(changed.invitesEnabled, false);
|
|
233
|
+
assert.equal((await workspaces.findById(workspace.id)).name, "Invitations");
|
|
234
|
+
assert.equal((await knex("workspace_settings").where({ workspace_id: workspace.id }).first()).light_primary_color, "#ABCDEF");
|
|
235
|
+
const invite = await invites.insert({
|
|
236
|
+
workspaceId: workspace.id, email: " GRACE@EXAMPLE.COM ", roleSid: " MEMBER ",
|
|
237
|
+
tokenHash: " token-one ", invitedByUserId: owner.id,
|
|
238
|
+
expiresAt: new Date("2026-09-14T10:00:00.000Z")
|
|
239
|
+
});
|
|
240
|
+
assert.equal(invite.workspaceId, workspace.id);
|
|
241
|
+
assert.equal(invite.invitedByUserId, owner.id);
|
|
242
|
+
assert.equal(invite.email, "grace@example.com");
|
|
243
|
+
assert.equal(invite.expiresAt, "2026-09-14T10:00:00.000Z");
|
|
244
|
+
const pending = await invites.listPendingByEmail(" GRACE@EXAMPLE.COM ");
|
|
245
|
+
assert.equal(pending.length, 1);
|
|
246
|
+
assert.equal(pending[0].workspaceSlug, "invitations");
|
|
247
|
+
assert.equal(pending[0].workspaceName, "Invitations");
|
|
248
|
+
assert.equal((await invites.findPendingByIdForWorkspace(invite.id, workspace.id)).id, invite.id);
|
|
249
|
+
await invites.markAcceptedById(invite.id);
|
|
250
|
+
assert.equal(await invites.findPendingByTokenHash("token-one"), null);
|
|
251
|
+
const accepted = await invites.findByTokenHashWithWorkspace("token-one");
|
|
252
|
+
assert.equal(accepted.status, "accepted");
|
|
253
|
+
assert.match(accepted.acceptedAt, /Z$/u);
|
|
254
|
+
assert.equal(accepted.workspaceId, workspace.id);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("one managed transaction rolls back onboarding across all six repositories", async () => {
|
|
258
|
+
await assert.rejects(profiles.withTransaction(async (trx) => {
|
|
259
|
+
const options = { trx };
|
|
260
|
+
const owner = await createProfile("Rollback", options);
|
|
261
|
+
await userSettings.ensureForUserId(owner.id, options);
|
|
262
|
+
const workspace = await workspaces.insert({ slug: "rollback", name: "Rollback", ownerUserId: owner.id }, options);
|
|
263
|
+
await memberships.ensureOwnerMembership(workspace.id, owner.id, options);
|
|
264
|
+
await settings.ensureForWorkspaceId(workspace.id, options);
|
|
265
|
+
await invites.insert({ workspaceId: workspace.id, email: "invite@example.com", tokenHash: "rollback-token" }, options);
|
|
266
|
+
assert.equal((await workspaces.listForUserId(owner.id, options))[0].id, workspace.id);
|
|
267
|
+
throw new Error("Abort onboarding");
|
|
268
|
+
}), /Abort onboarding/u);
|
|
269
|
+
for (const table of TABLES) {
|
|
270
|
+
assert.equal(Number((await knex(table).count({ count: "*" }).first()).count), 0, `${table} must roll back`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a standalone duplicate workspace recovers after rollback without overwriting the existing row", {
|
|
275
|
+
skip: !databaseUrl && "Application duplicate recovery supports native MySQL/Postgres drivers"
|
|
276
|
+
}, async () => {
|
|
277
|
+
const owner = await createProfile("Ada");
|
|
278
|
+
const original = await workspaces.insert({ slug: "duplicate", name: "Original", ownerUserId: owner.id });
|
|
279
|
+
const duplicate = await workspaces.insert({ slug: "duplicate", name: "Replacement", ownerUserId: owner.id });
|
|
280
|
+
assert.equal(duplicate.id, original.id);
|
|
281
|
+
assert.equal(duplicate.name, "Original");
|
|
282
|
+
assert.equal(Number((await knex("workspaces").count({ count: "*" }).first()).count), 1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a duplicate participant poisons its managed transaction even when its caller catches the failure", async () => {
|
|
286
|
+
const owner = await createProfile("Ada");
|
|
287
|
+
await workspaces.insert({ slug: "duplicate", name: "Original", ownerUserId: owner.id });
|
|
288
|
+
let participantFailure;
|
|
289
|
+
await assert.rejects(userSettings.withTransaction(async (trx) => {
|
|
290
|
+
await profiles.updateDisplayNameById(owner.id, "Must roll back", { trx });
|
|
291
|
+
try {
|
|
292
|
+
await workspaces.insert({ slug: "duplicate", name: "Replacement", ownerUserId: owner.id }, { trx });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
participantFailure = error;
|
|
295
|
+
}
|
|
296
|
+
}), (error) => {
|
|
297
|
+
assert.equal(error.transactionOutcome, "rolledBack");
|
|
298
|
+
return true;
|
|
299
|
+
});
|
|
300
|
+
assert.equal(participantFailure?.transactionOutcome, "pending");
|
|
301
|
+
if (databaseUrl) assert.ok(findDuplicateEntryError(participantFailure));
|
|
302
|
+
else assert.equal(participantFailure.cause.code, "SQLITE_CONSTRAINT_UNIQUE");
|
|
303
|
+
assert.equal((await profiles.findById(owner.id)).displayName, "Ada");
|
|
304
|
+
assert.equal(Number((await knex("workspaces").count({ count: "*" }).first()).count), 1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("profile email conflicts retain the real database cause after the standalone transaction rolls back", {
|
|
308
|
+
skip: !databaseUrl && "Application duplicate recovery supports native MySQL/Postgres drivers"
|
|
309
|
+
}, async () => {
|
|
310
|
+
const owner = await createProfile("Ada");
|
|
311
|
+
await assert.rejects(profiles.upsert({
|
|
312
|
+
authProvider: "local", authProviderUserSid: "different-identity",
|
|
313
|
+
email: owner.email, displayName: "Different person"
|
|
314
|
+
}), (error) => {
|
|
315
|
+
assert.equal(error.code, "USER_PROFILE_EMAIL_CONFLICT");
|
|
316
|
+
assert.equal(error.transactionOutcome, "rolledBack");
|
|
317
|
+
assert.ok(findDuplicateEntryError(error));
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
assert.equal(Number((await knex("users").count({ count: "*" }).first()).count), 1);
|
|
321
|
+
assert.equal((await profiles.findByEmail(owner.email)).id, owner.id);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -5,20 +5,11 @@ import { createRepository as createWorkspaceMembershipsRepository } from "../src
|
|
|
5
5
|
import { createRepository as createWorkspacesRepository } from "../src/server/common/repositories/workspacesRepository.js";
|
|
6
6
|
import { createRepository as createWorkspaceSettingsRepository } from "../src/server/workspaceSettings/workspaceSettingsRepository.js";
|
|
7
7
|
|
|
8
|
-
function createKnexStub() {
|
|
9
|
-
const knex = Object.assign(() => {
|
|
10
|
-
throw new Error("query execution not expected");
|
|
11
|
-
}, {
|
|
12
|
-
async transaction(work) {
|
|
13
|
-
return work({ trxId: "trx-1" });
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
return knex;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
8
|
function createApiStub() {
|
|
21
9
|
return {
|
|
10
|
+
async transaction(work) {
|
|
11
|
+
return work({ trxId: "managed-1" });
|
|
12
|
+
},
|
|
22
13
|
resources: {
|
|
23
14
|
workspaceInvites: {},
|
|
24
15
|
workspaceMemberships: {},
|
|
@@ -28,19 +19,52 @@ function createApiStub() {
|
|
|
28
19
|
};
|
|
29
20
|
}
|
|
30
21
|
|
|
31
|
-
test("workspaces-core repositories
|
|
32
|
-
const knex = createKnexStub();
|
|
22
|
+
test("workspaces-core repositories delegate transaction ownership and failures to the API", async () => {
|
|
33
23
|
const api = createApiStub();
|
|
34
24
|
const repositories = [
|
|
35
|
-
createWorkspaceInvitesRepository({ api
|
|
36
|
-
createWorkspaceMembershipsRepository({ api
|
|
37
|
-
createWorkspacesRepository({ api
|
|
38
|
-
createWorkspaceSettingsRepository({ api
|
|
25
|
+
createWorkspaceInvitesRepository({ api }),
|
|
26
|
+
createWorkspaceMembershipsRepository({ api }),
|
|
27
|
+
createWorkspacesRepository({ api }),
|
|
28
|
+
createWorkspaceSettingsRepository({ api })
|
|
39
29
|
];
|
|
40
30
|
|
|
41
31
|
for (const repository of repositories) {
|
|
42
32
|
assert.equal(typeof repository.withTransaction, "function");
|
|
43
33
|
const result = await repository.withTransaction(async (trx) => ({ id: trx.trxId }));
|
|
44
|
-
assert.deepEqual(result, { id: "
|
|
34
|
+
assert.deepEqual(result, { id: "managed-1" });
|
|
35
|
+
const failure = new Error("Participant failed");
|
|
36
|
+
await assert.rejects(repository.withTransaction(async () => { throw failure; }), error => error === failure);
|
|
45
37
|
}
|
|
46
38
|
});
|
|
39
|
+
|
|
40
|
+
const duplicateRecoveryCases = [
|
|
41
|
+
["settings", createWorkspaceSettingsRepository, "workspaceSettings", repository => repository.ensureForWorkspaceId("7")],
|
|
42
|
+
["owner membership", createWorkspaceMembershipsRepository, "workspaceMemberships", repository => repository.ensureOwnerMembership("7", "9")],
|
|
43
|
+
["membership", createWorkspaceMembershipsRepository, "workspaceMemberships", repository => repository.upsertMembership("7", "9")],
|
|
44
|
+
["invite", createWorkspaceInvitesRepository, "workspaceInvites", repository => repository.insert({ workspaceId: "7", email: "ada@example.com" })],
|
|
45
|
+
["workspace", createWorkspacesRepository, "workspaces", repository => repository.insert({ ownerUserId: "9", slug: "shared" })]
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const [name, createRepository, resourceName, write] of duplicateRecoveryCases) {
|
|
49
|
+
for (const outcome of ["pending", "committed", "unknown"]) {
|
|
50
|
+
test(`${name} preserves a ${outcome} duplicate failure without a fallback query`, async () => {
|
|
51
|
+
let reads = 0;
|
|
52
|
+
let writeStarted = false;
|
|
53
|
+
const failure = Object.assign(new Error("Write failed"), {
|
|
54
|
+
transactionOutcome: outcome, cause: { code: "ER_DUP_ENTRY" }
|
|
55
|
+
});
|
|
56
|
+
const api = createApiStub();
|
|
57
|
+
api.resources[resourceName] = {
|
|
58
|
+
async query() {
|
|
59
|
+
assert.equal(writeStarted, false, "A failed transaction cannot support duplicate recovery");
|
|
60
|
+
reads += 1;
|
|
61
|
+
return { data: [] };
|
|
62
|
+
},
|
|
63
|
+
async post() { writeStarted = true; throw failure; }
|
|
64
|
+
};
|
|
65
|
+
const repository = createRepository({ api });
|
|
66
|
+
await assert.rejects(write(repository), error => error === failure);
|
|
67
|
+
assert.equal(reads, ["invite", "workspace"].includes(name) ? 0 : 1);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -3,18 +3,6 @@ import test from "node:test";
|
|
|
3
3
|
import { toIsoString } from "@jskit-ai/database-runtime/shared";
|
|
4
4
|
import { createRepository } from "../src/server/common/repositories/workspaceInvitesRepository.js";
|
|
5
5
|
|
|
6
|
-
function createKnexStub() {
|
|
7
|
-
const knex = Object.assign(() => {
|
|
8
|
-
throw new Error("query execution not expected");
|
|
9
|
-
}, {
|
|
10
|
-
async transaction(work) {
|
|
11
|
-
return work({ trxId: "trx-1" });
|
|
12
|
-
}
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
return knex;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
6
|
function asCollectionDocument(rows = []) {
|
|
19
7
|
return {
|
|
20
8
|
data: Array.isArray(rows) ? rows : []
|
|
@@ -58,7 +46,8 @@ function createWorkspaceInvitesApiStub({
|
|
|
58
46
|
const api = {
|
|
59
47
|
resources: {
|
|
60
48
|
workspaceInvites: {
|
|
61
|
-
async query({ queryParams }) {
|
|
49
|
+
async query({ queryParams, format }) {
|
|
50
|
+
assert.equal(format, "plain");
|
|
62
51
|
const filters = queryParams?.filters || {};
|
|
63
52
|
const matching = rows.filter((row) => {
|
|
64
53
|
if (Object.hasOwn(filters, "id") && String(row.id) !== String(filters.id)) {
|
|
@@ -82,21 +71,25 @@ function createWorkspaceInvitesApiStub({
|
|
|
82
71
|
return asCollectionDocument(matching.map((row) => toWorkspaceInviteRow(row)));
|
|
83
72
|
},
|
|
84
73
|
async post(payload) {
|
|
85
|
-
const
|
|
86
|
-
|
|
74
|
+
const data = payload?.data || {};
|
|
75
|
+
assert.equal(payload.format, "plain");
|
|
76
|
+
assert.equal(payload.returning, "full");
|
|
77
|
+
assert.equal(payload.inputRecord, undefined);
|
|
78
|
+
assert.equal(payload.document, undefined);
|
|
79
|
+
state.postPayload = payload;
|
|
87
80
|
const row = {
|
|
88
81
|
id: "1",
|
|
89
|
-
workspace: { id: String(
|
|
90
|
-
email:
|
|
91
|
-
roleSid:
|
|
92
|
-
status:
|
|
93
|
-
tokenHash:
|
|
94
|
-
invitedByUser:
|
|
95
|
-
? { id: String(
|
|
82
|
+
workspace: { id: String(data.workspace || "") },
|
|
83
|
+
email: data.email,
|
|
84
|
+
roleSid: data.roleSid,
|
|
85
|
+
status: data.status,
|
|
86
|
+
tokenHash: data.tokenHash,
|
|
87
|
+
invitedByUser: data.invitedByUser
|
|
88
|
+
? { id: String(data.invitedByUser) }
|
|
96
89
|
: null,
|
|
97
|
-
expiresAt:
|
|
98
|
-
acceptedAt:
|
|
99
|
-
revokedAt:
|
|
90
|
+
expiresAt: data.expiresAt,
|
|
91
|
+
acceptedAt: data.acceptedAt,
|
|
92
|
+
revokedAt: data.revokedAt,
|
|
100
93
|
createdAt: "2026-03-09 00:26:35.710",
|
|
101
94
|
updatedAt: "2026-03-09 00:26:35.710"
|
|
102
95
|
};
|
|
@@ -105,17 +98,21 @@ function createWorkspaceInvitesApiStub({
|
|
|
105
98
|
return toWorkspaceInviteRow(row);
|
|
106
99
|
},
|
|
107
100
|
async patch(payload) {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
101
|
+
const data = payload?.data || {};
|
|
102
|
+
assert.equal(payload.format, "plain");
|
|
103
|
+
assert.equal(payload.returning, "full");
|
|
104
|
+
assert.equal(payload.inputRecord, undefined);
|
|
105
|
+
assert.equal(payload.document, undefined);
|
|
106
|
+
state.patchPayloads.push(payload);
|
|
107
|
+
const existing = rowById.get(String(payload.id));
|
|
111
108
|
if (existing) {
|
|
112
109
|
const updated = {
|
|
113
110
|
...existing,
|
|
114
|
-
...
|
|
111
|
+
...data
|
|
115
112
|
};
|
|
116
|
-
rowById.set(String(
|
|
113
|
+
rowById.set(String(payload.id), updated);
|
|
117
114
|
}
|
|
118
|
-
const updatedRow = rowById.get(String(
|
|
115
|
+
const updatedRow = rowById.get(String(payload.id)) || null;
|
|
119
116
|
return updatedRow ? toWorkspaceInviteRow(updatedRow) : null;
|
|
120
117
|
}
|
|
121
118
|
}
|
|
@@ -127,7 +124,7 @@ function createWorkspaceInvitesApiStub({
|
|
|
127
124
|
|
|
128
125
|
test("workspaceInvitesRepository.insert preserves expiresAt and relationship fields through the resource write path", async () => {
|
|
129
126
|
const { api, state } = createWorkspaceInvitesApiStub();
|
|
130
|
-
const repository = createRepository({ api
|
|
127
|
+
const repository = createRepository({ api });
|
|
131
128
|
|
|
132
129
|
await repository.insert({
|
|
133
130
|
workspaceId: "1",
|
|
@@ -139,11 +136,11 @@ test("workspaceInvitesRepository.insert preserves expiresAt and relationship fie
|
|
|
139
136
|
expiresAt: "2026-03-16T00:26:35.709Z"
|
|
140
137
|
});
|
|
141
138
|
|
|
142
|
-
assert.equal(state.postPayload.
|
|
143
|
-
assert.equal(state.postPayload.
|
|
144
|
-
assert.equal(state.postPayload.
|
|
145
|
-
assert.equal(state.postPayload.
|
|
146
|
-
assert.equal(typeof state.postPayload.
|
|
139
|
+
assert.equal(state.postPayload.data.workspace, "1");
|
|
140
|
+
assert.equal(state.postPayload.data.email, "invitee@example.com");
|
|
141
|
+
assert.equal(state.postPayload.data.invitedByUser, "1");
|
|
142
|
+
assert.equal(state.postPayload.data.tokenHash, "hash");
|
|
143
|
+
assert.equal(typeof state.postPayload.data.expiresAt, "string");
|
|
147
144
|
});
|
|
148
145
|
|
|
149
146
|
test("workspaceInvitesRepository.findPendingByTokenHash reads from the canonical invite resource without workspace data", async () => {
|
|
@@ -165,7 +162,7 @@ test("workspaceInvitesRepository.findPendingByTokenHash reads from the canonical
|
|
|
165
162
|
}
|
|
166
163
|
]
|
|
167
164
|
});
|
|
168
|
-
const repository = createRepository({ api
|
|
165
|
+
const repository = createRepository({ api });
|
|
169
166
|
|
|
170
167
|
const invite = await repository.findPendingByTokenHash("hash-token");
|
|
171
168
|
|
|
@@ -197,7 +194,7 @@ test("workspaceInvitesRepository.findByTokenHashWithWorkspace preserves workspac
|
|
|
197
194
|
}
|
|
198
195
|
]
|
|
199
196
|
});
|
|
200
|
-
const repository = createRepository({ api
|
|
197
|
+
const repository = createRepository({ api });
|
|
201
198
|
|
|
202
199
|
const invite = await repository.findByTokenHashWithWorkspace("hash-token");
|
|
203
200
|
|
|
@@ -227,14 +224,14 @@ test("workspaceInvitesRepository.markAcceptedById uses the internal invite resou
|
|
|
227
224
|
}]
|
|
228
225
|
])
|
|
229
226
|
});
|
|
230
|
-
const repository = createRepository({ api
|
|
227
|
+
const repository = createRepository({ api });
|
|
231
228
|
|
|
232
229
|
await repository.markAcceptedById("1");
|
|
233
230
|
|
|
234
231
|
const payload = state.patchPayloads[0];
|
|
235
|
-
assert.equal(payload.
|
|
236
|
-
assert.equal(typeof payload.
|
|
237
|
-
assert.equal(payload.
|
|
232
|
+
assert.equal(payload.data.status, "accepted");
|
|
233
|
+
assert.equal(typeof payload.data.acceptedAt, "string");
|
|
234
|
+
assert.equal(payload.data.updatedAt, payload.data.acceptedAt);
|
|
238
235
|
});
|
|
239
236
|
|
|
240
237
|
test("workspaceInvitesRepository.listPendingByWorkspaceIdWithWorkspace keeps workspace join fields outside the base resource contract", async () => {
|
|
@@ -261,7 +258,7 @@ test("workspaceInvitesRepository.listPendingByWorkspaceIdWithWorkspace keeps wor
|
|
|
261
258
|
}
|
|
262
259
|
]
|
|
263
260
|
});
|
|
264
|
-
const repository = createRepository({ api
|
|
261
|
+
const repository = createRepository({ api });
|
|
265
262
|
|
|
266
263
|
const invites = await repository.listPendingByWorkspaceIdWithWorkspace("9");
|
|
267
264
|
|