@upstash/redis-analytics 0.1.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/backend-client.d.ts +38 -0
  4. package/dist/backend-client.js +189 -0
  5. package/dist/client.d.ts +27 -0
  6. package/dist/client.js +157 -0
  7. package/dist/index.d.ts +11 -0
  8. package/dist/index.js +20 -0
  9. package/dist/protocol.d.ts +45 -0
  10. package/dist/protocol.js +4 -0
  11. package/dist/react.d.ts +33 -0
  12. package/dist/react.js +95 -0
  13. package/dist/services/events.d.ts +26 -0
  14. package/dist/services/events.js +143 -0
  15. package/dist/services/feature-flags.d.ts +14 -0
  16. package/dist/services/feature-flags.js +88 -0
  17. package/dist/services/logging.d.ts +13 -0
  18. package/dist/services/logging.js +66 -0
  19. package/dist/services/schema-registry.d.ts +15 -0
  20. package/dist/services/schema-registry.js +97 -0
  21. package/dist/services/search-index.d.ts +35 -0
  22. package/dist/services/search-index.js +293 -0
  23. package/dist/services/sessions.d.ts +18 -0
  24. package/dist/services/sessions.js +58 -0
  25. package/dist/types.d.ts +144 -0
  26. package/dist/types.js +2 -0
  27. package/dist/utils.d.ts +6 -0
  28. package/dist/utils.js +44 -0
  29. package/package.json +36 -0
  30. package/src/backend-client.ts +301 -0
  31. package/src/client.ts +245 -0
  32. package/src/index.ts +39 -0
  33. package/src/protocol.ts +57 -0
  34. package/src/react.ts +163 -0
  35. package/src/services/events.ts +187 -0
  36. package/src/services/feature-flags.ts +125 -0
  37. package/src/services/logging.ts +81 -0
  38. package/src/services/schema-registry.ts +125 -0
  39. package/src/services/search-index.ts +335 -0
  40. package/src/services/sessions.ts +86 -0
  41. package/src/types.ts +194 -0
  42. package/src/utils.ts +45 -0
