From 7aa8240629b49ea383638501db26e07c55b72432 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 17 Aug 2026 09:49:52 +0800 Subject: [PATCH] fix: preserve record query preview contract --- ...1006-aitable-pagination-minutes-unshare.md | 3 +- internal/shortcut/aitable/record_batch.go | 5 +-- internal/shortcut/aitable/record_query_all.go | 37 +++++++++++++++++-- .../aitable/record_upsert_by_key_e2e_test.go | 27 +++++++++++--- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/.changes/1006-aitable-pagination-minutes-unshare.md b/.changes/1006-aitable-pagination-minutes-unshare.md index 8ca97c30..9607d987 100644 --- a/.changes/1006-aitable-pagination-minutes-unshare.md +++ b/.changes/1006-aitable-pagination-minutes-unshare.md @@ -4,6 +4,7 @@ category: Fixed - **Aitable pagination and Minutes unshare verification** (#1006) — keeps record queries on the service's 20-record page boundary so multi-page reads - and mutation readbacks no longer report false retryable failures, and rejects + and mutation readbacks no longer report false retryable failures, preserves + `totalCount` when supplied, keeps `--dry-run` transport-free, and rejects Minutes unshare success until the listening note exists and the service acknowledges the exact task and member targets. diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 05294ad3..f779b32f 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -334,9 +334,8 @@ func queryDeletedRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID strin } return nil, fmt.Errorf("query_records deletion read-back chunk %d is missing records", offset/recordQueryServicePageSize+1) } - if responseHasMore(data) { - return nil, fmt.Errorf("query_records deletion read-back chunk %d is incomplete", offset/recordQueryServicePageSize+1) - } + // Exact-ID absence is complete evidence for deletion even when the + // service retains an unrelated continuation marker. remaining = append(remaining, records...) } return remaining, nil diff --git a/internal/shortcut/aitable/record_query_all.go b/internal/shortcut/aitable/record_query_all.go index 3e079384..eadec9f6 100644 --- a/internal/shortcut/aitable/record_query_all.go +++ b/internal/shortcut/aitable/record_query_all.go @@ -21,10 +21,12 @@ const ( ) type recordQueryWindow struct { - Records []map[string]any - HasMore bool - NextCursor string - Pages int + Records []map[string]any + HasMore bool + NextCursor string + Pages int + TotalCount any + HasTotalCount bool } func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit int) (recordQueryWindow, error) { @@ -53,6 +55,9 @@ func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit if err != nil { return recordQueryWindow{}, err } + if !window.HasTotalCount { + window.TotalCount, window.HasTotalCount = responseTotalCount(data) + } records, found := findRecords(data) if !found { if explicitEmptyRecordQuery(data) { @@ -119,6 +124,14 @@ func explicitEmptyRecordQuery(data map[string]any) bool { } func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) error { + if rt.DryRun() { + return rt.Output(map[string]any{ + "dry_run": true, + "executed": false, + "tool": "query_records", + "arguments": params, + }) + } limit := 100 if rt.Changed("limit") { limit = rt.Int("limit") @@ -168,6 +181,9 @@ func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) erro if window.NextCursor != "" { data["nextCursor"] = window.NextCursor } + if window.HasTotalCount { + data["totalCount"] = window.TotalCount + } return rt.Output(map[string]any{ "success": true, "status": "success", @@ -175,6 +191,19 @@ func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) erro }) } +func responseTotalCount(data map[string]any) (any, bool) { + for { + if totalCount, exists := data["totalCount"]; exists { + return totalCount, true + } + nested, ok := data["data"].(map[string]any) + if !ok { + return nil, false + } + data = nested + } +} + func recordQueryRequestedIDs(params map[string]any) ([]string, bool, error) { raw, exists := params["recordIds"] if !exists { diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 3a3800d2..6af79e58 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -280,7 +280,7 @@ func pagedRecordQueryResponse(t *testing.T, records []map[string]any, args map[s } end := minInt(offset+limit, len(records)) pageRecords := records[offset:end] - data := map[string]any{"records": pageRecords, "hasMore": end < len(records)} + data := map[string]any{"records": pageRecords, "hasMore": end < len(records), "totalCount": len(records)} if end < len(records) { data["nextCursor"] = fmt.Sprintf("offset-%d", end) } @@ -337,7 +337,7 @@ func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) t.Fatalf("record query size %d: %v", size, err) } data, ok := payload["data"].(map[string]any) - if !ok || payload["success"] != true || data["hasMore"] != false || len(data["records"].([]any)) != size { + if !ok || payload["success"] != true || data["hasMore"] != false || data["totalCount"] != float64(size) || len(data["records"].([]any)) != size { t.Fatalf("record query size %d payload = %#v", size, payload) } wantCalls := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize @@ -348,6 +348,22 @@ func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) } } +func TestCrossPlatformCoverageRecordQueryDryRunStopsBeforeTransportE2E(t *testing.T) { + caller := &upsertByKeyCaller{} + payload, err := runRecordQueryShortcutCLI(t, caller, 5, "--query", "needle", "--dry-run") + if err != nil { + t.Fatalf("record query dry-run error = %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("record query dry-run crossed transport: %#v", caller.calls) + } + arguments, _ := payload["arguments"].(map[string]any) + if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "query_records" || + arguments["baseId"] != "base" || arguments["tableId"] != "table" || arguments["keyword"] != "needle" || arguments["limit"] != float64(5) { + t.Fatalf("record query dry-run payload = %#v", payload) + } +} + func TestCrossPlatformCoverageRecordQueryExactIDsIgnoreResidualContinuationE2E(t *testing.T) { caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) { if tool != "query_records" { @@ -538,15 +554,16 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi } }) - t.Run("delete readback rejects continuation", func(t *testing.T) { + t.Run("delete readback ignores residual continuation", func(t *testing.T) { caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) { if tool == "query_records" { return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"unexpected"}}`, nil } return `{"deletedCount":1}`, nil }} - if _, err := runRecordDeleteCLI(t, caller, []string{"r1"}); err == nil || len(caller.calls) != 2 || caller.calls[1].tool != "query_records" { - t.Fatalf("delete continuation error = %v calls=%#v", err, caller.calls) + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err != nil || out == "" || len(caller.calls) != 2 || caller.calls[1].tool != "query_records" { + t.Fatalf("delete residual continuation output=%q error=%v calls=%#v", out, err, caller.calls) } }) }