@vtecx/vtecxdocument 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/framework.md +203 -49
- package/package.json +1 -1
package/docs/framework.md
CHANGED
|
@@ -57,10 +57,10 @@ SDK メソッドごとに戻り値の型が異なる。`feed.entry` は SDK 内
|
|
|
57
57
|
エラー時は `{ feed: { title: string } }` 型が返る。
|
|
58
58
|
|
|
59
59
|
```typescript
|
|
60
|
-
const entries = await vtecxnext.getFeed(
|
|
60
|
+
const entries = await vtecxnext.getFeed("/crm/customer")
|
|
61
61
|
// entries は VtecxApp.Entry[](配列)。Array.isArray による正規化は不要
|
|
62
62
|
|
|
63
|
-
const entry = await vtecxnext.getEntry(
|
|
63
|
+
const entry = await vtecxnext.getEntry("/crm/customer/0000000001")
|
|
64
64
|
// entry は VtecxApp.Entry(単一オブジェクト)または null(204)
|
|
65
65
|
```
|
|
66
66
|
|
|
@@ -72,10 +72,10 @@ const entry = await vtecxnext.getEntry('/crm/customer/0000000001')
|
|
|
72
72
|
|
|
73
73
|
```typescript
|
|
74
74
|
// NG: リビジョン番号が混入する
|
|
75
|
-
const id = entry.id.replace(
|
|
75
|
+
const id = entry.id.replace("/crm/customer/", "") // → "0000000001,3"
|
|
76
76
|
|
|
77
77
|
// OK: パス部分のみ取り出す
|
|
78
|
-
const id = entry.id.split(
|
|
78
|
+
const id = entry.id.split(",")[0].replace("/crm/customer/", "") // → "0000000001"
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
---
|
|
@@ -98,13 +98,13 @@ const id = entry.id.split(',')[0].replace('/crm/customer/', '') // → "0000000
|
|
|
98
98
|
|
|
99
99
|
```typescript
|
|
100
100
|
// ページング一覧(顧客一覧など)
|
|
101
|
-
const entries = await vtecxnext.getPageWithPagination(
|
|
101
|
+
const entries = await vtecxnext.getPageWithPagination("/crm/customer?l=25", n)
|
|
102
102
|
|
|
103
103
|
// ページング不要一覧(件数が少ないサブリストなど)
|
|
104
|
-
const feed = await vtecxnext.getFeed(
|
|
104
|
+
const feed = await vtecxnext.getFeed("/crm/contact/0000000001")
|
|
105
105
|
|
|
106
106
|
// 1件取得(一覧→詳細遷移時、編集画面など)
|
|
107
|
-
const entry = await vtecxnext.getEntry(
|
|
107
|
+
const entry = await vtecxnext.getEntry("/crm/customer/0000000001")
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
---
|
|
@@ -154,7 +154,7 @@ vte.cx のページングは **カーソルリスト(pageindex)** を事前
|
|
|
154
154
|
#### 実装例
|
|
155
155
|
|
|
156
156
|
```typescript
|
|
157
|
-
const n = parseInt(vtecxnext.getParameter(
|
|
157
|
+
const n = parseInt(vtecxnext.getParameter("n") ?? "1", 10)
|
|
158
158
|
const uri = `/crm/customer?l=25`
|
|
159
159
|
|
|
160
160
|
const entries = await vtecxnext.getPageWithPagination(uri, n)
|
|
@@ -167,10 +167,10 @@ return vtecxnext.response(200, entries ?? null)
|
|
|
167
167
|
|
|
168
168
|
```typescript
|
|
169
169
|
type PaginationInfo = {
|
|
170
|
-
lastPageNumber:
|
|
171
|
-
countWithinRange: number
|
|
172
|
-
hasNext:
|
|
173
|
-
isMemorysort:
|
|
170
|
+
lastPageNumber: number // 作成済みカーソルの最終ページ番号(0 = データなし)
|
|
171
|
+
countWithinRange: number // 指定ページ範囲内のエントリ数
|
|
172
|
+
hasNext: boolean // true = 指定 endPage を超えるデータが存在する
|
|
173
|
+
isMemorysort: boolean // メモリソートモードかどうか
|
|
174
174
|
}
|
|
175
175
|
```
|
|
176
176
|
|
|
@@ -282,7 +282,7 @@ await vtecxnext.put({ feed: { entry: [entry] } })
|
|
|
282
282
|
|
|
283
283
|
```typescript
|
|
284
284
|
if (error?.status === 403) {
|
|
285
|
-
router.push(
|
|
285
|
+
router.push("/login")
|
|
286
286
|
}
|
|
287
287
|
```
|
|
288
288
|
|
|
@@ -441,24 +441,24 @@ const uid = await vtecxnext.uid()
|
|
|
441
441
|
```typescript
|
|
442
442
|
const uid = await vtecxnext.uid()
|
|
443
443
|
const userEntry = await vtecxnext.getEntry(`/_user/${uid}`)
|
|
444
|
-
const email: string = userEntry?.contributor?.[0]?.email ??
|
|
444
|
+
const email: string = userEntry?.contributor?.[0]?.email ?? ""
|
|
445
445
|
```
|
|
446
446
|
|
|
447
447
|
#### ユーザーエントリの構造(`/_user/{uid}`)
|
|
448
448
|
|
|
449
|
-
| フィールド
|
|
450
|
-
|
|
|
451
|
-
| `title`
|
|
452
|
-
| `subtitle`
|
|
453
|
-
| `summary`
|
|
454
|
-
| `contributor[0].email` | メールアドレス
|
|
449
|
+
| フィールド | 内容 |
|
|
450
|
+
| ---------------------- | ------------------------------------- |
|
|
451
|
+
| `title` | UID |
|
|
452
|
+
| `subtitle` | ニックネーム |
|
|
453
|
+
| `summary` | ステータス(`Activated` / `Interim`) |
|
|
454
|
+
| `contributor[0].email` | メールアドレス |
|
|
455
455
|
|
|
456
456
|
### アカウントの状態
|
|
457
457
|
|
|
458
|
-
| 状態
|
|
459
|
-
|
|
|
460
|
-
| 仮登録 | `Interim`
|
|
461
|
-
| 本登録 | `Activated` | 可
|
|
458
|
+
| 状態 | summary 値 | ログイン |
|
|
459
|
+
| ------ | ----------- | ---------------------------- |
|
|
460
|
+
| 仮登録 | `Interim` | 不可(確認メール未クリック) |
|
|
461
|
+
| 本登録 | `Activated` | 可 |
|
|
462
462
|
|
|
463
463
|
### サービス名の取得
|
|
464
464
|
|
|
@@ -471,8 +471,8 @@ const serviceName = await vtecxnext.service()
|
|
|
471
471
|
```typescript
|
|
472
472
|
const uid = await vtecxnext.uid()
|
|
473
473
|
const entry = {
|
|
474
|
-
link: [{ ___rel:
|
|
475
|
-
user: { displayName:
|
|
474
|
+
link: [{ ___rel: "self", ___href: `/crm/user/${uid}` }],
|
|
475
|
+
user: { displayName: "John Doe", role: "sales" },
|
|
476
476
|
}
|
|
477
477
|
await vtecxnext.put({ feed: { entry: [entry] } })
|
|
478
478
|
```
|
|
@@ -489,11 +489,11 @@ vtecxnext のパスワード変更には **2 つのフロー** がある。い
|
|
|
489
489
|
vtecxnext.changepass(newpswd: string, oldpswd?: string, passresetToken?: string)
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
| 引数
|
|
493
|
-
|
|
|
494
|
-
| `newpswd`
|
|
495
|
-
| `oldpswd`
|
|
496
|
-
| `passresetToken` | パスワードリセットトークン。メールリセットフローで使用
|
|
492
|
+
| 引数 | 説明 |
|
|
493
|
+
| ---------------- | ---------------------------------------------------------- |
|
|
494
|
+
| `newpswd` | 新しいパスワード(ハッシュ済み) |
|
|
495
|
+
| `oldpswd` | 現在のパスワード(ハッシュ済み)。ログイン済みフローで使用 |
|
|
496
|
+
| `passresetToken` | パスワードリセットトークン。メールリセットフローで使用 |
|
|
497
497
|
|
|
498
498
|
パスワードは送信前に `getHashpass(password)`(`@vtecx/vtecxauth`)でハッシュする。
|
|
499
499
|
|
|
@@ -522,11 +522,11 @@ vtecxnext.changepass(newpswd: string, oldpswd?: string, passresetToken?: string)
|
|
|
522
522
|
### API ルートの振り分けロジック
|
|
523
523
|
|
|
524
524
|
```typescript
|
|
525
|
-
const rxid = vtecxnext.getParameter(
|
|
525
|
+
const rxid = vtecxnext.getParameter("_RXID") ?? ""
|
|
526
526
|
if (rxid) {
|
|
527
527
|
await vtecxnext.loginWithRxid(rxid)
|
|
528
528
|
} else if (!data.oldpswd) {
|
|
529
|
-
return vtecxnext.response(401, { feed: { title:
|
|
529
|
+
return vtecxnext.response(401, { feed: { title: "Authentication error." } })
|
|
530
530
|
}
|
|
531
531
|
await vtecxnext.changepass(data.newpswd, data.oldpswd, data.passresetToken)
|
|
532
532
|
```
|
|
@@ -562,10 +562,10 @@ await vtecxnext.changepass(data.newpswd, data.oldpswd, data.passresetToken)
|
|
|
562
562
|
| `getGroups()` | サービス内の全グループ一覧取得 |
|
|
563
563
|
|
|
564
564
|
```typescript
|
|
565
|
-
await vtecxnext.addGroup(
|
|
566
|
-
await vtecxnext.joinGroup(
|
|
567
|
-
await vtecxnext.addGroupByAdmin([uid],
|
|
568
|
-
const isAdmin = await vtecxnext.isGroupMember(
|
|
565
|
+
await vtecxnext.addGroup("/_group/sales")
|
|
566
|
+
await vtecxnext.joinGroup("/_group/sales")
|
|
567
|
+
await vtecxnext.addGroupByAdmin([uid], "/_group/sales")
|
|
568
|
+
const isAdmin = await vtecxnext.isGroupMember("/_group/admin")
|
|
569
569
|
const groups = await vtecxnext.getGroups()
|
|
570
570
|
```
|
|
571
571
|
|
|
@@ -593,13 +593,19 @@ const existingLinks = (customer as any)?.link ?? []
|
|
|
593
593
|
await vtecxnext.put({
|
|
594
594
|
feed: {
|
|
595
595
|
entry: [
|
|
596
|
-
{
|
|
596
|
+
{
|
|
597
|
+
link: [{ ___rel: "self", ___href: `/crm/member/${uid}` }],
|
|
598
|
+
contributor,
|
|
599
|
+
}, // 親パス(先頭)
|
|
597
600
|
{
|
|
598
601
|
...(customer as any),
|
|
599
|
-
link: [
|
|
602
|
+
link: [
|
|
603
|
+
...existingLinks,
|
|
604
|
+
{ ___rel: "alternate", ___href: `/crm/member/${uid}/${cid}` },
|
|
605
|
+
],
|
|
600
606
|
},
|
|
601
|
-
]
|
|
602
|
-
}
|
|
607
|
+
],
|
|
608
|
+
},
|
|
603
609
|
})
|
|
604
610
|
```
|
|
605
611
|
|
|
@@ -615,17 +621,24 @@ const feed = await vtecxnext.getFeed(`/crm/member/${uid}`)
|
|
|
615
621
|
```typescript
|
|
616
622
|
const customer = await vtecxnext.getEntry(`/crm/customer/${cid}`)
|
|
617
623
|
const memberUids = ((customer as any)?.link ?? [])
|
|
618
|
-
.filter(
|
|
619
|
-
|
|
624
|
+
.filter(
|
|
625
|
+
(l: any) =>
|
|
626
|
+
l.___rel === "alternate" && l.___href?.startsWith("/crm/member/"),
|
|
627
|
+
)
|
|
628
|
+
.map((l: any) => (l.___href as string).split("/")[3])
|
|
620
629
|
```
|
|
621
630
|
|
|
622
631
|
### 削除
|
|
623
632
|
|
|
624
633
|
```typescript
|
|
625
634
|
const customer = await vtecxnext.getEntry(`/crm/customer/${cid}`)
|
|
626
|
-
const updatedLinks = ((customer as any)?.link ?? [])
|
|
627
|
-
|
|
628
|
-
|
|
635
|
+
const updatedLinks = ((customer as any)?.link ?? []).filter(
|
|
636
|
+
(l: any) =>
|
|
637
|
+
!(l.___rel === "alternate" && l.___href === `/crm/member/${uid}/${cid}`),
|
|
638
|
+
)
|
|
639
|
+
await vtecxnext.put({
|
|
640
|
+
feed: { entry: [{ ...(customer as any), link: updatedLinks }] },
|
|
641
|
+
})
|
|
629
642
|
```
|
|
630
643
|
|
|
631
644
|
### エイリアスパスの権限設定
|
|
@@ -633,8 +646,13 @@ await vtecxnext.put({ feed: { entry: [{ ...(customer as any), link: updatedLinks
|
|
|
633
646
|
エイリアスパスも `folderacls.json` に登録する。
|
|
634
647
|
|
|
635
648
|
```json
|
|
636
|
-
{
|
|
637
|
-
"
|
|
649
|
+
{
|
|
650
|
+
"contributor": [
|
|
651
|
+
{ "uri": "urn:vte.cx:acl:/_group/$admin,CURD" },
|
|
652
|
+
{ "uri": "urn:vte.cx:acl:+,CURDE" }
|
|
653
|
+
],
|
|
654
|
+
"link": [{ "___rel": "self", "___href": "/crm/member" }]
|
|
655
|
+
}
|
|
638
656
|
```
|
|
639
657
|
|
|
640
658
|
---
|
|
@@ -788,6 +806,142 @@ ${VTECXNEXT_URL}/change-password?${PASSRESET_TOKEN}</summary>
|
|
|
788
806
|
|
|
789
807
|
---
|
|
790
808
|
|
|
809
|
+
## データ構築の実践的注意点(同梱ドキュメント補足)
|
|
810
|
+
|
|
811
|
+
**実運用で踏みやすい落とし穴**をまとめる。
|
|
812
|
+
|
|
813
|
+
### 1. 親子関係のあるデータは「1トランザクション(単一feed・順序付き)」で登録する(最重要)
|
|
814
|
+
|
|
815
|
+
`post` / `put` / `postBDBQ` / `putBDBQ` は**キー(エンティティ種別)が異なるエントリも 1 つの feed にまとめて送れる**。フレームワークは feed を**上から順に**登録するので、親 → 子の順に並べれば 1 リクエストで親子を正しく構築できる。
|
|
816
|
+
|
|
817
|
+
```
|
|
818
|
+
1リクエストにまとめる(上から順に登録される):
|
|
819
|
+
/test
|
|
820
|
+
/test/0001
|
|
821
|
+
/_group/test_group
|
|
822
|
+
/_group/test_group/111
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
**絶対禁止 — 親子があるのに親と子を別リクエストで非同期・並列に投げる。** 子が親より先に走るケースがあり、`Parent path is required`や `Access denied.` が発生する。
|
|
826
|
+
|
|
827
|
+
```
|
|
828
|
+
NG(A と B を並列):
|
|
829
|
+
A(同期): /test, /test/0001, /_group/test_group
|
|
830
|
+
B(A と並列): /_group/test_group/111 ← A 前に走ると Access denied / 親無し
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
- `api/data.md` の `put(feed, isbulk?, parallel?, async?)` の **`parallel` / `async` フラグは「独立した兄弟データ」専用**。親子・依存関係があるエントリに対して使ってはいけない。
|
|
834
|
+
- 例: 顧客とその担当チームを一度に作る場合、グループ定義(`/_group/sales`)→ メンバー所属(`/_group/sales/{uid}`)→ 顧客本体(`/crm/customer/{cid}`)→ 配下フォルダ(`/crm/customer/{cid}/contact`)を**1 つの `put` feed**にまとめる。
|
|
835
|
+
|
|
836
|
+
> **親が登録済みと担保できる場合のみ**、大量の独立した子(例: `/test/0001`〜`/test/9999`)を非同期並列で投げて高速化してよい。
|
|
837
|
+
|
|
838
|
+
### 2. ACL 保護フォルダへの書き込みは「グループ所属を先に」同一feedへ
|
|
839
|
+
|
|
840
|
+
保護フォルダ(`contributor` にグループ ACL を持つ)配下にエントリを書くには、**書き込み時点でそのグループに所属済み**である必要がある。グループ定義 → メンバー所属 → 保護フォルダ → 保護フォルダ配下エントリ を**この順で同一feed**に積む。
|
|
841
|
+
|
|
842
|
+
- 例: `/crm/customer/{cid}/member` をグループ `/_group/sales` で保護している場合、所属付与(`/_group/sales/{uid}`)を配下エントリより**前**に同一 feed へ置く。
|
|
843
|
+
- 失敗例: 所属付与(実体なしエントリ)と保護フォルダ配下エントリ(実体ありエントリ)を**別リクエストで並列実行**すると、所属が確定する前に配下への書き込みが走り `Access denied. uri = /crm/customer/{cid}/member/{uid}` になる。→ 1 トランザクションに統一すれば解消する。
|
|
844
|
+
|
|
845
|
+
### 3. `putBDBQ` / `postBDBQ` を「実体なしエントリを含む親子feed」に使わない
|
|
846
|
+
|
|
847
|
+
- `putBDBQ` / `postBDBQ` は **BDB と BQ(BigQuery)を1トランザクション**で書く。ただし**エンティティ実体を持たないエントリ(フォルダ作成・グループ定義・メンバー所属・ACL/エイリアスのみ)は BQ に書けない**(`A value is required for the entry field.` エラー)。
|
|
848
|
+
- このため BDB+BQ を併用する目的で feed を「実体あり(BDB+BQ) / 実体なし(BDBのみ)」に分割して並列実行する実装をすると、**§1・§2 の親子順序が壊れる**。フォルダ/グループ/保護フォルダ配下を同時に作る初期構築には使わない。
|
|
849
|
+
- **使い分け**:
|
|
850
|
+
- 初期構築・親子・グループ/ACL を含む一括登録 → **`put`(単一順序トランザクション・BDBのみ)**。
|
|
851
|
+
- 親が担保済みで BQ 読みモデルを併用する通常業務データ(一覧・集計の対象)→ `putBDBQ`。
|
|
852
|
+
|
|
853
|
+
### 3.1 重複を防ぐ — まず「単一トランザクション(原子性)」、分割時のみ補償
|
|
854
|
+
|
|
855
|
+
vte.cx の 1 リクエスト(`put`/`putBDBQ`)は**原子的**。途中で失敗すれば**その feed は1件も登録されない**。**`addids` で採番する実体(=再実行で新採番=重複の温床)は、必ず 1 回の `put`(または `putBDBQ`)にまとめる。** こうすれば「失敗 → 何も残らない → 再実行で重複しない」が成立する。**分割(逐次 await の複数リクエスト)は重複・孤児の元**なので避ける。
|
|
856
|
+
|
|
857
|
+
**分割を減らす実装の要点:**
|
|
858
|
+
|
|
859
|
+
- **不要なフォルダ作成を feed に混ぜない**(フォルダは実体なし=`putBDBQ` に入れると BQ エラー → 分割の原因になる)。
|
|
860
|
+
- **静的なフォルダはサービス初期構築時に作成済み** → 都度の登録 API で作り直さない。
|
|
861
|
+
- **動的に増えるフォルダは事前に作らず、書き込み側が upsert で自己修復**(下記)。
|
|
862
|
+
- → 結果、本体エントリを **1 回の `putBDBQ`** だけで登録でき、原子的になる(途中失敗で 0 件)。
|
|
863
|
+
- **BDB のみで足りる子(BQ 不要なサブエンティティ)は、その親フォルダごと 1 回の `put`** にまとめる(順序付き・原子的)。
|
|
864
|
+
|
|
865
|
+
**それでも「BQ実体の親(`putBDBQ`)」と「BDB実体の子(`put`)」のように 1 トランザクションにできない場合**は、途中失敗で不整合が残る前提で次を満たす(前方回復):
|
|
866
|
+
|
|
867
|
+
1. **残った不整合があってもアプリが正常動作する**=**子の書き込み側が親フォルダを upsert で自己修復**する。例: 子エントリの POST 時、本体を書く前に `put([親フォルダのエントリ])` で親パスを担保しておく。
|
|
868
|
+
2. **再リクエストが冪等で成功する**=同一 self パスへの `put` は upsert、key 指定の `putBDBQ` も upsert。ただし **`addids` 採番の実体は再実行で新採番=重複** するため、分割が避けられない一括取り込みで重複を防ぐには**業務コードでの存在チェック**か**取り込みバッチ ID での冪等化**を設計する。
|
|
869
|
+
3. **ロールバック(補償削除)は delete 自体が失敗し得る**ため、**前方回復(自己修復+冪等再実行)を第一選択**にする。
|
|
870
|
+
|
|
871
|
+
### 3.2 一括取り込み(数千〜数万件)が破綻しない設計
|
|
872
|
+
|
|
873
|
+
大量インポートで**やってはいけない**のは「1件ごとにサーバーと往復」すること(行ごとの `addids`/行ごとの存在チェック/巨大 feed の1リクエスト)。次の3点で件数に対して線形・低往復にする:
|
|
874
|
+
|
|
875
|
+
1. **採番はまとめて予約する。** `addids(uri, 1)` を行数分ループすると**1万回の往復で破綻**する。必要数を先に数えて **`addids(uri, N)` を1回**呼び、返値(最終ID)から**ローカルで連番採番**する(`await` 不要)。採番に飛び(gap)が出ても問題ない(§6)。
|
|
876
|
+
2. **存在チェックは一括 1(〜数)クエリにする。** 行ごとの存在確認は禁止。**CSV の業務コードだけを `IN` 句**でまとめて照会し(件数が多ければ 1000 件ずつチャンク)、`Map<業務コード, key>` を作って**メモリで突合**する。コストは**既存データ全体の規模ではなく取込件数**に比例する(全件取得 `getFeed(uri + '?l=*')` は既存が多いと破綻するので避ける)。
|
|
877
|
+
3. **書き込みはチャンクに分ける。** 1万件を1 feed で送ると payload 超過・タイムアウトになる。**500件程度ずつ**に分割して逐次 `await` する。各チャンクは原子的なので、途中失敗時も登録済み分は **業務コードでスキップ(冪等)** すれば再取り込みが安全。
|
|
878
|
+
|
|
879
|
+
> **冪等化(既存データのスキップ)はスケール対策と一体。** チャンク書き込みは全体としては原子的でないため、「既存の業務コードはスキップ」して**部分失敗後の再インポートで重複を作らない**ようにする。これにより「①一括予約採番 → ②一括存在チェック → ③チャンク書き込み(+スキップ)」で 1万件でも線形・再実行安全になる。
|
|
880
|
+
>
|
|
881
|
+
> **自己参照する階層(ツリー)データも一括予約できる。** 「親 id を子に渡す」ため逐次に見えるが、①CSV 全体から**新規ノード数を数えて `addids(uri, N)` を1回**で予約 → ②ローカル採番で「名前 → id」を確定しつつ、親参照(`parent_id`)は**確定済みの親 id をマップから引く**(id 確定済みなので順序非依存)。ツリーを**フラットパス**(`/crm/category/{id}`・親子関係は `parent_id` フィールドで表現)で持てば**書き込みもチャンク可**になり、ノードが大量でも線形・低往復で取り込める。
|
|
882
|
+
|
|
883
|
+
### 3.3 BQ テーブルは「初回書き込み」で作成される — 読取の Not found を許容する
|
|
884
|
+
|
|
885
|
+
BigQuery のテーブルは**そのエンティティを初めて `putBDBQ`/`postBQ` した時に作成**される。よって**まだ1件も書いていないエンティティを `execBQ` で読む**と `Not found: Table ... was not found in location ...` で失敗する。
|
|
886
|
+
|
|
887
|
+
- **読取(存在チェック・一覧)ではこの例外を握りつぶし、「0件」として処理を続行**する。最初の書き込み後はテーブルができる。例: 一括取り込みの存在チェッククエリは `Not found: Table`(`/not found/i` かつ `/table/i`)を検知したら空の結果を返し、全件を新規として登録する。
|
|
888
|
+
- 書き込み(`putBDBQ`)はテーブルを自動作成するので握りつぶさない(本当のエラーを隠さない)。
|
|
889
|
+
- 同様に、初回データが無いエンティティに対して BQ 読みの一覧を叩く経路も、空表示にフォールバックできるようにする。
|
|
890
|
+
|
|
891
|
+
### 4. グループ管理者・グループ参加系は「別 API」。同一feedに混ぜられない
|
|
892
|
+
|
|
893
|
+
`createGroupadmin`・`addGroupByAdmin` / `leaveGroupByAdmin` などは**通常のデータ feed とは別のエンドポイント**。1 つの `put` feed には同梱できない。→ 依存がある場合は **親(グループ)の登録を `await` で確定してから逐次呼ぶ**(並列にしない)。
|
|
894
|
+
|
|
895
|
+
- 例: グループ作成後に管理者を割り当てる `createGroupadmin([{ group: '/_group/sales', uids: [uid] }])` は、グループを作る `put` の確定後に逐次実行する。
|
|
896
|
+
|
|
897
|
+
### 5. 削除は BQ 残留に注意 — `deleteFolder` は BDB のみ
|
|
898
|
+
|
|
899
|
+
- `deleteFolder` / `deleteEntry` は **BDB のみ**削除する。BQ に複製のあるエンティティ(`putBDBQ` で書いたもの)は **`deleteBDBQ`** を使わないと **BQ レコードが残留**し、再登録時に幽霊行・カラム衝突の原因になる。
|
|
900
|
+
- 配下を列挙してまとめて消す場合も、BQ 複製を持つ種別は `deleteBDBQ` を通す。
|
|
901
|
+
|
|
902
|
+
### 6. 採番(`addids`)の実務
|
|
903
|
+
|
|
904
|
+
- `addids(uri, num)` は**原子的・単調増加**。`num` をまとめて予約できる(大量挿入は **N 件を一括予約**して採番往復を減らす)。
|
|
905
|
+
- **連番に飛び(gap)はあり得る**: 採番後に登録失敗・冪等スキップが起きると番号が欠ける。**連番が密であることに依存しない**。
|
|
906
|
+
- **業務コード ≠ BDB キー**: 採番した key を self パスのキーにする。`customer_code` 等の業務コードはデータフィールドで**一意保証が無い**(同一コードが並存し得る)。一覧の行 key・重複チェックは BDB パス(key)側で行う。
|
|
907
|
+
|
|
908
|
+
### 7. フィールドインデックスの実務的制約(インデックス設定の補足)
|
|
909
|
+
|
|
910
|
+
本文はインデックス種別(`field:path`=通常 / `field;path`=全文)を説明するが、運用上の以下が重要:
|
|
911
|
+
|
|
912
|
+
- **絞り込みは「先頭条件のみ」インデックスが効く**。2 番目以降の条件はサーバ側メモリフィルタ。→ 並べ替え・主検索キーにしたいフィールドを先頭条件に置く。
|
|
913
|
+
- **インデックスは既存データに遡及しない**(後付けインデックスは登録済みエントリを検索対象にしない)。既存データを跨いだ絞り込みが要るものは BQ 読みモデルにする。
|
|
914
|
+
- **正規表現検索(`-rg-`)はアンカーされる**ため部分一致は値の両端に `.*` が必須(`.*{値}.*`)。
|
|
915
|
+
- 全文索引(`-ft-` / `field;path`)はサーバ負荷が高い。部分一致は通常インデックス+`-rg-` で代替する方針。
|
|
916
|
+
|
|
917
|
+
### 8. 楽観的排他制御(`id = {path},{revision}`)
|
|
918
|
+
|
|
919
|
+
- エントリの `id` は **`{自パス},{リビジョン}`**。更新時に `revision` を渡すと不一致で **409**(楽観ロック)。
|
|
920
|
+
- `revision` を省略した削除/更新は**強制**(上書き)。同時更新が起こり得る箇所は revision を必ず引き回す。
|
|
921
|
+
|
|
922
|
+
### 9. 一覧の全件列挙は `?l=*`(既定ページサイズの切り捨て回避)
|
|
923
|
+
|
|
924
|
+
- `getFeed` は**既定ページサイズで切り捨てられ得る**。ツリー構築のように全件が要る場合(例: カテゴリの親子組み立て)は **`?l=*`** を付けて全件取得する。付けないと親が欠けて子がルート化しツリーが壊れる。
|
|
925
|
+
|
|
926
|
+
### 10. 冪等性・self パスの再 PUT
|
|
927
|
+
|
|
928
|
+
- 同一 self パスへの `put` は **upsert(冪等)**。同所属・同フォルダ定義を再度含めても安全(初期構築の再実行に耐える)。
|
|
929
|
+
- ただし §1〜§3 の順序・トランザクション境界は崩さないこと。
|
|
930
|
+
|
|
931
|
+
---
|
|
932
|
+
|
|
933
|
+
### チェックリスト(初期構築・親子を含む登録を書くとき)
|
|
934
|
+
|
|
935
|
+
1. 親 → 子 → 孫の順に **1 つの feed** に並べたか(グループ定義 → 所属 → 保護フォルダ → 配下エントリ)。
|
|
936
|
+
2. 親子・依存があるのに **並列/別リクエスト**になっていないか(`parallel`/`async`、`Promise.all` 分割に注意)。
|
|
937
|
+
3. フォルダ/グループ/ACL のみの実体なしエントリを **BQ 経路(putBDBQ)に流していない**か(BDB の `put` を使う)。
|
|
938
|
+
4. **トランザクションを分けた場合、途中失敗の補償を設計したか**(子書き込み側で親を自己修復/再実行が冪等で成功/§3.1)。
|
|
939
|
+
5. `createGroupadmin` 等の別 API は **親確定後に逐次**呼んでいるか。
|
|
940
|
+
6. BQ 複製を持つ種別の削除は **`deleteBDBQ`** か。
|
|
941
|
+
7. 検索キーは**先頭条件**・**既存データ遡及なし**を踏まえた設計か(必要なら BQ 読みモデル)。
|
|
942
|
+
|
|
943
|
+
---
|
|
944
|
+
|
|
791
945
|
## よくあるエラー
|
|
792
946
|
|
|
793
947
|
### Parent path is required.
|
|
@@ -859,7 +1013,7 @@ const entry = {
|
|
|
859
1013
|
**対処**: エラーとして扱わず、「データなし」の状態として処理する。
|
|
860
1014
|
|
|
861
1015
|
```typescript
|
|
862
|
-
const entry = await vtecxnext.getEntry(
|
|
1016
|
+
const entry = await vtecxnext.getEntry("/crm/customer/0000000001")
|
|
863
1017
|
if (entry == null) {
|
|
864
1018
|
// データが存在しない(204)
|
|
865
1019
|
}
|