Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ REPORT_NOTIFY_URL=http://mock.com/

# Url for connecting to Redis
REDIS_URL=redis://localhost:6379

# Disable memoization in tests
MEMOIZATION_TTL=-1
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ module.exports = {
setupFiles: [ './jest.setup.js' ],

setupFilesAfterEnv: ['./jest.setup.redis-mock.js', './jest.setup.mongo-repl-set.js'],

globalTeardown: './jest.global-teardown.js',
};
2 changes: 1 addition & 1 deletion jest.global-teardown.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ module.exports = () => {
process.exit(0);
}, 1000);
}
}
};
112 changes: 106 additions & 6 deletions lib/memoize/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('memoize decorator — per-test inline classes', () => {
@memoize({ strategy: 'concat', ttl: 60_000, max: 50 })
public async run(a: number, b: string) {
this.calls += 1;

return `${a}-${b}`;
}
}
Expand All @@ -37,7 +38,7 @@ describe('memoize decorator — per-test inline classes', () => {
*/
expect(await sample.run(1, 'x')).toBe('1-x');
/**
* In this case
* In this case
*/
expect(await sample.run(1, 'x')).toBe('1-x');
expect(await sample.run(1, 'x')).toBe('1-x');
Expand All @@ -52,6 +53,7 @@ describe('memoize decorator — per-test inline classes', () => {
@memoize({ strategy: 'concat' })
public async run(a: unknown, b: unknown) {
this.calls += 1;

return `${String(a)}|${String(b)}`;
}
}
Expand Down Expand Up @@ -84,9 +86,11 @@ describe('memoize decorator — per-test inline classes', () => {
it('should memoize return value for stringified objects across several calls', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'concat' })
public async run(x: unknown, y: unknown) {
this.calls += 1;

return 'ok';
}
}
Expand All @@ -103,9 +107,11 @@ describe('memoize decorator — per-test inline classes', () => {
it('should memoize return value for method with non-default arguments (NaN, Infinity, -0, Symbol, Date, RegExp) still cache same-args', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'concat' })
public async run(...args: unknown[]) {
this.calls += 1;

return args.map(String).join(',');
}
}
Expand All @@ -127,27 +133,31 @@ describe('memoize decorator — per-test inline classes', () => {

class Sample {
public calls = 0;

@memoize({ strategy: 'hash' })
public async run(...args: unknown[]) {
this.calls += 1;

return 'ok';
}
}
const sample = new Sample();

await sample.run({a: 1}, undefined, 0);
await sample.run({a: 1}, undefined, 0);
await sample.run({ a: 1 }, undefined, 0);
await sample.run({ a: 1 }, undefined, 0);

expect(hashSpy).toHaveBeenCalledWith([{a: 1}, undefined, 0], 'blake2b512', 'base64url');
expect(hashSpy).toHaveBeenCalledWith([ { a: 1 }, undefined, 0], 'blake2b512', 'base64url');
expect(sample.calls).toBe(1);
});

it('should not memoize return value with hash strategy and different arguments', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'hash' })
public async run(...args: unknown[]) {
this.calls += 1;

return 'ok';
}
}
Expand All @@ -163,9 +173,11 @@ describe('memoize decorator — per-test inline classes', () => {
it('should memoize return value with hash strategy across several calls with same args', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'hash' })
public async run(arg: unknown) {
this.calls += 1;

return 'ok';
}
}
Expand All @@ -186,9 +198,11 @@ describe('memoize decorator — per-test inline classes', () => {

class Sample {
public calls = 0;

@memoizeWithMockedTimers({ strategy: 'concat', ttl: 1_000 })
public async run(x: string) {
this.calls += 1;

return x;
}
}
Expand All @@ -204,16 +218,19 @@ describe('memoize decorator — per-test inline classes', () => {

await sample.run('k1');
expect(sample.calls).toBe(2);

});

it('error calls should never be momized', async () => {
class Sample {
public calls = 0;

@memoize()
public async run(x: number) {
this.calls += 1;
if (x === 1) throw new Error('boom');
if (x === 1) {
throw new Error('boom');
}

return x * 2;
}
}
Expand All @@ -226,4 +243,87 @@ describe('memoize decorator — per-test inline classes', () => {
await expect(sample.run(1)).rejects.toThrow('boom');
expect(sample.calls).toBe(2);
});

it('should NOT cache results listed in skipCache (primitives)', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'concat', skipCache: [null, undefined, 0, false, ''] })
public async run(kind: 'null' | 'undef' | 'zero' | 'false' | 'empty') {
this.calls += 1;
switch (kind) {
case 'null': return null;
case 'undef': return undefined;
case 'zero': return 0;
case 'false': return false;
case 'empty': return '';
}
}
}

const sample = new Sample();

// Each repeated call should invoke the original again because result is in skipCache.
await sample.run('null');
await sample.run('null');

await sample.run('undef');
await sample.run('undef');

await sample.run('zero');
await sample.run('zero');

await sample.run('false');
await sample.run('false');

await sample.run('empty');
await sample.run('empty');

// 5 kinds × 2 calls each = 10 calls, none cached
expect(sample.calls).toBe(10);
});

it('should cache results NOT listed in skipCache', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'concat', skipCache: [null, undefined] })
public async run(x: number) {
this.calls += 1;
// returns a non-skipped primitive
return x * 2;
}
}

