quickbase-js 2.3.0 → 2.4.1

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/README.md +145 -1107
  2. package/dist/auth/ticket.d.ts.map +1 -1
  3. package/dist/auth/ticket.js +4 -3
  4. package/dist/auth/ticket.js.map +1 -1
  5. package/dist/client/request.d.ts +4 -0
  6. package/dist/client/request.d.ts.map +1 -1
  7. package/dist/client/request.js +15 -3
  8. package/dist/client/request.js.map +1 -1
  9. package/dist/generated/client.d.ts +2 -0
  10. package/dist/generated/client.d.ts.map +1 -1
  11. package/dist/generated/client.js +134 -113
  12. package/dist/generated/client.js.map +1 -1
  13. package/dist/generated/types.d.ts +2700 -2256
  14. package/dist/generated/types.d.ts.map +1 -1
  15. package/dist/generated/types.js +0 -2
  16. package/dist/generated/types.js.map +1 -1
  17. package/dist/quickbase.esm.js +3 -3
  18. package/dist/quickbase.esm.js.map +3 -3
  19. package/dist/quickbase.min.js +3 -3
  20. package/dist/quickbase.min.js.map +3 -3
  21. package/package.json +16 -13
  22. package/src/auth/index.ts +55 -0
  23. package/src/auth/sso-token.ts +143 -0
  24. package/src/auth/temp-token.ts +160 -0
  25. package/src/auth/ticket.ts +329 -0
  26. package/src/auth/token-cache.ts +97 -0
  27. package/src/auth/types.ts +50 -0
  28. package/src/auth/user-token.ts +38 -0
  29. package/src/cli/schema.ts +512 -0
  30. package/src/client/index.ts +36 -0
  31. package/src/client/pagination.ts +337 -0
  32. package/src/client/request.ts +544 -0
  33. package/src/client/retry.ts +169 -0
  34. package/src/client/throttle.ts +105 -0
  35. package/src/core/config.ts +160 -0
  36. package/src/core/dates.ts +63 -0
  37. package/src/core/errors.ts +349 -0
  38. package/src/core/index.ts +50 -0
  39. package/src/core/logger.ts +113 -0
  40. package/src/core/schema.ts +213 -0
  41. package/src/core/transform.ts +295 -0
  42. package/src/core/types.ts +337 -0
  43. package/src/generated/client.ts +1785 -0
  44. package/src/generated/operations.json +672 -0
  45. package/src/generated/types.ts +4884 -0
  46. package/src/index.ts +259 -0
  47. package/src/xml/codepages.ts +119 -0
  48. package/src/xml/dbvars.ts +75 -0
  49. package/src/xml/errors.ts +133 -0
  50. package/src/xml/fields.ts +121 -0
  51. package/src/xml/groups.ts +560 -0
  52. package/src/xml/html.ts +152 -0
  53. package/src/xml/index.ts +1196 -0
  54. package/src/xml/metadata.ts +111 -0
  55. package/src/xml/records.ts +301 -0
  56. package/src/xml/request.ts +238 -0
  57. package/src/xml/roles.ts +321 -0
  58. package/src/xml/schema.ts +436 -0
  59. package/src/xml/transform.ts +381 -0
  60. package/src/xml/types.ts +602 -0
  61. package/src/xml/users.ts +221 -0
  62. package/src/xml/webhooks.ts +260 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Core module exports
