リストの読み取り
items.list のカーソルページネーション、スキーマを読み戻す唯一の手段としての items.info、download.start/download.get による非同期エクスポート
12 個ある slackLists.* メソッドのうち 4 つが読み取り専用で、それぞれ異なる 3 つの問いに答える。
| メソッド | 答えるもの | 必要なもの | レート階層 |
|---|---|---|---|
items.list | 「どんな行が存在するか」 | list_id | Tier 2(20+/min) |
items.info | 「スキーマはどうなっていて、上限はいくつか」 | list_id と既存行の id | 元調査に記載なし |
download.start / download.get | 「全部まとめてファイルでほしい」 | list_id | 元調査に記載なし |
5 つ目の選択肢は存在しない。slackLists.list(ワークスペースのリストを列挙する)と slackLists.info(行なしでスキーマを読む)はどちらも存在しない — slack. への実プローブが両方に unknown_method を返して確認済みで、それぞれの「あるはずの」ドキュメントページに対するソフト 404 のバイトサイズ対照でも裏付けられている。何を読み戻すにせよ、上の 3 行のいずれかを通ることになる。そして list_id は決して再発見できない — slackLists.create が返した瞬間から、それを永続化する責任は呼び出し側にある。
items.list — 全行、ただしクエリ言語なし
items.list が list_id 以外に取る引数はちょうど 3 つ、limit、cursor、archived だけ。これが表面のすべてで、フィルタも検索もソートも「X が Y と等しい行を探す」もない。ページネーションは Slack 標準のカーソル形式に従う。limit を渡し、レスポンスから response_metadata.next_cursor を読み、それが空で返ってくるまでカーソルを渡し続ける。
const MAX_PAGES = 100;
export async function listAllItems(
botToken: string,
listId: string,
archived = false,
): Promise<ListItem[]> {
const items: ListItem[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
let pageCount = 0;
do {
const res = await fetch("https://slack.com/api/slackLists.items.list", {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ list_id: listId, limit: 100, cursor, archived }),
});
const body = (await res.json()) as {
ok: boolean;
items?: unknown;
response_metadata?: { next_cursor?: string };
};
if (!body.ok) throw new Error("slackLists.items.list failed");
if (!Array.isArray(body.items)) {
throw new Error("slackLists.items.list: items is not an array");
}
items.push(...(body.items as ListItem[]));
pageCount += 1;
if (pageCount > MAX_PAGES) {
throw new Error(`slackLists.items.list exceeded ${MAX_PAGES} pages`);
}
const next = body.response_metadata?.next_cursor ?? "";
if (next === "") {
cursor = undefined; // explicit end-of-list, not merely a missing field
} else if (seenCursors.has(next)) {
throw new Error("slackLists.items.list: repeated cursor");
} else {
seenCursors.add(next);
cursor = next;
}
} while (cursor);
return items;
}Worker 内でのレスポンスサイズの制限
MAX_PAGES とページあたりの limit: 100 を組み合わせると、メモリ上に蓄積される行数は最大 10,000 行に収まる。この上限は付け足しではなく意図的なものだ — Cloudflare Worker の isolate にはリクエストごとに固定のメモリ上限があり、際限なく大きくなり続けるリスト(あるいは空のカーソルを永遠に返さないサーバー側のバグ)に対する items.push(...) の無制限ループは、きれいなエラーではなくメモリ不足による強制終了に行き着く。リストがページ上限を正当に超えうる場合は、呼び出し側で全件を 1 つの配列に蓄積するのではなく段階的に処理するか、インメモリの全件走査の代わりに(後述の)download.start / download.get によるファイルエクスポートを使うこと。
1,000 行を一巡すると limit 100 でおよそ 10 回のページネーション呼び出しになる — cron 1 ティックにつき 1 回の照合パスなら Tier 2(20+/min)に余裕で収まる。items.list は各行に updated_by も返す。これは「この行を人間が触ったか」を知る唯一のシグナルで、bot が排他的に所有しているはずのリストで発生した想定外の編集を検知するのに使える(イベントなし、冪等性なしを参照)。
items.list が返さないもの: 行数。合計もなければ、カーソル以外の has_more もなく、何もない。リストがどれだけ埋まっているかを知りたいなら、それは items.info の側から取る(次節)。
「この行は存在するか」を尋ねる呼び出しはない
items.list にフィルタ引数がない以上、特定の行が存在するかを確認する手段は、その row_id を(自分のデータベースから)すでに知っているか、リスト全体をページングして探すかのどちらかしかない。書き込み時にソース行ごとの row_id を永続化しておけば(イベントなし、冪等性なしを参照)、通常運用で後者に頼る必要はなくなる。全件ページネーションスキャンは復旧ケース専用にとっておくこと。row_id のマッピングが失われた場合、それを再構築する唯一の方法は items.list の全件スイープ 1 回で、各行のテキスト列に一緒に書き込んでおいた値(自分側の主キー)と突き合わせることになる。これより速い経路はない — スキャンを絞り込むためのフィルタも検索もクエリパラメータも存在しない。
archived フラグ
archived はフィルタ値ではなくブール値だ — 呼び出しが返す行の集合そのもの(アクティブかアーカイブ済みか)を切り替えるもので、クエリにアーカイブ条件を混ぜられるわけではない。自動アーカイブが何を取り除いたのかを見るには archived: true を明示的に渡す。なぜそのパスが重要で、どれくらいの頻度で回すべきかはアイテム上限と自動アーカイブを参照。
items.info — スキーマを読み戻す唯一の手段。ただし先に行が要る
items.info は 1 行に加えてリストのメタデータ一式(名前、スキーマ(全列とその型)、ビュー、上限)を返す。作成後にスキーマを読み戻せる場所は、slackLists.create 自身のレスポンスを除けばここだけだ — そして落とし穴が引数リストに焼き込まれている。このメソッドは list_id に加えて、既存行の id を取る。尋ねるための行をすでに持っていないかぎり、「このリストはどんな形をしているのか」とは訊けない。
つまり items.info は発見のためのツールではなく復旧のためのツールだ。想定されている流れはこうなる。
slackLists.createの時点でlist_idとすべてのcolumn_idを永続化する。 作成レスポンスは、これらが行なしで返ってくる唯一の場所だ。その永続化データが失われた場合、既知の行に対する
items.infoでスキーマを再構築できる — ただし出発点となる行 ID は 1 つ必要になる。恒久的な行を固定せずに常に 1 つ手元に持っておく方法が、次のライフサイクルだ。
センチネル行パターン
items.info にしか教えられないものが 2 つある — スキーマと list_limits(row_count、row_count_limit、archived_row_count)— そのどちらも入場料として行 ID を要求する。ミラーしているデータ行の 1 つをこの用途に充てるのは動きはするが単独では脆い(その行が自前の上限退避ロジックで削除されると、リストへのハンドルごと失われる)。ただし、このリスクは「使い捨ての行を 1 つ固定して決して削除しない」ことの根拠にはならない — むしろ、フォールスルーの連鎖でアンカー行を実行のたびに動的に解決すべきだという根拠になる。個々の行を失っても、代償は items.list 呼び出し 1 回分だけで、ハンドルそのものは失わない。
永久に削除しないセンチネルは、実際に運用者が目にするリストにとっても不適切な形だ。ずっとそこに居座る空の行は、運用者から見ればゴミにしか見えない。運用者が手で削除し、復旧ロジックが次のティックでそれを再作成し、インテグレーションはまるでゴミ行を量産しているように見える。それに加えて不要でもある — items.info が返すスキーマはリストレベル(list.list_metadata.schema)のものなので、アーカイブ済みの行を含め、どの行も等しく有効なハンドルになる。以下のライフサイクルでは、センチネルは常設の備品ではなく最後の手段として扱う。
アンカーの解決順序。
items.infoを呼ぶ対象の行を、次の順で試し、最初に解決できたところで止める:記録済みのセンチネル行 ID があればそれ、なければitems.listから得られる任意の行(アクティブな行を優先し、次にarchived: trueの行)、そのどちらも空だった場合にのみ、空の行を新規作成してその ID を新しいセンチネルとして記録する。行消失レースへのフォールスルー。
items.listの呼び出しと、それに続くitems.infoの呼び出しの間に、行が削除されることがある。items.infoはこれをスキーマ読み取りの失敗としてではなく、record_deleted、record_not_found、row_not_found、invalid_row_idのいずれかとして表面化させる。このエラー群を個別に捕捉し、アンカーの解決順序に沿って次の候補へ進む。病的なケースで無限ループにならないよう、試行回数には小さな上限(~5 回)を設ける。この 4 つのコードはlist_not_foundとは明確に異なる — こちらはフェイルクローズドのままにする。リストそのものへのアクセスが失われているということであり、別の行 ID をいくら再試行しても解決しない。退役。 リストが実際の行を 1 つでも持つようになったら、センチネルは役目を終えている:削除し、記録している ID をクリアする。順序が重要だ — 永続化を先に行う:Slack 上で行を削除する間もセンチネル ID の記録は残したままにし、その削除呼び出しが成功したあとにだけ記録済み ID をクリアする。先に ID をクリアしてから削除に失敗すると、ID が永遠にわからない空の行が取り残される。永続化を先に行っておけば、次のティックでどちらの失敗方向からも復旧できる — 削除に失敗した場合は同じ既知の ID に対して単に再試行すればよく、ID クリアに失敗した場合はすでに存在しない行を単に再削除するだけになる(成功とみなしてよい
record_deletedだ)。運用者が手で削除したものを再作成しない。 実際の行が残っているあいだに運用者がセンチネルを削除したら、削除されたままにしておく — アンカーの解決順序はすでに次の実行で実際の行にフォールスルーする。あとでリストが空になれば、必要になった次のティックで、上のステップ 1 を通じて自然にセンチネルが再作成される。空の行が存在するのは、空のリストを誰も見ていない間だけになる。
引数の名前を正しく付ける。
items.infoは行 ID をlist_idと並べてidとして受け取る —items.updateのcells[]の各エントリが同じ概念に使っているrow_idではない。この 2 つを取り違えると、型エラーにはならず一部のクライアント実装では引数が黙って落とされることがあるので、最初から正しく書くだけの価値がある。
// Stored per list. Recorded when a sentinel is created (step 1), cleared once
// the List carries a real row (step 3), and re-created on demand if the List
// empties out again (step 4) — never a fixed, permanent id.
let sentinelRowId: string | null = await loadSentinelRowId(listId);
// items.info takes the row id as `id`, not `row_id` — see step 5 above.
const res = await fetch("https://slack.com/api/slackLists.items.info", {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ list_id: listId, id: sentinelRowId }),
});Note
list_limits の正確な形(row_count / row_count_limit / archived_row_count 以外にどんなフィールドを持つか)は、実際のリストに対するライブプローブではなく Slack ドキュメントを対象とした元調査パスに由来する — このプロジェクトの検証スパイクはスキップされた(テストトークンが用意できなかった)。フィールドリストは「報告されている内容」として扱い、このページが明示していないフィールドに依存する前に、実際の items.info レスポンスで確認すること。このような未決着の問いを本番投入の前に決着させるための、可逆でリストごとのプローブパターンについては有効化ゲートのプローブを参照。
download.start / download.get — 非同期の一括エクスポート
差分読み取りではなく全件ダンプが欲しい場合、download.start が CSV または JSON の非同期エクスポートを開始し、download.get が完了後の結果を取得する。プログラムから行単位で読むのではなく、その場かぎりの全体スナップショット(手動監査、一度きりのバックフィル確認)が欲しいときは、items.list をページングするよりこちらのほうが適したツールだ — 1,000 行あたり約 10 回のページネーション呼び出しではなくエクスポートジョブ 1 件で済み、通常の items.list 照合パス用のレート予算とも競合しない。どちらのメソッドも items.list や items.info と同じ読み取りスコープ(lists:read)で動く。
関連ページ
イベントなし、冪等性なし — なぜ
items.listが唯一の受信シグナルなのか、そして全件スキャンをほとんど不要にするrow_idの永続化について。アイテム上限と自動アーカイブ —
items.listのarchivedフラグが何のためにあり、items.infoのlist_limitsが上限ポリシーにどう組み込まれるか。ワンウェイミラーパターン — センチネル行パターンと照合パスが、完全な同期設計の中でどう噛み合うか。
有効化ゲートのプローブ — このページの未検証事項を、準備済みリスト 1 つずつ経験的に決着させる。