1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-24 17:51:14 +02:00

feat: add users updated read model (#10693)

## About the changes
This adds a user read model using the updated_at new field in the users
table.
This commit is contained in:
Gastón Fournier 2025-09-24 17:39:03 +02:00 committed by GitHub
parent 413847374e
commit e0cdf6addd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 238 additions and 1 deletions

View File

@ -68,6 +68,7 @@ import { UniqueConnectionReadModel } from '../features/unique-connection/unique-
import { FeatureLinkStore } from '../features/feature-links/feature-link-store.js';
import { UnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store.js';
import { FeatureLinksReadModel } from '../features/feature-links/feature-links-read-model.js';
import { UserUpdatesReadModel } from '../features/users/user-updates-read-model.js';
export const createStores = (
config: IUnleashConfig,
@ -106,6 +107,7 @@ export const createStores = (
),
settingStore: new SettingStore(db, getLogger),
userStore: new UserStore(db, getLogger),
userUpdatesReadModel: new UserUpdatesReadModel(db, getLogger),
accountStore: new AccountStore(db, getLogger),
projectStore: new ProjectStore(db, eventBus, config),
tagStore: new TagStore(db, eventBus, getLogger),

View File

@ -12,9 +12,10 @@ import type { Db } from '../../db/db.js';
import type { Knex } from 'knex';
const TABLE = 'users';
export const USERS_TABLE = TABLE;
const PASSWORD_HASH_TABLE = 'used_passwords';
const USER_COLUMNS_PUBLIC = [
export const USER_COLUMNS_PUBLIC = [
'id',
'name',
'username',

View File

@ -0,0 +1,74 @@
import type { Logger, LogProvider } from '../../logger.js';
import type { Db } from '../../db/db.js';
import { USER_COLUMNS_PUBLIC, USERS_TABLE } from './user-store.js';
import type { Row } from '../../db/crud/row-type.js';
type UpdatedUser = {
id: number;
name?: string;
username?: string;
email?: string;
imageUrl?: string;
seenAt?: Date;
createdAt?: Date;
updatedAt?: Date | null;
deletedAt?: Date | null;
};
const toResponse = (row: Row<UpdatedUser>): UpdatedUser => {
return {
id: row.id,
name: row.name,
username: row.username,
email: row.email,
imageUrl: row.image_url,
seenAt: row.seen_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
};
};
export class UserUpdatesReadModel {
private db: Db;
private logger: Logger;
constructor(db: Db, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('user-updates-read-model.ts');
}
async getLastUpdatedAt(): Promise<Date | null> {
const result = await this.db(USERS_TABLE)
.where({
// also consider deleted users (different than activeUsers query)
is_system: false,
is_service: false,
})
.max('updated_at as last_updated_at')
.first();
return result ? result.last_updated_at : null;
}
async getUsersUpdatedAfter(
date: Date,
limit: number = 100,
): Promise<UpdatedUser[]> {
const result = await this.db(USERS_TABLE)
.where({
// also consider deleted users (different than activeUsers query)
is_system: false,
is_service: false,
})
.where('updated_at', '>', date)
.orderBy('updated_at', 'asc')
.select([
...USER_COLUMNS_PUBLIC,
'created_at',
'updated_at',
'deleted_at',
])
.limit(limit);
return result.map(toResponse);
}
}

View File

@ -62,6 +62,7 @@ import { ReleasePlanMilestoneStrategyStore } from '../features/release-plans/rel
import type { IFeatureLinkStore } from '../features/feature-links/feature-link-store-type.js';
import type { IUnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store.js';
import type { IFeatureLinksReadModel } from '../features/feature-links/feature-links-read-model-type.js';
import type { UserUpdatesReadModel } from '../features/users/user-updates-read-model.js';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -90,6 +91,7 @@ export interface IUnleashStores {
tagTypeStore: ITagTypeStore;
userFeedbackStore: IUserFeedbackStore;
userStore: IUserStore;
userUpdatesReadModel: UserUpdatesReadModel;
userSplashStore: IUserSplashStore;
roleStore: IRoleStore;
segmentStore: ISegmentStore;

View File

@ -0,0 +1,46 @@
exports.up = (db, callback) => {
db.runSql(
`
ALTER TABLE users ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
-- Backfill existing rows using max(created_at, deleted_at)
UPDATE users
SET updated_at = COALESCE(
GREATEST(created_at, deleted_at),
created_at,
now()
);
CREATE OR REPLACE FUNCTION set_users_updated_at()
RETURNS trigger AS $unleash_user_updated_at_fn$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$unleash_user_updated_at_fn$ LANGUAGE plpgsql;
CREATE TRIGGER trg_set_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION set_users_updated_at();
-- create an index only for non-system and non-service users
CREATE INDEX idx_users_only_updated_at_desc ON users (updated_at DESC)
WHERE is_system = false AND is_service = false;
`,
callback,
);
};
exports.down = (db, callback) => {
db.runSql(
`
DROP INDEX IF EXISTS idx_users_only_updated_at_desc;
DROP TRIGGER IF EXISTS trg_set_users_updated_at ON users;
DROP FUNCTION IF EXISTS set_users_updated_at();
ALTER TABLE users DROP COLUMN IF EXISTS updated_at;
`,
callback,
);
};

View File

@ -0,0 +1,110 @@
import dbInit, { type ITestDb } from '../helpers/database-init.js';
import type { IUnleashStores } from '../../../lib/types/index.js';
import { beforeAll, test, expect } from 'vitest';
let stores: IUnleashStores;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit();
stores = db.stores;
});
beforeEach(async () => {
await stores.userStore.deleteAll();
});
test('should have no users', async () => {
const readModel = stores.userUpdatesReadModel;
const lastUpdatedAt = await readModel.getLastUpdatedAt();
expect(lastUpdatedAt).toBeNull();
const users = await readModel.getUsersUpdatedAfter(new Date(0));
expect(users).toEqual([]);
});
test('Adding a user should return that user', async () => {
const readModel = stores.userUpdatesReadModel;
const userStore = stores.userStore;
const beforeInsert = new Date(Date.now() - 1000);
await userStore.upsert({ email: 'test@example.com' });
const lastUpdatedAt = await readModel.getLastUpdatedAt();
expect(lastUpdatedAt).toBeDefined();
expect(lastUpdatedAt).toBeInstanceOf(Date);
// check that it's recent
expect(lastUpdatedAt!.getTime()).toBeGreaterThanOrEqual(
beforeInsert.getTime(),
);
const users = await readModel.getUsersUpdatedAfter(beforeInsert);
expect(users).toHaveLength(1);
expect(users[0].email).toBe('test@example.com');
expect(users[0].createdAt).toBeInstanceOf(Date);
expect(users[0].updatedAt).toBeInstanceOf(Date);
expect(users[0].deletedAt).toBeNull();
});
test('Modifying a user should return that user', async () => {
const readModel = stores.userUpdatesReadModel;
const userStore = stores.userStore;
const inserted = await userStore.upsert({
email: 'test@example.com',
});
const afterInsert = new Date();
const lastUpdatedAt = await readModel.getLastUpdatedAt();
expect(lastUpdatedAt).toBeDefined();
expect(lastUpdatedAt).toBeInstanceOf(Date);
const users = await readModel.getUsersUpdatedAfter(afterInsert);
expect(users).toHaveLength(0);
await userStore.update(inserted.id, { name: 'New Name' });
const lastUpdatedAt2 = await readModel.getLastUpdatedAt();
expect(lastUpdatedAt2).toBeDefined();
expect(lastUpdatedAt2).toBeInstanceOf(Date);
expect(lastUpdatedAt2!.getTime()).toBeGreaterThanOrEqual(
lastUpdatedAt!.getTime(),
);
const users2 = await readModel.getUsersUpdatedAfter(afterInsert);
expect(users2).toHaveLength(1);
expect(users2[0].email).toBe('test@example.com');
expect(users2[0].name).toBe('New Name');
expect(users2[0].createdAt).toBeInstanceOf(Date);
expect(users2[0].updatedAt).toBeInstanceOf(Date);
expect(users2[0].deletedAt).toBeNull();
});
test('Deleting a user should return that user', async () => {
const readModel = stores.userUpdatesReadModel;
const userStore = stores.userStore;
const inserted = await userStore.upsert({
email: 'test@example.com',
});
const afterInsert = new Date();
const lastUpdatedAt = await readModel.getLastUpdatedAt();
expect(lastUpdatedAt).toBeDefined();
expect(lastUpdatedAt).toBeInstanceOf(Date);
const users = await readModel.getUsersUpdatedAfter(afterInsert);
expect(users).toHaveLength(0);
await userStore.delete(inserted.id);
const lastUpdatedAt2 = await readModel.getLastUpdatedAt();
expect(lastUpdatedAt2).toBeDefined();
expect(lastUpdatedAt2).toBeInstanceOf(Date);
expect(lastUpdatedAt2!.getTime()).toBeGreaterThanOrEqual(
lastUpdatedAt!.getTime(),
);
const users2 = await readModel.getUsersUpdatedAfter(afterInsert);
expect(users2).toHaveLength(1);
expect(users2[0].email).toBeNull(); // currently we nullify the email but this might change in the future
expect(users2[0].createdAt).toBeInstanceOf(Date);
expect(users2[0].updatedAt).toBeInstanceOf(Date);
expect(users2[0].deletedAt).toBeInstanceOf(Date);
});

View File

@ -65,6 +65,7 @@ import { UniqueConnectionReadModel } from '../../lib/features/unique-connection/
import FakeFeatureLinkStore from '../../lib/features/feature-links/fake-feature-link-store.js';
import { FakeFeatureLinksReadModel } from '../../lib/features/feature-links/fake-feature-links-read-model.js';
import { FakeUnknownFlagsStore } from '../../lib/features/metrics/unknown-flags/fake-unknown-flags-store.js';
import type { UserUpdatesReadModel } from '../../lib/features/users/user-updates-read-model.js';
const db = {
select: () => ({
@ -92,6 +93,7 @@ const createStores: () => IUnleashStores = () => {
addonStore: new FakeAddonStore(),
projectStore: new FakeProjectStore(),
userStore: new FakeUserStore(),
userUpdatesReadModel: {} as UserUpdatesReadModel,
accessStore: new FakeAccessStore(),
accountStore: new FakeAccountStore(),
userFeedbackStore: new FakeUserFeedbackStore(),