Compare commits

...

7 Commits

Author SHA1 Message Date
DarkSky c4c9e3c36d fix: retry 2026-05-30 18:05:35 +08:00
DarkSky 1a8d884f8e chore(server): add logs 2026-05-30 16:53:16 +08:00
DarkSky 91acb88a2d fix(server): mail test & retry 2026-05-30 16:30:51 +08:00
DarkSky 43704d60fb feat(server): improve calendar sync queue (#14783) 2026-04-05 11:05:35 +08:00
DarkSky 46e7e35357 feat(server): improve calendar sync queue (#14783) 2026-04-05 11:02:48 +08:00
DarkSky b98ab495bb fix(server): race condition for sync 2026-04-03 02:00:02 +08:00
DarkSky 99b07c2ee1 fix: ios marketing version 2026-03-04 01:17:14 +08:00
47 changed files with 1473 additions and 485 deletions
+18 -2
View File
@@ -62,6 +62,18 @@
"concurrency": 10
}
},
"queues.calendar": {
"type": "object",
"description": "The config for calendar job queue\n@default {\"concurrency\":4}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 4
}
},
"queues.doc": {
"type": "object",
"description": "The config for doc job queue\n@default {\"concurrency\":1}",
@@ -843,7 +855,7 @@
"properties": {
"google": {
"type": "object",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
"properties": {
"enabled": {
"type": "boolean"
@@ -859,6 +871,9 @@
},
"webhookVerificationToken": {
"type": "string"
},
"requestTimeoutMs": {
"type": "number"
}
},
"default": {
@@ -866,7 +881,8 @@
"clientId": "",
"clientSecret": "",
"externalWebhookUrl": "",
"webhookVerificationToken": ""
"webhookVerificationToken": "",
"requestTimeoutMs": 10000
}
},
"caldav": {
+1
View File
@@ -48,6 +48,7 @@ testem.log
/typings
tsconfig.tsbuildinfo
.context
*.md
# System Files
.DS_Store
@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE
"calendar_subscriptions"
ADD
COLUMN "next_sync_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD
COLUMN "sync_retry_count" INTEGER NOT NULL DEFAULT 0;
UPDATE
"calendar_subscriptions" AS s
SET
"next_sync_at" = CASE
WHEN s."last_sync_at" IS NULL THEN CURRENT_TIMESTAMP
ELSE s."last_sync_at" + make_interval(
mins => COALESCE(a."refresh_interval_minutes", 30)
)
END
FROM
"calendar_accounts" AS a
WHERE
a."id" = s."account_id";
-- CreateIndex
CREATE INDEX "calendar_subscriptions_custom_channel_id_idx" ON "calendar_subscriptions"("custom_channel_id");
-- CreateIndex
CREATE INDEX "calendar_subscriptions_enabled_next_sync_at_idx" ON "calendar_subscriptions"("enabled", "next_sync_at");
+2 -3
View File
@@ -110,7 +110,7 @@
"react-dom": "19.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"semver": "^7.7.3",
"semver": "^7.7.4",
"ses": "^1.14.0",
"socket.io": "^4.8.1",
"stripe": "^17.7.0",
@@ -138,8 +138,7 @@
"@types/nodemailer": "^7.0.0",
"@types/on-headers": "^1.0.3",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/semver": "^7.5.8",
"@types/semver": "^7.7.1",
"@types/sinon": "^21.0.0",
"@types/supertest": "^6.0.2",
"ava": "^6.4.0",
+4
View File
@@ -1037,6 +1037,8 @@ model CalendarSubscription {
enabled Boolean @default(true)
syncToken String? @map("sync_token") @db.Text
lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
nextSyncAt DateTime @default(now()) @map("next_sync_at") @db.Timestamptz(3)
syncRetryCount Int @default(0) @map("sync_retry_count")
customChannelId String? @map("custom_channel_id") @db.VarChar
customResourceId String? @map("custom_resource_id") @db.VarChar
channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
@@ -1050,6 +1052,8 @@ model CalendarSubscription {
@@unique([accountId, externalCalendarId])
@@index([accountId])
@@index([provider, externalCalendarId])
@@index([customChannelId])
@@index([enabled, nextSyncAt])
@@map("calendar_subscriptions")
}
@@ -858,7 +858,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com invited you to join Test Workspace
> You were invited to join a workspace on AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -973,7 +973,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com accepted your invitation
> Your workspace invitation was accepted
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1072,7 +1072,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com left Test Workspace
> A workspace member left
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1132,7 +1132,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> New request to join Test Workspace
> New request to join a workspace
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1239,7 +1239,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your request to join Test Workspace has been approved
> Your request to join a workspace has been approved
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1294,7 +1294,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your request to join Test Workspace was declined
> Your request to join a workspace was declined
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1348,7 +1348,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> You have been removed from Test Workspace
> You have been removed from a workspace
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1401,7 +1401,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your ownership of Test Workspace has been transferred
> Your workspace ownership has been transferred
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1454,7 +1454,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> You are now the owner of Test Workspace
> You are now the owner of a workspace
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1506,7 +1506,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com mentioned you in Test Doc
> You were mentioned in AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -1601,7 +1601,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com commented on Test Doc
> New comment in AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -1695,7 +1695,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com mentioned you in a comment on Test Doc
> You were mentioned in a comment
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -1894,7 +1894,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> You are now an admin of Test Workspace
> You are now a workspace admin
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1993,7 +1993,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your role has been changed in Test Workspace
> Your workspace role has been changed
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2094,7 +2094,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> [Action Required] Final warning: Your workspace Test Workspace will be deleted in 24 hours
> [Action Required] Final warning: Your workspace will be deleted in 24 hours
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2208,7 +2208,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> [Action Required] Important: Your workspace Test Workspace will be deleted soon
> [Action Required] Important: Your workspace will be deleted soon
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2324,7 +2324,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your workspace Test Workspace has been deleted
> Your workspace has been deleted
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2408,7 +2408,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> [Action Required] Your Test Workspace team workspace will expire soon
> [Action Required] Your team workspace will expire soon
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2511,7 +2511,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your Test Workspace team workspace has expired
> Your team workspace has expired
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2689,7 +2689,7 @@ Generated by [AVA](https://avajs.dev).
## should render mention email with empty doc title
> test@test.com mentioned you in
> You were mentioned in AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -1,5 +1,6 @@
import test from 'ava';
import { normalizeSMTPHeloHostname } from '../core/mail/utils';
import { Renderers } from '../mails';
import { TEST_DOC, TEST_USER } from '../mails/common';
@@ -21,3 +22,23 @@ test('should render mention email with empty doc title', async t => {
});
t.snapshot(content.html, content.subject);
});
test('should normalize valid SMTP HELO hostnames', t => {
t.is(normalizeSMTPHeloHostname('mail.example.com'), 'mail.example.com');
t.is(normalizeSMTPHeloHostname(' localhost '), 'localhost');
t.is(normalizeSMTPHeloHostname('[127.0.0.1]'), '[127.0.0.1]');
t.is(normalizeSMTPHeloHostname('[IPv6:2001:db8::1]'), '[IPv6:2001:db8::1]');
});
test('should reject invalid SMTP HELO hostnames', t => {
t.is(normalizeSMTPHeloHostname(), undefined);
t.is(normalizeSMTPHeloHostname(''), undefined);
t.is(normalizeSMTPHeloHostname(' '), undefined);
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
t.is(normalizeSMTPHeloHostname('-example.com'), undefined);
t.is(normalizeSMTPHeloHostname('example-.com'), undefined);
t.is(normalizeSMTPHeloHostname('example..com'), undefined);
t.is(normalizeSMTPHeloHostname('[bad host]'), undefined);
t.is(normalizeSMTPHeloHostname('[foo]'), undefined);
t.is(normalizeSMTPHeloHostname('[IPv6:foo]'), undefined);
});
@@ -6,6 +6,7 @@ import { JobQueue } from '../../base';
export class MockJobQueue {
add = Sinon.createStubInstance(JobQueue).add.resolves();
remove = Sinon.createStubInstance(JobQueue).remove.resolves();
removeWhere = Sinon.createStubInstance(JobQueue).removeWhere.resolves([]);
last<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
const addJobName = this.add.lastCall?.args[0];
@@ -437,6 +437,37 @@ test('should throw if user has subscription already', async t => {
);
});
test('should allow checkout after local subscription period ended', async t => {
const { service, u1, db, stripe } = t.context;
await db.subscription.create({
data: {
targetId: u1.id,
stripeSubscriptionId: 'sub_expired_ai',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-05-04T13:11:45.000Z'),
end: new Date('2026-05-11T13:11:45.000Z'),
},
});
await service.checkout(
{
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
successCallbackLink: '',
},
{ user: u1 }
);
t.true(stripe.checkout.sessions.create.calledOnce);
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
price: AI_YEARLY,
coupon: undefined,
});
});
test('should get correct pro plan price for checking out', async t => {
const { app, service, u1, stripe, feature } = t.context;
// non-ea user
@@ -6,6 +6,7 @@ import { AppModule } from '../app.module';
import {
CANARY_CLIENT_VERSION_MAX_AGE_DAYS,
ConfigFactory,
hasNewerVersion,
UseNamedGuard,
} from '../base';
import { Public } from '../core/auth/guard';
@@ -249,3 +250,11 @@ test('should reject old canary date version in canary namespace', async t => {
env.NAMESPACE = prevNamespace;
}
});
test('should compare release versions for available upgrades', t => {
t.false(hasNewerVersion('0.26.5', '0.26.4'));
t.false(hasNewerVersion('0.26.5', '0.26.5'));
t.true(hasNewerVersion('0.26.5', '0.26.6'));
t.true(hasNewerVersion('0.26.5', '0.26.6-beta.1'));
t.false(hasNewerVersion('0.26.6-beta.2', '0.26.6-beta.1'));
});
@@ -117,6 +117,22 @@ test('should remove job from queue', async t => {
t.is(nullData, undefined);
t.is(nullJob, undefined);
});
test('should remove jobs by payload predicate', async t => {
const keep = await queue.add('nightly.__test__job', { name: 'keep' });
const remove = await queue.add('nightly.__test__job', { name: 'remove' });
const other = await queue.add('nightly.__test__job2', { name: 'remove' });
const removed = await queue.removeWhere(
'nightly.__test__job',
job => job.name === 'remove'
);
t.deepEqual(removed, [{ name: 'remove' }]);
t.truthy(await queue.get(keep.id!, 'nightly.__test__job'));
t.is(await queue.get(remove.id!, 'nightly.__test__job'), undefined);
t.truthy(await queue.get(other.id!, 'nightly.__test__job2'));
});
// #endregion
// #region executor
@@ -55,6 +55,14 @@ defineModuleConfig('job', {
schema,
},
'queues.calendar': {
desc: 'The config for calendar job queue',
default: {
concurrency: 4,
},
schema,
},
'queues.doc': {
desc: 'The config for doc job queue',
default: {
@@ -28,6 +28,7 @@ export enum Queue {
DOC = 'doc',
COPILOT = 'copilot',
INDEXER = 'indexer',
CALENDAR = 'calendar',
}
export const QUEUES = Object.values(Queue);
@@ -55,6 +55,39 @@ export class JobQueue {
return undefined;
}
async removeWhere<T extends JobName>(
jobName: T,
predicate: (payload: Jobs[T]) => boolean | Promise<boolean>
): Promise<Jobs[T][]> {
const ns = namespace(jobName);
const queue = this.getQueue(ns);
const jobs = (await queue.getJobs([
'waiting',
'delayed',
'prioritized',
'paused',
'waiting-children',
])) as Job<JobData<T>>[];
const removed: Jobs[T][] = [];
for (const job of jobs) {
if (job.name !== jobName) {
continue;
}
const payload = job.data.payload;
if (!(await predicate(payload))) {
continue;
}
await job.remove();
this.logger.log(`Job ${jobName}(id=${job.id}) removed from queue ${ns}`);
removed.push(payload);
}
return removed;
}
async get<T extends JobName>(jobId: string, jobName: T) {
const ns = namespace(jobName);
const queue = this.getQueue(ns);
@@ -1,3 +1,5 @@
import semver from 'semver';
const DAY_MS = 24 * 60 * 60 * 1000;
// Example: 2026.2.6-canary.015
@@ -89,3 +91,26 @@ export function checkCanaryDateClientVersion(
normalized: parsed.normalized,
};
}
function normalizeComparableVersion(version: string): string | null {
const canary = parseCanaryDateClientVersion(version);
return semver.valid(canary?.normalized ?? version.trim(), {
loose: true,
});
}
export function hasNewerVersion(
currentVersion: string,
nextVersion: string
): boolean {
const current = normalizeComparableVersion(currentVersion);
const next = normalizeComparableVersion(nextVersion);
if (!current || !next) {
return currentVersion.trim() !== nextVersion.trim();
}
return semver.gt(next, current, {
loose: true,
});
}
@@ -12,7 +12,7 @@ import {
} from '@nestjs/graphql';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, URLHelper } from '../../base';
import { Config, hasNewerVersion, URLHelper } from '../../base';
import { Namespace } from '../../env';
import { Feature, type WorkspaceFeatureName } from '../../models';
import { CurrentUser, Public } from '../auth';
@@ -143,7 +143,7 @@ export class ServerConfigResolver {
}>;
const latest = releases.at(0);
if (!latest || latest.name === env.version) {
if (!latest || !hasNewerVersion(env.version, latest.name)) {
return null;
}
@@ -0,0 +1,243 @@
import test from 'ava';
import Sinon from 'sinon';
import { Mockers } from '../../../__tests__/mocks';
import { createTestingModule } from '../../../__tests__/utils';
import { Cache } from '../../../base';
import { Models } from '../../../models';
import { MailJob } from '../job';
import { MailSender } from '../sender';
let module: Awaited<ReturnType<typeof createTestingModule>>;
let cache: Cache;
let mailJob: MailJob;
let sender: MailSender;
let models: Models;
test.before(async () => {
module = await createTestingModule();
cache = module.get(Cache);
mailJob = module.get(MailJob);
sender = module.get(MailSender);
models = module.get(Models);
});
test.after.always(async () => {
await module.close();
});
test.afterEach(() => {
Sinon.restore();
});
test('should clear pending mail records when user is deleted', async t => {
const user = await module.create(Mockers.User);
const another = await module.create(Mockers.User);
const sendMailKey = 'mailjob:sendMail';
const retryMailKey = 'mailjob:sendMail:retry';
const userKey = `${sendMailKey}:SignIn:${user.email}`;
const userRetryKey = `${sendMailKey}:VerifyEmail:${user.email}`;
const anotherKey = `${sendMailKey}:SignIn:${another.email}`;
await cache.mapSet(sendMailKey, userKey, 1);
await cache.mapSet(sendMailKey, anotherKey, 1);
await cache.mapSet(
retryMailKey,
userRetryKey,
JSON.stringify({
startTime: Date.now(),
name: 'VerifyEmail',
to: user.email,
props: { url: 'https://affine.pro/verify' },
})
);
await mailJob.onUserDeleted({ ...user, ownedWorkspaces: [] });
t.true(module.queue.removeWhere.calledOnce);
t.is(module.queue.removeWhere.firstCall.args[0], 'notification.sendMail');
const shouldRemove = module.queue.removeWhere.firstCall.args[1];
t.true(
await shouldRemove({
to: user.email,
} as Jobs['notification.sendMail'])
);
t.false(
await shouldRemove({
to: another.email,
} as Jobs['notification.sendMail'])
);
t.is(await cache.mapGet(sendMailKey, userKey), undefined);
t.is(await cache.mapGet(retryMailKey, userRetryKey), undefined);
t.is(await cache.mapGet(sendMailKey, anotherKey), 1);
});
test('should skip queued mail for disabled recipient', async t => {
const user = await module.create(Mockers.User, { disabled: true });
const send = Sinon.stub(sender, 'send').resolves(true);
await mailJob.sendMail({
startTime: Date.now(),
name: 'SignIn',
to: user.email,
props: {
url: 'https://affine.pro/sign-in',
otp: '123456',
},
});
t.false(send.called);
t.truthy(await models.user.get(user.id, { withDisabled: true }));
});
test('should drop expired mail retry', async t => {
const send = Sinon.stub(sender, 'send').resolves(true);
await mailJob.sendMail({
startTime: Date.now() - 25 * 60 * 60 * 1000,
name: 'SignIn',
to: 'expired-retry@example.com',
props: {
url: 'https://affine.pro/sign-in',
otp: '123456',
},
});
t.false(send.called);
});
test('should drop time-sensitive mail after its business expiration', async t => {
const send = Sinon.stub(sender, 'send').resolves(true);
await mailJob.sendMail({
startTime: Date.now() - 31 * 60 * 1000,
name: 'SignIn',
to: 'expired-sign-in@example.com',
props: {
url: 'https://affine.pro/sign-in',
otp: '123456',
},
});
t.false(send.called);
});
test('should use explicit mail expiration when provided', async t => {
const send = Sinon.stub(sender, 'send').resolves(true);
await mailJob.sendMail({
startTime: Date.now(),
expiresAt: Date.now() - 1,
name: 'MemberInvitation',
to: 'expired-invitation@example.com',
props: {
user: {
$$userId: 'owner-id',
},
workspace: {
$$workspaceId: 'workspace-id',
},
url: 'https://affine.pro/invite/test',
},
});
t.false(send.called);
});
test('should drop mail retry after max attempts', async t => {
const send = Sinon.stub(sender, 'send').resolves(true);
await mailJob.sendMail({
startTime: Date.now(),
retryCount: 12,
name: 'SignIn',
to: 'max-retry@example.com',
props: {
url: 'https://affine.pro/sign-in',
otp: '123456',
},
});
t.false(send.called);
});
test('should requeue legacy stringified retry mail', async t => {
const retryMailKey = 'mailjob:sendMail:retry';
const job: Jobs['notification.sendMail'] = {
startTime: Date.now(),
name: 'SignIn',
to: 'legacy-retry@example.com',
props: {
url: 'https://affine.pro/sign-in',
otp: '123456',
},
};
const cacheKey = `${retryMailKey}:SignIn:${job.to}`;
Sinon.stub(cache, 'mapRandomKey')
.onFirstCall()
.resolves(cacheKey)
.onSecondCall()
.resolves(undefined);
await cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
await mailJob.sendRetryMails();
t.true(module.queue.add.calledWith('notification.sendMail', job));
t.is(await cache.mapGet(retryMailKey, cacheKey), undefined);
});
test('should skip member invitation mail when rendered workspace name contains domain', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner: { id: owner.id },
name: 'BTC https://spam.example',
});
const send = Sinon.stub(sender, 'send').resolves(true);
await mailJob.sendMail({
startTime: Date.now(),
name: 'MemberInvitation',
to: member.email,
props: {
user: {
$$userId: owner.id,
},
workspace: {
$$workspaceId: workspace.id,
},
url: 'https://affine.pro/invite/test',
},
});
t.false(send.called);
});
test('should keep dynamic mail props untouched for retry', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner: { id: owner.id },
name: 'Safe Workspace',
});
Sinon.stub(sender, 'send').resolves(false);
const job: Jobs['notification.sendMail'] = {
startTime: Date.now(),
name: 'MemberInvitation',
to: member.email,
props: {
user: {
$$userId: owner.id,
},
workspace: {
$$workspaceId: workspace.id,
},
url: 'https://affine.pro/invite/test',
},
};
await mailJob.sendMail(job);
t.deepEqual(job.props.user, { $$userId: owner.id });
t.deepEqual(job.props.workspace, { $$workspaceId: workspace.id });
});
+130 -12
View File
@@ -2,12 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { getStreamAsBuffer } from 'get-stream';
import { Cache, JOB_SIGNAL, JobQueue, OnJob, sleep } from '../../base';
import { Cache, JOB_SIGNAL, JobQueue, OnEvent, OnJob, sleep } from '../../base';
import { type MailName, MailProps, Renderers } from '../../mails';
import { UserProps, WorkspaceProps } from '../../mails/components';
import { Models } from '../../models';
import { DocReader } from '../doc/reader';
import { WorkspaceBlobStorage } from '../storage';
import { containsUrlOrDomain } from '../workspaces/abuse';
import { MailSender, SendOptions } from './sender';
type DynamicallyFetchedProps<Props> = {
@@ -35,7 +36,11 @@ type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
declare global {
interface Jobs {
'notification.sendMail': { startTime: number } & {
'notification.sendMail': {
startTime: number;
retryCount?: number;
expiresAt?: number;
} & {
[K in MailName]: SendMailJob<K>;
}[MailName];
}
@@ -47,6 +52,19 @@ const sendMailCacheKey = (name: string, to: string) =>
`${sendMailKey}:${name}:${to}`;
const retryMaxPerTick = 20;
const retryFirstTime = 3;
const retryMaxAttempts = 12;
const retryMaxAge = 24 * 60 * 60 * 1000;
const magicLinkExpiresIn = 30 * 60 * 1000;
const mailExpiresIn: Partial<Record<MailName, number>> = {
SignIn: magicLinkExpiresIn,
SignUp: magicLinkExpiresIn,
SetPassword: magicLinkExpiresIn,
ChangePassword: magicLinkExpiresIn,
VerifyEmail: magicLinkExpiresIn,
ChangeEmail: magicLinkExpiresIn,
VerifyChangeEmail: magicLinkExpiresIn,
};
@Injectable()
export class MailJob {
@@ -66,17 +84,65 @@ export class MailJob {
return Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000);
}
private getRetryExhaustedReason({
startTime,
retryCount,
expiresAt,
name,
}: Jobs['notification.sendMail']) {
const expiredAt =
expiresAt ?? startTime + (mailExpiresIn[name] ?? retryMaxAge);
if (Date.now() > expiredAt) {
return 'expired';
}
if ((retryCount ?? 0) > retryMaxAttempts) {
return 'max attempts reached';
}
return;
}
private async shouldSkipRecipient(to: string) {
const user = await this.models.user.getUserByEmail(to, {
withDisabled: true,
});
return user?.disabled === true;
}
private async deleteRecipientMailCache(to: string) {
const suffix = `:${to}`;
await Promise.all(
[sendMailKey, retryMailKey].map(async map => {
const keys = await this.cache.mapKeys(map);
await Promise.all(
keys
.filter(key => key.endsWith(suffix))
.map(key => this.cache.mapDelete(map, key))
);
})
);
}
private async sendMailInternal({
startTime,
name,
to,
props,
}: Jobs['notification.sendMail']) {
let options: Partial<SendOptions> = {};
if (await this.shouldSkipRecipient(to)) {
this.logger.debug(`Skip mail [${name}] to disabled user [${to}]`);
return;
}
for (const key in props) {
let options: Partial<SendOptions> = {};
const renderedProps = { ...props };
for (const key in renderedProps) {
// @ts-expect-error allow
const val = props[key];
const val = renderedProps[key];
if (val && typeof val === 'object') {
if ('$$workspaceId' in val) {
const workspaceProps = await this.fetchWorkspaceProps(
@@ -87,6 +153,16 @@ export class MailJob {
return;
}
if (
name === 'MemberInvitation' &&
containsUrlOrDomain(workspaceProps.name)
) {
this.logger.warn(
`Skip mail [${name}] to [${to}], reason=workspace name contains url or domain`
);
return;
}
if (workspaceProps.avatar) {
options.attachments = [
{
@@ -99,7 +175,7 @@ export class MailJob {
workspaceProps.avatar = 'cid:workspaceAvatar';
}
// @ts-expect-error replacement
props[key] = workspaceProps;
renderedProps[key] = workspaceProps;
} else if ('$$userId' in val) {
const userProps = await this.fetchUserProps(val.$$userId);
@@ -108,17 +184,30 @@ export class MailJob {
}
// @ts-expect-error replacement
props[key] = userProps;
renderedProps[key] = userProps;
}
}
}
if (
name === 'MemberInvitation' &&
'workspace' in renderedProps &&
containsUrlOrDomain(
(renderedProps.workspace as WorkspaceProps | undefined)?.name
)
) {
this.logger.warn(
`Skip mail [${name}] to [${to}], reason=workspace name contains url or domain`
);
return;
}
try {
const result = await this.sender.send(name, {
to,
...(await Renderers[name](
// @ts-expect-error the job trigger part has been typechecked
props
renderedProps
)),
...options,
});
@@ -130,7 +219,7 @@ export class MailJob {
}
return undefined;
} catch (e) {
this.logger.error(`Failed to send mail [${name}] to [${to}]`, e);
this.logger.error(`Failed to send mail [${name}] to [${to}]`, e, props);
// wait for a while before retrying
const retryDelay = this.calculateRetryDelay(startTime);
await sleep(retryDelay);
@@ -177,17 +266,41 @@ export class MailJob {
@OnJob('notification.sendMail')
async sendMail(job: Jobs['notification.sendMail']) {
const cacheKey = sendMailCacheKey(job.name, job.to);
job.retryCount = (job.retryCount ?? 0) + 1;
const exhaustedReason = this.getRetryExhaustedReason(job);
if (exhaustedReason) {
this.logger.warn(
`Drop mail [${job.name}] to [${job.to}], reason=${exhaustedReason}`
);
await Promise.all([
this.cache.mapDelete(sendMailKey, cacheKey),
this.cache.mapDelete(retryMailKey, cacheKey),
]);
return;
}
const retried = await this.cache.mapIncrease(sendMailKey, cacheKey, 1);
if (retried <= retryFirstTime) {
const ret = await this.sendMailInternal(job);
if (!ret) await this.cache.mapDelete(sendMailKey, cacheKey);
return ret;
}
await this.cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
await this.cache.mapSet(retryMailKey, cacheKey, job);
await this.cache.mapDelete(sendMailKey, cacheKey);
return undefined;
}
@OnEvent('user.deleted')
async onUserDeleted(user: Events['user.deleted']) {
await Promise.all([
this.deleteRecipientMailCache(user.email),
this.queue.removeWhere(
'notification.sendMail',
job => job.to === user.email
),
]);
}
@Cron(CronExpression.EVERY_MINUTE)
async sendRetryMails() {
// pick random one from the retry map
@@ -195,9 +308,14 @@ export class MailJob {
let key = await this.cache.mapRandomKey(retryMailKey);
while (key && processed < retryMaxPerTick) {
try {
const job = await this.cache.mapGet<string>(retryMailKey, key);
const job = await this.cache.mapGet<
Jobs['notification.sendMail'] | string
>(retryMailKey, key);
if (job) {
const jobData = JSON.parse(job) as Jobs['notification.sendMail'];
const jobData =
typeof job === 'string'
? (JSON.parse(job) as Jobs['notification.sendMail'])
: job;
await this.queue.add('notification.sendMail', jobData);
// wait for a while before retrying
const retryDelay = this.calculateRetryDelay(jobData.startTime);
@@ -140,7 +140,11 @@ export class MailSender {
return true;
} catch (e) {
metrics.mail.counter('failed_total').add(1, { name });
this.logger.error(`Failed to send mail [${name}].`, e);
this.logger.error(`Failed to send mail [${name}].`, e, {
subject: options.subject,
from: options.from,
to: options.to,
});
return false;
}
}
@@ -0,0 +1,55 @@
import { isIP } from 'node:net';
import { hostname as getHostname } from 'node:os';
const hostnameLabelRegexp = /^[A-Za-z0-9-]+$/;
function isValidSMTPAddressLiteral(hostname: string) {
if (!hostname.startsWith('[') || !hostname.endsWith(']')) return false;
const literal = hostname.slice(1, -1);
if (!literal || literal.includes(' ')) return false;
if (isIP(literal) === 4) return true;
if (literal.startsWith('IPv6:')) {
return isIP(literal.slice('IPv6:'.length)) === 6;
}
return false;
}
export function normalizeSMTPHeloHostname(hostname?: string) {
if (!hostname) return undefined;
const normalized = hostname.trim().replace(/\.$/, '');
if (!normalized) return undefined;
if (isValidSMTPAddressLiteral(normalized)) return normalized;
if (normalized.length > 253) return undefined;
const labels = normalized.split('.');
for (const label of labels) {
if (!label || label.length > 63) return undefined;
if (
!hostnameLabelRegexp.test(label) ||
label.startsWith('-') ||
label.endsWith('-')
) {
return undefined;
}
}
return normalized;
}
function readSystemHostname() {
try {
return getHostname();
} catch {
return '';
}
}
export function resolveSMTPHeloHostname(configuredName: string) {
const normalizedConfiguredName = normalizeSMTPHeloHostname(configuredName);
if (normalizedConfiguredName) return normalizedConfiguredName;
return normalizeSMTPHeloHostname(readSystemHostname());
}
@@ -87,6 +87,29 @@ test('should create invitation notification and email', async t => {
t.is(invitationMail.payload.name, 'MemberInvitation');
});
test('should not send invitation email when workspace name contains domain', async t => {
const spamWorkspace = await module.create(Mockers.Workspace, {
owner: {
id: owner.id,
},
name: 'BTC https://spam.example',
});
const inviteId = randomUUID();
const invitationMailCount = module.queue.count('notification.sendMail');
const notification = await notificationService.createInvitation({
userId: member.id,
body: {
workspaceId: spamWorkspace.id,
createdByUserId: owner.id,
inviteId,
},
});
t.truthy(notification);
t.is(module.queue.count('notification.sendMail'), invitationMailCount);
});
test('should not send invitation email if user setting is not to receive invitation email', async t => {
const inviteId = randomUUID();
await module.create(Mockers.UserSettings, {
@@ -22,6 +22,7 @@ import {
generateWorkspaceSettingsPath,
WorkspaceSettingsTab,
} from '../utils/workspace';
import { containsUrlOrDomain } from '../workspaces/abuse';
@Injectable()
export class NotificationService {
@@ -151,6 +152,16 @@ export class NotificationService {
}
private async sendInvitationEmail(input: InvitationNotificationCreate) {
const workspace = await this.docReader.getWorkspaceContent(
input.body.workspaceId
);
if (containsUrlOrDomain(workspace?.name)) {
this.logger.warn(
`Skip invitation email for workspace ${input.body.workspaceId}, reason=workspace name contains url or domain`
);
return;
}
const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`);
if (env.dev) {
// make it easier to test in dev mode
@@ -0,0 +1,12 @@
export const SHARE_ACTION_ACCOUNT_AGE_MS = 24 * 60 * 60 * 1000;
const URL_OR_DOMAIN_PATTERN =
/(?:https?:\/\/|www\.|(?<![@\w-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?:[/?#:]|$))/i;
export function containsUrlOrDomain(value: string | null | undefined) {
return URL_OR_DOMAIN_PATTERN.test(value ?? '');
}
export function isUserOldEnoughForShareActions(user: { createdAt: Date }) {
return Date.now() - user.createdAt.getTime() >= SHARE_ACTION_ACCOUNT_AGE_MS;
}
@@ -15,6 +15,7 @@ import { PrismaClient } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import {
ActionForbidden,
Cache,
DocActionDenied,
DocDefaultRoleCanNotBeOwner,
@@ -40,6 +41,7 @@ import {
DocRole,
} from '../../permission';
import { PublicUserType, WorkspaceUserType } from '../../user';
import { isUserOldEnoughForShareActions } from '../abuse';
import { WorkspaceType } from '../types';
import { TimeBucket, TimeWindow } from './analytics-types';
import {
@@ -299,6 +301,15 @@ export class WorkspaceDocResolver {
private readonly cache: Cache
) {}
private async assertCanShare(userId: string) {
const user = await this.models.user.get(userId);
if (!user || !isUserOldEnoughForShareActions(user)) {
throw new ActionForbidden(
'Sharing links is unavailable during the first 24 hours after signup.'
);
}
}
@ResolveField(() => WorkspaceDocMeta, {
description: 'Cloud page metadata of workspace',
complexity: 2,
@@ -413,6 +424,7 @@ export class WorkspaceDocResolver {
}
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
await this.assertCanShare(user.id);
const doc = await this.models.doc.publish(workspaceId, docId, mode);
@@ -15,6 +15,7 @@ import {
import { nanoid } from 'nanoid';
import {
ActionForbidden,
ActionForbiddenOnNonTeamWorkspace,
AlreadyInSpace,
AuthenticationRequired,
@@ -40,6 +41,7 @@ import { AccessController, WorkspaceRole } from '../../permission';
import { QuotaService } from '../../quota';
import { UserType } from '../../user';
import { validators } from '../../utils/validators';
import { containsUrlOrDomain, isUserOldEnoughForShareActions } from '../abuse';
import { WorkspaceService } from '../service';
import {
InvitationType,
@@ -68,6 +70,24 @@ export class WorkspaceMemberResolver {
private readonly quota: QuotaService
) {}
private async assertCanInviteOrShare(userId: string) {
const user = await this.models.user.get(userId);
if (!user || !isUserOldEnoughForShareActions(user)) {
throw new ActionForbidden(
'Inviting members and creating share links are unavailable during the first 24 hours after signup.'
);
}
}
private async assertWorkspaceNameCanInvite(workspaceId: string) {
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
if (containsUrlOrDomain(workspace.name)) {
throw new ActionForbidden(
'Workspace names containing links or domains cannot be used to invite members.'
);
}
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
@@ -141,6 +161,8 @@ export class WorkspaceMemberResolver {
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
await this.assertCanInviteOrShare(me.id);
await this.assertWorkspaceNameCanInvite(workspaceId);
if (emails.length > 512) {
throw new TooManyRequest();
@@ -272,6 +294,8 @@ export class WorkspaceMemberResolver {
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
await this.assertCanInviteOrShare(user.id);
await this.assertWorkspaceNameCanInvite(workspaceId);
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
+20 -45
View File
@@ -83,95 +83,70 @@ export const Renderers = {
//#region Workspace
MemberInvitation: make(
Invitation,
props => `${props.user.email} invited you to join ${props.workspace.name}`
'You were invited to join a workspace on AFFiNE'
),
MemberAccepted: make(
InvitationAccepted,
props => `${props.user.email} accepted your invitation`
),
MemberLeave: make(
MemberLeave,
props => `${props.user.email} left ${props.workspace.name}`
'Your workspace invitation was accepted'
),
MemberLeave: make(MemberLeave, 'A workspace member left'),
LinkInvitationReviewRequest: make(
LinkInvitationReviewRequest,
props => `New request to join ${props.workspace.name}`
'New request to join a workspace'
),
LinkInvitationApprove: make(
LinkInvitationApproved,
props => `Your request to join ${props.workspace.name} has been approved`
'Your request to join a workspace has been approved'
),
LinkInvitationDecline: make(
LinkInvitationReviewDeclined,
props => `Your request to join ${props.workspace.name} was declined`
),
MemberRemoved: make(
MemberRemoved,
props => `You have been removed from ${props.workspace.name}`
'Your request to join a workspace was declined'
),
MemberRemoved: make(MemberRemoved, 'You have been removed from a workspace'),
OwnershipTransferred: make(
OwnershipTransferred,
props => `Your ownership of ${props.workspace.name} has been transferred`
'Your workspace ownership has been transferred'
),
OwnershipReceived: make(
OwnershipReceived,
props => `You are now the owner of ${props.workspace.name}`
'You are now the owner of a workspace'
),
//#endregion
//#region Doc
Mention: make(
Mention,
props => `${props.user.email} mentioned you in ${props.doc.title}`
),
Comment: make(
Comment,
props => `${props.user.email} commented on ${props.doc.title}`
),
CommentMention: make(
CommentMention,
props =>
`${props.user.email} mentioned you in a comment on ${props.doc.title}`
),
Mention: make(Mention, 'You were mentioned in AFFiNE'),
Comment: make(Comment, 'New comment in AFFiNE'),
CommentMention: make(CommentMention, 'You were mentioned in a comment'),
//#endregion
//#region Team
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
props.isOwner
? 'Your workspace has been upgraded to team workspace! 🎉'
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
),
TeamBecomeAdmin: make(
TeamBecomeAdmin,
props => `You are now an admin of ${props.workspace.name}`
: 'A workspace has been upgraded to team workspace! 🎉'
),
TeamBecomeAdmin: make(TeamBecomeAdmin, 'You are now a workspace admin'),
TeamBecomeCollaborator: make(
TeamBecomeCollaborator,
props => `Your role has been changed in ${props.workspace.name}`
'Your workspace role has been changed'
),
TeamDeleteIn24Hours: make(
TeamDeleteIn24Hours,
props =>
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
'[Action Required] Final warning: Your workspace will be deleted in 24 hours'
),
TeamDeleteInOneMonth: make(
TeamDeleteInOneMonth,
props =>
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
'[Action Required] Important: Your workspace will be deleted soon'
),
TeamWorkspaceDeleted: make(
TeamWorkspaceDeleted,
props => `Your workspace ${props.workspace.name} has been deleted`
'Your workspace has been deleted'
),
TeamWorkspaceExpireSoon: make(
TeamExpireSoon,
props =>
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
),
TeamWorkspaceExpired: make(
TeamExpired,
props => `Your ${props.workspace.name} team workspace has expired`
'[Action Required] Your team workspace will expire soon'
),
TeamWorkspaceExpired: make(TeamExpired, 'Your team workspace has expired'),
//#endregion
//#region License
@@ -92,7 +92,7 @@ export class CalendarAccountModel extends BaseModel {
scope: input.scope ?? null,
status: input.status ?? 'active',
lastError: input.lastError ?? null,
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 30,
};
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
@@ -17,6 +17,8 @@ export interface UpsertCalendarSubscriptionInput {
export interface UpdateCalendarSubscriptionSyncInput {
syncToken?: string | null;
lastSyncAt?: Date | null;
nextSyncAt?: Date;
syncRetryCount?: number;
}
export interface UpdateCalendarSubscriptionChannelInput {
@@ -81,13 +83,21 @@ export class CalendarSubscriptionModel extends BaseModel {
}
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
return await this.db.calendarSubscription.update({
where: { id },
data: {
syncToken: input.syncToken ?? null,
lastSyncAt: input.lastSyncAt ?? null,
},
});
const data: Prisma.CalendarSubscriptionUncheckedUpdateInput = {};
if (input.syncToken !== undefined) {
data.syncToken = input.syncToken ?? null;
}
if (input.lastSyncAt !== undefined) {
data.lastSyncAt = input.lastSyncAt ?? null;
}
if (input.nextSyncAt !== undefined) {
data.nextSyncAt = input.nextSyncAt;
}
if (input.syncRetryCount !== undefined) {
data.syncRetryCount = input.syncRetryCount;
}
return await this.db.calendarSubscription.update({ where: { id }, data });
}
async updateChannel(
@@ -155,10 +165,16 @@ export class CalendarSubscriptionModel extends BaseModel {
});
}
async listAllWithAccountForSync() {
async listDueForSync(now: Date, limit: number) {
return await this.db.calendarSubscription.findMany({
where: { enabled: true },
include: { account: true },
where: {
enabled: true,
nextSyncAt: { lte: now },
account: { status: 'active' },
},
select: { id: true },
orderBy: { nextSyncAt: 'asc' },
take: limit,
});
}
@@ -169,13 +185,6 @@ export class CalendarSubscriptionModel extends BaseModel {
});
}
async updateLastSyncAt(id: string, lastSyncAt: Date) {
return await this.db.calendarSubscription.update({
where: { id },
data: { lastSyncAt },
});
}
async clearSyncTokensByAccount(accountId: string) {
return await this.db.calendarSubscription.updateMany({
where: { accountId },
@@ -200,6 +209,7 @@ export class CalendarSubscriptionModel extends BaseModel {
data: {
enabled: false,
syncToken: null,
syncRetryCount: 0,
customChannelId: null,
customResourceId: null,
channelExpiration: null,
@@ -14,6 +14,7 @@ import type {
} from '../../../models';
import { Models } from '../../../models';
import { CalendarModule } from '../index';
import { CalendarCronJobs } from '../cron';
import {
CalendarProvider,
CalendarProviderFactory,
@@ -85,6 +86,7 @@ const module = await createModule({
],
});
const calendarService = module.get(CalendarService);
const calendarCronJobs = module.get(CalendarCronJobs);
const providerFactory = module.get(CalendarProviderFactory);
const models = module.get(Models);
module.get(CryptoHelper).onConfigInit();
@@ -113,6 +115,8 @@ const createSubscription = async (
accountId: string,
overrides: Partial<UpsertCalendarSubscriptionInput> & {
syncToken?: string | null;
nextSyncAt?: Date;
syncRetryCount?: number;
customChannelId?: string | null;
customResourceId?: string | null;
channelExpiration?: Date | null;
@@ -134,6 +138,20 @@ const createSubscription = async (
});
}
if (
overrides.nextSyncAt !== undefined ||
overrides.syncRetryCount !== undefined
) {
await models.calendarSubscription.updateSync(subscription.id, {
...(overrides.nextSyncAt !== undefined
? { nextSyncAt: overrides.nextSyncAt }
: {}),
...(overrides.syncRetryCount !== undefined
? { syncRetryCount: overrides.syncRetryCount }
: {}),
});
}
if (
overrides.customChannelId !== undefined ||
overrides.customResourceId !== undefined ||
@@ -151,6 +169,8 @@ const createSubscription = async (
test.afterEach.always(() => {
mock.reset();
module.queue.add.resetHistory();
module.queue.remove.resetHistory();
});
test.after.always(async () => {
@@ -252,6 +272,9 @@ test('syncSubscription resets invalid sync token and maps events', async t => {
const updated = await models.calendarSubscription.get(subscription.id);
t.is(updated?.syncToken, 'next-token');
t.truthy(updated?.lastSyncAt);
t.is(updated?.syncRetryCount, 0);
t.truthy(updated?.nextSyncAt);
t.true(updated!.nextSyncAt.getTime() > updated!.lastSyncAt!.getTime());
const events = await models.calendarEvent.listBySubscriptionsInRange(
[subscription.id],
@@ -493,51 +516,22 @@ test('syncSubscription applies exponential backoff for repeated failures', async
mock.method(Date, 'now', () => now);
await calendarService.syncSubscription(subscription.id);
await calendarService.syncSubscription(subscription.id);
let updated = await models.calendarSubscription.get(subscription.id);
t.is(listEventsMock.mock.callCount(), 1);
t.is(updated?.syncRetryCount, 1);
t.is(
updated?.nextSyncAt.toISOString(),
new Date(now + baseDelayMs).toISOString()
);
now += baseDelayMs + 1000;
await calendarService.syncSubscription(subscription.id);
updated = await models.calendarSubscription.get(subscription.id);
t.is(listEventsMock.mock.callCount(), 2);
now += baseDelayMs + 1000;
await calendarService.syncSubscription(subscription.id);
t.is(listEventsMock.mock.callCount(), 2);
});
test('syncSubscription skips token refresh while in backoff window', async t => {
let now = new Date('2026-01-01T00:00:00.000Z').getTime();
mock.method(Date, 'now', () => now);
const user = await module.create(Mockers.User);
const account = await createAccount(user.id, {
accessToken: 'expired-access-token',
expiresAt: new Date(now - 5 * 60 * 1000),
});
const subscription = await createSubscription(account.id, {
syncToken: 'sync-token',
});
const provider = new MockCalendarProvider();
const refreshMock = mock.method(provider, 'refreshTokens', async () => ({
accessToken: `refreshed-${randomUUID()}`,
}));
const listEventsMock = mock.method(provider, 'listEvents', async () => {
throw new Error('upstream timeout');
});
mock.method(providerFactory, 'get', () => provider);
const baseDelayMs = 5 * 60 * 1000;
await calendarService.syncSubscription(subscription.id);
await calendarService.syncSubscription(subscription.id);
t.is(refreshMock.mock.callCount(), 1);
t.is(listEventsMock.mock.callCount(), 1);
now += baseDelayMs + 1000;
await calendarService.syncSubscription(subscription.id);
t.is(refreshMock.mock.callCount(), 2);
t.is(listEventsMock.mock.callCount(), 2);
t.is(updated?.syncRetryCount, 2);
t.is(
updated?.nextSyncAt.toISOString(),
new Date(now + baseDelayMs * 2).toISOString()
);
});
test('syncSubscription renews webhook channel when expiring', async t => {
@@ -599,3 +593,73 @@ test('syncSubscription renews webhook channel when expiring', async t => {
t.is(updated?.customResourceId, 'new-resource');
t.truthy(updated?.channelExpiration);
});
test('syncSubscription keeps schedule moving when webhook renewal fails', async t => {
const now = new Date('2026-01-01T00:00:00.000Z').getTime();
mock.method(Date, 'now', () => now);
const user = await module.create(Mockers.User);
const account = await createAccount(user.id, {
refreshIntervalMinutes: 60,
});
const subscription = await createSubscription(account.id, {
syncToken: 'sync-token',
channelExpiration: new Date(Date.now() + 60 * 60 * 1000),
});
const provider = new MockCalendarProvider();
mock.method(provider, 'listEvents', async () => ({
events: [],
nextSyncToken: 'next-sync',
}));
mock.method(provider, 'watchCalendar', async () => {
throw new Error('watch failed');
});
mock.method(providerFactory, 'get', () => provider);
await calendarService.syncSubscription(subscription.id);
const updated = await models.calendarSubscription.get(subscription.id);
t.truthy(updated?.lastSyncAt);
t.is(updated?.syncRetryCount, 0);
t.is(
updated?.nextSyncAt.toISOString(),
new Date(now + 15 * 60 * 1000).toISOString()
);
});
test('pollAccounts skips when nothing is due', async t => {
mock.method(models.calendarSubscription, 'listDueForSync', async () => []);
await calendarCronJobs.pollAccounts();
t.is(module.queue.count('calendar.syncSubscription'), 0);
});
test('pollAccounts enqueues due subscriptions only', async t => {
mock.method(models.calendarSubscription, 'listDueForSync', async () => [
{ id: 'due-subscription-a' },
{ id: 'due-subscription-b' },
]);
await calendarCronJobs.pollAccounts();
t.is(module.queue.count('calendar.syncSubscription'), 2);
t.deepEqual(
module.queue.add
.getCalls()
.map(call => [call.args[0], call.args[1], call.args[2]]),
[
[
'calendar.syncSubscription',
{ subscriptionId: 'due-subscription-a', reason: 'polling' },
{ jobId: 'due-subscription-a' },
],
[
'calendar.syncSubscription',
{ subscriptionId: 'due-subscription-b', reason: 'polling' },
{ jobId: 'due-subscription-b' },
],
]
);
});
@@ -8,6 +8,7 @@ export interface CalendarGoogleConfig {
clientSecret: string;
externalWebhookUrl?: string;
webhookVerificationToken?: string;
requestTimeoutMs?: number;
}
export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest';
@@ -49,6 +50,7 @@ const schema: JSONSchema = {
clientSecret: { type: 'string' },
externalWebhookUrl: { type: 'string' },
webhookVerificationToken: { type: 'string' },
requestTimeoutMs: { type: 'number' },
},
};
@@ -88,6 +90,7 @@ defineModuleConfig('calendar', {
clientSecret: '',
externalWebhookUrl: '',
webhookVerificationToken: '',
requestTimeoutMs: 10_000,
},
schema,
shape: z.object({
@@ -101,6 +104,7 @@ defineModuleConfig('calendar', {
.or(z.string().length(0))
.optional(),
webhookVerificationToken: z.string().optional(),
requestTimeoutMs: z.number().int().positive().optional(),
}),
link: 'https://developers.google.com/calendar/api/guides/push',
},
@@ -1,61 +1,33 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue } from '../../base';
import { Models } from '../../models';
import { CalendarService } from './service';
const CALENDAR_POLL_BATCH_SIZE = 200;
@Injectable()
export class CalendarCronJobs {
constructor(
private readonly models: Models,
private readonly calendar: CalendarService
private readonly queue: JobQueue
) {}
@Cron(CronExpression.EVERY_MINUTE)
async pollAccounts() {
const subscriptions =
await this.models.calendarSubscription.listAllWithAccountForSync();
const subscriptions = await this.models.calendarSubscription.listDueForSync(
new Date(),
CALENDAR_POLL_BATCH_SIZE
);
const accountDueAt = new Map<
string,
{ refreshInterval: number; lastSyncAt: Date | null }
>();
for (const subscription of subscriptions) {
const interval = subscription.account.refreshIntervalMinutes ?? 60;
const lastSyncAt = subscription.lastSyncAt ?? null;
const existing = accountDueAt.get(subscription.accountId);
if (!existing) {
accountDueAt.set(subscription.accountId, {
refreshInterval: interval,
lastSyncAt,
});
continue;
}
const earliest =
existing.lastSyncAt && lastSyncAt
? existing.lastSyncAt < lastSyncAt
? existing.lastSyncAt
: lastSyncAt
: (existing.lastSyncAt ?? lastSyncAt);
accountDueAt.set(subscription.accountId, {
refreshInterval: interval,
lastSyncAt: earliest,
});
}
const now = Date.now();
await Promise.allSettled(
Array.from(accountDueAt.entries()).map(([accountId, info]) => {
if (
!info.lastSyncAt ||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
) {
return this.calendar.syncAccount(accountId);
}
return Promise.resolve();
})
subscriptions.map(({ id }) =>
this.queue.add(
'calendar.syncSubscription',
{ subscriptionId: id, reason: 'polling' },
{ jobId: id }
)
)
);
}
}
@@ -7,6 +7,7 @@ import { PermissionModule } from '../../core/permission';
import { WorkspaceModule } from '../../core/workspaces';
import { CalendarController } from './controller';
import { CalendarCronJobs } from './cron';
import { CalendarJob } from './job';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviders } from './providers';
import {
@@ -25,6 +26,7 @@ import { CalendarService } from './service';
...CalendarProviders,
CalendarProviderFactory,
CalendarService,
CalendarJob,
CalendarOAuthService,
CalendarCronJobs,
CalendarServerConfigResolver,
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { OnJob } from '../../base';
import { CalendarService } from './service';
declare global {
interface Jobs {
'calendar.syncSubscription': {
subscriptionId: string;
reason?: 'polling' | 'webhook' | 'on-demand';
};
}
}
@Injectable()
export class CalendarJob {
constructor(private readonly calendar: CalendarService) {}
@OnJob('calendar.syncSubscription')
async syncSubscription({
subscriptionId,
reason,
}: Jobs['calendar.syncSubscription']) {
await this.calendar.syncSubscription(subscriptionId, { reason });
}
}
@@ -152,10 +152,28 @@ export abstract class CalendarProvider {
}
}
protected get requestTimeoutMs() {
const timeout = (this.config as { requestTimeoutMs?: number } | undefined)
?.requestTimeoutMs;
return typeof timeout === 'number' && timeout > 0 ? timeout : undefined;
}
protected withTimeout(signal?: AbortSignal | null) {
const timeoutMs = this.requestTimeoutMs;
if (!timeoutMs) return signal;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;
return AbortSignal.any([signal, timeoutSignal]);
}
protected async fetchJson<T>(url: string, init?: RequestInit) {
const response = await fetch(url, {
headers: { Accept: 'application/json', ...init?.headers },
...init,
signal: this.withTimeout(init?.signal),
headers: { ...init?.headers, Accept: 'application/json' },
});
const body = await response.text();
if (!response.ok) {
@@ -329,6 +329,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
private async fetchWithTokenHandling<T>(url: string, accessToken: string) {
const response = await fetch(url, {
signal: this.withTimeout(),
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
@@ -8,11 +8,11 @@ import { addDays, subDays } from 'date-fns';
import {
CalendarProviderRequestError,
Config,
exponentialBackoffDelay,
GraphqlBadRequest,
Mutex,
JobQueue,
URLHelper,
} from '../../base';
import { SessionRedis } from '../../base/redis';
import { Models } from '../../models';
import type { CalendarCalDAVProviderPreset } from './config';
import {
@@ -28,10 +28,10 @@ import type { LinkCalDAVAccountInput } from './types';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FUTURE_DAYS = 180;
const SYNC_FAILURE_BACKOFF_KEY_PREFIX = 'calendar:sync:backoff:';
const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_TTL_SECONDS = 24 * 60 * 60;
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
const CHANNEL_RENEW_RETRY_MS = 15 * 60 * 1000;
@Injectable()
export class CalendarService {
@@ -41,8 +41,7 @@ export class CalendarService {
constructor(
private readonly models: Models,
private readonly providerFactory: CalendarProviderFactory<CalendarProvider>,
private readonly mutex: Mutex,
private readonly redis: SessionRedis,
private readonly queue: JobQueue,
private readonly config: Config,
private readonly url: URLHelper
) {}
@@ -85,10 +84,24 @@ export class CalendarService {
return null;
}
return await this.models.calendarAccount.updateRefreshInterval(
accountId,
refreshIntervalMinutes
const updatedAccount =
await this.models.calendarAccount.updateRefreshInterval(
accountId,
refreshIntervalMinutes
);
const subscriptions =
await this.models.calendarSubscription.listByAccountForSync(accountId);
await Promise.all(
subscriptions.map(subscription =>
this.models.calendarSubscription.updateSync(subscription.id, {
nextSyncAt: this.calculateNextSyncAt(
subscription.lastSyncAt ?? this.now(),
refreshIntervalMinutes
),
})
)
);
return updatedAccount;
}
async unlinkAccount(userId: string, accountId: string) {
@@ -313,25 +326,6 @@ export class CalendarService {
return;
}
const now = Date.now();
const backoff = await this.getSyncFailureBackoff(subscription.id);
if (backoff && now < backoff.nextRetryAt.getTime()) {
return;
}
await using lock = await this.mutex.acquire(
`calendar:subscription:${subscriptionId}`
);
if (!lock) {
return;
}
const lockedNow = Date.now();
const lockedBackoff = await this.getSyncFailureBackoff(subscription.id);
if (lockedBackoff && lockedNow < lockedBackoff.nextRetryAt.getTime()) {
return;
}
const provider = this.providerFactory.get(
account.provider as CalendarProviderName
);
@@ -415,29 +409,28 @@ export class CalendarService {
}
if (synced) {
await this.clearSyncFailureBackoff(subscription.id);
await this.ensureWebhookChannel(subscription, provider, accessToken);
const syncedAt = this.now();
let nextSyncAt = this.calculateNextSyncAt(
syncedAt,
account.refreshIntervalMinutes
);
try {
await this.ensureWebhookChannel(subscription, provider, accessToken);
} catch (error) {
nextSyncAt = this.calculateChannelRetryAt(nextSyncAt);
this.logger.warn(
`Failed to ensure webhook channel for subscription ${subscription.id}`,
this.toError(error)
);
}
await this.models.calendarSubscription.updateSync(subscription.id, {
lastSyncAt: syncedAt,
nextSyncAt,
syncRetryCount: 0,
});
}
await this.models.calendarSubscription.updateLastSyncAt(
subscription.id,
new Date()
);
}
async syncAccount(accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account || account.status !== 'active') {
return;
}
const subscriptions =
await this.models.calendarSubscription.listByAccountForSync(accountId);
await Promise.allSettled(
subscriptions.map(subscription =>
this.syncSubscription(subscription.id, { reason: 'polling' })
)
);
}
async listWorkspaceEvents(params: {
@@ -455,9 +448,18 @@ export class CalendarService {
params.to
);
const subscriptions =
await this.models.calendarSubscription.listWithAccounts(subscriptionIds);
const staleSubscriptions = subscriptions.filter(
subscription =>
subscription.enabled &&
subscription.account.status === 'active' &&
subscription.nextSyncAt.getTime() <= this.nowMs()
);
Promise.allSettled(
subscriptionIds.map(subscriptionId =>
this.syncSubscription(subscriptionId, { reason: 'on-demand' })
staleSubscriptions.map(subscription =>
this.enqueueSyncSubscription(subscription.id, 'on-demand')
)
).catch(error => {
this.logger.warn('Calendar on-demand sync failed', error as Error);
@@ -513,7 +515,7 @@ export class CalendarService {
return;
}
await this.syncSubscription(subscription.id, { reason: 'webhook' });
await this.enqueueSyncSubscription(subscription.id, 'webhook');
}
getWebhookToken() {
@@ -747,7 +749,7 @@ export class CalendarService {
}
private getSyncWindow() {
const now = new Date();
const now = this.now();
return {
timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(),
timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(),
@@ -767,7 +769,7 @@ export class CalendarService {
if (
accessToken &&
account.expiresAt &&
account.expiresAt.getTime() > Date.now() + TOKEN_REFRESH_SKEW_MS
account.expiresAt.getTime() > this.nowMs() + TOKEN_REFRESH_SKEW_MS
) {
return { accessToken };
}
@@ -831,7 +833,7 @@ export class CalendarService {
return;
}
const renewThreshold = Date.now() + 24 * 60 * 60 * 1000;
const renewThreshold = this.nowMs() + 24 * 60 * 60 * 1000;
if (
subscription.channelExpiration &&
subscription.channelExpiration.getTime() > renewThreshold
@@ -873,6 +875,7 @@ export class CalendarService {
subscription: {
id: string;
externalCalendarId: string;
syncRetryCount: number;
customChannelId: string | null;
customResourceId: string | null;
};
@@ -895,7 +898,6 @@ export class CalendarService {
}
if (this.isTokenInvalidError(params.error)) {
await this.clearSyncFailureBackoff(params.subscription.id);
await this.models.calendarAccount.invalidateAndPurge(
params.account.id,
this.formatSyncError(params.error)
@@ -903,18 +905,14 @@ export class CalendarService {
return;
}
const backoff = await this.bumpSyncFailureBackoff(params.subscription.id);
const interval = params.account.refreshIntervalMinutes ?? 60;
const lastSyncAt = this.calculateLastSyncAtForRetry(
backoff.nextRetryAt,
interval
);
await this.models.calendarSubscription.updateLastSyncAt(
params.subscription.id,
lastSyncAt
);
const attempt = params.subscription.syncRetryCount + 1;
const nextRetryAt = this.calculateFailureRetryAt(attempt);
await this.models.calendarSubscription.updateSync(params.subscription.id, {
nextSyncAt: nextRetryAt,
syncRetryCount: attempt,
});
this.logger.warn(
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${backoff.attempt}, next retry at ${backoff.nextRetryAt.toISOString()}`,
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${attempt}, next retry at ${nextRetryAt.toISOString()}`,
this.toError(params.error)
);
}
@@ -927,15 +925,6 @@ export class CalendarService {
return status === 404;
}
private calculateLastSyncAtForRetry(
nextRetryAt: Date,
refreshIntervalMinutes: number
) {
// Cron schedules by `now - lastSyncAt >= refreshInterval`, so back-calculate
// a synthetic lastSyncAt to defer the next attempt to `nextRetryAt`.
return new Date(nextRetryAt.getTime() - refreshIntervalMinutes * 60 * 1000);
}
private async disableSubscription(params: {
subscriptionId: string;
provider: CalendarProvider;
@@ -966,68 +955,52 @@ export class CalendarService {
await this.models.calendarSubscription.disableAndPurge(
params.subscriptionId
);
await this.clearSyncFailureBackoff(params.subscriptionId);
}
private getSyncFailureBackoffKey(subscriptionId: string) {
return `${SYNC_FAILURE_BACKOFF_KEY_PREFIX}${subscriptionId}`;
}
private async getSyncFailureBackoff(subscriptionId: string) {
const key = this.getSyncFailureBackoffKey(subscriptionId);
const value = await this.redis.get(key);
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as {
attempt?: number;
nextRetryAt?: string;
};
if (!parsed.attempt || !parsed.nextRetryAt) {
return null;
async enqueueSyncSubscription(
subscriptionId: string,
reason: 'polling' | 'webhook' | 'on-demand'
) {
await this.queue.add(
'calendar.syncSubscription',
{
subscriptionId,
reason,
},
{
jobId: subscriptionId,
}
const nextRetryAt = new Date(parsed.nextRetryAt);
if (Number.isNaN(nextRetryAt.getTime())) {
return null;
}
return {
attempt: parsed.attempt,
nextRetryAt,
};
} catch {
return null;
}
);
}
private async bumpSyncFailureBackoff(subscriptionId: string) {
const state = await this.getSyncFailureBackoff(subscriptionId);
const attempt = (state?.attempt ?? 0) + 1;
const delay = Math.min(
SYNC_FAILURE_BACKOFF_BASE_MS * 2 ** (attempt - 1),
SYNC_FAILURE_BACKOFF_MAX_MS
);
const nextRetryAt = new Date(Date.now() + delay);
const key = this.getSyncFailureBackoffKey(subscriptionId);
await this.redis.set(
key,
JSON.stringify({
attempt,
nextRetryAt: nextRetryAt.toISOString(),
}),
'EX',
SYNC_FAILURE_BACKOFF_TTL_SECONDS
);
return {
attempt,
nextRetryAt,
};
private calculateNextSyncAt(base: Date, refreshIntervalMinutes?: number) {
const intervalMinutes =
refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
return new Date(base.getTime() + intervalMinutes * 60 * 1000);
}
private async clearSyncFailureBackoff(subscriptionId: string) {
const key = this.getSyncFailureBackoffKey(subscriptionId);
await this.redis.del(key);
private calculateChannelRetryAt(nextSyncAt: Date) {
return new Date(
Math.min(nextSyncAt.getTime(), this.nowMs() + CHANNEL_RENEW_RETRY_MS)
);
}
private calculateFailureRetryAt(attempt: number) {
return new Date(
this.nowMs() +
exponentialBackoffDelay(attempt - 1, {
baseDelayMs: SYNC_FAILURE_BACKOFF_BASE_MS,
maxDelayMs: SYNC_FAILURE_BACKOFF_MAX_MS,
})
);
}
private now() {
return new Date(this.nowMs());
}
private nowMs() {
return Date.now();
}
private formatSyncError(error: unknown) {
@@ -33,19 +33,37 @@ test('should not index workspace if indexer is disabled', async t => {
const count = module.queue.count('indexer.indexWorkspace');
// @ts-expect-error ignore missing fields
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
await indexerEvent.indexWorkspace({
workspaceId: 'test-workspace',
docId: 'test-workspace',
});
t.is(module.queue.count('indexer.indexWorkspace'), count);
});
test('should index workspace if indexer is enabled', async t => {
test('should index workspace when root snapshot is updated', async t => {
// @ts-expect-error ignore missing fields
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
await indexerEvent.indexWorkspace({
workspaceId: 'test-workspace',
docId: 'test-workspace',
});
const { payload } = await module.queue.waitFor('indexer.indexWorkspace');
t.is(payload.workspaceId, 'test-workspace');
});
test('should not index workspace when non-root snapshot is updated', async t => {
const count = module.queue.count('indexer.indexWorkspace');
// @ts-expect-error ignore missing fields
await indexerEvent.indexWorkspace({
workspaceId: 'test-workspace',
docId: 'child-doc',
});
t.is(module.queue.count('indexer.indexWorkspace'), count);
});
test('should not delete workspace if indexer is disabled', async t => {
Sinon.stub(config.indexer, 'enabled').value(false);
const count = module.queue.count('indexer.deleteWorkspace');
@@ -29,21 +29,20 @@ export class IndexerEvent {
);
}
@OnEvent('workspace.updated')
async indexWorkspace({ id }: Events['workspace.updated']) {
@OnEvent('doc.snapshot.updated')
async indexWorkspace({ workspaceId, docId }: Events['doc.snapshot.updated']) {
if (!this.config.indexer.enabled) {
return;
}
if (workspaceId !== docId) {
return;
}
await this.queue.add(
'indexer.indexWorkspace',
{
workspaceId: id,
},
{
jobId: `indexWorkspace/${id}`,
priority: 100,
}
{ workspaceId },
{ jobId: `indexWorkspace/${workspaceId}`, priority: 100 }
);
}
@@ -114,22 +114,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
throw new ManagedByAppStoreOrPlay();
}
const subscription = await this.getSubscription({
plan: lookupKey.plan,
userId: user.id,
});
if (
subscription &&
active &&
// do not allow to re-subscribe unless
!(
/* current subscription is a onetime subscription and so as the one that's checking out */
(
(subscription.variant === SubscriptionVariant.Onetime &&
(active.variant === SubscriptionVariant.Onetime &&
lookupKey.variant === SubscriptionVariant.Onetime) ||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
(subscription.recurring !== SubscriptionRecurring.Lifetime &&
subscription.variant !== SubscriptionVariant.Onetime &&
(active.recurring !== SubscriptionRecurring.Lifetime &&
active.variant !== SubscriptionVariant.Onetime &&
lookupKey.recurring === SubscriptionRecurring.Lifetime)
)
)
@@ -1,10 +1,21 @@
mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
sendTestEmail(config: {
host: $host,
port: $port,
sender: $sender,
username: $username,
password: $password,
ignoreTLS: $ignoreTLS,
})
}
mutation sendTestEmail(
$name: String!
$host: String!
$port: Int!
$sender: String!
$username: String!
$password: String!
$ignoreTLS: Boolean!
) {
sendTestEmail(
config: {
name: $name
host: $host
port: $port
sender: $sender
username: $username
password: $password
ignoreTLS: $ignoreTLS
}
)
}
+2 -2
View File
@@ -549,9 +549,9 @@ export const listUsersQuery = {
export const sendTestEmailMutation = {
id: 'sendTestEmailMutation' as const,
op: 'sendTestEmail',
query: `mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
query: `mutation sendTestEmail($name: String!, $host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) {
sendTestEmail(
config: {host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
config: {name: $name, host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS}
)
}`,
};
+1
View File
@@ -4027,6 +4027,7 @@ export type ListUsersQuery = {
};
export type SendTestEmailMutationVariables = Exact<{
name: Scalars['String']['input'];
host: Scalars['String']['input'];
port: Scalars['Int']['input'];
sender: Scalars['String']['input'];
+4
View File
@@ -27,6 +27,10 @@
"type": "Object",
"desc": "The config for copilot job queue"
},
"queues.calendar": {
"type": "Object",
"desc": "The config for calendar job queue"
},
"queues.doc": {
"type": "Object",
"desc": "The config for doc job queue"
@@ -18,7 +18,7 @@
"pl": 98,
"pt-BR": 96,
"ru": 98,
"sv-SE": 97,
"sv-SE": 96,
"uk": 96,
"ur": 2,
"zh-Hans": 98,
+52 -4
View File
@@ -73,8 +73,8 @@ update_app_stream_version() {
update_ios_marketing_version() {
local file_path=$1
# Remove everything after the "-"
local new_version=$(echo "$2" | sed -E 's/-.*$//')
# Normalize inputs like "v0.26.4-beta.1" to "0.26.4"
local new_version=$(echo "$2" | sed -E 's/^v//; s/-.*$//')
# Check if file exists
if [ ! -f "$file_path" ]; then
@@ -98,8 +98,56 @@ update_ios_marketing_version() {
rm "$file_path".bak
}
# Derive a date-based iOS MARKETING_VERSION from the latest stable/beta tag.
# Apple requires CFBundleShortVersionString to increase monotonically. Using
# date-based versions (YYYY.M.D) derived from the last stable/beta release tag
# ensures this. The user-facing App Store version is set separately in
# App Store Connect.
get_ios_version_from_git() {
# Find the most recent stable/beta tag reachable from HEAD (exclude canary/nightly)
local latest_tag
latest_tag=$(git describe --tags --match 'v[0-9]*' \
--exclude '*canary*' --exclude '*nightly*' \
--abbrev=0 HEAD 2>/dev/null)
if [ -z "$latest_tag" ]; then
# No stable/beta tag found, fall back to today's date
date +"%Y.%-m.%-d"
return
fi
# Get the tag creation date (tagger date for annotated tags, commit date for lightweight)
local tag_date
tag_date=$(git for-each-ref --format='%(creatordate:short)' "refs/tags/$latest_tag")
if [ -z "$tag_date" ]; then
date +"%Y.%-m.%-d"
return
fi
# Format as YYYY.M.D (no leading zeros for month/day)
local year month day
year=$(echo "$tag_date" | cut -d'-' -f1)
month=$((10#$(echo "$tag_date" | cut -d'-' -f2)))
day=$((10#$(echo "$tag_date" | cut -d'-' -f3)))
echo "${year}.${month}.${day}"
}
new_version=$1
ios_new_version=${IOS_APP_VERSION:-$new_version}
if [ -n "$IOS_APP_VERSION" ]; then
# Manual override via environment variable
ios_new_version=$IOS_APP_VERSION
elif echo "$new_version" | grep -qE '(canary|nightly)'; then
# Canary/nightly: use the date of the last stable/beta tag
ios_new_version=$(get_ios_version_from_git)
else
# Stable/beta release: use today's date
ios_new_version=$(date +"%Y.%-m.%-d")
fi
echo "iOS MARKETING_VERSION: $ios_new_version (app version: $new_version)"
update_app_version_in_helm_charts ".github/helm/affine/Chart.yaml" "$new_version"
update_app_version_in_helm_charts ".github/helm/affine/charts/graphql/Chart.yaml" "$new_version"
@@ -108,4 +156,4 @@ update_app_version_in_helm_charts ".github/helm/affine/charts/doc/Chart.yaml" "$
update_app_stream_version "packages/frontend/apps/electron/resources/affine.metainfo.xml" "$new_version"
update_ios_marketing_version "packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj" "$new_version"
update_ios_marketing_version "packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj" "$ios_new_version"
+301 -128
View File
@@ -1034,8 +1034,7 @@ __metadata:
"@types/nodemailer": "npm:^7.0.0"
"@types/on-headers": "npm:^1.0.3"
"@types/react": "npm:^19.0.1"
"@types/react-dom": "npm:^19.0.2"
"@types/semver": "npm:^7.5.8"
"@types/semver": "npm:^7.7.1"
"@types/sinon": "npm:^21.0.0"
"@types/supertest": "npm:^6.0.2"
ai: "npm:^5.0.118"
@@ -1078,7 +1077,7 @@ __metadata:
react-email: "npm:^4.3.2"
reflect-metadata: "npm:^0.2.2"
rxjs: "npm:^7.8.2"
semver: "npm:^7.7.3"
semver: "npm:^7.7.4"
ses: "npm:^1.14.0"
sinon: "npm:^21.0.1"
socket.io: "npm:^4.8.1"
@@ -6220,17 +6219,17 @@ __metadata:
linkType: hard
"@graphql-tools/graphql-file-loader@npm:^8.0.0":
version: 8.0.19
resolution: "@graphql-tools/graphql-file-loader@npm:8.0.19"
version: 8.1.12
resolution: "@graphql-tools/graphql-file-loader@npm:8.1.12"
dependencies:
"@graphql-tools/import": "npm:7.0.18"
"@graphql-tools/utils": "npm:^10.8.6"
"@graphql-tools/import": "npm:^7.1.12"
"@graphql-tools/utils": "npm:^11.0.0"
globby: "npm:^11.0.3"
tslib: "npm:^2.4.0"
unixify: "npm:^1.0.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/f7b8871582fdb925ba4b54463bdad651777a53769eafbf1febe1783ab25208a1c787fa2f3a16caabee0f9c1621adad47df539973539c57037f748c6c9d4ef103
checksum: 10/774f6c14e511d91ded6b7c338bd55bb31c3c5256af14799ae117f4580f6b7341fef3ca85e83d559b4400e0e933d04cb22d6c29390bf774e38d3d03b009203795
languageName: node
linkType: hard
@@ -6251,48 +6250,48 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/import@npm:7.0.18":
version: 7.0.18
resolution: "@graphql-tools/import@npm:7.0.18"
"@graphql-tools/import@npm:^7.1.12":
version: 7.1.12
resolution: "@graphql-tools/import@npm:7.1.12"
dependencies:
"@graphql-tools/utils": "npm:^10.8.6"
"@graphql-tools/utils": "npm:^11.0.0"
resolve-from: "npm:5.0.0"
tslib: "npm:^2.4.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/739086fcaffefa8efa38648477fd4aea41a87b5fa3f78552a2a7b6249dce1d84d84e164e61c73811d6ce9e8f5754b6bab675f7255482bf5ba080f83759a37138
checksum: 10/9c65b2fb5221e20955ae865454ff89fa9910e0282416e9f60826b3dc512c3b4cc71dba8784eecc574925486390c2e31e0b51d284781710df9f865c15811866f3
languageName: node
linkType: hard
"@graphql-tools/json-file-loader@npm:^8.0.0":
version: 8.0.18
resolution: "@graphql-tools/json-file-loader@npm:8.0.18"
version: 8.0.26
resolution: "@graphql-tools/json-file-loader@npm:8.0.26"
dependencies:
"@graphql-tools/utils": "npm:^10.8.6"
"@graphql-tools/utils": "npm:^11.0.0"
globby: "npm:^11.0.3"
tslib: "npm:^2.4.0"
unixify: "npm:^1.0.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/a2e6e37c0565674c18aa6c4d5574924fefda441c3bbfd40ff123ab747c6f542f99d3669f204c2f616a3cecdd8cb76a9d827bf50af13faf4a299e54f1577a9136
checksum: 10/6e4dc44a9aa3cdd3f7b958b50d98a26e56fb5cc9fab2667011ec12c82685016f269350d6ba2e45dcc7e6d71f8df7d1c4002edb55ce8896ba3e705cad14645ac1
languageName: node
linkType: hard
"@graphql-tools/load@npm:^8.1.0":
version: 8.1.0
resolution: "@graphql-tools/load@npm:8.1.0"
version: 8.1.8
resolution: "@graphql-tools/load@npm:8.1.8"
dependencies:
"@graphql-tools/schema": "npm:^10.0.23"
"@graphql-tools/utils": "npm:^10.8.6"
"@graphql-tools/schema": "npm:^10.0.31"
"@graphql-tools/utils": "npm:^11.0.0"
p-limit: "npm:3.1.0"
tslib: "npm:^2.4.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/4601dda7eb32cb8afed2379102ad82f8a948e478f42c7b1f354a3468ca8dfcdcc2a89e6c6ebcbb574c77eaa80d47f20c27230bdcd6c2d0a3600fa1d6a450cc95
checksum: 10/a9b24c8d9fc52ebf2b5e0d5dc99212e61704cfe0a07b17a5be3329c391ae01f710e0a2ca6b73b41379e9bee55210c0466d5dfb378a9e3cbe051062a69a07d616
languageName: node
linkType: hard
"@graphql-tools/merge@npm:9.0.24, @graphql-tools/merge@npm:^9.0.0, @graphql-tools/merge@npm:^9.0.24":
"@graphql-tools/merge@npm:9.0.24":
version: 9.0.24
resolution: "@graphql-tools/merge@npm:9.0.24"
dependencies:
@@ -6316,6 +6315,18 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/merge@npm:^9.0.0, @graphql-tools/merge@npm:^9.0.24, @graphql-tools/merge@npm:^9.1.7":
version: 9.1.7
resolution: "@graphql-tools/merge@npm:9.1.7"
dependencies:
"@graphql-tools/utils": "npm:^11.0.0"
tslib: "npm:^2.4.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/e0b77dfc16e91d7c2450df0b57a85a93e11f0f67e37e396bcf04275d1db8ed1b7257c763ebe6e7f122041d81f00d6aa954fbec531fa6c0b449d195a9aff199cc
languageName: node
linkType: hard
"@graphql-tools/optimize@npm:^2.0.0":
version: 2.0.0
resolution: "@graphql-tools/optimize@npm:2.0.0"
@@ -6366,7 +6377,7 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/schema@npm:10.0.23, @graphql-tools/schema@npm:^10.0.0, @graphql-tools/schema@npm:^10.0.11, @graphql-tools/schema@npm:^10.0.23":
"@graphql-tools/schema@npm:10.0.23":
version: 10.0.23
resolution: "@graphql-tools/schema@npm:10.0.23"
dependencies:
@@ -6379,6 +6390,19 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/schema@npm:^10.0.0, @graphql-tools/schema@npm:^10.0.11, @graphql-tools/schema@npm:^10.0.31":
version: 10.0.31
resolution: "@graphql-tools/schema@npm:10.0.31"
dependencies:
"@graphql-tools/merge": "npm:^9.1.7"
"@graphql-tools/utils": "npm:^11.0.0"
tslib: "npm:^2.4.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/5b775736f8b8454319e07cadc7d41bc5c9cc804a393490aaffd1bb7f59afddb02b498837e870bf98db4a1a989a721d5d8e2fd2b97409d078bac14503c2d4f9cb
languageName: node
linkType: hard
"@graphql-tools/schema@npm:^9.0.0":
version: 9.0.19
resolution: "@graphql-tools/schema@npm:9.0.19"
@@ -6430,6 +6454,20 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/utils@npm:^11.0.0":
version: 11.0.0
resolution: "@graphql-tools/utils@npm:11.0.0"
dependencies:
"@graphql-typed-document-node/core": "npm:^3.1.1"
"@whatwg-node/promise-helpers": "npm:^1.0.0"
cross-inspect: "npm:1.0.1"
tslib: "npm:^2.4.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 10/4cc7577ab85d60908a1d5d448071b318791b798f571cd4b8e4289e0e0eeae9d7183b661a1a7d5da3cedaf5f9b62b936031e3a90d2e17a1c50acbd95d9106ba3c
languageName: node
linkType: hard
"@graphql-tools/utils@npm:^9.2.1":
version: 9.2.1
resolution: "@graphql-tools/utils@npm:9.2.1"
@@ -9898,6 +9936,15 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/api-logs@npm:0.213.0":
version: 0.213.0
resolution: "@opentelemetry/api-logs@npm:0.213.0"
dependencies:
"@opentelemetry/api": "npm:^1.3.0"
checksum: 10/9b2d030d8534520f23e3903ccf64dc479fb4d37fcf5ca265ca7de439ec8dd5c849aaa62068278cd97879da7c9528e34c7162cc7bbd025dd8d74667843adc151f
languageName: node
linkType: hard
"@opentelemetry/api@npm:1.9.0, @opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.0":
version: 1.9.0
resolution: "@opentelemetry/api@npm:1.9.0"
@@ -9917,7 +9964,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/context-async-hooks@npm:2.5.0, @opentelemetry/context-async-hooks@npm:^2.2.0":
"@opentelemetry/context-async-hooks@npm:2.5.0":
version: 2.5.0
resolution: "@opentelemetry/context-async-hooks@npm:2.5.0"
peerDependencies:
@@ -9926,6 +9973,15 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/context-async-hooks@npm:2.6.0, @opentelemetry/context-async-hooks@npm:^2.2.0":
version: 2.6.0
resolution: "@opentelemetry/context-async-hooks@npm:2.6.0"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10/a1f746fb9bb25b4c40c0da4cc68a7412e82f120a6ddc80dcf0117432418e64c947527bca87895ebce4211a09992863a75a0f5b5f8e7185a573244cccb4809c42
languageName: node
linkType: hard
"@opentelemetry/core@npm:2.2.0":
version: 2.2.0
resolution: "@opentelemetry/core@npm:2.2.0"
@@ -9937,7 +9993,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/core@npm:2.5.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.2.0":
"@opentelemetry/core@npm:2.5.0":
version: 2.5.0
resolution: "@opentelemetry/core@npm:2.5.0"
dependencies:
@@ -9948,6 +10004,17 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/core@npm:2.6.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.2.0":
version: 2.6.0
resolution: "@opentelemetry/core@npm:2.6.0"
dependencies:
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10/21c017cc68fe7836d06ecac31abeba6ad610dd42e9c1cb9562cd13eed3f644c48111a1fc7d00dfc2b7cc179d40f059482c69c978c24352ac0c605f30686a01a4
languageName: node
linkType: hard
"@opentelemetry/exporter-logs-otlp-grpc@npm:0.211.0":
version: 0.211.0
resolution: "@opentelemetry/exporter-logs-otlp-grpc@npm:0.211.0"
@@ -10477,7 +10544,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/instrumentation@npm:0.211.0, @opentelemetry/instrumentation@npm:>=0.52.0 <1, @opentelemetry/instrumentation@npm:^0.211.0":
"@opentelemetry/instrumentation@npm:0.211.0, @opentelemetry/instrumentation@npm:^0.211.0":
version: 0.211.0
resolution: "@opentelemetry/instrumentation@npm:0.211.0"
dependencies:
@@ -10490,6 +10557,19 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/instrumentation@npm:>=0.52.0 <1":
version: 0.213.0
resolution: "@opentelemetry/instrumentation@npm:0.213.0"
dependencies:
"@opentelemetry/api-logs": "npm:0.213.0"
import-in-the-middle: "npm:^3.0.0"
require-in-the-middle: "npm:^8.0.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
checksum: 10/69baeaae0c5836ede140485530954d32c8d20d864340f7d57b43a6e3ef9c10394d29a1884dd2b076512aec896039a1ea02f698282649b5aa3fa59b13bca00f97
languageName: node
linkType: hard
"@opentelemetry/otlp-exporter-base@npm:0.211.0":
version: 0.211.0
resolution: "@opentelemetry/otlp-exporter-base@npm:0.211.0"
@@ -10562,7 +10642,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/resources@npm:2.5.0, @opentelemetry/resources@npm:^2.2.0":
"@opentelemetry/resources@npm:2.5.0":
version: 2.5.0
resolution: "@opentelemetry/resources@npm:2.5.0"
dependencies:
@@ -10574,6 +10654,18 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/resources@npm:2.6.0, @opentelemetry/resources@npm:^2.2.0":
version: 2.6.0
resolution: "@opentelemetry/resources@npm:2.6.0"
dependencies:
"@opentelemetry/core": "npm:2.6.0"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
checksum: 10/837e76911d013e52c1c0cd8da6f2912818fcc107fd1c6fcb2ec8faa6e617b802e0eef1aa53bd4417c7a77c96ea02b6f5bbdccb9ff411ef8efeff49c2e02d9443
languageName: node
linkType: hard
"@opentelemetry/sdk-logs@npm:0.211.0":
version: 0.211.0
resolution: "@opentelemetry/sdk-logs@npm:0.211.0"
@@ -10587,7 +10679,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/sdk-metrics@npm:2.5.0, @opentelemetry/sdk-metrics@npm:^2.2.0":
"@opentelemetry/sdk-metrics@npm:2.5.0":
version: 2.5.0
resolution: "@opentelemetry/sdk-metrics@npm:2.5.0"
dependencies:
@@ -10599,6 +10691,18 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/sdk-metrics@npm:^2.2.0":
version: 2.6.0
resolution: "@opentelemetry/sdk-metrics@npm:2.6.0"
dependencies:
"@opentelemetry/core": "npm:2.6.0"
"@opentelemetry/resources": "npm:2.6.0"
peerDependencies:
"@opentelemetry/api": ">=1.9.0 <1.10.0"
checksum: 10/5fd1254ab86cdb6573999f3c5d60b8332fb3a2b7d50d1befcfd9d8ef021d5e9e405e31394f7aa4a106a4546bd6308a652a78cd9a35d6fbe56e44984d605cb5d5
languageName: node
linkType: hard
"@opentelemetry/sdk-node@npm:^0.211.0":
version: 0.211.0
resolution: "@opentelemetry/sdk-node@npm:0.211.0"
@@ -10633,7 +10737,7 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-base@npm:2.5.0, @opentelemetry/sdk-trace-base@npm:^2.2.0":
"@opentelemetry/sdk-trace-base@npm:2.5.0":
version: 2.5.0
resolution: "@opentelemetry/sdk-trace-base@npm:2.5.0"
dependencies:
@@ -10646,7 +10750,20 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-node@npm:2.5.0, @opentelemetry/sdk-trace-node@npm:^2.2.0":
"@opentelemetry/sdk-trace-base@npm:2.6.0, @opentelemetry/sdk-trace-base@npm:^2.2.0":
version: 2.6.0
resolution: "@opentelemetry/sdk-trace-base@npm:2.6.0"
dependencies:
"@opentelemetry/core": "npm:2.6.0"
"@opentelemetry/resources": "npm:2.6.0"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
checksum: 10/8ca3c1c4d7a95ec8a28ab5237162e31334216a59408e9d9d10ad51f5709911a405699ad69f445c212aad55fb6cc2c70f473b835e9e52bf4ed63f237c4d1813af
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-node@npm:2.5.0":
version: 2.5.0
resolution: "@opentelemetry/sdk-trace-node@npm:2.5.0"
dependencies:
@@ -10659,6 +10776,19 @@ __metadata:
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-node@npm:^2.2.0":
version: 2.6.0
resolution: "@opentelemetry/sdk-trace-node@npm:2.6.0"
dependencies:
"@opentelemetry/context-async-hooks": "npm:2.6.0"
"@opentelemetry/core": "npm:2.6.0"
"@opentelemetry/sdk-trace-base": "npm:2.6.0"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10/b1576f44198ae18ab36dea92e5c254eb6a96ca5793a5adbb96a01c586dd992e3532eb291c9ea225e2cd6979f7f6dd3fca2fad56ab360ba573f8b3bfb462dd2aa
languageName: node
linkType: hard
"@opentelemetry/semantic-conventions@npm:^1.22.0, @opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0, @opentelemetry/semantic-conventions@npm:^1.38.0":
version: 1.39.0
resolution: "@opentelemetry/semantic-conventions@npm:1.39.0"
@@ -17305,10 +17435,10 @@ __metadata:
languageName: node
linkType: hard
"@types/semver@npm:^7, @types/semver@npm:^7.5.8":
version: 7.7.0
resolution: "@types/semver@npm:7.7.0"
checksum: 10/ee4514c6c852b1c38f951239db02f9edeea39f5310fad9396a00b51efa2a2d96b3dfca1ae84c88181ea5b7157c57d32d7ef94edacee36fbf975546396b85ba5b
"@types/semver@npm:^7, @types/semver@npm:^7.7.1":
version: 7.7.1
resolution: "@types/semver@npm:7.7.1"
checksum: 10/8f09e7e6ca3ded67d78ba7a8f7535c8d9cf8ced83c52e7f3ac3c281fe8c689c3fe475d199d94390dc04fc681d51f2358b430bb7b2e21c62de24f2bee2c719068
languageName: node
linkType: hard
@@ -18402,12 +18532,12 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.1, acorn@npm:^8.8.2":
version: 8.15.0
resolution: "acorn@npm:8.15.0"
"acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.1, acorn@npm:^8.8.2":
version: 8.16.0
resolution: "acorn@npm:8.16.0"
bin:
acorn: bin/acorn
checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4
checksum: 10/690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b
languageName: node
linkType: hard
@@ -18603,10 +18733,10 @@ __metadata:
languageName: node
linkType: hard
"ansi-regex@npm:^6.0.1":
version: 6.1.0
resolution: "ansi-regex@npm:6.1.0"
checksum: 10/495834a53b0856c02acd40446f7130cb0f8284f4a39afdab20d5dc42b2e198b1196119fe887beed8f9055c4ff2055e3b2f6d4641d0be018cdfb64fedf6fc1aac
"ansi-regex@npm:^6.2.2":
version: 6.2.2
resolution: "ansi-regex@npm:6.2.2"
checksum: 10/9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f
languageName: node
linkType: hard
@@ -18627,9 +18757,9 @@ __metadata:
linkType: hard
"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1":
version: 6.2.1
resolution: "ansi-styles@npm:6.2.1"
checksum: 10/70fdf883b704d17a5dfc9cde206e698c16bcd74e7f196ab821511651aee4f9f76c9514bdfa6ca3a27b5e49138b89cb222a28caf3afe4567570139577f991df32
version: 6.2.3
resolution: "ansi-styles@npm:6.2.3"
checksum: 10/c49dad7639f3e48859bd51824c93b9eb0db628afc243c51c3dd2410c4a15ede1a83881c6c7341aa2b159c4f90c11befb38f2ba848c07c66c9f9de4bcd7cb9f30
languageName: node
linkType: hard
@@ -19347,12 +19477,12 @@ __metadata:
languageName: node
linkType: hard
"brace-expansion@npm:^2.0.1":
version: 2.0.1
resolution: "brace-expansion@npm:2.0.1"
"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2":
version: 2.0.2
resolution: "brace-expansion@npm:2.0.2"
dependencies:
balanced-match: "npm:^1.0.0"
checksum: 10/a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
checksum: 10/01dff195e3646bc4b0d27b63d9bab84d2ebc06121ff5013ad6e5356daa5a9d6b60fa26cf73c74797f2dc3fbec112af13578d51f75228c1112b26c790a87b0488
languageName: node
linkType: hard
@@ -19834,9 +19964,9 @@ __metadata:
linkType: hard
"chalk@npm:^5.0.1, chalk@npm:^5.3.0, chalk@npm:^5.4.1":
version: 5.4.1
resolution: "chalk@npm:5.4.1"
checksum: 10/29df3ffcdf25656fed6e95962e2ef86d14dfe03cd50e7074b06bad9ffbbf6089adbb40f75c00744d843685c8d008adaf3aed31476780312553caf07fa86e5bc7
version: 5.6.2
resolution: "chalk@npm:5.6.2"
checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0
languageName: node
linkType: hard
@@ -20152,10 +20282,10 @@ __metadata:
languageName: node
linkType: hard
"cjs-module-lexer@npm:^1.2.2":
version: 1.4.3
resolution: "cjs-module-lexer@npm:1.4.3"
checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591
"cjs-module-lexer@npm:^2.2.0":
version: 2.2.0
resolution: "cjs-module-lexer@npm:2.2.0"
checksum: 10/fc8eb5c1919504366d8260a150d93c4e857740e770467dc59ca0cc34de4b66c93075559a5af65618f359187866b1be40e036f4e1a1bab2f1e06001c216415f74
languageName: node
linkType: hard
@@ -21781,9 +21911,9 @@ __metadata:
linkType: hard
"dayjs@npm:^1.11.13, dayjs@npm:^1.11.18":
version: 1.11.18
resolution: "dayjs@npm:1.11.18"
checksum: 10/7d29a90834cf4da2feb437c2f34b8235c3f94493a06d2f1bf9f506f1fa49eadf796f26e1d685b9fe8cb5e75ce6ee067825115e196f1af3d07b3552ff857bfc39
version: 1.11.20
resolution: "dayjs@npm:1.11.20"
checksum: 10/5347533f21a55b8bb1b1ef559be9b805514c3a8fb7e68b75fb7e73808131c59e70909c073aa44ce8a0d159195cd110cdd4081cf87ab96cb06fee3edacae791c6
languageName: node
linkType: hard
@@ -22271,14 +22401,14 @@ __metadata:
linkType: hard
"dompurify@npm:^3.2.5, dompurify@npm:^3.3.0":
version: 3.3.0
resolution: "dompurify@npm:3.3.0"
version: 3.3.3
resolution: "dompurify@npm:3.3.3"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
checksum: 10/d8782b10a0454344476936c91038d06c9450b3e3ada2ceb8f722525e6b54e64d847939b9f35bf385facd4139f0a2eaf7f5553efce351f8e9295620570875f002
checksum: 10/4cc9c539ed7136d46c6577613b8e20871c2b6165db01dfbd2a3c11c75f9e339c496ac6519a1c3190115def8cadae3720bef0417fc43fa28802c7407bab174da9
languageName: node
linkType: hard
@@ -23937,7 +24067,7 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:21.3.0, file-type@npm:^21.0.0":
"file-type@npm:21.3.0":
version: 21.3.0
resolution: "file-type@npm:21.3.0"
dependencies:
@@ -23949,6 +24079,18 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^21.0.0":
version: 21.3.3
resolution: "file-type@npm:21.3.3"
dependencies:
"@tokenizer/inflate": "npm:^0.4.1"
strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.1"
uint8array-extras: "npm:^1.4.0"
checksum: 10/7a900a89b7e9f65cfff4d489bc4ad9311d749dd3bae3da753b681dbd1d4bf5fff204c0fa78e620076ccbbc1225f0e25330cd5653342bc78bf6b272015153ea23
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
@@ -24567,10 +24709,10 @@ __metadata:
languageName: node
linkType: hard
"get-east-asian-width@npm:^1.0.0":
version: 1.3.0
resolution: "get-east-asian-width@npm:1.3.0"
checksum: 10/8e8e779eb28701db7fdb1c8cab879e39e6ae23f52dadd89c8aed05869671cee611a65d4f8557b83e981428623247d8bc5d0c7a4ef3ea7a41d826e73600112ad8
"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1":
version: 1.5.0
resolution: "get-east-asian-width@npm:1.5.0"
checksum: 10/60bc34cd1e975055ab99f0f177e31bed3e516ff7cee9c536474383954a976abaa6b94a51d99ad158ef1e372790fa096cab7d07f166bb0778f6587954c0fbe946
languageName: node
linkType: hard
@@ -25854,14 +25996,26 @@ __metadata:
linkType: hard
"import-in-the-middle@npm:^2, import-in-the-middle@npm:^2.0.0":
version: 2.0.0
resolution: "import-in-the-middle@npm:2.0.0"
version: 2.0.6
resolution: "import-in-the-middle@npm:2.0.6"
dependencies:
acorn: "npm:^8.14.0"
acorn: "npm:^8.15.0"
acorn-import-attributes: "npm:^1.9.5"
cjs-module-lexer: "npm:^1.2.2"
module-details-from-path: "npm:^1.0.3"
checksum: 10/badb8359552f1e9fedc8569299dd1937e802256ce0fe6aa9cb348bca6f217f06e16a3ca46f889bfcb66028a096a1956674d257de9e809db4271ca0e508521c30
cjs-module-lexer: "npm:^2.2.0"
module-details-from-path: "npm:^1.0.4"
checksum: 10/8be80d7f2d4ad34e5eb1082925ee2e90844edb65359cad0f5d8e934a09fafeca10e66f50d0b07570bd6b877ff678755d3c2d36d05258cc3541e39fa6aae6ae56
languageName: node
linkType: hard
"import-in-the-middle@npm:^3.0.0":
version: 3.0.0
resolution: "import-in-the-middle@npm:3.0.0"
dependencies:
acorn: "npm:^8.15.0"
acorn-import-attributes: "npm:^1.9.5"
cjs-module-lexer: "npm:^2.2.0"
module-details-from-path: "npm:^1.0.4"
checksum: 10/0bf1f22d00a080e7f651db8c5d136aa4aca6829397769f22fb544a67d9117b1c78590f180a690eb19eb0cfb4a558beac66b6508d346cccc91e1e5f75c934e9de
languageName: node
linkType: hard
@@ -26245,11 +26399,11 @@ __metadata:
linkType: hard
"is-fullwidth-code-point@npm:^5.0.0":
version: 5.0.0
resolution: "is-fullwidth-code-point@npm:5.0.0"
version: 5.1.0
resolution: "is-fullwidth-code-point@npm:5.1.0"
dependencies:
get-east-asian-width: "npm:^1.0.0"
checksum: 10/8dfb2d2831b9e87983c136f5c335cd9d14c1402973e357a8ff057904612ed84b8cba196319fabedf9aefe4639e14fe3afe9d9966d1d006ebeb40fe1fed4babe5
get-east-asian-width: "npm:^1.3.1"
checksum: 10/4700d8a82cb71bd2a2955587b2823c36dc4660eadd4047bfbd070821ddbce8504fc5f9b28725567ecddf405b1e06c6692c9b719f65df6af9ec5262bc11393a6a
languageName: node
linkType: hard
@@ -27089,13 +27243,13 @@ __metadata:
linkType: hard
"katex@npm:^0.16.0, katex@npm:^0.16.22, katex@npm:^0.16.27":
version: 0.16.27
resolution: "katex@npm:0.16.27"
version: 0.16.38
resolution: "katex@npm:0.16.38"
dependencies:
commander: "npm:^8.3.0"
bin:
katex: cli.js
checksum: 10/7666ae11c6c1238626bffaf1a526af6ff679114d62293bf2f0e29f8a34d8e961c0edcb686c5b628158ec92a143b4bef5d83539c81b29a63c7dcf0bdb4544eec9
checksum: 10/e9103def114d9d08ab216864e66b68e6f50a6360fdc5aa29d8edeee430e1618dd7551b9f080e9c591b3ee24c18fe6910b8fe0c89c7c4b1109abd2b63e223fbc5
languageName: node
linkType: hard
@@ -27986,9 +28140,9 @@ __metadata:
linkType: hard
"lru-cache@npm:^11.0.0":
version: 11.1.0
resolution: "lru-cache@npm:11.1.0"
checksum: 10/5011011675ca98428902de774d0963b68c3a193cd959347cb63b781dad4228924124afab82159fd7b8b4db18285d9aff462b877b8f6efd2b41604f806c1d9db4
version: 11.2.7
resolution: "lru-cache@npm:11.2.7"
checksum: 10/fbff4b8dee8189dde9b52cdfb3ea89b4c9cec094c1538cd30d1f47299477ff312efdb35f7994477ec72328f8e754e232b26a143feda1bd1f79ff22da6664d2c5
languageName: node
linkType: hard
@@ -29127,11 +29281,11 @@ __metadata:
linkType: hard
"minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5":
version: 9.0.6
resolution: "minimatch@npm:9.0.6"
version: 9.0.9
resolution: "minimatch@npm:9.0.9"
dependencies:
brace-expansion: "npm:^5.0.2"
checksum: 10/c7a46134aaf349f386de9a3f6c5b48c53bc3a4e2ef4b8b6365184504e28cc31cc261a388e181648cbc756b40e213dbce115c8087a47eff8f54ee28d62bc17b08
brace-expansion: "npm:^2.0.2"
checksum: 10/b91fad937deaffb68a45a2cb731ff3cff1c3baf9b6469c879477ed16f15c8f4ce39d63a3f75c2455107c2fdff0f3ab597d97dc09e2e93b883aafcf926ef0c8f9
languageName: node
linkType: hard
@@ -29234,9 +29388,9 @@ __metadata:
linkType: hard
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
version: 7.1.2
resolution: "minipass@npm:7.1.2"
checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6
languageName: node
linkType: hard
@@ -29280,14 +29434,14 @@ __metadata:
linkType: hard
"mlly@npm:^1.4.2, mlly@npm:^1.7.1, mlly@npm:^1.7.4":
version: 1.7.4
resolution: "mlly@npm:1.7.4"
version: 1.8.1
resolution: "mlly@npm:1.8.1"
dependencies:
acorn: "npm:^8.14.0"
pathe: "npm:^2.0.1"
pkg-types: "npm:^1.3.0"
ufo: "npm:^1.5.4"
checksum: 10/1b36163d38c2331f8ae480e6a11da3d15927a2148d729fcd9df6d0059ca74869aa693931bd1f762f82eb534b84c921bdfbc036eb0e4da4faeb55f1349d254f35
acorn: "npm:^8.16.0"
pathe: "npm:^2.0.3"
pkg-types: "npm:^1.3.1"
ufo: "npm:^1.6.3"
checksum: 10/8e424f0615d09adfb7d59ad8f0c8245df275cd05e483a4631a1b2c5dd7e09913a9ce8182bc1562d569941ecee25ab03f4429284265471f562da1dd308008e237
languageName: node
linkType: hard
@@ -29433,7 +29587,7 @@ __metadata:
languageName: node
linkType: hard
"multer@npm:2.0.2, multer@npm:^2.0.2":
"multer@npm:2.0.2":
version: 2.0.2
resolution: "multer@npm:2.0.2"
dependencies:
@@ -29448,6 +29602,18 @@ __metadata:
languageName: node
linkType: hard
"multer@npm:^2.0.2":
version: 2.1.1
resolution: "multer@npm:2.1.1"
dependencies:
append-field: "npm:^1.0.0"
busboy: "npm:^1.6.0"
concat-stream: "npm:^2.0.0"
type-is: "npm:^1.6.18"
checksum: 10/fb22868caaed37d725715c14c60b740b81665265da3a026bb61954414f65b99f76b360128413b8a2a7cc1a95ecae28a42bf831fe172bb79682d19ec105b556bd
languageName: node
linkType: hard
"multicast-dns@npm:^7.2.5":
version: 7.2.5
resolution: "multicast-dns@npm:7.2.5"
@@ -30483,9 +30649,9 @@ __metadata:
linkType: hard
"p-map@npm:^7.0.2, p-map@npm:^7.0.3":
version: 7.0.3
resolution: "p-map@npm:7.0.3"
checksum: 10/2ef48ccfc6dd387253d71bf502604f7893ed62090b2c9d73387f10006c342606b05233da0e4f29388227b61eb5aeface6197e166520c465c234552eeab2fe633
version: 7.0.4
resolution: "p-map@npm:7.0.4"
checksum: 10/ef48c3b2e488f31c693c9fcc0df0ef76518cf6426a495cf9486ebbb0fd7f31aef7f90e96f72e0070c0ff6e3177c9318f644b512e2c29e3feee8d7153fcb6782e
languageName: node
linkType: hard
@@ -30825,12 +30991,12 @@ __metadata:
linkType: hard
"path-scurry@npm:^2.0.0":
version: 2.0.0
resolution: "path-scurry@npm:2.0.0"
version: 2.0.2
resolution: "path-scurry@npm:2.0.2"
dependencies:
lru-cache: "npm:^11.0.0"
minipass: "npm:^7.1.2"
checksum: 10/285ae0c2d6c34ae91dc1d5378ede21981c9a2f6de1ea9ca5a88b5a270ce9763b83dbadc7a324d512211d8d36b0c540427d3d0817030849d97a60fa840a2c59ec
checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3
languageName: node
linkType: hard
@@ -30848,7 +31014,7 @@ __metadata:
languageName: node
linkType: hard
"path-to-regexp@npm:8.3.0, path-to-regexp@npm:^8.0.0, path-to-regexp@npm:^8.3.0":
"path-to-regexp@npm:8.3.0":
version: 8.3.0
resolution: "path-to-regexp@npm:8.3.0"
checksum: 10/568f148fc64f5fd1ecebf44d531383b28df924214eabf5f2570dce9587a228e36c37882805ff02d71c6209b080ea3ee6a4d2b712b5df09741b67f1f3cf91e55a
@@ -30862,6 +31028,13 @@ __metadata:
languageName: node
linkType: hard
"path-to-regexp@npm:^8.0.0, path-to-regexp@npm:^8.3.0":
version: 8.4.0
resolution: "path-to-regexp@npm:8.4.0"
checksum: 10/6864561ceacaece330a2213c6eb21505519673a65b4249e0e5d518984528f93b302b8bf85ccafc4f9d7f359f919d8b118dc00fdb0b26ded3d21e8802cffdfcb8
languageName: node
linkType: hard
"path-to-regexp@npm:~0.1.12":
version: 0.1.12
resolution: "path-to-regexp@npm:0.1.12"
@@ -31076,7 +31249,7 @@ __metadata:
languageName: node
linkType: hard
"pkg-types@npm:^1.2.0, pkg-types@npm:^1.3.0, pkg-types@npm:^1.3.1":
"pkg-types@npm:^1.2.0, pkg-types@npm:^1.3.1":
version: 1.3.1
resolution: "pkg-types@npm:1.3.1"
dependencies:
@@ -31651,11 +31824,11 @@ __metadata:
linkType: hard
"pretty-ms@npm:^9.0.0, pretty-ms@npm:^9.2.0":
version: 9.2.0
resolution: "pretty-ms@npm:9.2.0"
version: 9.3.0
resolution: "pretty-ms@npm:9.3.0"
dependencies:
parse-ms: "npm:^4.0.0"
checksum: 10/a65a1d81560867f4f7128862fdbf0e1c2d3c5607bf75cae7758bf8111e2c4b744be46e084704125a38ba918bb43defa7a53aaff0f48c5c2d95367d3148c980d9
checksum: 10/beb4e04dc17071885b827e3f33d36be279791f2f36a8c29a45c77e59979dad79a5d7e5211922c72a3f6f109bb64a707d70fcdba6746e077122afcd88ce202e98
languageName: node
linkType: hard
@@ -33431,9 +33604,9 @@ __metadata:
linkType: hard
"sax@npm:>=0.6.0, sax@npm:^1.2.4, sax@npm:^1.4.1":
version: 1.4.1
resolution: "sax@npm:1.4.1"
checksum: 10/b1c784b545019187b53a0c28edb4f6314951c971e2963a69739c6ce222bfbc767e54d320e689352daba79b7d5e06d22b5d7113b99336219d6e93718e2f99d335
version: 1.5.0
resolution: "sax@npm:1.5.0"
checksum: 10/9012ff37dda7a7ac5da45db2143b04036103e8bef8d586c3023afd5df6caf0ebd7f38017eee344ad2e2247eded7d38e9c42cf291d8dd91781352900ac0fd2d9f
languageName: node
linkType: hard
@@ -33523,7 +33696,7 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:7.7.4, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:~7.7.3":
"semver@npm:7.7.4, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:~7.7.3":
version: 7.7.4
resolution: "semver@npm:7.7.4"
bin:
@@ -34511,11 +34684,11 @@ __metadata:
linkType: hard
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0":
version: 7.1.0
resolution: "strip-ansi@npm:7.1.0"
version: 7.2.0
resolution: "strip-ansi@npm:7.2.0"
dependencies:
ansi-regex: "npm:^6.0.1"
checksum: 10/475f53e9c44375d6e72807284024ac5d668ee1d06010740dec0b9744f2ddf47de8d7151f80e5f6190fc8f384e802fdf9504b76a7e9020c9faee7103623338be2
ansi-regex: "npm:^6.2.2"
checksum: 10/96da3bc6d73cfba1218625a3d66cf7d37a69bf0920d8735b28f9eeaafcdb6c1fe8440e1ae9eb1ba0ca355dbe8702da872e105e2e939fa93e7851b3cb5dd7d316
languageName: node
linkType: hard
@@ -35144,9 +35317,9 @@ __metadata:
linkType: hard
"tinyexec@npm:^1.0.0, tinyexec@npm:^1.0.1":
version: 1.0.1
resolution: "tinyexec@npm:1.0.1"
checksum: 10/1f3c3281912d4ab168e067baf46627bb85a803eba0bcea113bba9fe8bdfdcc279cad08052a600d4b8fb603dd57e1af0c500e50a5e7e6b29b2574c88556f41fa6
version: 1.0.4
resolution: "tinyexec@npm:1.0.4"
checksum: 10/ccebe4044eef6fa5050929df7862fda70b4fb700f15d94aef8ae6109b9d194dbc3a990125d99944fd25b90fe2115e1927f055b909a604c571a81b647ede5757a
languageName: node
linkType: hard
@@ -35676,10 +35849,10 @@ __metadata:
languageName: node
linkType: hard
"ufo@npm:^1.5.4":
version: 1.6.1
resolution: "ufo@npm:1.6.1"
checksum: 10/088a68133b93af183b093e5a8730a40fe7fd675d3dc0656ea7512f180af45c92300c294f14d4d46d4b2b553e3e52d3b13d4856b9885e620e7001edf85531234e
"ufo@npm:^1.5.4, ufo@npm:^1.6.3":
version: 1.6.3
resolution: "ufo@npm:1.6.3"
checksum: 10/79803984f3e414567273a666183d6a50d1bec0d852100a98f55c1e393cb705e3b88033e04029dd651714e6eec99e1b00f54fdc13f32404968251a16f8898cfe5
languageName: node
linkType: hard
@@ -37022,8 +37195,8 @@ __metadata:
linkType: hard
"ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:^8.18.2, ws@npm:^8.18.3":
version: 8.19.0
resolution: "ws@npm:8.19.0"
version: 8.20.0
resolution: "ws@npm:8.20.0"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
@@ -37032,7 +37205,7 @@ __metadata:
optional: true
utf-8-validate:
optional: true
checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b
checksum: 10/b7ab934b21ffdea9f25a5af5097e8c1ec7625db553bca026c5a23e35b7c236f3fb89782f2b57fab9da553864512f9aa7d245827ef998d26ffa1b2187a19a6d10
languageName: node
linkType: hard