@fitzzero/quickdraw-core 1.0.0 → 1.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.
@@ -12,6 +12,168 @@ var consoleLogger = {
12
12
  child: () => consoleLogger
13
13
  };
14
14
 
15
+ // src/server/utils/zodToAdminFields.ts
16
+ var NON_EDITABLE_FIELDS = /* @__PURE__ */ new Set([
17
+ "id",
18
+ "createdAt",
19
+ "updatedAt",
20
+ "created_at",
21
+ "updated_at"
22
+ ]);
23
+ var DEFAULT_HIDDEN_FIELDS = /* @__PURE__ */ new Set([
24
+ "acl",
25
+ "serviceAccess",
26
+ "service_access"
27
+ ]);
28
+ function toLabel(fieldName) {
29
+ return fieldName.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
30
+ }
31
+ function getFieldType(zodType) {
32
+ const typeName = zodType._def.typeName;
33
+ if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
34
+ const inner = zodType._def.innerType;
35
+ return getFieldType(inner);
36
+ }
37
+ switch (typeName) {
38
+ case "ZodString":
39
+ return "string";
40
+ case "ZodNumber":
41
+ return "number";
42
+ case "ZodBoolean":
43
+ return "boolean";
44
+ case "ZodDate":
45
+ return "date";
46
+ case "ZodEnum":
47
+ case "ZodNativeEnum":
48
+ return "enum";
49
+ case "ZodObject":
50
+ case "ZodArray":
51
+ case "ZodRecord":
52
+ return "json";
53
+ default:
54
+ return "string";
55
+ }
56
+ }
57
+ function getEnumValues(zodType) {
58
+ const typeName = zodType._def.typeName;
59
+ if (typeName === "ZodOptional" || typeName === "ZodNullable" || typeName === "ZodDefault") {
60
+ const inner = zodType._def.innerType;
61
+ return getEnumValues(inner);
62
+ }
63
+ if (typeName === "ZodEnum") {
64
+ return zodType._def.values;
65
+ }
66
+ if (typeName === "ZodNativeEnum") {
67
+ const enumObj = zodType._def.values;
68
+ return Object.values(enumObj).filter(
69
+ (v) => typeof v === "string"
70
+ );
71
+ }
72
+ return void 0;
73
+ }
74
+ function isOptional(zodType) {
75
+ const typeName = zodType._def.typeName;
76
+ if (typeName === "ZodOptional" || typeName === "ZodNullable") {
77
+ return true;
78
+ }
79
+ if (typeName === "ZodDefault") {
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ function zodToAdminFields(schema, options = {}) {
85
+ const { hiddenFields = [], tableColumns, fieldOverrides = {} } = options;
86
+ let shape = {};
87
+ let currentSchema = schema;
88
+ while (currentSchema) {
89
+ const typeName = currentSchema._def.typeName;
90
+ if (typeName === "ZodObject") {
91
+ shape = currentSchema._def.shape();
92
+ break;
93
+ }
94
+ if (typeName === "ZodEffects" || typeName === "ZodOptional" || typeName === "ZodNullable") {
95
+ currentSchema = currentSchema._def.schema || currentSchema._def.innerType;
96
+ continue;
97
+ }
98
+ break;
99
+ }
100
+ const hiddenSet = /* @__PURE__ */ new Set([...DEFAULT_HIDDEN_FIELDS, ...hiddenFields]);
101
+ const fields = [];
102
+ for (const [name, zodType] of Object.entries(shape)) {
103
+ if (hiddenSet.has(name)) {
104
+ continue;
105
+ }
106
+ const fieldType = getFieldType(zodType);
107
+ const required = !isOptional(zodType);
108
+ const editable = !NON_EDITABLE_FIELDS.has(name);
109
+ const enumValues = getEnumValues(zodType);
110
+ let showInTable = true;
111
+ if (tableColumns) {
112
+ showInTable = tableColumns.includes(name);
113
+ } else {
114
+ showInTable = fieldType !== "json";
115
+ }
116
+ const baseConfig = {
117
+ name,
118
+ type: fieldType,
119
+ label: toLabel(name),
120
+ required,
121
+ editable,
122
+ showInTable,
123
+ sortable: fieldType !== "json" && fieldType !== "relation",
124
+ enumValues
125
+ };
126
+ const override = fieldOverrides[name];
127
+ if (override) {
128
+ Object.assign(baseConfig, override);
129
+ }
130
+ fields.push(baseConfig);
131
+ }
132
+ return fields;
133
+ }
134
+ function getDefaultEntityFields() {
135
+ return [
136
+ {
137
+ name: "id",
138
+ type: "string",
139
+ label: "ID",
140
+ required: true,
141
+ editable: false,
142
+ showInTable: true,
143
+ sortable: true
144
+ },
145
+ {
146
+ name: "createdAt",
147
+ type: "date",
148
+ label: "Created At",
149
+ required: true,
150
+ editable: false,
151
+ showInTable: true,
152
+ sortable: true
153
+ },
154
+ {
155
+ name: "updatedAt",
156
+ type: "date",
157
+ label: "Updated At",
158
+ required: true,
159
+ editable: false,
160
+ showInTable: false,
161
+ sortable: true
162
+ }
163
+ ];
164
+ }
165
+ function mergeWithDefaultFields(schemaFields, options = {}) {
166
+ const { includeDefaults = ["id", "createdAt"] } = options;
167
+ const defaultFields = getDefaultEntityFields().filter(
168
+ (f) => includeDefaults.includes(f.name)
169
+ );
170
+ const defaultNames = new Set(defaultFields.map((f) => f.name));
171
+ const filteredSchemaFields = schemaFields.filter(
172
+ (f) => !defaultNames.has(f.name)
173
+ );
174
+ return [...defaultFields, ...filteredSchemaFields];
175
+ }
176
+
15
177
  // src/server/BaseService.ts
16
178
  var BaseService = class {
17
179
  serviceName;
@@ -26,6 +188,8 @@ var BaseService = class {
26
188
  io = null;
27
189
  // Collection of public methods for registry discovery
28
190
  publicMethods = /* @__PURE__ */ new Map();
191
+ // Admin metadata configuration (set by installAdminMethods)
192
+ adminMeta = null;
29
193
  constructor(options) {
30
194
  this.serviceName = options.serviceName;
31
195
  this.hasEntryACL = options.hasEntryACL ?? false;
@@ -448,11 +612,50 @@ var BaseService = class {
448
612
  * reemit: "Admin",
449
613
  * unsubscribeAll: "Admin",
450
614
  * },
615
+ * schema: createEntitySchema,
616
+ * displayName: "Entities",
451
617
  * });
452
618
  * ```
453
619
  */
454
620
  installAdminMethods(options) {
455
- const { expose, access } = options;
621
+ const {
622
+ expose,
623
+ access,
624
+ schema,
625
+ displayName,
626
+ tableColumns,
627
+ hiddenFields,
628
+ fieldOverrides
629
+ } = options;
630
+ if (schema) {
631
+ const schemaFields = zodToAdminFields(schema, {
632
+ hiddenFields,
633
+ tableColumns,
634
+ fieldOverrides
635
+ });
636
+ const fields = mergeWithDefaultFields(schemaFields);
637
+ this.adminMeta = {
638
+ serviceName: this.serviceName,
639
+ displayName: displayName ?? this.toDisplayName(this.serviceName),
640
+ fields
641
+ };
642
+ } else {
643
+ this.adminMeta = {
644
+ serviceName: this.serviceName,
645
+ displayName: displayName ?? this.toDisplayName(this.serviceName),
646
+ fields: []
647
+ };
648
+ }
649
+ const shouldExposeMeta = expose.meta ?? (expose.list || expose.get || expose.create || expose.update || expose.delete);
650
+ if (shouldExposeMeta) {
651
+ this.publicMethods.set("adminMeta", {
652
+ name: "adminMeta",
653
+ access: access.meta ?? access.list,
654
+ handler: async (_payload, _context) => {
655
+ return this.getAdminMeta();
656
+ }
657
+ });
658
+ }
456
659
  if (expose.list) {
457
660
  this.publicMethods.set("adminList", {
458
661
  name: "adminList",
@@ -550,9 +753,30 @@ var BaseService = class {
550
753
  resolveEntryId: (payload) => payload.entryId
551
754
  });
552
755
  }
553
- this.logger.info(
554
- `Installed admin methods: ${Object.keys(expose).filter((k) => expose[k]).join(", ")}`
756
+ const installedMethods = Object.keys(expose).filter(
757
+ (k) => expose[k]
555
758
  );
759
+ if (shouldExposeMeta) installedMethods.push("meta");
760
+ this.logger.info(`Installed admin methods: ${installedMethods.join(", ")}`);
761
+ }
762
+ /**
763
+ * Convert service name to display name.
764
+ * e.g., "chatService" -> "Chats", "userService" -> "Users"
765
+ */
766
+ toDisplayName(serviceName) {
767
+ return serviceName.replace(/Service$/i, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()) + "s";
768
+ }
769
+ /**
770
+ * Get admin metadata for this service.
771
+ * Returns the configured metadata or throws if not configured.
772
+ */
773
+ getAdminMeta() {
774
+ if (!this.adminMeta) {
775
+ throw new Error(
776
+ `Admin methods not installed for ${this.serviceName}. Call installAdminMethods() first.`
777
+ );
778
+ }
779
+ return this.adminMeta;
556
780
  }
557
781
  /**
558
782
  * Admin method: List entities with pagination and filters.
@@ -687,12 +911,24 @@ var ServiceRegistry = class {
687
911
  io;
688
912
  services = /* @__PURE__ */ new Map();
689
913
  logger;
914
+ methodLoggingEnabled;
915
+ logPayloads;
916
+ logResponses;
690
917
  connectionHandlerInstalled = false;
691
918
  // Store method metadata for deferred registration
692
919
  methodRegistry = /* @__PURE__ */ new Map();
693
920
  constructor(io, options) {
694
921
  this.io = io;
695
922
  this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
923
+ this.methodLoggingEnabled = options?.methodLogging?.enabled ?? true;
924
+ this.logPayloads = options?.methodLogging?.logPayloads ?? false;
925
+ this.logResponses = options?.methodLogging?.logResponses ?? false;
926
+ if (this.methodLoggingEnabled) {
927
+ this.logger.info("Automatic method logging enabled", {
928
+ logPayloads: this.logPayloads,
929
+ logResponses: this.logResponses
930
+ });
931
+ }
696
932
  }
697
933
  /**
698
934
  * Install the single connection handler that wires all registered services.
@@ -798,33 +1034,45 @@ var ServiceRegistry = class {
798
1034
  }
799
1035
  }
800
1036
  await service.ensureAccessForMethod(method.access, socket, entryId);
801
- this.logger.info(
802
- `User ${userIdShort} ${serviceName}.${methodName} -> start`,
803
- {
1037
+ if (this.methodLoggingEnabled) {
1038
+ const logContext = {
804
1039
  category: "request_processing",
805
1040
  serviceName,
806
1041
  methodName,
807
1042
  userId: socket.userId,
808
1043
  socketId: socket.id
1044
+ };
1045
+ if (this.logPayloads) {
1046
+ logContext.payload = validatedPayload;
809
1047
  }
810
- );
1048
+ this.logger.info(
1049
+ `User ${userIdShort} ${serviceName}.${methodName} -> start`,
1050
+ logContext
1051
+ );
1052
+ }
811
1053
  const result = await method.handler(validatedPayload, {
812
1054
  userId: socket.userId,
813
1055
  socketId: socket.id,
814
1056
  serviceAccess: socket.serviceAccess ?? {}
815
1057
  });
816
- const durationMs = Date.now() - startedAt;
817
- this.logger.info(
818
- `User ${userIdShort} ${serviceName}.${methodName} -> success`,
819
- {
1058
+ if (this.methodLoggingEnabled) {
1059
+ const durationMs = Date.now() - startedAt;
1060
+ const logContext = {
820
1061
  category: "request_processing",
821
1062
  outcome: "success",
822
1063
  durationMs,
823
1064
  serviceName,
824
1065
  methodName,
825
1066
  userId: socket.userId
1067
+ };
1068
+ if (this.logResponses) {
1069
+ logContext.response = result;
826
1070
  }
827
- );
1071
+ this.logger.info(
1072
+ `User ${userIdShort} ${serviceName}.${methodName} -> success`,
1073
+ logContext
1074
+ );
1075
+ }
828
1076
  const successResponse = {
829
1077
  success: true,
830
1078
  data: result
@@ -832,14 +1080,22 @@ var ServiceRegistry = class {
832
1080
  callback?.(successResponse);
833
1081
  } catch (error) {
834
1082
  const durationMs = Date.now() - startedAt;
1083
+ const logContext = {
1084
+ category: "request_processing",
1085
+ outcome: "fail",
1086
+ durationMs,
1087
+ serviceName,
1088
+ methodName,
1089
+ userId: socket.userId,
1090
+ socketId: socket.id,
1091
+ error: error instanceof Error ? error.message : "Unknown error"
1092
+ };
1093
+ if (this.logPayloads) {
1094
+ logContext.payload = payload;
1095
+ }
835
1096
  this.logger.error(
836
1097
  `User ${userIdShort} ${serviceName}.${methodName} -> fail`,
837
- {
838
- category: "request_processing",
839
- outcome: "fail",
840
- durationMs,
841
- error: error instanceof Error ? error.message : "Unknown error"
842
- }
1098
+ logContext
843
1099
  );
844
1100
  const errorResponse = {
845
1101
  success: false,
@@ -947,7 +1203,10 @@ function createQuickdrawServer(options) {
947
1203
  credentials: options.cors?.credentials ?? true
948
1204
  }
949
1205
  });
950
- const registry = new ServiceRegistry(io, { logger });
1206
+ const registry = new ServiceRegistry(io, {
1207
+ logger,
1208
+ methodLogging: options.methodLogging
1209
+ });
951
1210
  io.use(async (socket, next) => {
952
1211
  const quickdrawSocket = socket;
953
1212
  try {
@@ -994,6 +1253,34 @@ function createQuickdrawServer(options) {
994
1253
  `Registered services: ${registry.getServices().join(", ")}`
995
1254
  );
996
1255
  });
1256
+ const shutdown = async (signal) => {
1257
+ serverLogger.info(`Received ${signal}, starting graceful shutdown...`);
1258
+ httpServer.close(() => {
1259
+ serverLogger.info("HTTP server closed");
1260
+ });
1261
+ const sockets = await io.fetchSockets();
1262
+ serverLogger.info(`Closing ${sockets.length} active socket connections...`);
1263
+ for (const socket of sockets) {
1264
+ const quickdrawSocket = socket;
1265
+ for (const service of registry.getServiceInstances()) {
1266
+ try {
1267
+ service.unsubscribeSocket(quickdrawSocket);
1268
+ } catch {
1269
+ }
1270
+ }
1271
+ socket.disconnect(true);
1272
+ }
1273
+ io.close(() => {
1274
+ serverLogger.info("Socket.io server closed");
1275
+ process.exit(0);
1276
+ });
1277
+ setTimeout(() => {
1278
+ serverLogger.error("Graceful shutdown timeout, forcing exit");
1279
+ process.exit(1);
1280
+ }, 1e4);
1281
+ };
1282
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1283
+ process.on("SIGINT", () => shutdown("SIGINT"));
997
1284
  return { io, httpServer, registry };
998
1285
  }
999
1286
 
@@ -1314,6 +1601,45 @@ var googleProvider = {
1314
1601
  }
1315
1602
  };
1316
1603
 
1317
- export { BaseService, ServiceRegistry, applyRateLimitMiddleware, createJWT, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, googleProvider, isRedisAdapterAvailable, setupRedisAdapter, verifyJWT };
1604
+ // src/server/utils/env.ts
1605
+ function validateEnv(options) {
1606
+ const result = checkEnv(options);
1607
+ if (!result.success) {
1608
+ throw new Error(result.message);
1609
+ }
1610
+ }
1611
+ function checkEnv(options) {
1612
+ const { required, productionOnly = true, errorPrefix = "Missing required environment variables" } = options;
1613
+ if (productionOnly && process.env.NODE_ENV !== "production") {
1614
+ return { success: true, missing: [] };
1615
+ }
1616
+ const missing = [];
1617
+ for (const varName of required) {
1618
+ const value = process.env[varName];
1619
+ if (!value || value.trim() === "") {
1620
+ missing.push(varName);
1621
+ }
1622
+ }
1623
+ if (missing.length > 0) {
1624
+ return {
1625
+ success: false,
1626
+ missing,
1627
+ message: `${errorPrefix}: ${missing.join(", ")}`
1628
+ };
1629
+ }
1630
+ return { success: true, missing: [] };
1631
+ }
1632
+ function requireEnv(name, defaultValue) {
1633
+ const value = process.env[name];
1634
+ if (!value || value.trim() === "") {
1635
+ if (defaultValue !== void 0) {
1636
+ return defaultValue;
1637
+ }
1638
+ throw new Error(`Environment variable ${name} is required but not set`);
1639
+ }
1640
+ return value;
1641
+ }
1642
+
1643
+ export { BaseService, ServiceRegistry, applyRateLimitMiddleware, checkEnv, createJWT, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, getDefaultEntityFields, googleProvider, isRedisAdapterAvailable, mergeWithDefaultFields, requireEnv, setupRedisAdapter, validateEnv, verifyJWT, zodToAdminFields };
1318
1644
  //# sourceMappingURL=index.js.map
1319
1645
  //# sourceMappingURL=index.js.map