aboutsummaryrefslogtreecommitdiffstats
path: root/docs/design/008-module-format.md
blob: 10393583cd2212eaa391dd39b95ff968e5612031 (plain)
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# Pure 모듈 형식 v5

2026-09-25. 구현 `prototypes/connections/module.{h,c}`, `module-text.c`, `module-list.c`, 상태 저장 `module-save.c`,
트리 저장소 연결 `module-store.c`.

Pure가 실행하는 앱 정의의 형식이다. JVM이 class 파일 형식을 소유하듯 Pure가 이 형식과
검증 규칙을 소유한다. 생산자는 [aup](/srv/git/aup.git) 같은 툴체인이다. Pure는 소스·문법을 모르며,
로드할 때 모든 규칙을 확인한 모듈만 실행한다.

v1(스칼라)은 워크벤치 앱과 같은 의미였다: f64 상태, 키 입력, 원자적 이벤트 핸들러, 순수한 보기.
v2는 타입 있는 바이트 값, 필드가 있는 **메시지 입력**, 키별 **영속 컬렉션**, 응답을 더했다.
v3은 **text 상태와 커서**, text 상수, text 입력, text 보기를 더했다.
v4는 **레코드**, 레코드의 **리스트** 상태, 리스트의 **인덱스** 상태를 더했다.
v5는 값을 가진 채 합류하는 분기(조건식)와, 리스트 항목마다 행을 내는 **표 보기**, 한 번 계산한 값을 여러 번
쓰게 하는 스택 복사(PICK·SLIDE, 툴체인의 함수 인라인용)를 더한다. 이전 판은 받지 않는다(생산자는 aup 하나다).
v5에는 뒤에 집계(FOLD), 걸러진 표, 마크, 그리고 **키 있는 리스트**와 text 비교가 더해졌다. 모두 전의 v5 모듈이 그대로
유효하도록 더한 것이다(새 명령과 이전에 0이던 자리의 값).

## 바이트 배치

모든 정수는 little-endian이고, 실수는 IEEE binary64 비트 그대로다.

| 위치 | 내용 |
|---|---|
| 0 | `PUREMOD5` (8 B) |
| 8 | u16 상태, u16 입력, u16 보기, u16 핸들러 |
| 16 | u32 명령 수, u32 문자열 바이트 수 |
| 24 | u16 필드, u16 컬렉션, u16 상수, u16 레코드 |
| 32 | u32 상수 블롭 바이트 수, u16 레코드 필드, u16 0 |
| 40 | 상태 × 16 B: u32 이름, u32 타입(0 수, 1 text, 2 커서, 3 리스트, 4 인덱스), 8 B 값: 수는 f64 초기값, text는 u32 초기 상수, 커서는 u32 대상 text 상태와 u32 플래그(1이면 마크: 그 자리에 넣은 글 앞에 남는다), 리스트는 u32 레코드와 u32 키(0이면 없음, 아니면 키 필드 + 1), 인덱스는 u32 대상 리스트 상태 |
| | 컬렉션 × 12 B: u32 이름, u32 1(바이트), u32 1(키별) |
| | 입력 × 16 B: u32 이름, u8 종류(1 키, 2 메시지, 3 text, 4 타이머), u8 키별 여부, u16 0, u32 키 이름(키 입력)·주기(타이머, 1 ms–24시간을 ms로) 또는 0, u16 첫 필드, u16 필드 수 |
| | 필드 × 8 B: u32 이름, u32 타입(1 u32le, 2 바이트, 3 수(binary64), 4 text). 페이로드는 접두부와 가변 필드의 바이트다. 접두부에는 선언 순서로 고정 필드의 값과 가변 필드(바이트·text)의 u32 길이가 들어가는데, 마지막 가변 필드는 길이 없이 나머지를 차지한다. 가변 필드의 바이트는 접두부 뒤에 선언 순서로 이어진다(가변 필드가 하나면 예전의 "바이트 꼬리"와 같다) |
| | 상수 × 8 B: u32 블롭 오프셋, u32 길이 (UTF-8) |
| | 레코드 × 8 B: u32 이름, u16 첫 레코드 필드, u16 필드 수(1–16) |
| | 레코드 필드 × 16 B: u32 이름, u32 타입(0 수, 1 text), 8 B 기본값: 수는 f64, text는 u32 상수 |
| | 보기 × 16 B: u32 이름, u8 타입(0 수, 1 text, 2 표), u8 걸러진 표의 리스트 상태 + 1(그 밖에는 0), u16 표(열이면 그 표 보기 + 1, 아니면 0), u32 시작 명령, u32 명령 수. 표는 시작 명령 자리에 리스트 상태, 명령 수 0. 걸러진 표는 타입 다음 바이트에 리스트 상태 + 1, 시작·명령 수에 필터 블록 |
| | 핸들러 × 12 B: u32 이벤트, u32 시작 명령, u32 명령 수 |
| | 명령 × 16 B: u8 opcode, 0 3바이트, u32 인자, f64 값 |
| | 문자열: `[u8 길이][바이트]`의 연속 |
| | 상수 블롭 (최대 8 KiB) |

파일 크기는 위 합과 정확히 같아야 한다. 이벤트 번호는 `frame`이 0, 입력 `i`의 누름·도착이 `1 + 2i`,
키의 뗌이 `2 + 2i`다. 메시지 필드는 입력 순서대로 이어지고, u32le 필드가 차례로 놓인 뒤 바이트 꼬리가
최대 하나 온다. 메시지 페이로드가 고정 부분보다 짧으면 이벤트는 `limit`으로 실패하고, 남는 바이트는 무시한다.

