thatcher 1.0.4 → 1.0.6

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 (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. package/src/ui/widgets.js +2 -2
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Debug Registry - Comprehensive globalThis.__debug__ registry for all Thatcher subsystems
3
+ * Modules register on import/init; supports nested namespaces and live data
4
+ */
5
+
6
+ import { createLogger } from './logger.js';
7
+
8
+ const logger = createLogger('[DebugRegistry]');
9
+
10
+ class DebugRegistry {
11
+ constructor() {
12
+ this._modules = new Map();
13
+ this._metadata = new Map();
14
+ }
15
+
16
+ expose(path, valueOrGetter, description = '') {
17
+ const parts = path.split('.');
18
+ const leaf = parts.pop();
19
+
20
+ let current = this._modules;
21
+ for (const part of parts) {
22
+ if (!current.has(part)) {
23
+ current.set(part, new Map());
24
+ }
25
+ current = current.get(part);
26
+ }
27
+
28
+ current.set(leaf, valueOrGetter);
29
+ this._metadata.set(path, { description, registeredAt: Date.now() });
30
+
31
+ this._syncToGlobal();
32
+ return this;
33
+ }
34
+
35
+ remove(path) {
36
+ const parts = path.split('.');
37
+ const leaf = parts.pop();
38
+
39
+ let current = this._modules;
40
+ for (const part of parts) {
41
+ if (!current.has(part)) return this;
42
+ current = current.get(part);
43
+ }
44
+
45
+ current.delete(leaf);
46
+ this._metadata.delete(path);
47
+
48
+ this._syncToGlobal();
49
+ return this;
50
+ }
51
+
52
+ get(path) {
53
+ const parts = path.split('.');
54
+ let current = this._modules;
55
+
56
+ for (const part of parts) {
57
+ if (!current.has(part)) return undefined;
58
+ current = current.get(part);
59
+ }
60
+
61
+ return typeof current === 'function' ? current() : current;
62
+ }
63
+
64
+ inspect(path) {
65
+ const value = this.get(path);
66
+ if (value === undefined) return { error: `Path "${path}" not found` };
67
+
68
+ const meta = this._metadata.get(path);
69
+ return {
70
+ path,
71
+ value: typeof value === 'function' ? value() : value,
72
+ metadata: meta,
73
+ };
74
+ }
75
+
76
+ list(prefix = '') {
77
+ const results = [];
78
+ for (const [path, meta] of this._metadata.entries()) {
79
+ if (!prefix || path.startsWith(prefix)) {
80
+ results.push({ path, description: meta.description, registeredAt: meta.registeredAt });
81
+ }
82
+ }
83
+ return results;
84
+ }
85
+
86
+ health() {
87
+ const checks = {};
88
+
89
+ if (globalThis.healthState) {
90
+ checks.health = globalThis.healthState;
91
+ }
92
+
93
+ if (globalThis.__resources) {
94
+ checks.resources = globalThis.__resources;
95
+ }
96
+
97
+ if (globalThis.__alerts) {
98
+ checks.alerts = globalThis.__alerts;
99
+ }
100
+
101
+ if (globalThis.__dbMonitor) {
102
+ checks.database = {
103
+ activeQueries: globalThis.__dbMonitor?.activeQueries?.length || 0,
104
+ slowQueries: globalThis.__dbMonitor?.slowQueries?.length || 0,
105
+ };
106
+ }
107
+
108
+ checks.modules = this._modules.size;
109
+ checks.uptime = process.uptime();
110
+ checks.memory = process.memoryUsage();
111
+
112
+ return checks;
113
+ }
114
+
115
+ toJSON() {
116
+ return this.list().map(item => ({
117
+ ...item,
118
+ value: this.get(item.path),
119
+ }));
120
+ }
121
+
122
+ _syncToGlobal() {
123
+ if (!globalThis.__debug__) {
124
+ globalThis.__debug__ = {
125
+ expose: (path, value, desc) => this.expose(path, value, desc),
126
+ get: (path) => this.get(path),
127
+ inspect: (path) => this.inspect(path),
128
+ list: (prefix) => this.list(prefix),
129
+ health: () => this.health(),
130
+ remove: (path) => this.remove(path),
131
+ };
132
+ } else {
133
+ globalThis.__debug__.expose = (path, value, desc) => this.expose(path, value, desc);
134
+ globalThis.__debug__.get = (path) => this.get(path);
135
+ globalThis.__debug__.inspect = (path) => this.inspect(path);
136
+ globalThis.__debug__.list = (prefix) => this.list(prefix);
137
+ globalThis.__debug__.health = () => this.health();
138
+ globalThis.__debug__.remove = (path) => this.remove(path);
139
+ }
140
+ }
141
+ }
142
+
143
+ export const debugRegistry = new DebugRegistry();
144
+
145
+ export function expose(path, value, description = '') {
146
+ return debugRegistry.expose(path, value, description);
147
+ }
148
+
149
+ export function removeDebug(path) {
150
+ return debugRegistry.remove(path);
151
+ }
152
+
153
+ export function getDebug(path) {
154
+ return debugRegistry.get(path);
155
+ }
156
+
157
+ export function inspectDebug(path) {
158
+ return debugRegistry.inspect(path);
159
+ }
160
+
161
+ export function listDebug(prefix = '') {
162
+ return debugRegistry.list(prefix);
163
+ }
164
+
165
+ export default DebugRegistry;
@@ -80,5 +80,83 @@ export function createErrorLogger(context = '') {
80
80
  warn: (msg, meta = {}) => {
81
81
  console.warn(`[${context}] ${msg}`, meta);
82
82
  },
83
+ info: (_msg, _meta = {}) => {},
84
+ debug: (msg, meta = {}) => {
85
+ if (process.env.DEBUG) {
86
+ console.debug(`[${context}] Debug:`, msg, meta);
87
+ }
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Normalize an arbitrary thrown value into one of thatcher's AppError-class
94
+ * instances. AppError instances (and anything already shaped like one — a
95
+ * numeric `status` + a `code`) pass through untouched; common low-level
96
+ * errors are mapped to the appropriate thatcher error class.
97
+ *
98
+ * (Ported from moonlanding, adapted to thatcher's class-based hierarchy and
99
+ * its `status`/`code`/`details` shape. Kept import-free so the module stays
100
+ * loadable under the plain-node test runner.)
101
+ * @param {unknown} error
102
+ * @returns {AppError}
103
+ */
104
+ export function normalizeError(error) {
105
+ if (error instanceof AppError) {
106
+ return error;
107
+ }
108
+
109
+ if (error && typeof error === 'object' && typeof error.status === 'number' && error.code) {
110
+ return error;
111
+ }
112
+
113
+ if (error instanceof SyntaxError) {
114
+ return new BadRequestError('Invalid request format');
115
+ }
116
+
117
+ if (error instanceof TypeError) {
118
+ return new AppError('Invalid operation', 'TYPE_ERROR', 400, { originalMessage: error.message });
119
+ }
120
+
121
+ const message = error && error.message ? String(error.message) : '';
122
+
123
+ if (message.includes('database is locked')) {
124
+ return new DatabaseError('operation', error);
125
+ }
126
+
127
+ if (message.includes('UNIQUE constraint failed')) {
128
+ const field = message.match(/UNIQUE constraint failed: (.+)/)?.[1] || 'record';
129
+ return new ConflictError(`${field} already exists`);
130
+ }
131
+
132
+ return new AppError(message || 'An unexpected error occurred', 'INTERNAL_ERROR', 500, {
133
+ originalMessage: message,
134
+ stack: error && error.stack ? error.stack.split('\n').slice(0, 3).join('\n') : undefined,
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Build a JSON-serializable error response body from any thrown value.
140
+ * (Ported from moonlanding, adapted to thatcher's error shape.)
141
+ * @param {unknown} error
142
+ * @param {boolean} [includeStack=false]
143
+ * @returns {object}
144
+ */
145
+ export function formatErrorResponse(error, includeStack = false) {
146
+ const normalized = normalizeError(error);
147
+ const response = {
148
+ status: 'error',
149
+ message: normalized.message,
150
+ code: normalized.code,
151
+ statusCode: normalized.status,
83
152
  };
153
+
154
+ if (normalized.details) response.details = normalized.details;
155
+ if (normalized.errors) response.errors = normalized.errors;
156
+
157
+ if (includeStack && error && error.stack) {
158
+ response.stack = error.stack.split('\n').slice(0, 5);
159
+ }
160
+
161
+ return response;
84
162
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Observability Export Sink - Streams traces and metrics to external systems
3
+ * Supports file, HTTP (OTLP), and stdout targets with batching and retry
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { createLogger } from './logger.js';
9
+
10
+ const logger = createLogger('[ExportSink]');
11
+
12
+ const BATCH_DEFAULT_SIZE = 100;
13
+ const BATCH_DEFAULT_INTERVAL_MS = 5000;
14
+ const MAX_QUEUE_SIZE = 10000;
15
+ const RETRY_BACKOFF_MS = [1000, 2000, 5000, 10000, 30000];
16
+
17
+ export class ExportSink {
18
+ constructor(options = {}) {
19
+ this.target = options.target || process.env.OBSERVABILITY_EXPORT_TARGET || 'stdout';
20
+ this.url = options.url || process.env.OBSERVABILITY_EXPORT_URL || '';
21
+ this.batchSize = options.batchSize || BATCH_DEFAULT_SIZE;
22
+ this.batchIntervalMs = options.batchIntervalMs || BATCH_DEFAULT_INTERVAL_MS;
23
+ this._queue = [];
24
+ this._running = false;
25
+ this._timer = null;
26
+ this._exportCount = 0;
27
+ this._errorCount = 0;
28
+ this._lastError = null;
29
+ }
30
+
31
+ async init() {
32
+ if (this.target === 'file') {
33
+ this._filePath = options.filePath || process.env.OBSERVABILITY_EXPORT_FILE || path.join(process.cwd(), 'observability.jsonl');
34
+ logger.info('File export sink initialized', { path: this._filePath });
35
+ } else if (this.target === 'http') {
36
+ if (!this.url) {
37
+ logger.error('HTTP export requires OBSERVABILITY_EXPORT_URL');
38
+ return;
39
+ }
40
+ logger.info('HTTP export sink initialized', { url: this.url });
41
+ } else {
42
+ logger.info('Stdout export sink initialized');
43
+ }
44
+
45
+ this._startBatchLoop();
46
+ globalThis.__trace_export__ = this;
47
+
48
+ return this;
49
+ }
50
+
51
+ async export(span) {
52
+ if (this._queue.length >= MAX_QUEUE_SIZE) {
53
+ this._queue.shift();
54
+ }
55
+
56
+ this._queue.push(span);
57
+
58
+ if (this._queue.length >= this.batchSize) {
59
+ await this._flush();
60
+ }
61
+ }
62
+
63
+ async _flush() {
64
+ if (this._queue.length === 0 || this._running) return;
65
+
66
+ this._running = true;
67
+ const batch = this._queue.splice(0, this.batchSize);
68
+
69
+ try {
70
+ await this._sendBatch(batch);
71
+ this._exportCount += batch.length;
72
+ } catch (error) {
73
+ this._errorCount += batch.length;
74
+ this._lastError = error.message;
75
+ logger.error('Export failed', { error: error.message, target: this.target });
76
+
77
+ this._queue.unshift(...batch);
78
+ if (this._queue.length > MAX_QUEUE_SIZE) {
79
+ this._queue.length = MAX_QUEUE_SIZE;
80
+ }
81
+ } finally {
82
+ this._running = false;
83
+ }
84
+ }
85
+
86
+ async _sendBatch(batch) {
87
+ const otlpBatch = batch.map(span => this._toOTLP(span));
88
+
89
+ switch (this.target) {
90
+ case 'file':
91
+ await this._exportToFile(otlpBatch);
92
+ break;
93
+ case 'http':
94
+ await this._exportToHTTP(otlpBatch);
95
+ break;
96
+ case 'stdout':
97
+ default:
98
+ await this._exportToStdout(otlpBatch);
99
+ break;
100
+ }
101
+ }
102
+
103
+ async _exportToFile(batch) {
104
+ const filePath = this._filePath || path.join(process.cwd(), 'observability.jsonl');
105
+ const lines = batch.map(s => JSON.stringify(s)).join('\n') + '\n';
106
+ fs.appendFileSync(filePath, lines);
107
+ }
108
+
109
+ async _exportToHTTP(batch) {
110
+ let lastError;
111
+
112
+ for (let attempt = 0; attempt < RETRY_BACKOFF_MS.length; attempt++) {
113
+ try {
114
+ const response = await fetch(this.url, {
115
+ method: 'POST',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ },
119
+ body: JSON.stringify({ resourceSpans: batch }),
120
+ signal: AbortSignal.timeout(10000),
121
+ });
122
+
123
+ if (!response.ok) {
124
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
125
+ }
126
+
127
+ return;
128
+ } catch (error) {
129
+ lastError = error;
130
+ if (attempt < RETRY_BACKOFF_MS.length - 1) {
131
+ await new Promise(resolve => setTimeout(resolve, RETRY_BACKOFF_MS[attempt]));
132
+ }
133
+ }
134
+ }
135
+
136
+ throw lastError;
137
+ }
138
+
139
+ async _exportToStdout(batch) {
140
+ for (const span of batch) {
141
+ console.log(JSON.stringify(span));
142
+ }
143
+ }
144
+
145
+ _toOTLP(span) {
146
+ return {
147
+ traceId: span.traceId,
148
+ spanId: span.spanId,
149
+ parentSpanId: span.parentSpanId,
150
+ name: span.name,
151
+ kind: span.kind,
152
+ startTimeUnixNano: (span.startTime * 1000000).toString(),
153
+ endTimeUnixNano: span.endTime ? (span.endTime * 1000000).toString() : null,
154
+ durationNano: span.duration ? (span.duration * 1000000).toString() : null,
155
+ status: { code: span.status === 'ok' ? 1 : 2, message: span.attributes.get('status.message') || '' },
156
+ attributes: Object.entries(span.attributes).map(([key, value]) => ({
157
+ key,
158
+ value: this._toOTLPValue(value),
159
+ })),
160
+ events: span.events.map(e => ({
161
+ timeUnixNano: (e.timestamp * 1000000).toString(),
162
+ name: e.name,
163
+ attributes: Object.entries(e.attributes).map(([key, value]) => ({
164
+ key,
165
+ value: this._toOTLPValue(value),
166
+ })),
167
+ })),
168
+ };
169
+ }
170
+
171
+ _toOTLPValue(value) {
172
+ if (value === null || value === undefined) {
173
+ return { stringValue: 'null' };
174
+ }
175
+ switch (typeof value) {
176
+ case 'string': return { stringValue: value };
177
+ case 'number': return Number.isInteger(value) ? { intValue: value.toString() } : { doubleValue: value };
178
+ case 'boolean': return { boolValue: value };
179
+ default: return { stringValue: JSON.stringify(value) };
180
+ }
181
+ }
182
+
183
+ _startBatchLoop() {
184
+ this._timer = setInterval(() => {
185
+ this._flush().catch(() => {});
186
+ }, this.batchIntervalMs);
187
+
188
+ this._timer.unref();
189
+ }
190
+
191
+ getStats() {
192
+ return {
193
+ target: this.target,
194
+ queueSize: this._queue.length,
195
+ exportCount: this._exportCount,
196
+ errorCount: this._errorCount,
197
+ lastError: this._lastError,
198
+ running: this._running,
199
+ };
200
+ }
201
+
202
+ async close() {
203
+ if (this._timer) {
204
+ clearInterval(this._timer);
205
+ this._timer = null;
206
+ }
207
+
208
+ await this._flush();
209
+ }
210
+ }
211
+
212
+ let _sink = null;
213
+
214
+ export async function createExportSink(options = {}) {
215
+ if (!_sink) {
216
+ _sink = new ExportSink(options);
217
+ await _sink.init();
218
+ }
219
+ return _sink;
220
+ }
221
+
222
+ export function getExportSink() {
223
+ return _sink;
224
+ }
225
+
226
+ export default ExportSink;
@@ -48,3 +48,129 @@ export function getFieldsByType(spec, type) {
48
48
  return Object.entries(spec.fields || {})
49
49
  .filter(([key, field]) => field.type === type);
50
50
  }
51
+
52
+ /* ------------------------------------------------------------------ *
53
+ * Predicate-based field query helpers (ported from moonlanding).
54
+ * Additive: these do not change thatcher's existing forEachField /
55
+ * getEditableFields / getFieldNames / getFieldsByType signatures.
56
+ * ------------------------------------------------------------------ */
57
+
58
+ /**
59
+ * Query the fields of a spec with one or more predicates.
60
+ * @param {object} spec
61
+ * @param {Function|Function[]} predicates - (field, key) => boolean
62
+ * @param {object} [options] - { keysOnly }
63
+ * @returns {Array<string|object>} keys when keysOnly, else { key, ...field }
64
+ */
65
+ export function fieldQuery(spec, predicates, options = {}) {
66
+ const preds = Array.isArray(predicates) ? predicates : [predicates];
67
+ const results = [];
68
+ for (const [key, field] of Object.entries(spec.fields || {})) {
69
+ if (preds.every(pred => pred(field, key))) {
70
+ results.push(options.keysOnly ? key : { key, ...field });
71
+ }
72
+ }
73
+ return results;
74
+ }
75
+
76
+ /**
77
+ * Reusable field predicates for fieldQuery.
78
+ */
79
+ export const is = {
80
+ notId: f => f.type !== 'id',
81
+ notHidden: f => !f.hidden,
82
+ notReadOnly: f => !f.readOnly,
83
+ required: f => f.required,
84
+ searchable: f => f.search,
85
+ listable: f => f.list === true,
86
+ ref: f => f.type === 'ref' && f.ref,
87
+ editable: f => !f.hidden && !f.readOnly && f.type !== 'id' && !f.auto && f.type !== 'auto_timestamp' && !f.auto_generate,
88
+ displayable: f => !f.hidden && f.type !== 'id',
89
+ ofType: type => f => f.type === type,
90
+ hasProperty: prop => f => f[prop] !== undefined,
91
+ };
92
+
93
+ /**
94
+ * Editable fields as full { key, ...field } objects (form rendering).
95
+ * Distinct from thatcher's getEditableFields (which returns names only).
96
+ */
97
+ export function getFormFields(spec) {
98
+ if (spec.system_entity) return [];
99
+ return fieldQuery(spec, is.editable);
100
+ }
101
+
102
+ /**
103
+ * Listable fields as full { key, ...field } objects.
104
+ */
105
+ export function getListFields(spec) {
106
+ return fieldQuery(spec, is.listable);
107
+ }
108
+
109
+ /**
110
+ * Displayable fields as full { key, ...field } objects.
111
+ */
112
+ export function getDisplayFields(spec) {
113
+ return fieldQuery(spec, is.displayable);
114
+ }
115
+
116
+ /**
117
+ * Required field names.
118
+ */
119
+ export function getRequiredFields(spec) {
120
+ return fieldQuery(spec, is.required, { keysOnly: true });
121
+ }
122
+
123
+ /**
124
+ * Searchable field names.
125
+ */
126
+ export function getSearchFields(spec) {
127
+ return fieldQuery(spec, is.searchable, { keysOnly: true });
128
+ }
129
+
130
+ /**
131
+ * Filterable fields, resolved from list.filters config.
132
+ */
133
+ export function getFilterableFields(spec) {
134
+ return (spec.list?.filters || [])
135
+ .map(filterKey => spec.fields?.[filterKey])
136
+ .filter(Boolean);
137
+ }
138
+
139
+ /**
140
+ * Reference (ref) fields as full { key, ...field } objects.
141
+ */
142
+ export function getRefFields(spec) {
143
+ return fieldQuery(spec, is.ref);
144
+ }
145
+
146
+ /**
147
+ * Get a single field definition by key.
148
+ */
149
+ export function getField(spec, fieldKey) {
150
+ return spec.fields?.[fieldKey];
151
+ }
152
+
153
+ /**
154
+ * Get a single field's type by key.
155
+ */
156
+ export function getFieldType(spec, fieldKey) {
157
+ return spec.fields?.[fieldKey]?.type;
158
+ }
159
+
160
+ /**
161
+ * Iterate editable fields on create. callback = (key, field) => void
162
+ */
163
+ export function iterateCreateFields(spec, callback) {
164
+ for (const { key, ...field } of fieldQuery(spec, is.editable)) {
165
+ callback(key, field);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Iterate editable fields on update. callback = (key, field) => void
171
+ */
172
+ export function iterateUpdateFields(spec, callback) {
173
+ for (const { key, ...field } of fieldQuery(spec, is.editable)) {
174
+ callback(key, field);
175
+ }
176
+ }