rootr-cli 0.24.0 → 0.26.0

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.
@@ -59,6 +59,8 @@ const FILTER_SHAPE = z.array(
59
59
  .optional()
60
60
  .describe(
61
61
  'Values the op needs. is/is_not/contains → one or more (OR between them). ' +
62
+ 'Text matching ignores case, spaces and brackets/punctuation ( ) [ ] · - . , / and treats ㈜ as (주). On a RELATION column, contains matches the linked rows\' ' +
63
+ 'titles (e.g. a business name) and is/is_not take linked row ids (from rootr_pick_rows) or exact titles. ' +
62
64
  'before/after/on → one date "YYYY-MM-DD". between → two dates. eq/gt/lt → one number. ' +
63
65
  'is_empty/is_not_empty/today/this_week/this_month/past/upcoming/checked/unchecked take none.',
64
66
  ),
@@ -324,9 +326,25 @@ export function registerDatabaseTools(server) {
324
326
  'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
325
327
  'Saved on the view, so everyone who opens it sees that span.',
326
328
  ),
329
+ density: z
330
+ .enum(['normal', 'compact'])
331
+ .nullable()
332
+ .optional()
333
+ .describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
334
+ frozenColumns: z
335
+ .number()
336
+ .int()
337
+ .min(0)
338
+ .max(3)
339
+ .nullable()
340
+ .optional()
341
+ .describe(
342
+ 'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
343
+ '0/null = off. e.g. 1 keeps the title column (상호명) in place.',
344
+ ),
327
345
  },
328
346
  },