const sample = new Sample();

expect(await sample.run(21)).toBe(42);
expect(await sample.run(21)).toBe(42);

expect(sample.calls).toBe(1);
});

it('should use equality for skipCache with objects: deep equal objects are cached', async () => {
const deepEqualObject = { a: 1 };

class Sample {
public calls = 0;

@memoize({ strategy: 'concat', skipCache: [deepEqualObject] })
public async run() {
this.calls += 1;

return { a: 1 };
}
}

const sample = new Sample();

const first = await sample.run();
const second = await sample.run();

expect(first).toEqual({ a: 1 });
expect(second).toBe(first);
expect(sample.calls).toBe(1);
});
});
10 changes: 9 additions & 1 deletion lib/memoize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface MemoizeOptions {
* Strategy for key generation
*/
strategy?: MemoizeKeyStrategy;

/**
* It allows to skip caching for list of return values specified
*/
skipCache?: any[]
}

/**
Expand All @@ -40,6 +45,7 @@ export function memoize(options: MemoizeOptions = {}): MethodDecorator {
max = 50,
ttl = 1000 * 60 * 30,
strategy = 'concat',
skipCache = []
} = options;
/* eslint-enable */

Expand Down Expand Up @@ -84,7 +90,9 @@ export function memoize(options: MemoizeOptions = {}): MethodDecorator {
try {
const result = await originalMethod.apply(this, args);

cache.set(key, result);
if (!skipCache.includes(result)) {
cache.set(key, result);
}

return result;
} catch (err) {
Expand Down
94 changes: 94 additions & 0 deletions migrations/20251104000000-add-timestamp-index-to-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* This migration creates indexes for all collections events:projectId on timestamp field
*/

/**
* Index name for timestamp field
*/
const timestampIndexName = 'timestamp';

module.exports = {
async up(db) {
const collections = await db.listCollections({}, {
authorizedCollections: true,
nameOnly: true,
}).toArray();

const targetCollections = [];

collections.forEach((collection) => {
if (/events:/.test(collection.name)) {
targetCollections.push(collection.name);
}
});

console.log(`${targetCollections.length} events collections will be updated.`);

let currentCollectionNumber = 1;

for (const collectionName of targetCollections) {
console.log(`${collectionName} in process.`);
console.log(`${currentCollectionNumber} of ${targetCollections.length} in process.`);

try {
const hasIndexAlready = await db.collection(collectionName).indexExists(timestampIndexName);

if (!hasIndexAlready) {
await db.collection(collectionName).createIndex({
timestamp: 1,
}, {
name: timestampIndexName,
sparse: true,
background: true,
});
console.log(`Index ${timestampIndexName} created for ${collectionName}`);
} else {
console.log(`Index ${timestampIndexName} already exists for ${collectionName}`);
}
} catch (error) {
console.error(`Error adding index to ${collectionName}:`, error);
}

currentCollectionNumber++;
}
},

async down(db) {
const collections = await db.listCollections({}, {
authorizedCollections: true,
nameOnly: true,
}).toArray();

const targetCollections = [];

collections.forEach((collection) => {
if (/events:/.test(collection.name)) {
targetCollections.push(collection.name);
}
});

console.log(`${targetCollections.length} events collections will be updated.`);

let currentCollectionNumber = 1;

for (const collectionName of targetCollections) {
console.log(`${collectionName} in process.`);
console.log(`${currentCollectionNumber} of ${targetCollections.length} in process.`);

try {
const hasIndexAlready = await db.collection(collectionName).indexExists(timestampIndexName);

if (hasIndexAlready) {
await db.collection(collectionName).dropIndex(timestampIndexName);
console.log(`Index ${timestampIndexName} dropped for ${collectionName}`);
} else {
console.log(`Index ${timestampIndexName} does not exist for ${collectionName}, skipping drop.`);
}
} catch (error) {
console.error(`Error dropping index from ${collectionName}:`, error);
}

currentCollectionNumber++;
}
}
};
Loading
Loading