Skip to content

Commit 9529379

Browse files
tea-artistgithub-actions[bot]caoxing9boris-wgaryli27
authored
[sync] fix(v2): skip deleted links in schema operation repair T7062 (#3620)
Synced from teableio/teable-ee@5e42495 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Aries X <caoxing9@gmail.com> Co-authored-by: Boris <boris2code@outlook.com> Co-authored-by: Gary Guangyu Li <gary@teable.ai> Co-authored-by: Jocky Zhou <jocky@teable.ai> Co-authored-by: Jun Lu <hammond@teable.io> Co-authored-by: Pengap <penganpingprivte@gmail.com> Co-authored-by: SkyHuang <sky.huang.fe@gmail.com> Co-authored-by: Uno <uno@teable.ai> Co-authored-by: nichenqin <nichenqin@hotmail.com> Co-authored-by: teable-mini[bot] <310066019+teable-mini[bot]@users.noreply.github.com>
1 parent 06a4461 commit 9529379

2,394 files changed

Lines changed: 246211 additions & 21995 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎agents.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Teable v2 agent guide
22

3-
DDD/domain-model guidance has moved to the skill `teable-ddd-domain-model` in `.codex/skills/teable-ddd-domain-model`. Use that skill for any v2/core domain, specification, or aggregate changes.
3+
DDD/domain-model skill: `teable-ddd-domain-model` in `.claude/skills`, `.codex/skills`, and `.opencode/skills`. Use it for any v2/core domain, command, port, import/host integration, or field that core "does not interpret".
4+
5+
v2-core is the Table bounded context (table, field, view, record, formula). Do not leak host folder / base-node / billing into `@teable/v2-core` as command fields or Noop ports, even as opaque ids. Nest attaches after `commandBus.execute` (the table is already ready). See `packages/v2/core/src/ARCHITECTURE.md`.
46

57
## Git hygiene
68

‎apps/nestjs-backend/package.json‎

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -126,18 +126,18 @@
126126
"webpack": "5.91.0"
127127
},
128128
"dependencies": {
129-
"@ai-sdk/amazon-bedrock": "4.0.97",
130-
"@ai-sdk/anthropic": "3.0.72",
131-
"@ai-sdk/azure": "3.0.55",
132-
"@ai-sdk/cohere": "3.0.31",
133-
"@ai-sdk/deepseek": "2.0.30",
134-
"@ai-sdk/google": "3.0.65",
135-
"@ai-sdk/mistral": "3.0.31",
136-
"@ai-sdk/openai": "3.0.54",
137-
"@ai-sdk/openai-compatible": "2.0.42",
138-
"@ai-sdk/provider": "3.0.9",
139-
"@ai-sdk/togetherai": "2.0.46",
140-
"@ai-sdk/xai": "3.0.84",
129+
"@ai-sdk/amazon-bedrock": "4.0.148",
130+
"@ai-sdk/anthropic": "3.0.107",
131+
"@ai-sdk/azure": "3.0.96",
132+
"@ai-sdk/cohere": "3.0.51",
133+
"@ai-sdk/deepseek": "2.0.52",
134+
"@ai-sdk/google": "3.0.104",
135+
"@ai-sdk/mistral": "3.0.54",
136+
"@ai-sdk/openai": "3.0.91",
137+
"@ai-sdk/openai-compatible": "2.0.64",
138+
"@ai-sdk/provider": "3.0.14",
139+
"@ai-sdk/togetherai": "2.0.70",
140+
"@ai-sdk/xai": "3.0.115",
141141
"@an-epiphany/websocket-json-stream": "1.2.0",
142142
"@aws-sdk/client-s3": "3.609.0",
143143
"@aws-sdk/lib-storage": "3.609.0",
@@ -160,21 +160,21 @@
160160
"@nestjs/websockets": "10.3.5",
161161
"@openrouter/ai-sdk-provider": "2.8.1",
162162
"@opentelemetry/api": "1.9.0",
163-
"@opentelemetry/context-async-hooks": "2.5.0",
164-
"@opentelemetry/exporter-logs-otlp-http": "0.201.1",
165-
"@opentelemetry/exporter-metrics-otlp-http": "0.201.1",
166-
"@opentelemetry/exporter-trace-otlp-http": "0.201.1",
167-
"@opentelemetry/instrumentation-express": "0.50.0",
168-
"@opentelemetry/instrumentation-http": "0.201.1",
169-
"@opentelemetry/instrumentation-ioredis": "0.49.0",
170-
"@opentelemetry/instrumentation-nestjs-core": "0.49.0",
171-
"@opentelemetry/instrumentation-pg": "0.49.0",
172-
"@opentelemetry/instrumentation-pino": "0.54.0",
173-
"@opentelemetry/instrumentation-runtime-node": "0.24.0",
174-
"@opentelemetry/resources": "2.0.1",
175-
"@opentelemetry/sdk-node": "0.201.1",
176-
"@opentelemetry/sdk-trace-base": "2.0.1",
177-
"@opentelemetry/semantic-conventions": "1.34.0",
163+
"@opentelemetry/context-async-hooks": "2.10.0",
164+
"@opentelemetry/exporter-logs-otlp-http": "0.221.0",
165+
"@opentelemetry/exporter-metrics-otlp-http": "0.221.0",
166+
"@opentelemetry/exporter-trace-otlp-http": "0.221.0",
167+
"@opentelemetry/instrumentation-express": "0.69.0",
168+
"@opentelemetry/instrumentation-http": "0.221.0",
169+
"@opentelemetry/instrumentation-ioredis": "0.69.0",
170+
"@opentelemetry/instrumentation-nestjs-core": "0.67.0",
171+
"@opentelemetry/instrumentation-pg": "0.73.0",
172+
"@opentelemetry/instrumentation-pino": "0.67.0",
173+
"@opentelemetry/instrumentation-runtime-node": "0.34.0",
174+
"@opentelemetry/resources": "2.10.0",
175+
"@opentelemetry/sdk-node": "0.221.0",
176+
"@opentelemetry/sdk-trace-base": "2.10.0",
177+
"@opentelemetry/semantic-conventions": "1.43.0",
178178
"@orpc/nest": "1.13.0",
179179
"@prisma/client": "6.2.1",
180180
"@prisma/instrumentation": "6.2.1",
@@ -189,6 +189,7 @@
189189
"@teable/openapi": "workspace:^",
190190
"@teable/v2-adapter-db-postgres-pg": "workspace:*",
191191
"@teable/v2-adapter-realtime-sharedb": "workspace:*",
192+
"@teable/v2-adapter-table-query-ops-postgres": "workspace:^",
192193
"@teable/v2-adapter-table-repository-postgres": "workspace:^",
193194
"@teable/v2-adapter-undo-redo-keyv": "workspace:*",
194195
"@teable/v2-container-node": "workspace:*",
@@ -202,7 +203,7 @@
202203
"@teable/v2-table-query-ops": "workspace:*",
203204
"@teable/v2-utils": "workspace:*",
204205
"@valibot/to-json-schema": "1.3.0",
205-
"ai": "6.0.169",
206+
"ai": "6.0.246",
206207
"ajv": "8.12.0",
207208
"archiver": "7.0.1",
208209
"axios": "1.7.7",
@@ -239,7 +240,7 @@
239240
"minio": "7.1.3",
240241
"ms": "2.1.3",
241242
"multer": "1.4.5-lts.1",
242-
"nanoid": "3.3.7",
243+
"nanoid": "3.3.18",
243244
"nest-knexjs": "0.0.22",
244245
"nestjs-cls": "4.3.0",
245246
"nestjs-i18n": "10.5.1",
@@ -265,6 +266,7 @@
265266
"passport-openidconnect": "0.1.2",
266267
"pause": "0.1.0",
267268
"pdf-parse": "2.4.5",
269+
"permessage-deflate": "0.1.7",
268270
"pg": "8.11.5",
269271
"pino-http": "10.5.0",
270272
"pino-pretty": "11.0.0",

‎apps/nestjs-backend/src/app.module.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { CommentOpenApiModule } from './features/comment/comment-open-api.module
2424
import { DashboardModule } from './features/dashboard/dashboard.module';
2525
import { ExportOpenApiModule } from './features/export/open-api/export-open-api.module';
2626
import { FieldOpenApiModule } from './features/field/open-api/field-open-api.module';
27+
import { GoogleSheetImportModule } from './features/google-sheet-import/google-sheet-import.module';
2728
import { HealthModule } from './features/health/health.module';
2829
import { ImportOpenApiModule } from './features/import/open-api/import-open-api.module';
2930
import { IntegrityModule } from './features/integrity/integrity.module';
@@ -40,6 +41,7 @@ import { PluginModule } from './features/plugin/plugin.module';
4041
import { PluginContextMenuModule } from './features/plugin-context-menu/plugin-context-menu.module';
4142
import { PluginPanelModule } from './features/plugin-panel/plugin-panel.module';
4243
import { RecordHistoryColdModule } from './features/record-history-cold/record-history-cold.module';
44+
import { RecordRemovalColdModule } from './features/record-removal-cold/record-removal-cold.module';
4345
import { SelectionModule } from './features/selection/selection.module';
4446
import { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module';
4547
import { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module';
@@ -89,6 +91,7 @@ export const appModules = {
8991
AccessTokenModule,
9092
ImportOpenApiModule,
9193
AirtableImportModule,
94+
GoogleSheetImportModule,
9295
ExportOpenApiModule,
9396
PinModule,
9497
AdminOpenApiModule,
@@ -102,10 +105,11 @@ export const appModules = {
102105
AiModule,
103106
PluginModule,
104107
PluginPanelModule,
105-
// the ONLY mount of the cold queue CONSUMER: feature modules import
106-
// RecordHistoryColdCoreModule (services only), so auxiliary entrypoints
107-
// composing them never become competing cold-queue workers
108+
// the ONLY mount of the cold queue CONSUMERS: feature modules import
109+
// the Core modules (services only), so auxiliary entrypoints composing
110+
// them never become competing cold-queue workers
108111
RecordHistoryColdModule,
112+
RecordRemovalColdModule,
109113
PluginContextMenuModule,
110114
PluginChartModule,
111115
ObservabilityModule,

‎apps/nestjs-backend/src/bootstrap.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { INestApplication } from '@nestjs/common';
44
import { ValidationPipe } from '@nestjs/common';
55
import { ConfigService } from '@nestjs/config';
66
import { NestFactory } from '@nestjs/core';
7+
import { isDomainError, toError } from '@teable/v2-core';
78
import { json, urlencoded } from 'express';
89
import helmet from 'helmet';
910
import isPortReachable from 'is-port-reachable';
@@ -84,9 +85,13 @@ export async function bootstrap() {
8485
logger.log(`> System Time Zone: ${timeZone}`);
8586
logger.log(`> Current System Time: ${now.toString()}`);
8687

87-
process.on('unhandledRejection', (reason: string, promise: Promise<unknown>) => {
88-
logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`);
89-
throw reason;
88+
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
89+
// DomainError is intentionally a POJO (Result-based, not thrown). If one
90+
// still escapes as an unhandled rejection, wrap it so Sentry gets a real
91+
// stack-bearing Error instead of collapsing into activeSpanWrapper.
92+
const normalized = isDomainError(reason) ? toError(reason) : reason;
93+
logger.error(`Unhandled Rejection at: ${promise}, reason: ${normalized}`);
94+
throw normalized;
9095
});
9196

9297
process.on('uncaughtException', (error) => {

‎apps/nestjs-backend/src/cache/cache.service.spec.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,91 @@ describe('CacheService.getMany', () => {
2323
expect(keyv.get).toHaveBeenCalledWith(['a', 'b']);
2424
});
2525
});
26+
27+
/**
28+
* The raw Redis commands (setnx, incr) must land on the same physical key the
29+
* keyv path reads — the key name comes from the store's own `_getKeyName`,
30+
* and setnx writes the keyv `{value, expires}` envelope. A hand-built
31+
* `namespace:key` once diverged from the real layout, making every
32+
* setnx-written key invisible to get()/del().
33+
*/
34+
describe('CacheService raw-command interop with the keyv layout', () => {
35+
let redis: {
36+
set: ReturnType<typeof vi.fn>;
37+
incr: ReturnType<typeof vi.fn>;
38+
expire: ReturnType<typeof vi.fn>;
39+
};
40+
let service: CacheService;
41+
42+
const makeService = (storeExtras: Record<string, unknown> = {}) => {
43+
redis = {
44+
set: vi.fn().mockResolvedValue('OK'),
45+
incr: vi.fn().mockResolvedValue(1),
46+
expire: vi.fn().mockResolvedValue(1),
47+
};
48+
const keyv = {
49+
opts: {
50+
namespace: 'teable_cache',
51+
serialize: JSON.stringify,
52+
store: {
53+
// KeyvRedis(useRedisSets: false) prefixes every key like this.
54+
// eslint-disable-next-line @typescript-eslint/naming-convention
55+
_getKeyName: (key: string) => `sets:namespace:teable_cache:${key}`,
56+
redis,
57+
...storeExtras,
58+
},
59+
},
60+
};
61+
service = new CacheService(keyv as unknown as Keyv<any>);
62+
};
63+
64+
beforeEach(() => makeService());
65+
66+
it('setnx writes the store-derived key with a get()-readable envelope', async () => {
67+
await service.setnx('lock:x', 'owner-1', 30);
68+
69+
const [physicalKey, payload, ex, ttl, nx] = redis.set.mock.calls[0];
70+
expect(physicalKey).toBe('sets:namespace:teable_cache:teable_cache:lock:x');
71+
expect([ex, ttl, nx]).toEqual(['EX', 30, 'NX']);
72+
// The envelope is what keyv's get() deserializes: {value, expires}.
73+
const envelope = JSON.parse(payload);
74+
expect(envelope.value).toBe('owner-1');
75+
expect(envelope.expires).toBeGreaterThan(Date.now());
76+
expect(envelope.expires).toBeLessThanOrEqual(Date.now() + 30_000);
77+
});
78+
79+
it('incr counts on the store-derived key and applies the first-hit ttl', async () => {
80+
await service.incr('oauth:device-rate:ip', 60);
81+
82+
expect(redis.incr).toHaveBeenCalledWith(
83+
'sets:namespace:teable_cache:teable_cache:oauth:device-rate:ip'
84+
);
85+
expect(redis.expire).toHaveBeenCalledWith(
86+
'sets:namespace:teable_cache:teable_cache:oauth:device-rate:ip',
87+
60
88+
);
89+
});
90+
91+
it('expire retargets the same derived key that incr counts on', async () => {
92+
// The sign-in limiter pairs these two: incr counts attempts, expire cuts
93+
// the counter short at lockout. Split layouts made expire a silent no-op.
94+
await service.incr('signin:attempts:a@b.c', 30);
95+
await service.expire('signin:attempts:a@b.c', 1);
96+
97+
expect(redis.incr).toHaveBeenCalledWith(
98+
'sets:namespace:teable_cache:teable_cache:signin:attempts:a@b.c'
99+
);
100+
expect(redis.expire).toHaveBeenLastCalledWith(
101+
'sets:namespace:teable_cache:teable_cache:signin:attempts:a@b.c',
102+
1
103+
);
104+
});
105+
106+
it('falls back to a plain namespace prefix when the store has no _getKeyName', async () => {
107+
makeService({ _getKeyName: undefined });
108+
109+
await service.setnx('lock:x', 'owner-1', 30);
110+
111+
expect(redis.set.mock.calls[0][0]).toBe('teable_cache:lock:x');
112+
});
113+
});

‎apps/nestjs-backend/src/cache/cache.service.ts‎

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,37 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
3232
}
3333

3434
/**
35-
* Atomic set-if-not-exists operation (Redis SETNX with EX)
36-
* Returns true if the key was set, false if it already existed
37-
* @param key - The key to set
38-
* @param value - The value to set
39-
* @param ttlSeconds - TTL in seconds
35+
* Physical Redis key for `key`, exactly as the keyv stack lays it out: the
36+
* keyv namespace prefix, then the store's own key naming (KeyvRedis with
37+
* `useRedisSets: false` adds `sets:namespace:<ns>:` on top). Asking the
38+
* store for its name instead of hand-building the layout is what keeps raw
39+
* commands and the keyv read path pointed at the same bytes — a hand-built
40+
* `namespace:key` silently diverged from the real layout, so keys written
41+
* by raw commands were invisible to get()/del().
42+
*/
43+
private getRedisKey(key: string): string {
44+
const prefixed = `${this.cacheManager.opts.namespace}:${key}`;
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46+
const store = this.cacheManager.opts?.store as any;
47+
return typeof store?._getKeyName === 'function' ? store._getKeyName(prefixed) : prefixed;
48+
}
49+
50+
/**
51+
* The envelope keyv wraps every value in (`{value, expires}`), produced with
52+
* the instance's own serializer — this is what makes a raw-written key
53+
* readable back through get().
54+
*/
55+
private serializeEnvelope(value: unknown, ttlSeconds: number): string {
56+
const serialize = this.cacheManager.opts.serialize ?? JSON.stringify;
57+
return serialize({ value, expires: Date.now() + ttlSeconds * 1000 }) as string;
58+
}
59+
60+
/**
61+
* Atomic set-if-not-exists operation (Redis SET NX EX).
62+
* Returns true if the key was set, false if it already existed.
63+
*
64+
* Fully interoperable with get()/del(): the write lands on the same
65+
* physical key, in the same envelope, as a set() would.
4066
*/
4167
async setnx<TKey extends keyof T>(
4268
key: TKey,
@@ -54,18 +80,23 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
5480
return true;
5581
}
5682

57-
// Use Redis SET with NX and EX for atomic operation
58-
const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;
59-
const serializedValue = JSON.stringify(value);
60-
const result = await redis.set(fullKey, serializedValue, 'EX', ttlSeconds, 'NX');
83+
const result = await redis.set(
84+
this.getRedisKey(key as string),
85+
this.serializeEnvelope(value, ttlSeconds),
86+
'EX',
87+
ttlSeconds,
88+
'NX'
89+
);
6190
return result === 'OK';
6291
}
6392

6493
/**
65-
* Atomic increment operation (Redis INCR with optional EX)
66-
* Returns the new value after increment
67-
* @param key - The key to increment
68-
* @param ttlSeconds - Optional TTL in seconds (only set on first increment)
94+
* Atomic increment operation (Redis INCR with optional EX).
95+
* Returns the new value after increment.
96+
*
97+
* The counter is NOT readable via get(): atomic INCR requires a bare
98+
* integer on the wire, which the keyv envelope cannot carry. Read the
99+
* count only from this method's return value. del() does work on it.
69100
*/
70101
async incr<TKey extends keyof T>(key: TKey, ttlSeconds?: number): Promise<number> {
71102
const redis = this.getRedisClient();
@@ -77,7 +108,7 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
77108
return newValue;
78109
}
79110

80-
const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;
111+
const fullKey = this.getRedisKey(key as string);
81112
const newValue = await redis.incr(fullKey);
82113

83114
// Set TTL only if provided and this is the first increment (value is 1)
@@ -139,8 +170,13 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
139170
}
140171

141172
/**
142-
* Update the TTL of an existing key without reading/writing data
143-
* Returns true if the key exists and TTL was updated
173+
* Update the TTL of an existing key without reading/writing data.
174+
* Returns true if the key exists and TTL was updated.
175+
*
176+
* Only safe for SHORTENING the life of set()/setnx()-written values: their
177+
* keyv envelope embeds an absolute `expires` that get() honors regardless of
178+
* the Redis TTL, so an extension keeps the key alive but not readable.
179+
* incr() counters carry no envelope and can go either way.
144180
*/
145181
async expire<TKey extends keyof T>(key: TKey, ttl: number | string): Promise<boolean> {
146182
const ttlSeconds = typeof ttl === 'string' ? second(ttl) : ttl;
@@ -155,8 +191,7 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
155191
return false;
156192
}
157193

158-
const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;
159-
const result = await redis.expire(fullKey, ttlSeconds);
194+
const result = await redis.expire(this.getRedisKey(key as string), ttlSeconds);
160195
return result === 1;
161196
}
162197
}

0 commit comments

Comments
 (0)