3
+ */
4
+
5
+ export type {
6
+ QuickbaseConfig,
7
+ AuthConfig,
8
+ UserTokenAuthConfig,
9
+ TempTokenAuthConfig,
10
+ SsoTokenAuthConfig,
11
+ TicketAuthConfig, // XML-API-TICKET: Remove if XML API discontinued
12
+ RateLimitConfig,
13
+ RetryConfig,
14
+ RequestContext,
15
+ RateLimitInfo,
16
+ PaginationMetadata,
17
+ PaginatedResponse,
18
+ FieldInfo,
19
+ ResolvedConfig,
20
+ // Schema types
21
+ Schema,
22
+ TableSchema,
23
+ FieldSchema,
24
+ ResolvedSchema,
25
+ } from './types.js';
26
+
27
+ export { DEFAULT_CONFIG } from './types.js';
28
+
29
+ export {
30
+ QuickbaseError,
31
+ RateLimitError,
32
+ AuthenticationError,
33
+ AuthorizationError,
34
+ NotFoundError,
35
+ ValidationError,
36
+ TimeoutError,
37
+ ServerError,
38
+ QuickbaseTicketError,
39
+ QuickbaseAppTokenError,
40
+ QuickbaseXmlError,
41
+ ReadOnlyError,
42
+ parseErrorResponse,
43
+ isRetryableError,
44
+ } from './errors.js';
45
+
46
+ export { Logger, createLogger } from './logger.js';
47
+
48
+ export { resolveConfig, getRealmHostname } from './config.js';
49
+
50
+ export { transformDates, isIsoDateString } from './dates.js';
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Logger utility that respects the debug configuration
3
+ */
4
+
5
+ export class Logger {
6
+ private readonly enabled: boolean;
7
+ private readonly prefix: string;
8
+
9
+ constructor(enabled: boolean = false, prefix: string = 'quickbase-js') {
10
+ this.enabled = enabled;
11
+ this.prefix = prefix;
12
+ }
13
+
14
+ private formatMessage(level: string, message: string): string {
15
+ const timestamp = new Date().toISOString();
16
+ return `[${timestamp}] [${this.prefix}] [${level}] ${message}`;
17
+ }
18
+
19
+ debug(message: string, data?: unknown): void {
20
+ if (this.enabled) {
21
+ if (data !== undefined) {
22
+ console.log(this.formatMessage('DEBUG', message), data);
23
+ } else {
24
+ console.log(this.formatMessage('DEBUG', message));
25
+ }
26
+ }
27
+ }
28
+
29
+ info(message: string, data?: unknown): void {
30
+ if (this.enabled) {
31
+ if (data !== undefined) {
32
+ console.info(this.formatMessage('INFO', message), data);
33
+ } else {
34
+ console.info(this.formatMessage('INFO', message));
35
+ }
36
+ }
37
+ }
38
+
39
+ warn(message: string, data?: unknown): void {
40
+ // Warnings are always logged, regardless of debug setting
41
+ if (data !== undefined) {
42
+ console.warn(this.formatMessage('WARN', message), data);
43
+ } else {
44
+ console.warn(this.formatMessage('WARN', message));
45
+ }
46
+ }
47
+
48
+ error(message: string, error?: unknown): void {
49
+ // Errors are always logged, regardless of debug setting
50
+ if (error !== undefined) {
51
+ console.error(this.formatMessage('ERROR', message), error);
52
+ } else {
53
+ console.error(this.formatMessage('ERROR', message));
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Log rate limit information
59
+ */
60
+ rateLimit(info: {
61
+ url: string;
62
+ status: number;
63
+ retryAfter?: number;
64
+ attempt: number;
65
+ rayId?: string;
66
+ }): void {
67
+ if (this.enabled) {
68
+ this.debug(
69
+ `Rate limited (attempt ${info.attempt}): ${info.url} - ` +
70
+ `Status ${info.status}, Retry-After: ${info.retryAfter ?? 'not specified'}s` +
71
+ (info.rayId ? `, Ray: ${info.rayId}` : '')
72
+ );
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Log request timing
78
+ */
79
+ timing(method: string, url: string, durationMs: number): void {
80
+ if (this.enabled) {
81
+ this.debug(`${method} ${url} completed in ${durationMs}ms`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Log retry attempt
87
+ */
88
+ retry(attempt: number, maxAttempts: number, delayMs: number, reason: string): void {
89
+ if (this.enabled) {
90
+ this.debug(
91
+ `Retry ${attempt}/${maxAttempts} in ${delayMs}ms: ${reason}`
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Log token operations (without exposing the actual token)
98
+ */
99
+ token(operation: 'fetch' | 'cache-hit' | 'cache-miss' | 'refresh' | 'expire', dbid?: string): void {
100
+ if (this.enabled) {
101
+ const target = dbid ? ` for dbid: ${dbid}` : '';
102
+ this.debug(`Token ${operation}${target}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Create a no-op logger for when logging is disabled
109
+ * This avoids conditional checks at every log call
110
+ */
111
+ export function createLogger(enabled: boolean): Logger {
112
+ return new Logger(enabled);
113
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Schema resolution and lookup utilities
3
+ */
4
+
5
+ import type { Schema, ResolvedSchema } from './types.js';
6
+
7
+ /**
8
+ * Error thrown when an unknown table or field alias is used
9
+ */
10
+ export class SchemaError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = 'SchemaError';
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Resolve a schema definition into lookup maps for efficient alias resolution
19
+ */
20
+ export function resolveSchema(schema?: Schema): ResolvedSchema | undefined {
21
+ if (!schema) return undefined;
22
+
23
+ const tableAliasToId = new Map<string, string>();
24
+ const tableIdToAlias = new Map<string, string>();
25
+ const fieldAliasToId = new Map<string, Map<string, number>>();
26
+ const fieldIdToAlias = new Map<string, Map<number, string>>();
27
+
28
+ for (const [tableAlias, tableSchema] of Object.entries(schema.tables)) {
29
+ const tableId = tableSchema.id;
30
+
31
+ // Table mappings
32
+ tableAliasToId.set(tableAlias, tableId);
33
+ tableIdToAlias.set(tableId, tableAlias);
34
+
35
+ // Field mappings for this table
36
+ const aliasToId = new Map<string, number>();
37
+ const idToAlias = new Map<number, string>();
38
+
39
+ for (const [fieldAlias, fieldId] of Object.entries(tableSchema.fields)) {
40
+ aliasToId.set(fieldAlias, fieldId);
41
+ idToAlias.set(fieldId, fieldAlias);
42
+ }
43
+
44
+ fieldAliasToId.set(tableId, aliasToId);
45
+ fieldIdToAlias.set(tableId, idToAlias);
46
+ }
47
+
48
+ return {
49
+ original: schema,
50
+ tableAliasToId,
51
+ tableIdToAlias,
52
+ fieldAliasToId,
53
+ fieldIdToAlias,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Resolve a table alias to its ID.
59
+ * If the input is already a table ID, returns it unchanged.
60
+ * Throws SchemaError if alias is not found.
61
+ */
62
+ export function resolveTableAlias(
63
+ schema: ResolvedSchema | undefined,
64
+ tableRef: string
65
+ ): string {
66
+ if (!schema) return tableRef;
67
+
68
+ // Check if it's an alias
69
+ const tableId = schema.tableAliasToId.get(tableRef);
70
+ if (tableId) return tableId;
71
+
72
+ // Check if it's already a table ID
73
+ if (schema.tableIdToAlias.has(tableRef)) return tableRef;
74
+
75
+ // Unknown alias - throw helpful error
76
+ const availableAliases = Array.from(schema.tableAliasToId.keys());
77
+ const suggestion = findSimilar(tableRef, availableAliases);
78
+ const suggestionText = suggestion ? ` Did you mean '${suggestion}'?` : '';
79
+ const availableText =
80
+ availableAliases.length > 0
81
+ ? ` Available: ${availableAliases.join(', ')}`
82
+ : '';
83
+
84
+ throw new SchemaError(
85
+ `Unknown table alias '${tableRef}'.${suggestionText}${availableText}`
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Resolve a field alias to its ID for a given table.
91
+ * If the input is already a field ID (number), returns it unchanged.
92
+ * Throws SchemaError if alias is not found.
93
+ */
94
+ export function resolveFieldAlias(
95
+ schema: ResolvedSchema | undefined,
96
+ tableId: string,
97
+ fieldRef: string | number
98
+ ): number {
99
+ // If it's already a number, return as-is
100
+ if (typeof fieldRef === 'number') return fieldRef;
101
+
102
+ // If no schema, try to parse as number or return as-is
103
+ if (!schema) {
104
+ const num = parseInt(fieldRef, 10);
105
+ if (!isNaN(num)) return num;
106
+ // Can't resolve without schema - pass through and let API handle it
107
+ return fieldRef as unknown as number;
108
+ }
109
+
110
+ // Look up the alias
111
+ const fieldMap = schema.fieldAliasToId.get(tableId);
112
+ if (fieldMap) {
113
+ const fieldId = fieldMap.get(fieldRef);
114
+ if (fieldId !== undefined) return fieldId;
115
+ }
116
+
117
+ // Check if it's a numeric string (raw field ID)
118
+ const num = parseInt(fieldRef, 10);
119
+ if (!isNaN(num)) return num;
120
+
121
+ // Unknown alias - throw helpful error
122
+ const availableAliases = fieldMap ? Array.from(fieldMap.keys()) : [];
123
+ const suggestion = findSimilar(fieldRef, availableAliases);
124
+ const suggestionText = suggestion ? ` Did you mean '${suggestion}'?` : '';
125
+
126
+ // Get table alias for better error message
127
+ const tableAlias = schema.tableIdToAlias.get(tableId) || tableId;
128
+
129
+ throw new SchemaError(
130
+ `Unknown field alias '${fieldRef}' in table '${tableAlias}'.${suggestionText}`
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Get the alias for a field ID, if defined in schema.
136
+ * Returns undefined if no alias is defined.
137
+ */
138
+ export function getFieldAlias(
139
+ schema: ResolvedSchema | undefined,
140
+ tableId: string,
141
+ fieldId: number
142
+ ): string | undefined {
143
+ if (!schema) return undefined;
144
+
145
+ const fieldMap = schema.fieldIdToAlias.get(tableId);
146
+ return fieldMap?.get(fieldId);
147
+ }
148
+
149
+ /**
150
+ * Get the alias for a table ID, if defined in schema.
151
+ * Returns undefined if no alias is defined.
152
+ */
153
+ export function getTableAlias(
154
+ schema: ResolvedSchema | undefined,
155
+ tableId: string
156
+ ): string | undefined {
157
+ if (!schema) return undefined;
158
+ return schema.tableIdToAlias.get(tableId);
159
+ }
160
+
161
+ /**
162
+ * Find a similar string from a list (for "did you mean" suggestions)
163
+ * Uses simple Levenshtein distance
164
+ */
165
+ function findSimilar(
166
+ input: string,
167
+ candidates: string[],
168
+ maxDistance = 3
169
+ ): string | undefined {
170
+ const inputLower = input.toLowerCase();
171
+ let bestMatch: string | undefined;
172
+ let bestDistance = maxDistance + 1;
173
+
174
+ for (const candidate of candidates) {
175
+ const distance = levenshteinDistance(inputLower, candidate.toLowerCase());
176
+ if (distance < bestDistance) {
177
+ bestDistance = distance;
178
+ bestMatch = candidate;
179
+ }
180
+ }
181
+
182
+ return bestDistance <= maxDistance ? bestMatch : undefined;
183
+ }
184
+
185
+ /**
186
+ * Calculate Levenshtein distance between two strings
187
+ */
188
+ function levenshteinDistance(a: string, b: string): number {
189
+ if (a.length === 0) return b.length;
190
+ if (b.length === 0) return a.length;
191
+
192
+ // Create matrix with proper initialization
193
+ const matrix: number[][] = Array.from({ length: b.length + 1 }, (_, i) =>
194
+ Array.from({ length: a.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
195
+ );
196
+
197
+ // Fill in the rest
198
+ for (let i = 1; i <= b.length; i++) {
199
+ for (let j = 1; j <= a.length; j++) {
200
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
201
+ matrix[i]![j] = matrix[i - 1]![j - 1]!;
202
+ } else {
203
+ matrix[i]![j] = Math.min(
204
+ matrix[i - 1]![j - 1]! + 1, // substitution
205
+ matrix[i]![j - 1]! + 1, // insertion
206
+ matrix[i - 1]![j]! + 1 // deletion
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ return matrix[b.length]![a.length]!;
213
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Request and response transformation utilities for schema aliases
3
+ */
4
+
5
+ import type { ResolvedSchema } from './types.js';
6
+ import {
7
+ resolveTableAlias,
8
+ resolveFieldAlias,
9
+ getFieldAlias,
10
+ } from './schema.js';
11
+
12
+ // =============================================================================
13
+ // Request Transformation
14
+ // =============================================================================
15
+
16
+ /**
17
+ * Context for request transformation
18
+ */
19
+ export interface RequestTransformContext {
20
+ schema?: ResolvedSchema;
21
+ /** The resolved table ID (after alias resolution) */
22
+ tableId?: string;
23
+ }
24
+
25
+ /**
26
+ * Transform a request body, resolving table and field aliases to IDs.
27
+ * Handles: from, to, select, sortBy, groupBy, where, data
28
+ */
29
+ export function transformRequest<T extends Record<string, unknown>>(
30
+ body: T,
31
+ ctx: RequestTransformContext
32
+ ): T {
33
+ // Work with a plain object to avoid TypeScript index signature issues
34
+ const result: Record<string, unknown> = { ...body };
35
+ let tableId = ctx.tableId;
36
+
37
+ // Schema-dependent transforms (alias resolution)
38
+ if (ctx.schema) {
39
+ // Resolve table references (from, to)
40
+ if ('from' in result && typeof result.from === 'string') {
41
+ tableId = resolveTableAlias(ctx.schema, result.from);
42
+ result.from = tableId;
43
+ }
44
+ if ('to' in result && typeof result.to === 'string') {
45
+ tableId = resolveTableAlias(ctx.schema, result.to);
46
+ result.to = tableId;
47
+ }
48
+
49
+ // Resolve field aliases (requires tableId)
50
+ if (tableId) {
51
+ // Resolve select array
52
+ if ('select' in result && Array.isArray(result.select)) {
53
+ result.select = (result.select as (string | number)[]).map((field) =>
54
+ resolveFieldAlias(ctx.schema!, tableId!, field)
55
+ );
56
+ }
57
+
58
+ // Resolve sortBy array
59
+ if ('sortBy' in result && Array.isArray(result.sortBy)) {
60
+ result.sortBy = (result.sortBy as Record<string, unknown>[]).map((sort) => ({
61
+ ...sort,
62
+ fieldId: resolveFieldAlias(ctx.schema!, tableId!, sort.fieldId as string | number),
63
+ }));
64
+ }
65
+
66
+ // Resolve groupBy array
67
+ if ('groupBy' in result && Array.isArray(result.groupBy)) {
68
+ result.groupBy = (result.groupBy as Record<string, unknown>[]).map((group) => ({
69
+ ...group,
70
+ fieldId: resolveFieldAlias(ctx.schema!, tableId!, group.fieldId as string | number),
71
+ }));
72
+ }
73
+
74
+ // Resolve where clause (string replacement)
75
+ if ('where' in result && typeof result.where === 'string') {
76
+ result.where = transformWhereClause(result.where, ctx.schema, tableId);
77
+ }
78
+ }
79
+ }
80
+
81
+ // Auto-wrap data values (always runs, no schema required)
82
+ if ('data' in result && Array.isArray(result.data)) {
83
+ result.data = (result.data as Record<string, unknown>[]).map((record) =>
84
+ wrapRecordValues(record, ctx.schema, tableId)
85
+ );
86
+ }
87
+
88
+ return result as T;
89
+ }
90
+
91
+ /**
92
+ * Wrap record values and optionally resolve field aliases.
93
+ * Always wraps plain values in { value: X } format.
94
+ * Resolves field aliases to IDs if schema is provided.
95
+ */
96
+ function wrapRecordValues(
97
+ record: Record<string, unknown>,
98
+ schema: ResolvedSchema | undefined,
99
+ tableId: string | undefined
100
+ ): Record<string, unknown> {
101
+ const result: Record<string, unknown> = {};
102
+
103
+ for (const [key, value] of Object.entries(record)) {
104
+ // Resolve field alias if schema is available
105
+ let outputKey = key;
106
+ if (schema && tableId) {
107
+ try {
108
+ const fieldId = resolveFieldAlias(schema, tableId, key);
109
+ outputKey = String(fieldId);
110
+ } catch {
111
+ // Not an alias, keep original key
112
+ }
113
+ }
114
+
115
+ // Always auto-wrap values
116
+ result[outputKey] = wrapFieldValue(value);
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Transform a where clause, replacing field aliases with IDs.
124
+ * QuickBase where syntax: {fieldId.operator.value}
125
+ * Example: {'status'.EX.'Active'} -> {7.EX.'Active'}
126
+ */
127
+ function transformWhereClause(
128
+ where: string,
129
+ schema: ResolvedSchema,
130
+ tableId: string
131
+ ): string {
132
+ // Match patterns like {'fieldAlias' or {fieldAlias. at the start of conditions
133
+ // The pattern captures the field reference between { and .
134
+ return where.replace(/\{['"]?([^.'"\}]+)['"]?\./g, (match, fieldRef: string) => {
135
+ try {
136
+ const fieldId = resolveFieldAlias(schema, tableId, fieldRef);
137
+ return `{${fieldId}.`;
138
+ } catch {
139
+ // If alias not found, keep original (might be a raw ID)
140
+ return match;
141
+ }
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Wrap a value in QuickBase's { value: X } format if not already wrapped.
147
+ * Handles plain values, arrays, and already-wrapped values.
148
+ */
149
+ function wrapFieldValue(value: unknown): { value: unknown } {
150
+ // Already wrapped - return as-is
151
+ if (isWrappedValue(value)) {
152
+ return value as { value: unknown };
153
+ }
154
+
155
+ // Wrap plain value
156
+ return { value };
157
+ }
158
+
159
+ /**
160
+ * Check if a value is already wrapped in { value: X } format.
161
+ */
162
+ function isWrappedValue(value: unknown): boolean {
163
+ if (value === null || value === undefined) return false;
164
+ if (typeof value !== 'object' || Array.isArray(value)) return false;
165
+
166
+ const obj = value as Record<string, unknown>;
167
+ return 'value' in obj;
168
+ }
169
+
170
+ // =============================================================================
171
+ // Response Transformation
172
+ // =============================================================================
173
+
174
+ /**
175
+ * Context for response transformation
176
+ */
177
+ export interface ResponseTransformContext {
178
+ schema?: ResolvedSchema;
179
+ /** The table ID for field alias lookups */
180
+ tableId?: string;
181
+ }
182
+
183
+ /**
184
+ * Transform a response, converting field IDs to aliases and unwrapping values.
185
+ * Handles nested data arrays (for runQuery, upsert responses).
186
+ */
187
+ export function transformResponse<T>(
188
+ response: T,
189
+ ctx: ResponseTransformContext
190
+ ): T {
191
+ if (!response || typeof response !== 'object') return response;
192
+
193
+ const result = { ...response } as Record<string, unknown>;
194
+
195
+ // Handle data array (runQuery, upsert responses)
196
+ if ('data' in result && Array.isArray(result.data) && ctx.tableId) {
197
+ result.data = result.data.map((record) =>
198
+ transformRecordForResponse(
199
+ record as Record<string, unknown>,
200
+ ctx.schema,
201
+ ctx.tableId!
202
+ )
203
+ );
204
+ }
205
+
206
+ return result as T;
207
+ }
208
+
209
+ /**
210
+ * Transform a single record from the response.
211
+ * - Converts field ID keys to aliases (if schema defined)
212
+ * - Unwraps { value: X } to just X
213
+ * - Unknown fields keep their numeric key but still get unwrapped
214
+ */
215
+ function transformRecordForResponse(
216
+ record: Record<string, unknown>,
217
+ schema: ResolvedSchema | undefined,
218
+ tableId: string
219
+ ): Record<string, unknown> {
220
+ const result: Record<string, unknown> = {};
221
+
222
+ for (const [key, value] of Object.entries(record)) {
223
+ // Determine the output key (alias or original)
224
+ let outputKey = key;
225
+ const fieldId = parseInt(key, 10);
226
+
227
+ if (!isNaN(fieldId) && schema) {
228
+ const alias = getFieldAlias(schema, tableId, fieldId);
229
+ if (alias) {
230
+ outputKey = alias;
231
+ }
232
+ }
233
+
234
+ // Unwrap { value: X } -> X
235
+ const unwrappedValue = unwrapFieldValue(value);
236
+ result[outputKey] = unwrappedValue;
237
+ }
238
+
239
+ return result;
240
+ }
241
+
242
+ /**
243
+ * Unwrap a field value from QuickBase's { value: X } format.
244
+ * Also handles arrays of values.
245
+ */
246
+ function unwrapFieldValue(value: unknown): unknown {
247
+ if (value === null || value === undefined) return value;
248
+
249
+ if (typeof value === 'object' && !Array.isArray(value)) {
250
+ const obj = value as Record<string, unknown>;
251
+ // Check if it's a { value: X } wrapper
252
+ if ('value' in obj && Object.keys(obj).length === 1) {
253
+ // Recursively unwrap in case value is an array of wrapped values
254
+ return unwrapFieldValue(obj.value);
255
+ }
256
+ // Check if it's a { value: X, ...metadata } wrapper (preserve just value)
257
+ if ('value' in obj) {
258
+ return unwrapFieldValue(obj.value);
259
+ }
260
+ }
261
+
262
+ // Handle array of { value: X } objects
263
+ if (Array.isArray(value)) {
264
+ return value.map(unwrapFieldValue);
265
+ }
266
+
267
+ return value;
268
+ }
269
+
270
+ // =============================================================================
271
+ // Table ID Extraction
272
+ // =============================================================================
273
+
274
+ /**
275
+ * Extract the table ID from a request body.
276
+ * Looks for 'from' or 'to' fields and resolves any aliases.
277
+ */
278
+ export function extractTableIdFromRequest(
279
+ body: Record<string, unknown> | undefined,
280
+ schema?: ResolvedSchema
281
+ ): string | undefined {
282
+ if (!body) return undefined;
283
+
284
+ // Check 'from' (runQuery, deleteRecords)
285
+ if ('from' in body && typeof body.from === 'string') {
286
+ return schema ? resolveTableAlias(schema, body.from) : body.from;
287
+ }
288
+
289
+ // Check 'to' (upsert)
290
+ if ('to' in body && typeof body.to === 'string') {
291
+ return schema ? resolveTableAlias(schema, body.to) : body.to;
292
+ }
293
+
294
+ return undefined;
295
+ }