@jskit-ai/users-core 0.1.197 → 0.1.199

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-core",
3
- "version": "0.1.197",
3
+ "version": "0.1.199",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -13,12 +13,12 @@
13
13
  "./shared/resources/userSettingsResource": "./src/shared/resources/userSettingsResource.js"
14
14
  },
15
15
  "dependencies": {
16
- "@jskit-ai/database-runtime": "0.1.184",
17
- "@jskit-ai/json-rest-api-core": "0.1.128",
18
- "@jskit-ai/resource-core": "0.1.126",
19
- "@jskit-ai/resource-crud-core": "0.1.126",
20
- "@jskit-ai/storage-runtime": "0.1.181",
21
- "@jskit-ai/uploads-runtime": "0.1.159",
16
+ "@jskit-ai/database-runtime": "0.1.186",
17
+ "@jskit-ai/json-rest-api-core": "0.1.130",
18
+ "@jskit-ai/resource-core": "0.1.128",
19
+ "@jskit-ai/resource-crud-core": "0.1.128",
20
+ "@jskit-ai/storage-runtime": "0.1.183",
21
+ "@jskit-ai/uploads-runtime": "0.1.161",
22
22
  "json-rest-schema": "^1.0.17"
23
23
  },
24
24
  "description": "Users/account runtime plus HTTP routes for account features.",
@@ -166,8 +166,8 @@
166
166
  }
167
167
  },
168
168
  "peerDependencies": {
169
- "@jskit-ai/auth-core": "0.1.182",
170
- "@jskit-ai/http-runtime": "0.1.182",
171
- "@jskit-ai/kernel": "0.1.184"
169
+ "@jskit-ai/auth-core": "0.1.184",
170
+ "@jskit-ai/http-runtime": "0.1.184",
171
+ "@jskit-ai/kernel": "0.1.186"
172
172
  }
173
173
  }
@@ -9,8 +9,8 @@
9
9
  "./shared": "./src/shared/index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@jskit-ai/crud-core": "0.1.195",
13
- "@jskit-ai/resource-crud-core": "0.1.126"
12
+ "@jskit-ai/crud-core": "0.1.197",
13
+ "@jskit-ai/resource-crud-core": "0.1.128"
14
14
  },
15
15
  "jskit": {
16
16
  "kind": "runtime",
@@ -9,9 +9,9 @@
9
9
  "./shared": "./src/shared/index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@jskit-ai/crud-core": "0.1.195",
13
- "@jskit-ai/resource-crud-core": "0.1.126",
14
- "@jskit-ai/workspaces-core": "0.1.162"
12
+ "@jskit-ai/crud-core": "0.1.197",
13
+ "@jskit-ai/resource-crud-core": "0.1.128",
14
+ "@jskit-ai/workspaces-core": "0.1.164"
15
15
  },