## 명령

| opcode | 이름 | 스택 | 의미 |
|---:|---|---|---|
| 0 | CONST | → n | 유한한 값 |
| 1 | LOAD | → n | 상태 `인자` |
| 2 | DT | → n | frame 1/60, 그 밖 0. 핸들러 전용 |
| 3–6 | ADD SUB MUL DIV | n n → n | DIV에서 0이면 실패 |
| 7 | NEG | n → n | |
| 8–13 | LT LE GT GE EQ NE | n n → n | 0 또는 1 |
| 14–16 | AND OR NOT | | 0이 아니면 참, 양쪽 모두 계산 |
| 17–18 | MIN MAX | n n → n | IEEE 754-2019: −0 < +0 |
| 19 | CLAMP | n n n → n | lo > hi이면 실패 |
| 20 | STORE | n → | 상태 `인자`. 핸들러 전용 |
| 21 | JZ | n → | 0이면 `인자`로 |
| 22 | JMP | | `인자`로 |
| 23 | FIELD | → n, b 또는 t | 메시지 필드 `인자`(u32·수는 n, 바이트는 b, text는 t). 메시지 핸들러 전용. 도착할 때 길이가 페이로드를 넘거나 text가 UTF-8이 아니거나 수가 유한하지 않으면 메시지가 `limit`·`encoding`·`non_finite`로 실패한다 |
| 24 | DOC | → b | 컬렉션 `인자`의 메시지 키 문서. 없으면 `not_found`. 키별 메시지 핸들러 전용 |
| 25 | DOC_SIZE | → n | 그 문서의 바이트 수(없으면 0) |
| 26 | LEN | b → n | 바이트 값의 길이 |
| 27 | REPLACE | n n b → | 문서의 `[a, b)`를 값으로 바꾼다. 정수가 아니거나 범위 밖이면 `limit` |
| 28 | REPLY | b → | 이 메시지의 응답 |
| 29 | REQUIRE | n → | 0이면 이벤트가 `limit`으로 실패 |
| 30 | TEXT_CONST | → t | 상수 `인자` |
| 31 | TEXT_LOAD | → t | text 상태 `인자`의 현재 값(복사 없는 버전) |
| 32 | TEXT_INPUT | → t | text 입력의 내용. text 입력 핸들러 전용 |
| 33 | NUM_TEXT | n → t | 가장 짧은 왕복 10진수. 1e15 미만 정수는 소수점·지수 없이, −0은 `0` |
| 34 | CONCAT | t t → t | |
| 35–37 | CHARS LINES TEXT_BYTES | t → n | 글자(Unicode scalar) 수, 줄 수(줄바꿈 + 1), 바이트 수 |
| 38 | CUR_LOAD | → c | 커서 상태 `인자` |
| 39–40 | CUR_START CUR_END | → c | text 상태 `인자`의 처음·끝 |
| 41 | CUR_MOVE | c n → c | 단위 `인자`(0 글자, 1 줄)로 n만큼. 끝에서 멈춘다. 줄 이동은 열을 유지 |
| 42 | CUR_STORE | c → | 커서 상태 `인자`. 핸들러 전용 |
| 43 | TEXT_INSERT | c t → | text `인자`의 커서 위치에 넣는다 |
| 44 | TEXT_REMOVE | c c → | 두 커서 사이를 지운다 |
| 45 | TEXT_LINE | c → t | 커서가 있는 줄(줄바꿈 제외) |
| 46 | TEXT_SET | t → | text 전체를 바꾸고 그 text의 커서를 처음으로 |
| 47 | TEXT_FROM | b → t | UTF-8이 아니면 이벤트가 실패(encoding) |
| 48 | BYTES_FROM | t → b | 같은 바이트 |
| 49 | CUR_COUNT | c → n | 단위 `인자`로 커서 앞의 글자 수, 또는 1부터 센 줄 번호 |
| 50 | LIST_COUNT | → n | 리스트 상태 `인자`의 항목 수 |
| 51 | LIST_APPEND | f… → | 레코드 필드 값을 필드 순서대로 받아 끝에 붙인다. 키 있는 리스트에서는 키 순서의 자리에 넣고, 그 키가 이미 있으면 `exists`. 핸들러 전용 |
| 52 | LIST_INSERT | i f… → | 인덱스의 항목 앞에 넣는다(빈 리스트면 처음). 키 있는 리스트에는 쓸 수 없다 |
| 53 | LIST_REMOVE | i → | 인덱스의 항목을 지운다. 없으면 `not_found` |
| 54 | ITEM_GET | i → f | 항목의 필드. `인자`의 아래 16비트는 리스트 상태, 위는 필드 번호. 없으면 `not_found` |
| 55 | ITEM_SET | i f → | 항목의 필드를 바꾼다. 키 있는 리스트의 키 필드는 바꿀 수 없다. 핸들러 전용 |
| 56 | INDEX_LOAD | → i | 인덱스 상태 `인자` |
| 57 | INDEX_STORE | i → | 인덱스 상태 `인자`. 핸들러 전용. 같은 이벤트에서 지우기 전에 얻은, 끝을 넘은 인덱스는 `not_found` |
| 58 | INDEX_MOVE | i n → i | n만큼. 정수가 아니면 `limit`. 처음과 마지막 항목에서 멈춘다 |
| 59–60 | INDEX_FIRST INDEX_LAST | → i | 리스트 상태 `인자`의 첫·마지막 항목(빈 리스트면 0) |
| 61 | INDEX_NUM | i → n | 0부터 센 위치 |
| 62 | ROW | → i | 열이 계산하는 행. `인자`는 그 표의 리스트 상태. 열 전용 |
| 63 | PICK | … → … v | `인자`개 아래의 값을 복사해 올린다(0이면 맨 위). 타입과 대상도 그대로 |
| 64 | SLIDE | … v → v | 맨 위 값 아래의 `인자`개(1 이상)를 버린다 |
| 65 | FOLD | → n 또는 i | 집계. `인자`의 비트 0–7은 종류(0 count, 1 sum, 2 max, 3 min, 4 first), 8–15는 리스트 상태, 16–31은 뒤따르는 블록의 길이 L. 그 L개 명령이 항목마다 그 리스트의 열처럼 돈다(`ROW`가 항목). count는 블록이 남긴 수가 0이 아닌 항목을 세고, sum·max·min은 블록이 남긴 값과 포함 여부(0이 아니면 포함)로 계산한다. first는 블록이 0이 아닌 수를 남긴 첫 항목의 인덱스(그 리스트의 인덱스)이고 거기서 멈춘다. 포함된 항목이 없으면 count·sum은 0, max·min·first는 `not_found` |
| 66 | TEXT_COMPARE | t t → n | 바이트 순서로 비교해 0 또는 1. `인자`는 관계(0 LT, 1 LE, 2 GT, 3 GE, 4 EQ, 5 NE, 수 비교 명령의 순서). 앞이 같으면 짧은 쪽이 앞이다 |
| 67 | LIST_FIND | k → i | 키 있는 리스트 `인자`에서 키가 k인 항목의 인덱스. 반씩 나눠 찾는다. 없으면 `not_found` |
| 68 | LIST_HAS | k → n | 그 키의 항목이 있으면 1, 없으면 0 |
| 69 | NOW | → n | 이벤트가 보는 지금(1970년부터 ms, UTC). 호스트가 정하고 스크립트는 아무 때나 재현한다. 핸들러 전용: 보기는 상태의 순수 함수로 남는다 |
| 70 | FLOOR | n → n | 내림 |
| 71 | MOD | n n → n | 나눗셈을 내림한 나머지. 결과는 나누는 수의 부호를 따른다(0도). 0으로 나누면 `divide` |

