()
| 64 | const replicated = (table: string) => `${table}_replicated`; |
| 65 | |
| 66 | export async function up() { |
| 67 | const isClustered = getIsCluster(); |
| 68 | const sqls: string[] = []; |
| 69 | |
| 70 | // 1. New profiles table. ReplacingMergeTree's version is now `last_seen_at`, |
| 71 | // so the latest activity timestamp wins on dedup — and `created_at` is |
| 72 | // preserved naturally (no longer the version, no special merge handling). |
| 73 | const profileTables = createTable({ |
| 74 | name: NEW_TABLE, |
| 75 | columns: [ |
| 76 | '`id` String CODEC(ZSTD(3))', |
| 77 | '`is_external` Bool', |
| 78 | '`first_name` String CODEC(ZSTD(3))', |
| 79 | '`last_name` String CODEC(ZSTD(3))', |
| 80 | '`email` String CODEC(ZSTD(3))', |
| 81 | '`avatar` String CODEC(ZSTD(3))', |
| 82 | '`properties` Map(String, String) CODEC(ZSTD(3))', |
| 83 | '`project_id` String CODEC(ZSTD(3))', |
| 84 | '`groups` Array(String) DEFAULT [] CODEC(ZSTD(3))', |
| 85 | '`created_at` DateTime64(3) CODEC(Delta(4), LZ4)', |
| 86 | '`last_seen_at` DateTime64(3) CODEC(Delta(4), LZ4)', |
| 87 | ], |
| 88 | indices: [ |
| 89 | 'INDEX idx_first_name first_name TYPE bloom_filter GRANULARITY 1', |
| 90 | 'INDEX idx_last_name last_name TYPE bloom_filter GRANULARITY 1', |
| 91 | 'INDEX idx_email email TYPE bloom_filter GRANULARITY 1', |
| 92 | ], |
| 93 | engine: 'ReplacingMergeTree(last_seen_at)', |
| 94 | orderBy: ['project_id', 'id'], |
| 95 | partitionBy: 'toYYYYMM(created_at)', |
| 96 | settings: { index_granularity: 8192 }, |
| 97 | distributionHash: 'cityHash64(project_id)', |
| 98 | replicatedVersion: '2', |
| 99 | isClustered, |
| 100 | }); |
| 101 | sqls.push(...profileTables); |
| 102 | |
| 103 | // 2. Find the oldest profile so we know how far back to batch. |
| 104 | const firstProfileResp = await chMigrationClient.query({ |
| 105 | query: 'SELECT min(created_at) AS created_at FROM profiles', |
| 106 | format: 'JSONEachRow', |
| 107 | }); |
| 108 | const firstProfileJson = await firstProfileResp.json<{ |
| 109 | created_at: string; |
| 110 | }>(); |
| 111 | const firstDate = firstProfileJson[0]?.created_at; |
| 112 | |
| 113 | if (firstDate && !firstDate.startsWith('1970')) { |
| 114 | const startDate = new Date(firstDate); |
| 115 | const endDate = new Date(); |
| 116 | endDate.setMonth(endDate.getMonth() + 1); |
| 117 | endDate.setDate(1); |
| 118 | |
| 119 | const monthBoundary = (d: Date) => |
| 120 | `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; |
| 121 | |
| 122 | let cursor = new Date(endDate); |
| 123 | while (true) { |
nothing calls this directly
no test coverage detected