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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/docker-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.22.3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [22.18.0]
node-version: [22.22.3]
runtime:
- mode: v1
force-v2-all: ''
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/linting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

strategy:
matrix:
node-version: [22.18.0]
node-version: [22.22.3]

steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/manual-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.22.3
- name: ⚙️ Install zx
run: npm install -g zx

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:

strategy:
matrix:
node-version: [22.18.0]
node-version: [22.22.3]

steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/v2-benchmark-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:

strategy:
matrix:
node-version: [22.18.0]
node-version: [22.22.3]

steps:
- uses: actions/checkout@v4
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/v2-core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Use Node.js 22.18.0
- name: Use Node.js 22.22.3
uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.22.3

- name: 📥 Monorepo install
uses: ./.github/actions/pnpm-install
Expand All @@ -63,10 +63,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Use Node.js 22.18.0
- name: Use Node.js 22.22.3
uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.22.3

- name: 📥 Monorepo install
uses: ./.github/actions/pnpm-install
Expand Down Expand Up @@ -102,10 +102,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Use Node.js 22.18.0
- name: Use Node.js 22.22.3
uses: actions/setup-node@v4
with:
node-version: 22.18.0
node-version: 22.22.3

- name: 📥 Download all coverage artifacts
uses: actions/download-artifact@v4
Expand Down
2 changes: 1 addition & 1 deletion .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ auto-install-peers=true
lockfile=true
# force use npmjs.org registry
registry=https://registry.npmjs.org/
use-node-version=22.18.0
use-node-version=22.22.3
save-prefix=''
4 changes: 3 additions & 1 deletion apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"eslint-config-next": "15.5.9",
"get-tsconfig": "4.7.3",
"istanbul-merge": "2.0.0",
"neverthrow": "8.2.0",
"npm-run-all2": "6.1.2",
"nyc": "15.1.0",
"pg-mem": "3.0.5",
Expand Down Expand Up @@ -167,7 +168,7 @@
"@opentelemetry/instrumentation-ioredis": "0.49.0",
"@opentelemetry/instrumentation-nestjs-core": "0.49.0",
"@opentelemetry/instrumentation-pg": "0.49.0",
"@opentelemetry/instrumentation-pino": "0.49.0",
"@opentelemetry/instrumentation-pino": "0.54.0",
"@opentelemetry/instrumentation-runtime-node": "0.24.0",
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/sdk-node": "0.201.1",
Expand Down Expand Up @@ -228,6 +229,7 @@
"keyv": "4.5.4",
"knex": "3.1.0",
"lodash": "4.17.21",
"markdown-it": "14.1.0",
"mime-types": "2.1.35",
"minio": "7.1.3",
"ms": "2.1.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/nestjs-backend/src/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface ICacheStore {
[key: `waitlist:invite-code:${string}`]: number;
[key: `send-mail-rate-limit:${string}`]: boolean;
[key: `oauth:token-rate:${string}:${string}`]: number;
[key: `automation:email:rate:${string}:${number}`]: number;
[key: `email:send:rate:${string}:${number}`]: number;
[key: `automation:email-att:${string}`]: string[];
[key: `automation:fail-notify-count:${string}`]: number;
// Distributed lock keys
Expand Down
3 changes: 3 additions & 0 deletions apps/nestjs-backend/src/configs/threshold.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const thresholdConfig = registerAs('threshold', () => ({
bigTransactionTimeout: Number(
process.env.BIG_TRANSACTION_TIMEOUT ?? 10 * 60 * 1000 /* 10 mins */
),
// DB statement_timeout (ms) for the search query, so a slow / full-scan search is canceled
// and its connection released instead of being held for minutes. Tune via SEARCH_TIMEOUT.
searchTimeout: Number(process.env.SEARCH_TIMEOUT ?? 15_000 /* 15s */),
automationGap: Number(process.env.AUTOMATION_GAP ?? 200),
maxAttachmentUploadSize: Number(process.env.MAX_ATTACHMENT_UPLOAD_SIZE ?? Infinity),
maxOpenapiAttachmentUploadSize: Number(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ const buildDateField = (): IFieldInstance =>
},
}) as IFieldInstance;

const buildMultipleSelectField = (): IFieldInstance =>
({
id: 'fldMultiSelect0001',
dbFieldName: 'Tags',
cellValueType: CellValueType.String,
isMultipleCellValue: true,
isStructuredCellValue: false,
type: FieldType.MultipleSelect,
options: {},
}) as IFieldInstance;

describe('SearchQueryPostgres', () => {
const db = knex({ client: 'pg' });

Expand Down Expand Up @@ -52,4 +63,19 @@ describe('SearchQueryPostgres', () => {
const compiled = builder.getQuery()?.toSQL();
expect(compiled?.sql).toBe('FALSE');
});

it('matches multipleSelect as a text cast so the gin_trgm index can be used', () => {
const field = buildMultipleSelectField();
const builder = new SearchQueryPostgres(
db.queryBuilder(),
field,
['Beta', 'fldMultiSelect0001'],
[]
);

const compiled = builder.getQuery()?.toSQL();
expect(compiled?.sql).toContain('("Tags")::text ILIKE');
expect(compiled?.sql).not.toContain('jsonb_array_elements');
expect(compiled?.bindings).toEqual(['%Beta%']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ export class SearchQueryPostgres extends SearchQueryAbstract {
case CellValueType.String: {
if (isStructuredCellValue) {
return this.multipleJson();
} else {
return this.multipleText();
}
if (field.type === FieldType.MultipleSelect) {
return this.multipleSelectText();
}
return this.multipleText();
}
case CellValueType.DateTime: {
return this.multipleDate();
Expand Down Expand Up @@ -180,6 +182,16 @@ export class SearchQueryPostgres extends SearchQueryAbstract {
);
}

// multipleSelect stores a plain string[] of option names. Match the whole cell as text so the
// predicate is sargable against the gin_trgm index (built on the same "<col>"::text expression)
// instead of a jsonb_array_elements + regex subquery that cannot use the index. Trades negligible
// precision (JSON brackets/quotes become matchable) for index usage.
protected multipleSelectText() {
const { search, knex } = this;
const escapedSearchValue = escapeLikeWildcards(search[0]);
return knex.raw(`(${this.fieldName})::text ILIKE ? ESCAPE '\\'`, [`%${escapedSearchValue}%`]);
}

protected multipleNumber() {
const { search, knex } = this;
const searchValue = search[0];
Expand Down
15 changes: 15 additions & 0 deletions apps/nestjs-backend/src/event-emitter/events/event.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export enum Events {
SHARED_VIEW_CREATE = 'shared.view.create',
SHARED_VIEW_DELETE = 'shared.view.delete',
SHARED_VIEW_UPDATE = 'shared.view.update',
SHARED_VIEW_REFRESH = 'shared.view.refresh',

USER_SIGNIN = 'user.signin',
USER_SIGNUP = 'user.signup',
Expand All @@ -67,6 +68,20 @@ export enum Events {
COLLABORATOR_DELETE = 'collaborator.delete',
COLLABORATOR_UPDATE = 'collaborator.update',

// Base-scope collaborator audit actions (parallel to the generic COLLABORATOR_*
// business events above, which are kept for internal pub/sub). Future space-level
// audit can mirror this with SPACE_COLLABORATOR_*.
BASE_COLLABORATOR_CREATE = 'base.collaborator.create',
BASE_COLLABORATOR_DELETE = 'base.collaborator.delete',
BASE_COLLABORATOR_UPDATE = 'base.collaborator.update',

// Base/Node share lifecycle (covers both node-scoped and base-wide shares;
// payload.type distinguishes 'node' | 'base').
BASE_SHARE_CREATE = 'base.share.create',
BASE_SHARE_UPDATE = 'base.share.update',
BASE_SHARE_DELETE = 'base.share.delete',
BASE_SHARE_REFRESH = 'base.share.refresh',

BASE_FOLDER_CREATE = 'base.folder.create',
BASE_FOLDER_DELETE = 'base.folder.delete',
BASE_FOLDER_UPDATE = 'base.folder.update',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ export class AccessTokenService {
@Audit({
action: Events.ACCESS_TOKEN_CREATE,
resourceId: (input: { userId?: string }, ctx) => input.userId ?? ctx.cls.get('user.id')!,
userId: (input: { userId?: string }, ctx) => input.userId ?? ctx.cls.get('user.id'),
// Record the token's settings so the audit row shows what access was granted. NEVER the secret:
// the token `sign` is generated server-side and is not part of the input, so this is safe.
params: (input: CreateAccessTokenRo & { clientId?: string }) => ({
name: input.name,
description: input.description,
scopes: input.scopes,
spaceIds: input.spaceIds,
baseIds: input.baseIds,
expiredTime: input.expiredTime,
hasFullAccess: input.hasFullAccess,
}),
emit: true,
})
async createAccessToken(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import {
CellValueType,
HttpErrorCode,
Expand Down Expand Up @@ -1014,8 +1013,15 @@ export class AggregationService implements IAggregationService {

this.logger.debug('getRecordIndexBySearchOrder sql: %s', sql);

const searchTimeout = this.thresholdConfig.searchTimeout;

try {
return await this.withDataPrismaTransaction(tableId, async (prisma) => {
// Bound the search at the DB level: a short / CJK term can defeat the pg_trgm index and
// degrade to a full-table scan. SET LOCAL statement_timeout makes Postgres cancel the
// statement and release the pooled connection, instead of the client abandoning the
// request while the query keeps running and starves the connection pool.
await prisma.$executeRawUnsafe(`SET LOCAL statement_timeout = ${searchTimeout}`);
const result = await prisma.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(sql);

// no result found
Expand Down Expand Up @@ -1080,18 +1086,52 @@ export class AggregationService implements IAggregationService {
recordId: item.__id,
};
});
// statement_timeout (above) is the real cap; give the JS-side tx timer headroom so
// Postgres cancels the statement first, instead of the data-tx default timeout firing early.
});
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2028') {
throw new CustomHttpException(`${error.message}`, HttpErrorCode.REQUEST_TIMEOUT, {
localization: {
i18nKey: 'httpErrors.aggregation.searchTimeOut',
},
});
if (this.isSearchTimeoutError(error)) {
throw new CustomHttpException(
`${(error as Error).message}`,
HttpErrorCode.REQUEST_TIMEOUT,
{
localization: {
i18nKey: 'httpErrors.aggregation.searchTimeOut',
},
}
);
}
throw error;
}
}

/**
* Detects a search timeout from any of the layers involved. The search runs on the data-db
* Prisma client, whose error classes come from a *separate* generated runtime, so cross-package
* `instanceof` (PrismaClientKnownRequestError / TimeoutHttpException) is unreliable here — we
* duck-type on the error shape instead:
* - Postgres cancels the statement via `SET LOCAL statement_timeout` → SQLSTATE 57014,
* - Prisma's interactive-transaction timeout → code P2028,
* - the data-prisma proxy converts P2028 into a TimeoutHttpException → code 'request_timeout'.
* The DB-level cancellation (57014) is what actually releases the pooled connection.
*/
private isSearchTimeoutError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
const err = error as { code?: unknown; meta?: unknown; message?: unknown };
const pgErrorCode =
typeof err.meta === 'object' && err.meta !== null
? (err.meta as { code?: unknown }).code
: undefined;
const message = typeof err.message === 'string' ? err.message : '';
return (
err.code === 'P2028' ||
err.code === 'request_timeout' ||
pgErrorCode === '57014' ||
/canceling statement due to statement timeout|Transaction already closed/i.test(message)
);
}
async getRecordIndex(tableId: string, queryRo: IRecordIndexRo): Promise<IRecordIndexVo> {
const { recordId } = queryRo;

Expand Down
Loading
Loading