`f`는 레코드 필드의 타입(수 또는 text)이고, `k`는 그 리스트의 키 필드 타입이다. 수 연산 결과가 유한하지 않으면 실패한다. 실패하면 그 이벤트의 모든
핸들러가 한 일이 취소된다.

## 로드할 때 검증하는 것

- 머리말, 예약 바이트, 명령 패딩이 0인지. 한도(상태 32, 입력 32, 보기 64(열 포함), 핸들러 64, 명령 512,
  필드 32, 컬렉션 1, 문자열 32 KiB)와 정확한 파일 크기.
- 이름은 ASCII 식별자 1–31바이트이고, 상태·레코드·컬렉션·입력·보기가 한 이름공간을 쓴다. 필드 이름은 메시지나
  레코드 안에서 유일하다. 레코드(최대 8)의 필드 범위는 차례로 이어지고 모두 합쳐 64개 이하다.
  키 입력의 키는 정해진 목록(`Space Enter` 화살표 `Backspace Delete Home End Tab Escape PageUp PageDown KeyA–Z Digit0–9`)
  안에 있고 겹치지 않는다. 메시지 입력은 키 이름이 없고 필드 범위가 차례로 이어진다. text 입력은 필드가 없다.
- 상수는 블롭 안에 있고 유효한 UTF-8이다. text 상태의 초기 상수, 커서의 대상 text, 리스트의 레코드, 인덱스의
  대상 리스트, 레코드 필드의 기본값(유한한 수 또는 있는 상수)이 존재한다.
- 메시지·text 핸들러의 이벤트는 도착(`1 + 2i`)뿐이다.
- 각 블록에서 명령마다 피연산자 타입(수, 바이트, text, 커서, 인덱스)과 스택 높이(0–64)를 확인한다. 커서 타입은 어느
  text의 커서인지, 인덱스 타입은 어느 리스트의 인덱스인지 담는다. 다른 text의 커서나 다른 리스트의 인덱스를 쓰는
  모듈은 거절한다. 레코드 명령은 레코드 필드 수만큼 필드 타입대로 꺼낸다. 명령이 놓일 수 있는 문맥(보기,
  열, 핸들러, 메시지 핸들러, 키별 메시지 핸들러, text 입력 핸들러)도 확인한다. 분기는 블록 안에서 앞으로만 간다.
  합류 지점에는 모든 경로가 같은 스택(높이, 타입, 커서·인덱스의 대상)을 가져와야 한다. 그래서 조건식처럼 값이
  합류를 건너가도 타입이 섞이지 않는다. 앞에 남은, 값을 가진 합류는 한 번에 8개까지다. 모든 명령에 도달할 수
  있어야 하고, 핸들러는 값을 남기지 않으며 보기와 열은 자기 타입의 값 하나를 남긴다.
