aboutsummaryrefslogtreecommitdiffstats
path: root/docs/design/002-dsl-direction.md
blob: efdca29653f46afa4c6f7be653a52dc28c086cc9 (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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# 002 — DSL 방향과 text

2026-09-25. 상태: 방향 확정. v0b 구현, v0c는 레코드·리스트·인덱스·조건식·표 보기·`fn`·`use`까지 구현.

## 위치

aup는 범용 언어가 아니라 **무엇이 무엇에 연결되는지를 선언하는 언어**다. 앱은 상태·입력·반응·보기를
적고, 저장·트랜잭션·화면 모델·스케줄링은 Pure가 맡는다. 인터페이스 어휘(`list`, `table`, `tone`,
`focus` 같은 것)는 언어에 넣지 않는다. **Vega는 aup 라이브러리**로서 그 어휘를 제공한다
(`use vega`). aup 자체는 일반적인 투영(`view`)과 모듈만 제공한다. Java 언어와 JavaFX의 관계와 같다.

## 원칙

1. 모든 구문은 Pure가 로드 전에 검증할 수 있는 것으로 컴파일된다. 무제한 반복·재귀는 없다.
   길이에 비례하는 연산은 이벤트 작업 예산을 쓰고, 넘으면 이벤트가 실패한다.
2. 이벤트가 트랜잭션이다. 효과는 이벤트 경계에서만 일어나고, 실패하면 아무것도 바뀌지 않는다.
3. 형태는 한 번만 선언한다. 같은 `record`가 저장·메시지·전송·보기에 쓰이고 표현은 런타임이 고른다.
4. 표면은 가독성을 우선한다. 줄바꿈으로 끝나는 문장, `{}` 블록, `state / input / on / view`.
   식별자는 당분간 ASCII다(키·도구·상호운용). 텍스트 내용은 전부 Unicode다.
5. 기능은 실제 소비자가 끌고 온다(v0b는 기존 편집기 동등성, 레코드·리스트는 Vega의 첫 컴포넌트).

## text

**한 타입이다.** `text`는 유효한 UTF-8의 불변 값이다. 한 단어든 1 MiB 문서든 같은 타입이다.
작은 값은 상태 안에 두고, 큰 값은 Pure 영속 트리의 익스텐트 열로 두지만 앱은 차이를 모른다.
"메모리 문자열과 파일·BLOB 문서는 다른 것"이라는 관례를 없앤다. 임의 바이트는 `bytes`다.
`bytes(t)`는 항상 성공하고, `text(b)`는 UTF-8을 검사해 잘못되면 이벤트가 실패한다.

```text
state title: text = "할 일"
state note: text = ""                  # 한 줄이든 1 MiB 문서든 같은 타입
state at: cursor in note               # 항상 글자 경계에 있고 편집을 따라간다
state from: mark in note               # 같지만, 이 자리에 넣은 글보다 앞에 남는다

input typed = text                     # 호스트가 준 확정 입력(IME 조합 완료분)
input back = key Backspace
input left = key ArrowLeft

on typed      { note.insert(at, typed) }        # at은 삽입한 글자 뒤로 간다
on back.down  { note.remove(at - 1 char .. at) }
on left.down  { at = at - 1 char }              # 처음에서 멈춘다

view header = "{title} · {note.chars}자"
view current = note.line(at)           # 커서가 있는 줄. 복사하지 않는다
```

- **리터럴.** `"안녕"`. 이스케이프는 `\n \t \" \\ \{ \u{AC00}`. 보간은 `"남은 일 {remaining}개"`.
  여러 줄은 `"""`로 쓰고 공통 들여쓰기를 뺀다.
- **위치는 정수가 아니다.** 바이트·UTF-16·코드포인트 인덱스가 섞이는 문제를 없애려고
  `cursor in 값` 타입을 둔다. 커서는 항상 글자 경계에 있고, 범위를 벗어나지 않으며, 같은 이벤트의
  편집에 맞춰 런타임이 옮긴다. 이동은 단위로 쓴다: `at + 1 char`, `at - 1 line`, `note.start`, `note.end`.
  범위는 `a .. b`, 부분은 `note[a .. b]`, 줄은 `note.line(at)`이다. 모두 복사 없는 보기다.
- **커서와 마크.** 둘 다 편집을 따라간다. 차이는 그 자리에 글을 넣을 때뿐이다. 커서(캐럿)는 넣은 글 뒤로 가고,
  마크(기억해 둔 위치, 선택의 시작점, 책갈피)는 글 앞에 남는다. 지울 때는 둘 다 지운 범위의 처음으로 모인다.
  타입은 같아서 커서에 쓰는 모든 것(이동, 비교, 범위, `insert` 위치)에 마크도 쓴다.
- **글자 단위.** 목표는 사용자가 한 글자로 보는 단위(grapheme cluster)다. 첫 구현의 `char`는 Unicode
  scalar이고, 분절 표(UAX #29)는 크기 비용이 있어 선택 모듈로 붙인다. `word`도 그때 추가한다.
- **편집.** `insert`, `remove`, `replace(a .. b, s)`는 Pure draft의 splice가 된다. 이벤트가 성공하면 커밋된다.
- **길이는 단위를 적는다.** `note.chars`, `note.lines`, `note.bytes`(저장 크기). 모호한 `len`은 없다.
- **비교는 정확하다.** `==`는 바이트가 같을 때 참이다. 정규화를 조용히 하지 않고 입력 바이트를 보존한다.
  정준 동치는 Unicode 모듈의 `same(a, b)`, 로케일 정렬도 모듈이다. 기본 정렬은 바이트 순서(결정적)다.
- **숫자 표시.** 보간은 가장 짧은 왕복 10진수(워크벤치와 같음)이고, `{price:.2}`처럼 형식을 적을 수 있다.
- **표시 폭은 text의 일이 아니다.** 터미널 칸 수(동아시아 폭), 글꼴 shaping, 양방향 텍스트는 Vega 표면이 맡는다.

## 단계

| 단계 | 들어가는 것 | 합격 기준 |
|---|---|---|
| v0a ✅ | 스칼라 상태, 키 입력, 핸들러, 보기 | 워크벤치 엔진과 비트 단위 일치 |
| v0b ✅ | `bytes`, 키별 영속 문서, `message` 입력, 응답, `require`, `text`(리터럴·보간·`cursor in`·`char`/`line`), `text` 입력 | 기존 편집기 CLI와 바이트 단위 일치, text 편집이 참조 모델과 일치 |
| v0c | `record`, `list of`, `index of`, 조건식, 항목 투영(`each`), 순수 `fn`, `use` 라이브러리, 집계, 키 있는 리스트(`map`의 자리), text 비교, 걷기(`for`)와 메시지의 행, `bool`, `i64` ✅ | 모든 연산에 실행량 상한, 리스트가 참조 모델과 일치 ✅ |
| v0d ✅ | `use vega`로 첫 인터페이스 투영(목록·표·커서·톤), [Vega 001](/srv/git/vega.git) | 한 명세가 세 표면(TUI·CLI·NUI 문장)에서 동작, 표면은 보이는 칸만 계산 |

### 구현된 text (v0b)

`examples/notes.aup`가 작은 편집기 전체다. 위 문법 중 다음을 구현했다.

- `text` 상태·문자열 초기값, 문자열의 이스케이프와 `{보간}`(수와 text), `input 이름 = text`
- `cursor in 이름`, `char`/`line` 거리, `.start`/`.end`, `insert`/`remove`, `.line(커서)`
- text의 `.chars`/`.lines`/`.bytes`, 커서의 `.chars`(처음부터 센 글자 수)와 `.line`(줄 번호)
- `text(bytes)`/`bytes(text)`. 응답과 문서 `replace`는 text를 UTF-8 바이트로 받는다.

`char`는 Unicode scalar다. 여러 줄 문자열, `{price:.2}` 같은 형식 지정, grapheme과 `word`는 아직 없다.
삽입 위치에 있던 커서는 모두 삽입한 글자 뒤로 간다(입력 커서와 표시가 같은 규칙). 보간 안에는 문자열을 쓸 수 없다.

검증: 무작위 입력(ASCII·한글·이모지·결합 문자·NUL·줄바꿈)과 이동·지우기 4,800단계에서 본문, 커서, 표시,
보기가 Python 참조 모델과 같다. 이스케이프와 숫자 표시(`1e+21`, `0.30000000000000004`, `2.5e-7`, `-0`은 `0`)를
확인한다. 컴파일러와 Pure 실행기를 각각 sanitizer로 빌드해 같은 검사를 통과했다.

### 구현된 레코드·리스트 (v0c-1)

`examples/tasks.aup`가 할 일 목록 전체다.

```text
record Task {
  title: text                         # text 필드. 기본값은 "" (title: text = "..." 로 바꾼다)
  done = 0                            # 수 필드와 기본값
}

state tasks: list of Task             # 런타임이 소유하는 항목 목록
state at: index of tasks              # 항상 tasks의 항목을 가리키는 위치

on typed { tasks.append(Task { title: typed }) }         # 빠진 필드는 기본값
on toggle.down { tasks[at].done = !tasks[at].done }
on drop.down { tasks.remove(at) }
on again.down { tasks.insert(at, Task { title: tasks[at].title }) }
on down.down { at = at + 1 }                              # 마지막 항목에서 멈춘다

view current = tasks[at].title
view status = "{at.position + 1}/{tasks.count}"
```

- **위치는 정수가 아니다.** 커서처럼 `index of 리스트` 타입을 둔다. 인덱스는 항상 그 리스트의 항목을
  가리키고(빈 리스트면 0), 앞에 넣거나 지워도 같은 항목을 따라가며, 가리키던 항목이 지워지면 다음 항목
  (없으면 마지막)으로 간다. `at + n`, `at - n`은 처음과 마지막에서 멈춘다. `.first`/`.last`, `.position`(앞 항목 수).
- **다른 리스트의 인덱스는 컴파일 오류다.** `ys[at]`처럼 `xs`의 인덱스로 `ys`를 읽는 모듈은 Pure도 로드 단계에서 거부한다.
- **항목 읽기는 실패할 수 있다.** 빈 리스트의 `tasks[at]`은 이벤트를 `not_found`로 실패시킨다(아무것도 바뀌지 않음).
  보기는 그때 값이 없다(`pure-run`은 `null`).
- **변경은 되돌릴 수 있다.** 리스트 변경은 이벤트마다 기록되고, 이벤트가 실패하면 모두 되돌린다.
- **레코드를 바꿔도 데이터가 남는다.** 모듈을 교체할 때 리스트는 이름으로 남고, 레코드가 바뀌었으면 항목마다
  이름과 타입이 같은 필드를 옮기고 새 필드는 기본값을 쓴다. 마이그레이션 스크립트가 필요 없다.
- 레코드 값(`Task { ... }`)은 `append`/`insert`/`add`의 인자와 항목 전체 대입(아래 "펼치기와 항목 전체")에 쓴다. 레코드
  상태는 없다. 찾기는 `first(...)`(조건)와 키 있는 리스트의 `xs[키]`(키)로 한다.

한도: 레코드 16개, 레코드당 필드 16개, 전체 필드 128개, 리스트당 항목 1,600만 개(2^24).

검증: 무작위 추가·이동·토글·복사·이름 바꾸기·지우기 4,800단계(빈 리스트에서 실패하는 이벤트 약 500건 포함)에서
리스트, 인덱스, 보기, 실패 여부가 Python 참조 모델과 같다. 필드를 더하고 순서를 바꾼 레코드로 교체해도 항목이
남는다. 진단 25건을 더했다.

### 구현된 조건식과 표 보기 (v0c-2)

```text
view current = if tasks.count > 0 { at.title } else { "(empty)" }

view board = each task in tasks {        # 항목마다 한 행
  title = task.title                     # task는 그 행의 인덱스
  mark = if task.done { "x" } else { " " }
  here = task.position == at.position
}
```

- **조건식.** `if 조건 { 값 } else { 값 }`, `else if` 연쇄. `else`는 필수이고 모든 가지의 타입이 같아야 한다
  (커서·인덱스는 같은 text·리스트). 고른 가지만 계산하므로 `if x > 0 { 10 / x } else { 0 }`은 실패하지 않는다.
  보기·핸들러·식 안 어디서나 쓰며, 값을 가진 채 8단계 넘게 중첩하면 컴파일 오류다(Pure 검증기의 합류 한도).
- **인덱스로 필드 읽기·쓰기.** 인덱스가 자기 리스트를 알므로 `at.title`, `at.done = 1`이 `tasks[at].title`과 같다.
  그래서 레코드 필드 이름으로 `position`은 쓸 수 없다.
- **표 보기.** `view 이름 = each 행 in 리스트 { 열 = 값 ... }`는 리스트 항목마다 행 하나를 낸다. 열은 수나 text이고
  행 이름(인덱스)과 다른 상태를 읽는다. 호스트는 보여 줄 행의 칸만 Pure에 물어 계산한다. 목록을 화면 모델로 복사하거나
  보이는 범위를 앱이 계산하는 코드(가상 스크롤)가 필요 없다. 열 이름은 표 안에서만 유일하면 된다.
- **걸러진 표.** `view open_list = each t in tasks where !t.done { label = t.title }`는 조건이 참인 항목마다 행
  하나를 낸다(리스트 순서 그대로). 열의 행 이름은 여전히 항목이라 `t.position`은 리스트 안의 위치다. Pure가 상태가
  바뀐 뒤 한 번 걸러 행과 항목의 대응을 두므로, 열린 일·끝난 일처럼 같은 리스트를 여러 목록으로 보여도 상태를
  둘로 나눠 맞출 필요가 없다. 조건 안에는 집계를 둘 수 없다.
- 목록·표·포커스·톤 같은 인터페이스 어휘는 여전히 aup에 없다. Vega가 이 투영 위에서 제공한다.

검증: 표 열과 조건 보기를 더한 할 일 목록이 참조 모델과 4,800단계에서 같고, 고르지 않은 가지가 계산되지 않음을 확인한다.
7단계 중첩은 컴파일되고 Pure가 받아들이며, 8단계는 aup가 거부한다(Pure도 거부하는 깊이). 진단 16건을 더했다.

### 집계: count, sum, max, min

```text
view open = count(t in tasks where !t.done)
view points = sum(t.points for t in tasks)
view best = max(p.score for p in players where p.active)
view summary = "{count(t in tasks where !t.done)} open of {tasks.count}"
```

- **찾기.** `first(항목 in 리스트 [where 조건])`은 조건이 참인 첫 항목의 인덱스다(그 리스트의 인덱스 타입이라
  `at = first(t in tasks where t.title == name)`, `first(…).done = 1`, `first(…).position`으로 쓴다). 없으면 보기는
  비고 핸들러는 `not_found`로 실패한다. 찾으면 거기서 멈춘다.
- **모양.** `count(항목 in 리스트 [where 조건])`, `sum|max|min|distinct(값 for 항목 in 리스트 [where 조건])`. `distinct`는
  포함된 항목의 서로 다른 값의 수다(SQL의 `count(distinct …)`). 값을 둘 받는
  `max(a, b)`·`min(a, b)`는 그대로 호출이고, 같은 이름의 fn(`fn sum(a, b)`)도 그대로 부를 수 있다. 첫 식 뒤의
  `for`, 또는 `count(`의 `이름 in`으로 가른다.
- **의미.** 항목마다 블록을 계산해 센다(조건이 참인 항목), 더한다, 가장 큰·작은 값을 고른다. 포함된 항목이 없으면
  count·sum은 0이고 max·min은 값이 없다(보기는 비고, 핸들러는 `not_found`로 실패).
- **블록은 표의 열처럼 돈다.** Pure의 FOLD가 뒤따르는 블록을 항목마다 열로 실행한다. 블록은 자기 항목과 상태만
  읽는다. 바깥 fn의 매개변수, 둘러싼 열의 행, `dt`, 메시지 필드는 쓸 수 없다(쓰면 컴파일 오류로 이유를 알린다).
  집계 안에 집계는 없다. 그래서 비용은 블록 길이 × 항목 수로 한정되고, 목록의 사본이나 집계용 상태가 필요 없다.

검증: 무작위 추가·토글·이동·삭제 3,600단계에서 네 집계(조건 있는 것 포함, 빈 max·min, 보간 안의 집계)가 파이썬
참조 모델과 같다. 진단 7건(중첩, 리스트 아님, text 합, 열의 행, 바깥 매개변수, `dt`, `in` 누락)을 더했다.

### 키 있는 리스트와 text 비교

```text
record Tally {
  word: text
  times = 0
}
state tallies: list of Tally by word      # 단어 순서, 단어마다 하나

on typed {
  if !tallies.has(typed) {
    tallies.add(Tally { word: typed })    # 키 자리에 넣는다. 이미 있으면 이벤트가 exists로 실패
  }
  tallies[typed].times = tallies[typed].times + 1
  at = tallies[typed]                     # 키로 찾은 인덱스
}
on forget.down { tallies.remove(at) }     # 인덱스로도, tallies.remove("pear")처럼 키로도 지운다
view later = count(t in tallies where t.word > "m")
```

- **맵을 따로 두지 않는다.** `list of 레코드 by 필드`는 항목을 그 필드의 순서(수는 값, text는 바이트)로 두고 키마다 하나만
  둔다. 맵, 집합, 정렬된 맵이 한 리스트에 순서 규칙 하나를 더한 것이 된다. 그래서 `each`·걸러진 표·집계·인덱스·저장·
  교체·Vega 목록이 모두 그대로 쓰이고, 목록은 늘 정렬돼 보인다.
- **키로 찾는다.** `xs[키]`는 반씩 나눠 찾은 항목이다(`xs[키].필드`로 읽고 쓰며, `xs[키]`만 쓰면 그 인덱스). 없으면
  `not_found`로 실패하므로 있을지 모를 때는 `xs.has(키)`를 먼저 묻는다. `xs[인덱스]`도 그대로 쓴다.
- **넣기는 `add`다.** 자리를 고르지 않으므로 `append`와 `insert`는 키 있는 리스트에 쓸 수 없고(컴파일 오류가 `add`를
  알려 준다), `add`는 키 없는 리스트에 쓸 수 없다. 같은 키가 있으면 이벤트가 `exists`로 실패한다(아무것도 바뀌지 않음).
- **키는 바뀌지 않는다.** 키 필드에 쓰면 컴파일 오류다(지우고 다시 넣는다). 그래서 순서는 넣기로만 만들어진다.
- **리스트가 키를 얻으면.** 모듈을 바꾸거나 다시 시작할 때 키 없던 리스트가 키를 얻으면 Pure가 키 순서로 다시 놓고
  인덱스는 자기 항목을 따라간다. 같은 키가 둘이면 모듈을 받지 않는다. 데이터를 버려 맞추지 않는다.
- **text 비교.** `==`, `!=`, `<`, `<=`, `>`, `>=`가 text끼리도 된다. 바이트 순서다(UTF-8에서는 코드 포인트 순서와 같다).
  정규화하지 않는다(`é`의 두 표기는 다르다). 로케일 정렬은 앞으로의 모듈 몫이다.

검증: 무작위 3,600단계(실패 이벤트 약 670건 포함)에서 text 키 리스트(빈 text, 한글, 이모지, 앞이 같은 키)와 수 키
리스트의 항목·인덱스·보기·실패 종류가 파이썬 모델과 같다. 진단 13건을 더했다. `examples/tally.aup`가 예제다.

### 구현된 fn과 use (v0c-3)

```text
# examples/words.aup: 상태 없이 fn만 가진 라이브러리
fn plural(count, one, many) = if count == 1 { one } else { many }
fn counted(count, one, many) = "{count} {plural(count, one, many)}"

# examples/tasks.aup
use words
view summary = words.counted(tasks.count, "task", "tasks")
```

- **fn은 식 하나다.** `fn 이름(매개변수, ...) = 값`, 매개변수가 없으면 `fn limit = 3`(상수처럼 `limit`으로 쓴다).
  매개변수에 타입을 적지 않는다. 부르는 곳마다 인라인하므로 수·text·커서·인덱스 무엇이든 받는다. 본문의
  타입 오류는 본문 위치에 `(in fn 'f' called at 줄:열)`을 붙여 알린다.
- **런타임 호출이 없다.** 인자는 부를 때 한 번 계산해 스택에 두고, 본문은 Pure의 PICK으로 읽고 SLIDE로 치운다.
  인자를 여러 번 써도 다시 계산하지 않으며, 쓰지 않는 인자도 계산한다(값 전달). 재귀는 컴파일 오류다.
  그래서 실행량 상한과 로드 전 검증이 그대로 유지된다.
- **use는 라이브러리를 가져온다.** `use words`는 소스 옆의 `words.aup`, 없으면 `AUP_PATH`(`:`로 구분)의 디렉터리에서
  찾는다. 라이브러리는 `fn`, `record`(아래), `use`만 선언하고 앱의 상태를 볼 수 없다(매개변수만 읽는다). 부를 때는
  `words.counted(...)`처럼 라이브러리 이름을 붙인다. 순환 use는 오류이고, 라이브러리의 오류는 그 파일 경로로 알린다.
- Vega는 이 자리에 들어간다. 인터페이스 어휘를 fn과 표 보기 위의 규약으로 제공하는 aup 라이브러리다.

한도: fn 64개, 매개변수 8개, 라이브러리 8개.

검증: 라이브러리를 쓴 할 일 목록이 참조 모델과 일치하고, 인자를 한 번만 계산하며(값 전달), 라이브러리 경로·순환·
상태 선언·앱 상태 읽기 오류가 해당 파일 위치로 보고된다. 같은 소스와 라이브러리는 같은 모듈을 만든다. 진단 10건을 더했다.

### 라이브러리의 레코드 (구현)

[외부 리뷰 005](/srv/git/pure.git)의 (4)에서 시작한다. 라이브러리는 `fn`과 `use`만 선언할 수 있어서, 레코드는 앱의 파일
하나에 모여야 했다. 같은 저장소를 읽는 두 번째 모듈(옮기기 스크립트, 질의)은 레코드를 베껴 써야 했다. 상태는 앱 하나의
것이지만(원칙 2), 레코드는 상태가 아니라 형태다(원칙 3: 형태는 한 번만 선언한다).

```text
# store.aup: 레코드와 fn을 가진 라이브러리
record User { name: text, points = 0 }
fn rank(points) = if points > 100 { "gold" } else { "plain" }

# main.aup
use store
state users: list of store.User by name
input join = message { name: text }
on join { users.add(store.User { name: join.name }) }
view golds = count(u in users where store.rank(u.points) == "gold")
```

- **라이브러리는 `record`도 선언한다.** 여전히 상태, 입력, 보기, 핸들러, 출력은 선언하지 않는다.
- **쓸 때는 라이브러리 이름을 붙인다.** `list of store.User`, `rows: list of store.User`, `store.User { ... }`. fn과 같다.
  라이브러리 안에서는 제 레코드를 이름만으로 쓴다.
- **모듈에는 선언한 이름으로 들어간다.** Pure 모듈의 레코드 이름은 식별자이고, 상태·입력·보기와 한 이름공간이다(Pure
  `core/module.c`). 그래서 `store.User`는 모듈에서 `User`다. 레코드를 앱에서 라이브러리로 옮겨도 모듈이 같다. 같은 저장소를
  읽는 다른 모듈도 같은 레코드를 같은 이름으로 갖는다. 한 모듈에 같은 이름의 레코드 둘(앱의 것과 라이브러리의 것, 두 라이브러리의
  것)은 컴파일 오류다.
- **쓰는 레코드만 모듈에 들어간다.** 앱이 선언한 레코드는 지금처럼 모두 들어간다. 라이브러리의 레코드는 앱의 리스트 상태나
  메시지의 행 필드가 처음 쓸 때 들어간다. 레코드 많은 라이브러리(Vega)를 써도 쓰지 않는 레코드는 모듈의 자리를 차지하지 않는다.
- **레코드 값은 선언으로 맞춘다.** `store.User { ... }`는 `list of store.User`에만 들어간다. 다른 라이브러리의 같은 이름 레코드와
  섞이지 않는다.

착수 기준:

- 바꿀 결정: 라이브러리가 레코드를 선언하게 할지. 아래를 채우지 못하면 레코드는 앱의 파일에만 둔다.
- 최소 확인(`make check`):
  - 레코드를 라이브러리로 옮긴 할 일 앱(`examples/tasks.aup`)이 옮기기 전과 바이트 단위로 같은 모듈을 만든다.
  - 레코드 셋을 가진 라이브러리에서 하나만 쓰는 앱의 모듈에는 레코드가 하나다.
  - 앱이 저장소에 쓴 항목을, 같은 라이브러리를 쓰는 두 번째 모듈(옮기기)이 같은 저장소에서 읽고 바꾸고, 앱이 다시 읽는다.
  - 진단: 라이브러리 레코드를 이름만으로 쓰기, 라이브러리에 없는 레코드, 같은 이름의 레코드 둘, 라이브러리 레코드와 앱의
    상태 이름이 겹침, 다른 레코드의 값을 리스트에 넣기, 라이브러리의 상태 선언(지금처럼 오류).
  - aup, Pure, Vega의 검사가 모두 초록이다.

검증(`tests/check.py`의 `library_records`): 레코드를 라이브러리로 옮긴 할 일 앱이 옮기기 전과 같은 모듈(2,509바이트)을
만든다. 레코드 셋을 가진 라이브러리에서 하나를 쓰는 앱의 모듈에는 레코드가 하나다. 앱이 `pure-run --store`로 사용자 셋을
넣고, 같은 라이브러리를 쓰는 옮기기 모듈이 같은 저장소에서 사용자마다 점수 10을 더하고, 앱이 다시 열어 30을 본다. 라이브러리
레코드의 행 필드를 가진 메시지를 `pure-serve`에 JSON으로 보내면 행이 들어온다. 위의 진단 여섯과 함께 같은 이름의 레코드가 두
라이브러리에서 올 때, 다른 레코드의 값, 라이브러리 안의 이름 겹침, 키 있는 리스트의 힌트(`users.add(store.User { ... })`처럼
라이브러리 이름을 붙여 알린다) 진단을 더했다(12건).

한도: 라이브러리들이 선언하는 레코드는 모두 합쳐 64개다(모듈에는 그 가운데 쓰는 것만, 앱의 것과 합쳐 16개까지).

- 레코드 이름을 모듈에서 앞에 붙이지 않는 까닭은 Pure다. 모듈의 레코드 이름은 점이 없는 식별자이고 상태와 한 이름공간이다.
  `store.User`를 `store_User` 같은 이름으로 바꿔 넣으면 레코드를 옮길 때 모듈이 바뀌고, 같은 저장소를 읽는 질의(이름까지 같아야
  한다, Pure `same_states`)가 앱과 맞지 않게 된다.
- 값을 이름이 아니라 선언으로 맞추는 까닭: 쓰이지 않아 모듈에 없는 다른 라이브러리의 같은 이름 레코드가, 이름만 같아서 리스트에
  들어가는 일을 막는다.

### 상태의 수명: transient (구현)

Pure [018](/srv/git/pure.git)의 1단계다. 지금까지 모든 `state`는 저장됐다. 접속 수, 캐시, 계산의 중간값처럼 다시 시작한 뒤에는
뜻이 없는 상태도 커밋마다 저장소에 썼다. 이제 값의 수명은 선언이 말한다.

```text
state visits = 0                         # 저장한다: 다시 시작해도 남는다
transient state online = 0               # 앱이 도는 동안만: 저장하지 않고, 다시 시작하면 초기값
transient state recent: list of Hit
transient state status: text = "idle"
transient state picked: index of tasks   # 저장되는 리스트의 저장되지 않는 선택
on tap.down {
  let n = online + 1                     # 이벤트의 값: 이벤트가 끝나면 없다
  online = n
}
```

| | 실패한 이벤트 | 저장소 | 다시 시작하면 | 모듈을 바꾸면 |
|---|---|---|---|---|
| `state` | 되돌린다 | 쓴다 | 저장된 값 | 이름으로 잇는다 |
| `transient state` | 되돌린다 | 쓰지 않는다 | 초기값 | 이름으로 잇는다 |
| `let` | 버린다 | 없다 | — | — |

- **`transient state`** 는 모든 상태 타입에 쓴다(수, bool, i64, text, 커서, 리스트, 인덱스). 보기, 이벤트, 되돌리기, 메모리 예산,
  스냅숏의 읽기에서는 다른 상태와 같다. 다른 것은 저장소뿐이다: 그것만 바꾼 이벤트는 저장소에 아무것도 쓰지 않는다.
- 저장되는 커서나 인덱스는 transient text나 리스트를 가리키지 못한다(다시 시작하면 가리키던 것이 비어 있다). transient 커서나
  인덱스는 저장되는 것을 가리켜도 된다.
- 모듈의 상태 타입 워드에 비트 하나다(Pure 008). transient가 없는 앱의 모듈은 바이트 그대로 지금과 같다.
- 모듈을 바꿀 때(`aup watch`, 호스트의 SIGHUP) 저장되던 상태를 transient로, 또는 그 반대로 바꿀 수 있다. 저장소에 남은 옛 값은
  버린 상태의 것처럼 다룬다.
- `transient`는 예약어다.

검증(`tests/check.py`의 `transients`와 진단 5건, Pure의 `test-module`과 `server_transient`): 같은 앱의 transient 판과 저장 판의
모듈이 상태 타입 워드 세 바이트만 다르다. 같은 저장 이벤트(`tap`) 사이에 transient만 바꾸는 이벤트와 실패하는 이벤트를 섞은 실행의
저장소가, 섞지 않은 실행의 저장소와 파일과 바이트까지 같다. 다시 열면 transient는 초기값이고 저장되는 것은 그대로다. 달리는 동안
모듈을 바꿔 저장되던 것을 transient로, transient를 저장되는 것으로 바꾸면 다시 열었을 때 바뀐 대로다. `pure-serve`에서 transient만
바꾸는 요청 100개가 저장소의 어떤 파일도 바꾸지 않고, `kill -9` 뒤에 다시 띄우면 transient는 초기값이다.

### 메모리 안의 계산 (구현)

Pure [018](/srv/git/pure.git)의 2단계이고, [외부 리뷰 005](/srv/git/pure.git)의 (1)에서 시작한다. 반복이 리스트의 항목을 도는
걷기 하나뿐이고 걷기 안에 걷기가 없으며 이벤트 동안만 쓰는 리스트가 없어서, 그래프 탐색이나 수치 반복을 aup로 쓸 수 없었다.

```text
on route {
  let seen: list of Seen by node         # 이벤트의 리스트: 비어서 시작하고, 이벤트가 끝나면 없다
  let queue: list of Step
  queue.append(Step { node: route.from })
  seen.add(Seen { node: route.from })
  for i in 0 .. nodes.count {            # 세는 반복: 0부터 nodes.count 앞까지
    if i >= queue.count { break }        # 가장 안쪽 for를 끝낸다
    let q = queue.first + i              # 자리로 읽는 큐
    for e in edges[q.node * 1048576 .. q.node * 1048576 + 1048576] {   # for 안의 for
      if !seen.has(e.to) {
        seen.add(Seen { node: e.to, depth: seen[q.node].depth + 1 })
        queue.append(Step { node: e.to })
      }
    }
  }
  hops = if seen.has(route.to) { seen[route.to].depth } else { -1 }
}
```

- **`let 이름: list of 레코드 [by 필드]`** 는 이벤트의 리스트다. 핸들러의 맨 바깥 블록에 둔다. 상태 리스트가 하는 것을 모두 한다
  (`add`, `append`, `insert`, `remove`, `[키]`, `has`, `count`, `first`, `last`, `+ n`, 집계, `for`). 이벤트가 시작할 때 비어 있고
  끝나면 비워진다. 저장되지 않고 되돌리기를 기록하지 않는다. 모듈에서는 `let.이름`이라는 이벤트의 리스트 상태다(Pure 008의 행
  목록과 같은 플래그). 같은 이름의 리스트를 쓰는 핸들러들은 그 상태 하나를 함께 쓰므로 레코드와 키가 같아야 하고, 한 이벤트의 두
  핸들러가 같은 이름을 쓰지는 못한다. 상태 32개 안에 든다.
- **`for 이름 in 낮음 .. 높음 { }`** 은 이름을 낮음부터 높음 앞까지 1씩 올리며 돈다. 둘 다 수(정수: 아니면 `limit`)이거나 하나라도
  i64면 i64다. 이름은 읽기만 한다. `for x in 리스트`(걷기)와는 `in` 뒤가 리스트 이름이고 `{`·`[`·`where`가 따르는지로 가른다.
- **`break`** 는 가장 안쪽 `for`(걷기든 세기든)를 끝낸다.
- **중첩.** `for` 안에 `for`를 8겹까지 쓴다. 바깥 걷기의 항목은 안에서 읽는다(컴파일러가 안쪽 걷기 앞에서 인덱스 지역 칸에 담는다).
  안쪽 `for`는 바깥 걷기가 도는 리스트에 넣거나 그 리스트에서 지우지 못한다(가장 안쪽 걷기는 지금처럼 자기 리스트에서 지울 수 있다).
- **끝.** 모든 반복은 시작할 때 횟수의 상한이 정해진다. 끝을 모르는 계산은 상한을 적은 세기와 `break`로 쓴다(BFS는 노드 수,
  DFS와 다익스트라는 간선 수 + 1). 그래도 이벤트마다 실행량 예산(정하지 않으면 2^28 명령)과 메모리 한도(2^28 바이트, 예산이
  있으면 예산)가 있어서 고장 난 반복은 `work`나 `memory`로 끝나고 아무것도 바꾸지 않는다.
- `break`는 예약어다.

예제: `examples/routes.aup`(너비 우선, 깊이 우선, 무게로 가장 가까운 길), `examples/numbers.aup`(뉴턴의 제곱근, 배낭 문제).

검증(`tests/check.py`의 `computing`과 진단 14건, Pure의 `test-module`의 `loops`와 `server_runaway`): 무작위 그래프 20개의 질의마다
세 탐색이 Python 모델과 같다(600번). 뉴턴의 제곱근 200개가 같은 순서로 계산한 Python의 binary64와 비트까지, 단계 수까지 같다. 배낭
문제 100개가 동적 계획법 모델과 같다. 이벤트가 끝난 뒤 이벤트의 리스트는 모두 비어 있다.

### 계산하는 보기 (구현)

Pure [018](/srv/git/pure.git)의 3단계다. 핸들러의 계산은 소유자 스레드에서 돌아 그동안 다른 이벤트를 받지 못한다. 상태를 바꾸지 않는
계산(경로 찾기, 통계)은 보기가 맞는 자리다: 보기는 스냅숏을 읽는 연결 스레드에서 돈다.

```text
view distance(start: u32, goal: u32) {
  let seen: list of Seen by node
  let queue: list of Step
  queue.append(Step { node: start })
  seen.add(Seen { node: start })
  for i in 0 .. nodes.count {
    if i >= queue.count { break }
    let q = queue.first + i
    for e in edges[q.node * 1048576 .. q.node * 1048576 + 1048576] {
      if !seen.has(e.to) {
        seen.add(Seen { node: e.to, depth: seen[q.node].depth + 1 })
        queue.append(Step { node: e.to })
      }
    }
  }
  return if seen.has(goal) { seen[goal].depth } else { -1 }
}
```

- **`view 이름(인자…) { 문장… return 값 }`** 은 핸들러처럼 문장을 갖는 보기다. `let`(값과 리스트), `for`, `break`, `if`를 쓰고, 마지막
  줄의 `return`이 보기의 값이다(수, bool, i64, text). `return`은 본문의 마지막 줄에만 있다.
- **본문은 상태를 바꾸지 않는다.** 바꾸는 것은 그 `let`과 자기 리스트(`let x: list of R`)뿐이다. `send`, `reply`, `require`, `now`, `dt`는
  없다. 평가마다 `let`은 새로 시작하고 리스트는 비어서 시작한다.
- HTTP로는 지금의 보기와 같다(`GET /views/distance?start=1&goal=5`). 읽는 연결마다 자기 리스트를 가지므로 여러 연결이 동시에 다른 인자로
  물어도 된다. 이벤트처럼 실행량 예산과 메모리 예산이 있다(넘으면 422 `work`, 503 `memory`).
- 표 보기의 열과 필터는 지금처럼 식 하나다.
- `return`은 예약어다.

예제: `examples/routes.aup`의 `distance`(인자를 받는 BFS)와 `reach`(첫 노드에서 닿는 노드 수와 가장 먼 거리).

검증(`tests/check.py`의 `computing`과 진단 8건, Pure의 `test-module`의 `view_bodies`와 `server_view_bodies`): `reach`가 무작위 그래프
20개의 모든 단계에서 모델과 같다. `pure-serve`에서 64연결이 동시에 물은 `distance` 1,280개가 모두 모델과 같다. 실행량과 메모리 예산을
넘는 본문은 `work`와 `memory`로 답한다.

### 시간

```text
state started = 0
state last = 0
input tick = every 1 second                 # ms, second, minute, hour (1 ms–24시간)
on tick {
  if started == 0 { started = now }         # now: 이 이벤트의 시각, 1970년부터 ms(UTC)
  last = now
}
view elapsed = floor((last - started) / 1000)
view clock = "{floor(last / 3600000) % 24}:{floor(last / 60000) % 60}:{floor(last / 1000) % 60}"
```

- **시간은 입력이다.** `now`는 핸들러에서만 읽는 이벤트의 시각이다. 호스트가 이벤트마다 정하고 스크립트는 아무 시각이나
  재현한다(`pure-run`의 `time MS`). 보기에서는 쓸 수 없다. 보기는 상태의 순수 함수라, 시계를 보이려면 타이머가 상태에 적는다.
- **타이머는 입력이다.** `input tick = every 1 second`는 호스트가 앱을 돌리는 동안 주기마다 틱을 넣는다. 틱은 키 이벤트처럼
  저장되는 이벤트다. 놓친 틱을 몰아서 넣지는 않는다.
- **시각을 나누는 셈.** `floor(x)`와 `x % y`(곱셈과 같은 우선순위)를 더했다. `%`의 결과는 나누는 수의 부호를 따른다(파이썬과
  같다). `-7 % 3`은 2다. 0으로 나누면 이벤트가 `divide`로 실패한다.

검증: 무작위 시각(정수·소수 ms, 앞뒤로)과 두 타이머의 틱, 키 3,600단계에서 상태와 보기가 파이썬 모델(`math.floor`, `%`)과 같다.
진단 9건을 더했다.

### 다시 빌드: aup watch

```sh
aup watch app.aup -o app.pmod     # 저장할 때마다 다시 빌드
vega tui app.pmod --watch         # 실행 중인 앱이 새 모듈로 바뀐다(상태는 이름으로 이어진다)
```

- **빌드는 읽은 바이트의 함수다.** 빌드는 연 경로와 찾아봤지만 없던 경로를 모두 기록하고, 거기서 읽은 바이트의
  digest를 함께 남긴다(소스, 쓴 라이브러리, 앞선 탐색 위치). 다시 빌드할 이유는 이 경로 중 하나가 다르게
  읽히는 것뿐이다. 파일 시각을 보지 않으므로 빌드와 기록 사이의 저장을 놓치지 않는다. 같은 바이트를 다시 저장하면
  아무 일도 없다. 오류가 난 라이브러리를 고치면 다시 빌드되고, 앞선 탐색 위치에 라이브러리가 새로 생겨도 다시
  빌드된다.
- **쓰는 중인 파일은 빌드하지 않는다.** 바뀐 내용은 50 ms 간격으로 두 번 연속 같게 읽힐 때 빌드한다. 편집기가
  제자리에 쓰는 도중의 빈 파일이나 앞부분도 컴파일될 수 있는데, 그런 모듈로 바꾸면 실행 중인 앱은 사라진 상태의
  값을 잃는다.
- **모듈은 통째로만 바뀐다.** 결과를 임시 파일에 쓰고 rename한다(`aup build`도 같다). 오류가 나면 마지막 모듈을
  그대로 두고, 결과마다 stderr에 한 줄(`aup: built app.pmod` 또는 진단)을 쓴다.

검증: 소스 수정, 라이브러리 수정, 라이브러리의 오류와 수정, 앞선 위치에 라이브러리가 생김, 같은 바이트 저장, 두
번에 나눠 쓴 저장 10회(앞부분만으로도 컴파일되는 소스)에서 빌드된 모듈이 매번 기대한 보기를 낸다. 두 번 읽기를
빼면 나눠 쓴 저장 검사가 8번 중 8번 실패한다.

### 걷기와 메시지의 행

```text
record Line {
  item = 0
  qty = 1                                # 빠진 필드는 기본값
  note: text
}
input order = message {
  customer: u32
  lines: list of Line                    # 메시지의 행: JSON에서는 객체의 배열
}

on order {
  for l in order.lines {                 # 행마다 한 번, 순서대로
    stock[l.item].left = stock[l.item].left - l.qty
    require stock[l.item].left >= 0      # 한 행이 실패하면 주문 전체가 실패한다
    log.append(Entry { item: l.item, qty: l.qty })
  }
  count = count + order.lines.count
}
on clean {
  for s in stock where s.left <= 0 {     # UPDATE … WHERE, DELETE … WHERE
    stock.remove(s)
  }
}
```

- **`for 항목 in 리스트 [where 조건] { … }`** 은 핸들러 안에서 리스트가 가진 항목마다 블록을 한 번씩 돈다(Pure의 EACH).
  항목은 그 리스트의 인덱스라서 `l.qty`로 읽고 `s.left = 0`으로 바꾸며 `stock.remove(s)`로 지운다. 지우면 다음 항목을
  건너뛰지 않는다. 걷는 리스트에 넣을 수는 없어서(다른 리스트나 `for` 뒤에는 된다) 걷기는 끝난다. 블록 안의 집계는 걷는
  항목을 읽을 수 있다(`sum(m.qty for m in order.lines where m.item == l.item)`). `for` 안의 `for`와 `reply`는 없다.
- **메시지의 행.** 메시지 필드의 타입으로 `list of 레코드`를 쓴다. 핸들러에서 `order.lines`는 그 메시지의 행을 담은 리스트다
  (`for`, 집계, `.count`·`.first`·`.last`). 그 핸들러 밖에서는 쓸 수 없다. 모듈에서는 `order.lines`라는 이름의 행 목록
  상태가 되고, 이벤트가 끝나면 비워지며 저장되지 않는다(Pure 008). HTTP로는 JSON 객체의 배열로 보내고, 행 필드 하나뿐인
  메시지는 행을 리스트의 저장 형식 바이트로 보내도 된다(대량 넣기). 출력은 행을 보내지 않는다.
- 이것으로 여러 행을 한꺼번에 바꾸는 트랜잭션(TPC-C의 주문처럼 행마다 재고를 줄이고 주문 행을 넣는 일)이 이벤트 하나다.
- 키 있는 리스트의 키를 범위로 묻는 `where`(`for l in lines where l.id >= start && l.id < start + 16`)와 집계, 표의 필터는 Pure가
  그 범위의 항목만 읽는다(키로 찾아서). 인덱스를 선언하지 않는다. 다만 집계와 걷기는 나머지 조건이 결함을 낼 수 없을 때만 그렇게
  하므로, 범위를 뜻으로 적으려면 **조각**을 쓴다: `lines[a .. b]`는 키가 a부터 b 앞까지인 항목들이다(`for l in lines[a .. b]`,
  `distinct(l.item for l in lines[a .. b] where stock[l.item].quantity < t)`). 조각 밖의 항목은 돌지 않으므로 그 조건이 결함을 낼
  수 있어도 된다. 수로 키를 둔 리스트에만 쓴다.

검증: 진단 9건(`for` 안의 `for`, 걷는 리스트에 넣기, `for` 안의 `reply`, 행을 보내는 출력, 핸들러 밖의 행, 값으로 쓴 행, 리스트가
아닌 것, 이미 선언된 항목 이름, 없는 레코드)과, 그룹 안의 행·`where`·걷는 항목을 읽는 집계·지우는 걷기를 쓴 앱을 Pure가
받아들이는 것. 실행은 Pure의 `walks`(모델과 대조한 주문·정리 3,000단계)와 `server_rows`(HTTP로 3만 행 한 요청, 무작위 주문
400개와 모델, 재시작)가 확인한다.

### let

```text
on order {
  let total = 0                          # 수
  for l in order.lines {
    let s = stock[l.item]                # 인덱스: 그 항목을 가리킨다
    s.left = s.left - l.qty
    total = total + l.qty * s.price      # let은 다시 줄 수 있다(같은 종류로)
  }
  revenue = revenue + total + sum(l.qty for l in order.lines where l.qty > total)
}
```

- **`let 이름 = 값`** 은 핸들러 안에서 수나 인덱스 하나를 블록 끝까지 이름 붙인다. 같은 종류의 값으로 다시 줄 수 있고(`total = …`),
  안쪽의 `for`와 집계에서 읽는다. 상태가 아니라 저장되지 않고 이벤트마다 새로 시작한다(Pure의 LOCAL_LOAD·LOCAL_STORE, 이벤트의 칸
  64개). 그래서 행마다 같은 항목을 다시 찾지 않고, 걷기 안에서 값을 모을 수 있다. text는 담지 않는다(text는 상태에 둔다).
- 이름은 선언된 이름, 블록 안의 다른 let, 걷는 항목과 겹칠 수 없다. 보기에는 let이 없다(보기는 식 하나다).

검증: 진단 9건과, 걷기 안의 합, 항목을 바꾸는 인덱스 let, 걷기 안의 let, 집계가 읽는 let, 가지마다의 let을 쓴 앱.

### 인자를 받는 보기

```text
view balance(aid: u32) = accounts[aid].balance
view richer(low: number) = count(a in accounts where a.balance > low)
view branch(b: u32, over: number) = each a in accounts where a.bid == b && a.balance > over {
  aid = a.aid
  share = a.balance / sum(x.balance for x in accounts where x.bid == b)
}
```

- **보기는 인자를 받는 함수다.** `view 이름(인자: 타입, …) = …`의 인자는 u32, i64, number, bool, bytes, text이고, 보기 안에서(표라면 그 필터와
  열, 그 안의 집계에서도) 이름으로 읽는다. HTTP로는 `GET /views/balance?aid=42`, 표는 `?b=1&over=0&from=20&count=20`이다. 등록한
  질의(`aup query`)도 인자를 받는다. 인자 이름은 상태나 다른 선언, 표의 행과 겹칠 수 없고 `from`·`count`는 쪽 나누기의 몫이다.
- 모듈에서는 `balance.args`라는 인자 입력이 보기를 가리키고, 보기는 메시지 핸들러가 필드를 읽듯 인자를 읽는다(Pure 008).
- 집계 안에서도 둘레의 인자와 메시지 필드를 읽는다. 그래서 핸들러의 `count(a in accounts where a.bid == pay.bid)`도 된다.

검증: 진단 8건(쪽 나누기 이름, 중복, 상태와 겹침, 표의 행과 겹침, 타입, 빈 괄호, 긴 이름, 보기 밖에서 쓴 인자)과 인자를 받는
보기들을 쓴 앱. HTTP는 Pure의 `server_arguments`가 모델과 대조한다.

구현은 C로 손수 쓴 재귀 하강 파서를 유지한다. 문법은 EBNF로 문서화하고, 이후 `aup fmt`와 언어 서버를 붙인다.

### 펼치기와 항목 전체

```text
record Card { title: text, lane = 0, note: text }
state cards: list of Card by title
output changed = message { title: text, lane: u32, note: text }
input remote = message like changed

on move.down {
  at.lane = at.lane + 1
  send changed { ..at }                  # 항목의 필드를 이름으로
}
on remote {
  if !cards.has(remote.title) { cards.add(Card { ..remote }) }
  else { cards[remote.title] = Card { ..remote } }   # 항목 전체: 모든 필드, 키는 그대로
}
```

- **`..source`** 는 레코드 값과 `send`의 중괄호 끝에 한 번 쓴다. 적지 않은 필드는 source의 같은 이름 필드에서 온다. source는
  항목(인덱스, `let`, `for`가 걷는 항목, `xs[키]`)이나 그 핸들러의 메시지다(그룹 안의 필드는 경로 이름으로 맞춘다). 레코드
  값에서 source에 없는 필드는 기본값이고, `send`에서는 지금처럼 모든 필드가 채워져야 한다(`send changed needs 'x'`). 적은
  필드가 먼저다: `send changed { note: "moved", ..at }`.
- **`xs[i] = Record { ... }`** 는 그 항목의 모든 필드를 바꾼다. 필드는 모두 주어야 한다(펼치기로 줘도 된다. 기본값으로 되돌리는
  일은 없다). 키 있는 리스트의 키는 바뀌지 않으므로, 값의 키가 항목의 키와 다르면 이벤트가 `limit`으로 실패하고 아무것도 바뀌지
  않는다.
- 둘 다 필드를 하나하나 적은 것과 같은 모듈을 낸다(검사가 바이트로 대조한다). 실행 비용이 따로 없다.
- 두 노드가 함께 쓰는 보드(Pure `docs/research/105-growing-a-shared-board.md`)에서 필드 하나를 더할 때 고칠 곳이 다섯에서 둘(레코드와
  출력)로 줄었다. 보내기와 적용은 필드를 이름으로 따라간다.

검증: 진단 7건(펼치기가 끝이 아님, 펼칠 수 없는 source, 핸들러 밖의 메시지, 그룹 안의 펼치기, 채워지지 않은 send 필드, 필드가
빠진 항목 전체, 레코드가 아닌 값). 펼치기와 항목 전체를 쓴 모듈과 필드를 적은 모듈이 바이트로 같다. `pure-run`에서 키가 다른 항목
전체 대입은 `limit`으로 실패하고 값을 바꾸지 않으며, 키가 같은 것은 바꾼다.