@@ -0,0 +1,335 @@
1
+ import type { Redis } from "@upstash/redis";
2
+ import { s } from "@upstash/redis";
3
+ import type { AnalyticsConfig, EventSchema, SearchIndexInfo } from "../types";
4
+ import { LoggingService } from "./logging";
5
+ import { SchemaRegistry } from "./schema-registry";
6
+ import { FeatureFlagService } from "./feature-flags";
7
+
8
+ export class SearchIndexService {
9
+ constructor(
10
+ private redis: Redis,
11
+ private config: AnalyticsConfig,
12
+ private schemaRegistry: SchemaRegistry,
13
+ private featureFlags: FeatureFlagService,
14
+ private logger: LoggingService
15
+ ) {}
16
+
17
+ private get indexName() {
18
+ return this.config.search.indexName;
19
+ }
20
+
21
+ private get indexNameB() {
22
+ return `${this.indexName}-b`;
23
+ }
24
+
25
+ private buildSchema(
26
+ eventSchemas: Record<string, EventSchema>,
27
+ featureFlagNames: string[]
28
+ ) {
29
+ // Build the schema object for Redis Search
30
+ // Using `any` for mixed field types since s.object accepts heterogeneous schemas
31
+ const schemaFields: Record<string, any> = {};
32
+
33
+ // Fixed fields
34
+ schemaFields["eventName"] = s.keyword();
35
+ schemaFields["eventTime"] = s.date().fast();
36
+ schemaFields["eventTimeMs"] = s.number("F64");
37
+ schemaFields["sessionId"] = s.string().noTokenize();
38
+ schemaFields["sessionCreationTimestamp"] = s.date().fast();
39
+
40
+ // Feature flag fields from definitions stored in Redis
41
+ for (const flagName of featureFlagNames) {
42
+ schemaFields[`featureFlags.${flagName}`] = s.string().noTokenize();
43
+ }
44
+
45
+ // Dynamic fields from event schemas
46
+ for (const [eventName, eventSchema] of Object.entries(eventSchemas)) {
47
+ // Session metadata schema uses sessionMetadata.* prefix
48
+ const prefix = eventName === "__session_metadata__" ? "sessionMetadata" : "properties";
49
+
50
+ for (const [propName, propDef] of Object.entries(
51
+ eventSchema.properties
52
+ )) {
53
+ const fieldName = `${prefix}.${propName}`;
54
+ if (schemaFields[fieldName]) continue; // Already defined
55
+
56
+ switch (propDef.type) {
57
+ case "string":
58
+ schemaFields[fieldName] = s.string();
59
+ break;
60
+ case "number":
61
+ schemaFields[fieldName] = s.number("F64");
62
+ break;
63
+ case "boolean":
64
+ schemaFields[fieldName] = s.boolean();
65
+ break;
66
+ }
67
+ }
68
+ }
69
+
70
+ return s.object(schemaFields);
71
+ }
72
+
73
+ private async findAliasTarget(): Promise<string | null> {
74
+ try {
75
+ const aliases = await this.redis.search.alias.list();
76
+ // aliases is a record of alias -> index name
77
+ const target = aliases[this.indexName];
78
+ return target ?? null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ private async indexExists(name: string): Promise<boolean> {
85
+ try {
86
+ const index = this.redis.search.index({ name });
87
+ await index.describe();
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async rebuild(): Promise<void> {
95
+ const [eventSchemas, flagDefinitions] = await Promise.all([
96
+ this.schemaRegistry.getAllSchemas(),
97
+ this.featureFlags.getDefinitions(),
98
+ ]);
99
+ const schema = this.buildSchema(eventSchemas, Object.keys(flagDefinitions));
100
+
101
+ const aliasTarget = await this.findAliasTarget();
102
+ const indexAExists = await this.indexExists(this.indexName);
103
+ const indexBExists = await this.indexExists(this.indexNameB);
104
+
105
+ // Case 1: No index exists at all — create index A
106
+ if (!indexAExists && !indexBExists && !aliasTarget) {
107
+ const newIndex = await this.redis.search.createIndex({
108
+ name: this.indexName,
109
+ schema,
110
+ dataType: "json",
111
+ prefix: "event-",
112
+ existsOk: true,
113
+ });
114
+ await newIndex.waitIndexing();
115
+
116
+ await this.logger.log({
117
+ logType: "index_update",
118
+ metadata: { action: "create", indexName: this.indexName },
119
+ changes: {
120
+ before: null,
121
+ after: { activeIndex: this.indexName },
122
+ },
123
+ });
124
+ return;
125
+ }
126
+
127
+ // Case 2: Alias exists pointing to B — swap back to A
128
+ if (aliasTarget === this.indexNameB) {
129
+ const newIndex = await this.redis.search.createIndex({
130
+ name: this.indexName,
131
+ schema,
132
+ dataType: "json",
133
+ prefix: "event-",
134
+ existsOk: true,
135
+ });
136
+ await newIndex.waitIndexing();
137
+
138
+ // Delete alias and old index B
139
+ await this.redis.search.alias.delete({ alias: this.indexName });
140
+ try {
141
+ await this.redis.search.index({ name: this.indexNameB }).drop();
142
+ } catch {
143
+ // Ignore if already gone
144
+ }
145
+
146
+ await this.logger.log({
147
+ logType: "index_update",
148
+ metadata: { action: "update", indexName: this.indexName },
149
+ changes: {
150
+ before: { activeIndex: this.indexNameB },
151
+ after: { activeIndex: this.indexName },
152
+ },
153
+ });
154
+ return;
155
+ }
156
+
157
+ // Case 3: No alias, index A exists — create B, alias to B, delete A
158
+ if (indexAExists && !aliasTarget) {
159
+ const newIndex = await this.redis.search.createIndex({
160
+ name: this.indexNameB,
161
+ schema,
162
+ dataType: "json",
163
+ prefix: "event-",
164
+ existsOk: true,
165
+ });
166
+ await newIndex.waitIndexing();
167
+
168
+ // Create alias pointing to B, then delete old index A
169
+ await this.redis.search.index({ name: this.indexName }).drop();
170
+ await this.redis.search.alias.add({
171
+ alias: this.indexName,
172
+ indexName: this.indexNameB,
173
+ });
174
+
175
+ await this.logger.log({
176
+ logType: "index_update",
177
+ metadata: { action: "update", indexName: this.indexNameB },
178
+ changes: {
179
+ before: { activeIndex: this.indexName },
180
+ after: { activeIndex: this.indexNameB },
181
+ },
182
+ });
183
+ return;
184
+ }
185
+
186
+ // Fallback: unexpected state — recreate from scratch
187
+ try {
188
+ await this.redis.search.alias.delete({ alias: this.indexName });
189
+ } catch { /* no alias */ }
190
+ try {
191
+ await this.redis.search.index({ name: this.indexName }).drop();
192
+ } catch { /* doesn't exist */ }
193
+ try {
194
+ await this.redis.search.index({ name: this.indexNameB }).drop();
195
+ } catch { /* doesn't exist */ }
196
+
197
+ const newIndex = await this.redis.search.createIndex({
198
+ name: this.indexName,
199
+ schema,
200
+ dataType: "json",
201
+ prefix: "event-",
202
+ existsOk: true,
203
+ });
204
+ await newIndex.waitIndexing();
205
+
206
+ await this.logger.log({
207
+ logType: "index_update",
208
+ metadata: { action: "create", indexName: this.indexName },
209
+ changes: {
210
+ before: null,
211
+ after: { activeIndex: this.indexName },
212
+ },
213
+ });
214
+ }
215
+
216
+ async getInfo(): Promise<SearchIndexInfo> {
217
+ // Try main index first, then B
218
+ for (const name of [this.indexName, this.indexNameB]) {
219
+ try {
220
+ const index = this.redis.search.index({ name });
221
+ const info = await index.describe();
222
+ if (!info) continue;
223
+ const countResult = await index.count({ filter: {} });
224
+
225
+ return {
226
+ currentIndex: name,
227
+ alias: this.indexName,
228
+ schema: info.schema as Record<string, unknown>,
229
+ documentCount: countResult.count,
230
+ };
231
+ } catch {
232
+ continue;
233
+ }
234
+ }
235
+
236
+ return {
237
+ currentIndex: "none",
238
+ alias: this.indexName,
239
+ schema: {},
240
+ documentCount: 0,
241
+ };
242
+ }
243
+
244
+ getActiveIndex() {
245
+ // Return an index reference for querying
246
+ return this.redis.search.index({ name: this.indexName });
247
+ }
248
+
249
+ getActiveIndexWithFallback() {
250
+ // Try main index, fallback to B
251
+ return {
252
+ primary: this.redis.search.index({ name: this.indexName }),
253
+ fallback: this.redis.search.index({ name: this.indexNameB }),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Compares the expected schema (built from config + schema registry)
259
+ * against the actual index schema. Returns drift info.
260
+ */
261
+ async checkSchemaDrift(): Promise<{
262
+ hasDrift: boolean;
263
+ missingFields: string[];
264
+ extraFields: string[];
265
+ indexExists: boolean;
266
+ }> {
267
+ const info = await this.getInfo();
268
+
269
+ if (info.currentIndex === "none") {
270
+ return {
271
+ hasDrift: false,
272
+ missingFields: [],
273
+ extraFields: [],
274
+ indexExists: false,
275
+ };
276
+ }
277
+
278
+ // Build expected schema from registries
279
+ const [eventSchemas, flagDefinitions] = await Promise.all([
280
+ this.schemaRegistry.getAllSchemas(),
281
+ this.featureFlags.getDefinitions(),
282
+ ]);
283
+ const expectedFields = new Set<string>();
284
+
285
+ // Fixed fields
286
+ expectedFields.add("eventName");
287
+ expectedFields.add("eventTime");
288
+ expectedFields.add("eventTimeMs");
289
+ expectedFields.add("sessionId");
290
+ expectedFields.add("sessionCreationTimestamp");
291
+
292
+ // Feature flag fields
293
+ for (const flagName of Object.keys(flagDefinitions)) {
294
+ expectedFields.add(`featureFlags.${flagName}`);
295
+ }
296
+
297
+ // Dynamic fields from event schemas
298
+ for (const [eventName, eventSchema] of Object.entries(eventSchemas)) {
299
+ const prefix = eventName === "__session_metadata__" ? "sessionMetadata" : "properties";
300
+ for (const propName of Object.keys(eventSchema.properties)) {
301
+ expectedFields.add(`${prefix}.${propName}`);
302
+ }
303
+ }
304
+
305
+ // Get actual indexed fields from the schema info
306
+ // The schema from describe() uses $ prefix for JSON paths
307
+ const actualFields = new Set<string>();
308
+ for (const key of Object.keys(info.schema)) {
309
+ // Normalize: remove leading "$." prefix if present
310
+ const normalized = key.startsWith("$.") ? key.slice(2) : key;
311
+ actualFields.add(normalized);
312
+ }
313
+
314
+ const missingFields: string[] = [];
315
+ for (const field of expectedFields) {
316
+ if (!actualFields.has(field)) {
317
+ missingFields.push(field);
318
+ }
319
+ }
320
+
321
+ const extraFields: string[] = [];
322
+ for (const field of actualFields) {
323
+ if (!expectedFields.has(field)) {
324
+ extraFields.push(field);
325
+ }
326
+ }
327
+
328
+ return {
329
+ hasDrift: missingFields.length > 0 || extraFields.length > 0,
330
+ missingFields,
331
+ extraFields,
332
+ indexExists: true,
333
+ };
334
+ }
335
+ }
@@ -0,0 +1,86 @@
1
+ import type { Redis } from "@upstash/redis";
2
+ import type {
3
+ AnalyticsConfig,
4
+ FeatureFlagAssignment,
5
+ Session,
6
+ SessionMetadata,
7
+ SessionResult,
8
+ } from "../types";
9
+ import { FeatureFlagService } from "./feature-flags";
10
+ import { LoggingService } from "./logging";
11
+ import { generateSessionId } from "../utils";
12
+
13
+ const SESSION_PREFIX = "analytics:session:";
14
+
15
+ export class SessionService {
16
+ constructor(
17
+ private redis: Redis,
18
+ private config: AnalyticsConfig,
19
+ private featureFlags: FeatureFlagService,
20
+ private logger: LoggingService
21
+ ) {}
22
+
23
+ async createSession(options?: {
24
+ featureFlags?: Record<string, FeatureFlagAssignment>;
25
+ metadata?: Record<string, unknown>;
26
+ }): Promise<Session> {
27
+ // Resolve feature flags
28
+ const resolvedFlags = await this.featureFlags.resolveFlags(
29
+ options?.featureFlags
30
+ );
31
+
32
+ const sessionId = generateSessionId();
33
+ const createdAt = Date.now();
34
+
35
+ const sessionData: SessionMetadata = {
36
+ sessionId,
37
+ createdAt,
38
+ featureFlags: resolvedFlags,
39
+ ...(options?.metadata ? { metadata: options.metadata } : {}),
40
+ };
41
+
42
+ // Store session with expiration
43
+ const key = `${SESSION_PREFIX}${sessionId}`;
44
+ await this.redis.set(key, JSON.stringify(sessionData), {
45
+ px: this.config.session.expirationMs,
46
+ });
47
+
48
+ return {
49
+ id: sessionId,
50
+ createdAt,
51
+ featureFlags: resolvedFlags,
52
+ ...(options?.metadata ? { metadata: options.metadata } : {}),
53
+ };
54
+ }
55
+
56
+ async getSession(sessionId: string): Promise<SessionResult> {
57
+ const key = `${SESSION_PREFIX}${sessionId}`;
58
+ const data = await this.redis.get<SessionMetadata | string>(key);
59
+
60
+ if (!data) {
61
+ return { exists: false };
62
+ }
63
+
64
+ const session: SessionMetadata =
65
+ typeof data === "string" ? JSON.parse(data) : data;
66
+
67
+ return { exists: true, session };
68
+ }
69
+
70
+ async getFeatureFlag(
71
+ sessionId: string,
72
+ flagName: string
73
+ ): Promise<string | null> {
74
+ const result = await this.getSession(sessionId);
75
+ if (!result.exists) return null;
76
+ return result.session.featureFlags[flagName] ?? null;
77
+ }
78
+
79
+ async getAllFeatureFlags(
80
+ sessionId: string
81
+ ): Promise<Record<string, string> | null> {
82
+ const result = await this.getSession(sessionId);
83
+ if (!result.exists) return null;
84
+ return result.session.featureFlags;
85
+ }
86
+ }
package/src/types.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { Redis } from "@upstash/redis";
2
+
3
+ // ─── Utility Types ──────────────────────────────────────────────
4
+
5
+ export type DeepPartial<T> = {
6
+ [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
7
+ };
8
+
9
+ // ─── Feature Flags ───────────────────────────────────────────────
10
+
11
+ export type FeatureFlagDefinition = {
12
+ possibleValues: string[];
13
+ defaultValue: string;
14
+ description?: string;
15
+ };
16
+
17
+ export type FeatureFlagConfig = FeatureFlagDefinition & {
18
+ name: string;
19
+ };
20
+
21
+ export type FeatureFlagDefinitions = Record<string, FeatureFlagDefinition>;
22
+
23
+ // Feature flag assignment: either explicit value or distribution weights
24
+ export type FeatureFlagAssignment =
25
+ | string
26
+ | Record<string, number>;
27
+
28
+ // ─── Sessions ────────────────────────────────────────────────────
29
+
30
+ export type SessionMetadata<
31
+ TFeatureFlags extends Record<string, string> = Record<string, string>,
32
+ > = {
33
+ sessionId: string;
34
+ createdAt: number;
35
+ featureFlags: TFeatureFlags;
36
+ metadata?: Record<string, unknown>;
37
+ };
38
+
39
+ export type SessionResult<
40
+ TFeatureFlags extends Record<string, string> = Record<string, string>,
41
+ > =
42
+ | { exists: true; session: SessionMetadata<TFeatureFlags> }
43
+ | { exists: false };
44
+
45
+ export type Session<
46
+ TFeatureFlags extends Record<string, string> = Record<string, string>,
47
+ > = {
48
+ id: string;
49
+ createdAt: number;
50
+ featureFlags: TFeatureFlags;
51
+ metadata?: Record<string, unknown>;
52
+ };
53
+
54
+ // ─── Events ──────────────────────────────────────────────────────
55
+
56
+ export type StandardEventMap = {
57
+ pageview: { path: string };
58
+ click: { element: string };
59
+ error: { message: string };
60
+ warning: { message: string };
61
+ info: { message: string };
62
+ };
63
+
64
+ export type StandardEventName = keyof StandardEventMap;
65
+
66
+ export type EventData = {
67
+ sessionCreationTimestamp: number;
68
+ sessionId: string;
69
+ /** ISO 8601 date string for date filtering */
70
+ eventTime: string;
71
+ /** Millisecond epoch for numeric aggregation (histogram) */
72
+ eventTimeMs: number;
73
+ eventName: string;
74
+ featureFlags: Record<string, string>;
75
+ sessionMetadata?: Record<string, unknown>;
76
+ properties?: Record<string, unknown>;
77
+ };
78
+
79
+ export type CaptureEventInput<
80
+ TCustomEvents extends Record<string, Record<string, unknown>> = Record<
81
+ string,
82
+ Record<string, unknown>
83
+ >,
84
+ > =
85
+ | {
86
+ [K in keyof TCustomEvents]: {
87
+ sessionId: string;
88
+ eventName: K & string;
89
+ properties: TCustomEvents[K];
90
+ };
91
+ }[keyof TCustomEvents]
92
+ | {
93
+ [K in StandardEventName]: {
94
+ sessionId: string;
95
+ eventName: K;
96
+ properties: StandardEventMap[K];
97
+ };
98
+ }[StandardEventName];
99
+
100
+ // ─── Schema Registry ────────────────────────────────────────────
101
+
102
+ export type SchemaPropertyType = "string" | "number" | "boolean";
103
+
104
+ export type SchemaProperty = {
105
+ type: SchemaPropertyType;
106
+ };
107
+
108
+ export type EventSchema = {
109
+ properties: Record<string, SchemaProperty>;
110
+ lastUpdated: number;
111
+ version: number;
112
+ };
113
+
114
+ // ─── System Logging ─────────────────────────────────────────────
115
+
116
+ export type LogType =
117
+ | "schema_update"
118
+ | "feature_flag_update"
119
+ | "validation_error"
120
+ | "system_error"
121
+ | "index_update";
122
+
123
+ export type SystemLog = {
124
+ logType: LogType;
125
+ timestamp: number;
126
+ eventName?: string;
127
+ changes?: {
128
+ before: unknown;
129
+ after: unknown;
130
+ };
131
+ metadata?: Record<string, unknown>;
132
+ };
133
+
134
+ // ─── Search Index ───────────────────────────────────────────────
135
+
136
+ export type SearchIndexInfo = {
137
+ currentIndex: string;
138
+ alias: string;
139
+ schema: Record<string, unknown>;
140
+ documentCount: number;
141
+ };
142
+
143
+ // ─── Configuration ──────────────────────────────────────────────
144
+
145
+ export type AnalyticsConfig = {
146
+ session: {
147
+ expirationMs: number;
148
+ };
149
+ featureFlags: FeatureFlagDefinitions;
150
+ events: {
151
+ customEventRetentionDays: number;
152
+ /** Maximum number of events allowed in a single batch request. Default: 20 */
153
+ maxBatchSize: number;
154
+ };
155
+ schemaValidation: {
156
+ /** Probability of validating schema on each event, between 0 and 1. Default: 1 (every event). Set lower (e.g. 0.01) to reduce Redis costs at the expense of delayed schema detection. */
157
+ checkFrequency: number;
158
+ };
159
+ logging: {
160
+ retentionDays: number;
161
+ enabledTypes: LogType[];
162
+ };
163
+ search: {
164
+ indexName: string;
165
+ autoUpdate: boolean;
166
+ rebuildOnStartup: boolean;
167
+ };
168
+ };
169
+
170
+ export type SDKOptions = {
171
+ redis: Redis;
172
+ config: AnalyticsConfig;
173
+ };
174
+
175
+ // ─── Server Configuration ───────────────────────────────────────
176
+
177
+ export type ServerConfig = {
178
+ redis: {
179
+ url: string;
180
+ token: string;
181
+ };
182
+ config?: DeepPartial<AnalyticsConfig>;
183
+ };
184
+
185
+ // ─── Client Configuration ───────────────────────────────────────
186
+
187
+ export type ClientConfig = {
188
+ /** Base URL for the analytics endpoint. Defaults to "/api/analytics" */
189
+ endpoint?: string;
190
+ /** Debounce time in ms for batching event captures. 0 = no batching. Defaults to 0. */
191
+ flushInterval?: number;
192
+ /** Maximum number of events to send in a single batch request. Default: 20. */
193
+ maxBatchSize?: number;
194
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,45 @@
1
+ export function generateShortId(): string {
2
+ return Math.random().toString(36).substring(2, 10);
3
+ }
4
+
5
+ export function generateSessionId(): string {
6
+ const timestamp = Date.now();
7
+ const shortId = generateShortId();
8
+ return `session-${timestamp}-${shortId}`;
9
+ }
10
+
11
+ export function generateEventKey(): string {
12
+ return `event-${generateShortId()}-${Date.now()}-${generateShortId()}`;
13
+ }
14
+
15
+ export function generateLogKey(): string {
16
+ const timestamp = Date.now();
17
+ const shortId = generateShortId();
18
+ return `system-log-${timestamp}-${shortId}`;
19
+ }
20
+
21
+ export function selectByDistribution(
22
+ distribution: Record<string, number>
23
+ ): string {
24
+ const entries = Object.entries(distribution);
25
+ const totalWeight = entries.reduce((sum, [, weight]) => sum + weight, 0);
26
+ let random = Math.random() * totalWeight;
27
+
28
+ for (const [value, weight] of entries) {
29
+ random -= weight;
30
+ if (random <= 0) {
31
+ return value;
32
+ }
33
+ }
34
+
35
+ // Fallback to last value
36
+ return entries[entries.length - 1][0];
37
+ }
38
+
39
+ export function inferPropertyType(
40
+ value: unknown
41
+ ): "string" | "number" | "boolean" {
42
+ if (typeof value === "boolean") return "boolean";
43
+ if (typeof value === "number") return "number";
44
+ return "string";
45
+ }