@jskit-ai/users-core 0.1.115 → 0.1.117
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.descriptor.mjs +19 -10
- package/package.json +9 -9
- package/src/server/common/repositories/userProfilesRepository.js +2 -1
- package/src/shared/resources/userProfileResource.js +8 -0
- package/templates/migrations/users_core_generic_initial.cjs +1 -0
- package/templates/migrations/users_core_profile_updated_at.cjs +48 -0
- package/templates/packages/users/src/shared/userResource.js +8 -0
- package/test/repositoryContracts.test.js +128 -0
- package/test/resourcesCanonical.test.js +9 -0
- package/test/usersPackageScaffoldContract.test.js +33 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/users-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.117",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Users/account runtime plus HTTP routes for account features.",
|
|
7
7
|
dependsOn: [
|
|
@@ -146,16 +146,16 @@ export default Object.freeze({
|
|
|
146
146
|
mutations: {
|
|
147
147
|
dependencies: {
|
|
148
148
|
runtime: {
|
|
149
|
-
"@jskit-ai/auth-core": "0.1.
|
|
150
|
-
"@jskit-ai/crud-core": "0.1.
|
|
151
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
152
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
153
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
154
|
-
"@jskit-ai/kernel": "0.1.
|
|
155
|
-
"@jskit-ai/resource-core": "0.1.
|
|
156
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
149
|
+
"@jskit-ai/auth-core": "0.1.106",
|
|
150
|
+
"@jskit-ai/crud-core": "0.1.115",
|
|
151
|
+
"@jskit-ai/database-runtime": "0.1.107",
|
|
152
|
+
"@jskit-ai/http-runtime": "0.1.106",
|
|
153
|
+
"@jskit-ai/json-rest-api-core": "0.1.52",
|
|
154
|
+
"@jskit-ai/kernel": "0.1.108",
|
|
155
|
+
"@jskit-ai/resource-core": "0.1.52",
|
|
156
|
+
"@jskit-ai/resource-crud-core": "0.1.52",
|
|
157
157
|
"@local/users": "file:packages/users",
|
|
158
|
-
"@jskit-ai/uploads-runtime": "0.1.
|
|
158
|
+
"@jskit-ai/uploads-runtime": "0.1.85"
|
|
159
159
|
},
|
|
160
160
|
dev: {}
|
|
161
161
|
},
|
|
@@ -182,6 +182,15 @@ export default Object.freeze({
|
|
|
182
182
|
category: "migration",
|
|
183
183
|
id: "users-core-profile-username-schema"
|
|
184
184
|
},
|
|
185
|
+
{
|
|
186
|
+
op: "install-migration",
|
|
187
|
+
from: "templates/migrations/users_core_profile_updated_at.cjs",
|
|
188
|
+
toDir: "migrations",
|
|
189
|
+
extension: ".cjs",
|
|
190
|
+
reason: "Install users profile updated-at migration.",
|
|
191
|
+
category: "migration",
|
|
192
|
+
id: "users-core-profile-updated-at-schema"
|
|
193
|
+
},
|
|
185
194
|
{
|
|
186
195
|
from: "templates/packages/users/package.json",
|
|
187
196
|
to: "packages/users/package.json",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/users-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.117",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
"./shared/resources/userSettingsResource": "./src/shared/resources/userSettingsResource.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@jskit-ai/auth-core": "0.1.
|
|
16
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
17
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
18
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
19
|
-
"@jskit-ai/kernel": "0.1.
|
|
20
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
21
|
-
"@jskit-ai/resource-core": "0.1.
|
|
22
|
-
"@jskit-ai/uploads-runtime": "0.1.
|
|
15
|
+
"@jskit-ai/auth-core": "0.1.106",
|
|
16
|
+
"@jskit-ai/database-runtime": "0.1.107",
|
|
17
|
+
"@jskit-ai/http-runtime": "0.1.106",
|
|
18
|
+
"@jskit-ai/json-rest-api-core": "0.1.52",
|
|
19
|
+
"@jskit-ai/kernel": "0.1.108",
|
|
20
|
+
"@jskit-ai/resource-crud-core": "0.1.52",
|
|
21
|
+
"@jskit-ai/resource-core": "0.1.52",
|
|
22
|
+
"@jskit-ai/uploads-runtime": "0.1.85",
|
|
23
23
|
"json-rest-schema": "1.x.x"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -58,7 +58,8 @@ function normalizeProfileRecord(payload = null) {
|
|
|
58
58
|
avatarStorageKey: normalizeNullableString(payload.avatarStorageKey),
|
|
59
59
|
avatarVersion: normalizeNullableVersion(payload.avatarVersion),
|
|
60
60
|
avatarUpdatedAt: payload.avatarUpdatedAt ? toIsoString(payload.avatarUpdatedAt) : null,
|
|
61
|
-
createdAt: payload.createdAt ? toIsoString(payload.createdAt) : null
|
|
61
|
+
createdAt: payload.createdAt ? toIsoString(payload.createdAt) : null,
|
|
62
|
+
updatedAt: payload.updatedAt ? toIsoString(payload.updatedAt) : null
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -150,6 +150,14 @@ const userProfileResource = defineCrudResource({
|
|
|
150
150
|
column: "created_at",
|
|
151
151
|
writeSerializer: "datetime-utc"
|
|
152
152
|
}
|
|
153
|
+
},
|
|
154
|
+
updatedAt: {
|
|
155
|
+
type: "dateTime",
|
|
156
|
+
default: "now()",
|
|
157
|
+
storage: {
|
|
158
|
+
column: "updated_at",
|
|
159
|
+
writeSerializer: "datetime-utc"
|
|
160
|
+
}
|
|
153
161
|
}
|
|
154
162
|
},
|
|
155
163
|
messages: USER_PROFILE_OPERATION_MESSAGES,
|
|
@@ -15,6 +15,7 @@ exports.up = async function up(knex) {
|
|
|
15
15
|
table.string("avatar_version", 64).nullable();
|
|
16
16
|
table.timestamp("avatar_updated_at", { useTz: false }).nullable();
|
|
17
17
|
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
18
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
18
19
|
table.unique(["auth_provider", "auth_provider_user_sid"], "uq_users_identity");
|
|
19
20
|
table.unique(["email"], "uq_users_email");
|
|
20
21
|
table.unique(["username"], "uq_users_username");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {import('knex').Knex} knex
|
|
3
|
+
*/
|
|
4
|
+
exports.up = async function up(knex) {
|
|
5
|
+
const hasUsersTable = await knex.schema.hasTable("users");
|
|
6
|
+
if (!hasUsersTable) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const hasUpdatedAt = await knex.schema.hasColumn("users", "updated_at");
|
|
11
|
+
if (hasUpdatedAt) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
await knex.schema.alterTable("users", (table) => {
|
|
16
|
+
table.timestamp("updated_at", { useTz: false }).nullable();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const hasCreatedAt = await knex.schema.hasColumn("users", "created_at");
|
|
20
|
+
await knex("users").whereNull("updated_at").update({
|
|
21
|
+
updated_at: hasCreatedAt
|
|
22
|
+
? knex.raw("COALESCE(??, CURRENT_TIMESTAMP)", ["created_at"])
|
|
23
|
+
: knex.raw("CURRENT_TIMESTAMP")
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await knex.schema.alterTable("users", (table) => {
|
|
27
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now()).alter();
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import('knex').Knex} knex
|
|
33
|
+
*/
|
|
34
|
+
exports.down = async function down(knex) {
|
|
35
|
+
const hasUsersTable = await knex.schema.hasTable("users");
|
|
36
|
+
if (!hasUsersTable) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hasUpdatedAt = await knex.schema.hasColumn("users", "updated_at");
|
|
41
|
+
if (!hasUpdatedAt) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await knex.schema.alterTable("users", (table) => {
|
|
46
|
+
table.dropColumn("updated_at");
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -39,6 +39,14 @@ const resource = defineCrudResource({
|
|
|
39
39
|
operations: {
|
|
40
40
|
output: { required: true }
|
|
41
41
|
}
|
|
42
|
+
},
|
|
43
|
+
updatedAt: {
|
|
44
|
+
type: "dateTime",
|
|
45
|
+
required: true,
|
|
46
|
+
storage: { writeSerializer: "datetime-utc" },
|
|
47
|
+
operations: {
|
|
48
|
+
output: { required: true }
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
},
|
|
44
52
|
searchSchema: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { DEFAULT_USER_SETTINGS } from "../src/shared/settings.js";
|
|
4
|
+
import { userProfileResource } from "../src/shared/resources/userProfileResource.js";
|
|
4
5
|
import { createRepository as createUserProfilesRepository } from "../src/server/common/repositories/userProfilesRepository.js";
|
|
5
6
|
import { createRepository as createUserSettingsRepository } from "../src/server/common/repositories/userSettingsRepository.js";
|
|
6
7
|
|
|
@@ -174,6 +175,133 @@ test("userProfilesRepository.upsert sends native JSON:API write documents with t
|
|
|
174
175
|
assert.equal(record?.id, "11");
|
|
175
176
|
});
|
|
176
177
|
|
|
178
|
+
test("userProfilesRepository.upsert patches existing profiles with resource-backed updatedAt", async () => {
|
|
179
|
+
const trx = { trxId: "trx-1" };
|
|
180
|
+
const existingRecord = {
|
|
181
|
+
id: "7",
|
|
182
|
+
authProvider: "local",
|
|
183
|
+
authProviderUserSid: "local-user-7",
|
|
184
|
+
email: "ada@example.com",
|
|
185
|
+
username: "ada",
|
|
186
|
+
displayName: "Ada Example",
|
|
187
|
+
avatarStorageKey: null,
|
|
188
|
+
avatarVersion: null,
|
|
189
|
+
avatarUpdatedAt: null,
|
|
190
|
+
createdAt: "2026-04-20T00:00:00.000Z",
|
|
191
|
+
updatedAt: "2026-04-20T00:00:00.000Z"
|
|
192
|
+
};
|
|
193
|
+
let patchParams = null;
|
|
194
|
+
const repository = createUserProfilesRepository({
|
|
195
|
+
knex: createKnexStub(),
|
|
196
|
+
api: {
|
|
197
|
+
resources: {
|
|
198
|
+
userProfiles: {
|
|
199
|
+
async query({ queryParams }) {
|
|
200
|
+
const filters = queryParams?.filters || {};
|
|
201
|
+
if (Object.hasOwn(filters, "authProvider") || Object.hasOwn(filters, "authProviderUserSid")) {
|
|
202
|
+
return asCollectionDocument([existingRecord]);
|
|
203
|
+
}
|
|
204
|
+
if (Object.hasOwn(filters, "username")) {
|
|
205
|
+
return asCollectionDocument([]);
|
|
206
|
+
}
|
|
207
|
+
return asCollectionDocument([]);
|
|
208
|
+
},
|
|
209
|
+
async patch(params) {
|
|
210
|
+
patchParams = params;
|
|
211
|
+
const attributes = params.inputRecord?.data?.attributes || {};
|
|
212
|
+
return {
|
|
213
|
+
...existingRecord,
|
|
214
|
+
...attributes
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const record = await repository.upsert({
|
|
223
|
+
authProvider: "local",
|
|
224
|
+
authProviderUserSid: "local-user-7",
|
|
225
|
+
email: " ADA.RENAMED@EXAMPLE.COM ",
|
|
226
|
+
displayName: "Ada Renamed"
|
|
227
|
+
}, { trx });
|
|
228
|
+
|
|
229
|
+
const attributes = patchParams?.inputRecord?.data?.attributes || {};
|
|
230
|
+
assert.equal(userProfileResource.schema.updatedAt?.type, "dateTime");
|
|
231
|
+
assert.equal(userProfileResource.schema.updatedAt?.storage?.column, "updated_at");
|
|
232
|
+
assert.equal(userProfileResource.schema.updatedAt?.storage?.writeSerializer, "datetime-utc");
|
|
233
|
+
assert.equal(patchParams?.transaction, trx);
|
|
234
|
+
assert.deepEqual(Object.keys(attributes).sort(), ["displayName", "email", "updatedAt", "username"]);
|
|
235
|
+
assert.equal(attributes.email, "ada.renamed@example.com");
|
|
236
|
+
assert.equal(attributes.displayName, "Ada Renamed");
|
|
237
|
+
assert.equal(attributes.username, "ada");
|
|
238
|
+
assert.ok(attributes.updatedAt instanceof Date);
|
|
239
|
+
assert.equal(record?.id, "7");
|
|
240
|
+
assert.equal(record?.updatedAt, attributes.updatedAt.toISOString());
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("userProfilesRepository profile patch helpers stamp updatedAt through the resource contract", async () => {
|
|
244
|
+
const trx = { trxId: "trx-1" };
|
|
245
|
+
const calls = [];
|
|
246
|
+
const repository = createUserProfilesRepository({
|
|
247
|
+
knex: createKnexStub(),
|
|
248
|
+
api: {
|
|
249
|
+
resources: {
|
|
250
|
+
userProfiles: {
|
|
251
|
+
async query() {
|
|
252
|
+
return asCollectionDocument([]);
|
|
253
|
+
},
|
|
254
|
+
async patch(params) {
|
|
255
|
+
calls.push(params);
|
|
256
|
+
const attributes = params.inputRecord?.data?.attributes || {};
|
|
257
|
+
return {
|
|
258
|
+
id: params.inputRecord?.data?.id || "7",
|
|
259
|
+
authProvider: "local",
|
|
260
|
+
authProviderUserSid: "local-user-7",
|
|
261
|
+
email: "ada@example.com",
|
|
262
|
+
username: "ada",
|
|
263
|
+
displayName: attributes.displayName || "Ada Example",
|
|
264
|
+
avatarStorageKey: attributes.avatarStorageKey ?? null,
|
|
265
|
+
avatarVersion: attributes.avatarVersion ?? null,
|
|
266
|
+
avatarUpdatedAt: attributes.avatarUpdatedAt ?? null,
|
|
267
|
+
createdAt: "2026-04-20T00:00:00.000Z",
|
|
268
|
+
updatedAt: attributes.updatedAt
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
const avatarUpdatedAt = new Date("2026-04-21T12:00:00.000Z");
|
|
276
|
+
|
|
277
|
+
await repository.updateDisplayNameById("7", "Ada Updated", { trx });
|
|
278
|
+
await repository.updateAvatarById("7", {
|
|
279
|
+
avatarStorageKey: "avatars/7.png",
|
|
280
|
+
avatarVersion: "v1",
|
|
281
|
+
avatarUpdatedAt
|
|
282
|
+
}, { trx });
|
|
283
|
+
await repository.clearAvatarById("7", { trx });
|
|
284
|
+
|
|
285
|
+
assert.equal(calls.length, 3);
|
|
286
|
+
for (const call of calls) {
|
|
287
|
+
assert.equal(call.transaction, trx);
|
|
288
|
+
assert.ok(call.inputRecord?.data?.attributes?.updatedAt instanceof Date);
|
|
289
|
+
}
|
|
290
|
+
assert.deepEqual(Object.keys(calls[0].inputRecord.data.attributes).sort(), ["displayName", "updatedAt"]);
|
|
291
|
+
assert.deepEqual(Object.keys(calls[1].inputRecord.data.attributes).sort(), [
|
|
292
|
+
"avatarStorageKey",
|
|
293
|
+
"avatarUpdatedAt",
|
|
294
|
+
"avatarVersion",
|
|
295
|
+
"updatedAt"
|
|
296
|
+
]);
|
|
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);
|
|
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);
|
|
303
|
+
});
|
|
304
|
+
|
|
177
305
|
test("userProfilesRepository.findByEmail normalizes email lookup", async () => {
|
|
178
306
|
const { api, calls } = createUserProfilesApiStub({
|
|
179
307
|
id: 7,
|
|
@@ -38,6 +38,15 @@ test("users-core resources expose messages for all operations", () => {
|
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("user profile resource exposes managed timestamps used by JSON REST writes", () => {
|
|
42
|
+
assert.equal(userProfileResource.schema.createdAt?.type, "dateTime");
|
|
43
|
+
assert.equal(userProfileResource.schema.createdAt?.storage?.column, "created_at");
|
|
44
|
+
assert.equal(userProfileResource.schema.createdAt?.storage?.writeSerializer, "datetime-utc");
|
|
45
|
+
assert.equal(userProfileResource.schema.updatedAt?.type, "dateTime");
|
|
46
|
+
assert.equal(userProfileResource.schema.updatedAt?.storage?.column, "updated_at");
|
|
47
|
+
assert.equal(userProfileResource.schema.updatedAt?.storage?.writeSerializer, "datetime-utc");
|
|
48
|
+
});
|
|
49
|
+
|
|
41
50
|
test("users-core specialized resource operations expose messages and validators", () => {
|
|
42
51
|
const operationSpecs = [
|
|
43
52
|
{ label: "userProfile.avatarUpload", operation: userProfileResource.operations.avatarUpload },
|
|
@@ -49,6 +49,37 @@ test("users-core installs the app-local users package scaffold", () => {
|
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
test("users-core installs all users profile schema migrations", async () => {
|
|
53
|
+
const expectedMigrationIds = [
|
|
54
|
+
"users-core-generic-initial-schema",
|
|
55
|
+
"users-core-profile-username-schema",
|
|
56
|
+
"users-core-profile-updated-at-schema"
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const migrationId of expectedMigrationIds) {
|
|
60
|
+
const mutation = readFileMutationById(migrationId);
|
|
61
|
+
assert.ok(mutation, `Missing users-core migration mutation ${migrationId}.`);
|
|
62
|
+
assert.equal(mutation.op, "install-migration", `${migrationId} must install a migration.`);
|
|
63
|
+
assert.equal(mutation.toDir, "migrations", `${migrationId} must target app migrations.`);
|
|
64
|
+
assert.equal(mutation.category, "migration", `${migrationId} must be categorized as a migration.`);
|
|
65
|
+
assert.ok(mutation.from, `${migrationId} must define a migration template source.`);
|
|
66
|
+
assert.ok(mutation.reason, `${migrationId} must document why it exists.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const initialMigrationSource = await readFile(
|
|
70
|
+
path.join(PACKAGE_ROOT, "templates/migrations/users_core_generic_initial.cjs"),
|
|
71
|
+
"utf8"
|
|
72
|
+
);
|
|
73
|
+
const updatedAtMigrationSource = await readFile(
|
|
74
|
+
path.join(PACKAGE_ROOT, "templates/migrations/users_core_profile_updated_at.cjs"),
|
|
75
|
+
"utf8"
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
assert.match(initialMigrationSource, /table\.timestamp\("updated_at"/);
|
|
79
|
+
assert.match(updatedAtMigrationSource, /hasColumn\("users", "updated_at"\)/);
|
|
80
|
+
assert.match(updatedAtMigrationSource, /COALESCE\(\?\?, CURRENT_TIMESTAMP\)/);
|
|
81
|
+
});
|
|
82
|
+
|
|
52
83
|
test("users-core base users package templates stay aligned with non-workspace apps", async () => {
|
|
53
84
|
const packageDescriptorSource = await readFile(
|
|
54
85
|
path.join(PACKAGE_ROOT, "templates/packages/users/package.descriptor.mjs"),
|
|
@@ -181,4 +212,6 @@ test("users-core local users resource scaffold stays read-only and canonical", a
|
|
|
181
212
|
assert.equal(typeof resource, "object");
|
|
182
213
|
assert.deepEqual(Object.keys(resource.operations), ["list", "view"]);
|
|
183
214
|
assert.equal(Object.hasOwn(resource.operations, "create"), false);
|
|
215
|
+
assert.equal(resource.schema.updatedAt?.type, "dateTime");
|
|
216
|
+
assert.equal(resource.schema.updatedAt?.storage?.writeSerializer, "datetime-utc");
|
|
184
217
|
});
|