- 집계(FOLD)의 블록은 그 리스트의 열로 따로 검증한다. 읽기만 하고(효과는 거절), count·first는 수 하나, sum·max·min은
  수 둘을 남긴다. first의 결과는 그 리스트의 인덱스 타입이다. 블록 안에 다시 집계를 둘 수 없다(비용 상한). 둘레의 블록은 FOLD와 그 블록을 명령 하나로 보고, 앞의 분기가
  블록 안으로 들어오지 못한다. 블록은 둘레 블록 안에 끝나야 한다.
- 키 있는 리스트의 키는 레코드 안의 필드다(플래그 1–16이 필드 수 이하). 플래그는 커서(마크)와 리스트에만 있다.
  키 있는 리스트에는 자리로 넣지(LIST_INSERT) 못하고, 키 필드를 바꾸지(ITEM_SET) 못한다. LIST_FIND·LIST_HAS는 키 있는
  리스트만 가리키고 키 필드 타입의 값을 받는다. FIND의 결과는 그 리스트의 인덱스다.
- 걸러진 표의 필터 블록은 그 리스트의 열로 검증한다. 읽기만 하고 수 하나를 남기며, 안에 집계를 둘 수 없다.
  타입 다음 바이트는 표에만 쓸 수 있다.
- 표는 리스트 상태를 가리키고 열은 앞에 나온 표를 가리킨다. 표 밖의 이름은 한 이름공간이고, 열 이름은 표
  안에서만 유일하다.

검증된 모듈은 실행 중 스택·타입 검사가 필요 없고, 이벤트 하나의 명령 수는 핸들러 길이 이하다. 집계가 있으면
블록 길이 × 항목 수(최대 65,536)만큼 더 돈다. 집계 안에 집계가 없으므로 이 곱이 상한이다.

## 실행

`PureApp`은 모듈 참조, 상태 값, text 상태 draft, 리스트, 열린 이벤트 하나를 가진다(3,128 B, `PureModule`은 상수
블롭을 포함해 28,600 B). text 상태는 저장소 파일이 없는 draft라 1 MiB 텍스트도 조각 트리로 편집한다. 커서는 바이트
위치이며 항상 글자 경계에 있다. 넣기는 그 위치 이후의 커서를 뒤로(삽입 위치의 커서 포함, 마크는 제자리) 밀고, 지우기는 범위
안의 커서를 범위 처음으로 모은다. 모든 이벤트에서 text draft를 잡아 두고, 실패하면 이벤트 전 버전으로 돌린다.

리스트는 런타임이 소유하는 항목 배열이고, 항목은 필드마다 수 또는 text 바이트를 가진다(최대 65,536 항목). 인덱스는
항상 그 리스트의 항목을 가리킨다(빈 리스트면 0). 넣기는 넣은 자리 이후를 가리키던 인덱스를 한 칸 밀어 같은 항목을
따라가게 하고, 지우기는 뒤의 인덱스를 당기며 지운 항목을 가리키던 인덱스는 다음 항목(없으면 마지막)으로 간다.
이벤트 동안 리스트 변경은 되돌리기 기록에 쌓이고, 실패하면 거꾸로 되돌리며 성공하면 지운 항목과 바뀐 text를 해제한다.
항목의 text 필드를 읽으면 이벤트 공간에 복사한다.

**키 있는 리스트**는 항목을 키 순서(수는 값, text는 바이트)로 두고 키마다 항목 하나만 둔다. 맵·집합·정렬된 맵을 따로
두지 않는다. 리스트 하나가 순서의 규칙만 달리 가진다. 그래서 표 보기, 걸러진 표, 집계, 인덱스, 저장, 모듈 교체가 모두
그대로다. 넣기(LIST_APPEND)는 반씩 나눠 찾은 자리에 넣고, 같은 키가 있으면 이벤트가 `exists`로 실패한다(호스트에는
`PURE_CONFLICT`). 찾기는 O(log n)이다. −0과 +0은 한 키다. 키는 바뀌지 않으므로(검증기가 막는다) 순서는 넣기로만
만들어진다.

모듈을 열 때(시작과 교체) 키 있는 리스트가 키 순서가 아니면(키가 없던 리스트, 다른 필드가 키이던 리스트, 밖에서 바뀐
저장본) 키 순서로 다시 놓고, 그 리스트의 인덱스는 자기 항목을 따라간다. 다시 놓은 리스트는 저장본도 다시 쓴다(레코드가
바뀐 리스트와 같은 규칙). 같은 키의 항목이 둘 이상이면 모듈을 열지 않는다(`PURE_CONFLICT`). 데이터를 버려 맞추지 않는다.
교체는 이전 모듈과 항목을 그대로 두고, 시작은 실패한다.

검증(`test-module`): 넣기와 중복(한 이벤트에서 두 번째 넣기가 실패하면 첫 넣기도 되돌린다), 찾기·`has`·지우기, 바이트
순서(빈 text가 처음, 앞이 같은 짧은 text가 먼저, UTF-8이 ASCII 뒤), −0과 0, 키 없는 모듈로 바꿨다 다시 키 있는 모듈로
바꾸기(정렬, 인덱스가 항목을 따라감), 중복 키의 거절, 저장소에서 키가 생긴 리스트의 정렬과 그 저장본으로 다시 시작,
키 있는 리스트 앱의 메모리·저장 대조(400단계 × 3, 재시작 포함), 검증 거절 8가지, 손상 모듈 20,000개(받아들인 모듈의
키 있는 리스트는 늘 엄격한 키 순서다). aup는 무작위 3,600단계에서 text 키와 수 키 리스트가 파이썬 모델과 같음을 확인한다.

