@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.
@@ -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.115",
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.104",
150
- "@jskit-ai/crud-core": "0.1.113",
151
- "@jskit-ai/database-runtime": "0.1.105",
152
- "@jskit-ai/http-runtime": "0.1.104",
153
- "@jskit-ai/json-rest-api-core": "0.1.50",
154
- "@jskit-ai/kernel": "0.1.106",
155
- "@jskit-ai/resource-core": "0.1.50",
156
- "@jskit-ai/resource-crud-core": "0.1.50",
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.83"
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.115",
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.104",
16
- "@jskit-ai/database-runtime": "0.1.105",
17
- "@jskit-ai/http-runtime": "0.1.104",
18
- "@jskit-ai/json-rest-api-core": "0.1.50",
19
- "@jskit-ai/kernel": "0.1.106",
20
- "@jskit-ai/resource-crud-core": "0.1.50",
21
- "@jskit-ai/resource-core": "0.1.50",
22
- "@jskit-ai/uploads-runtime": "0.1.83",
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
  });