@open-mercato/core 0.6.7-develop.6834.1.e76f4b8cbc → 0.6.7-develop.6851.1.c1680b00d8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +2 -3
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
- package/dist/modules/dashboards/lib/aggregations.js +20 -6
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +2 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
- package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +10 -0
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/cli.js +125 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +19 -7
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +43 -37
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +27 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +2 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/api_keys/i18n/ko.json +42 -0
- package/src/modules/attachments/i18n/ko.json +172 -0
- package/src/modules/audit_logs/i18n/ko.json +66 -0
- package/src/modules/auth/i18n/ko.json +255 -0
- package/src/modules/business_rules/i18n/ko.json +396 -0
- package/src/modules/catalog/i18n/ko.json +723 -0
- package/src/modules/communication_channels/i18n/ko.json +138 -0
- package/src/modules/configs/i18n/ko.json +197 -0
- package/src/modules/currencies/i18n/de.json +1 -0
- package/src/modules/currencies/i18n/en.json +1 -0
- package/src/modules/currencies/i18n/es.json +1 -0
- package/src/modules/currencies/i18n/ko.json +160 -0
- package/src/modules/currencies/i18n/pl.json +1 -0
- package/src/modules/customer_accounts/i18n/ko.json +354 -0
- package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +2 -3
- package/src/modules/customers/i18n/de.json +10 -0
- package/src/modules/customers/i18n/en.json +10 -0
- package/src/modules/customers/i18n/es.json +10 -0
- package/src/modules/customers/i18n/ko.json +2855 -0
- package/src/modules/customers/i18n/pl.json +10 -0
- package/src/modules/dashboards/i18n/ko.json +118 -0
- package/src/modules/dashboards/lib/aggregations.ts +42 -6
- package/src/modules/data_sync/i18n/ko.json +125 -0
- package/src/modules/design_system/i18n/ko.json +58 -0
- package/src/modules/dictionaries/i18n/ko.json +130 -0
- package/src/modules/directory/i18n/ko.json +105 -0
- package/src/modules/entities/api/definitions.ts +4 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
- package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
- package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
- package/src/modules/entities/i18n/ko.json +260 -0
- package/src/modules/feature_toggles/i18n/ko.json +96 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/inbox_ops/i18n/ko.json +196 -0
- package/src/modules/integrations/i18n/ko.json +129 -0
- package/src/modules/messages/i18n/ko.json +263 -0
- package/src/modules/notifications/i18n/ko.json +55 -0
- package/src/modules/payment_gateways/i18n/ko.json +87 -0
- package/src/modules/planner/i18n/ko.json +565 -0
- package/src/modules/portal/i18n/ko.json +143 -0
- package/src/modules/progress/i18n/ko.json +20 -0
- package/src/modules/query_index/api/reindex.ts +15 -0
- package/src/modules/query_index/cli.ts +180 -1
- package/src/modules/query_index/i18n/ko.json +69 -0
- package/src/modules/query_index/lib/coverage.ts +38 -8
- package/src/modules/query_index/lib/reindexer.ts +62 -40
- package/src/modules/resources/i18n/ko.json +770 -0
- package/src/modules/sales/commands/documents.ts +49 -0
- package/src/modules/sales/data/validators.ts +2 -0
- package/src/modules/sales/i18n/ko.json +1442 -0
- package/src/modules/shipping_carriers/i18n/ko.json +85 -0
- package/src/modules/staff/i18n/ko.json +1162 -0
- package/src/modules/sync_excel/i18n/ko.json +107 -0
- package/src/modules/translations/i18n/ko.json +45 -0
- package/src/modules/wms/i18n/ko.json +1053 -0
- package/src/modules/workflows/i18n/ko.json +1203 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"portal.dashboard.customize": "사용자 지정",
|
|
3
|
+
"portal.dashboard.done": "완료",
|
|
4
|
+
"portal.dashboard.email": "이메일",
|
|
5
|
+
"portal.dashboard.emailNotVerified": "인증되지 않음",
|
|
6
|
+
"portal.dashboard.emailVerified": "인증됨",
|
|
7
|
+
"portal.dashboard.emptyWidgets": "모듈이 portal:dashboard:sections 위치에 위젯을 삽입하면 이곳에 대시보드 위젯이 표시됩니다.",
|
|
8
|
+
"portal.dashboard.lastLogin": "마지막 로그인",
|
|
9
|
+
"portal.dashboard.logout": "로그아웃",
|
|
10
|
+
"portal.dashboard.memberSince": "가입일",
|
|
11
|
+
"portal.dashboard.never": "없음",
|
|
12
|
+
"portal.dashboard.noPermissions": "권한 없음",
|
|
13
|
+
"portal.dashboard.noRoles": "할당된 역할 없음",
|
|
14
|
+
"portal.dashboard.permissions": "권한",
|
|
15
|
+
"portal.dashboard.portalAdmin": "포털 관리자",
|
|
16
|
+
"portal.dashboard.profile": "프로필",
|
|
17
|
+
"portal.dashboard.roles": "역할",
|
|
18
|
+
"portal.dashboard.title": "대시보드",
|
|
19
|
+
"portal.dashboard.welcome": "{name}님, 환영합니다",
|
|
20
|
+
"portal.dashboard.widgets": "대시보드 위젯",
|
|
21
|
+
"portal.disabled.description": "관리자가 고객 포털을 비활성화했습니다. 자세한 내용은 소속 조직에 문의해 주세요.",
|
|
22
|
+
"portal.disabled.title": "포털을 사용할 수 없습니다",
|
|
23
|
+
"portal.footer.copyright": "© {year} 모든 권리 보유.",
|
|
24
|
+
"portal.info.instructions": "조직의 포털에 접속하려면 조직 고유의 URL로 이동하세요. 포털 링크를 모른다면 관리자에게 문의하세요.",
|
|
25
|
+
"portal.invite.backToLogin": "이미 계정이 있나요?",
|
|
26
|
+
"portal.invite.confirmPassword": "비밀번호 확인",
|
|
27
|
+
"portal.invite.confirmPassword.placeholder": "••••••••",
|
|
28
|
+
"portal.invite.description": "이 초대를 수락하려면 포털 계정을 만드세요.",
|
|
29
|
+
"portal.invite.displayName": "표시 이름",
|
|
30
|
+
"portal.invite.error.displayNameRequired": "표시 이름이 필요합니다.",
|
|
31
|
+
"portal.invite.error.generic": "초대 수락에 실패했습니다. 다시 시도해 주세요.",
|
|
32
|
+
"portal.invite.error.invalidToken": "초대가 유효하지 않거나 만료되었습니다.",
|
|
33
|
+
"portal.invite.error.noToken": "초대 토큰이 잘못되었거나 누락되었습니다.",
|
|
34
|
+
"portal.invite.error.passwordMismatch": "비밀번호가 일치하지 않습니다.",
|
|
35
|
+
"portal.invite.error.passwordTooShort": "비밀번호는 8자 이상이어야 합니다.",
|
|
36
|
+
"portal.invite.loginLink": "로그인",
|
|
37
|
+
"portal.invite.password": "비밀번호",
|
|
38
|
+
"portal.invite.password.placeholder": "••••••••",
|
|
39
|
+
"portal.invite.submit": "초대 수락",
|
|
40
|
+
"portal.invite.submitting": "초대를 수락하는 중…",
|
|
41
|
+
"portal.invite.title": "초대 수락",
|
|
42
|
+
"portal.landing.cta.login": "로그인",
|
|
43
|
+
"portal.landing.cta.signup": "계정 만들기",
|
|
44
|
+
"portal.landing.feature.account": "계정 관리",
|
|
45
|
+
"portal.landing.feature.account.description": "프로필을 업데이트하고, 팀원을 관리하고, 환경설정을 구성하세요.",
|
|
46
|
+
"portal.landing.feature.orders": "주문 및 인보이스",
|
|
47
|
+
"portal.landing.feature.orders.description": "주문을 추적하고, 인보이스를 다운로드하고, 배송 상태를 실시간으로 확인하세요.",
|
|
48
|
+
"portal.landing.feature.security": "안전한 접근",
|
|
49
|
+
"portal.landing.feature.security.description": "역할 기반 권한, 세션 관리, 전체 감사 추적 기능을 제공합니다.",
|
|
50
|
+
"portal.landing.hero.description": "계정을 관리하고, 주문을 확인하고, 모든 리소스를 한곳에서 이용하세요.",
|
|
51
|
+
"portal.landing.hero.title": "고객 포털에 오신 것을 환영합니다",
|
|
52
|
+
"portal.login.description": "고객 계정으로 로그인하세요.",
|
|
53
|
+
"portal.login.email": "이메일",
|
|
54
|
+
"portal.login.email.placeholder": "you@example.com",
|
|
55
|
+
"portal.login.error.generic": "로그인에 실패했습니다. 다시 시도해 주세요.",
|
|
56
|
+
"portal.login.error.inactive": "계정이 아직 활성화되지 않았습니다. 로그인하려면 관리자가 계정을 활성화해야 합니다.",
|
|
57
|
+
"portal.login.error.invalidCredentials": "이메일 또는 비밀번호가 올바르지 않습니다.",
|
|
58
|
+
"portal.login.error.locked": "계정이 일시적으로 잠겼습니다. 잠시 후 다시 시도해 주세요.",
|
|
59
|
+
"portal.login.noAccount": "계정이 없으신가요?",
|
|
60
|
+
"portal.login.password": "비밀번호",
|
|
61
|
+
"portal.login.password.placeholder": "비밀번호를 입력하세요",
|
|
62
|
+
"portal.login.signupLink": "가입하기",
|
|
63
|
+
"portal.login.submit": "로그인",
|
|
64
|
+
"portal.login.submitting": "로그인 중…",
|
|
65
|
+
"portal.login.title": "로그인",
|
|
66
|
+
"portal.nav.account": "계정",
|
|
67
|
+
"portal.nav.dashboard": "대시보드",
|
|
68
|
+
"portal.nav.home": "포털",
|
|
69
|
+
"portal.nav.invite": "초대 수락",
|
|
70
|
+
"portal.nav.login": "로그인",
|
|
71
|
+
"portal.nav.logout": "로그아웃",
|
|
72
|
+
"portal.nav.profile": "프로필",
|
|
73
|
+
"portal.nav.resetPassword": "비밀번호 재설정",
|
|
74
|
+
"portal.nav.signup": "회원가입",
|
|
75
|
+
"portal.notifications.badge": "읽지 않은 알림 {count}개",
|
|
76
|
+
"portal.notifications.daysAgo": "{d}일 전",
|
|
77
|
+
"portal.notifications.dismiss": "닫기",
|
|
78
|
+
"portal.notifications.empty": "아직 알림이 없습니다",
|
|
79
|
+
"portal.notifications.emptyUnread": "모두 확인했습니다!",
|
|
80
|
+
"portal.notifications.hoursAgo": "{h}시간 전",
|
|
81
|
+
"portal.notifications.justNow": "방금 전",
|
|
82
|
+
"portal.notifications.markAllRead": "모두 읽음으로 표시",
|
|
83
|
+
"portal.notifications.markRead": "읽음으로 표시",
|
|
84
|
+
"portal.notifications.minutesAgo": "{m}분 전",
|
|
85
|
+
"portal.notifications.tab.all": "전체",
|
|
86
|
+
"portal.notifications.tab.unread": "읽지 않음",
|
|
87
|
+
"portal.notifications.title": "알림",
|
|
88
|
+
"portal.notifications.unreadSummary": "읽지 않은 알림 {count}개",
|
|
89
|
+
"portal.notifications.view": "보기",
|
|
90
|
+
"portal.org.invalid": "조직을 찾을 수 없습니다. URL을 확인한 후 다시 시도해 주세요.",
|
|
91
|
+
"portal.org.loading": "조직 정보를 불러오는 중...",
|
|
92
|
+
"portal.org.noOrganization": "조직 정보를 사용할 수 없습니다.",
|
|
93
|
+
"portal.profile.label": "계정",
|
|
94
|
+
"portal.profile.title": "프로필",
|
|
95
|
+
"portal.resetPassword.backToLogin": "비밀번호가 기억나셨나요?",
|
|
96
|
+
"portal.resetPassword.confirmPassword": "새 비밀번호 확인",
|
|
97
|
+
"portal.resetPassword.confirmPassword.placeholder": "••••••••",
|
|
98
|
+
"portal.resetPassword.description": "아래에 새 비밀번호를 입력하세요.",
|
|
99
|
+
"portal.resetPassword.error.generic": "비밀번호 재설정에 실패했습니다. 다시 시도해 주세요.",
|
|
100
|
+
"portal.resetPassword.error.invalidToken": "재설정 토큰이 유효하지 않거나 만료되었습니다.",
|
|
101
|
+
"portal.resetPassword.error.noToken": "재설정 토큰이 유효하지 않거나 없습니다.",
|
|
102
|
+
"portal.resetPassword.error.passwordMismatch": "비밀번호가 일치하지 않습니다.",
|
|
103
|
+
"portal.resetPassword.error.passwordTooShort": "비밀번호는 8자 이상이어야 합니다.",
|
|
104
|
+
"portal.resetPassword.loginLink": "로그인",
|
|
105
|
+
"portal.resetPassword.password": "새 비밀번호",
|
|
106
|
+
"portal.resetPassword.password.placeholder": "••••••••",
|
|
107
|
+
"portal.resetPassword.submit": "비밀번호 재설정",
|
|
108
|
+
"portal.resetPassword.submitting": "비밀번호 재설정 중…",
|
|
109
|
+
"portal.resetPassword.success.description": "비밀번호가 성공적으로 재설정되었습니다.",
|
|
110
|
+
"portal.resetPassword.success.loginLink": "로그인하러 가기",
|
|
111
|
+
"portal.resetPassword.success.message": "이제 새 비밀번호로 로그인할 수 있습니다.",
|
|
112
|
+
"portal.resetPassword.success.title": "비밀번호 재설정 완료",
|
|
113
|
+
"portal.resetPassword.title": "비밀번호 재설정",
|
|
114
|
+
"portal.signup.description": "신규 고객 계정을 등록하세요.",
|
|
115
|
+
"portal.signup.displayName": "표시 이름",
|
|
116
|
+
"portal.signup.displayName.placeholder": "이름을 입력하세요",
|
|
117
|
+
"portal.signup.email": "이메일",
|
|
118
|
+
"portal.signup.email.placeholder": "you@example.com",
|
|
119
|
+
"portal.signup.error.displayName.required": "이름을 입력해 주세요.",
|
|
120
|
+
"portal.signup.error.email.invalid": "올바른 이메일 주소를 입력해 주세요.",
|
|
121
|
+
"portal.signup.error.generic": "가입에 실패했습니다. 다시 시도해 주세요.",
|
|
122
|
+
"portal.signup.error.password.minLength": "비밀번호는 8자 이상이어야 합니다.",
|
|
123
|
+
"portal.signup.hasAccount": "이미 계정이 있으신가요?",
|
|
124
|
+
"portal.signup.loginLink": "로그인",
|
|
125
|
+
"portal.signup.password": "비밀번호",
|
|
126
|
+
"portal.signup.password.placeholder": "안전한 비밀번호를 설정하세요",
|
|
127
|
+
"portal.signup.submit": "계정 만들기",
|
|
128
|
+
"portal.signup.submitting": "계정 생성 중…",
|
|
129
|
+
"portal.signup.success.description": "가입이 승인되었다면 로그인 전에 이메일에서 다음 단계를 확인하세요. 일부 조직에서는 관리자가 신규 계정을 활성화해야 합니다.",
|
|
130
|
+
"portal.signup.success.loginLink": "로그인하러 가기",
|
|
131
|
+
"portal.signup.success.title": "이메일을 확인하세요",
|
|
132
|
+
"portal.signup.title": "계정 만들기",
|
|
133
|
+
"portal.subtitle": "셀프서비스 허브",
|
|
134
|
+
"portal.title": "고객 포털",
|
|
135
|
+
"portal.verify.description": "이메일 주소를 확인하는 동안 잠시 기다려 주세요.",
|
|
136
|
+
"portal.verify.error.backToLogin": "로그인으로 돌아가기",
|
|
137
|
+
"portal.verify.error.generic": "이메일 인증에 실패했습니다. 다시 시도해 주세요.",
|
|
138
|
+
"portal.verify.error.invalidToken": "인증 토큰이 없습니다.",
|
|
139
|
+
"portal.verify.success.description": "이메일 인증이 완료되었습니다. 이제 포털에 로그인할 수 있습니다.",
|
|
140
|
+
"portal.verify.success.loginLink": "로그인",
|
|
141
|
+
"portal.verify.success.title": "이메일 인증 완료",
|
|
142
|
+
"portal.verify.title": "이메일 인증 중"
|
|
143
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"progress.actions.cancel": "취소",
|
|
3
|
+
"progress.actions.dismiss": "닫기",
|
|
4
|
+
"progress.actions.retry": "재시도",
|
|
5
|
+
"progress.activeCount": "{count}개 작업 실행 중",
|
|
6
|
+
"progress.errors.alreadyFinished": "작업이 이미 완료되었습니다",
|
|
7
|
+
"progress.errors.cannotCancel": "이 작업은 취소할 수 없습니다",
|
|
8
|
+
"progress.errors.notFound": "작업을 찾을 수 없습니다",
|
|
9
|
+
"progress.eta.hoursMinutes": "{hours}시간 {minutes}분 남음",
|
|
10
|
+
"progress.eta.minutes": "{count}분 남음",
|
|
11
|
+
"progress.eta.seconds": "{count}초 남음",
|
|
12
|
+
"progress.processed": "처리됨",
|
|
13
|
+
"progress.recentlyCompleted": "{count}개 작업 완료됨",
|
|
14
|
+
"progress.status.cancelled": "취소됨",
|
|
15
|
+
"progress.status.completed": "완료됨",
|
|
16
|
+
"progress.status.failed": "실패",
|
|
17
|
+
"progress.status.pending": "대기 중",
|
|
18
|
+
"progress.status.running": "실행 중",
|
|
19
|
+
"progress.title": "진행 상황"
|
|
20
|
+
}
|
|
@@ -85,6 +85,21 @@ export async function POST(req: Request) {
|
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
87
|
).catch(() => undefined)
|
|
88
|
+
// Queued once here rather than from a partition: the purge deletes every vector for the
|
|
89
|
+
// scope, so emitting it from inside the fan-out would race the per-record vectorize jobs
|
|
90
|
+
// its siblings are already queueing and drop vectors this run had just rebuilt.
|
|
91
|
+
if (force && partitions.length > 1) {
|
|
92
|
+
try {
|
|
93
|
+
await bus.emitEvent(
|
|
94
|
+
'query_index.vectorize_purge',
|
|
95
|
+
{ entityType, tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
|
|
96
|
+
{ persistent: true, deliverInline: false },
|
|
97
|
+
)
|
|
98
|
+
} catch {
|
|
99
|
+
// Best effort: a failed purge leaves stale vectors, which the orphan sweep still prunes.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
try {
|
|
89
104
|
await Promise.all(
|
|
90
105
|
partitions.map((part) => {
|
|
@@ -11,7 +11,7 @@ type ProgressBarHandle = {
|
|
|
11
11
|
update(completed: number): void
|
|
12
12
|
complete(): void
|
|
13
13
|
}
|
|
14
|
-
import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
14
|
+
import { resolveEntityTableName, resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
15
15
|
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
16
16
|
import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
|
|
17
17
|
import {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from './lib/batch'
|
|
24
24
|
import { reindexEntity, DEFAULT_REINDEX_PARTITIONS } from './lib/reindexer'
|
|
25
25
|
import { purgeIndexScope } from './lib/purge'
|
|
26
|
+
import { refreshCoverageSnapshot } from './lib/coverage'
|
|
26
27
|
import { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'
|
|
27
28
|
import type { VectorIndexService } from '@open-mercato/search/vector'
|
|
28
29
|
|
|
@@ -340,6 +341,168 @@ function describeScope(scope: ScopeDescriptor): string {
|
|
|
340
341
|
return parts.length ? ` (${parts.join(' ')})` : ''
|
|
341
342
|
}
|
|
342
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Above this many missing records the anti-join stops being the cheaper option and the
|
|
346
|
+
* repair falls back to a full single-partition rebuild of the entity.
|
|
347
|
+
*/
|
|
348
|
+
const MAX_TARGETED_REPAIR_RECORDS = 5000
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* List the base records in scope that have no live index row, so a repair rebuilds only
|
|
352
|
+
* what is actually missing instead of re-upserting the whole entity for a one-row gap.
|
|
353
|
+
*/
|
|
354
|
+
async function findUnindexedRecordIds(
|
|
355
|
+
db: Kysely<any>,
|
|
356
|
+
options: {
|
|
357
|
+
entityType: string
|
|
358
|
+
tableName: string
|
|
359
|
+
tenantId?: string | null
|
|
360
|
+
organizationId?: string | null
|
|
361
|
+
limit: number
|
|
362
|
+
},
|
|
363
|
+
): Promise<string[]> {
|
|
364
|
+
const columns = await getColumnSet(db, options.tableName)
|
|
365
|
+
let query = db
|
|
366
|
+
.selectFrom(`${options.tableName} as b` as any)
|
|
367
|
+
.select('b.id' as any)
|
|
368
|
+
.where(({ not, exists, selectFrom }: any) =>
|
|
369
|
+
not(exists(
|
|
370
|
+
selectFrom('entity_indexes as ei' as any)
|
|
371
|
+
.select(sql`1`.as('one'))
|
|
372
|
+
.whereRef('ei.entity_id' as any, '=', sql`b.id::text`)
|
|
373
|
+
.where('ei.entity_type' as any, '=', options.entityType)
|
|
374
|
+
.where('ei.deleted_at' as any, 'is', null as any),
|
|
375
|
+
)),
|
|
376
|
+
)
|
|
377
|
+
.limit(options.limit)
|
|
378
|
+
if (columns.has('deleted_at')) query = query.where('b.deleted_at' as any, 'is', null as any)
|
|
379
|
+
if (options.tenantId != null && columns.has('tenant_id')) {
|
|
380
|
+
query = query.where('b.tenant_id' as any, '=', options.tenantId)
|
|
381
|
+
}
|
|
382
|
+
if (options.organizationId != null && columns.has('organization_id')) {
|
|
383
|
+
query = query.where('b.organization_id' as any, '=', options.organizationId)
|
|
384
|
+
}
|
|
385
|
+
const rows = await query.execute() as Array<{ id: unknown }>
|
|
386
|
+
return rows.map((row) => String(row.id))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Recount the scope from the database after a reindex and rebuild whatever is still
|
|
391
|
+
* missing. Runs unconditionally so `mercato init` (and any scripted reindex) cannot finish
|
|
392
|
+
* leaving a gap that a human has to notice and repair by hand from the Query Indexes
|
|
393
|
+
* screen. When counts already agree this is two COUNT queries; when they do not, the
|
|
394
|
+
* repair is scoped to the records the anti-join says are actually missing.
|
|
395
|
+
*/
|
|
396
|
+
async function verifyAndRepairIndexCoverage(
|
|
397
|
+
em: EntityManager,
|
|
398
|
+
options: {
|
|
399
|
+
entityType: string
|
|
400
|
+
tenantId?: string | null
|
|
401
|
+
organizationId?: string | null
|
|
402
|
+
batchSize?: number
|
|
403
|
+
},
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const scope = {
|
|
406
|
+
entityType: options.entityType,
|
|
407
|
+
tenantId: options.tenantId ?? null,
|
|
408
|
+
organizationId: options.organizationId ?? null,
|
|
409
|
+
withDeleted: false,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let counts: { baseCount: number; indexedCount: number } | null = null
|
|
413
|
+
try {
|
|
414
|
+
counts = await refreshCoverageSnapshot(em, scope)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.warn(
|
|
417
|
+
` -> ${options.entityType}: coverage verification skipped (${error instanceof Error ? error.message : String(error)})`,
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (!counts) return
|
|
422
|
+
if (counts.indexedCount > counts.baseCount) {
|
|
423
|
+
// Legitimate for entities whose base table cannot express the scope, since a per-tenant
|
|
424
|
+
// reindex stamps its own scope onto every row. Surfaced rather than repaired: a rebuild
|
|
425
|
+
// cannot remove the surplus, and the over-count would otherwise mask a real shortfall.
|
|
426
|
+
console.warn(
|
|
427
|
+
` -> ${options.entityType}: ${counts.indexedCount} index rows for ${counts.baseCount} records — not repairable by reindexing`,
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
if (counts.indexedCount === counts.baseCount) return
|
|
432
|
+
|
|
433
|
+
const db = (em as any).getKysely() as Kysely<any>
|
|
434
|
+
let tableName: string | null = null
|
|
435
|
+
try {
|
|
436
|
+
tableName = resolveRegisteredEntityTableName(em as any, options.entityType)
|
|
437
|
+
} catch {
|
|
438
|
+
tableName = null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const missingIds = tableName
|
|
442
|
+
? await findUnindexedRecordIds(db, {
|
|
443
|
+
entityType: options.entityType,
|
|
444
|
+
tableName,
|
|
445
|
+
tenantId: options.tenantId ?? null,
|
|
446
|
+
organizationId: options.organizationId ?? null,
|
|
447
|
+
limit: MAX_TARGETED_REPAIR_RECORDS + 1,
|
|
448
|
+
}).catch(() => null)
|
|
449
|
+
: null
|
|
450
|
+
|
|
451
|
+
const targeted = missingIds && missingIds.length > 0 && missingIds.length <= MAX_TARGETED_REPAIR_RECORDS
|
|
452
|
+
console.log(
|
|
453
|
+
` -> ${options.entityType}: ${counts.indexedCount}/${counts.baseCount} indexed after reindex — repairing`
|
|
454
|
+
+ `${targeted ? ` ${missingIds!.length} missing record(s)` : ' the whole entity'}...`,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
if (targeted) {
|
|
459
|
+
const columns = await getColumnSet(db, tableName!)
|
|
460
|
+
for (const recordId of missingIds!) {
|
|
461
|
+
await rebuildEntityIndexes({
|
|
462
|
+
em,
|
|
463
|
+
db,
|
|
464
|
+
entityType: options.entityType,
|
|
465
|
+
tableName: tableName!,
|
|
466
|
+
orgOverride: options.organizationId ?? undefined,
|
|
467
|
+
tenantOverride: options.tenantId ?? undefined,
|
|
468
|
+
global: false,
|
|
469
|
+
includeDeleted: false,
|
|
470
|
+
offset: 0,
|
|
471
|
+
recordId,
|
|
472
|
+
batchSize: 1,
|
|
473
|
+
supportsOrgFilter: columns.has('organization_id'),
|
|
474
|
+
supportsTenantFilter: columns.has('tenant_id'),
|
|
475
|
+
supportsDeletedFilter: columns.has('deleted_at'),
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
await reindexEntity(em, {
|
|
480
|
+
entityType: options.entityType,
|
|
481
|
+
tenantId: options.tenantId,
|
|
482
|
+
organizationId: options.organizationId,
|
|
483
|
+
force: false,
|
|
484
|
+
batchSize: options.batchSize,
|
|
485
|
+
emitVectorizeEvents: false,
|
|
486
|
+
resetCoverage: false,
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.warn(
|
|
491
|
+
` -> ${options.entityType}: repair pass failed (${error instanceof Error ? error.message : String(error)})`,
|
|
492
|
+
)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const after = await refreshCoverageSnapshot(em, scope).catch(() => null)
|
|
497
|
+
if (after && after.indexedCount < after.baseCount) {
|
|
498
|
+
console.warn(
|
|
499
|
+
` -> ${options.entityType}: still ${after.indexedCount}/${after.baseCount} indexed after repair`,
|
|
500
|
+
)
|
|
501
|
+
} else {
|
|
502
|
+
console.log(` -> ${options.entityType}: coverage repaired`)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
343
506
|
const rebuild: ModuleCli = {
|
|
344
507
|
command: 'rebuild',
|
|
345
508
|
async run(rest) {
|
|
@@ -710,6 +873,14 @@ const reindex: ModuleCli = {
|
|
|
710
873
|
groupedProgress?.complete()
|
|
711
874
|
const totalProcessed = stats.reduce((acc, value) => acc + value, 0)
|
|
712
875
|
console.log(`Finished ${entity}: processed ${totalProcessed} row(s) across ${partitionTargets.length} partition(s)`)
|
|
876
|
+
if (partitionIndexOption === undefined) {
|
|
877
|
+
await verifyAndRepairIndexCoverage(baseEm, {
|
|
878
|
+
entityType: entity,
|
|
879
|
+
tenantId,
|
|
880
|
+
organizationId: orgId,
|
|
881
|
+
batchSize,
|
|
882
|
+
})
|
|
883
|
+
}
|
|
713
884
|
await recordIndexerLog(
|
|
714
885
|
{ em: baseEm },
|
|
715
886
|
{
|
|
@@ -848,6 +1019,14 @@ const reindex: ModuleCli = {
|
|
|
848
1019
|
groupedProgress?.complete()
|
|
849
1020
|
const totalProcessed = partitionResults.reduce((acc, value) => acc + value, 0)
|
|
850
1021
|
console.log(` -> ${id} complete: processed ${totalProcessed} row(s) across ${partitionTargets.length} partition(s)`)
|
|
1022
|
+
if (partitionIndexOption === undefined) {
|
|
1023
|
+
await verifyAndRepairIndexCoverage(baseEm, {
|
|
1024
|
+
entityType: id,
|
|
1025
|
+
tenantId,
|
|
1026
|
+
organizationId: orgId,
|
|
1027
|
+
batchSize,
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
851
1030
|
await recordIndexerLog(
|
|
852
1031
|
{ em: baseEm },
|
|
853
1032
|
{
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"query_index.banner.dismiss": "닫기",
|
|
3
|
+
"query_index.banner.manage_indexes": "인덱스 관리",
|
|
4
|
+
"query_index.banner.partial_counts": "{{total}}개 레코드 중 {{indexed}}개가 인덱싱되었습니다.",
|
|
5
|
+
"query_index.banner.partial_counts_excess": "현재 인덱스에 {{total}}개 레코드에 대해 {{indexed}}개 항목이 있습니다. 오래된 행을 정리하려면 재인덱싱하세요.",
|
|
6
|
+
"query_index.banner.partial_description": "쿼리 인덱스에서 부분적인 커버리지가 감지되었습니다. 모든 레코드를 새로 고치려면 재인덱싱하세요.",
|
|
7
|
+
"query_index.banner.partial_entity": "엔터티: {{entity}}",
|
|
8
|
+
"query_index.banner.partial_global_note": "다른 조직의 일부 레코드가 아직 인덱스에서 누락되어 있습니다.",
|
|
9
|
+
"query_index.banner.partial_title": "결과가 불완전할 수 있습니다",
|
|
10
|
+
"query_index.nav.queryIndexes": "쿼리 인덱스",
|
|
11
|
+
"query_index.table.actions.fulltextPurge": "전체 텍스트 검색 삭제",
|
|
12
|
+
"query_index.table.actions.fulltextReindex": "전체 텍스트 검색 재인덱싱",
|
|
13
|
+
"query_index.table.actions.purge": "삭제",
|
|
14
|
+
"query_index.table.actions.reindex": "재인덱싱",
|
|
15
|
+
"query_index.table.actions.reindexForce": "전체 재인덱싱 강제 실행",
|
|
16
|
+
"query_index.table.actions.vectorPurge": "벡터 검색 삭제",
|
|
17
|
+
"query_index.table.actions.vectorReindex": "벡터 검색 재인덱싱",
|
|
18
|
+
"query_index.table.columns.entity": "엔터티",
|
|
19
|
+
"query_index.table.columns.fulltext": "전체 텍스트",
|
|
20
|
+
"query_index.table.columns.indexed": "인덱싱됨",
|
|
21
|
+
"query_index.table.columns.records": "레코드",
|
|
22
|
+
"query_index.table.columns.status": "상태",
|
|
23
|
+
"query_index.table.columns.vector": "벡터",
|
|
24
|
+
"query_index.table.confirm.fulltextPurge": "이 엔터티의 전체 텍스트 인덱스를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
|
25
|
+
"query_index.table.confirm.vectorPurge": "이 엔터티의 벡터 임베딩을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
|
26
|
+
"query_index.table.errors.actionFailed": "{{action}} 실행에 실패했습니다.",
|
|
27
|
+
"query_index.table.errors.columns.details": "상세 정보",
|
|
28
|
+
"query_index.table.errors.columns.source": "소스",
|
|
29
|
+
"query_index.table.errors.columns.timestamp": "타임스탬프",
|
|
30
|
+
"query_index.table.errors.empty": "최근 오류가 기록되지 않았습니다.",
|
|
31
|
+
"query_index.table.errors.loadFailed": "쿼리 인덱스 상태를 불러오지 못했습니다.",
|
|
32
|
+
"query_index.table.errors.scope.organization": "조직 {{organizationId}}",
|
|
33
|
+
"query_index.table.errors.scope.tenant": "테넌트 {{tenantId}}",
|
|
34
|
+
"query_index.table.errors.subtitle": "이 범위의 쿼리 인덱스 및 벡터 인덱스 작업에 대해 기록된 최근 100개 오류입니다.",
|
|
35
|
+
"query_index.table.errors.title": "최근 인덱서 오류",
|
|
36
|
+
"query_index.table.errors.viewDetails": "기술 상세 정보 보기",
|
|
37
|
+
"query_index.table.filters.backend": "백엔드",
|
|
38
|
+
"query_index.table.filters.backendOptions.customFields": "사용자 정의 필드",
|
|
39
|
+
"query_index.table.filters.backendOptions.fulltext": "전체 텍스트 검색",
|
|
40
|
+
"query_index.table.filters.backendOptions.vector": "벡터 검색",
|
|
41
|
+
"query_index.table.logs.columns.message": "메시지",
|
|
42
|
+
"query_index.table.logs.columns.source": "소스",
|
|
43
|
+
"query_index.table.logs.columns.timestamp": "타임스탬프",
|
|
44
|
+
"query_index.table.logs.empty": "최근 활동이 없습니다.",
|
|
45
|
+
"query_index.table.logs.level.info": "정보",
|
|
46
|
+
"query_index.table.logs.level.warn": "경고",
|
|
47
|
+
"query_index.table.logs.subtitle": "이 범위의 쿼리 인덱스 및 벡터 인덱스 작업에 대해 기록된 최근 100개 상태 로그입니다.",
|
|
48
|
+
"query_index.table.logs.title": "최근 인덱서 활동",
|
|
49
|
+
"query_index.table.logs.viewDetails": "상세 정보 보기",
|
|
50
|
+
"query_index.table.refresh": "새로고침",
|
|
51
|
+
"query_index.table.searchPlaceholder": "엔터티로 필터링",
|
|
52
|
+
"query_index.table.status.failed": "실패한",
|
|
53
|
+
"query_index.table.status.in_sync": "동기화됨",
|
|
54
|
+
"query_index.table.status.not_measured": "아직 측정되지 않음",
|
|
55
|
+
"query_index.table.status.out_of_sync": "동기화 안 됨",
|
|
56
|
+
"query_index.table.status.partitionLabel": "파티션 {{index}}",
|
|
57
|
+
"query_index.table.status.progress": "{{processed}}/{{total}}",
|
|
58
|
+
"query_index.table.status.progressSingle": "{{processed}}",
|
|
59
|
+
"query_index.table.status.purging": "삭제 중",
|
|
60
|
+
"query_index.table.status.reindexing": "재인덱싱 중",
|
|
61
|
+
"query_index.table.status.scope.completed": "완료",
|
|
62
|
+
"query_index.table.status.scope.failed": "실패한",
|
|
63
|
+
"query_index.table.status.scope.purging": "삭제 중",
|
|
64
|
+
"query_index.table.status.scope.reindexing": "실행 중",
|
|
65
|
+
"query_index.table.status.scope.stalled": "정체됨",
|
|
66
|
+
"query_index.table.status.scopeLabel": "범위",
|
|
67
|
+
"query_index.table.status.stalled": "정체됨",
|
|
68
|
+
"query_index.table.status.withProgress": "{{status}} ({{progress}})"
|
|
69
|
+
}
|
|
@@ -294,6 +294,19 @@ export async function deleteCoverageForEntity(db: Kysely<any>, entityType: strin
|
|
|
294
294
|
.execute()
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
async function deleteCoverageScope(db: Kysely<any>, scope: CoverageScope): Promise<void> {
|
|
298
|
+
const { entityType, tenantId, organizationId, withDeleted } = scope
|
|
299
|
+
if (!entityType) return
|
|
300
|
+
let query = db
|
|
301
|
+
.deleteFrom('entity_index_coverage' as any)
|
|
302
|
+
.where('entity_type' as any, '=', entityType)
|
|
303
|
+
.where('with_deleted' as any, '=', withDeleted === true)
|
|
304
|
+
query = tenantId == null
|
|
305
|
+
? query.where('tenant_id' as any, 'is', null as any)
|
|
306
|
+
: query.where('tenant_id' as any, '=', tenantId)
|
|
307
|
+
await applyOrganizationCondition(query as any, 'organization_id', organizationId ?? null).execute()
|
|
308
|
+
}
|
|
309
|
+
|
|
297
310
|
async function tableHasColumn(db: Kysely<any>, table: string, column: string): Promise<boolean> {
|
|
298
311
|
const key = `${table}.${column}`
|
|
299
312
|
if (COLUMN_CACHE.has(key)) return COLUMN_CACHE.get(key)!
|
|
@@ -381,9 +394,9 @@ export async function primeColumnCache(db: Kysely<any>, checks: ColumnCheck[]):
|
|
|
381
394
|
export async function refreshCoverageSnapshot(
|
|
382
395
|
em: EntityManager,
|
|
383
396
|
scope: CoverageScope,
|
|
384
|
-
): Promise<
|
|
397
|
+
): Promise<{ baseCount: number; indexedCount: number } | null> {
|
|
385
398
|
const entityType = String(scope.entityType || '')
|
|
386
|
-
if (!entityType) return
|
|
399
|
+
if (!entityType) return null
|
|
387
400
|
const tenantId = scope.tenantId ?? null
|
|
388
401
|
const organizationId = scope.organizationId ?? null
|
|
389
402
|
const withDeleted = scope.withDeleted === true
|
|
@@ -395,22 +408,37 @@ export async function refreshCoverageSnapshot(
|
|
|
395
408
|
const hasTenant = await tableHasColumn(db, baseTable, 'tenant_id')
|
|
396
409
|
const hasDeleted = await tableHasColumn(db, baseTable, 'deleted_at')
|
|
397
410
|
|
|
398
|
-
|
|
399
|
-
|
|
411
|
+
// A scope the base table cannot express must not narrow the index side either. Index rows
|
|
412
|
+
// can carry an organization the base table has no column for — `organizations` has no
|
|
413
|
+
// `organization_id` yet its index rows derive one from the record id. Filtering only the
|
|
414
|
+
// index side compares two different populations and reports a gap no reindex can close;
|
|
415
|
+
// returning early instead of recounting left the previous snapshot frozen, which is what
|
|
416
|
+
// surfaced as a permanent "out of sync" row for `directory:organization`.
|
|
417
|
+
//
|
|
418
|
+
// Tenant is different: a base table without `tenant_id` (`user_roles`) cannot be counted
|
|
419
|
+
// per tenant at all, and writing the cross-tenant total into one tenant's row would leak
|
|
420
|
+
// another tenant's volume into it. Drop the unusable scoped row instead, leaving only the
|
|
421
|
+
// global row — which is the one that can be true — rather than freezing a stale count.
|
|
422
|
+
const scopeOrg = organizationId !== null && hasOrg
|
|
423
|
+
if (tenantId !== null && !hasTenant) {
|
|
424
|
+
await deleteCoverageScope(db, { entityType, tenantId, organizationId, withDeleted })
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
const scopeTenant = tenantId !== null && hasTenant
|
|
400
428
|
|
|
401
429
|
let baseQuery = db
|
|
402
430
|
.selectFrom(`${baseTable} as b` as any)
|
|
403
431
|
.select(sql`count(*)`.as('count'))
|
|
404
|
-
if (
|
|
405
|
-
if (
|
|
432
|
+
if (scopeOrg) baseQuery = baseQuery.where('b.organization_id' as any, '=', organizationId)
|
|
433
|
+
if (scopeTenant) baseQuery = baseQuery.where('b.tenant_id' as any, '=', tenantId)
|
|
406
434
|
if (!withDeleted && hasDeleted) baseQuery = baseQuery.where('b.deleted_at' as any, 'is', null as any)
|
|
407
435
|
|
|
408
436
|
let indexQuery = db
|
|
409
437
|
.selectFrom('entity_indexes as ei' as any)
|
|
410
438
|
.select(sql`count(*)`.as('count'))
|
|
411
439
|
.where('ei.entity_type' as any, '=', entityType)
|
|
412
|
-
if (
|
|
413
|
-
if (
|
|
440
|
+
if (scopeOrg) indexQuery = indexQuery.where('ei.organization_id' as any, '=', organizationId)
|
|
441
|
+
if (scopeTenant) indexQuery = indexQuery.where('ei.tenant_id' as any, '=', tenantId)
|
|
414
442
|
if (!withDeleted) indexQuery = indexQuery.where('ei.deleted_at' as any, 'is', null as any)
|
|
415
443
|
|
|
416
444
|
const vectorCountPromise = (async (): Promise<number | undefined> => {
|
|
@@ -453,6 +481,8 @@ export async function refreshCoverageSnapshot(
|
|
|
453
481
|
indexedCount: indexCount,
|
|
454
482
|
vectorCount,
|
|
455
483
|
})
|
|
484
|
+
|
|
485
|
+
return { baseCount, indexedCount: indexCount }
|
|
456
486
|
}
|
|
457
487
|
|
|
458
488
|
export async function writeCoverageCounts(
|