mirror of
https://github.com/immich-app/services.git
synced 2026-09-30 13:23:10 +08:00
chore(deps): update dependency eslint-plugin-unicorn to v70 (#86)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
co-authored by
Daniel Dietzler
parent
ff27106981
commit
0f1e9c88d4
@@ -53,6 +53,37 @@ export class CloudflareRestClient implements ICloudflareRestClient {
|
||||
private readonly fetchImpl?: typeof fetch,
|
||||
) {}
|
||||
|
||||
private async listPaginated<T>(path: string): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let page = 1;
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
const doFetch = this.fetchImpl ?? globalThis.fetch;
|
||||
for (;;) {
|
||||
const url = `${this.baseUrl}${path}${separator}page=${page}&per_page=${DEFAULT_PAGE_SIZE}`;
|
||||
const response = await doFetch(url, {
|
||||
headers: { Authorization: `Bearer ${this.apiToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line unicorn/prefer-await
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new CloudflareRestError(
|
||||
`Cloudflare REST error (${response.status}) for ${path}: ${body.slice(0, 200)}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as CloudflareListResponse<T>;
|
||||
if (payload.result) {
|
||||
all.push(...payload.result);
|
||||
}
|
||||
const info = payload.result_info;
|
||||
if (!info || page >= (info.total_pages ?? 1)) {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async listD1Databases(accountId: string): Promise<D1Database[]> {
|
||||
return this.listPaginated<D1Database>(`/accounts/${encodeURIComponent(accountId)}/d1/database`);
|
||||
}
|
||||
@@ -76,6 +107,7 @@ export class CloudflareRestClient implements ICloudflareRestClient {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line unicorn/prefer-await
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new CloudflareRestError(
|
||||
`Cloudflare REST error (${response.status}) for /zones/${zoneId}: ${body.slice(0, 200)}`,
|
||||
@@ -85,34 +117,4 @@ export class CloudflareRestClient implements ICloudflareRestClient {
|
||||
const payload = (await response.json()) as { result: Zone | null };
|
||||
return payload.result ?? null;
|
||||
}
|
||||
|
||||
private async listPaginated<T>(path: string): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let page = 1;
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
const doFetch = this.fetchImpl ?? globalThis.fetch;
|
||||
for (;;) {
|
||||
const url = `${this.baseUrl}${path}${separator}page=${page}&per_page=${DEFAULT_PAGE_SIZE}`;
|
||||
const response = await doFetch(url, {
|
||||
headers: { Authorization: `Bearer ${this.apiToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new CloudflareRestError(
|
||||
`Cloudflare REST error (${response.status}) for ${path}: ${body.slice(0, 200)}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as CloudflareListResponse<T>;
|
||||
if (payload.result) {
|
||||
all.push(...payload.result);
|
||||
}
|
||||
const info = payload.result_info;
|
||||
if (!info || page >= (info.total_pages ?? 1)) {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ describe('CloudflareMetricsCollector', () => {
|
||||
expect(pages?.tags.get('zone_name')).toBe('pages.example.com');
|
||||
|
||||
// Only the uncached zone should have been looked up individually.
|
||||
expect(restClient.getZoneCalls).toEqual(['zone-pages']);
|
||||
expect(restClient.zoneCalls).toEqual(['zone-pages']);
|
||||
|
||||
const lookup = provider.metrics.find(
|
||||
(m) => m.name === 'cloudflare_metrics_resource_lookup' && m.tags.get('resource') === 'zones_individual',
|
||||
|
||||
@@ -268,12 +268,12 @@ export class CloudflareMetricsCollector {
|
||||
let zoneErrors = 0;
|
||||
for (let i = 0; i < zoneTags.length; i++) {
|
||||
const zoneTag = zoneTags[i];
|
||||
const zoneName = zoneNames[i];
|
||||
if (batchResult.errors[zoneTag]) {
|
||||
zoneErrors++;
|
||||
console.error(`[collector] ${dataset.key} zone ${zoneTag} error:`, batchResult.errors[zoneTag]);
|
||||
continue;
|
||||
}
|
||||
const zoneName = zoneNames[i];
|
||||
const rows = batchResult.rows[zoneTag] ?? [];
|
||||
totalRows += rows.length;
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('dataset registry invariants', () => {
|
||||
for (const dataset of ALL_DATASETS) {
|
||||
const fieldCount = Object.keys(dataset.fields).length;
|
||||
expect(fieldCount, `${dataset.key} must have fields`).toBeGreaterThan(0);
|
||||
for (const [, spec] of Object.entries(dataset.fields)) {
|
||||
for (const spec of Object.values(dataset.fields)) {
|
||||
expect(['int', 'float']).toContain(spec.type);
|
||||
expect(spec.source[0]).toMatch(/^(sum|avg|max|min|quantiles|uniq|_top)$/);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-top-level-assignment-in-function */
|
||||
export const pendingFlushBuffers: string[] = [];
|
||||
// Cap retry buffer at 10 MB; eviction drops oldest-first.
|
||||
export const MAX_PENDING_FLUSH_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -43,10 +43,10 @@ const SCHEDULED_INVOCATIONS_SELECTION = `workers_scheduled: workersInvocationsSc
|
||||
|
||||
export function buildBatchedAccountQuery(
|
||||
datasets: readonly DatasetQuery[],
|
||||
includeScheduledInvocations = false,
|
||||
shouldIncludeScheduledInvocations = false,
|
||||
): string {
|
||||
const selections = datasets.map((d) => buildDatasetSelection(d, d.key));
|
||||
if (includeScheduledInvocations) {
|
||||
if (shouldIncludeScheduledInvocations) {
|
||||
selections.push(SCHEDULED_INVOCATIONS_SELECTION);
|
||||
}
|
||||
return `query CloudflareMetricsAccountBatch($accountTag: String!, $filter: JSON!) {
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('groupErrorsByAlias', () => {
|
||||
|
||||
it('returns an empty map when errors is null or undefined', () => {
|
||||
expect(groupErrorsByAlias(null)).toEqual({});
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
|
||||
expect(groupErrorsByAlias(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -249,6 +249,7 @@ describe('CloudflareGraphQLClient', () => {
|
||||
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
// Return the aliased fields that this chunk actually asked for.
|
||||
// eslint-disable-next-line unicorn/prefer-iterator-to-array
|
||||
const aliases = [...body.query.matchAll(/(ds_\d+): workersInvocationsAdaptive/g)].map((m) => m[1]);
|
||||
const account: Record<string, unknown> = {};
|
||||
for (const alias of aliases) {
|
||||
@@ -283,6 +284,7 @@ describe('CloudflareGraphQLClient', () => {
|
||||
}));
|
||||
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
// eslint-disable-next-line unicorn/prefer-iterator-to-array
|
||||
const aliases = [...body.query.matchAll(/(ds_\d+): workersInvocationsAdaptive/g)].map((m) => m[1]);
|
||||
const account: Record<string, unknown> = {};
|
||||
for (const alias of aliases) {
|
||||
@@ -393,6 +395,7 @@ describe('CloudflareGraphQLClient', () => {
|
||||
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
|
||||
calls++;
|
||||
const body = JSON.parse(init.body as string);
|
||||
// eslint-disable-next-line unicorn/prefer-iterator-to-array
|
||||
const aliases = [...body.query.matchAll(/(ds_\d+): workersInvocationsAdaptive/g)].map((m) => m[1]);
|
||||
const account: Record<string, unknown> = {};
|
||||
for (const alias of aliases) {
|
||||
|
||||
@@ -96,11 +96,11 @@ export class CloudflareGraphQLClient implements ICloudflareGraphQLClient {
|
||||
const result: BatchedDatasetResult = { rows: {}, errors: {} };
|
||||
|
||||
for (const [i, chunk] of chunks.entries()) {
|
||||
const includeScheduled = i === 0 && (options.includeScheduledInvocations ?? false);
|
||||
const chunkResult = await this.fetchAccountBatchChunk(accountTag, chunk, range, includeScheduled);
|
||||
const isIncludeScheduled = i === 0 && (options.includeScheduledInvocations ?? false);
|
||||
const chunkResult = await this.fetchAccountBatchChunk(accountTag, chunk, range, isIncludeScheduled);
|
||||
Object.assign(result.rows, chunkResult.rows);
|
||||
Object.assign(result.errors, chunkResult.errors);
|
||||
if (includeScheduled) {
|
||||
if (isIncludeScheduled) {
|
||||
if (chunkResult.errors.workers_scheduled) {
|
||||
result.errors.workers_scheduled = chunkResult.errors.workers_scheduled;
|
||||
} else {
|
||||
@@ -116,10 +116,10 @@ export class CloudflareGraphQLClient implements ICloudflareGraphQLClient {
|
||||
accountTag: string,
|
||||
datasets: readonly DatasetQuery[],
|
||||
range: { start: Date; end: Date },
|
||||
includeScheduledInvocations: boolean,
|
||||
shouldIncludeScheduledInvocations: boolean,
|
||||
attempt = 1,
|
||||
): Promise<BatchedDatasetResult> {
|
||||
const query = buildBatchedAccountQuery(datasets, includeScheduledInvocations);
|
||||
const query = buildBatchedAccountQuery(datasets, shouldIncludeScheduledInvocations);
|
||||
const variables = {
|
||||
accountTag,
|
||||
filter: buildFilterObject(datasets[0] ?? null, range),
|
||||
@@ -138,7 +138,7 @@ export class CloudflareGraphQLClient implements ICloudflareGraphQLClient {
|
||||
result.rows[dataset.key] = rows ?? [];
|
||||
}
|
||||
|
||||
if (includeScheduledInvocations) {
|
||||
if (shouldIncludeScheduledInvocations) {
|
||||
if (errorsByAlias.workers_scheduled) {
|
||||
result.errors.workers_scheduled = errorsByAlias.workers_scheduled;
|
||||
} else {
|
||||
@@ -153,14 +153,14 @@ export class CloudflareGraphQLClient implements ICloudflareGraphQLClient {
|
||||
for (const dataset of datasets) {
|
||||
result.errors[dataset.key] = message;
|
||||
}
|
||||
if (includeScheduledInvocations) {
|
||||
if (shouldIncludeScheduledInvocations) {
|
||||
result.errors.workers_scheduled = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry once when all fields errored — likely a transient Cloudflare
|
||||
// analytics backend issue that resolves on the next attempt.
|
||||
const totalFields = datasets.length + (includeScheduledInvocations ? 1 : 0);
|
||||
const totalFields = datasets.length + (shouldIncludeScheduledInvocations ? 1 : 0);
|
||||
if (attempt < 2 && totalFields > 0 && Object.keys(result.errors).length >= totalFields) {
|
||||
this._retryCount++;
|
||||
console.warn(`[graphql] all ${totalFields} fields errored, retrying chunk (attempt ${attempt + 1})`);
|
||||
@@ -172,7 +172,7 @@ export class CloudflareGraphQLClient implements ICloudflareGraphQLClient {
|
||||
accountTag,
|
||||
datasets,
|
||||
range,
|
||||
includeScheduledInvocations,
|
||||
shouldIncludeScheduledInvocations,
|
||||
attempt + 1,
|
||||
);
|
||||
if (Object.keys(retryResult.errors).length < totalFields) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createExecutionContext, createScheduledController, waitOnExecutionContext } from 'cloudflare:test';
|
||||
import { env, exports } from 'cloudflare:workers';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetFlushStateForTests } from './flush-state.js';
|
||||
import worker from './index.js';
|
||||
|
||||
@@ -31,16 +31,15 @@ describe('scheduled handler', () => {
|
||||
it('emits cron_error{reason=missing_config} when API token or account ID are missing', async () => {
|
||||
// Collect metric flush bodies seen by the victoria-metrics POST so we
|
||||
// can assert on the cron_error line without touching internal state.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const flushBodies: string[] = [];
|
||||
globalThis.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
|
||||
vi.stubGlobal('fetch', (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const target = typeof url === 'string' ? url : url.toString();
|
||||
if (target.includes('/write')) {
|
||||
flushBodies.push(init?.body as string);
|
||||
return Promise.resolve(new Response('', { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(new Response('', { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
const controller = createScheduledController();
|
||||
const ctx = createExecutionContext();
|
||||
@@ -51,7 +50,7 @@ describe('scheduled handler', () => {
|
||||
await worker.scheduled?.(controller, env as unknown as Env, ctx);
|
||||
await waitOnExecutionContext(ctx);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
const combined = flushBodies.join('\n');
|
||||
@@ -63,16 +62,15 @@ describe('scheduled handler', () => {
|
||||
// First tick: a successful flush populates lastFlushStats.
|
||||
// Second tick: takeLastFlushStats() drains it and emits
|
||||
// cloudflare_metrics_flush_* lines to the second tick's flush body.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const flushBodies: string[] = [];
|
||||
globalThis.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
|
||||
vi.stubGlobal('fetch', (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const target = typeof url === 'string' ? url : url.toString();
|
||||
if (target.includes('/write')) {
|
||||
flushBodies.push(init?.body as string);
|
||||
return Promise.resolve(new Response('', { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(new Response('', { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
try {
|
||||
const controller = createScheduledController();
|
||||
@@ -86,7 +84,7 @@ describe('scheduled handler', () => {
|
||||
await worker.scheduled?.(controller, env as unknown as Env, ctx2);
|
||||
await waitOnExecutionContext(ctx2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(flushBodies.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-top-level-assignment-in-function */
|
||||
import { CloudflareRestClient } from '../cloudflare-api.js';
|
||||
import { CloudflareMetricsCollector } from '../collector.js';
|
||||
import { ALL_DATASETS } from '../datasets.js';
|
||||
|
||||
@@ -88,7 +88,7 @@ describe.skipIf(!hasCredentials)('Cloudflare GraphQL integration', () => {
|
||||
expect(row.dimensions).toBeDefined();
|
||||
const timestampDim = dataset.timestampDimension ?? 'datetimeMinute';
|
||||
expect(row.dimensions[timestampDim]).toBeDefined();
|
||||
for (const [, spec] of Object.entries(dataset.fields)) {
|
||||
for (const spec of Object.values(dataset.fields)) {
|
||||
const [block, key] = spec.source;
|
||||
if (block === '_top') {
|
||||
continue;
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('InfluxDB line protocol NaN/Infinity handling', () => {
|
||||
|
||||
it('skips NaN float fields', () => {
|
||||
const provider = new InfluxMetricsProvider('', '');
|
||||
const metric = Metric.create('test').floatField('bad', Number.NaN).intField('good', 1);
|
||||
const metric = Metric.create('test').floatField('bad', NaN).intField('good', 1);
|
||||
provider.pushMetric(metric);
|
||||
const [line] = getLines(provider);
|
||||
expect(line).not.toContain('bad');
|
||||
@@ -95,7 +95,7 @@ describe('InfluxDB line protocol NaN/Infinity handling', () => {
|
||||
|
||||
it('drops the entire line when all fields are NaN/Infinity', () => {
|
||||
const provider = new InfluxMetricsProvider('', '');
|
||||
const metric = Metric.create('test').floatField('a', Number.NaN).floatField('b', Infinity);
|
||||
const metric = Metric.create('test').floatField('a', NaN).floatField('b', Infinity);
|
||||
provider.pushMetric(metric);
|
||||
expect(getLines(provider)).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -16,10 +16,12 @@ export class HeaderMetricsProvider implements IMetricsProviderRepository {
|
||||
|
||||
pushMetric(metric: Metric) {
|
||||
for (const [label, { value, type }] of metric.fields) {
|
||||
if (type === 'duration') {
|
||||
const suffix = label === 'duration' ? '' : `_${label.replace('_duration', '')}`;
|
||||
this._metrics.push(`${metric.name}${suffix};dur=${value}`);
|
||||
if (type !== 'duration') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = label === 'duration' ? '' : `_${label.replace('_duration', '')}`;
|
||||
this._metrics.push(`${metric.name}${suffix};dur=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetFlushStateForTests, takeLastFlushStats } from './flush-state.js';
|
||||
import { InfluxMetricsProvider, type IMetricsProviderRepository } from './metric-providers.js';
|
||||
import { Metric } from './metric.js';
|
||||
@@ -74,8 +74,7 @@ describe('InfluxMetricsProvider flush self-telemetry', () => {
|
||||
});
|
||||
|
||||
it('records stats with status=ok after a successful flush', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (() => Promise.resolve(new Response('', { status: 204 }))) as typeof fetch;
|
||||
vi.stubGlobal('fetch', () => Promise.resolve(new Response('', { status: 204 })));
|
||||
try {
|
||||
const provider = new InfluxMetricsProvider('token', 'prod');
|
||||
provider.pushMetric(Metric.create('foo').intField('v', 1));
|
||||
@@ -86,13 +85,12 @@ describe('InfluxMetricsProvider flush self-telemetry', () => {
|
||||
expect(stats?.pendingBuffers).toBe(0);
|
||||
expect(stats?.pendingBytes).toBe(0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('records stats with status=error after a failed flush', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (() => Promise.resolve(new Response('bad', { status: 502 }))) as typeof fetch;
|
||||
vi.stubGlobal('fetch', () => Promise.resolve(new Response('bad', { status: 502 })));
|
||||
try {
|
||||
const provider = new InfluxMetricsProvider('token', 'prod');
|
||||
provider.pushMetric(Metric.create('foo').intField('v', 1));
|
||||
@@ -104,13 +102,12 @@ describe('InfluxMetricsProvider flush self-telemetry', () => {
|
||||
expect(stats?.pendingBuffers).toBe(1);
|
||||
expect(stats?.pendingBytes).toBeGreaterThan(0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears lastFlushStats after a single take', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (() => Promise.resolve(new Response('', { status: 204 }))) as typeof fetch;
|
||||
vi.stubGlobal('fetch', () => Promise.resolve(new Response('', { status: 204 })));
|
||||
try {
|
||||
const provider = new InfluxMetricsProvider('token', 'prod');
|
||||
provider.pushMetric(Metric.create('foo').intField('v', 1));
|
||||
@@ -118,14 +115,13 @@ describe('InfluxMetricsProvider flush self-telemetry', () => {
|
||||
expect(takeLastFlushStats()).not.toBeNull();
|
||||
expect(takeLastFlushStats()).toBeNull();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('evicts the oldest stashed flush body when the total exceeds the 10 MB cap', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
// Always fail so every flush stashes its body in the retry buffer.
|
||||
globalThis.fetch = (() => Promise.resolve(new Response('', { status: 502 }))) as typeof fetch;
|
||||
vi.stubGlobal('fetch', () => Promise.resolve(new Response('', { status: 502 })));
|
||||
try {
|
||||
// Each payload is ~4 MB of raw line protocol; three of them land us
|
||||
// over the 10 MB cap and force an eviction.
|
||||
@@ -146,7 +142,7 @@ describe('InfluxMetricsProvider flush self-telemetry', () => {
|
||||
expect(stats?.pendingBuffers).toBe(2);
|
||||
expect(stats?.pendingBytes).toBeLessThan(10 * 1024 * 1024 + 4 * 1024 * 1024);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -185,12 +181,9 @@ describe('InfluxMetricsProvider line protocol', () => {
|
||||
});
|
||||
|
||||
it('stashes a failed flush body and resends it on the next successful flush', async () => {
|
||||
// Use a fetch mock via globalThis so the provider picks it up through its
|
||||
// real `fetch` call path.
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
const received: string[] = [];
|
||||
globalThis.fetch = ((_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
vi.stubGlobal('fetch', (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
call++;
|
||||
received.push(init?.body as string);
|
||||
if (call === 1) {
|
||||
@@ -198,7 +191,7 @@ describe('InfluxMetricsProvider line protocol', () => {
|
||||
return Promise.resolve(new Response('bad gateway', { status: 502 }));
|
||||
}
|
||||
return Promise.resolve(new Response('', { status: 204 }));
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
try {
|
||||
const provider = new InfluxMetricsProvider('token', 'prod');
|
||||
@@ -216,7 +209,7 @@ describe('InfluxMetricsProvider line protocol', () => {
|
||||
expect(received[1]).toContain('first');
|
||||
expect(received[1]).toContain('second');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function monitorAsyncFunction<T extends AsyncFn>(
|
||||
try {
|
||||
return await call(...args);
|
||||
} catch (error) {
|
||||
if (!acceptedErrors.some((acceptedError) => error instanceof acceptedError)) {
|
||||
if (acceptedErrors.every((acceptedError) => !(error instanceof acceptedError))) {
|
||||
console.error(error, `${operationName}_errors`);
|
||||
metric.intField('errors', 1);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('ResourceCacheService', () => {
|
||||
const service = new ResourceCacheService('acct', metrics, now, restClient);
|
||||
await service.populate();
|
||||
await service.resolveMissingZones(['zone-1']);
|
||||
expect(restClient.getZoneCalls).toHaveLength(0);
|
||||
expect(restClient.zoneCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('caps individual lookups per run', async () => {
|
||||
@@ -125,7 +125,7 @@ describe('ResourceCacheService', () => {
|
||||
const service = new ResourceCacheService('acct', metrics, now, restClient);
|
||||
await service.populate();
|
||||
await service.resolveMissingZones(Object.keys(zones));
|
||||
expect(restClient.getZoneCalls).toHaveLength(20);
|
||||
expect(restClient.zoneCalls).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('does nothing when no rest client is provided', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-top-level-assignment-in-function */
|
||||
import type { ICloudflareRestClient } from './cloudflare-api.js';
|
||||
import { Metric } from './metric.js';
|
||||
import type { CloudflareMetricsRepository } from './metrics.js';
|
||||
@@ -75,25 +76,28 @@ export class ResourceCacheService {
|
||||
|
||||
const restClient = this.restClient;
|
||||
const now = this.now().getTime();
|
||||
const cacheFresh = <T>(cache: CachedResourceLookup<T> | null): cache is CachedResourceLookup<T> =>
|
||||
const isCacheFresh = <T>(cache: CachedResourceLookup<T> | null): cache is CachedResourceLookup<T> =>
|
||||
cache !== null && now - cache.loadedAt < RESOURCE_CACHE_TTL_MS;
|
||||
|
||||
const [d1Result, queuesResult, zonesResult] = await Promise.allSettled([
|
||||
cacheFresh(cachedD1Databases)
|
||||
isCacheFresh(cachedD1Databases)
|
||||
? Promise.resolve(cachedD1Databases.values)
|
||||
: restClient.listD1Databases(this.accountTag).then((values) => {
|
||||
: // eslint-disable-next-line unicorn/prefer-await
|
||||
restClient.listD1Databases(this.accountTag).then((values) => {
|
||||
cachedD1Databases = { values, loadedAt: now };
|
||||
return values;
|
||||
}),
|
||||
cacheFresh(cachedQueues)
|
||||
isCacheFresh(cachedQueues)
|
||||
? Promise.resolve(cachedQueues.values)
|
||||
: restClient.listQueues(this.accountTag).then((values) => {
|
||||
: // eslint-disable-next-line unicorn/prefer-await
|
||||
restClient.listQueues(this.accountTag).then((values) => {
|
||||
cachedQueues = { values, loadedAt: now };
|
||||
return values;
|
||||
}),
|
||||
cacheFresh(cachedBulkZones)
|
||||
isCacheFresh(cachedBulkZones)
|
||||
? Promise.resolve(cachedBulkZones.values)
|
||||
: restClient.listZones(this.accountTag).then((values) => {
|
||||
: // eslint-disable-next-line unicorn/prefer-await
|
||||
restClient.listZones(this.accountTag).then((values) => {
|
||||
cachedBulkZones = { values, loadedAt: now };
|
||||
return values;
|
||||
}),
|
||||
@@ -101,26 +105,32 @@ export class ResourceCacheService {
|
||||
|
||||
this.recordResourceLookup('d1_databases', d1Result, (items) => {
|
||||
for (const db of items) {
|
||||
if (db.uuid && db.name) {
|
||||
this.cache.d1Databases.set(db.uuid, db.name);
|
||||
globalD1NameCache.set(db.uuid, db.name);
|
||||
if (!(db.uuid && db.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.cache.d1Databases.set(db.uuid, db.name);
|
||||
globalD1NameCache.set(db.uuid, db.name);
|
||||
}
|
||||
});
|
||||
this.recordResourceLookup('queues', queuesResult, (items) => {
|
||||
for (const q of items) {
|
||||
if (q.queue_id && q.queue_name) {
|
||||
this.cache.queues.set(q.queue_id, q.queue_name);
|
||||
globalQueueNameCache.set(q.queue_id, q.queue_name);
|
||||
if (!(q.queue_id && q.queue_name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.cache.queues.set(q.queue_id, q.queue_name);
|
||||
globalQueueNameCache.set(q.queue_id, q.queue_name);
|
||||
}
|
||||
});
|
||||
this.recordResourceLookup('zones', zonesResult, (items) => {
|
||||
for (const z of items) {
|
||||
if (z.id && z.name) {
|
||||
this.cache.zones.set(z.id, z.name);
|
||||
this.cache.bulkZoneTags.add(z.id);
|
||||
if (!(z.id && z.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.cache.zones.set(z.id, z.name);
|
||||
this.cache.bulkZoneTags.add(z.id);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export function asGraphQLClient(fake: FakeGraphQLClient): CloudflareGraphQLClien
|
||||
* pages-only zone exists outside the bulk list.
|
||||
*/
|
||||
export class FakeRestClient implements ICloudflareRestClient {
|
||||
public getZoneCalls: string[] = [];
|
||||
public zoneCalls: string[] = [];
|
||||
constructor(
|
||||
public readonly d1: Array<{ uuid: string; name: string }> = [],
|
||||
public readonly queues: Array<{ queue_id: string; queue_name: string }> = [],
|
||||
@@ -134,7 +134,7 @@ export class FakeRestClient implements ICloudflareRestClient {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async getZone(zoneId: string) {
|
||||
this.getZoneCalls.push(zoneId);
|
||||
this.zoneCalls.push(zoneId);
|
||||
if (zoneId in this.individualZones) {
|
||||
return this.individualZones[zoneId];
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export class ApprovalValidator {
|
||||
console.log(
|
||||
`[approval] DEBUG: Reviews by valid users (after deduplication):`,
|
||||
JSON.stringify(
|
||||
[...approvalsByUser.entries()].map(([userId, review]) => ({
|
||||
[...approvalsByUser].map(([userId, review]) => ({
|
||||
userId,
|
||||
login: review.user.login,
|
||||
state: review.state,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createOctokitForInstallation(appId: string, privateKey: string,
|
||||
console.error('Invalid privateKey:', {
|
||||
hasPrivateKey: !!privateKey,
|
||||
type: typeof privateKey,
|
||||
length: privateKey ? String(privateKey).length : 0,
|
||||
length: privateKey ? privateKey.length : 0,
|
||||
});
|
||||
throw new Error('Invalid GitHub App Private Key provided to createOctokitForInstallation');
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function getDevModeConfig(env: Env): DevModeConfig {
|
||||
// Extract PR number from stage (e.g., '-pr-123' -> 123)
|
||||
let prNumber: number | undefined;
|
||||
if (env.DEV_PR_NUMBER) {
|
||||
prNumber = Number.parseInt(env.DEV_PR_NUMBER, 10);
|
||||
prNumber = Number(env.DEV_PR_NUMBER);
|
||||
} else if (isPRDeployment && env.STAGE) {
|
||||
const match = env.STAGE.match(/-pr-(\d+)/);
|
||||
if (match) {
|
||||
prNumber = Number.parseInt(match[1], 10);
|
||||
prNumber = Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,13 @@ export async function verifyWebhookSignature(body: string, signature: string, se
|
||||
const computedSignature = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return safeCompare(computedSignature, providedSignature);
|
||||
return isSafeCompare(computedSignature, providedSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison to prevent timing attacks
|
||||
*/
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
function isSafeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -774,7 +774,7 @@ describe('GitHubRepository', () => {
|
||||
if (url.origin === 'https://api.github.com' && url.pathname === '/repos/immich-app/immich/releases') {
|
||||
return Promise.resolve(Response.json(githubReleases));
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected fetch: ${url.toString()}`));
|
||||
return Promise.reject(new Error(`unexpected fetch: ${url.href}`));
|
||||
});
|
||||
|
||||
const releases = await new GitHubRepository().fetchReleases();
|
||||
|
||||
@@ -111,7 +111,7 @@ export default {
|
||||
|
||||
if (env.ENVIRONMENT) {
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(url.toString(), request);
|
||||
const cacheKey = new Request(url.href, request);
|
||||
const cached = await cache.match(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
const response = jsonResponse(changelog, 200, { 'Cache-Control': 'public, max-age=86400' });
|
||||
if (env.ENVIRONMENT) {
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(url.toString(), request);
|
||||
const cacheKey = new Request(url.href, request);
|
||||
ctx.waitUntil(cache.put(cacheKey, response.clone()));
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -75,10 +75,12 @@ export class HeaderMetricsProvider implements IMetricsProviderRepository {
|
||||
|
||||
pushMetric(metric: Metric) {
|
||||
for (const [label, { value, type }] of metric.fields) {
|
||||
if (type === 'duration') {
|
||||
const suffix = label === 'duration' ? '' : `_${label.replace('_duration', '')}`;
|
||||
this._metrics.push(`${metric.name}${suffix};dur=${value}`);
|
||||
if (type !== 'duration') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = label === 'duration' ? '' : `_${label.replace('_duration', '')}`;
|
||||
this._metrics.push(`${metric.name}${suffix};dur=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function monitorAsyncFunction<T extends AsyncFn>(
|
||||
try {
|
||||
return await call(...args);
|
||||
} catch (error) {
|
||||
if (!acceptedErrors.some((acceptedError) => error instanceof acceptedError)) {
|
||||
if (acceptedErrors.every((acceptedError) => !(error instanceof acceptedError))) {
|
||||
console.error(error, `${operationName}_errors`);
|
||||
metric.intField('errors', 1);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class VersionService {
|
||||
})();
|
||||
|
||||
const response = new Map<ReleaseChannel, VersionResponse>(
|
||||
[...latest.entries()]
|
||||
[...latest]
|
||||
.filter(([_, release]) => release !== null)
|
||||
.map(
|
||||
([channel, release]) =>
|
||||
|
||||
@@ -16,10 +16,10 @@ export async function verifyWebhookSignature(body: string, signature: string, se
|
||||
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
|
||||
const computedSignature = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return timingSafeEqual(computedSignature, providedSignature);
|
||||
return isTimingSafeEqual(computedSignature, providedSignature);
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
function isTimingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+5
-1
@@ -39,7 +39,7 @@ export default typescriptEslint.config([
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/name-replacements': 'off',
|
||||
'unicorn/filename-case': 'off',
|
||||
'unicorn/no-null': 'off',
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
@@ -48,6 +48,10 @@ export default typescriptEslint.config([
|
||||
'unicorn/import-style': 'off',
|
||||
'unicorn/prefer-structured-clone': 'off',
|
||||
'unicorn/no-for-loop': 'off',
|
||||
'unicorn/consistent-class-member-order': 'off',
|
||||
'unicorn/no-computed-property-existence-check': 'off',
|
||||
'unicorn/max-nested-calls': ['error', { max: 4 }],
|
||||
'unicorn/no-break-in-nested-loop': 'off',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'require-await': 'off',
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-unicorn": "^65.0.0",
|
||||
"eslint-plugin-unicorn": "^70.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
|
||||
Generated
+8
-7
@@ -33,8 +33,8 @@ importers:
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.6.0))(eslint@10.6.0)(prettier@3.9.4)
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^65.0.0
|
||||
version: 65.0.1(eslint@10.6.0)
|
||||
specifier: ^70.0.0
|
||||
version: 70.0.0(eslint@10.6.0)
|
||||
globals:
|
||||
specifier: ^17.0.0
|
||||
version: 17.7.0
|
||||
@@ -1032,11 +1032,11 @@ packages:
|
||||
eslint-config-prettier:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-unicorn@65.0.1:
|
||||
resolution: {integrity: sha512-daCrQrgxOoOz2uMPWB3Y3vvv/5q+ncwICI8IjoebiwtW87CaY4tAN5EEiRXTYVnf7qi1v1BGBdHOSnZLV0rx6A==}
|
||||
engines: {node: ^20.10.0 || >=21.0.0}
|
||||
eslint-plugin-unicorn@70.0.0:
|
||||
resolution: {integrity: sha512-uAF9xMcVvvhTfvusCgogJ1wh4To3q2KhVMw3i1Apf/ILTbxsCjscvraAZACsEurb7no2fdXblD3whcbVnjw5zg==}
|
||||
engines: {node: '>=22'}
|
||||
peerDependencies:
|
||||
eslint: '>=9.38.0'
|
||||
eslint: '>=10.4'
|
||||
|
||||
eslint-scope@9.1.2:
|
||||
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
|
||||
@@ -2436,10 +2436,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
eslint-config-prettier: 10.1.8(eslint@10.6.0)
|
||||
|
||||
eslint-plugin-unicorn@65.0.1(eslint@10.6.0):
|
||||
eslint-plugin-unicorn@70.0.0(eslint@10.6.0):
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0)
|
||||
browserslist: 4.28.4
|
||||
change-case: 5.4.4
|
||||
ci-info: 4.4.0
|
||||
core-js-compat: 3.49.0
|
||||
|
||||
Reference in New Issue
Block a user