키·frame·text 이벤트와 타이머의 틱(`pure_app_tick`)은 그 핸들러를 실행한다. 틱은 키 이벤트처럼 저장되는 이벤트다.
호스트가 앱을 돌리는 동안 타이머의 주기마다 한 번 넣고, 놓친 틱을 몰아서 넣지는 않는다. 이벤트가 보는 시각은
`PureApp.now`이고 호스트가 정한다(`pure-run`의 `time MS`, `tick NAME`). 메시지 이벤트는 다음 순서로 처리한다.

1. 입력 종류, 키 형식, 기준 리비전(`-1`은 없음)을 확인하고, 페이로드가 고정 부분보다 짧으면 `limit`.
2. 키별 메시지면 데이터 호스트에서 그 키의 작업 버전을 연다. 기준 리비전이 있고 현재와 다르면 `conflict`.
3. 핸들러를 소스 순서대로 실행한다. 실패하면 작업 버전을 버리고 상태도 되돌린다.
4. 문서를 바꿨다면 기준 리비전이 있어야 하고, 작업 버전을 커밋한다. 문서에서 읽은 응답은 커밋 전에 복사한다.
   커밋이 실패하면 상태를 되돌린다. 결과 불명(`uncertain`)이면 상태만 되돌리고 그렇게 보고한다.

모듈을 바꾸면(`pure_app_replace`) 상태는 이름과 종류가 같을 때 남는다. 레코드가 같으면 리스트를 그대로 쓰고,
레코드가 바뀌었으면 항목마다 이름과 타입이 같은 필드를 옮기고 새 필드는 기본값으로 채운다(키 있는 리스트는 그다음 키
순서로 놓인다). 커서와 인덱스는 대상
text·리스트가 남을 때만 위치를 유지한다.

응답은 `pure_app_reply`로 순서대로 빌려준다. 페이로드와 문서의 익스텐트를 복사하지 않는다. 문서를 고친 이벤트의
문서 응답과 text 상태의 응답은 확정 전에 복사한다. text 보기는 `pure_app_view_text`로 빌려준다.

호스트는 인덱스 상태를 한 항목으로 옮길 수 있다(`pure_app_point`, 사용자가 행을 가리키는 것). 핸들러 없는 이벤트이며
키 이벤트처럼 저장되고, 마지막 항목을 넘으면 `not_found`다. 어느 인덱스를 옮길지는 보기가 알려 준다.
`pure_module_position`은 수 보기가 인덱스 상태의 위치 그대로(`view tasks_focus = at.position`)이면 그 상태를 돌려준다.
그래서 인터페이스(Vega)는 앱 코드 없이 목록의 클릭을 포커스로 옮긴다.

표 보기는 리스트 항목마다 행 하나를 가진다(`pure_app_rows`). 걸러진 표는 필터 블록이 0이 아닌 수를 남긴 항목마다
행 하나를 가진다(항목 순서 그대로). 필터는 상태가 바뀐 뒤 처음 행을 물을 때 항목마다 한 번 돌고, 그 결과(행 → 항목)를
다음 변경까지 쓴다. 칸마다 다시 거르지 않는다. 변경 번호는 이벤트, 가리키기, 저장 실패의 되돌리기, 모듈 교체마다
오르고 되돌아가지 않는다. 열의 `ROW`는 행이 아니라 항목이다. 호스트는 `pure_app_row_item`·`pure_app_item_row`로 행과
항목을 오간다. 그래서 포커스(인덱스, 곧 항목)를 걸러진 목록의 행으로 보이고, 클릭한 행을 항목으로 가리킨다. 호스트는 보여 줄 칸만
`pure_app_cell`·`pure_app_cell_text`로 행 번호와 함께 계산한다. 화면 모델을 따로 만들거나 목록을 복사하지 않으므로,
10만 항목 목록에서 20줄을 그리는 표면은 20행만 계산한다(가상 스크롤이 런타임의 성질이 된다). 보기가 실패해도
앱의 마지막 이벤트 실패 정보는 바뀌지 않는다.
데이터 호스트는 `PureData` 인터페이스이며, `pure_tree_data`가 트리 저장소의 draft로 구현한다.
`pure-edit-module`은 기존 문서 CLI(`pure-edit`)와 같은 명령·출력·종료 코드를 모듈로 제공한다.

## 상태 영속

데이터 호스트가 상태를 맡으면(`PureData`의 `state`·`save`, 트리 저장소에서는 `pure_tree_data`) 앱의 모든 상태가
저장소에 산다. 앱에 저장 코드가 없다. 선언한 상태가 곧 영속 상태다.

| 상태 | 저장 형태 (런타임 이름공간, 문서 키와 겹치지 않음) |
|---|---|
| text | `text-이름`: UTF-8 바이트 그대로. 앱의 draft가 곧 저장 문서라 입력 한 글자는 바뀐 익스텐트만 추가한다 |
| 리스트 | `list-이름`: `PLS1`, 레코드 필드 수, 필드마다 종류·이름, 그다음 항목(수는 binary64, text는 u32 길이와 바이트). 리스트 연산이 메모리의 항목과 draft를 함께 splice한다 |
| 수·커서·인덱스 | `values`: `PVS1`, 상태마다 종류·이름·binary64. 바뀐 이벤트에서만 다시 쓴다 |

