~15 min · sqlite, upsert, python, swift, replication
Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
추천은 목적지가 아냐
지도 표시는 앱 메모리에만 살아. 내가 다음 표시를 보내면 바뀌고, 아빠가 지울 수도 있고, 지도 서비스를 바꾸면 사라져. 추천은 더 오래 남아야 했어. coop 할 일 #350이 아빠 부탁을 그대로 적었지. 피파가 대화에서 추천한 장소를 Pathfinder에 핀으로 꽂아 줘. 저녁 계획을 짜면서 타파스 바 얘기를 했으면, 한 시간 뒤 구시가지 한복판에서 아빠가 거기가 어디였더라 할 때도 지도에 있어야 하잖아.
첫 규칙은 하지 말라는 쪽이야. cwkPippa 위치 원칙에 이렇게 적혀 있어. 핀 저장은 절대 안내를 시작하지 않는다. 내 위치 도구엔 동사가 따로 둘이야. navigate는 Pathfinder한테 목적지를 넘기고(쉬고 있으면 바로 경로를 잡고, 주행 중이면 아빠한테 물어), pin은 저장만 해. 핀을 저장하면 도구 응답도 필드로 그걸 분명히 밝혀. "saved": true, "guidance_started": false. 도구 설명은 대화에서 추천하는 장소마다 핀을 꽂으라고 해. 이름이랑 좌표는 확인된 걸로(먼저 find로 찾고, 절대 짐작 금지), 짧은 한국어 메모, 알고 있으면 여행 이름까지. 저장이 실패하면 실패했다고 말하고 다시 해 볼지 물어.
서버 쪽: 작은 SQLite 파일 하나
services/place_pins.py는 핀을 위치 저장소 옆의 place_pins.sqlite3에 둬. 테이블 하나, 칸 셋. id, 겹치면 안 되는 fingerprint, 그리고 핀 자체를 JSON으로 담은 body. 이 설계를 떠받치는 결정이 몇 개 있어.
비공개 턴은 흔적을 안 남겨. id가 priv-로 시작하는 대화는 핀을 저장 못 해. 지도는 함께 쓰고 오래 남는 거고, 비공개 턴은 그러면 안 되니까. 도구가 저장소 부르기 전에 한 번 막고, 저장소가 또 한 번 막아.
출처는 실행 환경이 정해. 출처 대화는 도구를 실행 중인 대화의 id야. 내가 인자로 채워 넣을 수 있는 값이 아냐.
한 여행 안에서 장소 하나에 지문 하나. 지문은 [trip, 대소문자를 맞춘 이름, 소수 다섯째 자리로 반올림한 위도, 같은 식의 경도]의 SHA-256이야. 위도 소수 다섯째 자리면 대략 1미터. 한 여행에서 "Cafe Uno"를 두 번 추천해도 핀은 하나야. 같은 카페라도 다른 여행이면 다른 핀이고. 아빠가 목록을 거르는 기준이 여행이거든.
다시 꽂으면 새 줄이 아니라 갱신이야. 처음 id랑 처음 added_at을 그대로 가져. 새 저장이 비워 둔 메모나 출처 대화는 예전 값으로 채우고, 예전 핀에 입구가 있었으면 입구 없이 다시 꽂아도 그 입구가 남아.
쓰기는 한 줄로 세워. 저장이든 삭제든 BEGIN IMMEDIATE로 열어. 추천 둘이 동시에 와서 둘 다 "그런 핀 없음"을 읽고 둘 다 넣는 일이 없게.
업서트 자체는 INSERT … ON CONFLICT(fingerprint) DO UPDATE SET body=excluded.body야. 이 구문 고른 게 보기보다 중요해. SQLite 업서트는 기존 줄을 그 자리에서 고쳐. 그래서 그 줄의 rowid가 그대로야. INSERT OR REPLACE였으면 지우고 다시 넣었을 거고, 다시 꽂힌 장소가 최신순 목록 맨 위로 튀어 올라서 새것인 척했겠지.
최신이 맨 위, 정하는 곳은 한 군데
이 순서는 아빠의 실제 부탁에서 나왔어. 세비야를 걷던 아빠가 새 핀이 목록 맨 아래로 떨어진 걸 봤거든(할 일 #390). 이제 서버는 내 pins 목록에도 폰에도 ORDER BY rowid DESC, 최신순으로 답해. 그래도 폰은 누구의 순서도 안 믿어. PlacePinsReplica.ordered(_:)는 핀마다 붙은 added_at으로 정렬해. 서버가 준 순서도, 예전에 저장해 둔 사본의 순서도 상관없어. 날짜 없는 핀은 날짜 있는 핀들 뒤에서 자기들끼리 순서를 지켜. 분할 컨트롤(최신순 / 오래된순)로 뒤집을 수 있고, 그 선택은 앱 설정에 남아서 다시 켜도 그대로야.
폰 쪽
폰에서 핀은 PlacePin이야. 서버의 snake_case 필드(source_conversation, added_at)를 풀어서 만들어. 아빠가 거기 가려고 하면 평범한 Place가 돼. id는 recommendation:<id>, 지도 서비스는 pippa, 입구는 유효할 때만 달고. 그래서 핀은 특별 취급 없이 다른 목적지들이 다니는 길에 그냥 합류해.
폰은 전체 목록을 이럴 때 받아 와. 피파에 연결될 때, 앱이 활성화될 때, 핸드오프를 확인하는 20초 폴링 때마다, 목록 시트가 열릴 때, 아빠가 핀 새로고침을 누를 때. 매번 앱이 앞에 떠 있어야 하고 가족 PIN이 저장돼 있어야 해. 사본은 서버 주소별 파일 places/pins-<host>.json에 살아. 다른 서버로 바꿔도 목록 두 개가 섞일 일이 없지.
지도에선 같은 핀이 세 스택 모두에 떠. Apple 지도엔 피파 마크 핀, Google 지도엔 분홍 마커, 카카오 지도엔 recommendation 스타일의 점. 여행 선택은 목록이랑 지도를 같이 거르고, 스위치 하나로 아무것도 안 지운 채 핀을 숨길 수 있어. 이 트랙 맨 위 화면이 바로 그 목록이야.
중복 제거는 서버가, 보여 주는 순서는 클라이언트가. 같은 것이냐는 쓰는 순간의 질문이라 쓰기가 한 줄로 서는 곳에 둬. 어떤 순서로 보이냐는 읽는 순간의 질문이라 읽는 곳에 둬. 그 순서는 마지막에 누가 보냈냐가 아니라, 레코드가 들고 있는 값으로 계산하고.
Code
cwkPippa, services/place_pins.py: 입구까지의 save() (원문 그대로 발췌, 트랜잭션은 이 뒤에 이어져)·python
def save(raw: dict, *, conversation_id: str | None) -> dict:
# A private turn must leave no durable trace in the shared map.
if conversation_id and conversation_id.startswith("priv-"):
raise ValueError("Private conversations cannot save shared Pathfinder pins.")
place = my_places.normalize_place(raw)
if place is None:
raise ValueError("A pin needs a name and finite latitude/longitude on the globe.")
trip = my_places._text(raw.get("trip"), 120)
key = json.dumps([trip or "", place["name"].casefold(), round(place["lat"], 5), round(place["lon"], 5)])
fingerprint = hashlib.sha256(key.encode()).hexdigest()
pin = {k: place[k] for k in ("name", "lat", "lon")}
pin.update(note=my_places._text(raw.get("note"), 1000), trip=trip,
source_conversation=conversation_id,
added_at=dad_location.stamp(datetime.now(timezone.utc)))
# A large building's door, so Pathfinder walks Dad to it on its level
# instead of the map's centre point (todo #369).
entrance = dad_location.normalize_entrance(raw, place["lat"], place["lon"])
if entrance:
pin["entrance"] = entrance
최소 스케치 (python3로 실행함): 한 여행의 장소 하나에 지문 하나, 다시 꽂아도 id랑 시각이랑 줄 자리는 그대로·python
# A minimal sketch of the place_pins upsert: one fingerprint per
# (trip, name, rounded point); a re-pin keeps its id, its first stamp and its row.
import hashlib, json, sqlite3, uuid
from datetime import datetime, timezone
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE pins (id TEXT PRIMARY KEY, fingerprint TEXT UNIQUE NOT NULL, body TEXT NOT NULL)")
def save(name, lat, lon, trip=None, note=None):
key = json.dumps([trip or "", name.casefold(), round(lat, 5), round(lon, 5)])
fingerprint = hashlib.sha256(key.encode()).hexdigest()
pin = dict(name=name, lat=lat, lon=lon, note=note, trip=trip,
added_at=datetime.now(timezone.utc).isoformat())
with db:
old = db.execute("SELECT body FROM pins WHERE fingerprint = ?", (fingerprint,)).fetchone()
if old:
previous = json.loads(old[0])
pin.update(id=previous["id"], added_at=previous["added_at"])
if pin["note"] is None:
pin["note"] = previous.get("note")
else:
pin["id"] = uuid.uuid4().hex[:8]
db.execute("INSERT INTO pins VALUES (?, ?, ?) "
"ON CONFLICT(fingerprint) DO UPDATE SET body=excluded.body",
(pin["id"], fingerprint, json.dumps(pin)))
return pin
def listing():
return [json.loads(b)["name"] + ":" + str(json.loads(b)["note"])
for (b,) in db.execute("SELECT body FROM pins ORDER BY rowid DESC")]
a = save("Cafe Uno", 48.85837, 2.29448, trip="Paris", note="good coffee")
save("Bakery", 48.86061, 2.33760, trip="Paris")
again = save("CAFE UNO", 48.858372, 2.294481, trip="Paris") # same place, said again
print("same id:", a["id"] == again["id"], "| same stamp:", a["added_at"] == again["added_at"])
print("rows:", db.execute("SELECT count(*) FROM pins").fetchone()[0])
print("newest first:", listing())
other = save("Cafe Uno", 48.85837, 2.29448, trip="Another trip")
print("another trip is another pin:", other["id"] != a["id"])
# Output:
# same id: True | same stamp: True
# rows: 2
# newest first: ['Bakery:None', 'CAFE UNO:good coffee']
# another trip is another pin: True
PathfinderKit, Places/PlacePins.swift: 순서를 정하는 단 한 곳 (원문 그대로 발췌)·swift
/// Newest recommendation first: the default order (Dad, todo #390).
public var visible: [PlacePin] { ordered(.newestFirst) }
/// The pins in Dad's chosen order, whatever order the server or an older
/// saved replica holds. Undated pins keep their order after the dated ones.
public func ordered(_ order: PinOrder) -> [PlacePin] {
snapshot.pins.enumerated()
.filter { !removals.contains($0.element.id) }
.map { (pin: $0.element, added: $0.element.addedDate, index: $0.offset) }
.sorted { lhs, rhs in
switch (lhs.added, rhs.added) {
case let (l?, r?) where l != r: order == .newestFirst ? l > r : l < r
case (_?, nil): true
case (nil, _?): false
default: lhs.index < rhs.index
}
}
.map(\.pin)
}
스케치를 돌려 보고, 업서트 줄을 INSERT OR REPLACE INTO pins VALUES (?, ?, ?)로 바꿔서 한 번 더 돌려 봐. 최신순 목록이 어떻게 됐는지, 왜 그런지 적어. 그다음 네 프로젝트에서 같은 레코드가 두 번 저장될 수 있는 테이블을 하나 찾아서 지문을 써 봐. 두 저장을 같은 것으로 보게 하는 필드가 정확히 뭔지, 각 필드를 어떻게 정규화하는지까지 적어.
Hint
REPLACE는 충돌하는 줄을 지우고 새 줄을 넣어서 rowid가 새로 붙어. 네 지문을 정할 땐 사람이 보기에 '같은 말을 두 번 한 것'인 차이(대소문자, GPS 1미터 떨림)랑 '다른 것'이 되는 차이(다른 여행, 다른 날)를 갈라 봐.
Progress
Progress is local-only — sign in to sync across devices.