@ultimat3/cli 12.0.0 → 14.0.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.
@@ -0,0 +1,275 @@
1
+ // Single responsibility: what two GENERATED schema snapshots disagree about, as data. One side is
2
+ // `snapshotOf(describeEntities())` — what the app declares right now — and the other is the
3
+ // `.snapshot.json` the newest migration wrote down.
4
+ //
5
+ // Not `@ultimat3/db`'s `diffSchema`, and it never can be: that one compares a LIVE catalog to a
6
+ // snapshot, so a predicate, a type name and a default are the server's own rewriting and are
7
+ // deliberately left uncompared. Here both sides come out of `snapshotOf`, which is what makes a
8
+ // check, a default and a column type comparable at all — and those are exactly the three the
9
+ // `drift` gate step could not see.
10
+
11
+ import type {
12
+ CheckDescription,
13
+ ColumnDescription,
14
+ ForeignKeyDescription,
15
+ IndexDescription,
16
+ SchemaDescription,
17
+ TableDescription,
18
+ } from '@ultimat3/db';
19
+ import { indexMethodOf } from '@ultimat3/db';
20
+
21
+ /**
22
+ * Which side holds a declaration the other does not, and they are two different repairs.
23
+ * `unmigrated` means the app declares it and no migration carries it, so no database will ever
24
+ * get it. `undeclared` means a migration recorded it and nothing in source declares it any more.
25
+ */
26
+ export type SchemaDirection = 'unmigrated' | 'undeclared';
27
+
28
+ /** The part of a table a difference is about. Rendered into the cause verbatim. */
29
+ export type SchemaPart = 'table' | 'column' | 'index' | 'foreign key' | 'check';
30
+
31
+ export interface SchemaDifference {
32
+ readonly direction: SchemaDirection;
33
+ readonly part: SchemaPart;
34
+ readonly table: string;
35
+ /** The column, index, constraint or table name. */
36
+ readonly name: string;
37
+ /** One line naming what differs — it reaches a `cause:`, so it never spans lines. */
38
+ readonly detail: string;
39
+ }
40
+
41
+ const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
42
+ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
43
+
44
+ const columns = (names: readonly string[]): string => names.join(', ');
45
+
46
+ /** A default is SQL or nothing at all, and "nothing" has to read as a value in the sentence. */
47
+ const defaultSql = (value: string | null): string => (value === null ? 'no default' : value);
48
+
49
+ function columnDetail(
50
+ declared: ColumnDescription,
51
+ recorded: ColumnDescription,
52
+ ): string | undefined {
53
+ if (declared.dataType !== recorded.dataType) {
54
+ return `is declared ${declared.dataType} and was recorded ${recorded.dataType}`;
55
+ }
56
+ if (declared.nullable !== recorded.nullable) {
57
+ return declared.nullable
58
+ ? 'is declared nullable and was recorded not null'
59
+ : 'is declared not null and was recorded nullable';
60
+ }
61
+ if (declared.default !== recorded.default) {
62
+ return `is declared ${defaultSql(declared.default)} and was recorded ${defaultSql(recorded.default)}`;
63
+ }
64
+ // Absent and absent agree; absent against an expression is a column the database computes on one
65
+ // side and a writer supplies on the other, which is a `23502` on the first insert.
66
+ if ((declared.generated ?? null) !== (recorded.generated ?? null)) {
67
+ return `is declared generated as ${declared.generated ?? 'nothing'} and was recorded ${
68
+ recorded.generated ?? 'not generated'
69
+ }`;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * Absent `using` is `btree` and absent `order` is `asc` — both are Postgres' own defaults and what
76
+ * every index recorded before those fields existed is. Read literally, every app with a sidecar
77
+ * from before them would report a difference on every index it has.
78
+ */
79
+ function indexDetail(declared: IndexDescription, recorded: IndexDescription): string | undefined {
80
+ if (columns(declared.columns) !== columns(recorded.columns)) {
81
+ return `is declared over (${columns(declared.columns)}) and was recorded over (${columns(recorded.columns)})`;
82
+ }
83
+ if (declared.unique !== recorded.unique) {
84
+ return declared.unique
85
+ ? 'is declared unique and was recorded non-unique'
86
+ : 'is declared non-unique and was recorded unique';
87
+ }
88
+ if (indexMethodOf(declared) !== indexMethodOf(recorded)) {
89
+ return `is declared ${indexMethodOf(declared)} and was recorded ${indexMethodOf(recorded)}`;
90
+ }
91
+ if ((declared.order ?? 'asc') !== (recorded.order ?? 'asc')) {
92
+ return `is declared ${declared.order ?? 'asc'} and was recorded ${recorded.order ?? 'asc'}`;
93
+ }
94
+ if (declared.where !== recorded.where) {
95
+ return `is declared ${declared.where === null ? 'over every row' : `where ${declared.where}`} and was recorded ${
96
+ recorded.where === null ? 'over every row' : `where ${recorded.where}`
97
+ }`;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function keyDetail(
103
+ declared: ForeignKeyDescription,
104
+ recorded: ForeignKeyDescription,
105
+ ): string | undefined {
106
+ const target = (key: ForeignKeyDescription): string =>
107
+ `(${columns(key.columns)}) -> "${key.referencedTable}" (${columns(key.referencedColumns)})`;
108
+ if (target(declared) !== target(recorded)) {
109
+ return `is declared ${target(declared)} and was recorded ${target(recorded)}`;
110
+ }
111
+ // `null` is "no rule declared", which Postgres records as `no action` — a real answer, and the
112
+ // difference between an orphan row and a cascading delete.
113
+ if (declared.onDelete !== recorded.onDelete) {
114
+ return `is declared on delete ${declared.onDelete ?? 'unset'} and was recorded on delete ${
115
+ recorded.onDelete ?? 'unset'
116
+ }`;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ const checkDetail = (declared: CheckDescription, recorded: CheckDescription): string | undefined =>
122
+ declared.expression === recorded.expression
123
+ ? undefined
124
+ : `is declared "${declared.expression}" and was recorded "${recorded.expression}"`;
125
+
126
+ interface ListComparison<T> {
127
+ readonly part: SchemaPart;
128
+ readonly table: string;
129
+ readonly declared: readonly T[];
130
+ readonly recorded: readonly T[];
131
+ readonly key: (item: T) => string;
132
+ /** What differs between two items of the same name, or `undefined` when they agree. */
133
+ readonly detail: (declared: T, recorded: T) => string | undefined;
134
+ }
135
+
136
+ /**
137
+ * Both directions of one named list, in one pass. A `Map` and not an object literal: a constraint
138
+ * name is data, and a computed read of a `Record<…>` answers an `Object.prototype` member for a
139
+ * table someone called `constructor`.
140
+ *
141
+ * A CHANGED item is reported as `unmigrated`, never as its own direction: the declaration is the
142
+ * intent and a migration that recorded something else is the one that is behind.
143
+ */
144
+ function compareList<T>(comparison: ListComparison<T>): SchemaDifference[] {
145
+ const { part, table } = comparison;
146
+ const out: SchemaDifference[] = [];
147
+ const recorded = new Map(comparison.recorded.map((item) => [comparison.key(item), item]));
148
+ for (const item of comparison.declared) {
149
+ const name = comparison.key(item);
150
+ const counterpart = recorded.get(name);
151
+ if (counterpart === undefined) {
152
+ out.push({ direction: 'unmigrated', part, table, name, detail: DECLARED_ONLY });
153
+ continue;
154
+ }
155
+ const detail = comparison.detail(item, counterpart);
156
+ if (detail !== undefined) out.push({ direction: 'unmigrated', part, table, name, detail });
157
+ }
158
+ const declared = new Set(comparison.declared.map(comparison.key));
159
+ for (const item of comparison.recorded) {
160
+ const name = comparison.key(item);
161
+ if (declared.has(name)) continue;
162
+ out.push({ direction: 'undeclared', part, table, name, detail: RECORDED_ONLY });
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * `checks` is absent — never `[]` — on a table that declares none, deliberately, so a sidecar
169
+ * written before the field existed reads as "nothing recorded" rather than "recorded none". Both
170
+ * sides normalise to empty here for the same reason: a table that never had a constraint must not
171
+ * report one, and a table that has nine of them must.
172
+ */
173
+ function compareTable(declared: TableDescription, recorded: TableDescription): SchemaDifference[] {
174
+ const table = declared.name;
175
+ const out: SchemaDifference[] = [];
176
+ // Read into locals first, and NOT named `…Key`: `bun run secret-compare` matches an operand
177
+ // whose NAME says it holds a credential, and a row's identity is the opposite of a secret — it
178
+ // is in every URL the app serves.
179
+ const declaredIdentity = columns(declared.primaryKey);
180
+ const recordedIdentity = columns(recorded.primaryKey);
181
+ if (declaredIdentity !== recordedIdentity) {
182
+ out.push({
183
+ direction: 'unmigrated',
184
+ part: 'table',
185
+ table,
186
+ name: table,
187
+ detail: `declares primary key (${columns(declared.primaryKey)}) and the migration recorded (${columns(recorded.primaryKey)})`,
188
+ });
189
+ }
190
+ out.push(
191
+ ...compareList({
192
+ part: 'column',
193
+ table,
194
+ declared: declared.columns,
195
+ recorded: recorded.columns,
196
+ key: (column) => column.name,
197
+ detail: columnDetail,
198
+ }),
199
+ ...compareList({
200
+ part: 'index',
201
+ table,
202
+ declared: declared.indexes,
203
+ recorded: recorded.indexes,
204
+ key: (index) => index.name,
205
+ detail: indexDetail,
206
+ }),
207
+ ...compareList({
208
+ part: 'foreign key',
209
+ table,
210
+ declared: declared.foreignKeys,
211
+ recorded: recorded.foreignKeys,
212
+ key: (key) => key.name,
213
+ detail: keyDetail,
214
+ }),
215
+ ...compareList({
216
+ part: 'check',
217
+ table,
218
+ declared: declared.checks ?? [],
219
+ recorded: recorded.checks ?? [],
220
+ key: (check) => check.name,
221
+ detail: checkDetail,
222
+ }),
223
+ );
224
+ return out;
225
+ }
226
+
227
+ const PART_ORDER: readonly SchemaPart[] = ['table', 'column', 'index', 'foreign key', 'check'];
228
+
229
+ /**
230
+ * Pure and total: the same two snapshots always produce the same ordered report, so a diff of two
231
+ * gate runs on two machines is readable.
232
+ *
233
+ * A table present on only one side is ONE difference and the columns under it are not descended
234
+ * into — the repair is a single statement, and a finding per column would be twelve instructions
235
+ * for it.
236
+ */
237
+ export function diffDeclaredSchema(
238
+ declared: SchemaDescription,
239
+ recorded: SchemaDescription,
240
+ ): readonly SchemaDifference[] {
241
+ const out: SchemaDifference[] = [];
242
+ const byName = new Map(recorded.tables.map((table) => [table.name, table]));
243
+ for (const table of declared.tables) {
244
+ const counterpart = byName.get(table.name);
245
+ if (counterpart === undefined) {
246
+ out.push({
247
+ direction: 'unmigrated',
248
+ part: 'table',
249
+ table: table.name,
250
+ name: table.name,
251
+ detail: DECLARED_ONLY,
252
+ });
253
+ continue;
254
+ }
255
+ out.push(...compareTable(table, counterpart));
256
+ }
257
+ const declaredTables = new Set(declared.tables.map((table) => table.name));
258
+ for (const table of recorded.tables) {
259
+ if (declaredTables.has(table.name)) continue;
260
+ out.push({
261
+ direction: 'undeclared',
262
+ part: 'table',
263
+ table: table.name,
264
+ name: table.name,
265
+ detail: RECORDED_ONLY,
266
+ });
267
+ }
268
+ out.sort((a, b) => {
269
+ if (a.table !== b.table) return a.table < b.table ? -1 : 1;
270
+ if (a.part !== b.part) return PART_ORDER.indexOf(a.part) - PART_ORDER.indexOf(b.part);
271
+ if (a.direction !== b.direction) return a.direction < b.direction ? -1 : 1;
272
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
273
+ });
274
+ return out;
275
+ }
@@ -0,0 +1,135 @@
1
+ // Single responsibility: the drift a hash cannot see — what the app's entities declare NOW against
2
+ // the schema the newest migration wrote down. No database, so the gate runs it in a CI with nothing
3
+ // listening, exactly as the source-hash half does.
4
+ //
5
+ // It exists because the hash half compares a schema-source hash to a `.hash` sidecar and never
6
+ // reads what the migration RECORDED: `dummy/social-media-clone` sat green on `drift` while nine
7
+ // declared CHECK constraints had never reached any database, and a squash produced a migration
8
+ // missing ten invariants and nine defaults with the gate green over it. Nothing that hashes source
9
+ // can see either, because the source did not move.
10
+
11
+ // why: Bun exposes no synchronous "does this path exist" primitive, and this is a probe rather
12
+ // than a read — `Bun.file().exists()` answers about a file where the question is about a directory.
13
+ import { existsSync } from 'node:fs';
14
+ // why: Bun ships no path joiner of its own, and this path is built to be probed, never opened.
15
+ import { join } from 'node:path';
16
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
17
+ import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
18
+ import { declaredSchema, snapshotOf, unrenderedOf } from '@ultimat3/db';
19
+ import { describeEntities } from '@ultimat3/entity';
20
+ import { loadApp } from './app-load';
21
+ import { checkSourceDrift, DB_PACKAGE } from './drift';
22
+ import { MIGRATIONS_DIR, readMigrations } from './migrations';
23
+ import type { Finding } from './output';
24
+ import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
25
+
26
+ /**
27
+ * What the app declares, or `undefined` when the app would not load. Injected so this module's
28
+ * rules are testable against hand-built descriptions with no app on disk, and so the ONE thing a
29
+ * caller must never do — read a short registry as the whole schema — has a single seam.
30
+ */
31
+ export type DeclaredEntities = (
32
+ root: string,
33
+ ) => Promise<readonly EntityDescriptionLike[] | undefined>;
34
+
35
+ /**
36
+ * `undefined`, never a short list. A module that will not import leaves the registry missing the
37
+ * entities it would have registered, and a short registry against a whole snapshot reads as "every
38
+ * one of these tables was dropped" — a DROP per table, handed out as the fix, for a syntax error in
39
+ * one file. `loadApp`'s findings are reported by the steps that own them.
40
+ */
41
+ const appEntities: DeclaredEntities = async (root) => {
42
+ const app = await loadApp(root);
43
+ if (app.findings.length > 0) return undefined;
44
+ return describeEntities();
45
+ };
46
+
47
+ /**
48
+ * `x db gen` is the repair for both directions — and it is the WRONG instruction while the
49
+ * declaration holds something this generator cannot write down, because regenerating drops it
50
+ * silently and the result is green. In that state the unrendered entry's own `fix:` is the
51
+ * instruction: `@ultimat3/db` owns that wording, and a second one here would drift from it.
52
+ */
53
+ function repairFix(
54
+ unrendered: readonly UnrenderedDeclaration[],
55
+ difference: SchemaDifference,
56
+ ): string {
57
+ // The entry ABOUT this difference first, then any entry at all. Reading `unrendered[0]`
58
+ // unconditionally meant that in any app carrying an unrendered DEFAULT, every difference —
59
+ // a dropped CHECK included — was answered with the default's edit, which is an instruction for
60
+ // a different problem in a different file.
61
+ const named = unrendered.find(
62
+ (entry) => entry.table === difference.table && entry.name === difference.name,
63
+ );
64
+ const blocked = named ?? unrendered[0];
65
+ if (blocked !== undefined) return blocked.fix;
66
+ // "record", not "add": one finding covers a declaration the migrations never carried AND one they
67
+ // carry differently, so `add` would be a wrong migration name for half of them. `undeclared` gets
68
+ // both branches, in the order they are safe — regenerating emits the DROP, and the declaration
69
+ // may have been LOST rather than removed, which is the whole reason this direction is its own
70
+ // finding.
71
+ return difference.direction === 'unmigrated'
72
+ ? `x db gen "record ${difference.name}"`
73
+ : `x db gen "drop ${difference.name}" # or re-declare ${difference.name} on the entity`;
74
+ }
75
+
76
+ /**
77
+ * Two directions, two codes, because they are two repairs. `unmigrated` means the database will
78
+ * never get what the app declares; `undeclared` means the database holds what nothing declares.
79
+ * One "drift" verdict over both teaches a reader neither.
80
+ */
81
+ function findingFor(difference: SchemaDifference, fix: string): Finding {
82
+ const cause = `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
83
+ return difference.direction === 'unmigrated'
84
+ ? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
85
+ : { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
86
+ }
87
+
88
+ /**
89
+ * One finding per difference, never one per table: each names one declaration and one edit, and a
90
+ * grouped finding would hand a reader nine constraint names under one instruction.
91
+ *
92
+ * Three conditions are deliberately left to their own reporters, because each already has a code
93
+ * and a remedy: an app with no `packages/db`, an app whose first migration has not been generated
94
+ * (`checkSourceDrift`, `x db gen "initial"`), and a newest migration carrying no sidecar
95
+ * (`X_MIGRATION_SNAPSHOT_MISSING`, whose two-branch fix is `@ultimat3/db`'s).
96
+ */
97
+ export async function checkSnapshotDrift(
98
+ root: string,
99
+ declared: DeclaredEntities = appEntities,
100
+ ): Promise<readonly Finding[]> {
101
+ if (!existsSync(join(root, DB_PACKAGE))) return [];
102
+ const entities = await declared(root);
103
+ if (entities === undefined) return [];
104
+ const migrations = await readMigrations(root);
105
+ if (migrations.length === 0) return [];
106
+ const recorded = declaredSchema(migrations);
107
+ if (recorded === undefined) return [];
108
+ const differences = diffDeclaredSchema(snapshotOf(entities), recorded);
109
+ if (differences.length === 0) return [];
110
+ // `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
111
+ // migration recorded a CHECK for it, which only the sidecar knows.
112
+ const unrendered = unrenderedOf(entities, recorded);
113
+ return differences.map((difference) => findingFor(difference, repairFix(unrendered, difference)));
114
+ }
115
+
116
+ /**
117
+ * The whole no-database drift answer, for the gate step and for `x doctor` — one composition, so
118
+ * the two can never disagree about an app.
119
+ *
120
+ * The source hash STAYS, and it runs second. It catches a class this comparison cannot see at all:
121
+ * a seed, a repo helper or a TS-only invariant moving under `packages/db/src` with no physical
122
+ * schema behind it, which is what `reconcileSchemaHash` exists to re-record. It is suppressed when
123
+ * the snapshot half found something, because both then answer one condition and only one of them
124
+ * is an instruction — `schema hashes to 3f2a, newest migration recorded 91bc` names no constraint,
125
+ * no column and no table.
126
+ */
127
+ export async function checkMigrationDrift(
128
+ root: string,
129
+ declared: DeclaredEntities = appEntities,
130
+ hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
131
+ ): Promise<readonly Finding[]> {
132
+ const snapshot = await checkSnapshotDrift(root, declared);
133
+ if (snapshot.length > 0) return snapshot;
134
+ return hashDrift(root);
135
+ }
@@ -0,0 +1,28 @@
1
+ // The one code raised while installing the framework's own tables. Apart from `errors.ts` for the
2
+ // reason `packages/jobs/src/backfill-errors.ts` is apart from that package's: one file, one job,
3
+ // and `errors.ts` is at 486 lines against the 500 the `filesize` step enforces. The code, its
4
+ // title and its registration stay in `error-codes.ts`, where every other CLI code lives.
5
+
6
+ import { renderThrowable, UltimateError } from '@ultimat3/core';
7
+
8
+ /**
9
+ * A statement in `FRAMEWORK_SCHEMA` did not apply. Raised in place of the driver's own rejection,
10
+ * which names neither the framework table being created nor the package that wants it — and a boot
11
+ * failure is read by an operator with no source tree open.
12
+ *
13
+ * `renderThrowable` and never `${cause}`: a `catch` binding is annotated by nobody, and a pool
14
+ * rejection is routinely an object whose `toString` throws.
15
+ */
16
+ export class FrameworkSchemaFailedError extends UltimateError {
17
+ constructor(input: { pkg: string; tables: readonly string[]; cause: unknown }) {
18
+ super({
19
+ code: 'X_FRAMEWORK_SCHEMA_FAILED',
20
+ cause: `${input.pkg} could not create ${input.tables.join(', ')}: ${renderThrowable(input.cause)}`,
21
+ // An EDIT plus the command that CONFIRMS it, which is the house shape for a repair the gate
22
+ // cannot perform: the two real causes are a role without `create`, and a relation of that
23
+ // name already present with an incompatible shape.
24
+ fix: `grant create on schema public to the role in DATABASE_URL, then re-run: x db migrate`,
25
+ meta: { pkg: input.pkg, tables: [...input.tables] },
26
+ });
27
+ }
28
+ }
@@ -23,8 +23,8 @@ import { policyFindings } from './app-permissions';
23
23
  import { APP_CONFIG_FILE } from './app-root';
24
24
  import { checkBudgets, readBuildStats } from './budgets';
25
25
  import { checkDestructiveMigrations } from './db-destructive';
26
+ import { checkUngeneratableMigrations } from './db-ungeneratable';
26
27
  import { checkDocumentStyles, documentSurfaces } from './document-styles';
27
- import { checkSourceDrift } from './drift';
28
28
  import { checkErrorCodeResolution, checkErrorFixReport } from './error-contract';
29
29
  import { guardFindings } from './guards';
30
30
  import { catalogFindings } from './i18n-registration';
@@ -32,6 +32,7 @@ import { liveRouteFindings } from './live-routes';
32
32
  import { msg } from './messages';
33
33
  import type { Finding } from './output';
34
34
  import { findingFrom } from './output';
35
+ import { checkMigrationDrift } from './schema-drift';
35
36
  import { scanSiteMeta } from './seo-meta';
36
37
  import { floorProblemFindings, readVerifyFloor } from './verify-floor';
37
38
  import type { VerifyStep } from './verify-step';
@@ -147,7 +148,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
147
148
  ...TEST_STEPS,
148
149
  {
149
150
  name: 'drift',
150
- summary: 'schema source vs migrations, and every destructive statement declared',
151
+ summary:
152
+ 'entity declarations vs migrations, every destructive statement declared, and every statement no declaration carries',
151
153
  // Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
152
154
  // Source, not database: the gate runs in CI with nothing listening, and the database half is
153
155
  // the post-migrate verification `runMigrations` performs where a connection is already open.
@@ -158,8 +160,13 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
158
160
  applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
159
161
  run: async (ctx) =>
160
162
  fromFindings([
161
- ...(await checkSourceDrift(ctx.root)),
163
+ ...(await checkMigrationDrift(ctx.root)),
162
164
  ...(await checkDestructiveMigrations(ctx.root)),
165
+ // The third rail, and the one the other two cannot see: a hand-written statement is
166
+ // recorded by no snapshot and hashed by no source, so both halves above are green over SQL
167
+ // a squash silently drops. Same directory, same reader, no database — this step's own
168
+ // question, which is why it is not an eighteenth step.
169
+ ...(await checkUngeneratableMigrations(ctx.root)),
163
170
  ]),
164
171
  },
165
172
  {