順番待ちSaaSをFirebaseだけで作る(採番の競合をルールで防ぐ・列挙禁止・リアルタイム)

自作の順番待ち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(権限を確認するために権限が要る)を避けている。店舗一覧の listallow 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
// numbering-test.mjs
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 });

// 採番: lastIssued を +1 する楽観的トランザクション
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 }); // ルールが +1 以外を拒否
return next;
});
}

// 10人が同時に採番
const nums = await Promise.all(Array.from({ length: 10 }, () => issue()));
console.log("issued:", nums.sort((a, b) => a - b));
1
node numbering-test.mjs

期待される出力:

  • issued: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] のように、重複も飛びもなく連番が配られる。
  • ルールを外して +1 以外の値を書こうとすると PERMISSION_DENIED になる(ルールが採番の一貫性を守っている証拠)。

実際の受付・スタッフ画面は ナラビ で確認できる。

まとめ

  • 採番はカウンタ +1 をセキュリティルールで強制し、トランザクションの再試行で競合を吸収する。
  • list 禁止・ownerUid 起点・差分フィールド限定で、サーバーレスでも権限を厳密に絞る。
  • リアルタイムは onSnapshot に任せ、ポーリングもWebSocketも書かない。

順番待ちSaaSをFirebaseだけで作る(採番の競合をルールで防ぐ・列挙禁止・リアルタイム)
https://blog.hashito.biz/2026/07/12/narabi-queue-firebase-only-numbering-transaction/
著者
hashito
作成日
2026年7月12日
著作権