@jskit-ai/workspaces-core 0.1.163 → 0.1.165

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.
@@ -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, knex, defaultInvitesEnabled = true } = {}) {
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
- if (typeof knex !== "function") {
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
- simplified: true
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
- inputRecord: createJsonApiInputRecord(
105
- RESOURCE_TYPE,
106
- createDefaultWorkspaceSettingsCreatePayload(normalizedWorkspaceId, defaultInvitesEnabled === true),
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
- inputRecord: createJsonApiInputRecord(
141
- RESOURCE_TYPE,
142
- {
143
- ...updatePayload,
144
- updatedAt: new Date().toISOString()
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 expose withTransaction", async () => {
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, knex }),
36
- createWorkspaceMembershipsRepository({ api, knex }),
37
- createWorkspacesRepository({ api, knex }),
38
- createWorkspaceSettingsRepository({ api, knex })
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: "trx-1" });
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 inputRecord = payload?.inputRecord?.data || {};
86
- state.postPayload = inputRecord;
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(inputRecord.relationships?.workspace?.data?.id || "") },
90
- email: inputRecord.attributes?.email,
91
- roleSid: inputRecord.attributes?.roleSid,
92
- status: inputRecord.attributes?.status,
93
- tokenHash: inputRecord.attributes?.tokenHash,
94
- invitedByUser: inputRecord.relationships?.invitedByUser?.data
95
- ? { id: String(inputRecord.relationships.invitedByUser.data.id) }
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: inputRecord.attributes?.expiresAt,
98
- acceptedAt: inputRecord.attributes?.acceptedAt,
99
- revokedAt: inputRecord.attributes?.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 inputRecord = payload?.inputRecord?.data || {};
109
- state.patchPayloads.push(inputRecord);
110
- const existing = rowById.get(String(inputRecord.id));
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
- ...(inputRecord.attributes || {})
111
+ ...data
115
112
  };
116
- rowById.set(String(inputRecord.id), updated);
113
+ rowById.set(String(payload.id), updated);
117
114
  }
118
- const updatedRow = rowById.get(String(inputRecord.id)) || null;
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, knex: createKnexStub() });
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.relationships?.workspace?.data?.id, "1");
143
- assert.equal(state.postPayload.attributes?.email, "invitee@example.com");
144
- assert.equal(state.postPayload.relationships?.invitedByUser?.data?.id, "1");
145
- assert.equal(state.postPayload.attributes?.tokenHash, "hash");
146
- assert.equal(typeof state.postPayload.attributes?.expiresAt, "string");
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, knex: createKnexStub() });
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, knex: createKnexStub() });
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, knex: createKnexStub() });
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.attributes?.status, "accepted");
236
- assert.equal(typeof payload.attributes?.acceptedAt, "string");
237
- assert.equal(payload.attributes?.updatedAt, payload.attributes?.acceptedAt);
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, knex: createKnexStub() });
261
+ const repository = createRepository({ api });
265
262
 
266
263
  const invites = await repository.listPendingByWorkspaceIdWithWorkspace("9");
267
264