- **시작.** 저장된 상태를 이름과 종류로 불러온다. 없는 상태는 초기값으로 시작한다. 레코드가 달라진 리스트는
  이름·종류가 같은 필드를 옮기고 새 필드는 기본값을 쓴 뒤 다시 저장한다(교체와 같은 규칙, 마이그레이션 스크립트 없음).
  커서가 text 범위나 글자 경계를 벗어나거나, 인덱스가 리스트 밖이면 0이 된다. 깨진 리스트·text(형식 오류, UTF-8
  아님)는 시작을 `store_error`로 멈춘다. 조용히 버리지 않는다.
- **저장은 묶음이다.** 저장 한 번이 마지막 저장 이후 메시지가 고친 문서들과 바뀐 상태 문서들을 저장소의
  **한 커밋**(`pure_draft_commit_all`)으로 공개한다. 모두 보이거나 하나도 보이지 않는다. 저장이 실패하면 마지막 저장
  이후의 이벤트가 **모두** 되돌려진다(값, text와 리스트 draft, 리스트 되돌리기 기록, 이벤트·tick 수). 그래서
  "성공을 알린 이벤트는 저장됐고, 실패를 알린 이벤트는 흔적이 없다"가 성립한다. 결과 불명(`uncertain`)이나 다른
  기록자와의 충돌 뒤에는 앱이 저장소와 어긋나 이후 저장이 충돌로 실패한다. 다시 시작하면 저장소의 상태로 이어진다.
- **기본은 이벤트마다 저장.** 키·text·메시지 이벤트는 저장까지 마치고 돌아온다(묶음 크기 1). 초당 60번 오는
  frame 이벤트는 다음 이벤트나 `pure_app_save`에 함께 들어간다. `pure_app_stop`만 하면 저장하지 않은 변경은 사라진다.
- **지연 저장(그룹 커밋).** `pure_app_defer(app, true)`면 이벤트는 바로 적용되고, 호스트가 입력을 몰아 처리한 뒤
  `pure_app_save` 한 번으로 저장한다. 이벤트는 그 뒤 저장이 성공해야 확정이고, 그 전에는 응답을 내보내지 않는
  것이 호스트의 몫이다. 한 묶음에서 키 하나의 문서는 한 번만 열린다. 같은 키의 두 번째 메시지나 256개 키를 넘는
  메시지는 먼저 묶음을 저장하므로 문서 리비전은 저장마다 한 칸씩 움직인다(응답에 알린 리비전 = 저장된 리비전).
  Pi 5의 SD(ext4)에서 이벤트 600개: 이벤트마다 저장 6,282 ms(10.47 ms/이벤트), 8개씩 973 ms, 64개씩 341 ms,
  한 번에 194 ms. `pure-run --store 경로 --batch N`으로 잰다.
- **교체.** 모듈을 바꾸면 새 상태는 저장된 값이 있으면 그것으로 시작하고, 버린 상태의 저장본은 그대로 남는다.
  새 모듈의 상태는 다음 이벤트나 저장에서 공개된다.

검증(`test-module`): 같은 무작위 이벤트열을 메모리 앱과 수시로 재시작하는 저장 앱에 주어 매 단계 상태가 비트 단위로
같다(리스트 앱·text 앱 각 400단계, 재시작 20회 이상). 수·text·리스트를 한꺼번에 바꾸는 이벤트 중 커밋의 모든 단계에서
프로세스를 죽여도 다시 시작하면 전부 이전 아니면 전부 이후다. 메시지의 문서와 상태가 함께 커밋되고 함께 실패한다.
레코드 변경은 재시작과 교체 모두에서 항목을 옮긴다. 지연 저장에서는 저장된 이벤트만 다시 재생한 메모리 앱과,
무작위로 저장하고 가끔 저장 없이 멈췄다 다시 시작하는 저장 앱이 매 단계 같다(리스트·text 앱 각 600단계). 묶음 저장이
실패하면 묶음 전체가 되돌려지고, 저장 중 프로세스가 죽으면 묶음 전체가 남거나 전혀 남지 않는다. 비용(Pi 5, SD의 ext4): 저장하는 이벤트 하나에 약 10.6 ms가
더 든다. 대부분 두 번의 `fsync`다. 300항목 리스트에서 인덱스 이동 한 번은 파일을 약 220 B 키운다(압축 전).

## HTTP 호스트 (`pure-serve`)

`pure-serve --module 앱.pmod --store 경로`는 앱의 **보기와 입력이 곧 HTTP 인터페이스**다. 라우터 코드가 없다.

| 요청 | 뜻 |
|---|---|
| `GET /views/이름` | 보기의 값. 수는 가장 짧은 왕복 10진수, text는 UTF-8 그대로(`text/plain`) |
| `GET /views/표?from=N&count=M` | 표의 행: `{"rows":전체,"from":N,"items":[{열: 값}…]}`(`application/json`). 요청한 행의 칸만 계산한다. 값이 없는 칸은 `null` |
| `POST /inputs/이름` | 이벤트. text 입력은 본문이 text, 키 입력은 누름(down 뒤 up). 메시지는 고정 필드(u32, 수)를 쿼리(`?points=3`)로, 가변 필드 하나를 본문으로 받거나, `Content-Type: application/json` 본문의 객체로 필드를 받는다(문자열은 바이트·text, 수는 u32·수). 응답 본문은 핸들러의 `reply`(메시지 필드) |
| `GET`·`PUT /objects/키` | 모듈에 키별 메시지 `show`·`edit`가 있으면 기존 문서 서버(`server.c`)와 같은 계약. `GET`은 `show`, `PUT`(`If-Match`/`If-None-Match`)은 `edit` |

