github-issue-tower-defence-management 1.107.0 → 1.109.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +14 -1
  3. package/bin/adapter/entry-points/cli/index.js +4 -4
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/dashboardComposeService.js +1 -0
  6. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +1 -1
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -1
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +2 -0
  12. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
  13. package/bin/adapter/repositories/GraphqlProjectRepository.js +98 -4
  14. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  15. package/bin/adapter/repositories/LocalStorageCacheRepository.js +5 -1
  16. package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -1
  17. package/bin/adapter/repositories/LocalStorageRepository.js +3 -0
  18. package/bin/adapter/repositories/LocalStorageRepository.js.map +1 -1
  19. package/bin/adapter/repositories/ProcHostMetricsRepository.js +25 -2
  20. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
  21. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +12 -6
  22. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/cli/index.ts +4 -0
  25. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +8 -1
  26. package/src/adapter/entry-points/console/dashboardComposeService.ts +1 -0
  27. package/src/adapter/entry-points/console/webServer.test.ts +4 -1
  28. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +6 -1
  29. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +4 -1
  30. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -1
  31. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +1 -0
  32. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +14 -2
  33. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +3 -0
  34. package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +354 -0
  35. package/src/adapter/repositories/GraphqlProjectRepository.ts +139 -1
  36. package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +35 -2
  37. package/src/adapter/repositories/LocalStorageCacheRepository.ts +5 -4
  38. package/src/adapter/repositories/LocalStorageRepository.test.ts +12 -0
  39. package/src/adapter/repositories/LocalStorageRepository.ts +3 -0
  40. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +26 -0
  41. package/src/adapter/repositories/ProcHostMetricsRepository.ts +36 -0
  42. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +44 -17
  43. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +13 -5
  44. package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -1
  45. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  46. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  47. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
  48. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
  49. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +10 -0
  50. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  51. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -1
  52. package/types/adapter/repositories/LocalStorageRepository.d.ts +1 -0
  53. package/types/adapter/repositories/LocalStorageRepository.d.ts.map +1 -1
  54. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +10 -1
  55. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
  56. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +2 -1
  57. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -1
