@exabugs/dynamodb-client 1.16.0 → 1.17.0

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -0
  3. package/dist/notify/channels/slack.d.ts +8 -0
  4. package/dist/notify/channels/slack.d.ts.map +1 -1
  5. package/dist/notify/channels/slack.js +27 -19
  6. package/dist/notify/channels/slack.js.map +1 -1
  7. package/dist/server/handler.cjs +3 -3
  8. package/dist/server/media-handler.cjs +2 -2
  9. package/dist/server/media-process-handler-dlq.cjs +2 -2
  10. package/dist/server/media-process-handler.cjs +2 -2
  11. package/dist/system-changes/handler.d.ts +47 -0
  12. package/dist/system-changes/handler.d.ts.map +1 -0
  13. package/dist/system-changes/handler.js +74 -0
  14. package/dist/system-changes/handler.js.map +1 -0
  15. package/dist/system-changes/index.d.ts +15 -0
  16. package/dist/system-changes/index.d.ts.map +1 -0
  17. package/dist/system-changes/index.js +12 -0
  18. package/dist/system-changes/index.js.map +1 -0
  19. package/dist/system-changes/record.d.ts +92 -0
  20. package/dist/system-changes/record.d.ts.map +1 -0
  21. package/dist/system-changes/record.js +198 -0
  22. package/dist/system-changes/record.js.map +1 -0
  23. package/dist/system-changes/recorder.d.ts +104 -0
  24. package/dist/system-changes/recorder.d.ts.map +1 -0
  25. package/dist/system-changes/recorder.js +184 -0
  26. package/dist/system-changes/recorder.js.map +1 -0
  27. package/dist/system-changes/slack.d.ts +19 -0
  28. package/dist/system-changes/slack.d.ts.map +1 -0
  29. package/dist/system-changes/slack.js +124 -0
  30. package/dist/system-changes/slack.js.map +1 -0
  31. package/docs/RECORDS_CLIENT.md +2 -0
  32. package/docs/SYSTEM_CHANGES.md +117 -0
  33. package/package.json +7 -2