- **실패는 실패 종류로 답한다.** 이벤트가 실패하면 `X-Pure-Error`에 종류를 싣는다. `not_found` 404, `exists` 409,
  `limit`·`divide`·`clamp`·`non_finite`·`encoding` 422, UTF-8이 아닌 text 400. 값이 없는 보기는 404 `no_value`, 없는 이름은
  404 `no_view`·`no_input`, 모자라거나 모르는 쿼리 필드는 400 `missing_field`·`query`다.
- **JSON은 연결 스레드가 풀어 둔다.** 앱 입력의 ingress는 `[u32 페이로드 크기][페이로드][온 그대로의 JSON]`이다. 본문을
  다 받으면 연결 스레드가 JSON을 메시지의 페이로드로 옮기고(이스케이프, 서로게이트 쌍, UTF-8, 수의 문법과 범위를 확인한다)
  나서야 요청이 준비된다. 그래서 앱 소유자는 해석하지 않고, 응답은 여전히 요청에서 빌린다. 필드가 빠지거나(`missing_field`),
  모르거나 두 번 오거나(`json_field`), 타입이 다르거나(`json_type`), JSON이 아니면(`json`) 400이고 요청은 실행되지 않는다.
- **라우트는 작업 슬롯에 둔다.** 요청이 가리키는 것(문서, 보기, 입력)과 메시지 필드는 받을 때 풀어 슬롯별 표와 ingress에
  둔다. 연결이 묶음 전에 끊겨도 받아들인 이벤트는 실행된다. 런타임 코어는 그대로다(키는 문서 키 또는 보기·입력 이름).
- **시간.** 요청의 이벤트는 적용되는 순간의 벽시계를 `now`로 본다. 앱을 돌리는 스레드(한 스레드면 그 스레드, 아니면
  소유자)가 상태를 가진 앱의 타이머를 제때 틱하고, 틱들을 저장 한 번으로 저장한다. 그래서 다시 띄운 서버는 저장된
  상태에서 이어서 틱한다. 대기는 다음 틱까지다(epoll의 대기 시간, 소유자는 조건 변수의 시한).
- **POST 응답은 요청에서 빌린다.** 코어가 쓰기 결과를 요청 바이트에서 빌리게 하므로 HTTP로 받는 메시지의 핸들러는
  메시지 필드만 응답할 수 있다(`reply score.name`). 시작할 때 확인한다. 상태에서 계산한 값은 보기로 읽는다.

- **연결.** HTTP/1.1 지속 연결과 파이프라이닝(응답은 순서대로), edge-triggered epoll 소유 스레드 하나, 기다리는
  연결을 한 번에 받는다. 연결 구조체는 받을 때 할당하고 닫으면 재사용하므로 메모리는 설정한 최대치가 아니라
  열린 연결 수를 따른다(기본 최대 1,024). 응답은 머리말과 본문을 `writev` 한 번으로 보낸다. 연결 기한(기본
  10초)은 진행이 멈춘 시간으로 센다. 느리게라도 계속 받아 가는 클라이언트는 큰 응답을 끝까지 받는다.
- **런타임 코어.** 요청은 `runtime.c`를 그대로 거친다(연결마다 작업 슬롯, 입력 바이트 예산, 코어가 소유한
  ingress, 요청에서 빌린 PUT 응답). 효과는 비동기 인터페이스(`pure_runtime_take`/`complete`)로 소유 스레드에서 처리한다.
- **이벤트 한 바퀴가 묶음 하나다.** 준비된 요청을 모두 지연 저장 모드의 앱에 차례로 적용하고, 저장 한 번 **뒤에**
  응답한다. 저장이 실패하면 묶음의 읽기까지 모두 실패한다. 같은 문서 키가 다시 오거나 문서 수가 한 저장의 한도를
  넘으려 하면 묶음을 먼저 끝낸다. 보기와 앱 상태의 이벤트는 어느 묶음에나 들어간다. 그래서 묶음 안의 모든 요청은
  자기 묶음의 저장으로 저장되거나 그것과 함께 실패하고, 보기는 저장될 상태만 내보낸다. 한 연결의 요청은 앞 요청에
  답한 뒤에 읽으므로 쓴 것을 곧 읽는다.
- **저장소 핸들.** 문서와 상태를 저장소 핸들(`pure_store_data`)로 열어 파일 매핑 하나를 공유하고, 묶음마다 한 번만
  저장소가 바뀌었는지 본다.
- **연결 스레드와 소유자.** `--threads N`은 스레드마다 같은 포트의 리스너(`SO_REUSEPORT`), epoll, 런타임 코어를 둔다.
  키별 문서만 가진 모듈은 스레드마다 앱과 저장소 핸들을 두고 저장소 말고는 아무것도 나누지 않는다. 상태를 가진
  모듈은 상태의 소유자가 하나여야 하므로, 스레드 하나가 더 앱을 소유한다. 연결 스레드는 요청을 받고 해석하고 답을
  쓰는 일(시스템 호출이 대부분이다)을 나눠 맡고, 준비된 요청을 소유자에게 넘긴다. 소유자는 모든 스레드의 요청을 한
  묶음으로 적용하고 저장 한 번 뒤에 각 스레드에 돌려주며(eventfd로 깨운다), 스레드는 돌려받은 것을 완료하고 답한다.
  작업 슬롯의 라우트와 입력은 완료될 때까지 그 스레드의 런타임 코어가 붙잡고, 빌려준 저장소 매핑의 참조 수는
  원자적이다. 저장한 묶음 뒤에는 새 요청이 100 µs 동안 끊길 때까지(최대 1 ms) 모은다. 답을 받은 클라이언트의 다음
  요청이 한 저장에 들어가게 하는 것으로, 한 스레드에서는 답하는 시간이 그 구실을 한다(묶음당 이벤트 30 → 50, p50은
  오히려 5.4 → 3.5 ms).