@@ -0,0 +1,354 @@
1
+ const mockPost = jest.fn();
2
+
3
+ jest.mock('ky', () => ({
4
+ default: {
5
+ post: mockPost,
6
+ get: jest.fn(),
7
+ put: jest.fn(),
8
+ patch: jest.fn(),
9
+ delete: jest.fn(),
10
+ extend: jest.fn(),
11
+ create: jest.fn(),
12
+ stop: jest.fn(),
13
+ },
14
+ __esModule: true,
15
+ }));
16
+
17
+ import {
18
+ GraphqlProjectRepository,
19
+ resolveProjectCacheTtlMs,
20
+ } from './GraphqlProjectRepository';
21
+ import { LocalStorageCacheRepository } from './LocalStorageCacheRepository';
22
+ import { LocalStorageRepository } from './LocalStorageRepository';
23
+ import { Project } from '../../domain/entities/Project';
24
+
25
+ const mockJsonResponse = <T>(data: T) => ({
26
+ json: jest.fn().mockResolvedValue(data),
27
+ });
28
+
29
+ const projectId = 'PVT_project123';
30
+
31
+ const getProjectResponse = {
32
+ data: {
33
+ node: {
34
+ id: projectId,
35
+ databaseId: 1,
36
+ title: 'A project',
37
+ shortDescription: '',
38
+ public: true,
39
+ closed: false,
40
+ createdAt: '2024-01-01T00:00:00.000Z',
41
+ updatedAt: '2024-01-01T00:00:00.000Z',
42
+ number: 49,
43
+ url: 'https://github.com/users/owner/projects/49',
44
+ fields: {
45
+ nodes: [
46
+ {
47
+ id: 'PVTSSF_status',
48
+ databaseId: 10,
49
+ name: 'Status',
50
+ dataType: 'SINGLE_SELECT',
51
+ options: [
52
+ { id: 'st1', name: 'Todo', description: '', color: 'GRAY' },
53
+ ],
54
+ },
55
+ ],
56
+ },
57
+ },
58
+ },
59
+ };
60
+
61
+ const expectedProject: Project = {
62
+ id: projectId,
63
+ url: 'https://github.com/users/owner/projects/49',
64
+ databaseId: 1,
65
+ name: 'A project',
66
+ status: {
67
+ name: 'Status',
68
+ fieldId: 'PVTSSF_status',
69
+ statuses: [{ id: 'st1', name: 'Todo', color: 'GRAY', description: '' }],
70
+ },
71
+ nextActionDate: null,
72
+ nextActionHour: null,
73
+ story: null,
74
+ remainingEstimationMinutes: null,
75
+ dependedIssueUrlSeparatedByComma: null,
76
+ completionDate50PercentConfidence: null,
77
+ };
78
+
79
+ const fetchProjectIdResponse = {
80
+ data: {
81
+ organization: null,
82
+ user: { projectV2: { id: projectId, databaseId: 1 } },
83
+ },
84
+ };
85
+
86
+ type CacheEntry = { value: object; timestamp: Date } | null;
87
+
88
+ const buildCacheStub = (overrides?: {
89
+ getLatest?: jest.Mock;
90
+ set?: jest.Mock;
91
+ }): Pick<LocalStorageCacheRepository, 'getLatest' | 'set'> & {
92
+ getLatest: jest.Mock;
93
+ set: jest.Mock;
94
+ } => ({
95
+ getLatest: overrides?.getLatest ?? jest.fn().mockResolvedValue(null),
96
+ set: overrides?.set ?? jest.fn().mockResolvedValue(undefined),
97
+ });
98
+
99
+ describe('GraphqlProjectRepository disk cache', () => {
100
+ const localStorageRepository = new LocalStorageRepository();
101
+ const thirtyMinutesMs = 30 * 60 * 1000;
102
+
103
+ beforeEach(() => {
104
+ jest.useFakeTimers();
105
+ mockPost.mockReset();
106
+ });
107
+
108
+ afterEach(() => {
109
+ jest.useRealTimers();
110
+ });
111
+
112
+ describe('resolveProjectCacheTtlMs', () => {
113
+ it('defaults to 30 minutes when unset', () => {
114
+ expect(resolveProjectCacheTtlMs(undefined)).toBe(thirtyMinutesMs);
115
+ });
116
+
117
+ it('uses the configured millisecond value when valid', () => {
118
+ expect(resolveProjectCacheTtlMs('60000')).toBe(60000);
119
+ });
120
+
121
+ it('falls back to the default for non-numeric or negative values', () => {
122
+ expect(resolveProjectCacheTtlMs('abc')).toBe(thirtyMinutesMs);
123
+ expect(resolveProjectCacheTtlMs('-1')).toBe(thirtyMinutesMs);
124
+ });
125
+ });
126
+
127
+ describe('getProject', () => {
128
+ it('fetches via GraphQL and writes the cache on a cache miss', async () => {
129
+ const cache = buildCacheStub();
130
+ mockPost.mockReturnValueOnce(mockJsonResponse(getProjectResponse));
131
+ const repository = new GraphqlProjectRepository(
132
+ localStorageRepository,
133
+ 'dummy',
134
+ cache,
135
+ );
136
+
137
+ const project = await repository.getProject(projectId);
138
+
139
+ expect(project).toEqual(expectedProject);
140
+ expect(mockPost).toHaveBeenCalledTimes(1);
141
+ expect(cache.set).toHaveBeenCalledWith(
142
+ `project-${projectId}`,
143
+ expectedProject,
144
+ );
145
+ });
146
+
147
+ it('returns the cached project without any GraphQL call when the cache is fresh', async () => {
148
+ const cache = buildCacheStub({
149
+ getLatest: jest.fn().mockResolvedValue({
150
+ value: expectedProject,
151
+ timestamp: new Date(),
152
+ } satisfies CacheEntry),
153
+ });
154
+ const repository = new GraphqlProjectRepository(
155
+ localStorageRepository,
156
+ 'dummy',
157
+ cache,
158
+ );
159
+
160
+ const project = await repository.getProject(projectId);
161
+
162
+ expect(project).toEqual(expectedProject);
163
+ expect(mockPost).not.toHaveBeenCalled();
164
+ expect(cache.set).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('refetches via GraphQL when the cached entry is older than the TTL', async () => {
168
+ const staleTimestamp = new Date(Date.now() - thirtyMinutesMs - 1);
169
+ const cache = buildCacheStub({
170
+ getLatest: jest.fn().mockResolvedValue({
171
+ value: expectedProject,
172
+ timestamp: staleTimestamp,
173
+ } satisfies CacheEntry),
174
+ });
175
+ mockPost.mockReturnValueOnce(mockJsonResponse(getProjectResponse));
176
+ const repository = new GraphqlProjectRepository(
177
+ localStorageRepository,
178
+ 'dummy',
179
+ cache,
180
+ );
181
+
182
+ const project = await repository.getProject(projectId);
183
+
184
+ expect(project).toEqual(expectedProject);
185
+ expect(mockPost).toHaveBeenCalledTimes(1);
186
+ expect(cache.set).toHaveBeenCalledTimes(1);
187
+ });
188
+
189
+ it('falls back to a live GraphQL fetch when the cache read throws', async () => {
190
+ const cache = buildCacheStub({
191
+ getLatest: jest.fn().mockRejectedValue(new Error('corrupted file')),
192
+ });
193
+ mockPost.mockReturnValueOnce(mockJsonResponse(getProjectResponse));
194
+ const repository = new GraphqlProjectRepository(
195
+ localStorageRepository,
196
+ 'dummy',
197
+ cache,
198
+ );
199
+
200
+ const project = await repository.getProject(projectId);
201
+
202
+ expect(project).toEqual(expectedProject);
203
+ expect(mockPost).toHaveBeenCalledTimes(1);
204
+ });
205
+
206
+ it('falls back to a live GraphQL fetch when the cached value is malformed', async () => {
207
+ const cache = buildCacheStub({
208
+ getLatest: jest.fn().mockResolvedValue({
209
+ value: { unexpected: 'shape' },
210
+ timestamp: new Date(),
211
+ } satisfies CacheEntry),
212
+ });
213
+ mockPost.mockReturnValueOnce(mockJsonResponse(getProjectResponse));
214
+ const repository = new GraphqlProjectRepository(
215
+ localStorageRepository,
216
+ 'dummy',
217
+ cache,
218
+ );
219
+
220
+ const project = await repository.getProject(projectId);
221
+
222
+ expect(project).toEqual(expectedProject);
223
+ expect(mockPost).toHaveBeenCalledTimes(1);
224
+ });
225
+
226
+ it('respects a custom TTL passed to the constructor', async () => {
227
+ const cache = buildCacheStub({
228
+ getLatest: jest.fn().mockResolvedValue({
229
+ value: expectedProject,
230
+ timestamp: new Date(Date.now() - 2000),
231
+ } satisfies CacheEntry),
232
+ });
233
+ mockPost.mockReturnValueOnce(mockJsonResponse(getProjectResponse));
234
+ const repository = new GraphqlProjectRepository(
235
+ localStorageRepository,
236
+ 'dummy',
237
+ cache,
238
+ 1000,
239
+ );
240
+
241
+ const project = await repository.getProject(projectId);
242
+
243
+ expect(project).toEqual(expectedProject);
244
+ expect(mockPost).toHaveBeenCalledTimes(1);
245
+ });
246
+ });
247
+
248
+ describe('fetchProjectId', () => {
249
+ it('fetches via GraphQL and writes the cache on a cache miss', async () => {
250
+ const cache = buildCacheStub();
251
+ mockPost.mockReturnValueOnce(mockJsonResponse(fetchProjectIdResponse));
252
+ const repository = new GraphqlProjectRepository(
253
+ localStorageRepository,
254
+ 'dummy',
255
+ cache,
256
+ );
257
+
258
+ const result = await repository.fetchProjectId('owner', 49);
259
+
260
+ expect(result).toBe(projectId);
261
+ expect(mockPost).toHaveBeenCalledTimes(1);
262
+ expect(cache.set).toHaveBeenCalledWith('projectId-owner:49', {
263
+ projectId,
264
+ });
265
+ });
266
+
267
+ it('returns the disk-cached project ID without any GraphQL call', async () => {
268
+ const cache = buildCacheStub({
269
+ getLatest: jest.fn().mockResolvedValue({
270
+ value: { projectId },
271
+ timestamp: new Date(),
272
+ } satisfies CacheEntry),
273
+ });
274
+ const repository = new GraphqlProjectRepository(
275
+ localStorageRepository,
276
+ 'dummy',
277
+ cache,
278
+ );
279
+
280
+ const result = await repository.fetchProjectId('owner', 49);
281
+
282
+ expect(result).toBe(projectId);
283
+ expect(mockPost).not.toHaveBeenCalled();
284
+ expect(cache.set).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it('does not apply a TTL to the project ID cache because the mapping is static', async () => {
288
+ const cache = buildCacheStub({
289
+ getLatest: jest.fn().mockResolvedValue({
290
+ value: { projectId },
291
+ timestamp: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
292
+ } satisfies CacheEntry),
293
+ });
294
+ const repository = new GraphqlProjectRepository(
295
+ localStorageRepository,
296
+ 'dummy',
297
+ cache,
298
+ );
299
+
300
+ const result = await repository.fetchProjectId('owner', 49);
301
+
302
+ expect(result).toBe(projectId);
303
+ expect(mockPost).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it('falls back to a live GraphQL fetch when the project ID cache read throws', async () => {
307
+ const cache = buildCacheStub({
308
+ getLatest: jest.fn().mockRejectedValue(new Error('corrupted file')),
309
+ });
310
+ mockPost.mockReturnValueOnce(mockJsonResponse(fetchProjectIdResponse));
311
+ const repository = new GraphqlProjectRepository(
312
+ localStorageRepository,
313
+ 'dummy',
314
+ cache,
315
+ );
316
+
317
+ const result = await repository.fetchProjectId('owner', 49);
318
+
319
+ expect(result).toBe(projectId);
320
+ expect(mockPost).toHaveBeenCalledTimes(1);
321
+ });
322
+
323
+ it('serves the second call from the in-memory L1 cache without a disk read', async () => {
324
+ const cache = buildCacheStub();
325
+ mockPost.mockReturnValueOnce(mockJsonResponse(fetchProjectIdResponse));
326
+ const repository = new GraphqlProjectRepository(
327
+ localStorageRepository,
328
+ 'dummy',
329
+ cache,
330
+ );
331
+
332
+ await repository.fetchProjectId('owner', 49);
333
+ await repository.fetchProjectId('owner', 49);
334
+
335
+ expect(mockPost).toHaveBeenCalledTimes(1);
336
+ expect(cache.getLatest).toHaveBeenCalledTimes(1);
337
+ });
338
+ });
339
+
340
+ describe('without a cache repository', () => {
341
+ it('still works using the in-memory cache only', async () => {
342
+ mockPost.mockReturnValueOnce(mockJsonResponse(fetchProjectIdResponse));
343
+ const repository = new GraphqlProjectRepository(
344
+ localStorageRepository,
345
+ 'dummy',
346
+ );
347
+
348
+ const result = await repository.fetchProjectId('owner', 49);
349
+
350
+ expect(result).toBe(projectId);
351
+ expect(mockPost).toHaveBeenCalledTimes(1);
352
+ });
353
+ });
354
+ });
@@ -1,11 +1,32 @@
1
1
  import ky from 'ky';
2
+ import typia from 'typia';
2
3
  import { BaseGitHubRepository } from './BaseGitHubRepository';
4
+ import { LocalStorageCacheRepository } from './LocalStorageCacheRepository';
5
+ import { LocalStorageRepository } from './LocalStorageRepository';
3
6
  import { ProjectRepository } from '../../domain/usecases/adapter-interfaces/ProjectRepository';
4
7
  import { FieldOption, Project } from '../../domain/entities/Project';
5
8
  import { normalizeFieldName } from './utils';
6
9
 
7
10
  const ONE_HOUR_MS = 60 * 60 * 1000;
8
11
 
12
+ const DEFAULT_PROJECT_CACHE_TTL_MS = 30 * 60 * 1000;
13
+
14
+ export const resolveProjectCacheTtlMs = (
15
+ rawValue: string | undefined,
16
+ ): number => {
17
+ if (rawValue === undefined) {
18
+ return DEFAULT_PROJECT_CACHE_TTL_MS;
19
+ }
20
+ const parsed = Number(rawValue);
21
+ if (!Number.isFinite(parsed) || parsed < 0) {
22
+ return DEFAULT_PROJECT_CACHE_TTL_MS;
23
+ }
24
+ return parsed;
25
+ };
26
+
27
+ const PROJECT_ID_DISK_CACHE_KEY_PREFIX = 'projectId';
28
+ const PROJECT_DISK_CACHE_KEY_PREFIX = 'project';
29
+
9
30
  export const convertToFieldOptionColor = (
10
31
  color: string,
11
32
  ): FieldOption['color'] => {
@@ -38,6 +59,111 @@ export class GraphqlProjectRepository
38
59
  {
39
60
  private readonly projectIdCache = new Map<string, string>();
40
61
  private readonly fetchProjectIdFailedAt = new Map<string, number>();
62
+ private readonly projectCache?: Pick<
63
+ LocalStorageCacheRepository,
64
+ 'getLatest' | 'set'
65
+ >;
66
+ private readonly projectCacheTtlMs: number;
67
+
68
+ constructor(
69
+ localStorageRepository: LocalStorageRepository,
70
+ ghToken: string = process.env.GH_TOKEN || 'dummy',
71
+ projectCache?: Pick<LocalStorageCacheRepository, 'getLatest' | 'set'>,
72
+ projectCacheTtlMs: number = resolveProjectCacheTtlMs(
73
+ process.env.TDPM_PROJECT_CACHE_TTL_MS,
74
+ ),
75
+ ) {
76
+ super(localStorageRepository, ghToken);
77
+ this.projectCache = projectCache;
78
+ this.projectCacheTtlMs = projectCacheTtlMs;
79
+ }
80
+
81
+ private readProjectIdFromDiskCache = async (
82
+ cacheKey: string,
83
+ ): Promise<string | null> => {
84
+ if (!this.projectCache) {
85
+ return null;
86
+ }
87
+ let cache: { value: object; timestamp: Date } | null;
88
+ try {
89
+ cache = await this.projectCache.getLatest(
90
+ `${PROJECT_ID_DISK_CACHE_KEY_PREFIX}-${cacheKey}`,
91
+ );
92
+ } catch (error) {
93
+ return null;
94
+ }
95
+ if (!cache) {
96
+ return null;
97
+ }
98
+ if (
99
+ 'projectId' in cache.value &&
100
+ typeof cache.value.projectId === 'string'
101
+ ) {
102
+ return cache.value.projectId;
103
+ }
104
+ return null;
105
+ };
106
+
107
+ private writeProjectIdToDiskCache = async (
108
+ cacheKey: string,
109
+ projectId: string,
110
+ ): Promise<void> => {
111
+ if (!this.projectCache) {
112
+ return;
113
+ }
114
+ try {
115
+ await this.projectCache.set(
116
+ `${PROJECT_ID_DISK_CACHE_KEY_PREFIX}-${cacheKey}`,
117
+ { projectId },
118
+ );
119
+ } catch (error) {
120
+ return;
121
+ }
122
+ };
123
+
124
+ private readProjectFromDiskCache = async (
125
+ projectId: string,
126
+ ): Promise<Project | null> => {
127
+ if (!this.projectCache) {
128
+ return null;
129
+ }
130
+ let cache: { value: object; timestamp: Date } | null;
131
+ try {
132
+ cache = await this.projectCache.getLatest(
133
+ `${PROJECT_DISK_CACHE_KEY_PREFIX}-${projectId}`,
134
+ );
135
+ } catch (error) {
136
+ return null;
137
+ }
138
+ if (!cache) {
139
+ return null;
140
+ }
141
+ const age = Date.now() - cache.timestamp.getTime();
142
+ if (age >= this.projectCacheTtlMs) {
143
+ return null;
144
+ }
145
+ if (!typia.is<Project>(cache.value)) {
146
+ return null;
147
+ }
148
+ return cache.value;
149
+ };
150
+
151
+ private writeProjectToDiskCache = async (
152
+ projectId: string,
153
+ project: Project,
154
+ ): Promise<void> => {
155
+ if (!this.projectCache) {
156
+ return;
157
+ }
158
+ try {
159
+ await this.projectCache.set(
160
+ `${PROJECT_DISK_CACHE_KEY_PREFIX}-${projectId}`,
161
+ project,
162
+ );
163
+ } catch (error) {
164
+ return;
165
+ }
166
+ };
41
167
 
42
168
  extractProjectFromUrl = (
43
169
  projectUrl: string,
@@ -60,6 +186,11 @@ export class GraphqlProjectRepository
60
186
  if (cached) {
61
187
  return cached;
62
188
  }
189
+ const diskCached = await this.readProjectIdFromDiskCache(cacheKey);
190
+ if (diskCached) {
191
+ this.projectIdCache.set(cacheKey, diskCached);
192
+ return diskCached;
193
+ }
63
194
  const failedAt = this.fetchProjectIdFailedAt.get(cacheKey);
64
195
  if (failedAt !== undefined && Date.now() - failedAt < ONE_HOUR_MS) {
65
196
  throw new Error(
@@ -139,6 +270,7 @@ export class GraphqlProjectRepository
139
270
  );
140
271
  }
141
272
  this.projectIdCache.set(cacheKey, projectId);
273
+ await this.writeProjectIdToDiskCache(cacheKey, projectId);
142
274
  return projectId;
143
275
  };
144
276
  findProjectIdByUrl = async (
@@ -148,6 +280,10 @@ export class GraphqlProjectRepository
148
280
  return await this.fetchProjectId(owner, projectNumber);
149
281
  };
150
282
  getProject = async (projectId: Project['id']): Promise<Project | null> => {
283
+ const diskCached = await this.readProjectFromDiskCache(projectId);
284
+ if (diskCached) {
285
+ return diskCached;
286
+ }
151
287
  const query = `query GetProjectV2($projectId: ID!) {
152
288
  node(id: $projectId) {
153
289
  ... on ProjectV2 {
@@ -292,7 +428,7 @@ export class GraphqlProjectRepository
292
428
  const completionDate50PercentConfidence = project.fields.nodes.find(
293
429
  (field) => normalizeFieldName(field.name).startsWith('completiondate'),
294
430
  );
295
- return {
431
+ const result: Project = {
296
432
  id: project.id,
297
433
  url: project.url,
298
434
  databaseId: project.databaseId,
@@ -353,6 +489,8 @@ export class GraphqlProjectRepository
353
489
  }
354
490
  : null,
355
491
  };
492
+ await this.writeProjectToDiskCache(projectId, result);
493
+ return result;
356
494
  };
357
495
  getByUrl = async (url: string): Promise<Project> => {
358
496
  const projectId = await this.findProjectIdByUrl(url);
@@ -15,6 +15,7 @@ describe('LocalStorageCacheRepository', () => {
15
15
  listFiles: jest.fn(),
16
16
  read: jest.fn(),
17
17
  write: jest.fn(),
18
+ rename: jest.fn(),
18
19
  mkdir: jest.fn(),
19
20
  remove: jest.fn(),
20
21
  };
@@ -69,6 +70,19 @@ describe('LocalStorageCacheRepository', () => {
69
70
  timestamp: new Date('2024-01-01T00:00:00.000Z'),
70
71
  },
71
72
  },
73
+ {
74
+ name: 'ignores in-progress temp files and reads the latest committed file',
75
+ key: 'test-key',
76
+ files: [
77
+ '2024-01-01T00:00:00.000Z.json',
78
+ '2024-01-01T00:00:01.000Z.json.1234.abc.tmp',
79
+ ],
80
+ fileContent: '{"test": "value"}',
81
+ expected: {
82
+ value: { test: 'value' },
83
+ timestamp: new Date('2024-01-01T00:00:00.000Z'),
84
+ },
85
+ },
72
86
  ];
73
87
 
74
88
  test.each(testCases)(
@@ -137,11 +151,30 @@ describe('LocalStorageCacheRepository', () => {
137
151
  expect(localStorageRepository.mkdir).toHaveBeenCalledWith(
138
152
  expectedDirPath,
139
153
  );
140
- expect(localStorageRepository.write).toHaveBeenCalledWith(
154
+ const writeArgs = localStorageRepository.write.mock.calls[0];
155
+ const writtenPath = writeArgs[0];
156
+ expect(writtenPath.startsWith(`${expectedFilePath}.`)).toBe(true);
157
+ expect(writtenPath.endsWith('.tmp')).toBe(true);
158
+ expect(writeArgs[1]).toBe(expectedContent);
159
+ expect(localStorageRepository.rename).toHaveBeenCalledWith(
160
+ writtenPath,
141
161
  expectedFilePath,
142
- expectedContent,
143
162
  );
144
163
  },
145
164
  );
165
+
166
+ test('writes to the temp file before renaming it into place', async () => {
167
+ const callOrder: string[] = [];
168
+ localStorageRepository.write.mockImplementation(() => {
169
+ callOrder.push('write');
170
+ });
171
+ localStorageRepository.rename.mockImplementation(() => {
172
+ callOrder.push('rename');
173
+ });
174
+
175
+ await repository.set('ordering-key', { ordered: true });
176
+
177
+ expect(callOrder).toEqual(['write', 'rename']);
178
+ });
146
179
  });
147
180
  });
@@ -15,6 +15,7 @@ export class LocalStorageCacheRepository {
15
15
  const dirPath = `${this.cachePath}/${key}`;
16
16
  const latestFile = this.localStorageRepository
17
17
  .listFiles(dirPath)
18
+ .filter((fileName) => !fileName.endsWith('.tmp'))
18
19
  .sort((a, b) => a.localeCompare(b))
19
20
  .reverse()[0];
20
21
  if (!latestFile) {
@@ -45,9 +46,9 @@ export class LocalStorageCacheRepository {
45
46
  const dirPath = `${this.cachePath}/${key}`;
46
47
  this.localStorageRepository.mkdir(dirPath);
47
48
  const timestamp = new Date().toISOString();
48
- this.localStorageRepository.write(
49
- `${dirPath}/${timestamp}.json`,
50
- JSON.stringify(value),
51
- );
49
+ const finalPath = `${dirPath}/${timestamp}.json`;
50
+ const tmpPath = `${finalPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
51
+ this.localStorageRepository.write(tmpPath, JSON.stringify(value));
52
+ this.localStorageRepository.rename(tmpPath, finalPath);
52
53
  };
53
54
  }
@@ -15,6 +15,7 @@ describe('LocalStorageRepository', () => {
15
15
  let mockReaddirSync: jest.SpyInstance;
16
16
  let mockMkdirSync: jest.SpyInstance;
17
17
  let mockExistsSync: jest.SpyInstance;
18
+ let mockRenameSync: jest.SpyInstance;
18
19
 
19
20
  beforeEach(() => {
20
21
  repository = new LocalStorageRepository();
@@ -23,6 +24,7 @@ describe('LocalStorageRepository', () => {
23
24
  mockReaddirSync = jest.spyOn(fs, 'readdirSync').mockImplementation();
24
25
  mockMkdirSync = jest.spyOn(fs, 'mkdirSync').mockImplementation();
25
26
  mockExistsSync = jest.spyOn(fs, 'existsSync').mockImplementation();
27
+ mockRenameSync = jest.spyOn(fs, 'renameSync').mockImplementation();
26
28
  });
27
29
 
28
30
  afterEach(() => {
@@ -59,6 +61,16 @@ describe('LocalStorageRepository', () => {
59
61
  });
60
62
  });
61
63
 
64
+ describe('rename', () => {
65
+ test('renames a file from the old path to the new path', () => {
66
+ repository.rename('/path/to/file.txt.tmp', '/path/to/file.txt');
67
+ expect(mockRenameSync).toHaveBeenCalledWith(
68
+ '/path/to/file.txt.tmp',
69
+ '/path/to/file.txt',
70
+ );
71
+ });
72
+ });
73
+
62
74
  describe('read', () => {
63
75
  const testCases: Array<{
64
76
  name: string;
@@ -9,6 +9,9 @@ export class LocalStorageRepository {
9
9
  read = (path: string): string | null => {
10
10
  return fs.readFileSync(path, 'utf8');
11
11
  };
12
+ rename = (oldPath: string, newPath: string) => {
13
+ fs.renameSync(oldPath, newPath);
14
+ };
12
15
  listFiles = (dirPath: string): string[] => {
13
16
  if (!fs.existsSync(dirPath)) {
14
17
  return [];