@@ -0,0 +1,124 @@
1
+ /**
2
+ * システム変更履歴の Slack のメッセージの組み立て(ADR 0006 の (a)。純粋な関数だけ)。
3
+ *
4
+ * 投稿は notify の `postSlackText`(`createSlackChannel` と同じ送信)に任せる(recorder.ts)。
5
+ * システム変更は、重複抑制・流量制限が不要で、運用通知(alerts)に混ぜないため、Notice を経由せず直接送る。
6
+ *
7
+ * 送るのは「結果」(成功・失敗)。「開始」は、既定では送らない(1 回の適用で 2 通になるため。notifyStarted で送る)。
8
+ * changeId の記録(変更〔run〕のモデル)では、終了への遷移が起きたときだけ送り(無視したときは送らない)、
9
+ * 所要時間(durationMs)があれば「所要 52 秒」の形で足す。
10
+ * 文言は asanowa(ADR-0044)の版に揃えた(ADR 0006 の決定。astro の「記録を省いた Lambda」は使わない)。
11
+ */
12
+ import { SYSTEM_CHANGES_RESOURCE } from './record.js';
13
+ const KIND_LABEL = {
14
+ 'app-release': 'アプリのリリース',
15
+ ota: 'OTA の配信',
16
+ infra: 'インフラの変更',
17
+ 'lambda-release': 'Lambda のリリース',
18
+ };
19
+ const RESULT_LABEL = {
20
+ started: '開始',
21
+ succeeded: '成功',
22
+ failed: '失敗',
23
+ };
24
+ const RESULT_MARK = {
25
+ started: '▶️',
26
+ succeeded: '✅',
27
+ failed: '🚨',
28
+ };
29
+ /** Slack の mrkdwn で、意味を持つ文字を逃がす */
30
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
31
+ /**
32
+ * details.lambdaReleases([{ target, result, change }]。ラッパーが infra の終了に埋め込む)から、成功・失敗の関数名を取り出す。
33
+ * 形の合わない要素は飛ばす
34
+ */
35
+ export function lambdaReleaseNames(value) {
36
+ const succeeded = [];
37
+ const failed = [];
38
+ if (!Array.isArray(value))
39
+ return { succeeded, failed };
40
+ for (const v of value) {
41
+ if (typeof v !== 'object' || v === null)
42
+ continue;
43
+ const { target, result } = v;
44
+ if (typeof target !== 'string' || target === '')
45
+ continue;
46
+ if (result === 'succeeded')
47
+ succeeded.push(target);
48
+ else if (result === 'failed')
49
+ failed.push(target);
50
+ }
51
+ return { succeeded, failed };
52
+ }
53
+ /** details のうち、Slack に出す項目(件数と、リリースされた Lambda の名前)。上限を設けて、長くしない */
54
+ function detailLines(details) {
55
+ if (!details)
56
+ return [];
57
+ const lines = [];
58
+ const num = (k) => (typeof details[k] === 'number' ? details[k] : undefined);
59
+ const add = num('add');
60
+ const change = num('change');
61
+ const destroy = num('destroy');
62
+ if (add !== undefined || change !== undefined || destroy !== undefined) {
63
+ lines.push(`変更: 追加 ${add ?? 0} / 更新 ${change ?? 0} / 削除 ${destroy ?? 0}`);
64
+ }
65
+ const { succeeded, failed } = lambdaReleaseNames(details.lambdaReleases);
66
+ for (const [names, label] of [
67
+ [succeeded, 'リリースした Lambda'],
68
+ [failed, '失敗した Lambda'],
69
+ ]) {
70
+ if (names.length > 0) {
71
+ const shown = names.slice(0, 15).map(esc).join(', ');
72
+ lines.push(`${label}: ${shown}${names.length > 15 ? ` ほか ${names.length - 15} 件` : ''}`);
73
+ }
74
+ }
75
+ const omitted = num('lambdaReleasesOmitted');
76
+ if (omitted !== undefined && omitted > 0)
77
+ lines.push(`ほかに記録しきれなかった Lambda: ${omitted} 件`);
78
+ if (typeof details.exit_code === 'number')
79
+ lines.push(`終了コード: ${details.exit_code}`);
80
+ return lines;
81
+ }
82
+ /** 所要時間(ミリ秒)を「52 秒」「3 分 12 秒」「1 時間 5 分」の形にする(1 秒未満は「1 秒未満」) */
83
+ export function formatDuration(ms) {
84
+ const total = Math.round(ms / 1000);
85
+ if (total < 1)
86
+ return '1 秒未満';
87
+ const h = Math.floor(total / 3600);
88
+ const m = Math.floor((total % 3600) / 60);
89
+ const s = total % 60;
90
+ if (h > 0)
91
+ return m > 0 ? `${h} 時間 ${m} 分` : `${h} 時間`;
92
+ if (m > 0)
93
+ return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分`;
94
+ return `${s} 秒`;
95
+ }
96
+ /** Slack に送るメッセージ(text)。管理画面の詳細へのリンクは、adminBaseUrl があるときだけ */
97
+ export function formatSystemChangeMessage(app, env, record, id, adminBaseUrl) {
98
+ const where = env ? `${app}/${env}` : app;
99
+ const head = `${RESULT_MARK[record.result]} *[${esc(where)}] ${KIND_LABEL[record.kind]} ${RESULT_LABEL[record.result]}*`;
100
+ const lines = [head, `対象: ${esc(record.target)}`];
101
+ if (record.version)
102
+ lines.push(`版: ${esc(record.version)}`);
103
+ if (record.commit)
104
+ lines.push(`コミット: ${esc(record.commit)}`);
105
+ if (record.actor)
106
+ lines.push(`実行者: ${esc(record.actor)}`);
107
+ lines.push(...detailLines(record.details));
108
+ if (typeof record.durationMs === 'number' && record.durationMs >= 0) {
109
+ lines.push(`所要 ${formatDuration(record.durationMs)}`);
110
+ }
111
+ lines.push(`発生: ${record.occurredAt}`);
112
+ if (adminBaseUrl)
113
+ lines.push(`詳細: ${adminBaseUrl.replace(/\/+$/, '')}/${SYSTEM_CHANGES_RESOURCE}/${encodeURIComponent(id)}/show`);
114
+ return lines.join('\n');
115
+ }
116
+ /** 通知するか(結果は送る。開始は notifyStarted のときだけ。呼び出し側が notify=false を指定したら送らない) */
117
+ export function shouldNotify(result, options) {
118
+ if (options.notify === false)
119
+ return false;
120
+ if (result === 'started')
121
+ return options.notifyStarted === true;
122
+ return true;
123
+ }
124
+ //# sourceMappingURL=slack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slack.js","sourceRoot":"","sources":["../../src/system-changes/slack.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,8 @@ Lambda などのサーバー側から Records(IAM 版 Function URL)を呼ぶ
9
9
  | `lazyCollection(name, options?)` | 最初の使用時に 1 回だけ接続し、コンテナ内で再利用。失敗はキャッシュしない。`.reset()` で破棄 |
10
10
  | `createAdminDirectory(options?)` | `@exabugs/dynamodb-client/notify`。`admins` の全件から通知の宛先(メール)を返す |
11
11
 
12
+ システム変更履歴の記録 Lambda(`createSystemChangeHandler`)も、内部で `withCollection` を使う([SYSTEM_CHANGES.md](SYSTEM_CHANGES.md))。
13
+
12
14
  `options`: `url`(省略時は環境変数 `RECORDS_IAM_URL`)、`region`(省略時は `AWS_REGION`、無ければ `REGION`)、`timeout`(ms。Records の Lambda の timeout より短くする)。
13
15
 
14
16
  `createAdminDirectory` の主なオプション: `recipientFields`(宛先を取るフィールド。既定 `['id']`、`['email','id']` で `email ?? id`)、`maxAdmins`(既定 200。件数が超えたら例外、ちょうどは成功)、`sortById`(id 昇順で読む)、`source`(`authorizedCollection` の `list` と同じ形。権限チェック層を通すアプリが渡す。指定すると自前で接続しない)。
@@ -0,0 +1,117 @@
1
+ # システム変更履歴(`@exabugs/dynamodb-client/system-changes`)
2
+
3
+ 環境に起きたシステム変更(インフラの変更・Lambda のリリース・アプリのリリース・OTA の配信)を、Records の `systemChanges` に記録し、結果を Slack に送る部品([ADR 0006](adr/0006-library-owns-table-operations.md) の (a)。1.17.0 から)。asanowa(ADR-0044)と astro(ADR-0011)に複製されていたロジックを 1 つにした。アプリが渡すのは設定だけ。
4
+
5
+ テーブルには、Records(`DynamoClient`。IAM 版 Function URL)経由でしか触れない([DYNAMODB_ACCESS.md](DYNAMODB_ACCESS.md))。
6
+
7
+ ## 記録 Lambda のエントリ
8
+
9
+ ```ts
10
+ import { createSystemChangeHandler } from '@exabugs/dynamodb-client/system-changes';
11
+
12
+ export const handler = createSystemChangeHandler();
13
+ ```
14
+
15
+ 環境変数(オプションで上書きできる。どちらも呼び出しのたびに読む):
16
+
17
+ | 環境変数 | オプション | 内容 |
18
+ | --------------------- | --------------------- | ------------------------------------------------------------------------------------------ |
19
+ | `PROJECT_NAME` | `app` | 必須。ログの `app`、Slack の見出し、失敗の案内の `<app>-<env>-records` |
20
+ | `RECORDS_IAM_URL` | `records.url` | 必須。Records の IAM 版 Function URL |
21
+ | `AWS_REGION` | `records.region` | 署名のリージョン(Lambda では自動で入る) |
22
+ | — | `records.timeout` | Records の HTTP の timeout。既定 5 秒(`RECORD_HTTP_TIMEOUT_MS`) |
23
+ | `SLACK_WEBHOOK_PARAM` | `slack.webhookParam` | Slack の webhook の SSM(SecureString)の名前。無ければ送らない |
24
+ | `ENV_NAME` | `slack.env` | 見出しの `[app/env]` |
25
+ | `ADMIN_BASE_URL` | `slack.adminBaseUrl` | 管理画面の URL。あれば `詳細: <URL>/systemChanges/{id}/show` を付ける |
26
+ | `NOTIFY_STARTED` | `slack.notifyStarted` | `true` で開始(`started`)も送る(既定は結果だけ) |
27
+ | — | `slack.getWebhookUrl` | webhook の取得。既定は SSM(実行環境の間はキャッシュ) |
28
+ | — | `slack.urlPrefix` | webhook の URL の接頭辞の確認。既定 `https://hooks.slack.com/` |
29
+ | — | `slack: false` | Slack に送らない |
30
+ | — | `collection` | 書き込み先の包み方(下) |
31
+ | — | `log` | ログの出力先。既定は notify の `logJson`(`{ app, target: 'systemChanges', event, ... }`) |
32
+
33
+ Lambda の実行ロールには、Records の IAM エイリアスへの `lambda:InvokeFunctionUrl` と `lambda:InvokeFunction`、SSM の `GetParameter`(webhook)が要る。
34
+
35
+ ### 権限チェック層を通すアプリ(astro など)
36
+
37
+ 既定の書き込み先は `systemChangesCollectionOf(raw)`(生のコレクションを包む)。データアクセスを自前の権限チェック層に限るアプリは、受け取った生のコレクションを自分の層で包んで返す:
38
+
39
+ ```ts
40
+ export const handler = createSystemChangeHandler({
41
+ collection: (raw) => {
42
+ const c = authorizedCollection('systemChanges', raw, PERMISSION_MATRIX, { role: 'system' });
43
+ return {
44
+ insertOne: async (doc) => ({ insertedId: await c.insert({ ...doc }) }),
45
+ findOne: (id) => c.read(id),
46
+ updateOne: (id, set, { upsert }) =>
47
+ upsert ? c.upsert(id, { $set: { ...set } }) : c.update(id, { $set: { ...set } }),
48
+ };
49
+ },
50
+ });
51
+ ```
52
+
53
+ `findOne` は、無い ID で `null` を返すこと。既定の包み方は `find({ id }, { sort: { id: 'asc' }, limit: 1 })`(Records の ID の経路。PK/SK の Query 1 回) で読む(`findOne` は Records が 404〔ITEM_NOT_FOUND〕を返して warn のログを出し、運用通知に届くため使わない)。
54
+
55
+ ## 入力と応答
56
+
57
+ 入力: `{ kind, result, target, changeId?, version?, commit?, actor?, details?, occurredAt?, notify? }`
58
+
59
+ | 項目 | 必須 | 内容 |
60
+ | ------------ | ---- | ---------------------------------------------------------------------------------------- |
61
+ | `kind` | ○ | `app-release` / `ota` / `infra` / `lambda-release` |
62
+ | `result` | ○ | `started` / `succeeded` / `failed` |
63
+ | `target` | ○ | 対象。200 文字まで |
64
+ | `version` | | 100 文字まで |
65
+ | `commit` | | 64 文字まで |
66
+ | `actor` | | 200 文字まで |
67
+ | `details` | | オブジェクト。JSON で 4096 バイトまで。シャドーにならない |
68
+ | `occurredAt` | | ISO 8601(タイムゾーン付き)。UTC にそろえて保存。無ければ現在時刻 |
69
+ | `changeId` | | ULID の形式。1 回の変更(run)= 1 レコードにする(開始と終了で同じ ID)。レコードの `id` |
70
+ | `notify` | | `false` で Slack に送らない。保存しない |
71
+
72
+ - 不明な項目(綴りの誤り。`parentId` なども)、不正な値は拒否する(`SystemChangeInputError`。記録しない)
73
+ - 文字列は、制御文字・改行を空白にし、上限を超えた分を切る(末尾に `…`)。空の任意の項目は保存しない
74
+ - `details.lambdaReleases`(`[{ target, result: 'succeeded' | 'failed', change }]`)と `details.lambdaReleasesOmitted`(件数)は、Slack に「リリースした Lambda」「失敗した Lambda」「ほかに記録しきれなかった Lambda」として出る(取り出しは `lambdaReleaseNames`)
75
+
76
+ 応答: `{ id, occurredAt, notified, ignored? }`。`ignored: true` は、`changeId` の記録で状態を変えずに無視したとき(エラーではない。Slack にも送らない)。
77
+
78
+ ### 遷移の規則(`changeId` の記録。`decideRunWrite`)
79
+
80
+ | 既存 | 入力 | 動作 |
81
+ | --------- | --------- | -------------------------------------------------------------------------------------------- |
82
+ | 無い | `started` | 作成(`startedAt`) |
83
+ | 無い | 終了 | 作成(`finishedAt`。終了が先に届いた) |
84
+ | `started` | 終了 | 更新: `result`・`finishedAt`・`durationMs` と入力にある項目。`occurredAt`・`kind` は変えない |
85
+ | `started` | `started` | 無視(再送) |
86
+ | 終了 | `started` | 無視(状態を戻さない) |
87
+ | 終了 | 同じ終了 | 無視(冪等) |
88
+ | 終了 | 違う終了 | 無視(最初の終了を優先)。warn `SYSTEM_CHANGE_CONFLICT` |
89
+
90
+ `details` は更新で深くマージされ(Records の `$set`)、配列は置き換わる。読み取り → 書き込みは非アトミック(Records に条件付き書き込みが無い)。同じ `changeId` を同時に呼ばないこと。
91
+
92
+ ## ログ(event)
93
+
94
+ `SYSTEM_CHANGE_RECORDED`(info)、`SYSTEM_CHANGE_IGNORED`(info)、`SYSTEM_CHANGE_CONFLICT`(warn)、`SYSTEM_CHANGE_INVALID_INPUT`(warn。例外)、`SYSTEM_CHANGE_RECORD_FAILED`(error。例外)、`SYSTEM_CHANGE_SLACK_FAILED`(warn。記録は成功のまま)。
95
+
96
+ ## 部品(ハンドラを使わないとき)
97
+
98
+ - `recordSystemChange(collection, input, { app, now?, log? })`: 検証して書く
99
+ - `recordAndNotify(collection, input, { app, slack?, now?, log? })`: 記録して Slack に送る
100
+ - `systemChangesCollectionOf(raw)`: Records のコレクションを書き込み先にする
101
+ - `parseSystemChangeInput` / `toSystemChangeRecord` / `decideRunWrite`: 純粋な関数
102
+ - `formatSystemChangeMessage(app, env, record, id, adminBaseUrl?)` / `formatDuration` / `lambdaReleaseNames` / `shouldNotify`
103
+ - 投稿は notify の `postSlackText`(`createSlackChannel` と同じ送信。URL の接頭辞の確認、URL を出さないエラー)
104
+
105
+ ## テスト
106
+
107
+ - 単体: `src/system-changes/__tests__/`。ハンドラは、本物の `DynamoClient` に `fetch` だけを差し替えて固定する(`connect()` の呼び忘れを見逃さない)
108
+ - 統合(DynamoDB Local。CI には入っていない): `src/system-changes/__tests__/system-changes.integration.test.ts`(実行方法は PUBLISHING.md)
109
+
110
+ ## アプリの移行(公開後の別作業)
111
+
112
+ ADR 0006 の段階 2 の手順: astro で先に置き換えて dev で確認し、そのあと asanowa。差分:
113
+
114
+ - Slack の文言は asanowa に揃えた。astro の「記録を省いた Lambda」は「ほかに記録しきれなかった Lambda」になる
115
+ - Slack の送信は `postSlackText` を通る: webhook の URL が `https://hooks.slack.com/` で始まらないと送らない(`slack.urlPrefix`)。失敗の warn の理由は従来と同じ `HTTP 500` やエラー名(時間切れは `AbortError` ではなく `TimeoutError`)
116
+ - asanowa の `changeId` の読み取りは `findOne`(404 を null にする)から `find({ id }, { sort: { id: 'asc' }, limit: 1 })`(Records の ID の経路。PK/SK の Query 1 回) に変わる。Records の ITEM_NOT_FOUND の warn が出なくなる
117
+ - 記録 Lambda の Terraform(`source_file`)は、アプリのバンドルのまま(Terraform のモジュール化は段階 6)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exabugs/dynamodb-client",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "DynamoDB Single-Table Client SDK with MongoDB-like API, Shadow Records, and Lambda implementation for serverless applications",
5
5
  "author": "exabugs",
6
6
  "license": "MIT",
@@ -44,7 +44,8 @@
44
44
  "CHANGELOG.md",
45
45
  "docs/NOTIFY.md",
46
46
  "docs/DYNAMODB_ACCESS.md",
47
- "docs/RECORDS_CLIENT.md"
47
+ "docs/RECORDS_CLIENT.md",
48
+ "docs/SYSTEM_CHANGES.md"
48
49
  ],
49
50
  "exports": {
50
51
  ".": {
@@ -124,6 +125,10 @@
124
125
  "./notify": {
125
126
  "import": "./dist/notify/index.js",
126
127
  "types": "./dist/notify/index.d.ts"
128
+ },
129
+ "./system-changes": {
130
+ "import": "./dist/system-changes/index.js",
131
+ "types": "./dist/system-changes/index.d.ts"
127
132
  }
128
133
  },
129
134
  "peerDependencies": {