- 저장된 문서를 읽는 GET 응답은 복사하지 않고 저장소 매핑의 익스텐트를 빌려준다. 보내는 동안 문서가 바뀌어도
  읽은 버전이 나간다. 빌려준 응답도 복사한 응답처럼 예산에 계상된다. 매핑 참조는 조각으로 보내는 자원으로
  런타임 코어에 넘어가, 응답이 소켓에 다 들어갈 때까지 예산을 차지하고 티켓과 함께 풀린다. 그래서 받지 않는
  클라이언트가 붙잡을 수 있는 옛 버전은 예산을 넘지 않는다(넘으면 `503 payload_budget`). 매핑이 담지 않는
  응답만 복사한다. PUT 응답은 요청 본문에서 빌려야 하므로 `edit` 핸들러는 메시지 필드만 응답할 수
  있다(`reply edit.insert`). 시작할 때 확인한다.

성능과 비교는 [연구 075](../research/075-http-host-against-axum.md). 검증(aup `make check`): 무작위 요청에 기존 서버와
상태 코드·ETag·오류·본문이 같고, 동시 편집이 커밋을 나누어 쓰며, `kill -9` 뒤에도 확인 응답한 편집이 모두 남는다.
앱 라우트는 키 있는 리스트 앱에 무작위 POST(쿼리 필드와 본문을 가진 메시지, text, 키)와 GET(수, text, 표의 쪽)을
800번 보내 상태 코드·오류·본문·JSON 행이 파이썬 모델과 같고, 멈췄다 다시 띄워도, 여섯 클라이언트가 보내는 중에
`kill -9`로 죽여도 확인 응답한 이벤트(약 4,200개)가 모두 남는다. 한 스레드와 소유자 모드 모두 확인한다. 같은 서비스를
axum·drogon으로 손수 쓴 것과의 비교는 [연구 077](../research/077-apps-as-http-services.md): 내구성 있는 쓰기 약 76배,
읽기는 drogon과 같고 axum보다 빠르며(20행 JSON 145k/s 대 95k/s), CPU와 메모리가 가장 적다.

아직 모듈 앱을 런타임 코어의 효과 단위로 세분하지는 않았다(`runtime.c`의 `definition`은 앱 전체다). 호스트가 직접 호출한다.

## 실행기 크기

실행기는 `-O2` text로 49.8 KB였다가 42.2 KB가 됐다. `module.c`가 34.0 → 27.0 KB, `module-list.c` 5.6 KB,
`module-save.c` 4.8 KB, `module-text.c` 3.6 KB, `module-store.c` 1.2 KB다. 형식과 API는 그대로다.

그 뒤 기능이 늘며 다시 49.9 KB다. 집계·걸러진 표·마크·가리키기가 `module.c`에 4.0 KB, 키 있는 리스트와 text 비교가
3.7 KB(`module.c` +1.8 KB, `module-list.c`의 찾기·정렬 +1.9 KB)를 더했다. 기능 하나가 몇 KB인지를 기록해 둔다.

- **검증기는 표를 읽는다.** 명령마다 인자가 무엇을 가리키는지(상태의 종류, 필드, 상수, 단위, 행, 분기, 깊이)와,
  어느 text나 리스트에 대해 동작하는지를 서명 표에 적는다. 커서·인덱스 피연산자는 모두 그 text나 리스트에 속해야
  하고, 밀어 넣는 커서·인덱스도 그렇다. 명령별 분기 대신 이 한 규칙으로 확인한다. 커서·인덱스가 아닌 값은
  어디에도 속하지 않는다. 그래서 줄 text나 커서로 센 수가 합류를 건널 때 소속이 달라 거절되던 경우가 사라졌다.
  로더와 검증기의 실패는 한 경로에서 형식화한다.
- **인터프리터.** 명령을 가져올 때 위치를 넘기고, 실패는 두 출구(명시한 실패, 데이터가 거절한 변경)로 모은다.
  위치를 밀어 넣는 명령들과 이동 명령들은 한 갈래를 나눠 쓴다.
- **한 경로.** 키·text·메시지 이벤트가 같은 처리(`dispatch`)를 탄다. 메시지만의 일(기준 리비전 없는 편집 거절,
  응답 복사, 문서의 묶음 참여)은 이벤트 값으로 들어간다. 앱 시작은 "아무 모듈도 없던 앱에 모듈을 연다"이다.
  교체와 같은 경로라, 새 상태는 저장된 값이 있으면 그것으로 시작한다(수·커서·인덱스도 이제 text·리스트와 같다).
- **한 번만 도는 코드는 크기로.** 로드, 검증, 시작, 교체, 저장된 상태 열기는 `cold`로 표시해 컴파일러가 크기를
  우선한다. 이벤트 경로는 그대로 속도를 우선한다. 작은 GET 처리량은 그대로다(1스레드 128k/s).

항목 text를 읽을 때마다 복사하는 비용은 남아 있다.