16
16
  "jskit": {
17
17
  "kind": "runtime",
@@ -14,14 +14,13 @@ const UsersIdentityProvider = defineProvider({
14
14
  id: "users.identity",
15
15
  requires: {
16
16
  authExtensions: "auth.extensions",
17
- database: "runtime.database",
18
17
  extensions: "users.extensions",
19
18
  jsonRestApi: "runtime.json-rest-api"
20
19
  },
21
20
  provides: {
22
21
  identity: "users.identity"
23
22
  },
24
- async setup({ authExtensions, database, extensions, jsonRestApi }) {
23
+ async setup({ authExtensions, extensions, jsonRestApi }) {
25
24
  const scopeOptions = {
26
25
  writeSerializers: { "datetime-utc": toDatabaseDateTimeUtc }
27
26
  };
@@ -36,8 +35,8 @@ const UsersIdentityProvider = defineProvider({
36
35
  createJsonRestResourceScopeOptions(userSettingsResource, scopeOptions)
37
36
  );
38
37
  const repositories = Object.freeze({
39
- userProfiles: createUserProfilesRepository({ api: jsonRestApi, knex: database.knex }),
40
- userSettings: createUserSettingsRepository({ api: jsonRestApi, knex: database.knex })
38
+ userProfiles: createUserProfilesRepository({ api: jsonRestApi }),
39
+ userSettings: createUserSettingsRepository({ api: jsonRestApi })
41
40
  });
42
41
  const profileProjector = createAuthProfileSyncService({
43
42
  userProfilesRepository: repositories.userProfiles,
@@ -17,6 +17,13 @@ const accountNotificationsActionSpecifications = Object.freeze([
17
17
  },
18
18
  input: userSettingsResource.operations.notificationsUpdate.body,
19
19
  output: null,
20
+ extensions: {
21
+ assistant: {
22
+ description: "Update the signed-in user’s notification preferences.",
23
+ output: userSettingsResource.operations.notificationsUpdate.output,
24
+ transformResult: (result) => result.value
25
+ }
26
+ },
20
27
  idempotency: "optional",
21
28
  audit: {
22
29
  actionName: "settings.notifications.update"
@@ -17,6 +17,13 @@ const accountPreferencesActionSpecifications = Object.freeze([
17
17
  },
18
18
  input: userSettingsResource.operations.preferencesUpdate.body,
19
19
  output: null,
20
+ extensions: {
21
+ assistant: {
22
+ description: "Update the signed-in user’s display and regional preferences.",
23
+ output: userSettingsResource.operations.preferencesUpdate.output,
24
+ transformResult: (result) => result.value
25
+ }
26
+ },
20
27
  idempotency: "optional",
21
28
  audit: {
22
29
  actionName: "settings.preferences.update"
@@ -1,3 +1,4 @@
1
+ import { userSettingsResource } from "../../shared/resources/userSettingsResource.js";
1
2
  import {
2
3
  emptyInputValidator,
3
4
  resolveRequest
@@ -24,6 +25,13 @@ const accountProfileActionSpecifications = deepFreeze([
24
25
  },
25
26
  input: emptyInputValidator,
26
27
  output: null,
28
+ extensions: {
29
+ assistant: {
30
+ description: "Read the signed-in user’s profile, preferences, notifications and security status.",
31
+ output: userSettingsResource.operations.view.output,
32
+ transformResult: (result) => result.value
33
+ }
34
+ },
27
35
  idempotency: "none",
28
36
  audit: {
29
37
  actionName: "settings.read"
@@ -46,6 +54,13 @@ const accountProfileActionSpecifications = deepFreeze([
46
54
  },
47
55
  input: settingsProfileUpdateInputValidator,
48
56
  output: null,
57
+ extensions: {
58
+ assistant: {
59
+ description: "Update the signed-in user’s display name.",
60
+ output: userSettingsResource.operations.view.output,
61
+ transformResult: (result) => result.response.value
62
+ }
63
+ },
49
64
  idempotency: "optional",
50
65
  audit: {
51
66
  actionName: "settings.profile.update"
@@ -74,6 +89,11 @@ const accountProfileActionSpecifications = deepFreeze([
74
89
  },
75
90
  input: userProfileResource.operations.avatarUpload.body,
76
91
  output: null,
92
+ extensions: {
93
+ assistant: {
94
+ exclude: "Use the authenticated account profile file picker; upload streams must not enter assistant arguments."
95
+ }
96
+ },
77
97
  idempotency: "none",
78
98
  audit: {
79
99
  actionName: "settings.profile.avatar.upload"
@@ -109,6 +129,13 @@ const accountProfileActionSpecifications = deepFreeze([
109
129
  },
110
130
  input: userProfileResource.operations.avatarDelete.body,
111
131
  output: null,
132
+ extensions: {
133
+ assistant: {
134
+ description: "Remove the signed-in user’s uploaded avatar.",
135
+ output: userSettingsResource.operations.view.output,
136
+ transformResult: (result) => result.value
137
+ }
138
+ },
112
139
  idempotency: "none",
113
140
  audit: {
114
141
  actionName: "settings.profile.avatar.delete"
@@ -25,6 +25,11 @@ const accountSecurityActionSpecifications = Object.freeze([
25
25
  },
26
26
  input: userSettingsResource.operations.passwordChange.body,
27
27
  output: null,
28
+ extensions: {
29
+ assistant: {
30
+ exclude: "Use the authenticated account security form; passwords must not enter assistant arguments."
31
+ }
32
+ },
28
33
  idempotency: "none",
29
34
  audit: {
30
35
  actionName: "settings.security.password.change"
@@ -52,6 +57,13 @@ const accountSecurityActionSpecifications = Object.freeze([
52
57
  },
53
58
  input: userSettingsResource.operations.passwordMethodToggle.body,
54
59
  output: null,
60
+ extensions: {
61
+ assistant: {
62
+ description: "Enable or disable password sign-in for the signed-in user, subject to the account authentication policy.",
63
+ output: userSettingsResource.operations.passwordMethodToggle.output,
64
+ transformResult: (result) => ({ securityStatus: result.value.securityStatus, settings: result.value.settings })
65
+ }
66
+ },
55
67
  idempotency: "none",
56
68
  audit: {
57
69
  actionName: "settings.security.password_method.toggle"
@@ -79,6 +91,11 @@ const accountSecurityActionSpecifications = Object.freeze([
79
91
  },
80
92
  input: oauthLinkStartInputValidator,
81
93
  output: null,
94
+ extensions: {
95
+ assistant: {
96
+ exclude: "Use the authenticated account security screen; OAuth authorization URLs contain transient credentials."
97
+ }
98
+ },
82
99
  idempotency: "none",
83
100
  audit: {
84
101
  actionName: "settings.security.oauth.link.start"
@@ -106,6 +123,13 @@ const accountSecurityActionSpecifications = Object.freeze([
106
123
  },
107
124
  input: userSettingsResource.operations.oauthUnlink.params,
108
125
  output: null,
126
+ extensions: {
127
+ assistant: {
128
+ description: "Unlink an OAuth sign-in method from the signed-in account, subject to the account authentication policy.",
129
+ output: userSettingsResource.operations.oauthUnlink.output,
130
+ transformResult: (result) => ({ securityStatus: result.value.securityStatus })
131
+ }
132
+ },
109
133
  idempotency: "none",
110
134
  audit: {
111
135
  actionName: "settings.security.oauth.unlink"
@@ -133,6 +157,13 @@ const accountSecurityActionSpecifications = Object.freeze([
133
157
  },
134
158
  input: userSettingsResource.operations.logoutOtherSessions.body,
135
159
  output: null,
160
+ extensions: {
161
+ assistant: {
162
+ description: "Sign the current user out of other devices.",
163
+ output: userSettingsResource.operations.logoutOtherSessions.output,
164
+ transformResult: () => ({ ok: true })
165
+ }
166
+ },
136
167
  idempotency: "none",
137
168
  audit: {
138
169
  actionName: "settings.security.sessions.logout_others"
@@ -1,9 +1,8 @@
1
1
  import {
2
2
  normalizeDbRecordId,
3
- toIsoString,
4
- createWithTransaction
3
+ toIsoString
5
4
  } from "@jskit-ai/database-runtime/shared";
6
- import { isDuplicateEntryError } from "@jskit-ai/database-runtime/shared/duplicateEntry";
5
+ import { findDuplicateEntryError, isDuplicateEntryError } from "@jskit-ai/database-runtime/shared/duplicateEntry";
7
6
  import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
8
7
 
9
8
  function uniqueSorted(values) {
@@ -32,12 +31,12 @@ function toDbJson(value, fallback = {}) {
32
31
  export {
33
32
  toIsoString,
34
33
  isDuplicateEntryError,
34
+ findDuplicateEntryError,
35
35
  normalizeText,
36
36
  normalizeLowerText,
37
37
  normalizeRecordId,
38
38
  normalizeDbRecordId,
39
39
  uniqueSorted,
40
40
  parseJson,
41
- toDbJson,
42
- createWithTransaction
41
+ toDbJson
43
42
  };
@@ -1,5 +1,5 @@
1
1
  import {
2
- createWithTransaction,
2
+ findDuplicateEntryError,
3
3
  isDuplicateEntryError,
4
4
  normalizeDbRecordId,
5
5
  normalizeLowerText,
@@ -8,13 +8,11 @@ import {
8
8
  toIsoString
9
9
  } from "./repositoryUtils.js";
10
10
  import {
11
- createJsonApiInputRecord,
12
11
  createJsonRestContext,
13
12
  extractJsonRestCollectionRows
14
13
  } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
15
14
  import { normalizeIdentity } from "../support/identity.js";
16
15
 
17
- const RESOURCE_TYPE = "userProfiles";
18
16
  const USERNAME_MAX_LENGTH = 120;
19
17
 
20
18
  function normalizeUsername(value) {
@@ -114,25 +112,28 @@ function buildUsernameCandidate(baseUsername, suffix) {
114
112
  }
115
113
 
116
114
  function duplicateTargetsEmail(error) {
117
- if (!isDuplicateEntryError(error)) {
115
+ const duplicate = findDuplicateEntryError(error);
116
+ if (!duplicate) {
118
117
  return false;
119
118
  }
120
119
 
121
- const message = normalizeLowerText(error?.sqlMessage || error?.message);
120
+ const message = normalizeLowerText(duplicate.sqlMessage || duplicate.message);
122
121
  return message.includes("email");
123
122
  }
124
123
 
125
124
  function duplicateTargetsUsername(error) {
126
- if (!isDuplicateEntryError(error)) {
125
+ const duplicate = findDuplicateEntryError(error);
126
+ if (!duplicate) {
127
127
  return false;
128
128
  }
129
129
 
130
- const message = normalizeLowerText(error?.sqlMessage || error?.message);
130
+ const message = normalizeLowerText(duplicate.sqlMessage || duplicate.message);
131
131
  return message.includes("username");
132
132
  }
133
133
 
134
- function createDuplicateEmailConflictError() {
135
- const error = new Error("Email is already linked to a different profile.");
134
+ function createDuplicateEmailConflictError(cause) {
135
+ const error = new Error("Email is already linked to a different profile.", { cause });
136
+ error.transactionOutcome = cause.transactionOutcome;
136
137
  error.code = "USER_PROFILE_EMAIL_CONFLICT";
137
138
  return error;
138
139
  }
@@ -150,7 +151,7 @@ async function resolveUniqueUsername(api, baseUsername, { excludeUserId = null,
150
151
  }
151
152
  },
152
153
  transaction,
153
- simplified: true
154
+ format: "plain"
154
155
  })
155
156
  );
156
157
  const existing = existingRows[0] || null;
@@ -163,15 +164,12 @@ async function resolveUniqueUsername(api, baseUsername, { excludeUserId = null,
163
164
  throw new Error("Unable to generate unique username.");
164
165
  }
165
166
 
166
- function createRepository({ api, knex } = {}) {
167
+ function createRepository({ api } = {}) {
167
168
  if (!api?.resources?.userProfiles) {
168
169
  throw new TypeError("internal.repository.user-profiles requires json-rest-api userProfiles resource.");
169
170
  }
170
- if (typeof knex !== "function") {
171
- throw new TypeError("internal.repository.user-profiles requires knex.");
172
- }
173
171
 
174
- const withTransaction = createWithTransaction(knex);
172
+ const withTransaction = (work) => api.transaction(work);
175
173
 
176
174
  async function queryFirst(filters = {}, options = {}) {
177
175
  const rows = extractJsonRestCollectionRows(
@@ -181,7 +179,7 @@ function createRepository({ api, knex } = {}) {
181
179
  filters
182
180
  },
183
181
  transaction: options?.trx || null,
184
- simplified: true
182
+ format: "plain"
185
183
  },
186
184
  createJsonRestContext(options?.context || null)
187
185
  )
@@ -233,16 +231,13 @@ function createRepository({ api, knex } = {}) {
233
231
 
234
232
  const updated = await api.resources.userProfiles.patch(
235
233
  {
236
- inputRecord: createJsonApiInputRecord(
237
- RESOURCE_TYPE,
238
- {
239
- displayName,
240
- updatedAt: new Date().toISOString()
241
- },
242
- {
243
- id: normalizedUserId
244
- }
245
- ),
234
+ id: normalizedUserId,
235
+ data: {
236
+ displayName,
237
+ updatedAt: new Date().toISOString()
238
+ },
239
+ format: "plain",
240
+ returning: "full",
246
241
  transaction: options?.trx || null
247
242
  },
248
243
  createJsonRestContext(options?.context || null)
@@ -259,18 +254,15 @@ function createRepository({ api, knex } = {}) {
259
254
 
260
255
  const updated = await api.resources.userProfiles.patch(
261
256
  {
262
- inputRecord: createJsonApiInputRecord(
263
- RESOURCE_TYPE,
264
- {
265
- avatarStorageKey: avatar.avatarStorageKey ?? null,
266
- avatarVersion: avatar.avatarVersion ?? null,
267
- avatarUpdatedAt: toIsoString(avatar.avatarUpdatedAt ?? new Date()),
268
- updatedAt: new Date().toISOString()
269
- },
270
- {
271
- id: normalizedUserId
272
- }
273
- ),
257
+ id: normalizedUserId,
258
+ data: {
259
+ avatarStorageKey: avatar.avatarStorageKey ?? null,
260
+ avatarVersion: avatar.avatarVersion ?? null,
261
+ avatarUpdatedAt: toIsoString(avatar.avatarUpdatedAt ?? new Date()),
262
+ updatedAt: new Date().toISOString()
263
+ },
264
+ format: "plain",
265
+ returning: "full",
274
266
  transaction: options?.trx || null
275
267
  },
276
268
  createJsonRestContext(options?.context || null)
@@ -287,18 +279,15 @@ function createRepository({ api, knex } = {}) {
287
279
 
288
280
  const updated = await api.resources.userProfiles.patch(
289
281
  {
290
- inputRecord: createJsonApiInputRecord(
291
- RESOURCE_TYPE,
292
- {
293
- avatarStorageKey: null,
294
- avatarVersion: null,
295
- avatarUpdatedAt: null,
296
- updatedAt: new Date().toISOString()
297
- },
298
- {
299
- id: normalizedUserId
300
- }
301
- ),
282
+ id: normalizedUserId,
283
+ data: {
284
+ avatarStorageKey: null,
285
+ avatarVersion: null,
286
+ avatarUpdatedAt: null,
287
+ updatedAt: new Date().toISOString()
288
+ },
289
+ format: "plain",
290
+ returning: "full",
302
291
  transaction: options?.trx || null
303
292
  },
304
293
  createJsonRestContext(options?.context || null)
@@ -330,84 +319,80 @@ function createRepository({ api, knex } = {}) {
330
319
  context: options?.context || null
331
320
  });
332
321
 
333
- try {
334
- if (existing) {
335
- const existingUsername = normalizeUsername(existing.username);
336
- const username = existingUsername || (
337
- await resolveUniqueUsername(
338
- api,
339
- requestedUsername || usernameBaseFromEmail(email),
340
- { excludeUserId: existing.id, transaction: trx }
341
- )
342
- );
343
-
344
- const updated = await api.resources.userProfiles.patch(
345
- {
346
- inputRecord: createJsonApiInputRecord(
347
- RESOURCE_TYPE,
348
- {
349
- email,
350
- displayName,
351
- username,
352
- updatedAt: new Date().toISOString()
353
- },
354
- {
355
- id: normalizeDbRecordId(existing.id, { fallback: null })
356
- }
357
- ),
358
- transaction: trx
359
- },
360
- createJsonRestContext(options?.context || null)
361
- );
362
-
363
- return normalizeProfileRecord(updated);
364
- }
365
-
366
- const username = await resolveUniqueUsername(
367
- api,
368
- requestedUsername || usernameBaseFromEmail(email),
369
- { transaction: trx }
322
+ if (existing) {
323
+ const existingUsername = normalizeUsername(existing.username);
324
+ const username = existingUsername || (
325
+ await resolveUniqueUsername(
326
+ api,
327
+ requestedUsername || usernameBaseFromEmail(email),
328
+ { excludeUserId: existing.id, transaction: trx }
329
+ )
370
330
  );
371
331
 
372
- const created = await api.resources.userProfiles.post(
332
+ const updated = await api.resources.userProfiles.patch(
373
333
  {
374
- inputRecord: createJsonApiInputRecord(RESOURCE_TYPE, {
375
- authProvider: identity.provider,
376
- authProviderUserSid: identity.providerUserId,
334
+ id: normalizeDbRecordId(existing.id, { fallback: null }),
335
+ data: {
377
336
  email,
378
337
  displayName,
379
338
  username,
380
- createdAt: new Date().toISOString()
381
- }),
339
+ updatedAt: new Date().toISOString()
340
+ },
341
+ format: "plain",
342
+ returning: "full",
382
343
  transaction: trx
383
344
  },
384
345
  createJsonRestContext(options?.context || null)
385
346
  );
386
347
 
387
- return normalizeProfileRecord(created);
388
- } catch (error) {
389
- if (duplicateTargetsEmail(error)) {
390
- throw createDuplicateEmailConflictError();
391
- }
392
- if (duplicateTargetsUsername(error)) {
393
- throw error;
394
- }
395
- if (!isDuplicateEntryError(error)) {
396
- throw error;
397
- }
348
+ return normalizeProfileRecord(updated);
398
349
  }
399
350
 
400
- return findByIdentity(identity, {
401
- trx,
402
- context: options?.context || null
403
- });
351
+ const username = await resolveUniqueUsername(
352
+ api,
353
+ requestedUsername || usernameBaseFromEmail(email),
354
+ { transaction: trx }
355
+ );
356
+
357
+ const created = await api.resources.userProfiles.post(
358
+ {
359
+ data: {
360
+ authProvider: identity.provider,
361
+ authProviderUserSid: identity.providerUserId,
362
+ email,
363
+ displayName,
364
+ username,
365
+ createdAt: new Date().toISOString()
366
+ },
367
+ format: "plain",
368
+ returning: "full",
369
+ transaction: trx
370
+ },
371
+ createJsonRestContext(options?.context || null)
372
+ );
373
+
374
+ return normalizeProfileRecord(created);
404
375
  };
405
376
 
406
- if (options?.trx) {
407
- return executeUpsert(options.trx);
377
+ try {
378
+ return await (options?.trx ? executeUpsert(options.trx) : withTransaction(executeUpsert));
379
+ } catch (error) {
380
+ if (["pending", "rolledBack"].includes(error?.transactionOutcome) && duplicateTargetsEmail(error)) {
381
+ throw createDuplicateEmailConflictError(error);
382
+ }
383
+ // A failed participant cannot recover inside the caller's transaction.
384
+ if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
385
+ throw error;
386
+ }
387
+ if (duplicateTargetsUsername(error)) {
388
+ throw error;
389
+ }
390
+ const existing = await findByIdentity(identity, { context: options?.context || null });
391
+ if (existing) {
392
+ return existing;
393
+ }
394
+ throw error;
408
395
  }
409
-
410
- return withTransaction(executeUpsert);
411
396
  }
412
397
 
413
398
  return Object.freeze({
@@ -1,16 +1,13 @@
1
1
  import {
2
2
  normalizeRecordId,
3
- isDuplicateEntryError,
4
- createWithTransaction
3
+ isDuplicateEntryError
5
4
  } from "./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 { DEFAULT_USER_SETTINGS } from "../../../shared/settings.js";
12
10
 
13
- const RESOURCE_TYPE = "userSettings";
14
11
  const USER_SETTINGS_PATCH_FIELDS = Object.freeze([
15
12
  "theme",
16
13
  "locale",
@@ -56,14 +53,11 @@ function createDefaultUserSettingsCreatePayload(userId) {
56
53
  };
57
54
  }
58
55
 
59
- function createRepository({ api, knex } = {}) {
56
+ function createRepository({ api } = {}) {
60
57
  if (!api?.resources?.userSettings) {
61
58
  throw new TypeError("userSettingsRepository requires json-rest-api userSettings resource.");
62
59
  }
63
- if (typeof knex !== "function") {
64
- throw new TypeError("userSettingsRepository requires knex.");
65
- }
66
- const withTransaction = createWithTransaction(knex);
60
+ const withTransaction = (work) => api.transaction(work);
67
61
 
68
62
  async function queryFirst(filters = {}, options = {}) {
69
63
  const rows = extractJsonRestCollectionRows(
@@ -73,7 +67,7 @@ function createRepository({ api, knex } = {}) {
73
67
  filters
74
68
  },
75
69
  transaction: options?.trx || null,
76
- simplified: true
70
+ format: "plain"
77
71
  },
78
72
  createJsonRestContext(options?.context || null)
79
73
  )
@@ -105,19 +99,15 @@ function createRepository({ api, knex } = {}) {
105
99
  try {
106
100
  await api.resources.userSettings.post(
107
101
  {
108
- inputRecord: createJsonApiInputRecord(
109
- RESOURCE_TYPE,
110
- createDefaultUserSettingsCreatePayload(normalizedUserId),
111
- {
112
- id: normalizedUserId
113
- }
114
- ),
102
+ data: createDefaultUserSettingsCreatePayload(normalizedUserId),
103
+ format: "plain",
104
+ returning: "full",
115
105
  transaction: options?.trx || null
116
106
  },
117
107
  createJsonRestContext(options?.context || null)
118
108
  );
119
109
  } catch (error) {
120
- if (!isDuplicateEntryError(error)) {
110
+ if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
121
111
  throw error;
122
112
  }
123
113
  }
@@ -141,16 +131,13 @@ function createRepository({ api, knex } = {}) {
141
131
 
142
132
  await api.resources.userSettings.patch(
143
133
  {
144
- inputRecord: createJsonApiInputRecord(
145
- RESOURCE_TYPE,
146
- {
147
- ...updatePayload,
148
- updatedAt: new Date().toISOString()
149
- },
150
- {
151
- id: normalizedUserId
152
- }
153
- ),
134
+ id: normalizedUserId,
135
+ data: {
136
+ ...updatePayload,
137
+ updatedAt: new Date().toISOString()
138
+ },
139
+ format: "plain",
140
+ returning: "full",
154
141
  transaction: options?.trx || null
155
142
  },
156
143
  createJsonRestContext(options?.context || null)
@@ -147,3 +147,31 @@ test("authProfileSyncService.findByIdentity normalizes provider identity input",
147
147
  providerUserId: "user-1"
148
148
  });
149
149
  });
150
+
151
+ test("auth profile sync joins the supplied managed transaction and propagates contributor failure", async () => {
152
+ const trx = { trxId: "managed-profile-sync" };
153
+ const failure = new Error("Lifecycle participant failed");
154
+ const calls = [];
155
+ const profile = {
156
+ id: "7", authProvider: "local", authProviderUserSid: "local-7",
157
+ email: "ada@example.com", displayName: "Ada"
158
+ };
159
+ const service = createService({
160
+ userProfilesRepository: {
161
+ async findByIdentity(_identity, options) { calls.push(options.trx); return null; },
162
+ async upsert(_data, options) { calls.push(options.trx); return profile; },
163
+ async withTransaction() { throw new Error("Must join the caller's owner"); }
164
+ },
165
+ userSettingsRepository: {
166
+ async ensureForUserId(_id, options) { calls.push(options.trx); }
167
+ },
168
+ resolveLifecycleContributors: () => [{
169
+ async afterIdentityProfileSynced({ options }) {
170
+ calls.push(options.trx);
171
+ throw failure;
172
+ }
173
+ }]
174
+ });
175
+ await assert.rejects(service.syncIdentityProfile(profile, { trx }), error => error === failure);
176
+ assert.deepEqual(calls, [trx, trx, trx, trx]);
177
+ });
@@ -46,7 +46,6 @@ test("UsersFeature assembles account behavior through explicit capabilities", as
46
46
  "auth.extensions": authExtensions,
47
47
  "auth.service": {},
48
48
  "runtime.bootstrap": bootstrap,
49
- "runtime.database": { knex() {} },
50
49
  "runtime.http": {
51
50
  router: {
52
51
  register(method, path) {
@@ -5,27 +5,17 @@ import { userProfileResource } from "../src/shared/resources/userProfileResource
5
5
  import { createRepository as createUserProfilesRepository } from "../src/server/common/repositories/userProfilesRepository.js";
6
6
  import { createRepository as createUserSettingsRepository } from "../src/server/common/repositories/userSettingsRepository.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 asCollectionDocument(rows = []) {
21
9
  return {
22
10
  data: Array.isArray(rows) ? rows : []
23
11
  };
24
12
  }
25
13
 
26
- test("users-core repositories expose withTransaction", async () => {
27
- const knex = createKnexStub();
14
+ test("users-core repositories delegate transaction ownership and failures to the API", async () => {
28
15
  const api = {
16
+ async transaction(work) {
17
+ return work({ trxId: "managed-1" });
18
+ },
29
19
  resources: {
30
20
  userProfiles: {
31
21
  async query() {
@@ -40,14 +30,16 @@ test("users-core repositories expose withTransaction", async () => {
40
30
  }
41
31
  };
42
32
  const repositories = [
43
- createUserProfilesRepository({ api, knex }),
44
- createUserSettingsRepository({ api, knex })
33
+ createUserProfilesRepository({ api }),
34
+ createUserSettingsRepository({ api })
45
35
  ];
46
36
 
47
37
  for (const repository of repositories) {
48
38
  assert.equal(typeof repository.withTransaction, "function");
49
39
  const result = await repository.withTransaction(async (trx) => ({ id: trx.trxId }));
50
- assert.deepEqual(result, { id: "trx-1" });
40
+ assert.deepEqual(result, { id: "managed-1" });
41
+ const failure = new Error("Participant failed");
42
+ await assert.rejects(repository.withTransaction(async () => { throw failure; }), error => error === failure);
51
43
  }
52
44
  });
53
45
 
@@ -59,7 +51,8 @@ function createUserProfilesApiStub(expectedRecord) {
59
51
  api: {
60
52
  resources: {
61
53
  userProfiles: {
62
- async query({ queryParams }) {
54
+ async query({ queryParams, format }) {
55
+ assert.equal(format, "plain");
63
56
  calls.push(queryParams?.filters || {});
64
57
  return asCollectionDocument(
65
58
  expectedRecord ? [{
@@ -74,12 +67,11 @@ function createUserProfilesApiStub(expectedRecord) {
74
67
  };
75
68
  }
76
69
 
77
- test("userSettingsRepository.ensureForUserId sends transaction outside simplified attributes", async () => {
70
+ test("userSettingsRepository.ensureForUserId sends transaction outside plain data", async () => {
78
71
  const trx = { trxId: "trx-1" };
79
72
  let queryCount = 0;
80
73
  let postParams = null;
81
74
  const repository = createUserSettingsRepository({
82
- knex: createKnexStub(),
83
75
  api: {
84
76
  resources: {
85
77
  userSettings: {
@@ -108,28 +100,25 @@ test("userSettingsRepository.ensureForUserId sends transaction outside simplifie
108
100
 
109
101
  const record = await repository.ensureForUserId("7", { trx });
110
102
 
111
- assert.equal(postParams?.simplified, undefined);
103
+ assert.equal(postParams?.format, "plain");
104
+ assert.equal(postParams?.returning, "full");
112
105
  assert.equal(postParams?.transaction, trx);
113
- assert.deepEqual(postParams?.inputRecord?.data, {
114
- type: "userSettings",
106
+ assert.deepEqual(postParams?.data, {
115
107
  id: "7",
116
- attributes: {
117
- id: "7",
118
- ...DEFAULT_USER_SETTINGS
119
- }
108
+ ...DEFAULT_USER_SETTINGS
120
109
  });
121
110
  assert.equal(record?.id, "7");
122
111
  });
123
112
 
124
- test("userProfilesRepository.upsert sends native JSON:API write documents with transaction outside the record body", async () => {
113
+ test("userProfilesRepository.upsert sends plain data with transaction outside the record body", async () => {
125
114
  const trx = { trxId: "trx-1" };
126
115
  let postParams = null;
127
116
  const repository = createUserProfilesRepository({
128
- knex: createKnexStub(),
129
117
  api: {
130
118
  resources: {
131
119
  userProfiles: {
132
- async query({ queryParams }) {
120
+ async query({ queryParams, format }) {
121
+ assert.equal(format, "plain");
133
122
  const filters = queryParams?.filters || {};
134
123
  if (Object.hasOwn(filters, "authProvider") || Object.hasOwn(filters, "authProviderUserSid")) {
135
124
  return asCollectionDocument([]);
@@ -141,7 +130,7 @@ test("userProfilesRepository.upsert sends native JSON:API write documents with t
141
130
  },
142
131
  async post(params) {
143
132
  postParams = params;
144
- const attributes = params.inputRecord?.data?.attributes || {};
133
+ const attributes = params.data || {};
145
134
  return {
146
135
  id: "11",
147
136
  authProvider: attributes.authProvider,
@@ -167,11 +156,13 @@ test("userProfilesRepository.upsert sends native JSON:API write documents with t
167
156
  displayName: "Ada Example"
168
157
  }, { trx });
169
158
 
170
- assert.equal(postParams?.simplified, undefined);
159
+ assert.equal(postParams?.format, "plain");
160
+ assert.equal(postParams?.returning, "full");
171
161
  assert.equal(postParams?.transaction, trx);
172
- assert.equal(postParams?.inputRecord?.transaction, undefined);
173
- assert.equal(postParams?.inputRecord?.data?.type, "userProfiles");
174
- assert.equal(postParams?.inputRecord?.data?.attributes?.authProvider, "supabase");
162
+ assert.equal(postParams?.data?.transaction, undefined);
163
+ assert.equal(postParams?.document, undefined);
164
+ assert.equal(postParams?.inputRecord, undefined);
165
+ assert.equal(postParams?.data?.authProvider, "supabase");
175
166
  assert.equal(record?.id, "11");
176
167
  });
177
168
 
@@ -192,11 +183,11 @@ test("userProfilesRepository.upsert patches existing profiles with resource-back
192
183
  };
193
184
  let patchParams = null;
194
185
  const repository = createUserProfilesRepository({
195
- knex: createKnexStub(),
196
186
  api: {
197
187
  resources: {
198
188
  userProfiles: {
199
- async query({ queryParams }) {
189
+ async query({ queryParams, format }) {
190
+ assert.equal(format, "plain");
200
191
  const filters = queryParams?.filters || {};
201
192
  if (Object.hasOwn(filters, "authProvider") || Object.hasOwn(filters, "authProviderUserSid")) {
202
193
  return asCollectionDocument([existingRecord]);
@@ -208,7 +199,7 @@ test("userProfilesRepository.upsert patches existing profiles with resource-back
208
199
  },
209
200
  async patch(params) {
210
201
  patchParams = params;
211
- const attributes = params.inputRecord?.data?.attributes || {};
202
+ const attributes = params.data || {};
212
203
  return {
213
204
  ...existingRecord,
214
205
  ...attributes
@@ -226,7 +217,7 @@ test("userProfilesRepository.upsert patches existing profiles with resource-back
226
217
  displayName: "Ada Renamed"
227
218
  }, { trx });
228
219
 
229
- const attributes = patchParams?.inputRecord?.data?.attributes || {};
220
+ const attributes = patchParams?.data || {};
230
221
  assert.equal(userProfileResource.schema.updatedAt?.type, "dateTime");
231
222
  assert.equal(userProfileResource.schema.updatedAt?.storage?.column, "updated_at");
232
223
  assert.equal(userProfileResource.schema.updatedAt?.storage?.writeSerializer, "datetime-utc");
@@ -244,7 +235,6 @@ test("userProfilesRepository profile patch helpers stamp updatedAt through the r
244
235
  const trx = { trxId: "trx-1" };
245
236
  const calls = [];
246
237
  const repository = createUserProfilesRepository({
247
- knex: createKnexStub(),
248
238
  api: {
249
239
  resources: {
250
240
  userProfiles: {
@@ -253,9 +243,9 @@ test("userProfilesRepository profile patch helpers stamp updatedAt through the r
253
243
  },
254
244
  async patch(params) {
255
245
  calls.push(params);
256
- const attributes = params.inputRecord?.data?.attributes || {};
246
+ const attributes = params.data || {};
257
247
  return {
258
- id: params.inputRecord?.data?.id || "7",
248
+ id: params.id || "7",
259
249
  authProvider: "local",
260
250
  authProviderUserSid: "local-user-7",
261
251
  email: "ada@example.com",
@@ -285,21 +275,26 @@ test("userProfilesRepository profile patch helpers stamp updatedAt through the r
285
275
  assert.equal(calls.length, 3);
286
276
  for (const call of calls) {
287
277
  assert.equal(call.transaction, trx);
288
- assert.equal(typeof call.inputRecord?.data?.attributes?.updatedAt, "string");
278
+ assert.equal(call.id, "7");
279
+ assert.equal(call.format, "plain");
280
+ assert.equal(call.returning, "full");
281
+ assert.equal(call.document, undefined);
282
+ assert.equal(call.inputRecord, undefined);
283
+ assert.equal(typeof call.data?.updatedAt, "string");
289
284
  }
290
- assert.deepEqual(Object.keys(calls[0].inputRecord.data.attributes).sort(), ["displayName", "updatedAt"]);
291
- assert.deepEqual(Object.keys(calls[1].inputRecord.data.attributes).sort(), [
285
+ assert.deepEqual(Object.keys(calls[0].data).sort(), ["displayName", "updatedAt"]);
286
+ assert.deepEqual(Object.keys(calls[1].data).sort(), [
292
287
  "avatarStorageKey",
293
288
  "avatarUpdatedAt",
294
289
  "avatarVersion",
295
290
  "updatedAt"
296
291
  ]);
297
- assert.equal(calls[1].inputRecord.data.attributes.avatarStorageKey, "avatars/7.png");
298
- assert.equal(calls[1].inputRecord.data.attributes.avatarVersion, "v1");
299
- assert.equal(calls[1].inputRecord.data.attributes.avatarUpdatedAt, avatarUpdatedAt.toISOString());
300
- assert.deepEqual(calls[2].inputRecord.data.attributes.avatarStorageKey, null);
301
- assert.deepEqual(calls[2].inputRecord.data.attributes.avatarVersion, null);
302
- assert.deepEqual(calls[2].inputRecord.data.attributes.avatarUpdatedAt, null);
292
+ assert.equal(calls[1].data.avatarStorageKey, "avatars/7.png");
293
+ assert.equal(calls[1].data.avatarVersion, "v1");
294
+ assert.equal(calls[1].data.avatarUpdatedAt, avatarUpdatedAt.toISOString());
295
+ assert.deepEqual(calls[2].data.avatarStorageKey, null);
296
+ assert.deepEqual(calls[2].data.avatarVersion, null);
297
+ assert.deepEqual(calls[2].data.avatarUpdatedAt, null);
303
298
  });
304
299
 
305
300
  test("userProfilesRepository.findByEmail normalizes email lookup", async () => {
@@ -316,8 +311,7 @@ test("userProfilesRepository.findByEmail normalizes email lookup", async () => {
316
311
  createdAt: "2026-04-20T00:00:00.000Z"
317
312
  });
318
313
  const repository = createUserProfilesRepository({
319
- api,
320
- knex: createKnexStub()
314
+ api
321
315
  });
322
316
 
323
317
  const profile = await repository.findByEmail(" ADA@EXAMPLE.COM ");
@@ -342,8 +336,7 @@ test("userProfilesRepository.findByIdentity reads existing profiles from collect
342
336
  createdAt: "2026-04-20T00:00:00.000Z"
343
337
  });
344
338
  const repository = createUserProfilesRepository({
345
- api,
346
- knex: createKnexStub()
339
+ api
347
340
  });
348
341
 
349
342
  const profile = await repository.findByIdentity({
@@ -362,11 +355,158 @@ test("userProfilesRepository.findByIdentity reads existing profiles from collect
362
355
  test("userProfilesRepository.findByEmail returns null when the row is missing", async () => {
363
356
  const { api } = createUserProfilesApiStub(undefined);
364
357
  const repository = createUserProfilesRepository({
365
- api,
366
- knex: createKnexStub()
358
+ api
367
359
  });
368
360
 
369
361
  const profile = await repository.findByEmail("missing@example.com");
370
362
 
371
363
  assert.equal(profile, null);
372
364
  });
365
+
366
+ for (const outcome of ["pending", "committed", "unknown"]) {
367
+ test(`user settings preserves a ${outcome} write failure instead of recovering as a duplicate`, async () => {
368
+ let reads = 0;
369
+ const failure = Object.assign(new Error("Write failed"), {
370
+ transactionOutcome: outcome,
371
+ cause: { code: "23505" }
372
+ });
373
+ const api = {
374
+ resources: {
375
+ userSettings: {
376
+ async query() { reads += 1; return asCollectionDocument([]); },
377
+ async post() { throw failure; }
378
+ }
379
+ }
380
+ };
381
+ const repository = createUserSettingsRepository({ api });
382
+ await assert.rejects(repository.ensureForUserId("7"), error => error === failure);
383
+ assert.equal(reads, 1);
384
+ });
385
+ }
386
+
387
+ test("user settings recovers a duplicate only after its standalone write rolled back", async () => {
388
+ let reads = 0;
389
+ const existing = { id: "7", ...DEFAULT_USER_SETTINGS };
390
+ const api = {
391
+ resources: {
392
+ userSettings: {
393
+ async query() { reads += 1; return asCollectionDocument(reads === 1 ? [] : [existing]); },
394
+ async post() {
395
+ throw Object.assign(new Error("Write failed"), {
396
+ transactionOutcome: "rolledBack", cause: { code: "23505" }
397
+ });
398
+ }
399
+ }
400
+ }
401
+ };
402
+ const repository = createUserSettingsRepository({ api });
403
+ assert.deepEqual(await repository.ensureForUserId("7"), existing);
404
+ assert.equal(reads, 2);
405
+ });
406
+
407
+ test("profile upsert performs duplicate recovery after its API transaction has finished", async () => {
408
+ const trx = { trxId: "managed-upsert" };
409
+ const profile = {
410
+ id: "7", authProvider: "local", authProviderUserSid: "local-7",
411
+ email: "ada@example.com", username: "ada", displayName: "Ada"
412
+ };
413
+ let active = false;
414
+ let rolledBack = false;
415
+ const api = {
416
+ async transaction(work) {
417
+ active = true;
418
+ try {
419
+ return await work(trx);
420
+ } catch (cause) {
421
+ active = false;
422
+ rolledBack = true;
423
+ throw Object.assign(new Error("Transaction failed", { cause }), { transactionOutcome: "rolledBack" });
424
+ }
425
+ },
426
+ resources: {
427
+ userProfiles: {
428
+ async query({ transaction, queryParams, format }) {
429
+ assert.equal(format, "plain");
430
+ if (active) {
431
+ assert.equal(transaction, trx);
432
+ return asCollectionDocument([]);
433
+ }
434
+ assert.equal(rolledBack, true);
435
+ assert.equal(transaction, null);
436
+ assert.equal(queryParams.filters.authProviderUserSid, "local-7");
437
+ return asCollectionDocument([profile]);
438
+ },
439
+ async post({ transaction }) {
440
+ assert.equal(transaction, trx);
441
+ throw Object.assign(new Error("Duplicate identity"), {
442
+ transactionOutcome: "pending", cause: { code: "23505", message: "unique provider identity" }
443
+ });
444
+ }
445
+ }
446
+ }
447
+ };
448
+ const repository = createUserProfilesRepository({ api });
449
+ assert.equal((await repository.upsert(profile)).id, "7");
450
+ assert.equal(active, false);
451
+ });
452
+
453
+ test("profile upsert never recovers inside a caller-owned failed transaction", async () => {
454
+ const trx = { trxId: "caller-managed" };
455
+ let reads = 0;
456
+ const failure = Object.assign(new Error("Duplicate identity"), {
457
+ transactionOutcome: "pending", cause: { code: "23505" }
458
+ });
459
+ const api = {
460
+ async transaction() { throw new Error("Must join the caller's owner"); },
461
+ resources: {
462
+ userProfiles: {
463
+ async query({ transaction }) {
464
+ assert.equal(transaction, trx);
465
+ reads += 1;
466
+ return asCollectionDocument([]);
467
+ },
468
+ async post() { throw failure; }
469
+ }
470
+ }
471
+ };
472
+ const repository = createUserProfilesRepository({ api });
473
+ await assert.rejects(repository.upsert({
474
+ authProvider: "local", authProviderUserSid: "local-7",
475
+ email: "ada@example.com", displayName: "Ada"
476
+ }, { trx }), error => error === failure);
477
+ assert.equal(reads, 2);
478
+ });
479
+
480
+ for (const outcome of ["pending", "committed", "unknown"]) {
481
+ test(`profile email conflict preserves the ${outcome} outcome from a caller-owned write`, async () => {
482
+ const trx = { trxId: "caller-managed" };
483
+ let reads = 0;
484
+ const failure = Object.assign(new Error("Resource write failed"), {
485
+ transactionOutcome: outcome,
486
+ cause: { code: "23505", message: "duplicate key in users_email_unique" }
487
+ });
488
+ const api = {
489
+ resources: {
490
+ userProfiles: {
491
+ async query() { reads += 1; return asCollectionDocument([]); },
492
+ async post() { throw failure; }
493
+ }
494
+ }
495
+ };
496
+ const repository = createUserProfilesRepository({ api });
497
+ await assert.rejects(repository.upsert({
498
+ authProvider: "local", authProviderUserSid: "local-7",
499
+ email: "ada@example.com", displayName: "Ada"
500
+ }, { trx }), error => {
501
+ if (outcome === "pending") {
502
+ assert.equal(error.code, "USER_PROFILE_EMAIL_CONFLICT");
503
+ assert.equal(error.cause, failure);
504
+ } else {
505
+ assert.equal(error, failure);
506
+ }
507
+ assert.equal(error.transactionOutcome, outcome);
508
+ return true;
509
+ });
510
+ assert.equal(reads, 2);
511
+ });
512
+ }