@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.
Files changed (88) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +2 -3
  3. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
  4. package/dist/modules/dashboards/lib/aggregations.js +20 -6
  5. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  6. package/dist/modules/entities/api/definitions.js +2 -0
  7. package/dist/modules/entities/api/definitions.js.map +2 -2
  8. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  9. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  10. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  11. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  12. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  13. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  14. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  15. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  16. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  17. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  18. package/dist/modules/query_index/api/reindex.js +10 -0
  19. package/dist/modules/query_index/api/reindex.js.map +2 -2
  20. package/dist/modules/query_index/cli.js +125 -1
  21. package/dist/modules/query_index/cli.js.map +2 -2
  22. package/dist/modules/query_index/lib/coverage.js +19 -7
  23. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  24. package/dist/modules/query_index/lib/reindexer.js +43 -37
  25. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  26. package/dist/modules/sales/commands/documents.js +27 -0
  27. package/dist/modules/sales/commands/documents.js.map +2 -2
  28. package/dist/modules/sales/data/validators.js +2 -0
  29. package/dist/modules/sales/data/validators.js.map +2 -2
  30. package/package.json +8 -8
  31. package/src/modules/api_keys/i18n/ko.json +42 -0
  32. package/src/modules/attachments/i18n/ko.json +172 -0
  33. package/src/modules/audit_logs/i18n/ko.json +66 -0
  34. package/src/modules/auth/i18n/ko.json +255 -0
  35. package/src/modules/business_rules/i18n/ko.json +396 -0
  36. package/src/modules/catalog/i18n/ko.json +723 -0
  37. package/src/modules/communication_channels/i18n/ko.json +138 -0
  38. package/src/modules/configs/i18n/ko.json +197 -0
  39. package/src/modules/currencies/i18n/de.json +1 -0
  40. package/src/modules/currencies/i18n/en.json +1 -0
  41. package/src/modules/currencies/i18n/es.json +1 -0
  42. package/src/modules/currencies/i18n/ko.json +160 -0
  43. package/src/modules/currencies/i18n/pl.json +1 -0
  44. package/src/modules/customer_accounts/i18n/ko.json +354 -0
  45. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +2 -3
  46. package/src/modules/customers/i18n/de.json +10 -0
  47. package/src/modules/customers/i18n/en.json +10 -0
  48. package/src/modules/customers/i18n/es.json +10 -0
  49. package/src/modules/customers/i18n/ko.json +2855 -0
  50. package/src/modules/customers/i18n/pl.json +10 -0
  51. package/src/modules/dashboards/i18n/ko.json +118 -0
  52. package/src/modules/dashboards/lib/aggregations.ts +42 -6
  53. package/src/modules/data_sync/i18n/ko.json +125 -0
  54. package/src/modules/design_system/i18n/ko.json +58 -0
  55. package/src/modules/dictionaries/i18n/ko.json +130 -0
  56. package/src/modules/directory/i18n/ko.json +105 -0
  57. package/src/modules/entities/api/definitions.ts +4 -0
  58. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  59. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  60. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  61. package/src/modules/entities/i18n/ko.json +260 -0
  62. package/src/modules/feature_toggles/i18n/ko.json +96 -0
  63. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  64. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  65. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  66. package/src/modules/inbox_ops/i18n/ko.json +196 -0
  67. package/src/modules/integrations/i18n/ko.json +129 -0
  68. package/src/modules/messages/i18n/ko.json +263 -0
  69. package/src/modules/notifications/i18n/ko.json +55 -0
  70. package/src/modules/payment_gateways/i18n/ko.json +87 -0
  71. package/src/modules/planner/i18n/ko.json +565 -0
  72. package/src/modules/portal/i18n/ko.json +143 -0
  73. package/src/modules/progress/i18n/ko.json +20 -0
  74. package/src/modules/query_index/api/reindex.ts +15 -0
  75. package/src/modules/query_index/cli.ts +180 -1
  76. package/src/modules/query_index/i18n/ko.json +69 -0
  77. package/src/modules/query_index/lib/coverage.ts +38 -8
  78. package/src/modules/query_index/lib/reindexer.ts +62 -40
  79. package/src/modules/resources/i18n/ko.json +770 -0
  80. package/src/modules/sales/commands/documents.ts +49 -0
  81. package/src/modules/sales/data/validators.ts +2 -0
  82. package/src/modules/sales/i18n/ko.json +1442 -0
  83. package/src/modules/shipping_carriers/i18n/ko.json +85 -0
  84. package/src/modules/staff/i18n/ko.json +1162 -0
  85. package/src/modules/sync_excel/i18n/ko.json +107 -0
  86. package/src/modules/translations/i18n/ko.json +45 -0
  87. package/src/modules/wms/i18n/ko.json +1053 -0
  88. 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<void> {
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
- if (organizationId !== null && !hasOrg) return
399
- if (tenantId !== null && !hasTenant) return
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 (organizationId !== null && hasOrg) baseQuery = baseQuery.where('b.organization_id' as any, '=', organizationId)
405
- if (tenantId !== null && hasTenant) baseQuery = baseQuery.where('b.tenant_id' as any, '=', tenantId)
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 (organizationId !== null) indexQuery = indexQuery.where('ei.organization_id' as any, '=', organizationId)
413
- if (tenantId !== null) indexQuery = indexQuery.where('ei.tenant_id' as any, '=', tenantId)
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(