329
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale }) => {
347
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns }) => {
330
348
  try {
331
349
  const client = makeClient();
332
350
  const id = await resolveNodeId(client, databaseId);
@@ -340,6 +358,8 @@ export function registerDatabaseTools(server) {
340
358
  sort,
341
359
  colWidths,
342
360
  calendarScale,
361
+ density,
362
+ frozenColumns,
343
363
  });
344
364
  return jsonResult(view);
345
365
  } catch (err) {
@@ -382,7 +402,9 @@ export function registerDatabaseTools(server) {
382
402
  'the same table can look different per view (a "담당자" view hiding the amounts, etc). Hiding a column ' +
383
403
  'does NOT erase its values: it disappears from that view only and comes back when you unhide it. ' +
384
404
  'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
385
- 'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched.',
405
+ 'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched. ' +
406
+ 'For an at-a-glance table also pass density "compact" (lower rows, smaller text) and frozenColumns N (first N visible ' +
407
+ 'columns stay fixed while scrolling sideways).',
386
408
  annotations: { readOnlyHint: false, destructiveHint: false },
387
409
  inputSchema: {
388
410
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -436,6 +458,22 @@ export function registerDatabaseTools(server) {
436
458
  'Tint rows/cards by value: the first matching rule wins. e.g. [{propId:"진행상태",op:"is",values:["채용불가"],color:"red"}, ' +
437
459
  '{propId:"면접예정일",op:"is_not_empty",color:"yellow"}]. Colors: gray, brown, orange, yellow, green, blue, purple, pink, red. null or [] turns it off.',
438
460
  ),
461
+ density: z
462
+ .enum(['normal', 'compact'])
463
+ .nullable()
464
+ .optional()
465
+ .describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
466
+ frozenColumns: z
467
+ .number()
468
+ .int()
469
+ .min(0)
470
+ .max(3)
471
+ .nullable()
472
+ .optional()
473
+ .describe(
474
+ 'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
475
+ '0/null = off. e.g. 1 keeps the title column (상호명) in place.',
476
+ ),
439
477
  },
440
478
  },
441
479
  async ({
@@ -452,6 +490,8 @@ export function registerDatabaseTools(server) {
452
490
  calendarScale,
453
491
  cardProps,
454
492
  rowColors,
493
+ density,
494
+ frozenColumns,
455
495
  }) => {
456
496
  try {
457
497
  const client = makeClient();
@@ -468,6 +508,8 @@ export function registerDatabaseTools(server) {
468
508
  calendarScale,
469
509
  cardProps,
470
510
  rowColors,
511
+ density,
512
+ frozenColumns,
471
513
  });
472
514
  return jsonResult({ view });
473
515
  } catch (err) {
@@ -551,6 +593,40 @@ export function registerDatabaseTools(server) {
551
593
  },
552
594
  );
553
595
 
596
+ // REL-PICK(P5) — 화면의 관계 고르기와 같은 찾기. 표 전체를 읽어 이름을 손으로 견주지 않게(9천 행이면 수 MB).
597
+ server.registerTool(
598
+ 'rootr_pick_rows',
599
+ {
600
+ title: 'Find rows to link (relation picker search)',
601
+ description:
602
+ 'Search a Rootr (루터) DATABASE for rows to put into a relation column — the same search the app\'s relation picker uses. ' +
603
+ 'Searches the WHOLE table by title: exact name → names starting with q → names containing q → the identify columns ' +
604
+ '(e.g. last 4 phone digits "5678"); newest rows first within each rank. Returns `{ rows: [{ id, title, subtitle }], total, hasMore }` ' +
605
+ 'only — `subtitle` comes from the title column\'s identifyProps (rootr_update_column) such as "***-5678 · 2026-10-02", so ' +
606
+ 'people with the same name can be told apart. Pass the relation column\'s relationFilter as `filters` to see only linkable rows, ' +
607
+ 'or `ids` to label rows you already have. Matching ignores spaces/case/brackets/punctuation and treats ㈜ as (주).',
608
+ annotations: { readOnlyHint: true, destructiveHint: false },
609
+ inputSchema: {
610
+ databaseId: z.string().describe('The TARGET database (the one the relation column points at): ' + DATABASE_TARGET_DESC),
611
+ q: z.string().optional().describe('Name (or part of it, or last phone digits). Empty → newest rows.'),
612
+ filters: z
613
+ .array(z.object({ propId: z.string(), op: z.string(), values: z.array(z.string()).optional() }))
614
+ .optional()
615
+ .describe('Optional conditions on the target table, e.g. the relation column\'s relationFilter'),
616
+ ids: z.array(z.string()).optional().describe('Only these row ids (to label them); q/filters are ignored'),
617
+ limit: z.number().optional().describe('Default 30, max 100'),
618
+ },
619
+ },
620
+ async ({ databaseId, q, filters, ids, limit }) => {
621
+ try {
622
+ const client = makeClient();
623
+ return jsonResult(await client.pickRows(await resolveNodeId(client, databaseId), { q, filters, ids, limit }));
624
+ } catch (err) {
625
+ return errorResult(err);
626
+ }
627
+ },
628
+ );
629
+
554
630
  server.registerTool(
555
631
  'rootr_add_row',
556
632
  {
@@ -908,6 +984,8 @@ export function registerDatabaseTools(server) {
908
984
  'rootr_query_vectors (Rootr never embeds it for you; people see only "vector · N dims" in the grid). ' +
909
985
  'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
910
986
  'they take no value and rootr_update_row will refuse to set them. ' +
987
+ 'Display options: align (left|center|right), wrap true; date columns: highlightOverdue true (past dates red — off by default), ' +
988
+ 'allowTime false (no time), allowRange false (no end date). ' +
911
989
  'Returns the created column WITH ITS id.',
912
990
  annotations: { readOnlyHint: false, destructiveHint: false },
913
991
  inputSchema: {
@@ -1014,9 +1092,38 @@ export function registerDatabaseTools(server) {
1014
1092
  'chat, button AI-fill steps, formula/rollup columns and MCP reads still see the values; to hide values from ' +
1015
1093
  'people, use an "own rows only" table or a private node. Table managers only.',
1016
1094
  ),
1095
+ align: z
1096
+ .enum(['left', 'center', 'right'])
1097
+ .optional()
1098
+ .describe('Text alignment of this column in the table (default: numbers right, the rest left). null resets.'),
1099
+ wrap: z
1100
+ .boolean()
1101
+ .optional()
1102
+ .describe('true = long text wraps inside the cell instead of being cut off (the row grows). false/null = one line.'),
1103
+ highlightOverdue: z
1104
+ .boolean()
1105
+ .optional()
1106
+ .describe(
1107
+ 'date only: show PAST dates in red (deadline-like columns). Default OFF — application/call dates stay plain. ' +
1108
+ 'Refused (400) on a non-date column.',
1109
+ ),
1110
+ allowTime: z
1111
+ .boolean()
1112
+ .optional()
1113
+ .describe(
1114
+ 'date only: false = no 「시간」 toggle; any value written later is stored as a plain date (time dropped). ' +
1115
+ 'Existing values keep showing. true/null = allowed (default).',
1116
+ ),
1117
+ allowRange: z
1118
+ .boolean()
1119
+ .optional()
1120
+ .describe(
1121
+ 'date only: false = no 「기간」 (end date) button; values written later keep only the start date. ' +
1122
+ 'Existing ranges keep showing and can be cleared. true/null = allowed (default).',
1123
+ ),
1017
1124
  },
1018
1125
  },
1019
- async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions }) => {
1126
+ async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions, align, wrap, highlightOverdue, allowTime, allowRange }) => {
1020
1127
  try {
1021
1128
  const client = makeClient();
1022
1129
  const id = await resolveNodeId(client, databaseId);
@@ -1030,6 +1137,11 @@ export function registerDatabaseTools(server) {
1030
1137
  prefix,
1031
1138
  searchExcluded,
1032
1139
  dimensions,
1140
+ align,
1141
+ wrap,
1142
+ highlightOverdue,
1143
+ allowTime,
1144
+ allowRange,
1033
1145
  });
1034
1146
  return jsonResult({ property });
1035
1147
  } catch (err) {
@@ -1049,15 +1161,29 @@ export function registerDatabaseTools(server) {
1049
1161
  'The column TYPE and a vector column\'s dimensions cannot be changed — that would rewrite every row (or every stored vector) — ' +
1050
1162
  'so passing type or dimensions is refused; add a new column and move the values instead. ' +
1051
1163
  'Call rootr_read_database first to get the exact propertyId.\n' +
1164
+ 'RENAMES CASCADE: a new `name` also rewrites everything that refers to the column BY NAME, in the same transaction — ' +
1165
+ 'formulas ({col} / prop("col")) in this table, button/automation {col} {col+1} {relation.col} templates here and in ' +
1166
+ 'tables that link to this one, rollups in other tables (pinned to the column id), linked form questions (kept connected ' +
1167
+ 'by id; the question text stays) and page live blocks (liveTable/liveStats/liveChart). Do NOT edit the formula first ' +
1168
+ '(a formula naming a column that does not exist yet is refused). The result has `renameCascade` {formulas, buttons, ' +
1169
+ 'automations, rollups, forms, pages, warnings, summary}; `warnings` lists what could not follow (e.g. two form questions ' +
1170
+ 'now landing on one column). Pass dryRun: true with `name` to get that list without changing anything.\n' +
1052
1171
  '`reverseName` on a relation column pairs it with the linked table: that table gets a matching column with ' +
1053
1172
  'this name and filling either side fills the other. Pass an empty string to unpair (neither column is deleted). ' +
1054
1173
  'Use it to make an existing one-way link two-way rather than rebuilding the table — a one-way column leaves ' +
1055
- 'every aggregate on the other side wrong until somebody backfills it by hand.',
1174
+ 'every aggregate on the other side wrong until somebody backfills it by hand.\n' +
1175
+ 'Display options (only what you pass changes; null resets): align left|center|right, wrap true (long text wraps), ' +
1176
+ 'and for DATE columns highlightOverdue true (past dates in red — off by default), allowTime false (no time), ' +
1177
+ 'allowRange false (no end date). e.g. { "allowTime": false, "allowRange": false } makes 「지원일」 a plain single date.',
1056
1178
  annotations: { readOnlyHint: false, destructiveHint: false },
1057
1179
  inputSchema: {
1058
1180
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
1059
1181
  propertyId: z.string().describe('Column id from rootr_read_database (NOT the header text)'),
1060
- name: z.string().optional().describe('New header text'),
1182
+ name: z.string().optional().describe('New header text — formulas, button/automation templates, rollups, linked forms and page blocks follow it'),
1183
+ dryRun: z
1184
+ .boolean()
1185
+ .optional()
1186
+ .describe('With `name` only: return what the rename would also change (renameCascade) without renaming'),
1061
1187
  options: z
1062
1188
  .array(z.object({ name: z.string(), color: z.string() }))
1063
1189
  .optional()
@@ -1107,6 +1233,40 @@ export function registerDatabaseTools(server) {
1107
1233
  'shows the latest 5 linked rows (contact history) and the total. Names or ids; an unknown name is refused (400), not dropped. ' +
1108
1234
  'null/[] = automatic (status, notes, dates, rollups; relations as history).',
1109
1235
  ),
1236
+ align: z
1237
+ .enum(['left', 'center', 'right'])
1238
+ .nullable()
1239
+ .optional()
1240
+ .describe('Text alignment of this column in the table (default: numbers right, the rest left). null resets.'),
1241
+ wrap: z
1242
+ .boolean()
1243
+ .nullable()
1244
+ .optional()
1245
+ .describe('true = long text wraps inside the cell instead of being cut off (the row grows). false/null = one line.'),
1246
+ highlightOverdue: z
1247
+ .boolean()
1248
+ .nullable()
1249
+ .optional()
1250
+ .describe(
1251
+ 'date only: show PAST dates in red (deadline-like columns). Default OFF — application/call dates stay plain. ' +
1252
+ 'Refused (400) on a non-date column.',
1253
+ ),
1254
+ allowTime: z
1255
+ .boolean()
1256
+ .nullable()
1257
+ .optional()
1258
+ .describe(
1259
+ 'date only: false = no 「시간」 toggle; any value written later is stored as a plain date (time dropped). ' +
1260
+ 'Existing values keep showing. true/null = allowed (default).',
1261
+ ),
1262
+ allowRange: z
1263
+ .boolean()
1264
+ .nullable()
1265
+ .optional()
1266
+ .describe(
1267
+ 'date only: false = no 「기간」 (end date) button; values written later keep only the start date. ' +
1268
+ 'Existing ranges keep showing and can be cleared. true/null = allowed (default).',
1269
+ ),
1110
1270
  relationFilter: z
1111
1271
  .array(z.object({ propId: z.string(), op: z.enum(['is', 'is_not_empty']), values: z.array(z.string()).optional() }))
1112
1272
  .nullable()
@@ -1115,6 +1275,48 @@ export function registerDatabaseTools(server) {
1115
1275
  'relation only: which rows of the linked table may be linked (all rules must match; propId is a column of the LINKED table). ' +
1116
1276
  'e.g. [{propId:"진행상태",op:"is",values:["채용"]}]. The picker hides others and the server refuses new links that fail. null turns it off.',
1117
1277
  ),
1278
+ dedupeCarry: z
1279
+ .record(z.any())
1280
+ .nullable()
1281
+ .optional()
1282
+ .describe(
1283
+ 'With dedupe on: "carry over previous info" (the same entry came back). When a NEW row has the same value as an earlier row, copy the ' +
1284
+ 'earlier row\'s values INTO the new row (the earlier row is untouched). { auto?: true = do it on every entry path (row add, cell, ' +
1285
+ 'form, button/automation add_row, REST/MCP/bulk) without asking; false/omitted = only the duplicate dialog button / ' +
1286
+ 'rootr_carry_over. label?: name of this action for the table (≤30 chars, e.g. "재등록"; toasts/merge comments use it; blank = neutral ' +
1287
+ 'default), exclude?: [columns not copied, e.g. "접수일"], overwrite?: [columns copied even if the new row has a value; ' +
1288
+ 'default fills empty cells only], relations?: true to copy relation columns too (default false — they are two-way), ' +
1289
+ 'latestBy?: date column or "createdAt" — which earlier row wins when several match (latest), mark?: values stamped on the new ' +
1290
+ 'row: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "재등록" } } — map looks at the value the ' +
1291
+ 'NEW row came in with; else covers any value not in map; if that column is still EMPTY when carried, the mark waits and is ' +
1292
+ 'applied once to the first value written there later (e.g. a later button step); missing select options are added }. ' +
1293
+ 'Names or ids. null turns it off.',
1294
+ ),
1295
+ validate: z
1296
+ .record(z.any())
1297
+ .nullable()
1298
+ .optional()
1299
+ .describe(
1300
+ 'Value check for NEW writes to this column — it only FLAGS a wrong input: the value is saved exactly as typed and the ' +
1301
+ 'response carries warnings[] (send checkWarnings: true on row PATCH/form submit to get 422 VALUE_WARNINGS first). The user ' +
1302
+ 'writes the rule: { kind: "pattern", pattern: "<regex the WHOLE value must match>", flags?: "i"|"s"|"m", negate?: true ' +
1303
+ '(flag values that CONTAIN the pattern instead), message?: "{name}: {value} …" } — linear-time engine, no back-references/' +
1304
+ 'look-arounds, max 500 chars; starting points via rootr_test_value_rule { presets: true } (email, URL, intl/KR/US phone, ' +
1305
+ 'postal codes …); formats differ per country, so pick/adapt one rather than assuming Korean formats. Other kinds: ' +
1306
+ '"range" (number: min/max), "date" (text reads as a date), "birthdate" (text/date; min/max = age). askDeleteRow?: true — ' +
1307
+ 'the in-app dialog also offers deleting the row. Applies to cells, row panel, forms, REST/MCP/bulk, buttons/automations. ' +
1308
+ 'Existing values are not changed. null turns it off.',
1309
+ ),
1310
+ identifyProps: z
1311
+ .array(z.object({ propId: z.string(), mask: z.enum(['last4']).optional() }))
1312
+ .nullable()
1313
+ .optional()
1314
+ .describe(
1315
+ 'TITLE column only: up to 3 columns of THIS table shown next to the title wherever another table picks one of these rows ' +
1316
+ '(relation picker, relation filter, rootr_pick_rows `subtitle`) so same-name rows can be told apart. mask "last4" shows only ' +
1317
+ 'the last 4 digits (***-5678) — use it for phone numbers. Names or ids; unknown/relation/files columns are refused (400). ' +
1318
+ 'e.g. on 지원자.이름: [{propId:"연락처",mask:"last4"},{propId:"지원일"}]. null/[] turns it off.',
1319
+ ),
1118
1320
  },
1119
1321
  },
1120
1322
  async ({
@@ -1131,8 +1333,17 @@ export function registerDatabaseTools(server) {
1131
1333
  dedupe,
1132
1334
  dedupeShow,
1133
1335
  relationFilter,
1336
+ align,
1337
+ wrap,
1338
+ highlightOverdue,
1339
+ allowTime,
1340
+ allowRange,
1341
+ dedupeCarry,
1342
+ validate,
1343
+ identifyProps,
1134
1344
  type,
1135
1345
  dimensions,
1346
+ dryRun,
1136
1347
  }) => {
1137
1348
  // 조용히 무시하지 않는다 — 못 바꾸는 것을 넘기면 까닭과 할 일을 말한다(벡터 차원을 바꾸면 저장된 벡터가 전부 틀려진다).
1138
1349
  if (type !== undefined || dimensions !== undefined) {
@@ -1146,6 +1357,11 @@ export function registerDatabaseTools(server) {
1146
1357
  try {
1147
1358
  const client = makeClient();
1148
1359
  const id = await resolveNodeId(client, databaseId);
1360
+ // COL-RENAME — 미리 보기: 이름을 바꾸면 무엇이 함께 바뀌는지(쓰지 않는다).
1361
+ if (dryRun) {
1362
+ if (typeof name !== 'string' || !name.trim()) return errorResult(new Error('dryRun 은 name(바꿀 이름)과 함께 보내 주세요.'));
1363
+ return jsonResult({ dryRun: true, renameCascade: await client.columnRenameImpact(id, propertyId, name) });
1364
+ }
1149
1365
  const property = await client.updateColumn(id, propertyId, {
1150
1366
  name,
1151
1367
  options,
@@ -1158,8 +1374,18 @@ export function registerDatabaseTools(server) {
1158
1374
  dedupe,
1159
1375
  dedupeShow,
1160
1376
  relationFilter,
1377
+ align,
1378
+ wrap,
1379
+ highlightOverdue,
1380
+ allowTime,
1381
+ allowRange,
1382
+ dedupeCarry,
1383
+ validate,
1384
+ identifyProps,
1161
1385
  });
1162
- return jsonResult({ property });
1386
+ // COL-RENAME — 함께 바뀐 것을 위로 꺼내 둔다(에이전트가 경고를 놓치지 않게).
1387
+ const { renameCascade, ...column } = property ?? {};
1388
+ return jsonResult(renameCascade ? { property: column, renameCascade } : { property });
1163
1389
  } catch (err) {
1164
1390
  return errorResult(err);
1165
1391
  }
@@ -1181,7 +1407,7 @@ export function registerDatabaseTools(server) {
1181
1407
  'and spaces. Give `rowId` to check an existing row (it is excluded from the result) or `values` ({column name or id: value}) ' +
1182
1408
  'to check before adding. Each match comes with the fields the duplicate dialog shows (`preview`: status, notes, dates, rollups ' +
1183
1409
  '— or the column\'s dedupeShow list) and `history`: the latest 5 linked rows per relation column (e.g. call log) with the total. ' +
1184
- 'Only rows the caller can see are returned. Then use rootr_merge_rows or rootr_keep_duplicate.',
1410
+ 'Only rows the caller can see are returned. Then use rootr_merge_rows, rootr_keep_duplicate or rootr_carry_over (copy the earlier row into the new one).',
1185
1411
  annotations: { readOnlyHint: true, destructiveHint: false },
1186
1412
  inputSchema: {
1187
1413
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -1208,7 +1434,7 @@ export function registerDatabaseTools(server) {
1208
1434
  description:
1209
1435
  'Merge row `rowId` into row `into` of the same Rootr (루터) DATABASE — the "same person applied again" case. Empty cells of ' +
1210
1436
  '`into` are filled from `rowId` (existing values are kept), relation/multi-select/people/files are unioned (so a call log linked ' +
1211
- 'to the new row moves to the old one), a "🔁 재지원" comment listing the new values is left on `into`, and `rowId` is DELETED ' +
1437
+ 'to the new row moves to the old one), a "🔁 <dedupeCarry.label or 다시 들어옴>" comment listing the new values is left on `into`, and `rowId` is DELETED ' +
1212
1438
  '(row history records it as merged into `into`). Both rows must be editable by the caller. Not reversible except by the table history.',
1213
1439
  annotations: { readOnlyHint: false, destructiveHint: true },
1214
1440
  inputSchema: {
@@ -1253,6 +1479,88 @@ export function registerDatabaseTools(server) {
1253
1479
  },
1254
1480
  );
1255
1481
 
1482
+ server.registerTool(
1483
+ 'rootr_carry_over',
1484
+ {
1485
+ title: "Carry over a person's previous row into this row",
1486
+ description:
1487
+ 'The "이전 정보 가져오기" choice: copy the values of an EARLIER row of the same person (same dedupe value, e.g. phone) ' +
1488
+ 'INTO row `rowId`, following the dedupe column\'s dedupeCarry settings (excluded columns such as the entry date, ' +
1489
+ 'marks such as 재등록 여부=true / 구분 A→A(재등록)). The earlier row is NOT changed and nothing is linked. ' +
1490
+ 'Without `from` the most recent matching row (latestBy, else creation time) is used. Row history records it as carried from that ' +
1491
+ 'row. Tables with dedupeCarry.auto do this by themselves on every write path — use this for tables without auto or to pick a ' +
1492
+ 'specific earlier row. Only rows the caller can see are used.',
1493
+ annotations: { readOnlyHint: false, destructiveHint: false },
1494
+ inputSchema: {
1495
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1496
+ rowId: z.string().describe('The new row that receives the values'),
1497
+ from: z.string().optional().describe('The earlier row to copy from (default: latest row with the same dedupe value)'),
1498
+ },
1499
+ },
1500
+ async ({ databaseId, rowId, from }) => {
1501
+ try {
1502
+ const client = makeClient();
1503
+ const id = await resolveNodeId(client, databaseId);
1504
+ return jsonResult(await client.carryOver(id, rowId, from));
1505
+ } catch (err) {
1506
+ return errorResult(err);
1507
+ }
1508
+ },
1509
+ );
1510
+
1511
+ server.registerTool(
1512
+ 'rootr_rule_violations',
1513
+ {
1514
+ title: 'List existing values that break column validation rules',
1515
+ description:
1516
+ 'Read-only. Lists cells of a Rootr (루터) DATABASE whose CURRENT value breaks the column\'s validate rule (rootr_update_column ' +
1517
+ 'validate: pattern / range / date / birthdate). Rules only check new writes, so values entered before the rule (or imported) ' +
1518
+ 'stay as they were — use this to find and fix them. Returns { checkedColumns, total, items: [{ rowId, title, propName, value, message }] }.',
1519
+ annotations: { readOnlyHint: true, destructiveHint: false },
1520
+ inputSchema: {
1521
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1522
+ limit: z.number().int().min(1).max(1000).optional().describe('Max items (default 200)'),
1523
+ },
1524
+ },
1525
+ async ({ databaseId, limit }) => {
1526
+ try {
1527
+ const client = makeClient();
1528
+ const id = await resolveNodeId(client, databaseId);
1529
+ return jsonResult(await client.ruleViolations(id, { limit }));
1530
+ } catch (err) {
1531
+ return errorResult(err);
1532
+ }
1533
+ },
1534
+ );
1535
+
1536
+ server.registerTool(
1537
+ 'rootr_test_value_rule',
1538
+ {
1539
+ title: 'Try a column validation rule on sample values (or list example patterns)',
1540
+ description:
1541
+ 'Read-only, saves nothing. Runs a validate rule (the same shape rootr_update_column takes, e.g. { kind: "pattern", pattern: ' +
1542
+ '"\\\\+\\\\d{1,3} .+", message: "…" }) against sample values and returns { rule, results: [{ value, ok, message? }] } — use it to ' +
1543
+ 'check a pattern before putting it on a column. `presets: true` returns example patterns to start from (email, URL, ' +
1544
+ 'international / KR / US phone, postal codes, digits only, …); formats differ per country and company, so adapt them.',
1545
+ annotations: { readOnlyHint: true, destructiveHint: false },
1546
+ inputSchema: {
1547
+ rule: z.record(z.any()).optional().describe('The validate rule to try (omit with presets: true)'),
1548
+ values: z.array(z.union([z.string(), z.number()])).max(20).optional().describe('Up to 20 sample values'),
1549
+ type: z.string().optional().describe('Column type the rule is for (default "text"; "number" for range)'),
1550
+ presets: z.boolean().optional().describe('true = list example patterns instead'),
1551
+ },
1552
+ },
1553
+ async ({ rule, values, type, presets }) => {
1554
+ try {
1555
+ const client = makeClient();
1556
+ if (presets || !rule) return jsonResult(await client.valueRulePresets());
1557
+ return jsonResult(await client.testValueRule(rule, values ?? [], type));
1558
+ } catch (err) {
1559
+ return errorResult(err);
1560
+ }
1561
+ },
1562
+ );
1563
+
1256
1564
  // ── ROW-HISTORY / RESTORE(F6·F7·G3) — 행·표 이력과 되돌리기 ─────────────────
1257
1565
 
1258
1566
  server.registerTool(