自作の順番待ちSaaS「ナラビ」(narabi.autoarticles.net )は、バックエンドのサーバーを持たず、Firebase(Firestore + Auth)だけで動く。飲食店の受付でQRから番号を取り、スタッフ画面がリアルタイムで進む。この構成で一番むずかしいのが採番 だ。複数人が同時に「次の番号」を取ろうとしたとき、同じ番号を配ってはいけない。サーバーが無い前提で、これをどう安全にするかを説明する。
採番の競合を「ルール」で防ぐ 肝は、番号の発行を店舗ドキュメントの lastIssued カウンタで管理し、その +1 以外の更新を Firestore セキュリティルールで拒否する ことだ。実際のルール(抜粋)はこうなっている。
1 2 3 4 5 6 7 8 match /stores/{storeId} { allow update : if isStoreStaff(storeId) || ( signedIn() && request.resource .data .diff (resource.data ).affectedKeys ().hasOnly (['lastIssued']) && request.resource .data .lastIssued == resource.data .lastIssued + 1 ); }
匿名の顧客が触れるのは lastIssued フィールドだけ(hasOnly)で、しかも「現在値ちょうど+1」にしか書けない。二人が同時に同じ現在値から+1しようとすると、片方のトランザクションだけが成功し、もう片方は失敗して再試行する。番号の飛びも重複も起きない。サーバーコードを一切書かずに、採番の一貫性をルールだけで保証 している。
列挙禁止となりすまし対策 権限の源泉は親ドキュメントの ownerUid に置き、chicken-and-egg(権限を確認するために権限が要る)を避けている。店舗一覧の list は allow list: if false で禁止し、IDを知る人だけが get できる(enumeration 対策)。チケットも、顧客本人は自分のチケットの「取消」だけ、しかも差分フィールドを hasOnly(['status','cancelledAt']) に限定して許可する。「誰が・どのフィールドを・どんな値にできるか」をルールで絞り込むのが、サーバーレスの安全設計の核心だ。
リアルタイム反映は Firestore の onSnapshot に任せる。番号が進めば全画面が自動で更新される。ポーリングも WebSocket 実装も要らない。
実際に試す Firestoreエミュレータで、lastIssued+1 制約と同時採番の競合を手元で確認する。Node 18以上・firebase-tools が必要。
まずルールを firestore.rules に保存する。
1 2 3 4 5 6 7 8 9 10 11 rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /stores/{storeId} { allow get, create : if request.auth != null; allow update : if request.auth != null && request.resource .data .diff (resource.data ).affectedKeys ().hasOnly (['lastIssued']) && request.resource .data .lastIssued == resource.data .lastIssued + 1; } } }
エミュレータを起動する。
1 firebase emulators:start --only firestore
別ターミナルで、同時に2人が採番する競合テストを走らせる(npm i firebase 済みのフォルダで)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import { initializeApp } from "firebase/app" ;import { getFirestore, connectFirestoreEmulator, doc, setDoc, runTransaction } from "firebase/firestore" ;import { getAuth, connectAuthEmulator, signInAnonymously } from "firebase/auth" ;const app = initializeApp({ projectId : "demo-narabi" });const db = getFirestore(app);const auth = getAuth(app); connectFirestoreEmulator(db, "127.0.0.1" , 8080 ); connectAuthEmulator(auth, "http://127.0.0.1:9099" );await signInAnonymously(auth);const ref = doc(db, "stores" , "s1" );await setDoc(ref, { lastIssued : 0 });async function issue ( ) { return runTransaction(db, async (tx) => { const snap = await tx.get(ref); const next = snap.data().lastIssued + 1 ; tx.update(ref, { lastIssued : next }); return next; }); }const nums = await Promise .all(Array .from({ length : 10 }, () => issue()));console .log("issued:" , nums.sort((a, b ) => a - b));
期待される出力:
issued: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] のように、重複も飛びもなく連番が配られる。
ルールを外して +1 以外の値を書こうとすると PERMISSION_DENIED になる(ルールが採番の一貫性を守っている証拠)。
実際の受付・スタッフ画面は ナラビ で確認できる。
まとめ
採番はカウンタ +1 をセキュリティルールで強制し、トランザクションの再試行で競合を吸収する。
list 禁止・ownerUid 起点・差分フィールド限定で、サーバーレスでも権限を厳密に絞る。
リアルタイムは onSnapshot に任せ、ポーリングもWebSocketも書かない。